262 Commits

Author SHA1 Message Date
shuaiplus 9e892e85a2 feat: update version to 1.4.1 and enhance drag-and-drop functionality for TOTP and website entries 2026-03-27 00:54:24 +08:00
shuaiplus 3e5a80e498 Refactor code structure for improved readability and maintainability 2026-03-27 00:08:29 +08:00
shuaiplus 89308fc8a6 feat: enhance login URI handling with match options and improve UI components 2026-03-26 21:59:50 +08:00
shuaiplus fe0bd80f43 feat: improve handling of archived timestamps in cipher storage normalization 2026-03-24 00:56:56 +08:00
shuaiplus 0062fd6c48 feat: enhance dark theme styles for mobile settings and table elements 2026-03-23 09:06:36 +08:00
shuaiplus 7373eeb501 feat: add backup start time configuration and theme switch functionality
- Introduced BACKUP_DEFAULT_START_TIME constant for backup scheduling.
- Updated BackupScheduleConfig interface to include startTime.
- Implemented normalizeStartTime function for validating and normalizing start time input.
- Enhanced backup settings parsing to accommodate start time.
- Added start time input field in BackupDestinationDetail component.
- Created ThemeSwitch component for toggling between light and dark themes.
- Integrated theme preference management in App component.
- Updated styles for dark mode support across the application.
- Added translations for theme toggle and backup start time labels.
2026-03-23 08:53:18 +08:00
shuaiplus 8b07cd4409 feat: refactor unarchive handling to support bulk unarchive and update prop types 2026-03-23 08:40:40 +08:00
shuaiplus 0fc7bd7985 feat: implement unarchive functionality for selected ciphers with state management 2026-03-23 08:32:43 +08:00
shuaiplus 58c029beba feat: add .tmp/ directory to .gitignore 2026-03-23 08:28:15 +08:00
shuaiplus ac79cbd8bd feat: remove temporary subproject references for bitwarden components 2026-03-23 08:28:07 +08:00
shuaiplus 96fc3ae485 feat: implement archive and bulk archive functionality with confirmation dialogs 2026-03-23 08:22:08 +08:00
shuaiplus cb4632cd04 feat: add bulk unarchive functionality for ciphers 2026-03-23 08:18:15 +08:00
shuaiplus f7b5534cd0 feat: add archiving functionality for ciphers
- Introduced `archive` and `unarchive` endpoints in the API for ciphers.
- Implemented bulk archiving and unarchiving of ciphers in the vault.
- Updated the storage schema to include `archived_at` timestamps for ciphers.
- Enhanced user interface to support archiving actions in the vault.
- Added necessary translations for archive-related actions.
- Updated user and device models to accommodate new fields related to archiving.
2026-03-23 01:10:48 +08:00
shuaiplus b50673f7d9 feat: update README files to clarify cloud backup center and password hint features 2026-03-20 06:55:20 +08:00
shuaiplus 98e94e766f feat: update README files for clarity and consistency in descriptions 2026-03-20 06:47:25 +08:00
shuaiplus a17ed646a0 feat: update backup routes and navigation links for consistency 2026-03-20 05:53:24 +08:00
shuaiplus c2b920532d feat: refactor backup scheduling to use interval hours and update UI components 2026-03-20 05:44:00 +08:00
shuaiplus fba2aa9746 feat: update version to 1.4.0 and integrate APP_VERSION in components 2026-03-20 05:03:04 +08:00
shuaiplus cbf1e86881 feat: enhance backup functionality with attachment options
- Added support for including attachments in backup exports.
- Updated backup-related interfaces and functions to handle attachment options.
- Introduced a new UI component for selecting attachment inclusion during backup operations.
- Modified existing components to integrate the new attachment functionality.
- Improved user feedback and error handling during backup processes.
2026-03-20 04:55:23 +08:00
shuaiplus 3d38424d77 feat: optimize backup archive settings for improved performance and reliability 2026-03-19 01:13:19 +08:00
shuaiplus 5ff322d809 feat: simplify asset serving and enhance bootstrap response handling 2026-03-19 00:52:58 +08:00
shuaiplus facd0ea5f7 feat: add master password hint functionality
- Updated user model to include masterPasswordHint.
- Modified sync handler to return masterPasswordHint.
- Implemented password hint retrieval in public API.
- Enhanced user profile management to allow updating of password hint.
- Added UI components for displaying and editing password hint.
- Updated localization files for new password hint strings.
- Improved rate limiting for sensitive public requests.
- Adjusted database schema to accommodate master password hint.
2026-03-19 00:38:56 +08:00
shuaiplus 8bc43b8f0c feat: update icon fetching logic to support multiple upstream sources 2026-03-18 22:37:37 +08:00
shuaiplus bb3fe41330 feat: implement direct file upload for sends with JWT token validation
- Added `processSendFileUpload` function to handle file uploads for sends.
- Integrated JWT token creation and verification for secure file uploads.
- Updated `handleCreateFileSendV2` and `handleGetSendFileUpload` to use new upload URL generation.
- Refactored upload handling in `handleUploadSendFile` and `handlePublicUploadSendFile` to utilize the new upload process.
- Introduced `uploadDirectEncryptedPayload` for handling direct uploads with progress tracking.
- Enhanced API routes to support both POST and PUT methods for attachment uploads.
- Added localization strings for upload progress messages.
- Created utility functions for direct upload URL building and payload parsing.
2026-03-18 02:26:10 +08:00
shuaiplus 3204eeb9ab feat: add duplicate handling features and UI elements for cipher management 2026-03-18 01:39:35 +08:00
shuaiplus 9280f6916e feat: add item limit for ciphers import and streamline response handling 2026-03-18 00:56:32 +08:00
shuaiplus 3f7ca52983 feat: refactor authentication flow and improve token verification process 2026-03-18 00:24:45 +08:00
shuaiplus 011fe15aae feat: enhance sync cache with size limits and entry management 2026-03-18 00:12:18 +08:00
shuaiplus 98a653efb6 feat: add support for steam:// secrets in TOTP handling and corresponding tests 2026-03-17 23:35:34 +08:00
copilot-swe-agent[bot] b5d58f1aa8 fix: support steam totp code generation and formatting
Co-authored-by: shuaiplus <100134295+shuaiplus@users.noreply.github.com>
2026-03-17 11:59:30 +08:00
shuaiplus 010cda972c feat: add observability configuration with logging and tracing options 2026-03-17 09:17:52 +08:00
shuaiplus 911cec337e feat: remove unused deriveLoginHash import and use deriveLoginHashLocally instead 2026-03-17 09:09:03 +08:00
shuaiplus 40fe9223ac feat: add parseSerializedUris function and update Bitwarden CSV parsing to handle multiple URIs 2026-03-17 09:03:14 +08:00
shuaiplus 3791f89a5c feat: enhance login handling by introducing local hash derivation and updating session management 2026-03-17 08:50:47 +08:00
shuaiplus 0ba85229a9 feat: refactor setup handling and enhance asset serving with bootstrap integration 2026-03-16 23:48:08 +08:00
shuaiplus b5f8ef28cc feat: enhance cipher data handling by expanding CipherRow interface and updating database queries 2026-03-16 22:41:47 +08:00
shuaiplus c16f9881d3 feat: add User-Agent header to fetch request in handleWebsiteIcon function 2026-03-16 22:08:08 +08:00
copilot-swe-agent[bot] 99f5bc735e fix: restore User-Agent header in website icon proxy to fix favicon display
Co-authored-by: shuaiplus <100134295+shuaiplus@users.noreply.github.com>
2026-03-16 17:43:00 +08:00
shuaiplus 623ad1acda feat: optimize XML decoding by using a switch statement for entity replacements 2026-03-16 00:58:13 +08:00
shuaiplus 43ec591414 feat: optimize XML decoding by using a switch statement for entity replacements 2026-03-16 00:58:13 +08:00
shuaiplus 2ebd0b60f7 feat: optimize path trimming and clean up unused imports in VaultPage component 2026-03-16 00:50:59 +08:00
shuaiplus 4de8643360 feat: optimize path trimming and clean up unused imports in VaultPage component 2026-03-16 00:50:59 +08:00
shuaiplus 2f448964f2 feat: enhance backup import functionality to handle skipped items and provide detailed feedback 2026-03-16 00:38:44 +08:00
shuaiplus 9fcd700dc4 feat: enhance backup import functionality to handle skipped items and provide detailed feedback 2026-03-16 00:38:44 +08:00
shuaiplus 3cb2ef1015 feat: enhance backup archive processing with configurable chunk sizes and compression levels 2026-03-16 00:24:14 +08:00
shuaiplus 557f4bfbbd feat: enhance backup archive processing with configurable chunk sizes and compression levels 2026-03-16 00:24:14 +08:00
shuaiplus c42a52f889 feat: enhance backup archive functionality with blob task management and concurrency handling 2026-03-16 00:05:11 +08:00
shuaiplus 3d33f78a0c feat: enhance backup archive functionality with blob task management and concurrency handling 2026-03-16 00:05:11 +08:00
shuaiplus 4b8cad6d00 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.
2026-03-15 23:12:45 +08:00
shuaiplus fc2667501c 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.
2026-03-15 23:12:45 +08:00
shuaiplus 9820c2ed44 feat: implement pending authentication actions for login, registration, and unlock flows 2026-03-15 18:32:30 +08:00
shuaiplus a4b45c1b59 feat: implement pending authentication actions for login, registration, and unlock flows 2026-03-15 18:32:30 +08:00
shuaiplus 171f3c5d71 feat: refactor authentication forms to use <form> elements for better submission handling 2026-03-15 18:26:36 +08:00
shuaiplus 588408ff96 feat: refactor authentication forms to use <form> elements for better submission handling 2026-03-15 18:26:36 +08:00
shuaiplus 722d3db0e9 refactor: enhance manual chunking in Vite config for better code splitting 2026-03-15 18:15:28 +08:00
shuaiplus ca74e55979 refactor: enhance manual chunking in Vite config for better code splitting 2026-03-15 18:15:28 +08:00
shuaiplus f0ace28bf2 feat: add shared API utilities for handling requests and responses
- Introduced `shared.ts` with utility functions for API interactions, including JSON parsing, error handling, and content disposition parsing.
- Added `vault.ts` to manage vault-related operations such as folder and cipher management, including creation, deletion, and bulk operations.
- Implemented encryption and decryption methods for secure data handling within the vault.
- Created `backup-settings-repair.ts` to automatically repair backup settings for admin profiles if needed.
2026-03-15 04:17:09 +08:00
shuaiplus 1cef45e373 feat: add shared API utilities for handling requests and responses
- Introduced `shared.ts` with utility functions for API interactions, including JSON parsing, error handling, and content disposition parsing.
- Added `vault.ts` to manage vault-related operations such as folder and cipher management, including creation, deletion, and bulk operations.
- Implemented encryption and decryption methods for secure data handling within the vault.
- Created `backup-settings-repair.ts` to automatically repair backup settings for admin profiles if needed.
2026-03-15 04:17:09 +08:00
shuaiplus 1fcfeb91d1 feat: refactor import routes and enhance backup state management with user ID 2026-03-15 03:44:38 +08:00
shuaiplus f749bbf7fd feat: refactor import routes and enhance backup state management with user ID 2026-03-15 03:44:38 +08:00
shuaiplus 5faf1bdee1 feat: update backup strategy terminology for clarity in UI 2026-03-15 03:36:28 +08:00
shuaiplus 8755b64f56 feat: update backup strategy terminology for clarity in UI 2026-03-15 03:36:28 +08:00
shuaiplus b1c6ec50da feat: add backup recommendations and update backup strategy UI
- Introduced new backup recommendations feature with interfaces for recommended storage providers.
- Updated i18n translations for backup strategy to reflect new terminology and improved descriptions.
- Enhanced types with optional private and public keys in user profiles.
- Redesigned backup-related styles for better layout and responsiveness.
- Updated TypeScript configuration to include shared modules.
- Configured Vite to resolve shared modules and allow filesystem access.
- Added cron triggers for periodic tasks in Wrangler configuration.
2026-03-15 03:34:16 +08:00
shuaiplus 05f1b2f9a8 feat: add backup recommendations and update backup strategy UI
- Introduced new backup recommendations feature with interfaces for recommended storage providers.
- Updated i18n translations for backup strategy to reflect new terminology and improved descriptions.
- Enhanced types with optional private and public keys in user profiles.
- Redesigned backup-related styles for better layout and responsiveness.
- Updated TypeScript configuration to include shared modules.
- Configured Vite to resolve shared modules and allow filesystem access.
- Added cron triggers for periodic tasks in Wrangler configuration.
2026-03-15 03:34:16 +08:00
shuaiplus 51d0e60cf1 refactor: improve base32 normalization function for better readability and performance 2026-03-12 02:28:19 +08:00
shuaiplus 33323439cd refactor: improve base32 normalization function for better readability and performance 2026-03-12 02:28:19 +08:00
shuaiplus cc522ec40f fix: clean up security scan warnings 2026-03-12 02:18:14 +08:00
shuaiplus 96b076b113 fix: clean up security scan warnings 2026-03-12 02:18:14 +08:00
shuaiplus 246a743822 merge: adopt simplified security scan workflow from pr-70 2026-03-12 02:01:27 +08:00
shuaiplus 73e90f7860 merge: adopt simplified security scan workflow from pr-70 2026-03-12 02:01:27 +08:00
shuaiplus 37cbb2f2c7 refactor: simplify security scan reporting workflow 2026-03-12 02:01:22 +08:00
shuaiplus b10e6032d4 refactor: simplify security scan reporting workflow 2026-03-12 02:01:22 +08:00
shuaiplus 0bb1baf768 refactor: optimize random byte generation for recovery and JWT secret functions 2026-03-12 01:59:28 +08:00
shuaiplus a994214e4a refactor: optimize random byte generation for recovery and JWT secret functions 2026-03-12 01:59:28 +08:00
shuaiplus 3eb517a92f feat(ciphers): add bulk restore and permanent delete functionality for ciphers
style: enhance list count display in VaultPage and styles
fix(i18n): add translations for bulk restore and permanent delete messages
2026-03-12 01:37:33 +08:00
shuaiplus f51468b7b9 feat(ciphers): add bulk restore and permanent delete functionality for ciphers
style: enhance list count display in VaultPage and styles
fix(i18n): add translations for bulk restore and permanent delete messages
2026-03-12 01:37:33 +08:00
shuaiplus ad764a9c5b refactor(cors): simplify origin handling and improve CORS headers 2026-03-11 02:36:50 +08:00
shuaiplus 94cb6177f2 refactor(cors): simplify origin handling and improve CORS headers 2026-03-11 02:36:50 +08:00
shuaiplus 9b26feb310 Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-03-11 02:22:45 +08:00
shuaiplus 80d6315148 Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-03-11 02:22:45 +08:00
shuaiplus f4d2e7932a Refactor VaultPage component: remove exposed password checks, add bulk delete functionality for folders, and improve list rendering performance
- Removed password breach checking logic and related state management from VaultPage.
- Introduced bulk delete functionality for folders with a confirmation dialog.
- Enhanced list rendering with virtualization to improve performance.
- Updated styles for folder actions and list items for better UI consistency.
- Removed unused password breach library and related translations.
2026-03-11 02:22:35 +08:00
shuaiplus 7c64453c1a Refactor VaultPage component: remove exposed password checks, add bulk delete functionality for folders, and improve list rendering performance
- Removed password breach checking logic and related state management from VaultPage.
- Introduced bulk delete functionality for folders with a confirmation dialog.
- Enhanced list rendering with virtualization to improve performance.
- Updated styles for folder actions and list items for better UI consistency.
- Removed unused password breach library and related translations.
2026-03-11 02:22:35 +08:00
nap0o 810edfe8a6 feat: 利用Github Action进行代码安全扫描,并生成报告 2026-03-10 11:34:42 +08:00
nap0o d1aee25905 feat: 利用Github Action进行代码安全扫描,并生成报告 2026-03-10 11:34:42 +08:00
Shuai 3b0ccf2a77 Update wrangler.toml 2026-03-09 09:48:07 +08:00
Shuai cf815805e9 Update wrangler.toml 2026-03-09 09:48:07 +08:00
shuaiplus bc5efbf2fd feat(notifications): enhance NotificationsHub with device status updates and logout notifications 2026-03-09 01:21:39 +08:00
shuaiplus 616d6273bb feat(notifications): enhance NotificationsHub with device status updates and logout notifications 2026-03-09 01:21:39 +08:00
shuaiplus 1285f6296e feat(cors): add Access-Control-Allow-Credentials header for CORS support 2026-03-09 00:52:24 +08:00
shuaiplus cb137fe0c7 feat(cors): add Access-Control-Allow-Credentials header for CORS support 2026-03-09 00:52:24 +08:00
shuaiplus 899f1004a3 feat: implement NotificationsHub for real-time vault sync notifications
- Added NotificationsHub durable object to handle WebSocket connections for vault sync notifications.
- Integrated SignalR protocol for message framing and communication.
- Updated storage service methods to return revision date and user ID for vault sync notifications.
- Enhanced existing handlers (attachments, ciphers, folders, sends, and import) to notify users of vault sync events.
- Created new notifications handler for WebSocket negotiation and binding user IDs.
- Updated frontend to establish WebSocket connection for receiving vault sync notifications.
- Improved CORS headers to support new notification endpoints.
- Bumped wrangler version in package.json to 4.71.0.
2026-03-09 00:25:34 +08:00
shuaiplus f0c57a7f9c feat: implement NotificationsHub for real-time vault sync notifications
- Added NotificationsHub durable object to handle WebSocket connections for vault sync notifications.
- Integrated SignalR protocol for message framing and communication.
- Updated storage service methods to return revision date and user ID for vault sync notifications.
- Enhanced existing handlers (attachments, ciphers, folders, sends, and import) to notify users of vault sync events.
- Created new notifications handler for WebSocket negotiation and binding user IDs.
- Updated frontend to establish WebSocket connection for receiving vault sync notifications.
- Improved CORS headers to support new notification endpoints.
- Bumped wrangler version in package.json to 4.71.0.
2026-03-09 00:25:34 +08:00
shuaiplus 54cf1ff718 feat(i18n): update error messages for device trust operations 2026-03-08 22:24:11 +08:00
shuaiplus e0d53b4683 feat(i18n): update error messages for device trust operations 2026-03-08 22:24:11 +08:00
shuaiplus c34c44ce5b feat(devices): add functionality to delete all authorized devices 2026-03-08 22:12:01 +08:00
shuaiplus d48e6b6ce5 feat(devices): add functionality to delete all authorized devices 2026-03-08 22:12:01 +08:00
shuaiplus 1062725b46 feat: update Content Security Policy to include pwned passwords API 2026-03-08 19:29:06 +08:00
shuaiplus 61dac98a12 feat: update Content Security Policy to include pwned passwords API 2026-03-08 19:29:06 +08:00
shuaiplus c8194a04c7 feat(vault): add password exposure check and related UI enhancements 2026-03-08 19:23:24 +08:00
shuaiplus 219f569969 feat(vault): add password exposure check and related UI enhancements 2026-03-08 19:23:24 +08:00
shuaiplus a372b99fc9 feat: add invite code handling from URL for registration flow 2026-03-08 17:15:37 +08:00
shuaiplus f556782c86 feat: add invite code handling from URL for registration flow 2026-03-08 17:15:37 +08:00
shuaiplus 68583821fe feat: enhance mobile layout and accessibility across components
- Added mobile layout support in AdminPage, SecurityDevicesPage, SendsPage, and VaultPage.
- Implemented responsive design adjustments including mobile sidebar and panel transitions.
- Updated table structures to include data labels for better accessibility.
- Introduced new translations for mobile-specific UI elements.
- Enhanced styles for mobile views, including button adjustments and sidebar behaviors.
2026-03-08 17:07:21 +08:00
shuaiplus ed678a070e feat: enhance mobile layout and accessibility across components
- Added mobile layout support in AdminPage, SecurityDevicesPage, SendsPage, and VaultPage.
- Implemented responsive design adjustments including mobile sidebar and panel transitions.
- Updated table structures to include data labels for better accessibility.
- Introduced new translations for mobile-specific UI elements.
- Enhanced styles for mobile views, including button adjustments and sidebar behaviors.
2026-03-08 17:07:21 +08:00
shuaiplus 0e1152a0b9 feat(vault): add sorting functionality with persistent storage 2026-03-08 14:21:48 +08:00
shuaiplus 5fee320eee feat(vault): add sorting functionality with persistent storage 2026-03-08 14:21:48 +08:00
shuaiplus eeb477b84c feat: Implement admin backup export and import functionality
- Added new endpoints for exporting and importing instance-level backups.
- Introduced user interface components for backup management in the web app.
- Enhanced import/export logic to handle attachments and provide detailed summaries.
- Updated localization files to include new strings related to backup features.
- Improved styling for backup-related UI elements.
2026-03-08 13:36:51 +08:00
shuaiplus 01f01e5903 feat: Implement admin backup export and import functionality
- Added new endpoints for exporting and importing instance-level backups.
- Introduced user interface components for backup management in the web app.
- Enhanced import/export logic to handle attachments and provide detailed summaries.
- Updated localization files to include new strings related to backup features.
- Improved styling for backup-related UI elements.
2026-03-08 13:36:51 +08:00
shuaiplus 206b0be566 feat: add TOTP codes page and related components for displaying verification codes 2026-03-08 02:31:36 +08:00
shuaiplus 5c2c6cfb6c feat: add TOTP codes page and related components for displaying verification codes 2026-03-08 02:31:36 +08:00
shuaiplus eec27f3a40 chore: remove obsolete KV ID from wrangler.kv.toml 2026-03-08 01:07:25 +08:00
shuaiplus ec57897a5f chore: remove obsolete KV ID from wrangler.kv.toml 2026-03-08 01:07:25 +08:00
shuaiplus d828f145db docs: update deployment instructions in README and README_EN to reflect new Workers URL 2026-03-07 06:42:12 +08:00
shuaiplus 3f7af954c7 docs: update deployment instructions in README and README_EN to reflect new Workers URL 2026-03-07 06:42:12 +08:00
shuaiplus e7d2c85de9 chore: remove obsolete workflows and update sync process in sync-upstream.yml 2026-03-07 06:36:41 +08:00
shuaiplus 1b242b8404 chore: remove obsolete workflows and update sync process in sync-upstream.yml 2026-03-07 06:36:41 +08:00
shuaiplus 49c71039a4 docs: update README and README_EN with clearer instructions for repository setup and synchronization 2026-03-07 04:01:48 +08:00
shuaiplus 4cec39cfe2 docs: update README and README_EN with clearer instructions for repository setup and synchronization 2026-03-07 04:01:48 +08:00
shuaiplus ca194da822 feat: add workflow to import KV ID from NodeWarden2 and update README for deployment instructions 2026-03-07 03:47:21 +08:00
shuaiplus e931307c8f feat: add workflow to import KV ID from NodeWarden2 and update README for deployment instructions 2026-03-07 03:47:21 +08:00
shuaiplus 23c78b3408 feat: update workflows and README for KV and R2 mode switching 2026-03-07 02:33:29 +08:00
shuaiplus 0fcdc61843 feat: update workflows and README for KV and R2 mode switching 2026-03-07 02:33:29 +08:00
shuaiplus 1aa29dda11 docs: update README and README_EN with clearer deployment instructions and buttons 2026-03-06 03:20:19 +08:00
shuaiplus be572746a3 docs: update README and README_EN with clearer deployment instructions and buttons 2026-03-06 03:20:19 +08:00
shuaiplus bf066fc68b docs: update README with clearer deployment instructions and badges 2026-03-06 03:15:41 +08:00
shuaiplus 40a3105b82 docs: update README with clearer deployment instructions and badges 2026-03-06 03:15:41 +08:00
shuaiplus 03b793b14a feat: refactor kv sync logic to use regex for R2 block replacement 2026-03-06 03:08:38 +08:00
shuaiplus 5f386c80c5 feat: refactor kv sync logic to use regex for R2 block replacement 2026-03-06 03:08:38 +08:00
shuaiplus 54466160af feat: update sync workflow and README for KV storage support 2026-03-06 03:06:34 +08:00
shuaiplus 257928a317 feat: update sync workflow and README for KV storage support 2026-03-06 03:06:34 +08:00
shuaiplus fdf266111b feat: update README files to improve clarity on R2 vs KV storage options 2026-03-06 01:07:24 +08:00
shuaiplus 39ec5da861 feat: update README files to improve clarity on R2 vs KV storage options 2026-03-06 01:07:24 +08:00
shuaiplus 5d636e4977 feat: add support for KV storage mode and enhance attachment handling 2026-03-06 01:00:19 +08:00
shuaiplus 57aa7457ae feat: add support for KV storage mode and enhance attachment handling 2026-03-06 01:00:19 +08:00
shuaiplus 773453b7cc feat: improve client IP identification logic for rate limiting 2026-03-05 22:03:40 +08:00
shuaiplus c54740517c feat: improve client IP identification logic for rate limiting 2026-03-05 22:03:40 +08:00
shuaiplus d054d76afe feat: update Content Security Policy for enhanced security and resource loading 2026-03-05 21:40:39 +08:00
shuaiplus dc7d80ddfc feat: update Content Security Policy for enhanced security and resource loading 2026-03-05 21:40:39 +08:00
shuaiplus dab0961a63 feat: improve error handling and localization for vault operations and import/export processes 2026-03-05 02:55:59 +08:00
shuaiplus 1e34a96c57 feat: improve error handling and localization for vault operations and import/export processes 2026-03-05 02:55:59 +08:00
shuaiplus e12ab2b334 feat: implement constant time comparison for MAC verification to enhance security 2026-03-05 02:41:02 +08:00
shuaiplus 380cd34474 feat: implement constant time comparison for MAC verification to enhance security 2026-03-05 02:41:02 +08:00
shuaiplus 7b5f6163cf feat: remove handleUpdateProfile function to streamline account management 2026-03-05 02:37:27 +08:00
shuaiplus 56235cb94d feat: remove handleUpdateProfile function to streamline account management 2026-03-05 02:37:27 +08:00
shuaiplus 55c5573544 feat: enhance rate limiting with new public request budgets and client IP validation 2026-03-05 02:26:05 +08:00
shuaiplus 49af3e7099 feat: enhance rate limiting with new public request budgets and client IP validation 2026-03-05 02:26:05 +08:00
shuaiplus 9db92d13ab feat: enhance send file download token with JTI for improved validation 2026-03-05 01:31:02 +08:00
shuaiplus c39654ab3c feat: enhance send file download token with JTI for improved validation 2026-03-05 01:31:02 +08:00
shuaiplus 12024203be feat: reorder key assignment logic in handleSetKeys for improved readability 2026-03-05 01:18:23 +08:00
shuaiplus f5684145f9 feat: reorder key assignment logic in handleSetKeys for improved readability 2026-03-05 01:18:23 +08:00
shuaiplus a2654dcde3 feat: enhance import/export feature description for completeness and clarity 2026-03-04 23:52:56 +08:00
shuaiplus 8c35d89519 feat: enhance import/export feature description for completeness and clarity 2026-03-04 23:52:56 +08:00
shuaiplus cb662b7d70 feat: update import/export feature descriptions for clarity and completeness 2026-03-04 23:49:37 +08:00
shuaiplus 4d5f207ce7 feat: update import/export feature descriptions for clarity and completeness 2026-03-04 23:49:37 +08:00
shuaiplus 1ac063909f feat: improve import/export feature descriptions for clarity and consistency 2026-03-04 23:17:58 +08:00
shuaiplus 3f62a03181 feat: improve import/export feature descriptions for clarity and consistency 2026-03-04 23:17:58 +08:00
shuaiplus 35dc239c25 feat: enhance import/export page with new layout and features 2026-03-04 23:07:03 +08:00
shuaiplus 7ace10e7cc feat: enhance import/export page with new layout and features 2026-03-04 23:07:03 +08:00
shuaiplus c99a558b5e feat: add support for SSH key fingerprint normalization and compatibility 2026-03-04 22:45:30 +08:00
shuaiplus 8df3221078 feat: add support for SSH key fingerprint normalization and compatibility 2026-03-04 22:45:30 +08:00
shuaiplus 819734ce5c feat: add export and import functionality for Bitwarden and NodeWarden formats
- Implemented export formats for Bitwarden (JSON, encrypted JSON, ZIP) and NodeWarden (JSON).
- Added support for attachments in ciphers and introduced new types for handling attachments.
- Enhanced import formats to include Bitwarden ZIP and NodeWarden JSON.
- Updated internationalization strings for attachment-related features.
- Improved UI styles for attachment management and import summary display.
2026-03-04 01:03:49 +08:00
shuaiplus 36f398b728 feat: add export and import functionality for Bitwarden and NodeWarden formats
- Implemented export formats for Bitwarden (JSON, encrypted JSON, ZIP) and NodeWarden (JSON).
- Added support for attachments in ciphers and introduced new types for handling attachments.
- Enhanced import formats to include Bitwarden ZIP and NodeWarden JSON.
- Updated internationalization strings for attachment-related features.
- Improved UI styles for attachment management and import summary display.
2026-03-04 01:03:49 +08:00
shuaiplus 7b4733d4c4 feat: implement folder management features including create, update, and delete actions 2026-03-03 21:03:16 +08:00
shuaiplus 6ca1fa739f feat: implement folder management features including create, update, and delete actions 2026-03-03 21:03:16 +08:00
shuaiplus af56236dba Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-03-03 20:30:28 +08:00
shuaiplus 7193df7f11 Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-03-03 20:30:28 +08:00
Zheng Li 3622c58680 fix: add build command to wrangler.toml for CI/CD compatibility 2026-03-03 20:30:06 +08:00
Zheng Li 0d36aa9139 fix: add build command to wrangler.toml for CI/CD compatibility 2026-03-03 20:30:06 +08:00
shuaiplus b5284e669a feat: add FIDO2 credentials support to CipherLogin and VaultDraft types
- Introduced CipherLoginPasskey interface to represent FIDO2 credentials with a creation date.
- Updated CipherLogin interface to include an optional fido2Credentials property.
- Modified VaultDraft interface to add loginFido2Credentials property for handling FIDO2 credentials.
2026-03-03 02:18:26 +08:00
shuaiplus d63755f67d feat: add FIDO2 credentials support to CipherLogin and VaultDraft types
- Introduced CipherLoginPasskey interface to represent FIDO2 credentials with a creation date.
- Updated CipherLogin interface to include an optional fido2Credentials property.
- Modified VaultDraft interface to add loginFido2Credentials property for handling FIDO2 credentials.
2026-03-03 02:18:26 +08:00
shuaiplus 4da5525a1a fix: update 2FA support descriptions and improve error handling in TOTP actions 2026-03-02 22:36:10 +08:00
shuaiplus 6dcc18e2e9 fix: update 2FA support descriptions and improve error handling in TOTP actions 2026-03-02 22:36:10 +08:00
shuaiplus 16a7bcace9 fix: resolve merge conflict in twoFactorRequiredResponse function 2026-03-02 22:12:46 +08:00
shuaiplus f230e5c8c2 fix: resolve merge conflict in twoFactorRequiredResponse function 2026-03-02 22:12:46 +08:00
shuaiplus f59e81de3a Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-03-02 22:08:53 +08:00
shuaiplus 8ac2ab0699 Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-03-02 22:08:53 +08:00
shuaiplus 227d43194d fix: update two-factor provider constants for backward compatibility 2026-03-02 22:07:04 +08:00
shuaiplus f9030d5dbb fix: update two-factor provider constants for backward compatibility 2026-03-02 22:07:04 +08:00
copilot-swe-agent[bot] 3341a9ef74 fix: return numeric provider IDs in TwoFactorProviders for Android client compatibility
Co-authored-by: shuaiplus <100134295+shuaiplus@users.noreply.github.com>
2026-03-02 13:57:37 +08:00
copilot-swe-agent[bot] 41221998c9 fix: return numeric provider IDs in TwoFactorProviders for Android client compatibility
Co-authored-by: shuaiplus <100134295+shuaiplus@users.noreply.github.com>
2026-03-02 13:57:37 +08:00
shuaiplus d0c97ee573 fix: correct typo in README.md 2026-03-02 00:41:10 +08:00
shuaiplus fab6d9da67 fix: correct typo in README.md 2026-03-02 00:41:10 +08:00
shuaiplus 01154947ef feat: add Import & Export page and update Help page with new navigation 2026-03-02 00:10:44 +08:00
shuaiplus 82131bd892 fix: update deploy script to use consistent build command 2026-03-02 00:10:44 +08:00
shuaiplus ddf5901730 chore: ensure newline at end of .gitignore file 2026-03-02 00:10:44 +08:00
shuaiplus 65b57b00e2 chore: remove accidental tmp submodules 2026-03-02 00:10:44 +08:00
shuaiplus 15eb72a4b3 chore: remove accidental tmp submodules 2026-03-02 00:10:44 +08:00
shuaiplus 30884d7184 feat: add build script for consistent project building 2026-03-02 00:10:44 +08:00
shuaiplus 1ab8e1baa7 feat: remove deprecated Bitwarden subprojects from the repository 2026-03-02 00:10:44 +08:00
shuaiplus d3d4755505 feat: update routing regex patterns for improved API path matching 2026-03-02 00:10:44 +08:00
shuaiplus a0b9f970c1 feat: update favicon and logo images for improved branding 2026-03-02 00:10:44 +08:00
shuaiplus f20a71e8a8 feat: enhance security headers and update content security policy in response and HTML files 2026-03-02 00:10:44 +08:00
shuaiplus 7d5681665f feat: enhance deployment process and update dependencies
- Updated the deployment script to build the web application before deploying.
- Upgraded Wrangler dependency from 4.61.1 to 4.69.0.

feat: add import item limit and request body size limit

- Introduced a new limit for the maximum total items allowed in a single import (5000).
- Set a hard body size limit for JSON API endpoints (25 MB).

feat: validate KDF parameters during registration and password change

- Added validation for KDF parameters to ensure compliance with Bitwarden's minimum requirements.
- Enhanced error handling for invalid KDF parameters during user registration and password change.

feat: clean up R2 files on user deletion

- Implemented cleanup of R2 files associated with user attachments and sends before deleting user metadata.

feat: verify folder ownership when creating or updating ciphers

- Added checks to ensure that users cannot reference folders owned by other users when creating or updating ciphers.

fix: handle corrupted cipher data gracefully

- Improved error handling when retrieving ciphers from the database to avoid crashes due to corrupted data.

feat: increment send access count atomically

- Added a method to atomically increment the access count for sends and return whether the update was successful.

fix: enforce request body size limits

- Implemented checks to reject oversized request bodies for non-file upload paths.

fix: update error handling for database initialization

- Enhanced error logging for database initialization failures while providing a generic message to clients.

feat: enhance security with Content Security Policy

- Added a Content Security Policy to the web application to improve security against XSS attacks.

fix: remove plaintext TOTP secret from localStorage

- Updated the TOTP enabling process to remove the plaintext secret from localStorage after it is stored on the server.

fix: ensure only PBKDF2 hash is sent for public send access

- Modified the public send access payload to ensure only the PBKDF2 hash is sent, never the plaintext password.
2026-03-02 00:10:44 +08:00
shuaiplus 1a94f8dd44 feat: enhance password security with server-side hashing and constant-time comparisons 2026-03-02 00:10:44 +08:00
shuaiplus 66f995d981 feat: unify API rate limiting and enhance request budgets 2026-03-02 00:10:44 +08:00
shuaiplus 234e3a5e96 docs: update capability descriptions in README files for clarity 2026-03-02 00:10:44 +08:00
shuaiplus d3b515fd99 feat: add TOTP recovery code field to users table 2026-03-02 00:10:44 +08:00
shuaiplus 68f66cf4e6 docs: update README files for clarity on deployment steps and features 2026-03-02 00:10:44 +08:00
shuaiplus 9061ab52b6 feat: add JWT secret safety checks and warning page for insecure configurations 2026-03-02 00:10:44 +08:00
shuaiplus 1d170baaaf fix: remove unnecessary zoom property from html in styles.css 2026-03-02 00:10:44 +08:00
shuaiplus bacf27b936 feat(i18n): add internationalization support with English and Chinese translations 2026-03-02 00:10:44 +08:00
shuaiplus 1810e0aa7a feat: add recovery code functionality and device management 2026-03-02 00:10:44 +08:00
shuaiplus 3a650740a1 feat: update README files to reflect full user management and support for text and file sends 2026-03-02 00:10:44 +08:00
shuaiplus 9b490016aa fix: update README to clarify NodeWarden as a third-party Bitwarden server 2026-03-02 00:10:44 +08:00
shuaiplus 0db5f957c8 feat: add NodeWarden logo to README files for improved branding 2026-03-02 00:10:44 +08:00
shuaiplus 8481e2756e feat: enhance send functionality with improved key handling and decryption, update UI components for better user experience 2026-03-02 00:10:44 +08:00
shuaiplus b7dfd1b3ad feat: enhance SendsPage with notes display and update VaultPage for improved filtering and history tracking 2026-03-02 00:10:44 +08:00
shuaiplus 9c1c5e2c26 feat: add PublicSendPage and SendsPage components for managing sends 2026-03-02 00:10:44 +08:00
shuaiplus 15e0a29bb1 feat: add favicon and logo assets, update App component to use logo 2026-03-02 00:10:44 +08:00
shuaiplus 205ccdad8b feat: add SSH key utilities and improve field decryption 2026-03-02 00:10:44 +08:00
shuaiplus 389872d491 feat: enhance VaultPage and App layout with new UI components and styles 2026-03-02 00:10:44 +08:00
shuaiplus d7c41edad4 feat: enhance authentication and settings UI 2026-03-02 00:10:44 +08:00
shuaiplus 5509492563 feat: add cryptographic utilities and types for secure data handling 2026-03-02 00:10:44 +08:00
shuaiplus 7c7d32de30 feat: add toast notifications and dialog components for improved user interaction 2026-03-02 00:10:44 +08:00
shuaiplus 4831a0915c feat: implement vault locking mechanism with auto-lock settings and unlock functionality 2026-03-02 00:10:44 +08:00
shuaiplus 930f4f86cc feat: add QR code generation support and rate limiting for known device probes 2026-03-02 00:10:44 +08:00
shuaiplus ceb4bef9e4 Add vault-utils.js with utility functions for field type parsing, selection counting, cipher type mapping, URI handling, and extracting first cipher URI 2026-03-02 00:10:44 +08:00
shuaiplus c4c25efc50 Add runtime configuration loader and styles for web application 2026-03-02 00:10:44 +08:00
shuaiplus bda0cba1c6 Enhance styles for app layout and components 2026-03-02 00:10:44 +08:00
shuaiplus b10ce83ca0 Add global styles for web client interface 2026-03-02 00:10:44 +08:00
shuaiplus ee784d18db Implement code changes to enhance functionality and improve performance 2026-03-02 00:10:44 +08:00
shuaiplus ec9be40d6c feat: 更新网页客户端样式和布局,提升用户体验 2026-03-02 00:10:44 +08:00
shuaiplus b21b031120 Refactor code structure for improved readability and maintainability 2026-03-02 00:10:44 +08:00
shuaiplus 90da97c945 feat: enhance registration and password management UI with additional state handling 2026-03-02 00:10:44 +08:00
shuaiplus 39fbdc7e0e feat: implement admin user management and invite system 2026-03-02 00:10:44 +08:00
shuaiplus 9359ce2a2c feat: remove setup disabling functionality and related UI elements 2026-02-25 01:30:08 +08:00
shuaiplus 026aea03dc feat: add overlap grace period for refresh tokens to handle concurrent requests 2026-02-25 00:22:31 +08:00
shuaiplus 6621738b02 feat: add compatibility for custom fields handling in cipher creation and update 2026-02-25 00:10:11 +08:00
shuaiplus 431cc0d5d7 feat: add compatibility for fido2Credentials counter and implement no-op device token update handler 2026-02-23 23:29:00 +08:00
shuaiplus 2226bdd9ef feat: add CLI deployment instructions 2026-02-23 20:01:55 +08:00
shuaiplus f7a5966104 feat: add compatibility mode for deleting ciphers to support Bitwarden clients 2026-02-23 19:35:06 +08:00
shuaiplus 747cad35f5 enhance README with badges and project links 2026-02-23 16:56:00 +08:00
shuaiplus c44436a5fd fix: ensure attachment size is formatted as string for compatibility with Bitwarden clients 2026-02-23 14:07:11 +08:00
shuaiplus a3f074f38a feat: add TOTP code generation and display functionality with UI enhancements 2026-02-21 15:13:21 +08:00
shuaiplus 8106364650 feat: enhance two-factor authentication handling and improve error responses 2026-02-21 14:13:22 +08:00
shuaiplus 2934ebd36d feat: enhance registration page with TOTP support and UI improvements 2026-02-20 20:28:08 +08:00
shuaiplus 177d34ba54 fix: increase max login attempts and improve two-factor token error response 2026-02-20 18:53:10 +08:00
shuaiplus 622a4ec506 chore: update version to 1.1.0 and improve two-factor provider validation 2026-02-20 18:39:18 +08:00
shuaiplus 3f8a6d78d5 feat: add token revocation endpoint and enhance ciphers import request structure 2026-02-20 18:16:07 +08:00
shuaiplus 269055867b feat: extend CiphersImportRequest with additional fields for enhanced import functionality 2026-02-20 16:54:42 +08:00
shuaiplus 363a029618 feat: Implement TOTP-based two-factor authentication
- Added TOTP support for two-factor authentication in user profiles and login flows.
- Introduced device management endpoints to handle known devices and their registration.
- Enhanced database schema to include devices and trusted two-factor tokens.
- Updated response handling to include two-factor token in successful login responses.
- Modified registration and login pages to guide users through enabling TOTP.
- Improved device identification and management utilities for better user experience.
2026-02-20 15:59:55 +08:00
shuaiplus 2b6852fb7f fix: update TOTP field description for clarity in README files 2026-02-20 00:41:47 +08:00
shuaiplus e452dde3dc fix: update JWT_SECRET description for clarity 2026-02-20 00:04:14 +08:00
shuaiplus 6b8ee28e54 fix: update version to 1.0.0 in package.json and package-lock.json 2026-02-19 22:14:44 +08:00
shuaiplus 2f7dbc78d3 chore: remove temporary subproject references for cleanup 2026-02-19 21:39:12 +08:00
shuaiplus 1a22b108ca style: enhance register page styling with grid background and button effects 2026-02-19 21:13:59 +08:00
shuaiplus 40549147bd fix: update bitwarden server version to 2026.1.0 2026-02-19 19:58:33 +08:00
shuaiplus c0a390baa5 Refactor code structure for improved readability and maintainability 2026-02-19 18:57:23 +08:00
shuaiplus 7cdccde684 docs: add Star History section to README files 2026-02-19 16:08:08 +08:00
shuaiplus 9edaa647c4 feat(storage): add method to retrieve attachments by user ID for improved data handling 2026-02-19 02:27:56 +08:00
shuaiplus ba9710cdf0 fix(storage): optimize attachment retrieval by batching cipher IDs to improve performance 2026-02-19 01:42:55 +08:00
shuaiplus 69f4fde5a2 docs: update feature comparison table in README files for clarity and consistency 2026-02-18 21:29:51 +08:00
shuaiplus 2a747c996d feat(pagination): add pagination utility functions for handling page size and continuation tokens
- Introduced `PaginationRequest` interface to define pagination parameters.
- Implemented `parsePagination` function to extract and validate pagination parameters from a URL.
- Added `encodeContinuationToken` and `decodeContinuationToken` functions for managing continuation tokens.
- Ensured that pagination respects maximum page size limits defined in configuration.
2026-02-18 20:59:46 +08:00
shuaiplus e1f1c6f865 fix: enhance attachment handling and folder deletion logic; improve error responses and rate limiting 2026-02-18 03:06:50 +08:00
shuaiplus 73db6c518b fix: track and clean up test-created cipher and folder IDs to prevent undecryptable items 2026-02-17 22:47:15 +08:00
shuaiplus 1d1cbd2c8e fix: enhance cipher handling to support unknown fields and improve database binding 2026-02-17 22:20:01 +08:00
shuaiplus 72ec21415b fix: adjust layout and improve JWT_SECRET instructions on registration page 2026-02-15 03:10:59 +08:00
shuaiplus 649f54f923 fix: update README to clarify deployment steps and features 2026-02-15 02:56:31 +08:00
shuaiplus beefe2227e Refactor code structure for improved readability and maintainability 2026-02-15 02:45:57 +08:00
shuaiplus 326e13adf0 fix: remove placeholder database_id from D1 database configuration 2026-02-15 02:25:26 +08:00
shuaiplus 6e1a8e7b5c fix: correct link to English README in Chinese version 2026-02-15 02:23:01 +08:00
shuaiplus c5d3052080 Refactor code structure for improved readability and maintainability 2026-02-15 02:21:55 +08:00
138 changed files with 30497 additions and 6609 deletions
+467
View File
@@ -0,0 +1,467 @@
const fs = require('fs');
const path = require('path');
/**
* Security Report Generator (Node.js)
* Better, faster, and more maintainable than Bash.
*/
class SecurityReport {
constructor() {
this.results = {
codeql: { status: 'PASS', findings: [], alertCount: 0, rulesCount: 0 },
snyk: { status: 'PASS', findings: [], vulnCount: 0 },
gitleaks: { status: 'PASS', findings: [], leaksCount: 0 },
trivy: { status: 'PASS', findings: [], misconfigCount: 0 },
coverage: { actions: 0, js: 0, ts: 0 },
artifactUris: []
};
this.auditTime = new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
this.runId = process.env.GITHUB_RUN_ID || '0';
this.repository = process.env.GITHUB_REPOSITORY || 'unknown/repo';
this.runUrl = `https://github.com/${this.repository}/actions/runs/${this.runId}`;
this.locales = {
zh: {
filename: 'security-report-cn.md',
switcher: '[English](security-report.md) | 中文',
title: '🛡️ 安全审计与透明度报告',
grade: '安全评级',
important: '> [!IMPORTANT]\n> 本报告由 **GitHub Actions** 自动生成。为确保数据主权的绝对透明度,所有核心模块的安全扫描结果均实时公开。',
auditTime: '📅 审计时间',
runId: '📝 运行 ID',
env: '🛠️ 环境',
dashboard: '📉 实时安全仪表盘',
tool: '工具',
status: '状态',
findings: '发现项',
leaks: '泄露',
vulns: '漏洞',
alerts: '告警',
coverageTitle: '🔍 扫描覆盖范围',
module: '模块',
auditedFiles: '已审计文件',
coverage: '覆盖率',
detailedFindings: '🔍 详细发现项',
gitleaksTitle: '🔑 凭据泄露检查 (Gitleaks)',
gitleaksDesc: '`检测代码历史记录中硬编码的 API 密钥、密码或其他敏感令牌。`',
gitleaksSafe: '✅ **安全**:未发现硬编码的敏感凭据。',
gitleaksScope: '`扫描范围:所有代码更改和 Git 历史记录 (Gitleaks 全量扫描)`',
snykTitle: '📦 第三方依赖',
snykSafe: '✅ **安全**:在依赖项中未发现已知漏洞。',
package: '软件包',
severity: '严重程度',
description: '描述',
fixPlan: '修复方案',
codeqlTitle: '💻 代码质量与安全 (CodeQL)',
codeqlSummary: '#### 摘要',
rulesChecked: '已检查规则',
totalAlerts: '告警总数',
codeqlSafe: '✅ **安全**:CodeQL 扫描清洁,未检测到问题。',
ruleId: '规则 ID',
level: '级别',
location: '位置',
auditedList: '📂 已审计文件列表',
guideTitle: '⚠️ 操作指南',
guideDesc: '如果您看到 **FAIL** 状态或严重的代码问题:',
guideStep1: '1. **开发人员**:使用上方表格中的 **位置** 列找到确切的文件和行号。',
guideStep2: '2. **纠正**:遵循为每个规则提供的文档链接以提交修复。',
guideStep3: '3. **可追溯性**:完整的原始 `.sarif` 数据已附加到此分支。下载并将其导入您的 IDE(例如 VS Code SARIF 查看器)进行本地分析。',
footer: '💡 *由 Antigravity AI 安全引擎生成。透明度是我们的承诺。*',
auditedIcon: '✅ **已审计**',
noFiles: '未检索到文件。',
trivyTitle: '🛡️ 容器配置安全 (Trivy)',
trivyDesc: '`检测 Dockerfile 和容器配置中的安全风险与最佳实践。`',
trivySafe: '✅ **安全**:未发现容器配置缺陷。'
},
en: {
filename: 'security-report.md',
switcher: 'English | [中文](security-report-cn.md)',
title: '🛡️ Security Audit & Transparency Report',
grade: 'Security Grade',
important: '> [!IMPORTANT]\n> This report is automatically generated by **GitHub Actions**. To ensure absolute transparency of data sovereignty, all core module security scan results are made public in real-time.',
auditTime: '📅 Audit Time',
runId: '📝 Run ID',
env: '🛠️ Environment',
dashboard: '📉 Real-time Security Dashboard',
tool: 'Tool',
status: 'Status',
findings: 'Findings',
leaks: 'Leaks',
vulns: 'Vulns',
alerts: 'Alerts',
coverageTitle: '🔍 Scan Coverage',
module: 'Module',
auditedFiles: 'Audited Files',
coverage: 'Coverage',
detailedFindings: '🔍 Detailed Findings',
gitleaksTitle: '🔑 Credential Leak Check (Gitleaks)',
gitleaksDesc: '`This section detects hardcoded API Keys, passwords, or other sensitive tokens in the code history.`',
gitleaksSafe: '✅ **SAFE**: No hardcoded sensitive credentials found.',
gitleaksScope: '`Scan Scope: All code changes and Git history (Gitleaks Full Scan)`',
snykTitle: '📦 Third-party Dependencies',
snykSafe: '✅ **SAFE**: No known vulnerabilities found in dependencies.',
package: 'Package',
severity: 'Severity',
description: 'Description',
fixPlan: 'Fix Plan',
codeqlTitle: '💻 Code Quality & Safety (CodeQL)',
codeqlSummary: '#### Summary',
rulesChecked: 'Rules Checked',
totalAlerts: 'Total Alerts',
codeqlSafe: '✅ **SAFE**: CodeQL clean. No issues detected.',
ruleId: 'Rule ID',
level: 'Level',
location: 'Location',
auditedList: '📂 Audited File List',
guideTitle: '⚠️ Action Guide',
guideDesc: 'If you see a **FAIL** status or serious code issues:',
guideStep1: '1. **Developers**: Use the **Location** column in the tables above to find the exact file and line number.',
guideStep2: '2. **Remediate**: Follow the documentation links provided for each rule to submit a fix.',
guideStep3: '3. **Traceability**: Full raw `.sarif` data is attached to this branch. Download and import it into your IDE (e.g., VS Code SARIF Viewer) for local analysis.',
footer: '💡 *Generated by Antigravity AI Security Engine. Transparency is our commitment.*',
auditedIcon: '✅ **Audited**',
noFiles: 'No files found.',
trivyTitle: '🛡️ Container Config Security (Trivy)',
trivyDesc: '`This section detects security risks and best practices in Dockerfile and container configurations.`',
trivySafe: '✅ **SAFE**: No container configuration defects found.'
}
};
}
// --- Data Parsers ---
async parseCodeQL() {
const sarifPath = 'sarif-results';
if (!fs.existsSync(sarifPath)) return;
const files = this.globFiles(sarifPath, '.sarif');
let totalAlerts = 0;
let rulesSet = new Set();
let findings = [];
let artifactUris = new Set();
for (const file of files) {
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
for (const run of data.runs || []) {
// Collect Rules
(run.tool.driver.rules || []).forEach(r => rulesSet.add(r.id));
(run.tool.extensions || []).forEach(ext => {
(ext.rules || []).forEach(r => rulesSet.add(r.id));
});
// Collect Results
for (const res of run.results || []) {
totalAlerts++;
const loc = (res.locations && res.locations[0]?.physicalLocation) || {};
findings.push({
id: res.ruleId,
level: res.level || 'warning',
path: loc.artifactLocation?.uri || 'Global',
line: loc.region?.startLine || '-',
message: res.message?.text || 'No description'
});
}
// Track Coverage (Deduplicated)
(run.artifacts || []).forEach(art => {
const uri = art.location?.uri || '';
if (uri) artifactUris.add(uri);
});
}
}
this.results.artifactUris = Array.from(artifactUris).sort();
this.results.coverage.actions = this.results.artifactUris.filter(u => u.startsWith('.github/workflows/')).length;
this.results.coverage.js = this.results.artifactUris.filter(u => u.endsWith('.js')).length;
this.results.coverage.ts = this.results.artifactUris.filter(u => u.endsWith('.ts')).length;
this.results.codeql.alertCount = totalAlerts;
this.results.codeql.rulesCount = rulesSet.size;
this.results.codeql.findings = findings;
if (totalAlerts > 0) this.results.codeql.status = 'INFO';
}
async parseSnyk() {
const jsonPath = 'snyk_result.json';
if (!fs.existsSync(jsonPath)) return;
try {
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
const projects = Array.isArray(data) ? data : [data];
let vulnTotal = 0;
let findings = [];
for (const proj of projects) {
const vulns = proj.vulnerabilities || [];
vulnTotal += vulns.length;
vulns.forEach(v => {
findings.push({
pkg: `${v.packageName}@${v.version}`,
severity: v.severity,
title: v.title,
url: v.url,
fixedIn: Array.isArray(v.fixedIn) ? v.fixedIn.join(', ') : (v.fixedIn || 'N/A')
});
});
}
this.results.snyk.vulnCount = vulnTotal;
this.results.snyk.findings = findings;
if (vulnTotal > 0) this.results.snyk.status = 'WARN';
} catch (e) {
console.error('Error parsing Snyk JSON:', e.message);
}
}
async parseGitleaks() {
const files = this.globFiles('.', 'results.sarif');
if (files.length === 0) return;
try {
const data = JSON.parse(fs.readFileSync(files[0], 'utf8'));
let leaks = 0;
let findings = [];
for (const run of data.runs || []) {
for (const res of run.results || []) {
leaks++;
findings.push({
id: res.ruleId,
message: res.message.text,
path: res.locations[0]?.physicalLocation?.artifactLocation?.uri || 'Unknown'
});
}
}
this.results.gitleaks.leaksCount = leaks;
this.results.gitleaks.findings = findings;
if (leaks > 0) this.results.gitleaks.status = 'FAIL';
} catch (e) {
console.error('Error parsing Gitleaks SARIF:', e.message);
}
}
async parseTrivy() {
const jsonPath = 'trivy_result.json';
if (!fs.existsSync(jsonPath)) return;
try {
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
let misconfigs = 0;
let findings = [];
(data.Results || []).forEach(res => {
(res.Misconfigurations || []).forEach(m => {
misconfigs++;
findings.push({
id: m.ID,
severity: m.Severity,
title: m.Title,
message: m.Message,
status: m.Status,
target: res.Target
});
});
});
this.results.trivy.misconfigCount = misconfigs;
this.results.trivy.findings = findings;
if (misconfigs > 0) this.results.trivy.status = 'WARN';
} catch (e) {
console.error('Error parsing Trivy JSON:', e.message);
}
}
generateTable(type, t) {
let files = [];
if (type === 'actions') files = this.results.artifactUris.filter(u => u.startsWith('.github/workflows/'));
else if (type === 'js') files = this.results.artifactUris.filter(u => u.endsWith('.js'));
else if (type === 'ts') files = this.results.artifactUris.filter(u => u.endsWith('.ts'));
if (files.length === 0) return `> ${t.noFiles}\n`;
let table = `| ${t.module} | ${t.location} | ${t.status} |\n| :--- | :--- | :--- |\n`;
files.forEach(f => {
const filename = path.basename(f);
table += `| \`${filename}\` | \`${f}\` | ${t.auditedIcon} |\n`;
});
return table;
}
// --- Renderers ---
generateMarkdown(localeKey) {
const { codeql, snyk, gitleaks, coverage } = this.results;
const t = this.locales[localeKey];
// Calculate Grade
let grade = 'A+';
let gradeColor = 'success';
if (gitleaks.status === 'FAIL') { grade = 'D'; gradeColor = 'red'; }
else if (snyk.vulnCount > 10 || this.results.trivy.misconfigCount > 5) { grade = 'C'; gradeColor = 'orange'; }
else if (snyk.vulnCount > 0 || codeql.alertCount > 0 || this.results.trivy.misconfigCount > 0) { grade = 'B'; gradeColor = 'blue'; }
const badge = (label, value, color) => `![${label}](https://img.shields.io/badge/${label.replace(/ /g, '_')}-${value}-${color}?style=for-the-badge)`;
let md = `# ${t.title}\n\n`;
md += `${t.switcher}\n\n`;
md += `${badge(t.grade.replace(/ /g, '_'), grade, gradeColor)}\n\n`;
md += `${t.important}\n\n`;
md += `| ${t.auditTime} | ${t.runId} | ${t.env} |\n`;
md += `| :--- | :--- | :--- |\n`;
md += `| \`${this.auditTime}\` | [#${this.runId}](${this.runUrl}) | \`GitHub CI/CD\` |\n\n`;
md += `---\n\n## ${t.dashboard}\n\n`;
md += `| ${t.tool} | ${t.status} | ${t.findings} |\n`;
md += `| :--- | :--- | :--- |\n`;
md += `| **Credential Leak (Gitleaks)** | ${this.getBadge(gitleaks.status)} | \`${gitleaks.leaksCount}\` ${t.leaks} |\n`;
md += `| **Dependency Scan (Snyk)** | ${this.getBadge(snyk.status)} | \`${snyk.vulnCount}\` ${t.vulns} |\n`;
md += `| **Static Analysis (CodeQL)** | ${this.getBadge(codeql.status)} | \`${codeql.alertCount}\` ${t.alerts} |\n`;
md += `| **Container Scan (Trivy)** | ${this.getBadge(this.results.trivy.status)} | \`${this.results.trivy.misconfigCount}\` ${t.findings} |\n\n`;
md += `---\n\n## ${t.coverageTitle}\n\n`;
md += `| ${t.module} | ${t.auditedFiles} | ${t.coverage} |\n`;
md += `| :--- | :---: | :---: |\n`;
md += `| **GitHub Actions** | \`${coverage.actions}\` | ✨ **100%** |\n`;
md += `| **JavaScript (Frontend)** | \`${coverage.js}\` | ✨ **100%** |\n`;
md += `| **TypeScript (Backend)** | \`${coverage.ts}\` | ✨ **100%** |\n\n`;
md += `---\n\n## ${t.detailedFindings}\n\n`;
// Gitleaks Section
md += `### ${t.gitleaksTitle}\n`;
md += `${t.gitleaksDesc} ${t.gitleaksScope}\n\n`;
if (gitleaks.findings.length > 0) {
md += `| ${t.ruleId} | ${t.location} | ${t.description} |\n`;
md += `| :--- | :--- | :--- |\n`;
gitleaks.findings.forEach(f => {
md += `| \`${f.id}\` | \`${f.path}\` | ${f.message} |\n`;
});
} else {
md += `${t.gitleaksSafe}\n`;
}
// Trivy Section
md += `\n### ${t.trivyTitle}\n`;
md += `${t.trivyDesc}\n\n`;
if (this.results.trivy.findings.length > 0) {
md += `| ${t.ruleId} | ${t.severity} | ${t.location} | ${t.description} |\n`;
md += `| :--- | :---: | :--- | :--- |\n`;
this.results.trivy.findings.forEach(f => {
const icon = f.severity === 'CRITICAL' ? '🔴' : (f.severity === 'HIGH' ? '🟠' : '🟡');
md += `| \`${f.id}\` | ${icon} ${f.severity} | \`${f.target}\` | ${f.title}: ${f.message} |\n`;
});
} else {
md += `${t.trivySafe}\n`;
}
// Snyk Section
md += `\n### ${t.snykTitle}\n`;
if (snyk.findings.length > 0) {
md += `| ${t.package} | ${t.severity} | ${t.description} | ${t.fixPlan} |\n`;
md += `| :--- | :---: | :--- | :--- |\n`;
snyk.findings.forEach(f => {
const icon = f.severity === 'critical' ? '🔴' : (f.severity === 'high' ? '🟠' : '🟡');
md += `| \`${f.pkg}\` | ${icon} ${f.severity} | [${f.title}](${f.url}) | ${f.fixedIn === 'N/A' ? 'No fix' : `Upgrade to \`${f.fixedIn}\``} |\n`;
});
} else {
md += `${t.snykSafe}\n`;
}
// CodeQL Section
md += `\n### ${t.codeqlTitle}\n`;
if (codeql.findings.length > 0) {
md += `${t.codeqlSummary}\n- **${t.rulesChecked}**: \`${codeql.rulesCount}\`\n- **${t.totalAlerts}**: \`${codeql.alertCount}\`\n\n`;
md += `| ${t.ruleId} | ${t.level} | ${t.location} | ${t.description} |\n`;
md += `| :--- | :---: | :--- | :--- |\n`;
codeql.findings.forEach(f => {
const icon = f.level === 'error' ? '🔴' : (f.level === 'warning' ? '🟠' : '🔵');
const prefix = f.id.split('/')[0];
const langMap = {
'js': 'javascript',
'actions': 'github-actions',
'cpp': 'cpp',
'cs': 'csharp',
'go': 'go',
'java': 'java',
'py': 'python',
'rb': 'ruby',
'swift': 'swift'
};
const langPath = langMap[prefix] || 'javascript';
md += `| [${f.id}](https://codeql.github.com/codeql-query-help/${langPath}/${f.id.replace(/\//g, '-')}/) | ${icon} ${f.level} | \`${f.path}:${f.line}\` | ${f.message} |\n`;
});
} else {
md += `${t.codeqlSafe}\n`;
}
// Audited Files List
md += `\n### ${t.auditedList}\n`;
md += `<details>\n<summary><b>GitHub Actions (${this.results.coverage.actions})</b></summary>\n\n`;
md += this.generateTable('actions', t);
md += `\n</details>\n\n`;
md += `<details>\n<summary><b>JavaScript (${this.results.coverage.js})</b></summary>\n\n`;
md += this.generateTable('js', t);
md += `\n</details>\n\n`;
md += `<details>\n<summary><b>TypeScript (${this.results.coverage.ts})</b></summary>\n\n`;
md += this.generateTable('ts', t);
md += `\n</details>\n\n`;
// Action Guide
md += `--- \n\n## ${t.guideTitle}\n\n`;
md += `${t.guideDesc}\n`;
md += `${t.guideStep1}\n`;
md += `${t.guideStep2}\n`;
md += `${t.guideStep3}\n\n`;
md += `--- \n\n${t.footer}`;
return md;
}
// --- Helpers ---
getBadge(status) {
if (status === 'PASS') return '![Pass](https://img.shields.io/badge/Status-PASS-success?style=for-the-badge)';
if (status === 'WARN' || status === 'INFO') return '![Warning](https://img.shields.io/badge/Status-NOTICE-orange?style=for-the-badge)';
return '![Fail](https://img.shields.io/badge/Status-FAIL-red?style=for-the-badge)';
}
globFiles(dir, ext) {
let results = [];
const list = fs.readdirSync(dir);
for (const file of list) {
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat && stat.isDirectory()) {
results = results.concat(this.globFiles(fullPath, ext));
} else if (file.endsWith(ext)) {
results.push(fullPath);
}
}
return results;
}
async run() {
console.log('--- Security Report Generation Started ---');
await this.parseCodeQL();
await this.parseSnyk();
await this.parseGitleaks();
await this.parseTrivy();
for (const localeKey of Object.keys(this.locales)) {
const locale = this.locales[localeKey];
const markdown = this.generateMarkdown(localeKey);
fs.writeFileSync(locale.filename, markdown);
console.log(`Report generated successfully at ${locale.filename}`);
}
}
}
new SecurityReport().run().catch(err => {
console.error('Report generation failed:', err);
process.exit(1);
});
+142
View File
@@ -0,0 +1,142 @@
name: Security Scan
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
jobs:
scan:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
actions: read
env:
SECURITY_SNYK_TOKEN: ${{ secrets.SECURITY_SNYK_TOKEN }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Initialize CodeQL
if: env.ACT != 'true'
continue-on-error: true
uses: github/codeql-action/init@v4
with:
languages: javascript-typescript, actions
build-mode: none
queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
if: env.ACT != 'true'
continue-on-error: true
uses: github/codeql-action/analyze@v4
with:
upload: true
output: sarif-results
- name: Install Gitleaks
if: env.ACT != 'true'
continue-on-error: true
run: |
GITLEAKS_VERSION="8.28.0"
curl -sSL -o gitleaks.tar.gz "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
tar -xzf gitleaks.tar.gz gitleaks
chmod +x gitleaks
sudo mv gitleaks /usr/local/bin/gitleaks
- name: Secret Detection
if: env.ACT != 'true'
continue-on-error: true
run: |
gitleaks git . --report-format sarif --report-path results.sarif --no-banner || true
- name: Install Project Dependencies
if: env.SECURITY_SNYK_TOKEN != ''
env:
SECURITY_PACKAGE: ${{ vars.SECURITY_PACKAGE || '' }}
run: |
echo "Preparing dependency lock files for security scanning..."
if [ -z "$SECURITY_PACKAGE" ]; then
echo "SECURITY_PACKAGE is empty, installing in root..."
npm install --package-lock-only
else
echo "SECURITY_PACKAGE is set to: $SECURITY_PACKAGE"
# Split by comma and install
IFS=',' read -ra PACKAGES <<< "$SECURITY_PACKAGE"
for pkg in "${PACKAGES[@]}"; do
if [ -d "$pkg" ]; then
echo "Installing in "$pkg"..."
npm install --prefix "$pkg" --package-lock-only
else
echo "Warning: Directory $pkg not found, skipping."
fi
done
fi
- name: Dependency Scan
id: snyk
if: env.SECURITY_SNYK_TOKEN != ''
continue-on-error: true
run: |
npm install -g snyk
snyk auth ${{ secrets.SECURITY_SNYK_TOKEN }}
snyk test --all-projects --json-file-output=snyk_result.json > snyk_result.txt || true
env:
SECURITY_SNYK_TOKEN: ${{ secrets.SECURITY_SNYK_TOKEN }}
- name: Check for Dockerfile
id: check_docker
run: |
if [ -f "Dockerfile" ]; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Container Security Scan (Trivy)
if: steps.check_docker.outputs.exists == 'true'
continue-on-error: true
run: |
VERSION="0.56.1"
echo "Installing Trivy $VERSION..."
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin "v$VERSION"
trivy config . --format json --output trivy_result.json --severity CRITICAL,HIGH || true
- name: Generate Security Report
run: |
# Gitleaks typically produces results.sarif if configured or by default in some versions
# We'll ensure it exists for our reporter
node .github/scripts/security.cjs
# Also append to step summary for immediate visibility in GHA UI
cat security-report.md >> $GITHUB_STEP_SUMMARY
echo -e "\n---\n" >> $GITHUB_STEP_SUMMARY
cat security-report-cn.md >> $GITHUB_STEP_SUMMARY
- name: Upload Gitleaks Results to GitHub Security
uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: results.sarif
category: gitleaks
- name: Upload Security Report Artifacts
if: always()
uses: actions/upload-artifact@v6
with:
name: security-report
if-no-files-found: ignore
path: |
security-report.md
security-report-cn.md
snyk_result.txt
snyk_result.json
trivy_result.json
results.sarif
sarif-results/*.sarif
+14 -8
View File
@@ -13,16 +13,22 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0
- run: |
git remote add upstream https://github.com/shuaiplus/nodewarden.git || true
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Sync main from upstream
run: |
git remote add upstream https://github.com/shuaiplus/NodeWarden.git || true
git fetch upstream
git checkout main
git merge upstream/main
# 强制让当前分支完全等于 upstream
git reset --hard upstream/main
# 强制推送
git push origin main --force
- name: Push synced main
run: |
git push origin main
+2 -1
View File
@@ -38,4 +38,5 @@ npm-debug.log*
# Package lock (optional - remove if you want to commit it)
# package-lock.json
tmp/
tmp/
.tmp/
+88 -78
View File
@@ -3,120 +3,128 @@
</p>
<p align="center">
运行在 Cloudflare Workers Bitwarden 第三方服务端,兼容官方客户
运行在 Cloudflare Workers 上的第三方 Bitwarden 兼容服务端。
</p>
[![Powered by Cloudflare](https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white)](https://workers.cloudflare.com/)
[![License: LGPL-3.0](https://img.shields.io/badge/License-LGPL--3.0-2ea44f)](./LICENSE)
[![Deploy to Cloudflare Workers](https://img.shields.io/badge/Deploy%20to-Cloudflare%20Workers-F38020?logo=cloudflare&logoColor=white)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/NodeWarden)
[![Latest Release](https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag)](https://github.com/shuaiplus/NodeWarden/releases/latest)
[![Sync Upstream](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg)](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
[更新日志](./RELEASE_NOTES.md) [提交问题](https://github.com/shuaiplus/NodeWarden/issues/new/choose) [最新发布](https://github.com/shuaiplus/NodeWarden/releases/latest)
English[`README_EN.md`](./README_EN.md)
[更新日志](./RELEASE_NOTES.md) | [提交问题](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [最新发布](https://github.com/shuaiplus/NodeWarden/releases/latest)
English: [`README_EN.md`](./README_EN.md)
> **免责声明**
> 本项目仅供学习交流使用。我们不对任何数据丢失负责,强烈建议定期备份的密码库。
> 本项目与 Bitwarden 官方无关,请向 Bitwarden 官方反馈问题。
> 本项目仅供学习交流使用,请定期备份的密码库。
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
---
## 与 Bitwarden 官方服务端能力对比
| 能力 | Bitwarden | NodeWarden | 说明 |
| 能力 | Bitwarden | NodeWarden | 说明 |
|---|---|---|---|
| Web Vault(登录/笔记/卡片/身份) | ✅ | ✅ | 网页端密码库管理页面 |
| 文件夹 / 收藏 | ✅ | ✅ | 常用管理能力可用 |
| 全量同步 `/api/sync` | ✅ | ✅ | 已做兼容与性能优化 |
| 附件上传/下载 | ✅ | ✅ | 基于 Cloudflare R2 |
| 导入功能 | ✅ | ✅ | 覆盖常见导入路径 |
| 网站图标代理 | | ✅ | 通过 `/icons/{hostname}/icon.png` |
| passkey、TOTP字段 | ❌ | ✅ |官方需要会员,我们的不需要 |
| Send | ✅ | ✅ | 已支持文本 Send 与文件 Send |
| 多用户 | ✅ | ✅ | 完整的用户管理,邀请机制 |
| 组织/集合/成员权限 | ✅ | ❌ | 没必要实现 |
| 登录 2FATOTP/WebAuthn/Duo/Email | ✅ | ⚠️ 部分支持 | 仅支持 TOTP(通过 `TOTP_SECRET` |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 没必要实现 |
| 紧急访问 | ✅ | ❌ | 没必要实现 |
| 管理后台 / 计费订阅 | ✅ | ❌ | 纯免费 |
| 推送通知完整链路 | ✅ | ❌ | 没必要实现 |
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
| 全量同步 `/api/sync` | ✅ | ✅ | 已针对官方客户端做兼容优化 |
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
| Send | ✅ | ✅ | 支持文本与文件 Send |
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
| **云端备份中心** | | ✅ | **支持 WebDAV / E3 定时备份** |
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
| TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 |
| 多用户 | ✅ | ✅ | 支持邀请码注册 |
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 实现 |
| 登录 2FA | ✅ | ⚠️ 部分支持 | 当前仅支持用户级 TOTP |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 实现 |
## 测试情况:
- ✅ Windows 客户端(v2026.1.0
- ✅ 手机 Appv2026.1.0
- ✅ 浏览器扩展(v2026.1.0
- ✅ Linux 客户端(v2026.1.0
- ⬜ macOS 客户端(未测试)
---
# 快速开始
## 已测试客户端
### 一键部署
- ✅ Windows 桌面端
- ✅ 手机 App
- ✅ 浏览器扩展
- ✅ Linux 桌面端
- ⚠️ macOS 桌面端尚未完整验证
**部署步骤:**
---
1. 首先Fork本仓库,命名为**NodeWarden**
2. 点击下面的一键部署按钮,修改项目名称为**NodeWarden2**,修改**JWT_SECRET**成32为随机字符串
3. [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
4. 部署完成后,同一页面打开workers设置,将**Git存储库**断开连接
5. 同一位置,**Git存储库**链接至第一步Fork的仓库
## 网页部署
**同步上游(更新):**
- 手动:Github打开你Fork的私人仓库,看到顶部同步提示时,点击 “Sync fork”。
- 自动:进入你的 Fork 仓库 → Actions,点击 “I understand my workflows, go ahead and enable them”,每天凌晨三点自动同步至上游
### CLI 部署
1. Fork 本仓库。若本项目对你有帮助,欢迎点个 Star。
2. 打开 [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) ➜ `Continue with GitHub` ➜ 选择你 Fork 后的仓库(`NodeWarden`)➜ 下一步 ➜ (默认使用 R2 存储;若未开通,可用 KV 来代替,将**部署命令**改为 `npm run deploy:kv`)➜ 部署 ➜ 打开生成的链接
| 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|---|---|---|---|
| R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
| KV | 不需要 | 25 MiBCloudflare限制) | 1 GB |
> [!TIP]
> 同步方法(更新仓库):
>- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork``Update branch`
>- 自动:进入你的 Fork 仓库 ➜ `Actions``Sync upstream``Enable workflow`,会在每天凌晨 3 点自动同步上游。
## CLI 部署
```powershell
# 先把仓库拉到本地
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
# 安装依赖
npm install
# Cloudflare CLI 登录
npx wrangler login
# 创建云资源(D1 + R2
npx wrangler d1 create nodewarden-db
npx wrangler r2 bucket create nodewarden-attachments
# 默认:R2 模式
npm run deploy
# 部署
npm run deploy
# 可选:KV 模式
npm run deploy:kv
# 需更新时重新拉取仓库,重新部署即可,无需创建云资源
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
npm run deploy
```
---
## 本地开发
这是一个 Cloudflare Workers 的 TypeScript 项目(Wrangler)。
```bash
npm install
# 本地开发
npm run dev
npm run dev:kv
```
---
## 常见问题
**Q: 如何备份数据?**
A: 在客户端中选择「导出密码库」,保存 JSON 文件。
**Q: 忘记主密码怎么办?**
A: 无法恢复,这是端到端加密的特性。建议妥善保管主密码。
**Q: 可以多人使用吗?**
A: 支持。第一个注册的用户自动成为管理员,管理员可在管理页面生成邀请码,其他用户凭邀请码注册。
---
## 云端备份说明
- 远程备份支持 **WebDAV****E3**
- 勾选“包含附件”后:
- ZIP 内仍只包含 `db.json``manifest.json`
- 真实附件单独存放在 `attachments/`
- 后续备份会按稳定 blob 名复用已有附件,不会每次全量重传
- 远程还原时:
- 会从 `attachments/` 目录按需读取附件
- 缺失的附件会被安全跳过
- 被跳过的附件不会在恢复后的数据库中留下脏记录
---
## 导入 / 导出
当前支持的导入来源包括:
- Bitwarden JSON
- Bitwarden CSV
- Bitwarden 密码库 + 附件 ZIP
- NodeWarden JSON
- 网页导入器里可见的多种浏览器 / 密码管理器格式
当前支持的导出方式包括:
- Bitwarden JSON
- Bitwarden 加密 JSON
- 带附件的 ZIP 导出
- NodeWarden JSON 系列
- 备份中心中的实例级完整手动导出
---
## 开源协议
LGPL-3.0 License
@@ -125,10 +133,12 @@ LGPL-3.0 License
## 致谢
- [Bitwarden](https://bitwarden.com/) - 原始设计客户端
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务实现参考
- [Bitwarden](https://bitwarden.com/) - 原始设计客户端
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务实现参考
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left)](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
+77 -75
View File
@@ -3,119 +3,122 @@
</p>
<p align="center">
A third-party Bitwarden server running on Cloudflare Workers, fully compatible with official clients.
A third-party Bitwarden-compatible server running on Cloudflare Workers.
</p>
[![Powered by Cloudflare](https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white)](https://workers.cloudflare.com/)
[![License: LGPL-3.0](https://img.shields.io/badge/License-LGPL--3.0-2ea44f)](./LICENSE)
[![Deploy to Cloudflare Workers](https://img.shields.io/badge/Deploy%20to-Cloudflare%20Workers-F38020?logo=cloudflare&logoColor=white)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/NodeWarden)
[![Latest Release](https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag)](https://github.com/shuaiplus/NodeWarden/releases/latest)
[![Sync Upstream](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg)](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
[Release Notes](./RELEASE_NOTES.md) [Report an Issue](https://github.com/shuaiplus/NodeWarden/issues/new/choose) [Latest Release](https://github.com/shuaiplus/NodeWarden/releases/latest)
[Release Notes](./RELEASE_NOTES.md) | [Report an Issue](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [Latest Release](https://github.com/shuaiplus/NodeWarden/releases/latest)
中文文档:[`README.md`](./README.md)
English: [`README.md`](./README.md)
> **Disclaimer**
> This project is for learning and communication purposes only. We are not responsible for any data loss; regular vault backups are strongly recommended.
> This project is not affiliated with Bitwarden. Please do not report issues to the official Bitwarden team.
> This project is for learning and communication purposes only. Please back up your vault regularly.
> This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team.
---
## Feature Comparison Table (vs Official Bitwarden Server)
## Feature Comparison with Official Bitwarden Server
| Capability | Bitwarden | NodeWarden | Notes |
| Capability | Bitwarden | NodeWarden | Notes |
|---|---|---|---|
| Web Vault (logins/notes/cards/identities) | ✅ | ✅ | Web-based vault management UI |
| Folders / Favorites | ✅ | ✅ | Common vault organization supported |
| Full sync `/api/sync` | ✅ | ✅ | Compatibility and performance optimized |
| Attachment upload/download | ✅ | ✅ | Backed by Cloudflare R2 |
| Import flow (common clients) | ✅ | ✅ | Common import paths covered |
| Website icon proxy | | ✅ | Via `/icons/{hostname}/icon.png` |
| passkey、TOTP fields | ❌ | ✅ | Official service requires premium; NodeWarden does not |
| Multi-user | ✅ | ✅ | Full user management with invitation mechanism |
| Send | ✅ | ✅ | Text Send and File Send are supported |
| Organizations / Collections / Member roles | ✅ | ❌ | Not necessary to implement |
| Login 2FA (TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ Partial | TOTP-only via `TOTP_SECRET` |
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not necessary to implement |
| Emergency access | ✅ | ❌ | Not necessary to implement |
| Admin console / Billing & subscription | ✅ | ❌ | Free only |
| Full push notification pipeline | ✅ | ❌ | Not necessary to implement |
## Tested clients / platforms
- ✅ Windows desktop client (v2026.1.0)
- ✅ Mobile app (v2026.1.0)
- ✅ Browser extension (v2026.1.0)
- ✅ Linux desktop client (v2026.1.0)
- ⬜ macOS desktop client (not tested)
| Web Vault | ✅ | ✅ | **Original Web Vault interface** |
| Full sync `/api/sync` | ✅ | ✅ | Optimized for official clients |
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
| Send | ✅ | ✅ | Supports both text and file Sends |
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
| **Cloud Backup Center** | | ✅ | **Supports scheduled backups with WebDAV / E3** |
| Password hint (web) | ⚠️ Limited | ✅ | **No email required** |
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
| Multi-user | ✅ | ✅ | Invite-based registration |
| Organizations / Collections / Member roles | ✅ | ❌ | Not implemented |
| Login 2FA | ✅ | ⚠️ Partial | Currently only user-level TOTP |
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not implemented |
---
# Quick start
## Tested Clients
### One-click deploy
- ✅ Windows desktop client
- ✅ Mobile app
- ✅ Browser extension
- ✅ Linux desktop client
- ⚠️ macOS desktop client not fully verified
**Deploy steps:**
---
1. Fork this repository and name it **NodeWarden**.
2. Click the deploy button below, rename the project to **NodeWarden2**, and set **JWT_SECRET** to a 32-character random string.
3. [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
4. After deployment, open the Workers settings on the same page and disconnect the **Git repository**.
5. From the same location, reconnect the **Git repository** to the fork you created in step 1.
## Web Deploy
**Sync upstream (update):**
- Manual: Open your forked repository on GitHub and click **Sync fork** when the sync prompt appears at the top.
- Automatic: Go to your fork → Actions, click "I understand my workflows, go ahead and enable them". The repository will auto-sync with upstream every day at 3 AM.
1. Fork this repository. If this project helps you, please consider giving it a Star.
2. Open [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) -> `Continue with GitHub` -> select your forked repository (`NodeWarden`) -> `Next` -> deploy.
R2 is used by default. If R2 is unavailable for your account, you can use KV instead by changing the **deploy command** to `npm run deploy:kv`.
### CLI deploy
| Storage | Card required | Single attachment / Send file limit | Free tier |
|---|---|---|---|
| R2 | Yes | 100 MB (soft limit, can be adjusted) | 10 GB |
| KV | No | 25 MiB (Cloudflare limit) | 1 GB |
> [!TIP]
> How to keep your fork updated:
> - Manual: open your fork on GitHub, click `Sync fork`, then `Update branch`
> - Automatic: go to your fork -> `Actions` -> `Sync upstream` -> `Enable workflow`; it will sync upstream automatically every day at 3 AM
## CLI Deploy
```powershell
# Clone repository
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
# Install dependencies
npm install
# Cloudflare CLI login
npx wrangler login
# Create cloud resources (D1 + R2)
npx wrangler d1 create nodewarden-db
npx wrangler r2 bucket create nodewarden-attachments
# Default: R2 mode
npm run deploy
# Deploy
npm run deploy
# Optional: KV mode
npm run deploy:kv
# To update later: re-clone and re-deploy — no need to recreate cloud resources
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
npm run deploy
```
---
## Local development
This repo is a Cloudflare Workers TypeScript project (Wrangler).
```bash
npm install
# Local development
npm run dev
npm run dev:kv
```
---
## FAQ
## Cloud Backup Notes
**Q: How do I back up my data?**
A: Use **Export vault** in your client and save the JSON file.
- Remote backup supports **WebDAV** and **E3**
- When `Include attachments` is enabled:
- the ZIP still contains only `db.json` and `manifest.json`
- real attachment files are stored separately under `attachments/`
- later backups reuse existing attachments by stable blob name instead of uploading everything again
- During remote restore:
- required attachment files are loaded from `attachments/`
- missing attachments are skipped safely
- skipped attachments do not leave broken rows in the restored database
**Q: What if I forget the master password?**
A: It cant be recovered (end-to-end encryption). Keep it safe.
---
**Q: Can multiple people use it?**
A: Yes. The first registered user becomes the admin. The admin can generate invite codes from the admin panel, and other users register with those codes.
## Import / Export
Current supported import sources include:
- Bitwarden JSON
- Bitwarden CSV
- Bitwarden vault + attachments ZIP
- NodeWarden JSON
- Multiple browser / password-manager formats visible in the web import selector
Current supported export formats include:
- Bitwarden JSON
- Bitwarden encrypted JSON
- ZIP export with attachments
- NodeWarden JSON variants
- Full manual instance export from the backup center
---
@@ -131,9 +134,8 @@ LGPL-3.0 License
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - server implementation reference
- [Cloudflare Workers](https://workers.cloudflare.com/) - serverless platform
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left)](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
+8
View File
@@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT,
master_password_hint TEXT,
master_password_hash TEXT NOT NULL,
key TEXT NOT NULL,
private_key TEXT,
@@ -24,6 +25,7 @@ CREATE TABLE IF NOT EXISTS users (
security_stamp TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'active',
verify_devices INTEGER NOT NULL DEFAULT 1,
totp_secret TEXT,
totp_recovery_code TEXT,
created_at TEXT NOT NULL,
@@ -50,10 +52,12 @@ CREATE TABLE IF NOT EXISTS ciphers (
key TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
archived_at TEXT,
deleted_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at);
CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at);
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at);
CREATE TABLE IF NOT EXISTS folders (
@@ -143,6 +147,10 @@ CREATE TABLE IF NOT EXISTS devices (
device_identifier TEXT NOT NULL,
name TEXT NOT NULL,
type INTEGER NOT NULL,
session_stamp TEXT,
encrypted_user_key TEXT,
encrypted_public_key TEXT,
encrypted_private_key TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, device_identifier),
+151 -44
View File
@@ -1,15 +1,21 @@
{
"name": "nodewarden",
"version": "1.1.0",
"version": "1.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nodewarden",
"version": "1.1.0",
"version": "1.4.1",
"license": "LGPL-3.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@noble/hashes": "^2.0.1",
"@tanstack/react-query": "^5.90.21",
"@zip.js/zip.js": "^2.8.22",
"fflate": "^0.8.2",
"lucide-preact": "^0.575.0",
"preact": "^10.28.4",
"qrcode-generator": "^2.0.4",
@@ -22,7 +28,7 @@
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"wrangler": "^4.69.0"
"wrangler": "^4.71.0"
}
},
"node_modules/@babel/code-frame": {
@@ -383,14 +389,14 @@
}
},
"node_modules/@cloudflare/unenv-preset": {
"version": "2.14.0",
"resolved": "https://registry.npmmirror.com/@cloudflare/unenv-preset/-/unenv-preset-2.14.0.tgz",
"integrity": "sha512-XKAkWhi1nBdNsSEoNG9nkcbyvfUrSjSf+VYVPfOto3gLTZVc3F4g6RASCMh6IixBKCG2yDgZKQIHGKtjcnLnKg==",
"version": "2.15.0",
"resolved": "https://registry.npmmirror.com/@cloudflare/unenv-preset/-/unenv-preset-2.15.0.tgz",
"integrity": "sha512-EGYmJaGZKWl+X8tXxcnx4v2bOZSjQeNI5dWFeXivgX9+YCT69AkzHHwlNbVpqtEUTbew8eQurpyOpeN8fg00nw==",
"dev": true,
"license": "MIT OR Apache-2.0",
"peerDependencies": {
"unenv": "2.0.0-rc.24",
"workerd": "^1.20260218.0"
"workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0"
},
"peerDependenciesMeta": {
"workerd": {
@@ -399,9 +405,9 @@
}
},
"node_modules/@cloudflare/workerd-darwin-64": {
"version": "1.20260305.0",
"resolved": "https://registry.npmmirror.com/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260305.0.tgz",
"integrity": "sha512-chhKOpymo0Eh9J3nymrauMqKGboCc4uz/j0gA1G4gioMnKsN2ZDKJ+qjRZDnCoVGy8u2C4pxlmyIfsXCAfIzhQ==",
"version": "1.20260301.1",
"resolved": "https://registry.npmmirror.com/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260301.1.tgz",
"integrity": "sha512-+kJvwociLrvy1JV9BAvoSVsMEIYD982CpFmo/yMEvBwxDIjltYsLTE8DLi0mCkGsQ8Ygidv2fD9wavzXeiY7OQ==",
"cpu": [
"x64"
],
@@ -416,9 +422,9 @@
}
},
"node_modules/@cloudflare/workerd-darwin-arm64": {
"version": "1.20260305.0",
"resolved": "https://registry.npmmirror.com/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260305.0.tgz",
"integrity": "sha512-K9aG2OQk5bBfOP+fyGPqLcqZ9OR3ra6uwnxJ8f2mveq2A2LsCI7ZeGxQiAj75Ti80ytH/gJffZIx4Np2JtU3aQ==",
"version": "1.20260301.1",
"resolved": "https://registry.npmmirror.com/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260301.1.tgz",
"integrity": "sha512-PPIetY3e67YBr9O4UhILK8nbm5TqUDl14qx4rwFNrRSBOvlzuczzbd4BqgpAtbGVFxKp1PWpjAnBvGU/OI/tLQ==",
"cpu": [
"arm64"
],
@@ -433,9 +439,9 @@
}
},
"node_modules/@cloudflare/workerd-linux-64": {
"version": "1.20260305.0",
"resolved": "https://registry.npmmirror.com/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260305.0.tgz",
"integrity": "sha512-tt7XUoIw/cYFeGbkPkcZ6XX1aZm26Aju/4ih+DXxOosbBeGshFSrNJDBfAKKOvkjsAZymJ+WWVDBU+hmNaGfwA==",
"version": "1.20260301.1",
"resolved": "https://registry.npmmirror.com/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260301.1.tgz",
"integrity": "sha512-Gu5vaVTZuYl3cHa+u5CDzSVDBvSkfNyuAHi6Mdfut7TTUdcb3V5CIcR/mXRSyMXzEy9YxEWIfdKMxOMBjupvYQ==",
"cpu": [
"x64"
],
@@ -450,9 +456,9 @@
}
},
"node_modules/@cloudflare/workerd-linux-arm64": {
"version": "1.20260305.0",
"resolved": "https://registry.npmmirror.com/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260305.0.tgz",
"integrity": "sha512-72QTkY5EzylmvCZ8ZTrnJ9DctmQsfSof1OKyOWqu/pv/B2yACfuPMikq8RpPxvVu7hhS0ztGP6ZvXz72Htq4Zg==",
"version": "1.20260301.1",
"resolved": "https://registry.npmmirror.com/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260301.1.tgz",
"integrity": "sha512-igL1pkyCXW6GiGpjdOAvqMi87UW0LMc/+yIQe/CSzuZJm5GzXoAMrwVTkCFnikk6JVGELrM5x0tGYlxa0sk5Iw==",
"cpu": [
"arm64"
],
@@ -467,9 +473,9 @@
}
},
"node_modules/@cloudflare/workerd-windows-64": {
"version": "1.20260305.0",
"resolved": "https://registry.npmmirror.com/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260305.0.tgz",
"integrity": "sha512-BA0uaQPOaI2F6mJtBDqplGnQQhpXCzwEMI33p/TnDxtSk9u8CGIfBFuI6uqo8mJ6ijIaPjeBLGOn2CiRMET4qg==",
"version": "1.20260301.1",
"resolved": "https://registry.npmmirror.com/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260301.1.tgz",
"integrity": "sha512-Q0wMJ4kcujXILwQKQFc1jaYamVsNvjuECzvRrTI8OxGFMx2yq9aOsswViE4X1gaS2YQQ5u0JGwuGi5WdT1Lt7A==",
"cpu": [
"x64"
],
@@ -504,6 +510,60 @@
"node": ">=12"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmmirror.com/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmmirror.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.8.1",
"resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz",
@@ -1519,6 +1579,18 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@poppinss/colors": {
"version": "4.1.6",
"resolved": "https://registry.npmmirror.com/@poppinss/colors/-/colors-4.1.6.tgz",
@@ -2075,6 +2147,17 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@zip.js/zip.js": {
"version": "2.8.22",
"resolved": "https://registry.npmmirror.com/@zip.js/zip.js/-/zip.js-2.8.22.tgz",
"integrity": "sha512-0KlzbVR6r8irIX2o3zvUlosBDef62VDl47oUfa1U/qgEs67h4/eGBrX/6HWa1RQbt+J6sAeVmtyFKbTHNdF8qQ==",
"license": "BSD-3-Clause",
"engines": {
"bun": ">=0.7.0",
"deno": ">=1.0.0",
"node": ">=18.0.0"
}
},
"node_modules/babel-plugin-transform-hook-names": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz",
@@ -2413,6 +2496,12 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
@@ -2541,16 +2630,16 @@
}
},
"node_modules/miniflare": {
"version": "4.20260305.0",
"resolved": "https://registry.npmmirror.com/miniflare/-/miniflare-4.20260305.0.tgz",
"integrity": "sha512-jVhtKJtiwaZa3rI+WgoLvSJmEazDsoUmAPYRUmEe2VO6VSbvkhbnDRm+dsPbYRatgNIExwrpqG1rv96jHiSb0w==",
"version": "4.20260301.1",
"resolved": "https://registry.npmmirror.com/miniflare/-/miniflare-4.20260301.1.tgz",
"integrity": "sha512-fqkHx0QMKswRH9uqQQQOU/RoaS3Wjckxy3CUX3YGJr0ZIMu7ObvI+NovdYi6RIsSPthNtq+3TPmRNxjeRiasog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "0.8.1",
"sharp": "^0.34.5",
"undici": "7.18.2",
"workerd": "1.20260305.0",
"workerd": "1.20260301.1",
"ws": "8.18.0",
"youch": "4.1.0-beta.10"
},
@@ -2714,6 +2803,19 @@
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.4"
}
},
"node_modules/regexparam": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/regexparam/-/regexparam-3.0.0.tgz",
@@ -2779,6 +2881,12 @@
"fsevents": "~2.3.2"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz",
@@ -2911,9 +3019,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.21.0",
@@ -3113,12 +3219,13 @@
}
},
"node_modules/workerd": {
"version": "1.20260305.0",
"resolved": "https://registry.npmmirror.com/workerd/-/workerd-1.20260305.0.tgz",
"integrity": "sha512-JkhfCLU+w+KbQmZ9k49IcDYc78GBo7eG8Mir8E2+KVjR7otQAmpcLlsous09YLh8WQ3Bt3Mi6/WMStvMAPukeA==",
"version": "1.20260301.1",
"resolved": "https://registry.npmmirror.com/workerd/-/workerd-1.20260301.1.tgz",
"integrity": "sha512-oterQ1IFd3h7PjCfT4znSFOkJCvNQ6YMOyZ40YsnO3nrSpgB4TbJVYWFOnyJAw71/RQuupfVqZZWKvsy8GO3fw==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"workerd": "bin/workerd"
},
@@ -3126,11 +3233,11 @@
"node": ">=16"
},
"optionalDependencies": {
"@cloudflare/workerd-darwin-64": "1.20260305.0",
"@cloudflare/workerd-darwin-arm64": "1.20260305.0",
"@cloudflare/workerd-linux-64": "1.20260305.0",
"@cloudflare/workerd-linux-arm64": "1.20260305.0",
"@cloudflare/workerd-windows-64": "1.20260305.0"
"@cloudflare/workerd-darwin-64": "1.20260301.1",
"@cloudflare/workerd-darwin-arm64": "1.20260301.1",
"@cloudflare/workerd-linux-64": "1.20260301.1",
"@cloudflare/workerd-linux-arm64": "1.20260301.1",
"@cloudflare/workerd-windows-64": "1.20260301.1"
}
},
"node_modules/wouter": {
@@ -3148,20 +3255,20 @@
}
},
"node_modules/wrangler": {
"version": "4.69.0",
"resolved": "https://registry.npmmirror.com/wrangler/-/wrangler-4.69.0.tgz",
"integrity": "sha512-EmVfIM65I5b4ITHe3Y9R7zQyf4NUBQ1leStakMlWiVR9n6VlDwuEltyQI2l3i0JciDnWyR3uqe+T6C08ivniTQ==",
"version": "4.71.0",
"resolved": "https://registry.npmmirror.com/wrangler/-/wrangler-4.71.0.tgz",
"integrity": "sha512-j6pSGAncOLNQDRzqtp0EqzYj52CldDP7uz/C9cxVrIgqa5p+cc0b4pIwnapZZAGv9E1Loa3tmPD0aXonH7KTkw==",
"dev": true,
"license": "MIT OR Apache-2.0",
"dependencies": {
"@cloudflare/kv-asset-handler": "0.4.2",
"@cloudflare/unenv-preset": "2.14.0",
"@cloudflare/unenv-preset": "2.15.0",
"blake3-wasm": "2.1.5",
"esbuild": "0.27.3",
"miniflare": "4.20260305.0",
"miniflare": "4.20260301.1",
"path-to-regexp": "6.3.0",
"unenv": "2.0.0-rc.24",
"workerd": "1.20260305.0"
"workerd": "1.20260301.1"
},
"bin": {
"wrangler": "bin/wrangler.js",
@@ -3174,7 +3281,7 @@
"fsevents": "~2.3.2"
},
"peerDependencies": {
"@cloudflare/workers-types": "^4.20260305.0"
"@cloudflare/workers-types": "^4.20260226.1"
},
"peerDependenciesMeta": {
"@cloudflare/workers-types": {
+15 -8
View File
@@ -1,19 +1,17 @@
{
"name": "nodewarden",
"version": "1.1.0",
"version": "1.4.1",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus",
"license": "LGPL-3.0",
"main": "src/index.ts",
"type": "module",
"scripts": {
"dev": "npm run web:build && wrangler dev -c wrangler.toml",
"dev:worker": "wrangler dev -c wrangler.toml",
"web:dev": "vite --config webapp/vite.config.ts",
"web:build": "vite build --config webapp/vite.config.ts",
"web:typecheck": "tsc -p webapp/tsconfig.json --noEmit",
"dev": "wrangler dev -c wrangler.toml",
"dev:kv": "wrangler dev -c wrangler.kv.toml",
"build": "vite build --config webapp/vite.config.ts",
"deploy": "npm run build && wrangler deploy"
"deploy": "wrangler deploy",
"deploy:kv": "wrangler deploy -c wrangler.kv.toml"
},
"keywords": [
"bitwarden",
@@ -32,6 +30,9 @@
},
"ATTACHMENTS": {
"description": "R2 bucket for storing file attachments"
},
"ATTACHMENTS_KV": {
"description": "Optional KV namespace fallback for attachment/send-file storage"
}
}
},
@@ -42,10 +43,16 @@
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"wrangler": "^4.69.0"
"wrangler": "^4.71.0"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@noble/hashes": "^2.0.1",
"@tanstack/react-query": "^5.90.21",
"@zip.js/zip.js": "^2.8.22",
"fflate": "^0.8.2",
"lucide-preact": "^0.575.0",
"preact": "^10.28.4",
"qrcode-generator": "^2.0.4",
+1
View File
@@ -0,0 +1 @@
export const APP_VERSION = '1.4.1';
+151
View File
@@ -0,0 +1,151 @@
export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
export const BACKUP_DEFAULT_E3_REGION = 'auto';
export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden';
export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
export const BACKUP_DEFAULT_START_TIME = '03:00';
export type BackupDestinationType = 'e3' | 'webdav';
export interface E3BackupDestination {
endpoint: string;
bucket: string;
region: string;
accessKeyId: string;
secretAccessKey: string;
rootPath: string;
}
export interface WebDavBackupDestination {
baseUrl: string;
username: string;
password: string;
remotePath: string;
}
export type BackupDestinationConfig =
| E3BackupDestination
| WebDavBackupDestination;
export interface BackupRuntimeState {
lastAttemptAt: string | null;
lastAttemptLocalDate: string | null;
lastSuccessAt: string | null;
lastErrorAt: string | null;
lastErrorMessage: string | null;
lastUploadedFileName: string | null;
lastUploadedSizeBytes: number | null;
lastUploadedDestination: string | null;
}
export interface BackupScheduleConfig {
enabled: boolean;
intervalHours: number;
startTime: string;
timezone: string;
retentionCount: number | null;
}
export interface BackupDestinationRecord {
id: string;
name: string;
type: BackupDestinationType;
includeAttachments: boolean;
destination: BackupDestinationConfig;
schedule: BackupScheduleConfig;
runtime: BackupRuntimeState;
}
export interface BackupSettings {
destinations: BackupDestinationRecord[];
}
export function createBackupRandomId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `backup-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
export function createDefaultBackupRuntimeState(): BackupRuntimeState {
return {
lastAttemptAt: null,
lastAttemptLocalDate: null,
lastSuccessAt: null,
lastErrorAt: null,
lastErrorMessage: null,
lastUploadedFileName: null,
lastUploadedSizeBytes: null,
lastUploadedDestination: null,
};
}
export function createDefaultBackupScheduleConfig(timezone: string = BACKUP_DEFAULT_TIMEZONE): BackupScheduleConfig {
return {
enabled: false,
intervalHours: BACKUP_DEFAULT_INTERVAL_HOURS,
startTime: BACKUP_DEFAULT_START_TIME,
timezone,
retentionCount: BACKUP_DEFAULT_RETENTION_COUNT,
};
}
export function createDefaultBackupDestinationConfig(type: BackupDestinationType): BackupDestinationConfig {
if (type === 'e3') {
return {
endpoint: '',
bucket: '',
region: BACKUP_DEFAULT_E3_REGION,
accessKeyId: '',
secretAccessKey: '',
rootPath: BACKUP_DEFAULT_REMOTE_PATH,
};
}
return {
baseUrl: '',
username: '',
password: '',
remotePath: BACKUP_DEFAULT_REMOTE_PATH,
};
}
export function createDefaultBackupDestinationName(type: BackupDestinationType, index: number): string {
if (type === 'e3') return `E3 ${index}`;
return `WebDAV ${index}`;
}
export interface CreateBackupDestinationRecordOptions {
id?: string;
name?: string;
timezone?: string;
}
export function createBackupDestinationRecord(
type: BackupDestinationType,
index: number,
options: CreateBackupDestinationRecordOptions = {}
): BackupDestinationRecord {
return {
id: options.id || createBackupRandomId(),
name: options.name || createDefaultBackupDestinationName(type, index),
type,
includeAttachments: false,
destination: createDefaultBackupDestinationConfig(type),
schedule: createDefaultBackupScheduleConfig(options.timezone || BACKUP_DEFAULT_TIMEZONE),
runtime: createDefaultBackupRuntimeState(),
};
}
export function createDefaultBackupSettings(
timezone: string = BACKUP_DEFAULT_TIMEZONE,
options: { destinationName?: string } = {}
): BackupSettings {
return {
destinations: [
createBackupDestinationRecord('webdav', 1, {
timezone,
name: options.destinationName,
}),
],
};
}
+25 -1
View File
@@ -38,6 +38,24 @@
// Public (unauthenticated) request budget per IP per minute.
// 公开(未认证)接口每 IP 每分钟请求配额。
publicRequestsPerMinute: 60,
// Public read-only request budget per IP per minute.
// 公开只读接口每 IP 每分钟请求配额。
publicReadRequestsPerMinute: 120,
// Sensitive public/auth request budget per IP per minute.
// 敏感公开/认证接口每 IP 每分钟请求配额。
sensitivePublicRequestsPerMinute: 30,
// Password hint lookup budget per IP per minute.
// 密码提示查询接口每 IP 每分钟请求配额。
passwordHintRequestsPerMinute: 1,
// Password hint lookup budget per IP per hour.
// 密码提示查询接口每 IP 每小时请求配额。
passwordHintRequestsPerHour: 3,
// Register endpoint budget per IP per minute.
// 注册接口每 IP 每分钟请求配额。
registerRequestsPerMinute: 5,
// Refresh-token grant budget per IP per minute.
// refresh_token 授权每 IP 每分钟请求配额。
refreshTokenRequestsPerMinute: 30,
// Fixed window size for API rate limiting in seconds.
// API 限流固定窗口大小(秒)。
apiWindowSeconds: 60,
@@ -70,7 +88,7 @@
send: {
// Max file size allowed for Send file uploads.
// Send 文件上传大小上限。
maxFileSizeBytes: 550_502_400,
maxFileSizeBytes: 100 * 1024 * 1024,
// Max days allowed between now and deletion date.
// 允许的最远删除日期(距当前天数)。
maxDeletionDays: 31,
@@ -95,6 +113,12 @@
// In-memory /api/sync response cache TTL (milliseconds).
// /api/sync 内存缓存有效期(毫秒)。
syncResponseTtlMs: 30 * 1000,
// Max size of a single cached /api/sync body in bytes.
// 单个 /api/sync 缓存响应允许的最大字节数。
syncResponseMaxBodyBytes: 512 * 1024,
// Max total in-memory bytes used by /api/sync cache per isolate.
// 每个 isolate 中 /api/sync 缓存允许占用的最大总字节数。
syncResponseMaxTotalBytes: 2 * 1024 * 1024,
// Max in-memory /api/sync cache entries per isolate.
// 每个 isolate 的 /api/sync 最大缓存条目数。
syncResponseMaxEntries: 64,
+453
View File
@@ -0,0 +1,453 @@
import type { Env } from '../types';
const SIGNALR_RECORD_SEPARATOR = 0x1e;
const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARATOR]);
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
const SIGNALR_PING_INTERVAL_MS = 15_000;
type HubProtocol = 'json' | 'messagepack';
interface ConnectionState {
handshakeComplete: boolean;
protocol: HubProtocol;
deviceIdentifier: string | null;
}
function concatBytes(chunks: Uint8Array[]): Uint8Array {
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
out.set(chunk, offset);
offset += chunk.length;
}
return out;
}
function encodeUtf8(value: string): Uint8Array {
return new TextEncoder().encode(value);
}
function encodeMsgPackInteger(value: number): Uint8Array {
const normalized = Math.trunc(value);
if (normalized >= 0 && normalized <= 0x7f) {
return new Uint8Array([normalized]);
}
if (normalized >= 0 && normalized <= 0xff) {
return new Uint8Array([0xcc, normalized]);
}
if (normalized >= 0 && normalized <= 0xffff) {
return new Uint8Array([0xcd, normalized >> 8, normalized & 0xff]);
}
const safe = normalized >>> 0;
return new Uint8Array([
0xce,
(safe >>> 24) & 0xff,
(safe >>> 16) & 0xff,
(safe >>> 8) & 0xff,
safe & 0xff,
]);
}
function encodeMsgPackString(value: string): Uint8Array {
const bytes = encodeUtf8(value);
const len = bytes.length;
if (len < 32) {
return concatBytes([new Uint8Array([0xa0 | len]), bytes]);
}
if (len <= 0xff) {
return concatBytes([new Uint8Array([0xd9, len]), bytes]);
}
return concatBytes([new Uint8Array([0xda, (len >> 8) & 0xff, len & 0xff]), bytes]);
}
function encodeMsgPackTimestamp(date: Date): Uint8Array {
const seconds = BigInt(Math.floor(date.getTime() / 1000));
const nanos = BigInt(date.getMilliseconds()) * 1000000n;
const timestamp = (nanos << 34n) | seconds;
const payload = new Uint8Array(8);
for (let i = 7; i >= 0; i--) {
payload[i] = Number((timestamp >> BigInt((7 - i) * 8)) & 0xffn);
}
return concatBytes([new Uint8Array([0xc7, 0x08, 0xff]), payload]);
}
function encodeMsgPackArray(values: unknown[]): Uint8Array {
const items = values.map(encodeMsgPack);
const len = items.length;
const header =
len < 16
? new Uint8Array([0x90 | len])
: new Uint8Array([0xdc, (len >> 8) & 0xff, len & 0xff]);
return concatBytes([header, ...items]);
}
function encodeMsgPackMap(value: Record<string, unknown>): Uint8Array {
const entries = Object.entries(value);
const len = entries.length;
const header =
len < 16
? new Uint8Array([0x80 | len])
: new Uint8Array([0xde, (len >> 8) & 0xff, len & 0xff]);
const chunks: Uint8Array[] = [header];
for (const [key, entryValue] of entries) {
chunks.push(encodeMsgPackString(key), encodeMsgPack(entryValue));
}
return concatBytes(chunks);
}
function encodeMsgPack(value: unknown): Uint8Array {
if (value === null || value === undefined) return new Uint8Array([0xc0]);
if (value instanceof Date) return encodeMsgPackTimestamp(value);
if (typeof value === 'string') return encodeMsgPackString(value);
if (typeof value === 'number') return encodeMsgPackInteger(value);
if (typeof value === 'boolean') return new Uint8Array([value ? 0xc3 : 0xc2]);
if (Array.isArray(value)) return encodeMsgPackArray(value);
if (value instanceof Uint8Array) {
const len = value.length;
if (len <= 0xff) return concatBytes([new Uint8Array([0xc4, len]), value]);
return concatBytes([new Uint8Array([0xc5, (len >> 8) & 0xff, len & 0xff]), value]);
}
return encodeMsgPackMap(value as Record<string, unknown>);
}
function frameSignalRBinary(payload: Uint8Array): Uint8Array {
const len = payload.length;
const prefix: number[] = [];
let value = len;
do {
let current = value & 0x7f;
value >>>= 7;
if (value > 0) current |= 0x80;
prefix.push(current);
} while (value > 0);
return concatBytes([new Uint8Array(prefix), payload]);
}
function buildSignalRJsonInvocation(
userId: string,
updateType: number,
revisionDate: string,
contextId: string | null
): string {
return JSON.stringify({
type: 1,
target: 'ReceiveMessage',
arguments: [
{
ContextId: contextId,
Type: updateType,
Payload: {
UserId: userId,
Date: revisionDate,
},
},
],
}) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
}
function buildSignalRJsonPing(): string {
return JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
}
function buildSignalRMessagePackInvocation(
userId: string,
updateType: number,
revisionDate: string,
contextId: string | null
): Uint8Array {
// SignalR MessagePack hub protocol uses an array-based invocation shape:
// [type, headers, invocationId, target, arguments]
const payload = encodeMsgPack([
1,
{},
null,
'ReceiveMessage',
[
{
ContextId: contextId,
Type: updateType,
Payload: {
UserId: userId,
Date: new Date(revisionDate),
},
},
],
]);
return frameSignalRBinary(payload);
}
function buildSignalRMessagePackPing(): Uint8Array {
return frameSignalRBinary(encodeMsgPack([6]));
}
function decodeIncomingMessage(data: string | ArrayBuffer | ArrayBufferView): string {
if (typeof data === 'string') return data;
if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data));
return new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
}
export class NotificationsHub {
private readonly connections = new Map<WebSocket, ConnectionState>();
private userId = '';
private pingTimer: ReturnType<typeof setInterval> | null = null;
constructor(private readonly state: DurableObjectState, private readonly env: Env) {
void this.state;
void this.env;
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === '/internal/notify' && request.method === 'POST') {
const body = (await request.json().catch(() => null)) as {
revisionDate?: string;
userId?: string;
contextId?: string | null;
updateType?: number;
targetDeviceIdentifier?: string | null;
} | null;
const revisionDate = String(body?.revisionDate || '').trim() || new Date().toISOString();
this.userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || this.userId).trim();
const contextId = String(body?.contextId || '').trim() || null;
const updateType = Number(body?.updateType || SIGNALR_UPDATE_TYPE_SYNC_VAULT) || SIGNALR_UPDATE_TYPE_SYNC_VAULT;
const targetDeviceIdentifier = String(body?.targetDeviceIdentifier || '').trim() || null;
this.broadcastMessage(updateType, revisionDate, contextId, targetDeviceIdentifier);
return new Response(null, { status: 204 });
}
if (url.pathname === '/internal/online' && request.method === 'GET') {
return new Response(JSON.stringify({ deviceIdentifiers: this.getOnlineDeviceIdentifiers() }), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
}
if (url.pathname !== '/notifications/hub') {
return new Response('Not found', { status: 404 });
}
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
return new Response('Expected websocket', { status: 426 });
}
const requestUserId = String(url.searchParams.get('nw_uid') || '').trim();
const requestDeviceIdentifier = String(url.searchParams.get('nw_did') || '').trim() || null;
if (requestUserId) {
this.userId = requestUserId;
}
if (!this.userId) {
return new Response('Unauthorized', { status: 401 });
}
const pair = new WebSocketPair();
const client = pair[0];
const server = pair[1];
server.accept();
this.connections.set(server, {
handshakeComplete: false,
protocol: 'messagepack',
deviceIdentifier: requestDeviceIdentifier,
});
this.ensurePingLoop();
server.addEventListener('message', (event) => {
void this.handleSocketMessage(server, event.data);
});
server.addEventListener('close', () => {
const shouldBroadcast = !!this.connections.get(server)?.handshakeComplete;
this.connections.delete(server);
this.stopPingLoopIfIdle();
if (shouldBroadcast) this.broadcastDeviceStatus();
});
server.addEventListener('error', () => {
const shouldBroadcast = !!this.connections.get(server)?.handshakeComplete;
this.connections.delete(server);
this.stopPingLoopIfIdle();
if (shouldBroadcast) this.broadcastDeviceStatus();
try {
server.close(1011, 'Socket error');
} catch {
// ignore close races
}
});
return new Response(null, {
status: 101,
webSocket: client,
});
}
private async handleSocketMessage(socket: WebSocket, rawData: string | ArrayBuffer | ArrayBufferView): Promise<void> {
const connection = this.connections.get(socket);
if (!connection) return;
if (!connection.handshakeComplete) {
const text = decodeIncomingMessage(rawData);
const frames = text.split(String.fromCharCode(SIGNALR_RECORD_SEPARATOR)).filter(Boolean);
for (const frame of frames) {
try {
const handshake = JSON.parse(frame) as { protocol?: string };
const protocol = handshake.protocol === 'json' ? 'json' : 'messagepack';
connection.protocol = protocol;
connection.handshakeComplete = true;
socket.send(SIGNALR_HANDSHAKE_ACK);
this.broadcastDeviceStatus();
return;
} catch {
// Ignore malformed pre-handshake payloads.
}
}
return;
}
}
private ensurePingLoop(): void {
if (this.pingTimer !== null) return;
this.pingTimer = setInterval(() => {
this.broadcastPing();
}, SIGNALR_PING_INTERVAL_MS);
}
private stopPingLoopIfIdle(): void {
if (this.connections.size > 0 || this.pingTimer === null) return;
clearInterval(this.pingTimer);
this.pingTimer = null;
}
private broadcastPing(): void {
if (this.connections.size === 0) {
this.stopPingLoopIfIdle();
return;
}
for (const [socket, connection] of this.connections) {
if (!connection.handshakeComplete) continue;
try {
if (connection.protocol === 'json') {
socket.send(buildSignalRJsonPing());
} else {
socket.send(buildSignalRMessagePackPing());
}
} catch {
this.connections.delete(socket);
try {
socket.close(1011, 'Ping send failed');
} catch {
// ignore close races
}
}
}
this.stopPingLoopIfIdle();
}
private getOnlineDeviceIdentifiers(): string[] {
const out = new Set<string>();
for (const connection of this.connections.values()) {
if (!connection.handshakeComplete || !connection.deviceIdentifier) continue;
out.add(connection.deviceIdentifier);
}
return Array.from(out);
}
private broadcastMessage(
updateType: number,
revisionDate: string,
contextId: string | null,
targetDeviceIdentifier: string | null
): void {
if (!this.userId || this.connections.size === 0) return;
for (const [socket, connection] of this.connections) {
if (!connection.handshakeComplete) continue;
if (targetDeviceIdentifier && connection.deviceIdentifier !== targetDeviceIdentifier) continue;
try {
if (connection.protocol === 'json') {
socket.send(buildSignalRJsonInvocation(this.userId, updateType, revisionDate, contextId));
} else {
socket.send(buildSignalRMessagePackInvocation(this.userId, updateType, revisionDate, contextId));
}
} catch {
this.connections.delete(socket);
try {
socket.close(1011, 'Notification send failed');
} catch {
// ignore close races
}
}
}
this.stopPingLoopIfIdle();
}
private broadcastDeviceStatus(): void {
this.broadcastMessage(SIGNALR_UPDATE_TYPE_DEVICE_STATUS, new Date().toISOString(), null, null);
}
}
export async function notifyUserVaultSync(
env: Env,
userId: string,
revisionDate: string,
contextId?: string | null
): Promise<void> {
return notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_VAULT, revisionDate, contextId ?? null, null);
}
export async function notifyUserLogout(
env: Env,
userId: string,
targetDeviceIdentifier?: string | null
): Promise<void> {
return notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_LOG_OUT, new Date().toISOString(), null, targetDeviceIdentifier ?? null);
}
export async function getOnlineUserDevices(env: Env, userId: string): Promise<string[]> {
try {
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
const stub = env.NOTIFICATIONS_HUB.get(id);
const response = await stub.fetch('https://notifications/internal/online');
if (!response.ok) return [];
const body = (await response.json().catch(() => null)) as { deviceIdentifiers?: string[] } | null;
return Array.isArray(body?.deviceIdentifiers) ? body.deviceIdentifiers.filter((value) => !!String(value || '').trim()) : [];
} catch {
return [];
}
}
async function notifyUserUpdate(
env: Env,
userId: string,
updateType: number,
revisionDate: string,
contextId: string | null,
targetDeviceIdentifier: string | null
): Promise<void> {
try {
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
const stub = env.NOTIFICATIONS_HUB.get(id);
await stub.fetch('https://notifications/internal/notify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-NodeWarden-UserId': userId,
},
body: JSON.stringify({
revisionDate,
contextId: contextId || null,
updateType,
targetDeviceIdentifier: targetDeviceIdentifier || null,
}),
});
} catch (error) {
console.error('Failed to broadcast realtime notification:', error);
}
}
+162 -24
View File
@@ -7,6 +7,7 @@ import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits';
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
import { buildAccountKeys } from '../utils/user-decryption';
function looksLikeEncString(value: string): boolean {
if (!value) return false;
@@ -45,13 +46,27 @@ function validateKdfParams(kdfType: number | undefined, kdfIterations: number |
}
function normalizeTotpSecret(input: string): string {
return input.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
const raw = String(input || '').toUpperCase();
let out = '';
for (const char of raw) {
if (char === ' ' || char === '\t' || char === '\n' || char === '\r' || char === '-') continue;
out += char;
}
while (out.endsWith('=')) {
out = out.slice(0, -1);
}
return out;
}
function normalizeRecoveryCodeInput(input: string): string {
return String(input || '').toUpperCase().replace(/[^A-Z2-7]/g, '');
}
function normalizeMasterPasswordHint(input: string | null | undefined): string | null {
const normalized = String(input || '').trim();
return normalized ? normalized : null;
}
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
const secret = (env.JWT_SECRET || '').trim();
if (!secret) return 'missing';
@@ -60,7 +75,18 @@ function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' |
return null;
}
async function verifyUserSecret(
auth: AuthService,
user: User,
secret: string | null | undefined
): Promise<boolean> {
const normalized = String(secret || '').trim();
if (!normalized) return false;
return auth.verifyPassword(normalized, user.masterPasswordHash, user.email);
}
function toProfile(user: User, env: Env): ProfileResponse {
void env;
return {
id: user.id,
name: user.name,
@@ -69,12 +95,12 @@ function toProfile(user: User, env: Env): ProfileResponse {
premium: true,
premiumFromOrganization: false,
usesKeyConnector: false,
masterPasswordHint: null,
masterPasswordHint: user.masterPasswordHint,
culture: 'en-US',
twoFactorEnabled: !!user.totpSecret || isTotpEnabled(env.TOTP_SECRET),
twoFactorEnabled: !!user.totpSecret,
key: user.key,
privateKey: user.privateKey,
accountKeys: null,
accountKeys: buildAccountKeys(user),
securityStamp: user.securityStamp || user.id,
organizations: [],
providers: [],
@@ -82,6 +108,7 @@ function toProfile(user: User, env: Env): ProfileResponse {
forcePasswordReset: false,
avatarColor: null,
creationDate: user.createdAt,
verifyDevices: user.verifyDevices,
role: user.role,
status: user.status,
object: 'profile',
@@ -114,6 +141,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
kdfMemory?: number;
kdfParallelism?: number;
inviteCode?: string;
masterPasswordHint?: string;
keys?: {
publicKey?: string;
encryptedPrivateKey?: string;
@@ -133,6 +161,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
const privateKey = body.keys?.encryptedPrivateKey;
const publicKey = body.keys?.publicKey;
const inviteCode = (body.inviteCode || '').trim();
const masterPasswordHint = normalizeMasterPasswordHint(body.masterPasswordHint);
if (!email || !masterPasswordHash || !key) {
return errorResponse('Email, masterPasswordHash, and key are required', 400);
@@ -149,6 +178,9 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
if (!looksLikeEncString(privateKey)) {
return errorResponse('encryptedPrivateKey is not a valid encrypted string', 400);
}
if (masterPasswordHint && masterPasswordHint.length > 120) {
return errorResponse('masterPasswordHint must be 120 characters or fewer', 400);
}
const kdfErr = validateKdfParams(body.kdf, body.kdfIterations, body.kdfMemory, body.kdfParallelism);
if (kdfErr) return errorResponse(kdfErr, 400);
@@ -161,6 +193,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
id: generateUUID(),
email,
name: name || email,
masterPasswordHint,
masterPasswordHash: serverHash,
key,
privateKey,
@@ -172,6 +205,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
securityStamp: generateUUID(),
role: 'user',
status: 'active',
verifyDevices: true,
totpSecret: null,
totpRecoveryCode: null,
createdAt: now,
@@ -231,6 +265,80 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
return jsonResponse({ success: true, role: user.role }, 200);
}
// POST /api/accounts/password-hint
export async function handleGetPasswordHint(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
const clientIdentifier = getClientIdentifier(request);
if (!clientIdentifier) {
return errorResponse('Client IP is required', 403);
}
let body: { email?: string };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const email = String(body.email || '').trim().toLowerCase();
if (!email) {
return errorResponse('Email is required', 400);
}
const rateLimit = new RateLimitService(env.DB);
const minuteBudget = await rateLimit.consumeBudgetWithWindow(
`${clientIdentifier}:password-hint`,
LIMITS.rateLimit.passwordHintRequestsPerMinute,
60
);
if (!minuteBudget.allowed) {
return new Response(
JSON.stringify({
error: 'Too many requests',
error_description: `Rate limit exceeded. Try again in ${minuteBudget.retryAfterSeconds || 60} seconds.`,
}),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(minuteBudget.retryAfterSeconds || 60),
'X-RateLimit-Remaining': '0',
},
}
);
}
const hourlyBudget = await rateLimit.consumeBudgetWithWindow(
`${clientIdentifier}:password-hint-hour`,
LIMITS.rateLimit.passwordHintRequestsPerHour,
60 * 60
);
if (!hourlyBudget.allowed) {
return new Response(
JSON.stringify({
error: 'Too many requests',
error_description: `Rate limit exceeded. Try again in ${hourlyBudget.retryAfterSeconds || 3600} seconds.`,
}),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(hourlyBudget.retryAfterSeconds || 3600),
'X-RateLimit-Remaining': '0',
},
}
);
}
const user = await storage.getUser(email);
const hint = user?.status === 'active' ? normalizeMasterPasswordHint(user.masterPasswordHint) : null;
return jsonResponse({
object: 'passwordHint',
hasHint: !!hint,
masterPasswordHint: hint,
});
}
// GET /api/accounts/profile
export async function handleGetProfile(request: Request, env: Env, userId: string): Promise<Response> {
void request;
@@ -246,34 +354,59 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: { name?: string; email?: string };
let body: {
masterPasswordHint?: string | null;
};
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
if (typeof body.name === 'string') {
user.name = body.name.trim() || user.name;
}
if (typeof body.email === 'string') {
const normalized = body.email.trim().toLowerCase();
if (!normalized) return errorResponse('Email is required', 400);
user.email = normalized;
const masterPasswordHint = normalizeMasterPasswordHint(body.masterPasswordHint);
if (masterPasswordHint && masterPasswordHint.length > 120) {
return errorResponse('masterPasswordHint must be 120 characters or fewer', 400);
}
user.masterPasswordHint = masterPasswordHint;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
return jsonResponse(toProfile(user, env));
}
// PUT/POST /api/accounts/verify-devices
export async function handleSetVerifyDevices(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: {
secret?: string;
masterPasswordHash?: string;
verifyDevices?: boolean;
};
try {
await storage.saveUser(user);
} catch (error) {
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
if (msg.includes('unique') || msg.includes('constraint')) {
return errorResponse('Email already registered', 409);
}
throw error;
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
return handleGetProfile(request, env, userId);
if (typeof body.verifyDevices !== 'boolean') {
return errorResponse('verifyDevices must be true or false', 400);
}
const verified = await verifyUserSecret(auth, user, body.secret || body.masterPasswordHash);
if (!verified) {
return errorResponse('User verification failed.', 400);
}
user.verifyDevices = body.verifyDevices;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
return new Response(null, { status: 200 });
}
// POST /api/accounts/keys
@@ -308,15 +441,16 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
return errorResponse('Invalid password', 400);
}
if (body.key) user.key = body.key;
if (body.encryptedPrivateKey) user.privateKey = body.encryptedPrivateKey;
if (body.publicKey) user.publicKey = body.publicKey;
if (body.key && !looksLikeEncString(body.key)) {
return errorResponse('key is not a valid encrypted string', 400);
}
if (body.encryptedPrivateKey && !looksLikeEncString(body.encryptedPrivateKey)) {
return errorResponse('encryptedPrivateKey is not a valid encrypted string', 400);
}
if (body.key) user.key = body.key;
if (body.encryptedPrivateKey) user.privateKey = body.encryptedPrivateKey;
if (body.publicKey) user.publicKey = body.publicKey;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
@@ -526,7 +660,11 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
const email = String(body.email || body.username || '').trim().toLowerCase();
const masterPasswordHash = String(body.masterPasswordHash || body.password || '').trim();
const recoveryCode = normalizeRecoveryCodeInput(String(body.recoveryCode || body.twoFactorToken || body.recovery_code || ''));
const recoverLimitKey = `${getClientIdentifier(request)}:recover-2fa:${email || 'unknown'}`;
const clientIdentifier = getClientIdentifier(request);
if (!clientIdentifier) {
return errorResponse('Client IP is required', 403);
}
const recoverLimitKey = `${clientIdentifier}:recover-2fa:${email || 'unknown'}`;
const recoverAttemptCheck = await rateLimit.checkLoginAttempt(recoverLimitKey);
if (!recoverAttemptCheck.allowed) {
+3 -2
View File
@@ -2,6 +2,7 @@ import { Env, User, Invite } from '../types';
import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { deleteBlobObject, getAttachmentObjectKey, getSendFileObjectKey } from '../services/blob-store';
function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active';
@@ -260,7 +261,7 @@ export async function handleAdminDeleteUser(
const attachmentMap = await storage.getAttachmentsByUserId(target.id);
for (const [cipherId, attachments] of attachmentMap) {
for (const att of attachments) {
await env.ATTACHMENTS.delete(`${cipherId}/${att.id}`);
await deleteBlobObject(env, getAttachmentObjectKey(cipherId, att.id));
}
}
// 2. Send files (keyed by sends/sendId/fileId)
@@ -271,7 +272,7 @@ export async function handleAdminDeleteUser(
const parsed = JSON.parse(send.data) as Record<string, unknown>;
const fileId = typeof parsed.id === 'string' ? parsed.id : null;
if (fileId) {
await env.ATTACHMENTS.delete(`sends/${send.id}/${fileId}`);
await deleteBlobObject(env, getSendFileObjectKey(send.id, fileId));
}
} catch { /* non-file send or bad data, skip */ }
}
+130 -61
View File
@@ -1,10 +1,34 @@
import { Env, Attachment, DEFAULT_DEV_SECRET } from '../types';
import { notifyUserVaultSync } from '../durable/notifications-hub';
import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response';
import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload';
import { generateUUID } from '../utils/uuid';
import { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt';
import { cipherToResponse } from './ciphers';
import {
createAttachmentUploadToken,
createFileDownloadToken,
verifyAttachmentUploadToken,
verifyFileDownloadToken,
} from '../utils/jwt';
import { cipherToResponse, shouldOmitPasskeysForResponse } from './ciphers';
import { LIMITS } from '../config/limits';
import { readActingDeviceIdentifier } from '../utils/device';
import {
deleteBlobObject,
getAttachmentObjectKey,
getBlobObject,
getBlobStorageMaxBytes,
putBlobObject,
} from '../services/blob-store';
async function notifyVaultSyncForRequest(
request: Request,
env: Env,
userId: string,
revisionDate: string
): Promise<void> {
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
// Format file size to human readable
function formatSize(bytes: number): string {
@@ -14,9 +38,53 @@ function formatSize(bytes: number): string {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
// Get R2 object path for attachment
function getAttachmentPath(cipherId: string, attachmentId: string): string {
return `${cipherId}/${attachmentId}`;
async function processAttachmentUpload(
request: Request,
env: Env,
attachment: Attachment,
cipherId: string
): Promise<Response> {
const storage = new StorageService(env.DB);
const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.attachment.maxFileSizeBytes);
const upload = await parseDirectUploadPayload(request, {
expectedSize: Number(attachment.size) || 0,
maxFileSize,
tooLargeMessage: `File too large. Maximum size is ${Math.floor(maxFileSize / (1024 * 1024))}MB`,
});
if (upload instanceof Response) {
return upload;
}
const path = getAttachmentObjectKey(cipherId, attachment.id);
try {
await putBlobObject(env, path, upload.body, {
size: upload.size,
contentType: upload.contentType,
customMetadata: {
cipherId,
attachmentId: attachment.id,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('KV object too large')) {
return errorResponse(`File too large. Maximum size is ${Math.floor(maxFileSize / (1024 * 1024))}MB`, 413);
}
return errorResponse('Attachment storage is not configured', 500);
}
if (upload.size !== attachment.size) {
attachment.size = upload.size;
attachment.sizeName = formatSize(upload.size);
await storage.saveAttachment(attachment);
}
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) {
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
}
return new Response(null, { status: 201 });
}
// POST /api/ciphers/{cipherId}/attachment/v2
@@ -71,24 +139,31 @@ export async function handleCreateAttachment(
await storage.addAttachmentToCipher(cipherId, attachmentId);
// Update cipher revision date
await storage.updateCipherRevisionDate(cipherId);
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) {
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
}
// Get updated cipher for response
const updatedCipher = await storage.getCipher(cipherId);
const attachments = await storage.getAttachmentsByCipher(cipherId);
const jwtSecret = getSafeJwtSecret(env);
if (!jwtSecret) {
return errorResponse('Server configuration error', 500);
}
const uploadToken = await createAttachmentUploadToken(userId, cipherId, attachmentId, jwtSecret);
return jsonResponse({
object: 'attachment-fileUpload',
attachmentId: attachmentId,
url: `/api/ciphers/${cipherId}/attachment/${attachmentId}`,
fileUploadType: 0, // Direct upload
cipherResponse: cipherToResponse(updatedCipher!, attachments),
url: buildDirectUploadUrl(request, `/api/ciphers/${cipherId}/attachment/${attachmentId}`, uploadToken),
fileUploadType: 1,
cipherResponse: cipherToResponse(updatedCipher!, attachments, {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
}),
});
}
// Maximum file size: 100MB
const MAX_FILE_SIZE = LIMITS.attachment.maxFileSizeBytes;
// POST /api/ciphers/{cipherId}/attachment/{attachmentId}
// Upload attachment file content
export async function handleUploadAttachment(
@@ -112,54 +187,45 @@ export async function handleUploadAttachment(
return errorResponse('Attachment not found', 404);
}
// Check content-length header for size limit
const contentLength = request.headers.get('content-length');
if (contentLength && parseInt(contentLength) > MAX_FILE_SIZE) {
return errorResponse('File too large. Maximum size is 100MB', 413);
return processAttachmentUpload(request, env, attachment, cipherId);
}
export async function handlePublicUploadAttachment(
request: Request,
env: Env,
cipherId: string,
attachmentId: string
): Promise<Response> {
const jwtSecret = getSafeJwtSecret(env);
if (!jwtSecret) {
return errorResponse('Server configuration error', 500);
}
// Get the file from multipart form data
const contentType = request.headers.get('content-type') || '';
if (!contentType.includes('multipart/form-data')) {
return errorResponse('Content-Type must be multipart/form-data', 400);
const token = new URL(request.url).searchParams.get('token');
if (!token) {
return errorResponse('Token required', 401);
}
const formData = await request.formData();
const file = formData.get('data') as File | null;
if (!file) {
return errorResponse('No file uploaded', 400);
const claims = await verifyAttachmentUploadToken(token, jwtSecret);
if (!claims) {
return errorResponse('Invalid or expired token', 401);
}
if (claims.cipherId !== cipherId || claims.attachmentId !== attachmentId) {
return errorResponse('Token mismatch', 401);
}
// Check actual file size
if (file.size > MAX_FILE_SIZE) {
return errorResponse('File too large. Maximum size is 100MB', 413);
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(cipherId);
if (!cipher || cipher.userId !== claims.userId) {
return errorResponse('Cipher not found', 404);
}
// Store file in R2
const path = getAttachmentPath(cipherId, attachmentId);
await env.ATTACHMENTS.put(path, file.stream(), {
httpMetadata: {
contentType: 'application/octet-stream',
},
customMetadata: {
cipherId: cipherId,
attachmentId: attachmentId,
},
});
// Update attachment size if different
const actualSize = file.size;
if (actualSize !== attachment.size) {
attachment.size = actualSize;
attachment.sizeName = formatSize(actualSize);
await storage.saveAttachment(attachment);
const attachment = await storage.getAttachment(attachmentId);
if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404);
}
// Update cipher revision date
await storage.updateCipherRevisionDate(cipherId);
return new Response(null, { status: 200 });
return processAttachmentUpload(request, env, attachment, cipherId);
}
// GET /api/ciphers/{cipherId}/attachment/{attachmentId}
@@ -242,9 +308,8 @@ export async function handlePublicDownloadAttachment(
return errorResponse('Attachment not found', 404);
}
// Get file from R2
const path = getAttachmentPath(cipherId, attachmentId);
const object = await env.ATTACHMENTS.get(path);
const path = getAttachmentObjectKey(cipherId, attachmentId);
const object = await getBlobObject(env, path);
if (!object) {
return errorResponse('Attachment file not found', 404);
@@ -257,7 +322,7 @@ export async function handlePublicDownloadAttachment(
return new Response(object.body, {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Type': object.contentType || 'application/octet-stream',
'Content-Length': String(object.size),
'Cache-Control': 'private, no-cache',
},
@@ -287,9 +352,8 @@ export async function handleDeleteAttachment(
return errorResponse('Attachment not found', 404);
}
// Delete file from R2
const path = getAttachmentPath(cipherId, attachmentId);
await env.ATTACHMENTS.delete(path);
const path = getAttachmentObjectKey(cipherId, attachmentId);
await deleteBlobObject(env, path);
// Delete attachment metadata
await storage.deleteAttachment(attachmentId);
@@ -298,14 +362,19 @@ export async function handleDeleteAttachment(
await storage.removeAttachmentFromCipher(cipherId, attachmentId);
// Update cipher revision date
await storage.updateCipherRevisionDate(cipherId);
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) {
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
}
// Get updated cipher for response
const updatedCipher = await storage.getCipher(cipherId);
const attachments = await storage.getAttachmentsByCipher(cipherId);
return jsonResponse({
cipher: cipherToResponse(updatedCipher!, attachments),
cipher: cipherToResponse(updatedCipher!, attachments, {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
}),
});
}
@@ -318,8 +387,8 @@ export async function deleteAllAttachmentsForCipher(
const attachments = await storage.getAttachmentsByCipher(cipherId);
for (const attachment of attachments) {
const path = getAttachmentPath(cipherId, attachment.id);
await env.ATTACHMENTS.delete(path);
const path = getAttachmentObjectKey(cipherId, attachment.id);
await deleteBlobObject(env, path);
await storage.deleteAttachment(attachment.id);
}
}
+550
View File
@@ -0,0 +1,550 @@
import type { Env, User } from '../types';
import { errorResponse, jsonResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { type BackupArchiveBundle, buildBackupArchive } from '../services/backup-archive';
import {
type BackupDestinationRecord,
type BackupSettingsInput,
BACKUP_SCHEDULER_WINDOW_MINUTES,
getBackupLocalDateKey,
getDefaultBackupSettings,
getBackupSettingsRepairState,
isBackupDueNow,
loadBackupSettings,
normalizeBackupSettingsInput,
normalizeImportedBackupSettings,
repairBackupSettings,
requireBackupDestination,
saveBackupSettings,
} from '../services/backup-config';
import { type BackupImportExecutionResult, importBackupArchiveBytes, importRemoteBackupArchiveBytes } from '../services/backup-import';
import {
deleteRemoteBackupFile,
downloadRemoteBackupFile,
ensureRemoteRestoreCandidate,
listRemoteBackupEntries,
pruneRemoteBackupArchives,
remoteBackupFileExists,
uploadRemoteBackupFile,
uploadBackupArchive,
} from '../services/backup-uploader';
import { StorageService } from '../services/storage';
import { getBlobObject } from '../services/blob-store';
function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active';
}
async function writeAuditLog(
storage: StorageService,
actorUserId: string | null,
action: string,
targetType: string | null,
targetId: string | null,
metadata: Record<string, unknown> | null
): Promise<void> {
await storage.createAuditLog({
id: generateUUID(),
actorUserId,
action,
targetType,
targetId,
metadata: metadata ? JSON.stringify(metadata) : null,
createdAt: new Date().toISOString(),
});
}
function getBackupDestinationSummary(destination: BackupDestinationRecord | null): Record<string, unknown> {
if (!destination) {
return {
destinationId: null,
destinationName: null,
destinationType: null,
};
}
return {
destinationId: destination.id,
destinationName: destination.name,
destinationType: destination.type,
};
}
function ensureBackupBlobName(value: string): string {
const normalized = String(value || '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
if (!normalized) {
throw new Error('Backup attachment blob is required');
}
const parts = normalized.split('/').filter(Boolean);
if (!parts.length || parts.some((part) => part === '.' || part === '..')) {
throw new Error('Backup attachment blob is invalid');
}
return parts.join('/');
}
async function executeConfiguredBackup(
env: Env,
storage: StorageService,
actorUserId: string | null,
trigger: 'manual' | 'scheduled',
destinationId?: string | null
): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> {
const currentSettings = await loadBackupSettings(storage, env, 'UTC');
const destination = requireBackupDestination(currentSettings, destinationId);
const now = new Date();
destination.runtime.lastAttemptAt = now.toISOString();
destination.runtime.lastAttemptLocalDate = getBackupLocalDateKey(now, destination.schedule.timezone);
destination.runtime.lastErrorAt = null;
destination.runtime.lastErrorMessage = null;
await saveBackupSettings(storage, env, currentSettings);
try {
const archive = await buildBackupArchive(env, now, {
includeAttachments: destination.includeAttachments,
});
for (const attachment of archive.manifest.attachmentBlobs || []) {
const remotePath = `attachments/${attachment.blobName}`;
if (await remoteBackupFileExists(destination, remotePath)) continue;
const object = await getBlobObject(env, attachment.blobName);
if (!object) {
throw new Error(`Attachment blob missing for ${attachment.blobName}`);
}
const bytes = new Uint8Array(await new Response(object.body).arrayBuffer());
await uploadRemoteBackupFile(destination, remotePath, bytes, {
contentType: object.contentType,
});
}
const upload = await uploadBackupArchive(destination, archive.bytes, archive.fileName);
let prunedFileCount = 0;
let pruneErrorMessage: string | null = null;
try {
prunedFileCount = await pruneRemoteBackupArchives(destination, destination.schedule.retentionCount, archive.fileName);
} catch (error) {
pruneErrorMessage = error instanceof Error ? error.message : 'Old backup cleanup failed';
}
destination.runtime.lastSuccessAt = new Date().toISOString();
destination.runtime.lastErrorAt = null;
destination.runtime.lastErrorMessage = null;
destination.runtime.lastUploadedFileName = archive.fileName;
destination.runtime.lastUploadedSizeBytes = archive.bytes.byteLength;
destination.runtime.lastUploadedDestination = upload.remotePath;
await saveBackupSettings(storage, env, currentSettings);
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, {
...getBackupDestinationSummary(destination),
provider: upload.provider,
remotePath: upload.remotePath,
fileName: archive.fileName,
fileBytes: archive.bytes.byteLength,
prunedFileCount,
pruneError: pruneErrorMessage,
});
return {
fileName: archive.fileName,
fileSize: archive.bytes.byteLength,
remotePath: upload.remotePath,
provider: upload.provider,
};
} catch (error) {
destination.runtime.lastErrorAt = new Date().toISOString();
destination.runtime.lastErrorMessage = error instanceof Error ? error.message : 'Backup upload failed';
await saveBackupSettings(storage, env, currentSettings);
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, {
...getBackupDestinationSummary(destination),
error: destination.runtime.lastErrorMessage,
});
throw error;
}
}
function toImportStatusCode(message: string): number {
const lower = message.toLowerCase();
if (lower.includes('invalid backup') || lower.includes('invalid json')) return 400;
if (lower.includes('fresh instance')) return 409;
if (lower.includes('not configured') || lower.includes('kv')) return 409;
return 500;
}
async function runImportAndAudit(
env: Env,
actorUser: User,
archiveBytes: Uint8Array,
replaceExisting: boolean,
metadata: Record<string, unknown>
): Promise<BackupImportExecutionResult> {
const storage = new StorageService(env.DB);
const imported = await importBackupArchiveBytes(archiveBytes, env, actorUser.id, replaceExisting);
await writeAuditLog(storage, imported.auditActorUserId, 'admin.backup.import', 'backup', null, {
users: imported.result.imported.users,
ciphers: imported.result.imported.ciphers,
attachments: imported.result.imported.attachmentFiles,
skippedAttachments: imported.result.skipped.attachments,
skippedReason: imported.result.skipped.reason,
replaceExisting,
...metadata,
});
return imported;
}
export async function runScheduledBackupIfDue(env: Env): Promise<void> {
const storage = new StorageService(env.DB);
const settings = await loadBackupSettings(storage, env, 'UTC');
const now = new Date();
for (const destination of settings.destinations) {
if (!isBackupDueNow(destination, now, BACKUP_SCHEDULER_WINDOW_MINUTES)) continue;
await executeConfiguredBackup(env, storage, null, 'scheduled', destination.id);
}
}
export async function handleGetAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
void request;
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
const storage = new StorageService(env.DB);
try {
const settings = await loadBackupSettings(storage, env, 'UTC');
return jsonResponse(settings);
} catch (error) {
return errorResponse(error instanceof Error ? error.message : 'Backup settings could not be loaded', 409);
}
}
export async function handleUpdateAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: BackupSettingsInput;
try {
body = await request.json<BackupSettingsInput>();
} catch {
return errorResponse('Backup settings payload is invalid', 400);
}
const storage = new StorageService(env.DB);
let previous;
try {
previous = await loadBackupSettings(storage, env, 'UTC');
} catch {
previous = getDefaultBackupSettings('UTC');
}
let next;
try {
next = normalizeBackupSettingsInput(body, previous);
} catch (error) {
return errorResponse(error instanceof Error ? error.message : 'Backup settings are invalid', 400);
}
await saveBackupSettings(storage, env, next);
await writeAuditLog(storage, actorUser.id, 'admin.backup.settings.update', 'backup', null, {
destinationCount: next.destinations.length,
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
});
return jsonResponse(next);
}
export async function handleGetAdminBackupSettingsRepairState(request: Request, env: Env, actorUser: User): Promise<Response> {
void request;
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
const storage = new StorageService(env.DB);
try {
const state = await getBackupSettingsRepairState(storage, env, 'UTC');
return jsonResponse({
object: 'backup-settings-repair',
needsRepair: state.needsRepair,
portable: state.portable,
});
} catch (error) {
return errorResponse(error instanceof Error ? error.message : 'Backup settings repair state could not be loaded', 409);
}
}
export async function handleRepairAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: BackupSettingsInput;
try {
body = await request.json<BackupSettingsInput>();
} catch {
return errorResponse('Backup settings repair payload is invalid', 400);
}
const storage = new StorageService(env.DB);
let previous;
try {
previous = await loadBackupSettings(storage, env, 'UTC');
} catch {
previous = getDefaultBackupSettings('UTC');
}
let next;
try {
next = normalizeBackupSettingsInput(body, previous);
} catch (error) {
return errorResponse(error instanceof Error ? error.message : 'Backup settings repair payload is invalid', 400);
}
await repairBackupSettings(storage, env, next);
await writeAuditLog(storage, actorUser.id, 'admin.backup.settings.repair', 'backup', null, {
destinationCount: next.destinations.length,
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
});
return jsonResponse(next);
}
export async function handleRunAdminConfiguredBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
const storage = new StorageService(env.DB);
try {
let body: { destinationId?: string } | null = null;
try {
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
body = await request.json<{ destinationId?: string }>();
}
} catch {
return errorResponse('Backup run payload is invalid', 400);
}
const result = await executeConfiguredBackup(env, storage, actorUser.id, 'manual', body?.destinationId || null);
const settings = await loadBackupSettings(storage, env, 'UTC');
return jsonResponse({
object: 'backup-run',
result: {
fileName: result.fileName,
fileSize: result.fileSize,
provider: result.provider,
remotePath: result.remotePath,
},
settings,
});
} catch (error) {
return errorResponse(error instanceof Error ? error.message : 'Backup run failed', 500);
}
}
export async function handleListAdminRemoteBackups(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
const storage = new StorageService(env.DB);
try {
const settings = await loadBackupSettings(storage, env, 'UTC');
const url = new URL(request.url);
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
const listing = await listRemoteBackupEntries(destination, url.searchParams.get('path') || '');
return jsonResponse({
object: 'backup-remote-browser',
destinationId: destination.id,
destinationName: destination.name,
...listing,
});
} catch (error) {
return errorResponse(error instanceof Error ? error.message : 'Remote backup listing failed', 409);
}
}
export async function handleDownloadAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
const storage = new StorageService(env.DB);
try {
const settings = await loadBackupSettings(storage, env, 'UTC');
const url = new URL(request.url);
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
const remoteFile = await downloadRemoteBackupFile(destination, path);
return new Response(remoteFile.bytes, {
status: 200,
headers: {
'Content-Type': remoteFile.contentType || 'application/zip',
'Content-Disposition': `attachment; filename="${remoteFile.fileName}"`,
'Cache-Control': 'no-store',
},
});
} catch (error) {
return errorResponse(error instanceof Error ? error.message : 'Remote backup download failed', 409);
}
}
export async function handleDeleteAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
const storage = new StorageService(env.DB);
try {
const settings = await loadBackupSettings(storage, env, 'UTC');
const url = new URL(request.url);
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
await deleteRemoteBackupFile(destination, path);
await writeAuditLog(storage, actorUser.id, 'admin.backup.remote.delete', 'backup', null, {
...getBackupDestinationSummary(destination),
remotePath: path,
});
return jsonResponse({ object: 'backup-remote-delete', deleted: true, path });
} catch (error) {
return errorResponse(error instanceof Error ? error.message : 'Remote backup delete failed', 409);
}
}
export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: { destinationId?: string; path?: string; replaceExisting?: boolean };
try {
body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>();
} catch {
return errorResponse('Remote restore payload is invalid', 400);
}
const storage = new StorageService(env.DB);
try {
const settings = await loadBackupSettings(storage, env, 'UTC');
const destination = requireBackupDestination(settings, body.destinationId || null);
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const remoteFile = await downloadRemoteBackupFile(destination, path);
const imported = await (async () => {
const storage = new StorageService(env.DB);
const result = await importRemoteBackupArchiveBytes(
remoteFile.bytes,
env,
actorUser.id,
!!body.replaceExisting,
{
hasAttachment: async (blobName) => remoteBackupFileExists(destination, `attachments/${blobName}`),
loadAttachment: async (blobName) => {
const file = await downloadRemoteBackupFile(destination, `attachments/${blobName}`).catch(() => null);
return file?.bytes || null;
},
}
);
await writeAuditLog(storage, result.auditActorUserId, 'admin.backup.import', 'backup', null, {
users: result.result.imported.users,
ciphers: result.result.imported.ciphers,
attachments: result.result.imported.attachmentFiles,
skippedAttachments: result.result.skipped.attachments,
skippedReason: result.result.skipped.reason,
replaceExisting: !!body.replaceExisting,
...getBackupDestinationSummary(destination),
remotePath: path,
bytes: remoteFile.bytes.byteLength,
trigger: 'remote',
});
return result;
})();
return jsonResponse(imported.result);
} catch (error) {
const message = error instanceof Error ? error.message : 'Remote backup restore failed';
return errorResponse(message, toImportStatusCode(message));
}
}
export async function handleAdminExportBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
const storage = new StorageService(env.DB);
let body: { includeAttachments?: boolean } | null = null;
try {
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
body = await request.json<{ includeAttachments?: boolean }>();
}
} catch {
return errorResponse('Backup export payload is invalid', 400);
}
let archive: BackupArchiveBundle;
try {
archive = await buildBackupArchive(env, new Date(), {
includeAttachments: !!body?.includeAttachments,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Backup export failed';
return errorResponse(message, message.includes('blob missing') ? 409 : 500);
}
await writeAuditLog(storage, actorUser.id, 'admin.backup.export', 'backup', null, {
users: archive.manifest.tableCounts.users,
ciphers: archive.manifest.tableCounts.ciphers,
attachments: archive.manifest.tableCounts.attachments,
compressedBytes: archive.bytes.byteLength,
includesAttachments: archive.manifest.includes.attachments,
});
return new Response(archive.bytes, {
status: 200,
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${archive.fileName}"`,
'Cache-Control': 'no-store',
},
});
}
export async function handleDownloadAdminBackupAttachment(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
try {
const url = new URL(request.url);
const blobName = ensureBackupBlobName(url.searchParams.get('blobName') || '');
const object = await getBlobObject(env, blobName);
if (!object) {
return errorResponse('Backup attachment blob not found', 404);
}
return new Response(object.body, {
status: 200,
headers: {
'Content-Type': object.contentType || 'application/octet-stream',
'Content-Length': String(object.size),
'Cache-Control': 'no-store',
},
});
} catch (error) {
return errorResponse(error instanceof Error ? error.message : 'Backup attachment download failed', 400);
}
}
export async function handleAdminImportBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let formData: FormData;
try {
formData = await request.formData();
} catch {
return errorResponse('Content-Type must be multipart/form-data', 400);
}
const file = formData.get('file');
if (!file || typeof file !== 'object' || !('arrayBuffer' in file)) {
return errorResponse('Backup file is required', 400);
}
const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1';
let archiveBytes: Uint8Array;
try {
archiveBytes = new Uint8Array(await (file as { arrayBuffer(): Promise<ArrayBuffer> }).arrayBuffer());
} catch {
return errorResponse('Unable to read backup file', 400);
}
try {
const imported = await runImportAndAudit(env, actorUser, archiveBytes, replaceExisting, {
trigger: 'local',
bytes: archiveBytes.byteLength,
});
return jsonResponse(imported.result);
} catch (error) {
const message = error instanceof Error ? error.message : 'Backup import failed';
return errorResponse(message, toImportStatusCode(message));
}
}
export async function seedDefaultBackupSettings(env: Env): Promise<void> {
const storage = new StorageService(env.DB);
const current = await storage.getConfigValue('backup.settings.v1');
if (current) {
await normalizeImportedBackupSettings(storage, env, 'UTC');
return;
}
await saveBackupSettings(storage, env, getDefaultBackupSettings('UTC'));
}
+401 -39
View File
@@ -1,9 +1,20 @@
import { Env, Cipher, CipherResponse, Attachment } from '../types';
import { StorageService } from '../services/storage';
import { notifyUserVaultSync } from '../durable/notifications-hub';
import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { deleteAllAttachmentsForCipher } from './attachments';
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
import { readActingDeviceIdentifier } from '../utils/device';
async function notifyVaultSyncForRequest(
request: Request,
env: Env,
userId: string,
revisionDate: string
): Promise<void> {
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
function getAliasedProp(source: any, aliases: string[]): { present: boolean; value: any } {
if (!source || typeof source !== 'object') return { present: false, value: undefined };
@@ -15,28 +26,129 @@ function getAliasedProp(source: any, aliases: string[]): { present: boolean; val
return { present: false, value: undefined };
}
// Android 2026.2.0 expects fido2Credentials[].counter to be a string.
export function normalizeCipherLoginForCompatibility(login: any): any {
if (!login || typeof login !== 'object') return login ?? null;
function normalizeCipherTimestamp(value: unknown): string | null {
if (value == null || value === '') return null;
const parsed = new Date(String(value));
if (Number.isNaN(parsed.getTime())) return null;
return parsed.toISOString();
}
const fido2 = Array.isArray(login.fido2Credentials)
? login.fido2Credentials.map((cred: any) => {
if (!cred || typeof cred !== 'object') return cred;
const rawCounter = cred.counter;
const counter =
rawCounter === null || rawCounter === undefined
? '0'
: String(rawCounter);
return {
...cred,
counter,
};
})
: login.fido2Credentials;
function readCipherArchivedAt(source: any, fallback: string | null = null): string | null {
const archived = getAliasedProp(source, ['archivedAt', 'ArchivedAt', 'archivedDate', 'ArchivedDate']);
return archived.present ? normalizeCipherTimestamp(archived.value) : fallback;
}
function syncCipherComputedAliases(cipher: Cipher): Cipher {
cipher.archivedDate = cipher.archivedAt ?? null;
cipher.deletedDate = cipher.deletedAt ?? null;
return cipher;
}
function normalizeCipherForStorage(cipher: Cipher): Cipher {
cipher.login = normalizeCipherLoginForStorage(cipher.login);
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
const hasArchivedAt = Object.prototype.hasOwnProperty.call(cipher as object, 'archivedAt');
cipher.archivedAt = hasArchivedAt
? normalizeCipherTimestamp(cipher.archivedAt) ?? null
: normalizeCipherTimestamp(cipher.archivedDate) ?? null;
return syncCipherComputedAliases(cipher);
}
function looksLikeCipherString(value: unknown): boolean {
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
}
export function shouldOmitPasskeysForResponse(request: Request | null | undefined): boolean {
const userAgent = String(request?.headers.get('user-agent') || '').toLowerCase();
if (!userAgent) return false;
// Temporary compatibility fallback:
// mobile clients expect official EncString payloads for most FIDO2 fields.
// Keep passkeys available everywhere, but suppress only legacy malformed data
// for mobile clients so newly-saved credentials can flow through unchanged.
return (
userAgent.includes('android') ||
userAgent.includes('iphone') ||
userAgent.includes('ipad') ||
userAgent.includes('ios')
);
}
export function normalizeCipherLoginForStorage(login: any): any {
if (!login || typeof login !== 'object') return login ?? null;
return {
...login,
fido2Credentials: fido2,
fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
};
}
export function normalizeCipherLoginForCompatibility(
login: any,
options?: { omitFido2Credentials?: boolean }
): any {
const normalized = normalizeCipherLoginForStorage(login);
if (!normalized || typeof normalized !== 'object') return normalized ?? null;
if (!options?.omitFido2Credentials) return normalized;
const credentials = Array.isArray(normalized.fido2Credentials) ? normalized.fido2Credentials : null;
if (!credentials?.length) return normalized;
const hasMalformedCredential = credentials.some((credential: any) => {
if (!credential || typeof credential !== 'object') return true;
const requiredEncryptedFields = [
credential.credentialId,
credential.keyType,
credential.keyAlgorithm,
credential.keyCurve,
credential.keyValue,
credential.rpId,
credential.counter,
credential.discoverable,
];
const optionalEncryptedFields = [
credential.userHandle,
credential.userName,
credential.rpName,
credential.userDisplayName,
];
if (requiredEncryptedFields.some((value) => !looksLikeCipherString(value))) {
return true;
}
if (optionalEncryptedFields.some((value) => value != null && !looksLikeCipherString(value))) {
return true;
}
return false;
});
return hasMalformedCredential
? {
...normalized,
fido2Credentials: null,
}
: normalized;
}
// Android 2026.2.0 requires sshKey.keyFingerprint in sync payloads.
// Keep legacy alias "fingerprint" in parallel for older web payloads.
export function normalizeCipherSshKeyForCompatibility(sshKey: any): any {
if (!sshKey || typeof sshKey !== 'object') return sshKey ?? null;
const candidate =
sshKey.keyFingerprint !== undefined && sshKey.keyFingerprint !== null
? sshKey.keyFingerprint
: sshKey.fingerprint;
const normalizedFingerprint =
candidate === undefined || candidate === null
? ''
: String(candidate);
return {
...sshKey,
keyFingerprint: normalizedFingerprint,
fingerprint: normalizedFingerprint,
};
}
@@ -59,10 +171,15 @@ export function formatAttachments(attachments: Attachment[]): any[] | null {
// Uses opaque passthrough: spreads ALL stored fields (including unknown/future ones),
// then overlays server-computed fields. This ensures new Bitwarden client fields
// survive a round-trip without code changes.
export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): CipherResponse {
export function cipherToResponse(
cipher: Cipher,
attachments: Attachment[] = [],
options?: { omitFido2Credentials?: boolean }
): CipherResponse {
// Strip internal-only fields that must not appear in the API response
const { userId, createdAt, updatedAt, deletedAt, ...passthrough } = cipher;
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null);
const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher;
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null, options);
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
return {
// Pass through ALL stored cipher fields (known + unknown)
@@ -74,7 +191,7 @@ export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = [])
creationDate: createdAt,
revisionDate: updatedAt,
deletedDate: deletedAt,
archivedDate: null,
archivedDate: archivedAt ?? null,
edit: true,
viewPassword: true,
permissions: {
@@ -85,6 +202,7 @@ export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = [])
collectionIds: [],
attachments: formatAttachments(attachments),
login: normalizedLogin,
sshKey: normalizedSshKey,
encryptedFor: null,
};
}
@@ -95,6 +213,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
const url = new URL(request.url);
const includeDeleted = url.searchParams.get('deleted') === 'true';
const pagination = parsePagination(url);
const omitFido2Credentials = shouldOmitPasskeysForResponse(request);
let filteredCiphers: Cipher[];
let continuationToken: string | null = null;
@@ -121,7 +240,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
const cipherResponses = [];
for (const cipher of filteredCiphers) {
const attachments = attachmentsByCipher.get(cipher.id) || [];
cipherResponses.push(cipherToResponse(cipher, attachments));
cipherResponses.push(cipherToResponse(cipher, attachments, { omitFido2Credentials }));
}
return jsonResponse({
@@ -141,7 +260,11 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
}
const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse(cipherToResponse(cipher, attachments));
return jsonResponse(
cipherToResponse(cipher, attachments, {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
);
}
async function verifyFolderOwnership(storage: StorageService, folderId: string | null | undefined, userId: string): Promise<boolean> {
@@ -178,11 +301,12 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
reprompt: cipherData.reprompt || 0,
createdAt: now,
updatedAt: now,
archivedAt: readCipherArchivedAt(cipherData, null),
deletedAt: null,
};
cipher.login = normalizeCipherLoginForCompatibility(cipher.login);
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
normalizeCipherForStorage(cipher);
// Prevent referencing a folder owned by another user.
if (cipher.folderId) {
@@ -191,9 +315,15 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
}
await storage.saveCipher(cipher);
await storage.updateRevisionDate(userId);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(cipherToResponse(cipher), 200);
return jsonResponse(
cipherToResponse(cipher, [], {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
}),
200
);
}
// PUT /api/ciphers/:id
@@ -229,9 +359,9 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
createdAt: existingCipher.createdAt,
updatedAt: new Date().toISOString(),
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
deletedAt: existingCipher.deletedAt,
};
cipher.login = normalizeCipherLoginForCompatibility(cipher.login);
// Custom fields deletion compatibility:
// - Accept both camelCase "fields" and PascalCase "Fields".
@@ -243,6 +373,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
} else if (request.method === 'PUT' || request.method === 'POST') {
cipher.fields = null;
}
normalizeCipherForStorage(cipher);
// Prevent referencing a folder owned by another user.
if (cipher.folderId) {
@@ -251,9 +382,14 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
}
await storage.saveCipher(cipher);
await storage.updateRevisionDate(userId);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(cipherToResponse(cipher));
return jsonResponse(
cipherToResponse(cipher, [], {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
);
}
// DELETE /api/ciphers/:id
@@ -268,10 +404,16 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
// Soft delete
cipher.deletedAt = new Date().toISOString();
cipher.updatedAt = cipher.deletedAt;
syncCipherComputedAliases(cipher);
await storage.saveCipher(cipher);
await storage.updateRevisionDate(userId);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(cipherToResponse(cipher));
return jsonResponse(
cipherToResponse(cipher, [], {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
);
}
// DELETE /api/ciphers/:id (compat mode)
@@ -290,7 +432,8 @@ export async function handleDeleteCipherCompat(request: Request, env: Env, userI
if (cipher.deletedAt) {
await deleteAllAttachmentsForCipher(env, id);
await storage.deleteCipher(id, userId);
await storage.updateRevisionDate(userId);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return new Response(null, { status: 204 });
}
@@ -310,7 +453,8 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
await deleteAllAttachmentsForCipher(env, id);
await storage.deleteCipher(id, userId);
await storage.updateRevisionDate(userId);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return new Response(null, { status: 204 });
}
@@ -326,10 +470,16 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
cipher.deletedAt = null;
cipher.updatedAt = new Date().toISOString();
syncCipherComputedAliases(cipher);
await storage.saveCipher(cipher);
await storage.updateRevisionDate(userId);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(cipherToResponse(cipher));
return jsonResponse(
cipherToResponse(cipher, [], {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
);
}
// PUT /api/ciphers/:id/partial - Update only favorite/folderId
@@ -359,11 +509,17 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
cipher.favorite = body.favorite;
}
cipher.updatedAt = new Date().toISOString();
syncCipherComputedAliases(cipher);
await storage.saveCipher(cipher);
await storage.updateRevisionDate(userId);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(cipherToResponse(cipher));
return jsonResponse(
cipherToResponse(cipher, [], {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
);
}
// POST/PUT /api/ciphers/move - Bulk move to folder
@@ -386,7 +542,213 @@ export async function handleBulkMoveCiphers(request: Request, env: Env, userId:
if (!folderOk) return errorResponse('Folder not found', 404);
}
await storage.bulkMoveCiphers(body.ids, body.folderId || null, userId);
const revisionDate = await storage.bulkMoveCiphers(body.ids, body.folderId || null, userId);
if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return new Response(null, { status: 204 });
}
async function buildCipherListResponse(
request: Request,
storage: StorageService,
userId: string,
ids: string[]
): Promise<Response> {
const ciphers = await storage.getCiphersByIds(ids, userId);
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map((cipher) => cipher.id));
const omitFido2Credentials = shouldOmitPasskeysForResponse(request);
return jsonResponse({
data: ciphers.map((cipher) =>
cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], {
omitFido2Credentials,
})
),
object: 'list',
continuationToken: null,
});
}
function parseCipherIdList(body: { ids?: unknown }): string[] | null {
if (!Array.isArray(body.ids)) return null;
return Array.from(new Set(body.ids.map((id) => String(id || '').trim()).filter(Boolean)));
}
// PUT/POST /api/ciphers/:id/archive
export async function handleArchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id);
if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
}
if (cipher.deletedAt) {
return errorResponse('Cannot archive a deleted cipher', 400);
}
cipher.archivedAt = new Date().toISOString();
cipher.updatedAt = cipher.archivedAt;
normalizeCipherForStorage(cipher);
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse(
cipherToResponse(cipher, attachments, {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
);
}
// PUT/POST /api/ciphers/:id/unarchive
export async function handleUnarchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id);
if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
}
cipher.archivedAt = null;
cipher.updatedAt = new Date().toISOString();
normalizeCipherForStorage(cipher);
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse(
cipherToResponse(cipher, attachments, {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
);
}
// PUT/POST /api/ciphers/archive
export async function handleBulkArchiveCiphers(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
let body: { ids?: unknown };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const ids = parseCipherIdList(body);
if (!ids) {
return errorResponse('ids array is required', 400);
}
const revisionDate = await storage.bulkArchiveCiphers(ids, userId);
if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return buildCipherListResponse(request, storage, userId, ids);
}
// PUT/POST /api/ciphers/unarchive
export async function handleBulkUnarchiveCiphers(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
let body: { ids?: unknown };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const ids = parseCipherIdList(body);
if (!ids) {
return errorResponse('ids array is required', 400);
}
const revisionDate = await storage.bulkUnarchiveCiphers(ids, userId);
if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return buildCipherListResponse(request, storage, userId, ids);
}
// POST /api/ciphers/delete - Bulk soft delete
export async function handleBulkDeleteCiphers(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
let body: { ids?: string[] };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
if (!body.ids || !Array.isArray(body.ids)) {
return errorResponse('ids array is required', 400);
}
const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId);
if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return new Response(null, { status: 204 });
}
// POST /api/ciphers/restore - Bulk restore
export async function handleBulkRestoreCiphers(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
let body: { ids?: string[] };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
if (!body.ids || !Array.isArray(body.ids)) {
return errorResponse('ids array is required', 400);
}
const revisionDate = await storage.bulkRestoreCiphers(body.ids, userId);
if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return new Response(null, { status: 204 });
}
// POST /api/ciphers/delete-permanent - Bulk permanent delete
export async function handleBulkPermanentDeleteCiphers(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
let body: { ids?: string[] };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
if (!body.ids || !Array.isArray(body.ids)) {
return errorResponse('ids array is required', 400);
}
const ids = Array.from(new Set(body.ids.map((id) => String(id || '').trim()).filter(Boolean)));
if (!ids.length) {
return new Response(null, { status: 204 });
}
for (const id of ids) {
await deleteAllAttachmentsForCipher(env, id);
}
const revisionDate = await storage.bulkDeleteCiphers(ids, userId);
if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return new Response(null, { status: 204 });
}
+332 -21
View File
@@ -1,7 +1,105 @@
import type { Device, DevicePendingAuthRequest, DeviceResponse, ProtectedDeviceResponse as ProtectedDeviceWireResponse } from '../types';
import { Env } from '../types';
import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub';
import { StorageService } from '../services/storage';
import { errorResponse, jsonResponse } from '../utils/response';
import { readKnownDeviceProbe } from '../utils/device';
import { generateUUID } from '../utils/uuid';
function normalizeIdentifier(value: string | null | undefined): string {
return String(value || '').trim();
}
function buildDevicePendingAuthRequest(value?: { id?: string | null; creationDate?: string | null } | null): DevicePendingAuthRequest | null {
if (!value?.id || !value.creationDate) return null;
return {
id: String(value.id),
creationDate: String(value.creationDate),
};
}
function isTrustedDevice(device: Pick<Device, 'encryptedUserKey' | 'encryptedPublicKey'>): boolean {
return !!(device.encryptedUserKey && device.encryptedPublicKey);
}
function buildDeviceResponse(device: Device): DeviceResponse {
const response = {
Id: device.deviceIdentifier,
id: device.deviceIdentifier,
UserId: device.userId,
userId: device.userId,
Name: device.name,
name: device.name,
Identifier: device.deviceIdentifier,
identifier: device.deviceIdentifier,
Type: device.type,
type: device.type,
CreationDate: device.createdAt,
creationDate: device.createdAt,
RevisionDate: device.updatedAt,
revisionDate: device.updatedAt,
IsTrusted: isTrustedDevice(device),
isTrusted: isTrustedDevice(device),
EncryptedUserKey: device.encryptedUserKey,
encryptedUserKey: device.encryptedUserKey,
EncryptedPublicKey: device.encryptedPublicKey,
encryptedPublicKey: device.encryptedPublicKey,
DevicePendingAuthRequest: buildDevicePendingAuthRequest(device.devicePendingAuthRequest),
devicePendingAuthRequest: buildDevicePendingAuthRequest(device.devicePendingAuthRequest),
object: 'device',
};
return response as DeviceResponse;
}
function buildProtectedDeviceResponse(device: Device): ProtectedDeviceWireResponse {
const response = {
Id: device.deviceIdentifier,
id: device.deviceIdentifier,
Name: device.name,
name: device.name,
Identifier: device.deviceIdentifier,
identifier: device.deviceIdentifier,
Type: device.type,
type: device.type,
CreationDate: device.createdAt,
creationDate: device.createdAt,
EncryptedUserKey: device.encryptedUserKey,
encryptedUserKey: device.encryptedUserKey,
EncryptedPublicKey: device.encryptedPublicKey,
encryptedPublicKey: device.encryptedPublicKey,
object: 'protectedDevice',
};
return response as ProtectedDeviceWireResponse;
}
function parseKeysBody(body: any, fallback?: Device): {
encryptedUserKey?: string | null;
encryptedPublicKey?: string | null;
encryptedPrivateKey?: string | null;
} {
return {
encryptedUserKey:
Object.prototype.hasOwnProperty.call(body || {}, 'encryptedUserKey')
? body?.encryptedUserKey ?? null
: fallback?.encryptedUserKey ?? null,
encryptedPublicKey:
Object.prototype.hasOwnProperty.call(body || {}, 'encryptedPublicKey')
? body?.encryptedPublicKey ?? null
: fallback?.encryptedPublicKey ?? null,
encryptedPrivateKey:
Object.prototype.hasOwnProperty.call(body || {}, 'encryptedPrivateKey')
? body?.encryptedPrivateKey ?? null
: fallback?.encryptedPrivateKey ?? null,
};
}
async function readJsonBody(request: Request): Promise<any> {
try {
return await request.json();
} catch {
return null;
}
}
// GET /api/devices/knowndevice
// Compatible with Bitwarden/Vaultwarden behavior:
@@ -26,29 +124,53 @@ export async function handleGetDevices(request: Request, env: Env, userId: strin
const devices = await storage.getDevicesByUserId(userId);
return jsonResponse({
data: devices.map(device => ({
id: device.deviceIdentifier,
name: device.name,
identifier: device.deviceIdentifier,
type: device.type,
creationDate: device.createdAt,
revisionDate: device.updatedAt,
object: 'device',
})),
data: devices.map((device) => buildDeviceResponse(device)),
object: 'list',
continuationToken: null,
});
}
// GET /api/devices/identifier/:deviceIdentifier
export async function handleGetDeviceByIdentifier(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
const normalized = normalizeIdentifier(deviceIdentifier);
if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB);
const device = await storage.getDevice(userId, normalized);
if (!device) {
return errorResponse('Device not found', 404);
}
return jsonResponse(buildDeviceResponse(device));
}
// GET /api/devices/:deviceIdentifier
export async function handleGetDevice(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier);
}
// GET /api/devices/authorized
// Returns known devices together with active 2FA remember-token expiry.
export async function handleGetAuthorizedDevices(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const [devices, trusted] = await Promise.all([
const [devices, trusted, onlineDeviceIdentifiers] = await Promise.all([
storage.getDevicesByUserId(userId),
storage.getTrustedDeviceTokenSummariesByUserId(userId),
getOnlineUserDevices(env, userId),
]);
const onlineSet = new Set(onlineDeviceIdentifiers);
const trustedByIdentifier = new Map<string, { expiresAt: number; tokenCount: number }>();
for (const row of trusted) {
@@ -60,12 +182,8 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
knownIdentifiers.add(device.deviceIdentifier);
const trustedInfo = trustedByIdentifier.get(device.deviceIdentifier);
return {
id: device.deviceIdentifier,
name: device.name,
identifier: device.deviceIdentifier,
type: device.type,
creationDate: device.createdAt,
revisionDate: device.updatedAt,
...buildDeviceResponse(device),
online: onlineSet.has(device.deviceIdentifier),
trusted: !!trustedInfo,
trustedTokenCount: trustedInfo?.tokenCount || 0,
trustedUntil: trustedInfo?.expiresAt ? new Date(trustedInfo.expiresAt).toISOString() : null,
@@ -75,13 +193,23 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
for (const row of trusted) {
if (knownIdentifiers.has(row.deviceIdentifier)) continue;
data.push({
id: row.deviceIdentifier,
const placeholderDevice: Device = {
userId,
deviceIdentifier: row.deviceIdentifier,
name: 'Unknown device',
identifier: row.deviceIdentifier,
type: 14,
creationDate: '',
revisionDate: '',
sessionStamp: '',
encryptedUserKey: null,
encryptedPublicKey: null,
encryptedPrivateKey: null,
devicePendingAuthRequest: null,
createdAt: '',
updatedAt: '',
};
data.push({
...buildDeviceResponse(placeholderDevice),
isTrusted: true,
online: onlineSet.has(row.deviceIdentifier),
trusted: true,
trustedTokenCount: row.tokenCount,
trustedUntil: row.expiresAt ? new Date(row.expiresAt).toISOString() : null,
@@ -133,7 +261,162 @@ export async function handleDeleteDevice(
const storage = new StorageService(env.DB);
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
await storage.deleteRefreshTokensByDevice(userId, normalized);
const deleted = await storage.deleteDevice(userId, normalized);
if (deleted) {
await notifyUserLogout(env, userId, normalized);
}
return jsonResponse({ success: deleted });
}
// DELETE /api/devices
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
const [removedTrusted, removedSessions, removedDevices] = await Promise.all([
storage.deleteTrustedTwoFactorTokensByUserId(userId),
storage.deleteRefreshTokensByUserId(userId),
storage.deleteDevicesByUserId(userId),
]);
user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await notifyUserLogout(env, userId, null);
return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices });
}
// PUT/POST /api/devices/identifier/:deviceIdentifier/keys
export async function handleUpdateDeviceKeys(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
const normalized = normalizeIdentifier(deviceIdentifier);
if (!normalized) return errorResponse('Invalid device identifier', 400);
const body = await readJsonBody(request);
const storage = new StorageService(env.DB);
const device = await storage.getDevice(userId, normalized);
if (!device) {
return errorResponse('Device not found', 404);
}
const updated = await storage.updateDeviceKeys(userId, normalized, parseKeysBody(body, device));
if (!updated) {
return errorResponse('Device not found', 404);
}
const nextDevice = await storage.getDevice(userId, normalized);
return jsonResponse(buildDeviceResponse(nextDevice || device));
}
// POST /api/devices/update-trust
export async function handleUpdateDeviceTrust(
request: Request,
env: Env,
userId: string
): Promise<Response> {
const body = await readJsonBody(request);
const storage = new StorageService(env.DB);
const currentDeviceIdentifier =
normalizeIdentifier(request.headers.get('Device-Identifier')) ||
normalizeIdentifier(request.headers.get('X-Device-Identifier'));
const updates: Array<{
deviceIdentifier: string;
keys: {
encryptedUserKey?: string | null;
encryptedPublicKey?: string | null;
encryptedPrivateKey?: string | null;
};
}> = [];
if (currentDeviceIdentifier && body?.currentDevice) {
updates.push({
deviceIdentifier: currentDeviceIdentifier,
keys: parseKeysBody(body.currentDevice, await storage.getDevice(userId, currentDeviceIdentifier) || undefined),
});
}
if (Array.isArray(body?.otherDevices)) {
for (const item of body.otherDevices) {
const deviceIdentifier = normalizeIdentifier(item?.deviceId);
if (!deviceIdentifier) continue;
updates.push({
deviceIdentifier,
keys: parseKeysBody(item, await storage.getDevice(userId, deviceIdentifier) || undefined),
});
}
}
let updatedCount = 0;
for (const update of updates) {
const ok = await storage.updateDeviceKeys(userId, update.deviceIdentifier, update.keys);
if (ok) updatedCount++;
}
return jsonResponse({ success: true, updated: updatedCount });
}
// POST /api/devices/untrust
export async function handleUntrustDevices(
request: Request,
env: Env,
userId: string
): Promise<Response> {
const body = await readJsonBody(request);
const storage = new StorageService(env.DB);
const devices = Array.isArray(body?.devices) ? body.devices.map((id: unknown) => normalizeIdentifier(String(id))) : [];
const removed = await storage.clearDeviceKeys(userId, devices);
for (const deviceIdentifier of devices) {
if (!deviceIdentifier) continue;
await storage.deleteTrustedTwoFactorTokensByDevice(userId, deviceIdentifier);
}
return jsonResponse({ success: true, removed });
}
// POST /api/devices/:deviceIdentifier/retrieve-keys
export async function handleRetrieveDeviceKeys(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
const normalized = normalizeIdentifier(deviceIdentifier);
if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB);
const device = await storage.getDevice(userId, normalized);
if (!device) {
return errorResponse('Device not found', 404);
}
return jsonResponse(buildProtectedDeviceResponse(device));
}
// POST /api/devices/:id/deactivate
export async function handleDeactivateDevice(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
const normalized = normalizeIdentifier(deviceIdentifier);
if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB);
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
await storage.deleteRefreshTokensByDevice(userId, normalized);
const deleted = await storage.deleteDevice(userId, normalized);
if (deleted) {
await notifyUserLogout(env, userId, normalized);
}
return jsonResponse({ success: deleted });
}
@@ -153,3 +436,31 @@ export async function handleUpdateDeviceToken(
return new Response(null, { status: 200 });
}
// PUT/POST /api/devices/:deviceIdentifier/web-push-auth
export async function handleUpdateDeviceWebPushAuth(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
void env;
void userId;
void deviceIdentifier;
return new Response(null, { status: 200 });
}
// PUT/POST /api/devices/:deviceIdentifier/clear-token
export async function handleClearDeviceToken(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
void env;
void userId;
void deviceIdentifier;
return new Response(null, { status: 200 });
}
+41 -3
View File
@@ -1,9 +1,20 @@
import { Env, Folder, FolderResponse } from '../types';
import { notifyUserVaultSync } from '../durable/notifications-hub';
import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response';
import { readActingDeviceIdentifier } from '../utils/device';
import { generateUUID } from '../utils/uuid';
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
async function notifyVaultSyncForRequest(
request: Request,
env: Env,
userId: string,
revisionDate: string
): Promise<void> {
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
// Convert internal folder to API response format
function folderToResponse(folder: Folder): FolderResponse {
return {
@@ -75,7 +86,8 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
};
await storage.saveFolder(folder);
await storage.updateRevisionDate(userId);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(folderToResponse(folder), 200);
}
@@ -102,7 +114,8 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
folder.updatedAt = new Date().toISOString();
await storage.saveFolder(folder);
await storage.updateRevisionDate(userId);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(folderToResponse(folder));
}
@@ -118,7 +131,32 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str
await storage.clearFolderFromCiphers(userId, id);
await storage.deleteFolder(id, userId);
await storage.updateRevisionDate(userId);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return new Response(null, { status: 204 });
}
// POST /api/folders/delete
export async function handleBulkDeleteFolders(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
let body: { ids?: string[] };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const ids = Array.isArray(body.ids) ? body.ids.map((id) => String(id || '').trim()).filter(Boolean) : [];
if (!ids.length) {
return errorResponse('Folder ids are required', 400);
}
const revisionDate = await storage.bulkDeleteFolders(ids, userId);
if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return new Response(null, { status: 204 });
}
+115 -68
View File
@@ -8,43 +8,80 @@ import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
import { createRefreshToken } from '../utils/jwt';
import { readAuthRequestDeviceInfo } from '../utils/device';
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
import { generateUUID } from '../utils/uuid';
import { issueSendAccessToken } from './sends';
import {
buildAccountKeys,
buildUserDecryptionOptions,
} from '../utils/user-decryption';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_REMEMBER = 5;
const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8;
// Android client (2026.2.x) deserializes TwoFactorProviders2 keys with -1 for recovery code.
// Keep request parsing backward-compatible with historical provider values (8 / 100).
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_LEGACY = 8;
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100;
function resolveTotpSecret(userSecret: string | null, envSecret: string | undefined): string | null {
function resolveTotpSecret(userSecret: string | null): string | null {
if (userSecret && isTotpEnabled(userSecret)) {
return userSecret;
}
if (isTotpEnabled(envSecret)) {
return envSecret!;
}
return null;
}
function buildPreloginResponse(
email: string,
kdfType: number,
kdfIterations: number,
kdfMemory: number | null,
kdfParallelism: number | null
): Record<string, unknown> {
return {
kdf: kdfType,
kdfIterations,
kdfMemory,
kdfParallelism,
KdfSettings: {
KdfType: kdfType,
Iterations: kdfIterations,
Memory: kdfMemory,
Parallelism: kdfParallelism,
},
Salt: email.toLowerCase(),
};
}
function twoFactorRequiredResponse(message: string = 'Two factor required.', includeRecoveryCode: boolean = false): Response {
const providers = includeRecoveryCode
? [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR), String(TWO_FACTOR_PROVIDER_RECOVERY_CODE)]
? [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR), TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE]
: [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)];
const providers2: Record<string, null> = {};
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',
@@ -106,6 +143,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const grantType = body.grant_type;
const clientIdentifier = getClientIdentifier(request);
if (!clientIdentifier) {
return identityErrorResponse('Client IP is required', 'invalid_request', 403);
}
if (grantType === 'password') {
// Login with password
@@ -151,9 +191,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
);
}
// Optional 2FA: enabled per-user secret first, then falls back to global env secret for compatibility.
// Optional 2FA: enabled only by per-user secret.
let trustedTwoFactorTokenToReturn: string | undefined;
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret, env.TOTP_SECRET);
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret);
if (effectiveTotpSecret) {
const canUseRecoveryCode = !!user.totpRecoveryCode;
const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim();
@@ -168,13 +208,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
}
const parsedProvider = Number.parseInt(normalizedTwoFactorProvider, 10);
if (!Number.isFinite(parsedProvider)) {
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
}
let passedByRememberToken = false;
if (parsedProvider === TWO_FACTOR_PROVIDER_REMEMBER) {
if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_REMEMBER)) {
if (deviceInfo.deviceIdentifier) {
const trustedUserId = await storage.getTrustedTwoFactorDeviceTokenUserId(
normalizedTwoFactorToken,
@@ -187,12 +222,16 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
if (!passedByRememberToken) {
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
}
} else if (parsedProvider === TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken);
if (!totpOk) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
} else if (parsedProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE) {
} else if (
normalizedTwoFactorProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE ||
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE_LEGACY) ||
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST)
) {
if (!recoveryCodeEquals(normalizedTwoFactorToken, user.totpRecoveryCode)) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
@@ -220,15 +259,25 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
}
// Persist device only after successful password + (optional) 2FA verification.
if (deviceInfo.deviceIdentifier) {
await storage.upsertDevice(user.id, deviceInfo.deviceIdentifier, deviceInfo.deviceName, deviceInfo.deviceType);
const deviceSession =
deviceInfo.deviceIdentifier
? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() }
: null;
if (deviceSession) {
await storage.upsertDevice(
user.id,
deviceSession.identifier,
deviceInfo.deviceName,
deviceInfo.deviceType,
deviceSession.sessionStamp
);
}
// Successful login - clear failed attempts
await rateLimit.clearLoginAttempts(loginIdentifier);
const accessToken = await auth.generateAccessToken(user);
const refreshToken = await auth.generateRefreshToken(user.id);
const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
const response: TokenResponse = {
access_token: accessToken,
@@ -238,30 +287,22 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
Key: user.key,
PrivateKey: user.privateKey,
AccountKeys: buildAccountKeys(user),
accountKeys: buildAccountKeys(user),
Kdf: user.kdfType,
KdfIterations: user.kdfIterations,
KdfMemory: user.kdfMemory,
KdfParallelism: user.kdfParallelism,
ForcePasswordReset: false,
ResetMasterPassword: false,
MasterPasswordPolicy: {
Object: 'masterPasswordPolicy',
},
ApiUseKeyConnector: false,
scope: 'api offline_access',
unofficialServer: true,
UserDecryptionOptions: {
HasMasterPassword: true,
Object: 'userDecryptionOptions',
MasterPasswordUnlock: {
Kdf: {
KdfType: user.kdfType,
Iterations: user.kdfIterations,
Memory: user.kdfMemory || null,
Parallelism: user.kdfParallelism || null,
},
MasterKeyEncryptedUserKey: user.key,
MasterKeyWrappedUserKey: user.key,
Salt: email, // email is already lowercased above
Object: 'masterPasswordUnlock',
},
},
UserDecryptionOptions: buildUserDecryptionOptions(user),
userDecryptionOptions: buildUserDecryptionOptions(user),
};
return jsonResponse(response);
@@ -297,7 +338,14 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
).trim() || null;
const password = String(body.password || '').trim() || null;
const result = await issueSendAccessToken(env, sendId, passwordHashB64, password);
const result = await issueSendAccessToken(
env,
sendId,
passwordHashB64,
password,
rateLimit,
`${clientIdentifier}:send-password`
);
if ('error' in result) {
return result.error;
}
@@ -310,6 +358,18 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
unofficialServer: true,
});
} else if (grantType === 'refresh_token') {
const refreshLimit = await rateLimit.consumeBudget(
`${clientIdentifier}:identity-refresh`,
LIMITS.rateLimit.refreshTokenRequestsPerMinute
);
if (!refreshLimit.allowed) {
return identityErrorResponse(
`Rate limit exceeded. Try again in ${refreshLimit.retryAfterSeconds} seconds.`,
'TooManyRequests',
429
);
}
// Refresh token
const refreshToken = body.refresh_token;
if (!refreshToken) {
@@ -328,8 +388,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
Date.now() + LIMITS.auth.refreshTokenOverlapGraceMs
);
const { accessToken, user } = result;
const newRefreshToken = await auth.generateRefreshToken(user.id);
const { accessToken, user, device } = result;
const newRefreshToken = await auth.generateRefreshToken(user.id, device);
const response: TokenResponse = {
access_token: accessToken,
@@ -338,30 +398,22 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
refresh_token: newRefreshToken,
Key: user.key,
PrivateKey: user.privateKey,
AccountKeys: buildAccountKeys(user),
accountKeys: buildAccountKeys(user),
Kdf: user.kdfType,
KdfIterations: user.kdfIterations,
KdfMemory: user.kdfMemory,
KdfParallelism: user.kdfParallelism,
ForcePasswordReset: false,
ResetMasterPassword: false,
MasterPasswordPolicy: {
Object: 'masterPasswordPolicy',
},
ApiUseKeyConnector: false,
scope: 'api offline_access',
unofficialServer: true,
UserDecryptionOptions: {
HasMasterPassword: true,
Object: 'userDecryptionOptions',
MasterPasswordUnlock: {
Kdf: {
KdfType: user.kdfType,
Iterations: user.kdfIterations,
Memory: user.kdfMemory || null,
Parallelism: user.kdfParallelism || null,
},
MasterKeyEncryptedUserKey: user.key,
MasterKeyWrappedUserKey: user.key,
Salt: user.email.toLowerCase(),
Object: 'masterPasswordUnlock',
},
},
UserDecryptionOptions: buildUserDecryptionOptions(user),
userDecryptionOptions: buildUserDecryptionOptions(user),
};
return jsonResponse(response);
@@ -396,12 +448,7 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
const kdfMemory = user?.kdfMemory ?? null;
const kdfParallelism = user?.kdfParallelism ?? null;
return jsonResponse({
kdf: kdfType,
kdfIterations: kdfIterations,
kdfMemory: kdfMemory,
kdfParallelism: kdfParallelism,
});
return jsonResponse(buildPreloginResponse(email, kdfType, kdfIterations, kdfMemory, kdfParallelism));
}
// POST /identity/connect/revocation
+27 -8
View File
@@ -1,13 +1,16 @@
import { Env, Cipher, Folder, CipherType } from '../types';
import { notifyUserVaultSync } from '../durable/notifications-hub';
import { StorageService } from '../services/storage';
import { errorResponse } from '../utils/response';
import { errorResponse, jsonResponse } from '../utils/response';
import { readActingDeviceIdentifier } from '../utils/device';
import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits';
import { normalizeCipherLoginForCompatibility } from './ciphers';
import { normalizeCipherLoginForStorage, normalizeCipherSshKeyForCompatibility } from './ciphers';
// Bitwarden client import request format
interface CiphersImportRequest {
ciphers: Array<{
id?: string | null;
type: number;
name?: string | null;
notes?: string | null;
@@ -90,6 +93,8 @@ async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[
// POST /api/ciphers/import - Bitwarden client import endpoint
export async function handleCiphersImport(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const url = new URL(request.url);
const returnCipherMap = url.searchParams.get('returnCipherMap') === '1';
let importData: CiphersImportRequest;
try {
@@ -151,9 +156,12 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
// Create ciphers
const cipherRows: Cipher[] = [];
const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = [];
for (let i = 0; i < ciphers.length; i++) {
const c = ciphers[i];
const folderId = cipherFolderMap.get(i) || null;
const sourceIdRaw = String(c?.id ?? '').trim();
const sourceId = sourceIdRaw || null;
const cipher: Cipher = {
...c,
@@ -220,15 +228,17 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
})) || null,
passwordHistory: c.passwordHistory ?? null,
reprompt: c.reprompt ?? 0,
sshKey: (c as any).sshKey ?? null,
sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null),
key: (c as any).key ?? null,
createdAt: now,
updatedAt: now,
archivedAt: null,
deletedAt: null,
};
cipher.login = normalizeCipherLoginForCompatibility(cipher.login);
cipher.login = normalizeCipherLoginForStorage(cipher.login);
cipherRows.push(cipher);
cipherMapRows.push({ index: i, sourceId, id: cipher.id });
}
if (cipherRows.length > 0) {
@@ -236,10 +246,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
const data = JSON.stringify(cipher);
return env.DB
.prepare(
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' +
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at'
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at'
)
.bind(
cipher.id,
@@ -254,6 +264,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
bindNull(cipher.key),
cipher.createdAt,
cipher.updatedAt,
bindNull(cipher.archivedAt),
bindNull(cipher.deletedAt)
);
});
@@ -261,7 +272,15 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
}
// Update revision date
await storage.updateRevisionDate(userId);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
if (returnCipherMap) {
return jsonResponse({
object: 'import-result',
cipherMap: cipherMapRows,
});
}
return new Response(null, { status: 200 });
}
+58
View File
@@ -0,0 +1,58 @@
import { AuthService } from '../services/auth';
import type { Env, JWTPayload } from '../types';
import { errorResponse, jsonResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
function extractAccessToken(request: Request): string | null {
const url = new URL(request.url);
const queryToken = String(url.searchParams.get('access_token') || '').trim();
if (queryToken) return queryToken;
const authHeader = String(request.headers.get('Authorization') || '').trim();
const match = authHeader.match(/^Bearer\s+(.+)$/i);
return match?.[1]?.trim() || null;
}
async function authenticateNotificationsRequest(request: Request, env: Env): Promise<JWTPayload | null> {
const accessToken = extractAccessToken(request);
if (!accessToken) return null;
const auth = new AuthService(env);
return auth.verifyAccessToken(`Bearer ${accessToken}`);
}
export async function handleNotificationsNegotiate(request: Request, env: Env): Promise<Response> {
const payload = await authenticateNotificationsRequest(request, env);
if (!payload?.sub) return errorResponse('Unauthorized', 401);
const connectionId = generateUUID();
return jsonResponse({
connectionId,
connectionToken: connectionId,
negotiateVersion: 1,
availableTransports: [
{
transport: 'WebSockets',
transferFormats: ['Text', 'Binary'],
},
],
});
}
export async function handleNotificationsHub(request: Request, env: Env): Promise<Response> {
const payload = await authenticateNotificationsRequest(request, env);
if (!payload?.sub) return errorResponse('Unauthorized', 401);
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
return errorResponse('Expected websocket', 426);
}
const userId = payload.sub;
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
const stub = env.NOTIFICATIONS_HUB.get(id);
const forwardedUrl = new URL(request.url);
forwardedUrl.searchParams.set('nw_uid', userId);
if (payload.did) {
forwardedUrl.searchParams.set('nw_did', payload.did);
}
return stub.fetch(new Request(forwardedUrl.toString(), request));
}
+691
View File
@@ -0,0 +1,691 @@
import { Env, Send, SendAuthType, SendType } from '../types';
import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response';
import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload';
import { generateUUID } from '../utils/uuid';
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
import { LIMITS } from '../config/limits';
import {
getBlobStorageMaxBytes,
getSendFileObjectKey,
putBlobObject,
deleteBlobObject,
} from '../services/blob-store';
import { createSendFileUploadToken, verifySendFileUploadToken } from '../utils/jwt';
import {
formatSize,
getAliasedProp,
normalizeEmails,
notifyVaultSyncForRequest,
parseDate,
parseFileLength,
parseInteger,
parseMaxAccessCount,
parseSendAuthType,
parseSendType,
parseStoredSendData,
sanitizeSendData,
sendToResponse,
setSendPassword,
validateDeletionDate,
} from './sends-shared';
async function processSendFileUpload(
request: Request,
env: Env,
send: Send,
fileId: string
): Promise<Response> {
const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.send.maxFileSizeBytes);
const sendData = parseStoredSendData(send);
const expectedFileId = typeof sendData.id === 'string' ? sendData.id : null;
if (!expectedFileId || expectedFileId !== fileId) {
return errorResponse('Send file does not match send data.', 400);
}
const expectedFileName = typeof sendData.fileName === 'string' ? sendData.fileName : null;
const expectedSize = parseInteger(sendData.size);
const upload = await parseDirectUploadPayload(request, {
expectedSize,
expectedFileName,
maxFileSize,
tooLargeMessage: 'Send storage limit exceeded with this file',
sizeMismatchMessage: 'Send file size does not match.',
fileNameMismatchMessage: 'Send file name does not match.',
});
if (upload instanceof Response) {
return upload;
}
try {
await putBlobObject(env, getSendFileObjectKey(send.id, fileId), upload.body, {
size: upload.size,
contentType: upload.contentType,
customMetadata: {
sendId: send.id,
fileId,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('KV object too large')) {
return errorResponse('Send storage limit exceeded with this file', 413);
}
return errorResponse('Attachment storage is not configured', 500);
}
const storage = new StorageService(env.DB);
const revisionDate = await storage.updateRevisionDate(send.userId);
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
return new Response(null, { status: 201 });
}
export async function handleGetSends(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const url = new URL(request.url);
const pagination = parsePagination(url);
let sends: Send[];
let continuationToken: string | null = null;
if (pagination) {
const pageRows = await storage.getSendsPage(userId, pagination.limit + 1, pagination.offset);
const hasNext = pageRows.length > pagination.limit;
sends = hasNext ? pageRows.slice(0, pagination.limit) : pageRows;
continuationToken = hasNext ? encodeContinuationToken(pagination.offset + sends.length) : null;
} else {
sends = await storage.getAllSends(userId);
}
return jsonResponse({
data: sends.map(sendToResponse),
object: 'list',
continuationToken,
});
}
export async function handleGetSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId);
if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404);
}
return jsonResponse(sendToResponse(send));
}
export async function handleCreateSend(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
let body: unknown;
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const typeRaw = getAliasedProp(body, ['type', 'Type']);
const sendType = parseSendType(typeRaw.value);
if (sendType === null) {
return errorResponse('Invalid Send type', 400);
}
if (sendType === SendType.File) {
return errorResponse('File sends should use /api/sends/file/v2', 400);
}
const nameRaw = getAliasedProp(body, ['name', 'Name']);
const keyRaw = getAliasedProp(body, ['key', 'Key']);
const deletionDateRaw = getAliasedProp(body, ['deletionDate', 'DeletionDate']);
const textRaw = getAliasedProp(body, ['text', 'Text']);
if (typeof nameRaw.value !== 'string' || !nameRaw.value.trim()) {
return errorResponse('Name is required', 400);
}
if (typeof keyRaw.value !== 'string' || !keyRaw.value.trim()) {
return errorResponse('Key is required', 400);
}
const deletionDate = parseDate(deletionDateRaw.value);
if (!deletionDate) {
return errorResponse('Invalid deletionDate', 400);
}
const deletionValidation = validateDeletionDate(deletionDate);
if (deletionValidation) return deletionValidation;
const sendData = sanitizeSendData(textRaw.value);
if (!sendData) {
return errorResponse('Send data not provided', 400);
}
const maxAccessRaw = getAliasedProp(body, ['maxAccessCount', 'MaxAccessCount']);
const maxAccess = parseMaxAccessCount(maxAccessRaw.value);
if (!maxAccess.ok) return maxAccess.response;
const expirationRaw = getAliasedProp(body, ['expirationDate', 'ExpirationDate']);
const expirationDate = expirationRaw.value === null || expirationRaw.value === undefined
? null
: parseDate(expirationRaw.value);
if (expirationRaw.value !== null && expirationRaw.value !== undefined && !expirationDate) {
return errorResponse('Invalid expirationDate', 400);
}
const disabledRaw = getAliasedProp(body, ['disabled', 'Disabled']);
const hideEmailRaw = getAliasedProp(body, ['hideEmail', 'HideEmail']);
const notesRaw = getAliasedProp(body, ['notes', 'Notes']);
const passwordRaw = getAliasedProp(body, ['password', 'Password']);
const authTypeRaw = getAliasedProp(body, ['authType', 'AuthType']);
const emailsRaw = getAliasedProp(body, ['emails', 'Emails']);
const requestedAuthType = parseSendAuthType(authTypeRaw.value);
if (authTypeRaw.present && requestedAuthType === null) {
return errorResponse('Invalid authType', 400);
}
const normalizedEmails = normalizeEmails(emailsRaw.value);
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
return errorResponse('Invalid emails', 400);
}
const now = new Date().toISOString();
const send: Send = {
id: generateUUID(),
userId,
type: sendType,
name: nameRaw.value.trim(),
notes: typeof notesRaw.value === 'string' ? notesRaw.value : null,
data: JSON.stringify(sendData),
key: keyRaw.value,
passwordHash: null,
passwordSalt: null,
passwordIterations: null,
authType: requestedAuthType ?? SendAuthType.None,
emails: normalizedEmails,
maxAccessCount: maxAccess.value,
accessCount: 0,
disabled: typeof disabledRaw.value === 'boolean' ? disabledRaw.value : false,
hideEmail: typeof hideEmailRaw.value === 'boolean' ? hideEmailRaw.value : null,
createdAt: now,
updatedAt: now,
expirationDate: expirationDate ? expirationDate.toISOString() : null,
deletionDate: deletionDate.toISOString(),
};
if (typeof passwordRaw.value === 'string' && passwordRaw.value.length > 0) {
await setSendPassword(send, passwordRaw.value);
} else if (send.authType === SendAuthType.Password) {
return errorResponse('Password is required for password auth', 400);
}
if (send.authType !== SendAuthType.Email) {
send.emails = null;
}
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(sendToResponse(send));
}
export async function handleCreateFileSendV2(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.send.maxFileSizeBytes);
let body: unknown;
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const typeRaw = getAliasedProp(body, ['type', 'Type']);
const sendType = parseSendType(typeRaw.value);
if (sendType !== SendType.File) {
return errorResponse('Send content is not a file', 400);
}
const fileLengthRaw = getAliasedProp(body, ['fileLength', 'FileLength']);
const fileLengthParsed = parseFileLength(fileLengthRaw.value);
if (!fileLengthParsed.ok) return fileLengthParsed.response;
if (fileLengthParsed.value > maxFileSize) {
return errorResponse('Send storage limit exceeded with this file', 400);
}
const nameRaw = getAliasedProp(body, ['name', 'Name']);
const keyRaw = getAliasedProp(body, ['key', 'Key']);
const deletionDateRaw = getAliasedProp(body, ['deletionDate', 'DeletionDate']);
const fileRaw = getAliasedProp(body, ['file', 'File']);
if (typeof nameRaw.value !== 'string' || !nameRaw.value.trim()) {
return errorResponse('Name is required', 400);
}
if (typeof keyRaw.value !== 'string' || !keyRaw.value.trim()) {
return errorResponse('Key is required', 400);
}
const deletionDate = parseDate(deletionDateRaw.value);
if (!deletionDate) {
return errorResponse('Invalid deletionDate', 400);
}
const deletionValidation = validateDeletionDate(deletionDate);
if (deletionValidation) return deletionValidation;
const fileData = sanitizeSendData(fileRaw.value);
if (!fileData) {
return errorResponse('Send data not provided', 400);
}
const fileId = generateUUID();
fileData.id = fileId;
fileData.size = fileLengthParsed.value;
fileData.sizeName = formatSize(fileLengthParsed.value);
const maxAccessRaw = getAliasedProp(body, ['maxAccessCount', 'MaxAccessCount']);
const maxAccess = parseMaxAccessCount(maxAccessRaw.value);
if (!maxAccess.ok) return maxAccess.response;
const expirationRaw = getAliasedProp(body, ['expirationDate', 'ExpirationDate']);
const expirationDate = expirationRaw.value === null || expirationRaw.value === undefined
? null
: parseDate(expirationRaw.value);
if (expirationRaw.value !== null && expirationRaw.value !== undefined && !expirationDate) {
return errorResponse('Invalid expirationDate', 400);
}
const disabledRaw = getAliasedProp(body, ['disabled', 'Disabled']);
const hideEmailRaw = getAliasedProp(body, ['hideEmail', 'HideEmail']);
const notesRaw = getAliasedProp(body, ['notes', 'Notes']);
const passwordRaw = getAliasedProp(body, ['password', 'Password']);
const authTypeRaw = getAliasedProp(body, ['authType', 'AuthType']);
const emailsRaw = getAliasedProp(body, ['emails', 'Emails']);
const requestedAuthType = parseSendAuthType(authTypeRaw.value);
if (authTypeRaw.present && requestedAuthType === null) {
return errorResponse('Invalid authType', 400);
}
const normalizedEmails = normalizeEmails(emailsRaw.value);
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
return errorResponse('Invalid emails', 400);
}
const now = new Date().toISOString();
const send: Send = {
id: generateUUID(),
userId,
type: sendType,
name: nameRaw.value.trim(),
notes: typeof notesRaw.value === 'string' ? notesRaw.value : null,
data: JSON.stringify(fileData),
key: keyRaw.value,
passwordHash: null,
passwordSalt: null,
passwordIterations: null,
authType: requestedAuthType ?? SendAuthType.None,
emails: normalizedEmails,
maxAccessCount: maxAccess.value,
accessCount: 0,
disabled: typeof disabledRaw.value === 'boolean' ? disabledRaw.value : false,
hideEmail: typeof hideEmailRaw.value === 'boolean' ? hideEmailRaw.value : null,
createdAt: now,
updatedAt: now,
expirationDate: expirationDate ? expirationDate.toISOString() : null,
deletionDate: deletionDate.toISOString(),
};
if (typeof passwordRaw.value === 'string' && passwordRaw.value.length > 0) {
await setSendPassword(send, passwordRaw.value);
} else if (send.authType === SendAuthType.Password) {
return errorResponse('Password is required for password auth', 400);
}
if (send.authType !== SendAuthType.Email) {
send.emails = null;
}
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
const jwtSecret = getSafeJwtSecret(env);
if (!jwtSecret) {
return errorResponse('Server configuration error', 500);
}
const uploadToken = await createSendFileUploadToken(userId, send.id, fileId, jwtSecret);
return jsonResponse({
fileUploadType: 1,
object: 'send-fileUpload',
url: buildDirectUploadUrl(request, `/api/sends/${send.id}/file/${fileId}`, uploadToken),
sendResponse: sendToResponse(send),
});
}
export async function handleGetSendFileUpload(
request: Request,
env: Env,
userId: string,
sendId: string,
fileId: string
): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId);
if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404);
}
if (send.type !== SendType.File) {
return errorResponse('Send is not a file type send.', 400);
}
const sendData = parseStoredSendData(send);
const expectedFileId = typeof sendData.id === 'string' ? sendData.id : null;
if (!expectedFileId || expectedFileId !== fileId) {
return errorResponse('Send file does not match send data.', 400);
}
const jwtSecret = getSafeJwtSecret(env);
if (!jwtSecret) {
return errorResponse('Server configuration error', 500);
}
const uploadToken = await createSendFileUploadToken(userId, send.id, fileId, jwtSecret);
return jsonResponse({
fileUploadType: 1,
object: 'send-fileUpload',
url: buildDirectUploadUrl(request, `/api/sends/${send.id}/file/${fileId}`, uploadToken),
sendResponse: sendToResponse(send),
});
}
export async function handleUploadSendFile(
request: Request,
env: Env,
userId: string,
sendId: string,
fileId: string
): Promise<Response> {
const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId);
if (!send || send.userId !== userId) {
return errorResponse('Send not found. Unable to save the file.', 404);
}
if (send.type !== SendType.File) {
return errorResponse('Send is not a file type send.', 400);
}
return processSendFileUpload(request, env, send, fileId);
}
export async function handlePublicUploadSendFile(
request: Request,
env: Env,
sendId: string,
fileId: string
): Promise<Response> {
const jwtSecret = getSafeJwtSecret(env);
if (!jwtSecret) {
return errorResponse('Server configuration error', 500);
}
const token = new URL(request.url).searchParams.get('token');
if (!token) {
return errorResponse('Token required', 401);
}
const claims = await verifySendFileUploadToken(token, jwtSecret);
if (!claims) {
return errorResponse('Invalid or expired token', 401);
}
if (claims.sendId !== sendId || claims.fileId !== fileId) {
return errorResponse('Token mismatch', 401);
}
const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId);
if (!send || send.userId !== claims.userId) {
return errorResponse('Send not found. Unable to save the file.', 404);
}
if (send.type !== SendType.File) {
return errorResponse('Send is not a file type send.', 400);
}
return processSendFileUpload(request, env, send, fileId);
}
export async function handleUpdateSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId);
if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404);
}
let body: unknown;
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const typeRaw = getAliasedProp(body, ['type', 'Type']);
if (typeRaw.present) {
const incomingType = parseSendType(typeRaw.value);
if (incomingType === null) {
return errorResponse('Invalid Send type', 400);
}
if (incomingType !== send.type) {
return errorResponse("Sends can't change type", 400);
}
}
const deletionRaw = getAliasedProp(body, ['deletionDate', 'DeletionDate']);
if (deletionRaw.present) {
const deletionDate = parseDate(deletionRaw.value);
if (!deletionDate) return errorResponse('Invalid deletionDate', 400);
const deletionValidation = validateDeletionDate(deletionDate);
if (deletionValidation) return deletionValidation;
send.deletionDate = deletionDate.toISOString();
}
const expirationRaw = getAliasedProp(body, ['expirationDate', 'ExpirationDate']);
if (expirationRaw.present) {
if (expirationRaw.value === null || expirationRaw.value === '') {
send.expirationDate = null;
} else {
const expiration = parseDate(expirationRaw.value);
if (!expiration) return errorResponse('Invalid expirationDate', 400);
send.expirationDate = expiration.toISOString();
}
}
const nameRaw = getAliasedProp(body, ['name', 'Name']);
if (nameRaw.present) {
if (typeof nameRaw.value !== 'string' || !nameRaw.value.trim()) {
return errorResponse('Name is required', 400);
}
send.name = nameRaw.value.trim();
}
const keyRaw = getAliasedProp(body, ['key', 'Key']);
if (keyRaw.present) {
if (typeof keyRaw.value !== 'string' || !keyRaw.value.trim()) {
return errorResponse('Key is required', 400);
}
send.key = keyRaw.value;
}
const notesRaw = getAliasedProp(body, ['notes', 'Notes']);
if (notesRaw.present) {
send.notes = typeof notesRaw.value === 'string' ? notesRaw.value : null;
}
const disabledRaw = getAliasedProp(body, ['disabled', 'Disabled']);
if (disabledRaw.present) {
if (typeof disabledRaw.value !== 'boolean') {
return errorResponse('Invalid disabled', 400);
}
send.disabled = disabledRaw.value;
}
const hideEmailRaw = getAliasedProp(body, ['hideEmail', 'HideEmail']);
if (hideEmailRaw.present) {
if (hideEmailRaw.value === null) {
send.hideEmail = null;
} else if (typeof hideEmailRaw.value === 'boolean') {
send.hideEmail = hideEmailRaw.value;
} else {
return errorResponse('Invalid hideEmail', 400);
}
}
const maxAccessRaw = getAliasedProp(body, ['maxAccessCount', 'MaxAccessCount']);
if (maxAccessRaw.present) {
const parsedMax = parseMaxAccessCount(maxAccessRaw.value);
if (!parsedMax.ok) return parsedMax.response;
send.maxAccessCount = parsedMax.value;
}
if (send.type === SendType.Text) {
const textRaw = getAliasedProp(body, ['text', 'Text']);
if (textRaw.present) {
const textData = sanitizeSendData(textRaw.value);
if (!textData) {
return errorResponse('Send data not provided', 400);
}
send.data = JSON.stringify(textData);
}
}
const authTypeRaw = getAliasedProp(body, ['authType', 'AuthType']);
if (authTypeRaw.present) {
const parsedAuthType = parseSendAuthType(authTypeRaw.value);
if (parsedAuthType === null) {
return errorResponse('Invalid authType', 400);
}
send.authType = parsedAuthType;
if (parsedAuthType !== SendAuthType.Email) {
send.emails = null;
}
}
const emailsRaw = getAliasedProp(body, ['emails', 'Emails']);
if (emailsRaw.present) {
const normalizedEmails = normalizeEmails(emailsRaw.value);
if (emailsRaw.value !== null && normalizedEmails === null) {
return errorResponse('Invalid emails', 400);
}
send.emails = normalizedEmails;
if (send.emails) {
send.authType = SendAuthType.Email;
} else if (send.authType === SendAuthType.Email) {
send.authType = SendAuthType.None;
}
}
const passwordRaw = getAliasedProp(body, ['password', 'Password']);
if (passwordRaw.present && typeof passwordRaw.value === 'string') {
await setSendPassword(send, passwordRaw.value);
}
if (send.authType === SendAuthType.Password && !send.passwordHash) {
return errorResponse('Password is required for password auth', 400);
}
send.updatedAt = new Date().toISOString();
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(sendToResponse(send));
}
export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId);
if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404);
}
if (send.type === SendType.File) {
const data = parseStoredSendData(send);
const fileId = typeof data.id === 'string' ? data.id : null;
if (fileId) {
await deleteBlobObject(env, getSendFileObjectKey(send.id, fileId));
}
}
await storage.deleteSend(sendId, userId);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return new Response(null, { status: 200 });
}
export async function handleBulkDeleteSends(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
let body: { ids?: string[] };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
if (!body.ids || !Array.isArray(body.ids)) {
return errorResponse('ids array is required', 400);
}
const sends = await storage.getSendsByIds(body.ids, userId);
for (const send of sends) {
if (send.type !== SendType.File) continue;
const data = parseStoredSendData(send);
const fileId = typeof data.id === 'string' ? data.id : null;
if (fileId) {
await deleteBlobObject(env, getSendFileObjectKey(send.id, fileId));
}
}
const revisionDate = await storage.bulkDeleteSends(body.ids, userId);
if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return new Response(null, { status: 200 });
}
export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId);
if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404);
}
await setSendPassword(send, null);
send.updatedAt = new Date().toISOString();
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(sendToResponse(send));
}
export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId);
if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404);
}
send.authType = SendAuthType.None;
send.emails = null;
send.updatedAt = new Date().toISOString();
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(sendToResponse(send));
}
+400
View File
@@ -0,0 +1,400 @@
import { Env, SendType } from '../types';
import { StorageService } from '../services/storage';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse } from '../utils/response';
import { LIMITS } from '../config/limits';
import {
createSendAccessToken,
createSendFileDownloadToken,
verifySendAccessToken,
verifySendFileDownloadToken,
} from '../utils/jwt';
import {
getBlobObject,
getSendFileObjectKey,
} from '../services/blob-store';
import {
SEND_INACCESSIBLE_MSG,
extractBearerToken,
fromAccessId,
getCreatorIdentifier,
getSafeJwtSecret,
hasEmailAuth,
isSendAvailable,
notifyVaultSyncForRequest,
parseStoredSendData,
resolveSendFromIdOrAccessId,
sendPasswordLimitKey,
sendPasswordLockedErrorResponse,
sendPasswordLockedOAuthResponse,
sendToAccessResponse,
validatePublicSendAccess,
verifySendPassword,
verifySendPasswordHashB64,
} from './sends-shared';
export async function handleAccessSend(request: Request, env: Env, accessId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const sendId = fromAccessId(accessId);
if (!sendId) {
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
const send = await storage.getSend(sendId);
if (!send || !isSendAvailable(send)) {
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
let body: unknown = {};
try {
body = await request.json();
} catch {
body = {};
}
let sendPasswordLimitIpKey: string | null = null;
let sendPasswordRateLimit: RateLimitService | null = null;
if (send.passwordHash) {
const clientIdentifier = getClientIdentifier(request);
if (!clientIdentifier) {
return errorResponse('Client IP is required', 403);
}
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier);
sendPasswordRateLimit = new RateLimitService(env.DB);
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
if (!sendPasswordCheck.allowed) {
return sendPasswordLockedErrorResponse(sendPasswordCheck.retryAfterSeconds || 60);
}
}
const validation = await validatePublicSendAccess(send, body);
if (!validation.ok) {
if (validation.reason === 'invalid_password' && sendPasswordRateLimit && sendPasswordLimitIpKey) {
const failed = await sendPasswordRateLimit.recordFailedLogin(sendPasswordLimitIpKey);
if (failed.locked) {
return sendPasswordLockedErrorResponse(failed.retryAfterSeconds || 60);
}
}
return validation.response;
}
if (send.passwordHash && sendPasswordRateLimit && sendPasswordLimitIpKey) {
await sendPasswordRateLimit.clearLoginAttempts(sendPasswordLimitIpKey);
}
if (send.type === SendType.Text) {
const updated = await storage.incrementSendAccessCount(send.id);
if (!updated) {
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
send.accessCount += 1;
const revisionDate = await storage.updateRevisionDate(send.userId);
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
}
const creatorIdentifier = await getCreatorIdentifier(storage, send);
return jsonResponse(sendToAccessResponse(send, creatorIdentifier));
}
export async function handleAccessSendFile(
request: Request,
env: Env,
idOrAccessId: string,
fileId: string
): Promise<Response> {
const secret = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) {
return errorResponse('Server configuration error', 500);
}
const storage = new StorageService(env.DB);
const send = await resolveSendFromIdOrAccessId(storage, idOrAccessId);
if (!send || !isSendAvailable(send) || send.type !== SendType.File) {
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
const data = parseStoredSendData(send);
const expectedFileId = typeof data.id === 'string' ? data.id : null;
if (!expectedFileId || expectedFileId !== fileId) {
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
let body: unknown = {};
try {
body = await request.json();
} catch {
body = {};
}
let sendPasswordLimitIpKey: string | null = null;
let sendPasswordRateLimit: RateLimitService | null = null;
if (send.passwordHash) {
const clientIdentifier = getClientIdentifier(request);
if (!clientIdentifier) {
return errorResponse('Client IP is required', 403);
}
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier);
sendPasswordRateLimit = new RateLimitService(env.DB);
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
if (!sendPasswordCheck.allowed) {
return sendPasswordLockedErrorResponse(sendPasswordCheck.retryAfterSeconds || 60);
}
}
const validation = await validatePublicSendAccess(send, body);
if (!validation.ok) {
if (validation.reason === 'invalid_password' && sendPasswordRateLimit && sendPasswordLimitIpKey) {
const failed = await sendPasswordRateLimit.recordFailedLogin(sendPasswordLimitIpKey);
if (failed.locked) {
return sendPasswordLockedErrorResponse(failed.retryAfterSeconds || 60);
}
}
return validation.response;
}
if (send.passwordHash && sendPasswordRateLimit && sendPasswordLimitIpKey) {
await sendPasswordRateLimit.clearLoginAttempts(sendPasswordLimitIpKey);
}
const updated = await storage.incrementSendAccessCount(send.id);
if (!updated) {
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
send.accessCount += 1;
const revisionDate = await storage.updateRevisionDate(send.userId);
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
const token = await createSendFileDownloadToken(send.id, fileId, secret);
const url = new URL(request.url);
const downloadUrl = `${url.origin}/api/sends/${send.id}/${fileId}?t=${token}`;
return jsonResponse({
object: 'send-fileDownload',
id: fileId,
url: downloadUrl,
});
}
export async function handleAccessSendV2(request: Request, env: Env): Promise<Response> {
const jwt = getSafeJwtSecret(env);
if (!jwt.ok) return jwt.response;
const token = extractBearerToken(request);
if (!token) {
return errorResponse('Unauthorized', 401);
}
const claims = await verifySendAccessToken(token, jwt.secret);
if (!claims) {
return errorResponse('Unauthorized', 401);
}
const storage = new StorageService(env.DB);
const send = await storage.getSend(claims.sub);
if (!send || !isSendAvailable(send)) {
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
if (send.type === SendType.Text) {
const updated = await storage.incrementSendAccessCount(send.id);
if (!updated) {
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
send.accessCount += 1;
const revisionDate = await storage.updateRevisionDate(send.userId);
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
}
const creatorIdentifier = await getCreatorIdentifier(storage, send);
return jsonResponse(sendToAccessResponse(send, creatorIdentifier));
}
export async function handleAccessSendFileV2(request: Request, env: Env, fileId: string): Promise<Response> {
const jwt = getSafeJwtSecret(env);
if (!jwt.ok) return jwt.response;
const token = extractBearerToken(request);
if (!token) {
return errorResponse('Unauthorized', 401);
}
const claims = await verifySendAccessToken(token, jwt.secret);
if (!claims) {
return errorResponse('Unauthorized', 401);
}
const storage = new StorageService(env.DB);
const send = await storage.getSend(claims.sub);
if (!send || !isSendAvailable(send) || send.type !== SendType.File) {
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
const data = parseStoredSendData(send);
const expectedFileId = typeof data.id === 'string' ? data.id : null;
if (!expectedFileId || expectedFileId !== fileId) {
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
const updated = await storage.incrementSendAccessCount(send.id);
if (!updated) {
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
send.accessCount += 1;
const revisionDate = await storage.updateRevisionDate(send.userId);
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
const downloadToken = await createSendFileDownloadToken(send.id, fileId, jwt.secret);
const url = new URL(request.url);
const downloadUrl = `${url.origin}/api/sends/${send.id}/${fileId}?t=${downloadToken}`;
return jsonResponse({
object: 'send-fileDownload',
id: fileId,
url: downloadUrl,
});
}
export async function handleDownloadSendFile(
request: Request,
env: Env,
sendId: string,
fileId: string
): Promise<Response> {
const jwt = getSafeJwtSecret(env);
if (!jwt.ok) return jwt.response;
const url = new URL(request.url);
const token = url.searchParams.get('t') || url.searchParams.get('token');
if (!token) {
return errorResponse('Token required', 401);
}
const claims = await verifySendFileDownloadToken(token, jwt.secret);
if (!claims) {
return errorResponse('Invalid or expired token', 401);
}
if (claims.sendId !== sendId || claims.fileId !== fileId) {
return errorResponse('Token mismatch', 401);
}
const storage = new StorageService(env.DB);
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId));
if (!object) {
return errorResponse('Send file not found', 404);
}
const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp);
if (!firstUse) {
return errorResponse('Invalid or expired token', 401);
}
return new Response(object.body, {
headers: {
'Content-Type': object.contentType || 'application/octet-stream',
'Content-Length': String(object.size),
'Cache-Control': 'private, no-cache',
},
});
}
export async function issueSendAccessToken(
env: Env,
sendIdOrAccessId: string,
passwordHashB64?: string | null,
password?: string | null,
rateLimit?: RateLimitService,
sendPasswordLimitIpKey?: string
): Promise<{ token: string } | { error: Response }> {
const jwt = getSafeJwtSecret(env);
if (!jwt.ok) {
return { error: jwt.response };
}
const storage = new StorageService(env.DB);
const send = await resolveSendFromIdOrAccessId(storage, sendIdOrAccessId);
if (!send || !isSendAvailable(send)) {
return {
error: jsonResponse(
{
error: 'invalid_grant',
error_description: SEND_INACCESSIBLE_MSG,
send_access_error_type: 'send_not_available',
ErrorModel: {
Message: SEND_INACCESSIBLE_MSG,
Object: 'error',
},
},
400
),
};
}
if (hasEmailAuth(send)) {
const message = 'Email verification for this Send is not supported by this server.';
return {
error: jsonResponse(
{
error: 'invalid_grant',
error_description: message,
send_access_error_type: 'email_verification_not_supported',
ErrorModel: {
Message: message,
Object: 'error',
},
},
400
),
};
}
if (send.passwordHash) {
if (rateLimit && sendPasswordLimitIpKey) {
const sendPasswordCheck = await rateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
if (!sendPasswordCheck.allowed) {
return {
error: sendPasswordLockedOAuthResponse(sendPasswordCheck.retryAfterSeconds || 60),
};
}
}
let ok = false;
if (passwordHashB64) {
ok = verifySendPasswordHashB64(send, passwordHashB64);
} else if (password) {
ok = await verifySendPassword(send, password);
}
if (!ok) {
if (rateLimit && sendPasswordLimitIpKey) {
const failed = await rateLimit.recordFailedLogin(sendPasswordLimitIpKey);
if (failed.locked) {
return {
error: sendPasswordLockedOAuthResponse(failed.retryAfterSeconds || 60),
};
}
}
return {
error: jsonResponse(
{
error: 'invalid_grant',
error_description: 'Invalid password.',
send_access_error_type: 'invalid_password',
ErrorModel: {
Message: 'Invalid password.',
Object: 'error',
},
},
400
),
};
}
if (rateLimit && sendPasswordLimitIpKey) {
await rateLimit.clearLoginAttempts(sendPasswordLimitIpKey);
}
}
const token = await createSendAccessToken(send.id, jwt.secret);
return { token };
}
+451
View File
@@ -0,0 +1,451 @@
import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types';
import { notifyUserVaultSync } from '../durable/notifications-hub';
import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response';
import { readActingDeviceIdentifier } from '../utils/device';
import { LIMITS } from '../config/limits';
export const SEND_INACCESSIBLE_MSG = 'Send does not exist or is no longer available';
const SEND_PASSWORD_ITERATIONS = 100_000;
export const SEND_PASSWORD_LIMIT_SCOPE = 'send-password';
export async function notifyVaultSyncForRequest(
request: Request,
env: Env,
userId: string,
revisionDate: string
): Promise<void> {
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
export function getAliasedProp(source: unknown, aliases: string[]): { present: boolean; value: unknown } {
if (!source || typeof source !== 'object') return { present: false, value: undefined };
for (const key of aliases) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
const value = (source as Record<string, unknown>)[key];
return { present: true, value };
}
}
return { present: false, value: undefined };
}
export function base64UrlEncode(data: Uint8Array): string {
const base64 = btoa(String.fromCharCode(...data));
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export function base64UrlDecode(input: string): Uint8Array | null {
try {
let normalized = input.replace(/-/g, '+').replace(/_/g, '/');
while (normalized.length % 4) normalized += '=';
const raw = atob(normalized);
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
} catch {
return null;
}
}
function uuidToBytes(uuid: string): Uint8Array | null {
const hex = uuid.replace(/-/g, '').toLowerCase();
if (!/^[0-9a-f]{32}$/.test(hex)) return null;
const bytes = new Uint8Array(16);
for (let i = 0; i < 16; i++) {
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return bytes;
}
function bytesToUuid(bytes: Uint8Array): string | null {
if (bytes.length !== 16) return null;
const hex = Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20, 32),
].join('-');
}
function toAccessId(sendId: string): string {
const bytes = uuidToBytes(sendId);
if (!bytes) return '';
return base64UrlEncode(bytes);
}
export function fromAccessId(accessId: string): string | null {
const bytes = base64UrlDecode(accessId);
if (!bytes || bytes.length !== 16) return null;
return bytesToUuid(bytes);
}
function isLikelyUuid(value: string): boolean {
return /^[a-f0-9-]{36}$/i.test(value);
}
export async function resolveSendFromIdOrAccessId(storage: StorageService, idOrAccessId: string): Promise<Send | null> {
if (isLikelyUuid(idOrAccessId)) {
const send = await storage.getSend(idOrAccessId);
if (send) return send;
}
const sendId = fromAccessId(idOrAccessId);
if (!sendId) return null;
return storage.getSend(sendId);
}
export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} Bytes`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
export function parseDate(raw: unknown): Date | null {
if (typeof raw !== 'string' || !raw.trim()) return null;
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return null;
return date;
}
export function parseInteger(raw: unknown): number | null {
if (raw === null || raw === undefined || raw === '') return null;
const value = typeof raw === 'string' ? Number(raw) : raw;
if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) return null;
return value;
}
export function sanitizeSendData(raw: unknown): Record<string, unknown> | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const data = { ...(raw as Record<string, unknown>) };
delete data.response;
return data;
}
export function parseStoredSendData(send: Send): Record<string, unknown> {
try {
const parsed = JSON.parse(send.data) as unknown;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return { ...(parsed as Record<string, unknown>) };
}
return {};
} catch {
return {};
}
}
function normalizeSendDataSizeField(data: Record<string, unknown>): Record<string, unknown> {
const normalized = { ...data };
if (typeof normalized.size === 'number' && Number.isFinite(normalized.size)) {
normalized.size = String(Math.trunc(normalized.size));
}
return normalized;
}
export function isSendAvailable(send: Send): boolean {
const now = Date.now();
if (send.maxAccessCount !== null && send.accessCount >= send.maxAccessCount) {
return false;
}
if (send.expirationDate) {
const expirationMs = new Date(send.expirationDate).getTime();
if (!Number.isNaN(expirationMs) && now >= expirationMs) {
return false;
}
}
const deletionMs = new Date(send.deletionDate).getTime();
if (!Number.isNaN(deletionMs) && now >= deletionMs) {
return false;
}
if (send.disabled) {
return false;
}
return true;
}
async function deriveSendPasswordHash(password: string, salt: Uint8Array, iterations: number): Promise<Uint8Array> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey('raw', encoder.encode(password), { name: 'PBKDF2' }, false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits(
{
name: 'PBKDF2',
salt,
iterations,
hash: 'SHA-256',
},
key,
256
);
return new Uint8Array(bits);
}
function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) {
diff |= a[i] ^ b[i];
}
return diff === 0;
}
function isLikelyHashB64(value: string): boolean {
const raw = String(value || '').trim();
if (!raw) return false;
if (!/^[A-Za-z0-9+/_=-]+$/.test(raw)) return false;
const decoded = base64UrlDecode(raw);
return !!decoded && decoded.length === 32;
}
export async function setSendPassword(send: Send, password: string | null): Promise<void> {
if (!password) {
send.passwordHash = null;
send.passwordSalt = null;
send.passwordIterations = null;
if (send.authType === SendAuthType.Password) {
send.authType = SendAuthType.None;
}
return;
}
if (isLikelyHashB64(password)) {
send.passwordHash = password.trim();
send.passwordSalt = null;
send.passwordIterations = null;
send.authType = SendAuthType.Password;
return;
}
const salt = crypto.getRandomValues(new Uint8Array(64));
const hash = await deriveSendPasswordHash(password, salt, SEND_PASSWORD_ITERATIONS);
send.passwordSalt = base64UrlEncode(salt);
send.passwordHash = base64UrlEncode(hash);
send.passwordIterations = SEND_PASSWORD_ITERATIONS;
send.authType = SendAuthType.Password;
}
export async function verifySendPassword(send: Send, password: string): Promise<boolean> {
if (!send.passwordHash) {
return false;
}
if (!send.passwordSalt || !send.passwordIterations) {
return verifySendPasswordHashB64(send, password);
}
const salt = base64UrlDecode(send.passwordSalt);
const expected = base64UrlDecode(send.passwordHash);
if (!salt || !expected) return false;
const actual = await deriveSendPasswordHash(password, salt, send.passwordIterations);
return constantTimeEqual(actual, expected);
}
export function verifySendPasswordHashB64(send: Send, passwordHashB64: string): boolean {
if (!send.passwordHash || !passwordHashB64) return false;
const expected = base64UrlDecode(send.passwordHash);
const provided = base64UrlDecode(passwordHashB64);
if (!expected || !provided) return false;
return constantTimeEqual(expected, provided);
}
export function validateDeletionDate(date: Date): Response | null {
const maxMs = Date.now() + LIMITS.send.maxDeletionDays * 24 * 60 * 60 * 1000;
if (date.getTime() > maxMs) {
return errorResponse(
'You cannot have a Send with a deletion date that far into the future. Adjust the Deletion Date to a value less than 31 days from now and try again.',
400
);
}
return null;
}
export function parseMaxAccessCount(value: unknown): { ok: true; value: number | null } | { ok: false; response: Response } {
const parsed = parseInteger(value);
if (value === undefined || value === null || value === '') {
return { ok: true, value: null };
}
if (parsed === null || parsed < 0) {
return { ok: false, response: errorResponse('Invalid maxAccessCount', 400) };
}
return { ok: true, value: parsed };
}
export function parseFileLength(value: unknown): { ok: true; value: number } | { ok: false; response: Response } {
const parsed = parseInteger(value);
if (parsed === null) {
return { ok: false, response: errorResponse('Invalid send length', 400) };
}
if (parsed < 0) {
return { ok: false, response: errorResponse("Send size can't be negative", 400) };
}
return { ok: true, value: parsed };
}
export function parseSendType(value: unknown): SendType | null {
const type = parseInteger(value);
if (type === SendType.Text || type === SendType.File) return type;
return null;
}
export function parseSendAuthType(value: unknown): SendAuthType | null {
if (value === undefined || value === null || value === '') return null;
const parsed = parseInteger(value);
if (parsed === SendAuthType.Email || parsed === SendAuthType.Password || parsed === SendAuthType.None) {
return parsed;
}
return null;
}
export function normalizeEmails(value: unknown): string | null {
if (value === null || value === undefined || value === '') return null;
if (typeof value === 'string') return value;
if (Array.isArray(value)) {
const strings = value.filter((v) => typeof v === 'string').map((v) => String(v));
if (strings.length === 0) return null;
return strings.join(',');
}
return null;
}
export function hasEmailAuth(send: Send): boolean {
return send.authType === SendAuthType.Email;
}
export function getSafeJwtSecret(env: Env): { ok: true; secret: string } | { ok: false; response: Response } {
const secret = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
return { ok: false, response: errorResponse('Server configuration error', 500) };
}
return { ok: true, secret };
}
export function extractBearerToken(request: Request): string | null {
const authHeader = request.headers.get('Authorization');
if (!authHeader) return null;
const match = authHeader.match(/^Bearer\s+(.+)$/i);
return match ? match[1].trim() : null;
}
export function sendToResponse(send: Send): SendResponse {
const data = normalizeSendDataSizeField(parseStoredSendData(send));
return {
id: send.id,
accessId: toAccessId(send.id),
type: Number(send.type) || 0,
name: send.name,
notes: send.notes,
text: send.type === SendType.Text ? data : null,
file: send.type === SendType.File ? data : null,
key: send.key,
maxAccessCount: send.maxAccessCount,
accessCount: send.accessCount,
password: send.passwordHash,
emails: send.emails,
authType: send.authType,
disabled: send.disabled,
hideEmail: send.hideEmail,
revisionDate: send.updatedAt,
expirationDate: send.expirationDate,
deletionDate: send.deletionDate,
object: 'send',
};
}
export function sendToAccessResponse(send: Send, creatorIdentifier: string | null): Record<string, unknown> {
const data = normalizeSendDataSizeField(parseStoredSendData(send));
return {
id: send.id,
type: Number(send.type) || 0,
name: send.name,
text: send.type === SendType.Text ? data : null,
file: send.type === SendType.File ? data : null,
expirationDate: send.expirationDate,
deletionDate: send.deletionDate,
creatorIdentifier,
object: 'send-access',
};
}
export async function getCreatorIdentifier(storage: StorageService, send: Send): Promise<string | null> {
if (send.hideEmail) return null;
const owner = await storage.getUserById(send.userId);
return owner?.email ?? null;
}
export type PublicSendAccessValidationResult =
| { ok: true }
| { ok: false; response: Response; reason: 'email_auth_unsupported' | 'password_missing' | 'invalid_password' };
export function sendPasswordLimitKey(clientIdentifier: string): string {
return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}`;
}
function sendPasswordLockMessage(retryAfterSeconds: number): string {
return `Too many failed send password attempts. Try again in ${Math.ceil(retryAfterSeconds / 60)} minutes.`;
}
export function sendPasswordLockedErrorResponse(retryAfterSeconds: number): Response {
return errorResponse(sendPasswordLockMessage(retryAfterSeconds), 429);
}
export function sendPasswordLockedOAuthResponse(retryAfterSeconds: number): Response {
const message = sendPasswordLockMessage(retryAfterSeconds);
return jsonResponse(
{
error: 'invalid_grant',
error_description: message,
send_access_error_type: 'too_many_password_attempts',
ErrorModel: {
Message: message,
Object: 'error',
},
},
429
);
}
export async function validatePublicSendAccess(send: Send, body: unknown): Promise<PublicSendAccessValidationResult> {
if (hasEmailAuth(send)) {
return { ok: false, response: errorResponse(SEND_INACCESSIBLE_MSG, 404), reason: 'email_auth_unsupported' };
}
if (!send.passwordHash) return { ok: true };
const passwordRaw = getAliasedProp(body, ['password', 'Password']);
const passwordHashB64Raw = getAliasedProp(body, [
'password_hash_b64',
'passwordHashB64',
'passwordHash',
'password_hash',
]);
let validPassword = false;
if (send.passwordSalt && send.passwordIterations) {
if (typeof passwordRaw.value !== 'string') {
return { ok: false, response: errorResponse('Password not provided', 401), reason: 'password_missing' };
}
validPassword = await verifySendPassword(send, passwordRaw.value);
} else {
const candidate =
typeof passwordHashB64Raw.value === 'string'
? passwordHashB64Raw.value
: typeof passwordRaw.value === 'string'
? passwordRaw.value
: '';
if (!candidate) return { ok: false, response: errorResponse('Password not provided', 401), reason: 'password_missing' };
validPassword = verifySendPasswordHashB64(send, candidate);
}
if (!validPassword) {
return { ok: false, response: errorResponse('Invalid password', 400), reason: 'invalid_password' };
}
return { ok: true };
}
+3 -1290
View File
File diff suppressed because it is too large Load Diff
-11
View File
@@ -1,11 +0,0 @@
import { Env } from '../types';
import { StorageService } from '../services/storage';
import { jsonResponse } from '../utils/response';
// GET /setup/status
export async function handleSetupStatus(request: Request, env: Env): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const registered = (await storage.isRegistered()) || (await storage.getUserCount()) > 0;
return jsonResponse({ registered });
}
+82 -41
View File
@@ -4,14 +4,23 @@ import { errorResponse } from '../utils/response';
import { cipherToResponse } from './ciphers';
import { sendToResponse } from './sends';
import { LIMITS } from '../config/limits';
import { isTotpEnabled } from '../utils/totp';
import {
buildAccountKeys,
buildUserDecryptionCompat,
buildUserDecryptionOptions,
} from '../utils/user-decryption';
interface SyncCacheEntry {
userId: string;
revisionDate: string;
body: string;
expiresAt: number;
bytes: number;
}
const syncResponseCache = new Map<string, SyncCacheEntry>();
let syncResponseCacheTotalBytes = 0;
const textEncoder = new TextEncoder();
function buildSyncCacheKey(userId: string, revisionDate: string, excludeDomains: boolean): string {
return `${userId}:${revisionDate}:${excludeDomains ? '1' : '0'}`;
@@ -21,21 +30,67 @@ function readSyncCache(key: string): string | null {
const hit = syncResponseCache.get(key);
if (!hit) return null;
if (hit.expiresAt <= Date.now()) {
syncResponseCache.delete(key);
deleteSyncCacheEntry(key, hit);
return null;
}
return hit.body;
}
function writeSyncCache(key: string, body: string): void {
if (syncResponseCache.size >= LIMITS.cache.syncResponseMaxEntries) {
const oldestKey = syncResponseCache.keys().next().value as string | undefined;
if (oldestKey) syncResponseCache.delete(oldestKey);
function deleteSyncCacheEntry(key: string, entry?: SyncCacheEntry): void {
const existing = entry ?? syncResponseCache.get(key);
if (!existing) return;
syncResponseCache.delete(key);
syncResponseCacheTotalBytes = Math.max(0, syncResponseCacheTotalBytes - existing.bytes);
}
function pruneExpiredSyncCache(nowMs: number = Date.now()): void {
for (const [key, entry] of syncResponseCache.entries()) {
if (entry.expiresAt <= nowMs) {
deleteSyncCacheEntry(key, entry);
}
}
}
function pruneStaleUserSyncCache(userId: string, revisionDate: string): void {
for (const [key, entry] of syncResponseCache.entries()) {
if (entry.userId === userId && entry.revisionDate !== revisionDate) {
deleteSyncCacheEntry(key, entry);
}
}
}
function writeSyncCache(userId: string, revisionDate: string, key: string, body: string): void {
const nowMs = Date.now();
pruneExpiredSyncCache(nowMs);
pruneStaleUserSyncCache(userId, revisionDate);
const bodyBytes = textEncoder.encode(body).byteLength;
if (bodyBytes > LIMITS.cache.syncResponseMaxBodyBytes) {
return;
}
const existing = syncResponseCache.get(key);
if (existing) {
deleteSyncCacheEntry(key, existing);
}
while (
syncResponseCache.size >= LIMITS.cache.syncResponseMaxEntries ||
syncResponseCacheTotalBytes + bodyBytes > LIMITS.cache.syncResponseMaxTotalBytes
) {
const oldestKey = syncResponseCache.keys().next().value as string | undefined;
if (!oldestKey) break;
deleteSyncCacheEntry(oldestKey);
}
syncResponseCache.set(key, {
userId,
revisionDate,
body,
expiresAt: Date.now() + LIMITS.cache.syncResponseTtlMs,
expiresAt: nowMs + LIMITS.cache.syncResponseTtlMs,
bytes: bodyBytes,
});
syncResponseCacheTotalBytes += bodyBytes;
}
// GET /api/sync
@@ -44,6 +99,12 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
const url = new URL(request.url);
const excludeDomainsParam = url.searchParams.get('excludeDomains');
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
const userAgent = String(request.headers.get('user-agent') || '').toLowerCase();
const omitFido2Credentials =
userAgent.includes('android') ||
userAgent.includes('iphone') ||
userAgent.includes('ipad') ||
userAgent.includes('ios');
const user = await storage.getUserById(userId);
if (!user) {
@@ -74,12 +135,12 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
premium: true,
premiumFromOrganization: false,
usesKeyConnector: false,
masterPasswordHint: null,
masterPasswordHint: user.masterPasswordHint,
culture: 'en-US',
twoFactorEnabled: !!user.totpSecret || isTotpEnabled(env.TOTP_SECRET),
twoFactorEnabled: !!user.totpSecret,
key: user.key,
privateKey: user.privateKey,
accountKeys: null,
accountKeys: buildAccountKeys(user),
securityStamp: user.securityStamp || user.id,
organizations: [],
providers: [],
@@ -87,6 +148,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
forcePasswordReset: false,
avatarColor: null,
creationDate: user.createdAt,
verifyDevices: user.verifyDevices,
object: 'profile',
};
@@ -94,7 +156,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
const cipherResponses: CipherResponse[] = [];
for (const cipher of ciphers) {
const attachments = attachmentsByCipher.get(cipher.id) || [];
cipherResponses.push(cipherToResponse(cipher, attachments));
cipherResponses.push(cipherToResponse(cipher, attachments, { omitFido2Credentials }));
}
// Build folder responses
@@ -119,42 +181,21 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
},
policies: [],
sends: sends.map(sendToResponse),
UserDecryption: {
MasterPasswordUnlock: buildUserDecryptionOptions(user).MasterPasswordUnlock,
TrustedDeviceOption: null,
KeyConnectorOption: null,
Object: 'userDecryption',
},
// PascalCase for desktop/browser clients
UserDecryptionOptions: {
HasMasterPassword: true,
Object: 'userDecryptionOptions',
MasterPasswordUnlock: {
Kdf: {
KdfType: user.kdfType,
Iterations: user.kdfIterations,
Memory: user.kdfMemory || null,
Parallelism: user.kdfParallelism || null,
},
MasterKeyEncryptedUserKey: user.key,
MasterKeyWrappedUserKey: user.key,
Salt: user.email.toLowerCase(),
Object: 'masterPasswordUnlock',
},
},
UserDecryptionOptions: buildUserDecryptionOptions(user),
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
userDecryption: {
masterPasswordUnlock: {
kdf: {
kdfType: user.kdfType,
iterations: user.kdfIterations,
memory: user.kdfMemory || null,
parallelism: user.kdfParallelism || null,
},
masterKeyWrappedUserKey: user.key,
masterKeyEncryptedUserKey: user.key,
salt: user.email.toLowerCase(),
},
},
userDecryption: buildUserDecryptionCompat(user) as SyncResponse['userDecryption'],
object: 'sync',
};
const body = JSON.stringify(syncResponse);
writeSyncCache(cacheKey, body);
writeSyncCache(userId, revisionDate, cacheKey, body);
return new Response(body, {
status: 200,
+43
View File
@@ -1,12 +1,36 @@
import { Env } from './types';
import { NotificationsHub } from './durable/notifications-hub';
import { handleRequest } from './router';
import { StorageService } from './services/storage';
import { applyCors, jsonResponse } from './utils/response';
import { runScheduledBackupIfDue } from './handlers/backup';
let dbInitialized = false;
let dbInitError: string | null = null;
let dbInitPromise: Promise<void> | null = null;
function isWorkerHandledPath(path: string): boolean {
return (
path.startsWith('/api/') ||
path.startsWith('/identity/') ||
path.startsWith('/icons/') ||
path.startsWith('/notifications/') ||
path.startsWith('/.well-known/') ||
path === '/config' ||
path === '/api/config' ||
path === '/api/version'
);
}
async function maybeServeAsset(request: Request, env: Env): Promise<Response | null> {
if (!env.ASSETS) return null;
if (request.method !== 'GET' && request.method !== 'HEAD') return null;
const url = new URL(request.url);
if (isWorkerHandledPath(url.pathname)) return null;
return env.ASSETS.fetch(request);
}
async function ensureDatabaseInitialized(env: Env): Promise<void> {
if (dbInitialized) return;
@@ -32,6 +56,11 @@ async function ensureDatabaseInitialized(env: Env): Promise<void> {
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
void ctx;
const assetResponse = await maybeServeAsset(request, env);
if (assetResponse) {
return applyCors(request, assetResponse);
}
await ensureDatabaseInitialized(env);
if (dbInitError) {
// Log full error server-side, return generic message to client.
@@ -53,4 +82,18 @@ export default {
const resp = await handleRequest(request, env);
return applyCors(request, resp);
},
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
void controller;
await ensureDatabaseInitialized(env);
if (dbInitError) {
console.error('Skipping scheduled backup because DB init failed:', dbInitError);
return;
}
ctx.waitUntil(runScheduledBackupIfDue(env).catch((error) => {
console.error('Scheduled backup failed:', error);
}));
},
};
export { NotificationsHub };
+69
View File
@@ -0,0 +1,69 @@
import type { Env, User } from './types';
import {
handleAdminExportBackup,
handleDownloadAdminRemoteBackup,
handleDeleteAdminRemoteBackup,
handleDownloadAdminBackupAttachment,
handleGetAdminBackupSettings,
handleGetAdminBackupSettingsRepairState,
handleAdminImportBackup,
handleListAdminRemoteBackups,
handleRepairAdminBackupSettings,
handleRestoreAdminRemoteBackup,
handleRunAdminConfiguredBackup,
handleUpdateAdminBackupSettings,
} from './handlers/backup';
export async function handleAdminBackupRoute(
request: Request,
env: Env,
actorUser: User,
path: string,
method: string
): Promise<Response | null> {
if (path === '/api/admin/backup/export' && method === 'POST') {
return handleAdminExportBackup(request, env, actorUser);
}
if (path === '/api/admin/backup/blob' && method === 'GET') {
return handleDownloadAdminBackupAttachment(request, env, actorUser);
}
if (path === '/api/admin/backup/settings') {
if (method === 'GET') return handleGetAdminBackupSettings(request, env, actorUser);
if (method === 'PUT') return handleUpdateAdminBackupSettings(request, env, actorUser);
return null;
}
if (path === '/api/admin/backup/settings/repair') {
if (method === 'GET') return handleGetAdminBackupSettingsRepairState(request, env, actorUser);
if (method === 'POST') return handleRepairAdminBackupSettings(request, env, actorUser);
return null;
}
if (path === '/api/admin/backup/run' && method === 'POST') {
return handleRunAdminConfiguredBackup(request, env, actorUser);
}
if (path === '/api/admin/backup/remote' && method === 'GET') {
return handleListAdminRemoteBackups(request, env, actorUser);
}
if (path === '/api/admin/backup/remote/download' && method === 'GET') {
return handleDownloadAdminRemoteBackup(request, env, actorUser);
}
if (path === '/api/admin/backup/remote/file' && method === 'DELETE') {
return handleDeleteAdminRemoteBackup(request, env, actorUser);
}
if (path === '/api/admin/backup/remote/restore' && method === 'POST') {
return handleRestoreAdminRemoteBackup(request, env, actorUser);
}
if (path === '/api/admin/backup/import' && method === 'POST') {
return handleAdminImportBackup(request, env, actorUser);
}
return null;
}
+51
View File
@@ -0,0 +1,51 @@
import type { Env, User } from './types';
import {
handleAdminListUsers,
handleAdminCreateInvite,
handleAdminListInvites,
handleAdminDeleteAllInvites,
handleAdminRevokeInvite,
handleAdminSetUserStatus,
handleAdminDeleteUser,
} from './handlers/admin';
import { handleAdminBackupRoute } from './router-admin-backup';
export async function handleAdminRoute(
request: Request,
env: Env,
actorUser: User,
path: string,
method: string
): Promise<Response | null> {
if (path === '/api/admin/users' && method === 'GET') {
return handleAdminListUsers(request, env, actorUser);
}
const adminBackupResponse = await handleAdminBackupRoute(request, env, actorUser, path, method);
if (adminBackupResponse) return adminBackupResponse;
if (path === '/api/admin/invites') {
if (method === 'GET') return handleAdminListInvites(request, env, actorUser);
if (method === 'POST') return handleAdminCreateInvite(request, env, actorUser);
if (method === 'DELETE') return handleAdminDeleteAllInvites(request, env, actorUser);
return null;
}
const adminInviteMatch = path.match(/^\/api\/admin\/invites\/([^/]+)$/i);
if (adminInviteMatch && method === 'DELETE') {
const inviteCode = decodeURIComponent(adminInviteMatch[1]);
return handleAdminRevokeInvite(request, env, actorUser, inviteCode);
}
const adminUserStatusMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)\/status$/i);
if (adminUserStatusMatch && (method === 'PUT' || method === 'POST')) {
return handleAdminSetUserStatus(request, env, actorUser, adminUserStatusMatch[1]);
}
const adminUserDeleteMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)$/i);
if (adminUserDeleteMatch && method === 'DELETE') {
return handleAdminDeleteUser(request, env, actorUser, adminUserDeleteMatch[1]);
}
return null;
}
+302
View File
@@ -0,0 +1,302 @@
import type { Env, User } from './types';
import { errorResponse, jsonResponse } from './utils/response';
import {
handleGetProfile,
handleUpdateProfile,
handleSetKeys,
handleGetRevisionDate,
handleVerifyPassword,
handleChangePassword,
handleSetVerifyDevices,
handleGetTotpStatus,
handleSetTotpStatus,
handleGetTotpRecoveryCode,
} from './handlers/accounts';
import {
handleGetCiphers,
handleGetCipher,
handleCreateCipher,
handleUpdateCipher,
handleDeleteCipher,
handleDeleteCipherCompat,
handlePermanentDeleteCipher,
handleRestoreCipher,
handleBulkArchiveCiphers,
handlePartialUpdateCipher,
handleBulkUnarchiveCiphers,
handleBulkMoveCiphers,
handleBulkDeleteCiphers,
handleBulkPermanentDeleteCiphers,
handleBulkRestoreCiphers,
handleArchiveCipher,
handleUnarchiveCipher,
} from './handlers/ciphers';
import {
handleGetFolders,
handleGetFolder,
handleCreateFolder,
handleUpdateFolder,
handleDeleteFolder,
handleBulkDeleteFolders,
} from './handlers/folders';
import {
handleGetSends,
handleGetSend,
handleCreateSend,
handleCreateFileSendV2,
handleGetSendFileUpload,
handleUploadSendFile,
handleUpdateSend,
handleDeleteSend,
handleBulkDeleteSends,
handleRemoveSendPassword,
handleRemoveSendAuth,
} from './handlers/sends';
import { handleSync } from './handlers/sync';
import { handleCiphersImport } from './handlers/import';
import {
handleCreateAttachment,
handleUploadAttachment,
handleGetAttachment,
handleDeleteAttachment,
} from './handlers/attachments';
import { handleAuthenticatedDeviceRoute } from './router-devices';
import { handleAdminRoute } from './router-admin';
export async function handleAuthenticatedRoute(
request: Request,
env: Env,
userId: string,
currentUser: User,
path: string,
method: string
): Promise<Response | null> {
if (method === 'POST' || method === 'PUT' || method === 'DELETE') {
const blockedAccountPaths = new Set([
'/api/accounts/set-password',
'/api/accounts/delete',
'/api/accounts/delete-account',
'/api/accounts/delete-vault',
]);
if (blockedAccountPaths.has(path)) {
return errorResponse('Not implemented', 501);
}
}
if (path === '/api/accounts/profile') {
if (method === 'GET') return handleGetProfile(request, env, userId);
if (method === 'PUT') return handleUpdateProfile(request, env, userId);
return errorResponse('Method not allowed', 405);
}
if ((path === '/api/accounts/password' || path === '/api/accounts/change-password') && (method === 'POST' || method === 'PUT')) {
return handleChangePassword(request, env, userId);
}
if (path === '/api/accounts/keys' && method === 'POST') {
return handleSetKeys(request, env, userId);
}
if (path === '/api/accounts/totp') {
if (method === 'GET') return handleGetTotpStatus(request, env, userId);
if (method === 'PUT' || method === 'POST') return handleSetTotpStatus(request, env, userId);
return null;
}
if ((path === '/api/accounts/totp/recovery-code' || path === '/api/two-factor/get-recover') && method === 'POST') {
return handleGetTotpRecoveryCode(request, env, userId);
}
if (path === '/api/accounts/revision-date' && method === 'GET') {
return handleGetRevisionDate(request, env, userId);
}
if (path === '/api/accounts/verify-password' && method === 'POST') {
return handleVerifyPassword(request, env, userId);
}
if (path === '/api/accounts/verify-devices' && (method === 'PUT' || method === 'POST')) {
return handleSetVerifyDevices(request, env, userId);
}
if (path === '/api/sync' && method === 'GET') {
return handleSync(request, env, userId);
}
if (path.startsWith('/notifications/')) {
return errorResponse('Not found', 404);
}
if (path === '/api/ciphers' || path === '/api/ciphers/create') {
if (method === 'GET') return handleGetCiphers(request, env, userId);
if (method === 'POST') return handleCreateCipher(request, env, userId);
return null;
}
if (path === '/api/ciphers/import' && method === 'POST') {
return handleCiphersImport(request, env, userId);
}
if (path === '/api/ciphers/delete' && method === 'POST') {
return handleBulkDeleteCiphers(request, env, userId);
}
if (path === '/api/ciphers/delete-permanent' && method === 'POST') {
return handleBulkPermanentDeleteCiphers(request, env, userId);
}
if (path === '/api/ciphers/restore' && method === 'POST') {
return handleBulkRestoreCiphers(request, env, userId);
}
if (path === '/api/ciphers/archive' && (method === 'PUT' || method === 'POST')) {
return handleBulkArchiveCiphers(request, env, userId);
}
if (path === '/api/ciphers/unarchive' && (method === 'PUT' || method === 'POST')) {
return handleBulkUnarchiveCiphers(request, env, userId);
}
if (path === '/api/ciphers/move' && (method === 'POST' || method === 'PUT')) {
return handleBulkMoveCiphers(request, env, userId);
}
const cipherMatch = path.match(/^\/api\/ciphers\/([a-f0-9-]+)(\/.*)?$/i);
if (cipherMatch) {
const cipherId = cipherMatch[1];
const subPath = cipherMatch[2] || '';
if (subPath === '' || subPath === '/') {
if (method === 'GET') return handleGetCipher(request, env, userId, cipherId);
if (method === 'PUT' || method === 'POST') return handleUpdateCipher(request, env, userId, cipherId);
if (method === 'DELETE') return handleDeleteCipherCompat(request, env, userId, cipherId);
}
if (subPath === '/delete' && method === 'PUT') return handleDeleteCipher(request, env, userId, cipherId);
if (subPath === '/delete' && method === 'DELETE') return handlePermanentDeleteCipher(request, env, userId, cipherId);
if (subPath === '/restore' && method === 'PUT') return handleRestoreCipher(request, env, userId, cipherId);
if (subPath === '/archive' && (method === 'PUT' || method === 'POST')) return handleArchiveCipher(request, env, userId, cipherId);
if (subPath === '/unarchive' && (method === 'PUT' || method === 'POST')) return handleUnarchiveCipher(request, env, userId, cipherId);
if (subPath === '/partial' && (method === 'PUT' || method === 'POST')) return handlePartialUpdateCipher(request, env, userId, cipherId);
if (subPath === '/share' && method === 'POST') return handleGetCipher(request, env, userId, cipherId);
if (subPath === '/details' && method === 'GET') return handleGetCipher(request, env, userId, cipherId);
if (subPath === '/attachment/v2' && method === 'POST') return handleCreateAttachment(request, env, userId, cipherId);
if (subPath === '/attachment' && method === 'POST') return handleCreateAttachment(request, env, userId, cipherId);
const attachmentMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)$/i);
if (attachmentMatch) {
const attachmentId = attachmentMatch[1];
if (method === 'POST' || method === 'PUT') return handleUploadAttachment(request, env, userId, cipherId, attachmentId);
if (method === 'GET') return handleGetAttachment(request, env, userId, cipherId, attachmentId);
if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
}
const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i);
if (attachmentDeleteMatch && method === 'POST') {
return handleDeleteAttachment(request, env, userId, cipherId, attachmentDeleteMatch[1]);
}
}
if (path === '/api/folders') {
if (method === 'GET') return handleGetFolders(request, env, userId);
if (method === 'POST') return handleCreateFolder(request, env, userId);
return null;
}
if (path === '/api/folders/delete' && method === 'POST') {
return handleBulkDeleteFolders(request, env, userId);
}
const folderMatch = path.match(/^\/api\/folders\/([a-f0-9-]+)$/i);
if (folderMatch) {
const folderId = folderMatch[1];
if (method === 'GET') return handleGetFolder(request, env, userId, folderId);
if (method === 'PUT') return handleUpdateFolder(request, env, userId, folderId);
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
}
if (path.startsWith('/api/auth-requests')) {
return jsonResponse({ data: [], object: 'list', continuationToken: null });
}
if (path === '/api/collections' || path.startsWith('/api/collections/')) {
if (method === 'GET') {
return jsonResponse({ data: [], object: 'list', continuationToken: null });
}
return null;
}
if (path === '/api/organizations' || path.startsWith('/api/organizations/')) {
if (method === 'GET') {
return jsonResponse({ data: [], object: 'list', continuationToken: null });
}
return null;
}
if (path === '/api/sends') {
if (method === 'GET') return handleGetSends(request, env, userId);
if (method === 'POST') return handleCreateSend(request, env, userId);
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);
}
const sendMatch = path.match(/^\/api\/sends\/([^/]+)(\/.*)?$/i);
if (sendMatch) {
const sendId = sendMatch[1];
const subPath = sendMatch[2] || '';
if (subPath === '' || subPath === '/') {
if (method === 'GET') return handleGetSend(request, env, userId, sendId);
if (method === 'PUT') return handleUpdateSend(request, env, userId, sendId);
if (method === 'DELETE') return handleDeleteSend(request, env, userId, sendId);
}
if (subPath === '/remove-password' && (method === 'PUT' || method === 'POST')) {
return handleRemoveSendPassword(request, env, userId, sendId);
}
if (subPath === '/remove-auth' && (method === 'PUT' || method === 'POST')) {
return handleRemoveSendAuth(request, env, userId, sendId);
}
const sendFileUploadMatch = subPath.match(/^\/file\/([^/]+)\/?$/i);
if (sendFileUploadMatch) {
const fileId = sendFileUploadMatch[1];
if (method === 'GET') return handleGetSendFileUpload(request, env, userId, sendId, fileId);
if (method === 'POST' || method === 'PUT') return handleUploadSendFile(request, env, userId, sendId, fileId);
}
}
if (path === '/api/policies' || path.startsWith('/api/policies/')) {
if (method === 'GET') {
return jsonResponse({ data: [], object: 'list', continuationToken: null });
}
return null;
}
if (path === '/api/settings/domains') {
if (method === 'GET' || method === 'PUT' || method === 'POST') {
return jsonResponse({
equivalentDomains: [],
globalEquivalentDomains: [],
object: 'domains',
});
}
return null;
}
const authenticatedDeviceResponse = await handleAuthenticatedDeviceRoute(request, env, userId, path, method);
if (authenticatedDeviceResponse) return authenticatedDeviceResponse;
const adminResponse = await handleAdminRoute(request, env, currentUser, path, method);
if (adminResponse) return adminResponse;
return null;
}
+107
View File
@@ -0,0 +1,107 @@
import type { Env } from './types';
import {
handleGetAuthorizedDevices,
handleGetDevice,
handleGetDevices,
handleGetDeviceByIdentifier,
handleUpdateDeviceKeys,
handleUpdateDeviceTrust,
handleUntrustDevices,
handleRetrieveDeviceKeys,
handleDeactivateDevice,
handleRevokeAllTrustedDevices,
handleRevokeTrustedDevice,
handleDeleteAllDevices,
handleDeleteDevice,
handleUpdateDeviceToken,
handleUpdateDeviceWebPushAuth,
handleClearDeviceToken,
} from './handlers/devices';
export async function handleAuthenticatedDeviceRoute(
request: Request,
env: Env,
userId: string,
path: string,
method: string
): Promise<Response | null> {
if (path === '/api/devices') {
if (method === 'GET') return handleGetDevices(request, env, userId);
if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId);
return null;
}
if (path === '/api/devices/authorized') {
if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId);
if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
return null;
}
const authorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)$/i);
if (authorizedDeviceMatch && method === 'DELETE') {
const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]);
return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier);
}
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i);
if (deleteDeviceMatch && method === 'GET') {
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
return handleGetDevice(request, env, userId, deviceIdentifier);
}
if (deleteDeviceMatch && method === 'DELETE') {
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
return handleDeleteDevice(request, env, userId, deviceIdentifier);
}
const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i);
if (identifierMatch && method === 'GET') {
const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier);
}
const deviceKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/keys$/i) || path.match(/^\/api\/devices\/identifier\/([^/]+)\/keys$/i);
if (deviceKeysMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(deviceKeysMatch[1]);
return handleUpdateDeviceKeys(request, env, userId, deviceIdentifier);
}
const identifierTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/token$/i);
if (identifierTokenMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(identifierTokenMatch[1]);
return handleUpdateDeviceToken(request, env, userId, deviceIdentifier);
}
const identifierWebPushMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/web-push-auth$/i);
if (identifierWebPushMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(identifierWebPushMatch[1]);
return handleUpdateDeviceWebPushAuth(request, env, userId, deviceIdentifier);
}
const identifierClearTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/clear-token$/i);
if (identifierClearTokenMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(identifierClearTokenMatch[1]);
return handleClearDeviceToken(request, env, userId, deviceIdentifier);
}
const identifierRetrieveKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/retrieve-keys$/i);
if (identifierRetrieveKeysMatch && method === 'POST') {
const deviceIdentifier = decodeURIComponent(identifierRetrieveKeysMatch[1]);
return handleRetrieveDeviceKeys(request, env, userId, deviceIdentifier);
}
const identifierDeactivateMatch = path.match(/^\/api\/devices\/([^/]+)\/deactivate$/i);
if (identifierDeactivateMatch && (method === 'POST' || method === 'DELETE')) {
const deviceIdentifier = decodeURIComponent(identifierDeactivateMatch[1]);
return handleDeactivateDevice(request, env, userId, deviceIdentifier);
}
if (path === '/api/devices/update-trust' && method === 'POST') {
return handleUpdateDeviceTrust(request, env, userId);
}
if (path === '/api/devices/untrust' && method === 'POST') {
return handleUntrustDevices(request, env, userId);
}
return null;
}
+355
View File
@@ -0,0 +1,355 @@
import { LIMITS } from './config/limits';
import { DEFAULT_DEV_SECRET } from './types';
import {
handleAccessSend,
handleAccessSendFile,
handleAccessSendV2,
handleAccessSendFileV2,
handleDownloadSendFile,
} from './handlers/sends';
import { handleKnownDevice } from './handlers/devices';
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
import {
handleRegister,
handleGetPasswordHint,
handleRecoverTwoFactor,
} from './handlers/accounts';
import { handlePublicDownloadAttachment } from './handlers/attachments';
import { handlePublicUploadAttachment } from './handlers/attachments';
import {
handleNotificationsHub,
handleNotificationsNegotiate,
} from './handlers/notifications';
import { handlePublicUploadSendFile } from './handlers/sends';
import { jsonResponse } from './utils/response';
import type { Env } from './types';
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
type JwtUnsafeReason = 'missing' | 'default' | 'too_short' | null;
export interface WebBootstrapResponse {
defaultKdfIterations: number;
jwtUnsafeReason: JwtUnsafeReason;
jwtSecretMinLength: number;
}
function isSameOriginWriteRequest(request: Request): boolean {
const targetOrigin = new URL(request.url).origin;
const origin = request.headers.get('Origin');
if (origin) {
return origin === targetOrigin;
}
const referer = request.headers.get('Referer');
if (referer) {
try {
return new URL(referer).origin === targetOrigin;
} catch {
return false;
}
}
return false;
}
function getNwIconSvg(): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="NW icon"><rect x="4" y="4" width="88" height="88" rx="20" fill="#111418"/><text x="48" y="60" text-anchor="middle" font-size="36" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-weight="800" letter-spacing="0.5" fill="#FFFFFF">NW</text></svg>`;
}
function handleNwFavicon(): Response {
return new Response(getNwIconSvg(), {
status: 200,
headers: {
'Content-Type': 'image/svg+xml; charset=utf-8',
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
},
});
}
function buildIconServiceBase(origin: string): string {
return `${origin}/icons`;
}
function buildIconServiceTemplate(origin: string): string {
return `${buildIconServiceBase(origin)}/{}/icon.png`;
}
function buildIconServiceCsp(origin: string): string {
return `img-src 'self' data: ${origin}`;
}
function buildConfigResponse(origin: string) {
return {
version: LIMITS.compatibility.bitwardenServerVersion,
gitHash: 'nodewarden',
server: null,
environment: {
cloudRegion: 'self-hosted',
vault: origin,
api: origin + '/api',
identity: origin + '/identity',
notifications: origin + '/notifications',
icons: origin,
sso: '',
fillAssistRules: null,
},
push: {
pushTechnology: 0,
vapidPublicKey: null,
},
communication: null,
settings: {
disableUserRegistration: false,
},
_icon_service_url: buildIconServiceTemplate(origin),
_icon_service_csp: buildIconServiceCsp(origin),
featureStates: {
'duo-redirect': true,
'email-verification': true,
'pm-19051-send-email-verification': false,
'pm-19148-innovation-archive': true,
'unauth-ui-refresh': true,
'web-push': false,
},
object: 'config',
};
}
function normalizeIconHost(rawHost: string): string | null {
const decoded = decodeURIComponent(String(rawHost || '').trim()).toLowerCase().replace(/\.+$/, '');
if (!decoded || decoded.includes('/') || decoded.includes('\\')) return null;
try {
const parsed = new URL(`https://${decoded}`);
return parsed.hostname === decoded ? decoded : null;
} catch {
return null;
}
}
async function handleWebsiteIcon(host: string): Promise<Response> {
const normalizedHost = normalizeIconHost(host);
if (!normalizedHost) return handleNwFavicon();
const encodedHost = encodeURIComponent(normalizedHost);
const requestHeaders = { 'User-Agent': 'NodeWarden/1.0' };
const upstreamSources: Array<{ url: string; headers?: HeadersInit }> = [
{
url: `https://icons.bitwarden.net/${encodedHost}/icon.png`,
headers: requestHeaders,
},
{
url: `https://favicon.im/${encodedHost}`,
headers: requestHeaders,
},
{
url: `https://icons.duckduckgo.com/ip3/${encodedHost}.ico`,
headers: requestHeaders,
},
];
try {
for (const source of upstreamSources) {
const resp = await fetch(source.url, {
headers: source.headers,
redirect: 'follow',
cf: {
cacheEverything: true,
cacheTtl: LIMITS.cache.iconTtlSeconds,
},
} as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } });
if (!resp.ok) continue;
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
if (!contentType.startsWith('image/')) continue;
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}`,
},
});
}
return handleNwFavicon();
} catch {
return handleNwFavicon();
}
}
export function buildWebBootstrapResponse(env: Env): WebBootstrapResponse {
const secret = (env.JWT_SECRET || '').trim();
const jwtUnsafeReason =
!secret
? 'missing'
: secret === DEFAULT_DEV_SECRET
? 'default'
: secret.length < LIMITS.auth.jwtSecretMinLength
? 'too_short'
: null;
return {
defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
jwtUnsafeReason,
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
};
}
export async function handlePublicRoute(
request: Request,
env: Env,
path: string,
method: string,
enforcePublicRateLimit: PublicRateLimiter
): Promise<Response | null> {
if (path === '/.well-known/appspecific/com.chrome.devtools.json' && method === 'GET') {
return new Response('{}', {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
},
});
}
if ((path === '/api/web-bootstrap' || path === '/web-bootstrap') && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
return jsonResponse(buildWebBootstrapResponse(env));
}
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
if (iconMatch && method === 'GET') {
return handleWebsiteIcon(iconMatch[1]);
}
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
if (publicAttachmentMatch && method === 'GET') {
return handlePublicDownloadAttachment(request, env, publicAttachmentMatch[1], publicAttachmentMatch[2]);
}
const publicAttachmentUploadMatch = path.match(/^\/api\/ciphers\/([a-f0-9-]+)\/attachment\/([a-f0-9-]+)$/i);
if (publicAttachmentUploadMatch && (method === 'POST' || method === 'PUT') && new URL(request.url).searchParams.has('token')) {
return handlePublicUploadAttachment(request, env, publicAttachmentUploadMatch[1], publicAttachmentUploadMatch[2]);
}
const publicSendUploadMatch = path.match(/^\/api\/sends\/([^/]+)\/file\/([^/]+)\/?$/i);
if (publicSendUploadMatch && (method === 'POST' || method === 'PUT') && new URL(request.url).searchParams.has('token')) {
return handlePublicUploadSendFile(request, env, publicSendUploadMatch[1], publicSendUploadMatch[2]);
}
const sendAccessMatch = path.match(/^\/api\/sends\/access\/([^/]+)$/i);
if (sendAccessMatch && method === 'POST') {
const blocked = await enforcePublicRateLimit();
if (blocked) return blocked;
return handleAccessSend(request, env, sendAccessMatch[1]);
}
if (path === '/api/sends/access' && method === 'POST') {
const blocked = await enforcePublicRateLimit();
if (blocked) return blocked;
return handleAccessSendV2(request, env);
}
const sendAccessFileV2Match = path.match(/^\/api\/sends\/access\/file\/([^/]+)\/?$/i);
if (sendAccessFileV2Match && method === 'POST') {
const blocked = await enforcePublicRateLimit();
if (blocked) return blocked;
return handleAccessSendFileV2(request, env, sendAccessFileV2Match[1]);
}
const sendAccessFileMatch = path.match(/^\/api\/sends\/([^/]+)\/access\/file\/([^/]+)\/?$/i);
if (sendAccessFileMatch && method === 'POST') {
const blocked = await enforcePublicRateLimit();
if (blocked) return blocked;
return handleAccessSendFile(request, env, sendAccessFileMatch[1], sendAccessFileMatch[2]);
}
const sendDownloadMatch = path.match(/^\/api\/sends\/([^/]+)\/([^/]+)\/?$/i);
if (sendDownloadMatch && method === 'GET') {
return handleDownloadSendFile(request, env, sendDownloadMatch[1], sendDownloadMatch[2]);
}
if (path === '/identity/connect/token' && method === 'POST') {
return handleToken(request, env);
}
if (path === '/api/devices/knowndevice' && method === 'GET') {
const blocked = await enforcePublicRateLimit();
if (blocked) return jsonResponse(false);
return handleKnownDevice(request, env);
}
const clearDeviceTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/clear-token$/i);
if (clearDeviceTokenMatch && (method === 'PUT' || method === 'POST')) {
return new Response(null, { status: 200 });
}
if ((path === '/identity/connect/revocation' || path === '/identity/connect/revoke') && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handleRevocation(request, env);
}
if (path === '/identity/accounts/prelogin' && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handlePrelogin(request, env);
}
if (path === '/identity/accounts/prelogin/password' && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handlePrelogin(request, env);
}
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
return handleRecoverTwoFactor(request, env);
}
if (path === '/api/accounts/password-hint' && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
if (!isSameOriginWriteRequest(request)) {
return new Response(JSON.stringify({ error: 'Forbidden origin' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
return handleGetPasswordHint(request, env);
}
if ((path === '/config' || path === '/api/config') && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
const origin = new URL(request.url).origin;
return jsonResponse(buildConfigResponse(origin));
}
if (path === '/api/version' && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
return jsonResponse(LIMITS.compatibility.bitwardenServerVersion);
}
if (path === '/api/accounts/register' && method === 'POST') {
const blocked = await enforcePublicRateLimit('register', LIMITS.rateLimit.registerRequestsPerMinute);
if (blocked) return blocked;
if (!isSameOriginWriteRequest(request)) {
return new Response(JSON.stringify({ error: 'Forbidden origin' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
return handleRegister(request, env);
}
if (path === '/notifications/hub/negotiate' && method === 'POST') {
return handleNotificationsNegotiate(request, env);
}
if (path === '/notifications/hub' && method === 'GET') {
return handleNotificationsHub(request, env);
}
return null;
}
+82 -707
View File
@@ -1,124 +1,10 @@
import { Env, DEFAULT_DEV_SECRET } from './types';
import { DEFAULT_DEV_SECRET, Env } from './types';
import { AuthService } from './services/auth';
import { StorageService } from './services/storage';
import { RateLimitService, getClientIdentifier } from './services/ratelimit';
import { handleCors, errorResponse, jsonResponse } from './utils/response';
import { handleCors, errorResponse } from './utils/response';
import { LIMITS } from './config/limits';
// Identity handlers
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
// Account handlers
import {
handleRegister,
handleGetProfile,
handleSetKeys,
handleGetRevisionDate,
handleVerifyPassword,
handleChangePassword,
handleGetTotpStatus,
handleSetTotpStatus,
handleGetTotpRecoveryCode,
handleRecoverTwoFactor,
} from './handlers/accounts';
// Cipher handlers
import {
handleGetCiphers,
handleGetCipher,
handleCreateCipher,
handleUpdateCipher,
handleDeleteCipher,
handleDeleteCipherCompat,
handlePermanentDeleteCipher,
handleRestoreCipher,
handlePartialUpdateCipher,
handleBulkMoveCiphers,
} from './handlers/ciphers';
// Folder handlers
import {
handleGetFolders,
handleGetFolder,
handleCreateFolder,
handleUpdateFolder,
handleDeleteFolder
} from './handlers/folders';
// Send handlers
import {
handleGetSends,
handleGetSend,
handleCreateSend,
handleCreateFileSendV2,
handleGetSendFileUpload,
handleUploadSendFile,
handleUpdateSend,
handleDeleteSend,
handleRemoveSendPassword,
handleRemoveSendAuth,
handleAccessSend,
handleAccessSendFile,
handleAccessSendV2,
handleAccessSendFileV2,
handleDownloadSendFile,
} from './handlers/sends';
// Sync handler
import { handleSync } from './handlers/sync';
// Setup handlers
import { handleSetupStatus } from './handlers/setup';
import {
handleKnownDevice,
handleGetAuthorizedDevices,
handleGetDevices,
handleRevokeAllTrustedDevices,
handleRevokeTrustedDevice,
handleDeleteDevice,
handleUpdateDeviceToken
} from './handlers/devices';
// Import handler
import { handleCiphersImport } from './handlers/import';
// Attachment handlers
import {
handleCreateAttachment,
handleUploadAttachment,
handleGetAttachment,
handleDeleteAttachment,
handlePublicDownloadAttachment,
} from './handlers/attachments';
import {
handleAdminListUsers,
handleAdminCreateInvite,
handleAdminListInvites,
handleAdminDeleteAllInvites,
handleAdminRevokeInvite,
handleAdminSetUserStatus,
handleAdminDeleteUser,
} from './handlers/admin';
function isSameOriginWriteRequest(request: Request): boolean {
const targetOrigin = new URL(request.url).origin;
const origin = request.headers.get('Origin');
if (origin) {
return origin === targetOrigin;
}
const referer = request.headers.get('Referer');
if (referer) {
try {
return new URL(referer).origin === targetOrigin;
} catch {
return false;
}
}
// Require browser-origin evidence for setup/register write operations.
return false;
}
import { handleAuthenticatedRoute } from './router-authenticated';
import { handlePublicRoute } from './router-public';
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
const secret = (env.JWT_SECRET || '').trim();
@@ -128,85 +14,16 @@ function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' |
return null;
}
function getNwIconSvg(): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="NW icon"><rect x="4" y="4" width="88" height="88" rx="20" fill="#111418"/><text x="48" y="60" text-anchor="middle" font-size="36" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-weight="800" letter-spacing="0.5" fill="#FFFFFF">NW</text></svg>`;
}
function isImportBypassRequest(request: Request, path: string, method: string): boolean {
if (request.headers.get('X-NodeWarden-Import') !== '1') return false;
function handleNwFavicon(): Response {
return new Response(getNwIconSvg(), {
status: 200,
headers: {
'Content-Type': 'image/svg+xml; charset=utf-8',
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
},
});
}
function isValidIconHostname(hostname: string): boolean {
if (!hostname) return false;
if (hostname.length > 253) return false;
const normalized = hostname.toLowerCase().replace(/\.$/, '');
// Slightly relaxed domain validation:
// - keep strict label boundaries (no leading/trailing hyphen)
// - allow punycode TLD (e.g. xn--...)
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;
});
}
// Icons handler - proxy to Bitwarden's official icon service
async function handleGetIcon(request: Request, env: Env, hostname: string): Promise<Response> {
try {
void env;
const normalizedHostname = hostname.toLowerCase();
if (!isValidIconHostname(normalizedHostname)) {
return new Response(null, { status: 204 });
}
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;
}
// Use Bitwarden's official icon service
const iconUrl = `https://icons.bitwarden.net/${normalizedHostname}/icon.png`;
const resp = await fetch(iconUrl, {
headers: { 'User-Agent': 'NodeWarden/1.0' },
redirect: 'follow',
cf: {
cacheEverything: true,
cacheTtl: LIMITS.cache.iconTtlSeconds,
},
});
if (resp.ok) {
const body = await resp.arrayBuffer();
const iconResponse = new Response(body, {
status: 200,
headers: {
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`, // 7 days
},
});
await cache.put(cacheKey, iconResponse.clone());
return iconResponse;
}
return new Response(null, { status: 204 });
} catch {
return new Response(null, { status: 204 });
if (method === 'POST') {
if (path === '/api/ciphers/import') return true;
if (/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/v2$/i.test(path)) return true;
if (/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path)) return true;
}
return false;
}
export async function handleRequest(request: Request, env: Env): Promise<Response> {
@@ -215,554 +32,112 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
const method = request.method;
const clientId = getClientIdentifier(request);
async function enforcePublicRateLimit(): Promise<Response | null> {
async function enforcePublicRateLimit(
category: string = 'public',
maxRequests: number = LIMITS.rateLimit.publicRequestsPerMinute
): Promise<Response | null> {
if (!clientId) {
return new Response(
JSON.stringify({
error: 'Forbidden',
error_description: 'Client IP is required',
}),
{
status: 403,
headers: { 'Content-Type': 'application/json' },
}
);
}
const rateLimit = new RateLimitService(env.DB);
const check = await rateLimit.consumeBudget(`${clientId}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
const check = await rateLimit.consumeBudget(`${clientId}:${category}`, maxRequests);
if (check.allowed) return null;
return new Response(JSON.stringify({
error: 'Too many requests',
error_description: `Rate limit exceeded. Try again in ${check.retryAfterSeconds} seconds.`,
}), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(check.retryAfterSeconds || 60),
'X-RateLimit-Remaining': '0',
},
});
return new Response(
JSON.stringify({
error: 'Too many requests',
error_description: `Rate limit exceeded. Try again in ${check.retryAfterSeconds} seconds.`,
}),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(check.retryAfterSeconds || 60),
'X-RateLimit-Remaining': '0',
},
}
);
}
// Handle CORS preflight
if (method === 'OPTIONS') {
return handleCors(request);
}
// Route matching
try {
// Reject oversized bodies before any path-specific parsing.
// File upload paths enforce their own limits and are exempt here.
const isFileUploadPath =
const isLargeUploadPath =
/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path) ||
/^\/api\/sends\/[a-f0-9-]+\/file\/[a-f0-9-]+$/i.test(path);
if (!isFileUploadPath) {
/^\/api\/sends\/[a-f0-9-]+\/file\/[a-f0-9-]+$/i.test(path) ||
path === '/api/admin/backup/import';
if (!isLargeUploadPath) {
const contentLength = parseInt(request.headers.get('Content-Length') || '0', 10);
if (contentLength > LIMITS.request.maxBodyBytes) {
return errorResponse('Request body too large', 413);
}
}
// Setup status
if (path === '/setup/status' && method === 'GET') {
return handleSetupStatus(request, env);
}
const publicResponse = await handlePublicRoute(request, env, path, method, enforcePublicRateLimit);
if (publicResponse) return publicResponse;
// Web runtime config for static client bootstrap
if (path === '/api/web/config' && method === 'GET') {
const jwtUnsafeReason = jwtSecretUnsafeReason(env);
return jsonResponse({
defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
jwtUnsafeReason,
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
});
}
// Browser/devtools probe endpoint
if (path === '/.well-known/appspecific/com.chrome.devtools.json' && method === 'GET') {
return new Response('{}', {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
},
});
}
// Favicon
if ((path === '/favicon.ico' || path === '/favicon.svg') && method === 'GET') {
return handleNwFavicon();
}
// Icon endpoint - proxy to Bitwarden's icon service (no auth required)
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
if (iconMatch) {
const hostname = iconMatch[1];
return handleGetIcon(request, env, hostname);
}
// Public attachment download (no auth header, uses token in query string)
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
if (publicAttachmentMatch && method === 'GET') {
const cipherId = publicAttachmentMatch[1];
const attachmentId = publicAttachmentMatch[2];
return handlePublicDownloadAttachment(request, env, cipherId, attachmentId);
}
// Public Send access endpoints
const sendAccessMatch = path.match(/^\/api\/sends\/access\/([^/]+)$/i);
if (sendAccessMatch && method === 'POST') {
const blocked = await enforcePublicRateLimit();
if (blocked) return blocked;
const accessId = sendAccessMatch[1];
return handleAccessSend(request, env, accessId);
}
const sendAccessV2Match = path === '/api/sends/access';
if (sendAccessV2Match && method === 'POST') {
const blocked = await enforcePublicRateLimit();
if (blocked) return blocked;
return handleAccessSendV2(request, env);
}
const sendAccessFileV2Match = path.match(/^\/api\/sends\/access\/file\/([^/]+)\/?$/i);
if (sendAccessFileV2Match && method === 'POST') {
const blocked = await enforcePublicRateLimit();
if (blocked) return blocked;
const fileId = sendAccessFileV2Match[1];
return handleAccessSendFileV2(request, env, fileId);
}
const sendAccessFileMatch = path.match(/^\/api\/sends\/([^/]+)\/access\/file\/([^/]+)\/?$/i);
if (sendAccessFileMatch && method === 'POST') {
const blocked = await enforcePublicRateLimit();
if (blocked) return blocked;
const idOrAccessId = sendAccessFileMatch[1];
const fileId = sendAccessFileMatch[2];
return handleAccessSendFile(request, env, idOrAccessId, fileId);
}
const sendDownloadMatch = path.match(/^\/api\/sends\/([^/]+)\/([^/]+)\/?$/i);
if (sendDownloadMatch && method === 'GET') {
const sendId = sendDownloadMatch[1];
const fileId = sendDownloadMatch[2];
return handleDownloadSendFile(request, env, sendId, fileId);
}
// Notifications hub (stub - no auth required, return 200 for connection)
if (path.startsWith('/notifications/')) {
const blocked = await enforcePublicRateLimit();
if (blocked) return blocked;
return new Response(null, { status: 200 });
}
// Known device check (no auth required)
if (path === '/api/devices/knowndevice' && method === 'GET') {
const blocked = await enforcePublicRateLimit();
if (blocked) return jsonResponse(false);
return handleKnownDevice(request, env);
}
// Identity endpoints (no auth required)
if (path === '/identity/connect/token' && method === 'POST') {
return handleToken(request, env);
}
if ((path === '/identity/connect/revocation' || path === '/identity/connect/revoke') && method === 'POST') {
return handleRevocation(request, env);
}
if (path === '/identity/accounts/prelogin' && method === 'POST') {
return handlePrelogin(request, env);
}
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
return handleRecoverTwoFactor(request, env);
}
// Config endpoint (no auth required for basic config)
// Bitwarden clients call GET "/config" (relative to the API base URL).
// They also tolerate different casing, but their response models use PascalCase.
const isConfigRequest = (path === '/config' || path === '/api/config') && method === 'GET';
if (isConfigRequest) {
const origin = url.origin;
return jsonResponse({
// ── Version Strategy (Plan E) ──────────────────────────────────────
// Bitwarden clients use this version for backwards-compatibility feature gating.
// Confirmed version-gated features (from client source code):
// - Individual cipher key encryption: >= 2024.2.0
// (clients/libs/common/src/vault/services/cipher.service.ts: CIPHER_KEY_ENC_MIN_SERVER_VER)
// (android/.../FeatureFlagManagerImpl.kt: CIPHER_KEY_ENC_MIN_SERVER_VERSION)
// - MasterPasswordUnlockData (mobile): >= 2025.8.0
// (documented in Vaultwarden source comments)
// There is NO global minimum version that blocks all client functionality.
// Keep this aligned with Vaultwarden's reported version to maintain compatibility.
// When Vaultwarden bumps their version, update this value accordingly.
// Vaultwarden source: src/api/core/mod.rs → fn config()
version: LIMITS.compatibility.bitwardenServerVersion,
gitHash: 'nodewarden',
server: null,
environment: {
vault: origin,
api: origin + '/api',
identity: origin + '/identity',
notifications: origin + '/notifications',
sso: '',
},
// Feature flags control client behavior. Clients use server-provided values;
// flags not listed here fall back to DefaultFeatureFlagValue (all false).
// Only enable flags for features we actually support.
// Reference: clients/libs/common/src/enums/feature-flag.enum.ts
featureStates: {
'duo-redirect': true,
'email-verification': true,
'pm-19051-send-email-verification': false,
'unauth-ui-refresh': true,
},
object: 'config',
});
}
// Version endpoint (some clients probe this to validate the server)
if (path === '/api/version' && method === 'GET') {
return jsonResponse(LIMITS.compatibility.bitwardenServerVersion); // Always same value as /config.version
}
// Registration endpoint (no auth required):
// - first user can self-register and becomes admin
// - later registrations require inviteCode in request body
if (path === '/api/accounts/register' && method === 'POST') {
if (!isSameOriginWriteRequest(request)) {
return errorResponse('Forbidden origin', 403);
}
return handleRegister(request, env);
}
// If JWT_SECRET is not safely configured, block any other endpoints.
const secret = jwtSecretUnsafeReason(env);
if (secret) {
const secretIssue = jwtSecretUnsafeReason(env);
if (secretIssue) {
return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500);
}
// All other API endpoints require authentication
const auth = new AuthService(env);
const authHeader = request.headers.get('Authorization');
const payload = await auth.verifyAccessToken(authHeader);
if (!payload) {
const verified = await auth.verifyAccessTokenWithUser(authHeader);
if (!verified) {
return errorResponse('Unauthorized', 401);
}
const { payload, user: currentUser } = verified;
const actingDeviceId = String(payload.did || '').trim();
if (actingDeviceId) {
const nextHeaders = new Headers(request.headers);
nextHeaders.set('X-NodeWarden-Acting-Device-Id', actingDeviceId);
request = new Request(request, { headers: nextHeaders });
}
const userId = payload.sub;
const storage = new StorageService(env.DB);
const currentUser = await storage.getUserById(userId);
if (!currentUser) {
return errorResponse('Unauthorized', 401);
}
if (currentUser.status !== 'active') {
return errorResponse('Account is disabled', 403);
}
// Unified rate limiting for all authenticated API requests.
{
if (!isImportBypassRequest(request, path, method)) {
const rateLimit = new RateLimitService(env.DB);
const rateLimitCheck = await rateLimit.consumeBudget(
userId + ':api',
LIMITS.rateLimit.apiRequestsPerMinute
);
const rateLimitCheck = await rateLimit.consumeBudget(`${userId}:api`, LIMITS.rateLimit.apiRequestsPerMinute);
if (!rateLimitCheck.allowed) {
return new Response(JSON.stringify({
error: 'Too many requests',
error_description: `Rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`,
}), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': rateLimitCheck.retryAfterSeconds!.toString(),
'X-RateLimit-Remaining': '0',
},
});
return new Response(
JSON.stringify({
error: 'Too many requests',
error_description: `Rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`,
}),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(rateLimitCheck.retryAfterSeconds || 60),
'X-RateLimit-Remaining': '0',
},
}
);
}
}
// Block account operations we do not support yet.
if (method === 'POST' || method === 'PUT' || method === 'DELETE') {
const blockedAccountPaths = new Set([
'/api/accounts/set-password',
'/api/accounts/delete',
'/api/accounts/delete-account',
'/api/accounts/delete-vault',
]);
if (blockedAccountPaths.has(path)) {
return errorResponse('Not implemented', 501);
}
}
const authenticatedResponse = await handleAuthenticatedRoute(request, env, userId, currentUser, path, method);
if (authenticatedResponse) return authenticatedResponse;
// Account endpoints
if (path === '/api/accounts/profile') {
if (method === 'GET') return handleGetProfile(request, env, userId);
return errorResponse('Method not allowed', 405);
}
if ((path === '/api/accounts/password' || path === '/api/accounts/change-password') && (method === 'POST' || method === 'PUT')) {
return handleChangePassword(request, env, userId);
}
if (path === '/api/accounts/keys' && method === 'POST') {
return handleSetKeys(request, env, userId);
}
if (path === '/api/accounts/totp') {
if (method === 'GET') return handleGetTotpStatus(request, env, userId);
if (method === 'PUT' || method === 'POST') return handleSetTotpStatus(request, env, userId);
}
if ((path === '/api/accounts/totp/recovery-code' || path === '/api/two-factor/get-recover') && method === 'POST') {
return handleGetTotpRecoveryCode(request, env, userId);
}
// Revision date endpoint
if (path === '/api/accounts/revision-date' && method === 'GET') {
return handleGetRevisionDate(request, env, userId);
}
// Verify password endpoint
if (path === '/api/accounts/verify-password' && method === 'POST') {
return handleVerifyPassword(request, env, userId);
}
// Sync endpoint
if (path === '/api/sync' && method === 'GET') {
return handleSync(request, env, userId);
}
// Cipher endpoints
if (path === '/api/ciphers' || path === '/api/ciphers/create') {
if (method === 'GET') return handleGetCiphers(request, env, userId);
if (method === 'POST') return handleCreateCipher(request, env, userId);
}
// Ciphers import endpoint (Bitwarden client format)
if (path === '/api/ciphers/import' && method === 'POST') {
return handleCiphersImport(request, env, userId);
}
// Bulk cipher operations (only move is allowed)
if (path === '/api/ciphers/move') {
if (method === 'POST' || method === 'PUT') {
return handleBulkMoveCiphers(request, env, userId);
}
}
// Match /api/ciphers/:id patterns
const cipherMatch = path.match(/^\/api\/ciphers\/([a-f0-9-]+)(\/.*)?$/i);
if (cipherMatch) {
const cipherId = cipherMatch[1];
const subPath = cipherMatch[2] || '';
if (subPath === '' || subPath === '/') {
if (method === 'GET') return handleGetCipher(request, env, userId, cipherId);
if (method === 'PUT' || method === 'POST') return handleUpdateCipher(request, env, userId, cipherId);
if (method === 'DELETE') return handleDeleteCipherCompat(request, env, userId, cipherId);
}
if (subPath === '/delete' && method === 'PUT') {
return handleDeleteCipher(request, env, userId, cipherId);
}
if (subPath === '/delete' && method === 'DELETE') {
return handlePermanentDeleteCipher(request, env, userId, cipherId);
}
if (subPath === '/restore' && method === 'PUT') {
return handleRestoreCipher(request, env, userId, cipherId);
}
if (subPath === '/partial' && (method === 'PUT' || method === 'POST')) {
return handlePartialUpdateCipher(request, env, userId, cipherId);
}
// Share endpoint - just return the cipher (single user mode)
if (subPath === '/share' && method === 'POST') {
return handleGetCipher(request, env, userId, cipherId);
}
if (subPath === '/details' && method === 'GET') {
return handleGetCipher(request, env, userId, cipherId);
}
// Attachment endpoints
// POST /api/ciphers/{id}/attachment/v2 - Create attachment metadata
if (subPath === '/attachment/v2' && method === 'POST') {
return handleCreateAttachment(request, env, userId, cipherId);
}
// Legacy attachment endpoint - also goes to v2 flow
if (subPath === '/attachment' && method === 'POST') {
return handleCreateAttachment(request, env, userId, cipherId);
}
// Match /api/ciphers/{id}/attachment/{attachmentId}
const attachmentMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)$/i);
if (attachmentMatch) {
const attachmentId = attachmentMatch[1];
if (method === 'POST') return handleUploadAttachment(request, env, userId, cipherId, attachmentId);
if (method === 'GET') return handleGetAttachment(request, env, userId, cipherId, attachmentId);
if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
}
// DELETE via POST (legacy)
const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i);
if (attachmentDeleteMatch && method === 'POST') {
const attachmentId = attachmentDeleteMatch[1];
return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
}
}
// Folder endpoints
if (path === '/api/folders') {
if (method === 'GET') return handleGetFolders(request, env, userId);
if (method === 'POST') return handleCreateFolder(request, env, userId);
}
// Match /api/folders/:id patterns
const folderMatch = path.match(/^\/api\/folders\/([a-f0-9-]+)$/i);
if (folderMatch) {
const folderId = folderMatch[1];
if (method === 'GET') return handleGetFolder(request, env, userId, folderId);
if (method === 'PUT') return handleUpdateFolder(request, env, userId, folderId);
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
}
// Auth requests endpoint (stub - we don't support passwordless login)
if (path.startsWith('/api/auth-requests')) {
return jsonResponse({ data: [], object: 'list', continuationToken: null });
}
// Collections endpoint (stub - no organization support)
if (path === '/api/collections' || path.startsWith('/api/collections/')) {
if (method === 'GET') {
return jsonResponse({ data: [], object: 'list', continuationToken: null });
}
}
// Organizations endpoint (stub - no organization support)
if (path === '/api/organizations' || path.startsWith('/api/organizations/')) {
if (method === 'GET') {
return jsonResponse({ data: [], object: 'list', continuationToken: null });
}
}
// Send endpoints
if (path === '/api/sends') {
if (method === 'GET') return handleGetSends(request, env, userId);
if (method === 'POST') return handleCreateSend(request, env, userId);
}
if ((path === '/api/sends/file/v2' || path === '/api/sends/file') && method === 'POST') {
return handleCreateFileSendV2(request, env, userId);
}
const sendMatch = path.match(/^\/api\/sends\/([^/]+)(\/.*)?$/i);
if (sendMatch) {
const sendId = sendMatch[1];
const subPath = sendMatch[2] || '';
if (subPath === '' || subPath === '/') {
if (method === 'GET') return handleGetSend(request, env, userId, sendId);
if (method === 'PUT') return handleUpdateSend(request, env, userId, sendId);
if (method === 'DELETE') return handleDeleteSend(request, env, userId, sendId);
}
if (subPath === '/remove-password' && (method === 'PUT' || method === 'POST')) {
return handleRemoveSendPassword(request, env, userId, sendId);
}
if (subPath === '/remove-auth' && (method === 'PUT' || method === 'POST')) {
return handleRemoveSendAuth(request, env, userId, sendId);
}
const sendFileUploadMatch = subPath.match(/^\/file\/([^/]+)\/?$/i);
if (sendFileUploadMatch) {
const fileId = sendFileUploadMatch[1];
if (method === 'GET') return handleGetSendFileUpload(request, env, userId, sendId, fileId);
if (method === 'POST' || method === 'PUT') return handleUploadSendFile(request, env, userId, sendId, fileId);
}
}
// Policies endpoint (stub - not implemented)
if (path === '/api/policies' || path.startsWith('/api/policies/')) {
if (method === 'GET') {
return jsonResponse({ data: [], object: 'list', continuationToken: null });
}
}
// Settings domains endpoint (stub)
if (path === '/api/settings/domains') {
if (method === 'GET') {
return jsonResponse({
equivalentDomains: [],
globalEquivalentDomains: [],
object: 'domains',
});
}
if (method === 'PUT' || method === 'POST') {
return jsonResponse({
equivalentDomains: [],
globalEquivalentDomains: [],
object: 'domains',
});
}
}
// Devices endpoint
if (path === '/api/devices' && method === 'GET') {
return handleGetDevices(request, env, userId);
}
if (path === '/api/devices/authorized') {
if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId);
if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
}
const authorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)$/i);
if (authorizedDeviceMatch && method === 'DELETE') {
const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]);
return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier);
}
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i);
if (deleteDeviceMatch && method === 'DELETE') {
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
return handleDeleteDevice(request, env, userId, deviceIdentifier);
}
// Admin endpoints
if (path === '/api/admin/users' && method === 'GET') {
return handleAdminListUsers(request, env, currentUser);
}
if (path === '/api/admin/invites') {
if (method === 'GET') return handleAdminListInvites(request, env, currentUser);
if (method === 'POST') return handleAdminCreateInvite(request, env, currentUser);
if (method === 'DELETE') return handleAdminDeleteAllInvites(request, env, currentUser);
}
const adminInviteMatch = path.match(/^\/api\/admin\/invites\/([^/]+)$/i);
if (adminInviteMatch && method === 'DELETE') {
const inviteCode = decodeURIComponent(adminInviteMatch[1]);
return handleAdminRevokeInvite(request, env, currentUser, inviteCode);
}
const adminUserStatusMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)\/status$/i);
if (adminUserStatusMatch && (method === 'PUT' || method === 'POST')) {
return handleAdminSetUserStatus(request, env, currentUser, adminUserStatusMatch[1]);
}
const adminUserDeleteMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)$/i);
if (adminUserDeleteMatch && method === 'DELETE') {
return handleAdminDeleteUser(request, env, currentUser, adminUserDeleteMatch[1]);
}
// Device push token endpoint (no-op compatibility handler)
const deviceTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/token$/i);
if (deviceTokenMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(deviceTokenMatch[1]);
return handleUpdateDeviceToken(request, env, userId, deviceIdentifier);
}
// Not found
return errorResponse('Not found', 404);
} catch (error) {
console.error('Request error:', error);
return errorResponse('Internal server error', 500);
+47 -15
View File
@@ -7,6 +7,11 @@ import { StorageService } from './storage';
// This second layer only needs to be non-trivial, not expensive.
const SERVER_HASH_ITERATIONS = 100_000;
export interface VerifiedAccessContext {
payload: JWTPayload;
user: User;
}
export class AuthService {
private storage: StorageService;
@@ -61,27 +66,27 @@ export class AuthService {
}
// Generate access token
async generateAccessToken(user: User): Promise<string> {
async generateAccessToken(user: User, device?: { identifier: string; sessionStamp: string } | null): Promise<string> {
return createJWT(
{
sub: user.id,
email: user.email,
name: user.name,
sstamp: user.securityStamp,
...(device?.identifier ? { did: device.identifier, dstamp: device.sessionStamp } : {}),
},
this.env.JWT_SECRET
);
}
// Generate refresh token
async generateRefreshToken(userId: string): Promise<string> {
async generateRefreshToken(userId: string, device?: { identifier: string; sessionStamp: string } | null): Promise<string> {
const token = createRefreshToken();
await this.storage.saveRefreshToken(token, userId);
await this.storage.saveRefreshToken(token, userId, undefined, device?.identifier ?? null, device?.sessionStamp ?? null);
return token;
}
// Verify access token from Authorization header
async verifyAccessToken(authHeader: string | null): Promise<JWTPayload | null> {
async verifyAccessTokenWithUser(authHeader: string | null): Promise<VerifiedAccessContext | null> {
if (!authHeader) return null;
const parts = authHeader.split(' ');
@@ -92,30 +97,57 @@ export class AuthService {
const payload = await verifyJWT(parts[1], this.env.JWT_SECRET);
if (!payload) return null;
// Verify security stamp - ensures token is invalidated after password change
const user = await this.storage.getUserById(payload.sub);
if (!user) return null;
if (payload.sstamp !== user.securityStamp) {
return null; // Token was issued before password change
return null;
}
return payload;
if (payload.did) {
const device = await this.storage.getDevice(user.id, payload.did);
if (!device) return null;
if (!payload.dstamp || payload.dstamp !== device.sessionStamp) return null;
}
return { payload, user };
}
// Verify access token from Authorization header
async verifyAccessToken(authHeader: string | null): Promise<JWTPayload | null> {
const verified = await this.verifyAccessTokenWithUser(authHeader);
return verified?.payload ?? null;
}
// Refresh access token
async refreshAccessToken(refreshToken: string): Promise<{ accessToken: string; user: User } | null> {
const userId = await this.storage.getRefreshTokenUserId(refreshToken);
if (!userId) return null;
async refreshAccessToken(
refreshToken: string
): Promise<{ accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } | null> {
const record = await this.storage.getRefreshTokenRecord(refreshToken);
if (!record?.userId) return null;
const user = await this.storage.getUserById(userId);
const user = await this.storage.getUserById(record.userId);
if (!user) return null;
if (user.status !== 'active') {
await this.storage.deleteRefreshToken(refreshToken);
return null;
}
const accessToken = await this.generateAccessToken(user);
return { accessToken, user };
let device: { identifier: string; sessionStamp: string } | null = null;
if (record.deviceIdentifier) {
const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier);
if (!boundDevice) {
await this.storage.deleteRefreshToken(refreshToken);
return null;
}
if (!record.deviceSessionStamp || boundDevice.sessionStamp !== record.deviceSessionStamp) {
await this.storage.deleteRefreshToken(refreshToken);
return null;
}
device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp };
}
const accessToken = await this.generateAccessToken(user, device);
return { accessToken, user, device };
}
}
+335
View File
@@ -0,0 +1,335 @@
import { zipSync, unzipSync } from 'fflate';
import type { Env } from '../types';
import { APP_VERSION } from '../../shared/app-version';
import {
getAttachmentObjectKey,
getBlobStorageKind,
} from './blob-store';
type SqlRow = Record<string, string | number | null>;
const BACKUP_FORMAT_VERSION = 1;
// Worker-side backup export must stay well below Cloudflare CPU limits.
// Prefer store-only ZIP entries over heavier compression to keep exports reliable.
const BACKUP_TEXT_COMPRESSION_LEVEL = 0;
const BACKUP_JSON_INDENT = 2;
const MAX_BACKUP_ARCHIVE_BYTES = 64 * 1024 * 1024;
const MAX_BACKUP_ARCHIVE_ENTRY_COUNT = 10_000;
const MAX_BACKUP_EXTRACTED_BYTES = 64 * 1024 * 1024;
const MAX_BACKUP_DB_JSON_BYTES = 32 * 1024 * 1024;
export interface BackupManifest {
formatVersion: 1;
exportedAt: string;
appVersion: string;
storageKind: 'r2' | 'kv' | null;
tableCounts: Record<string, number>;
includes: {
attachments: boolean;
};
blobSummary: {
attachmentFiles: number;
totalBytes: number;
largestObjectBytes: number;
};
attachmentBlobs?: BackupManifestAttachmentBlob[];
}
export interface BackupManifestAttachmentBlob {
cipherId: string;
attachmentId: string;
blobName: string;
sizeBytes: number;
}
export interface BackupPayload {
manifest: BackupManifest;
db: {
config: SqlRow[];
users: SqlRow[];
user_revisions: SqlRow[];
folders: SqlRow[];
ciphers: SqlRow[];
attachments: SqlRow[];
};
}
export interface BackupArchiveBundle {
bytes: Uint8Array;
fileName: string;
manifest: BackupManifest;
}
export interface BuildBackupArchiveOptions {
includeAttachments?: boolean;
}
async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Promise<SqlRow[]> {
const result = await db.prepare(sql).bind(...values).all<SqlRow>();
return (result.results || []).map((row) => ({ ...row }));
}
function buildBackupFileName(date: Date = new Date()): 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]}.zip`;
}
function validateArchiveSize(bytes: Uint8Array): void {
if (bytes.byteLength > MAX_BACKUP_ARCHIVE_BYTES) {
throw new Error(`Backup archive is too large. The current restore limit is ${Math.floor(MAX_BACKUP_ARCHIVE_BYTES / (1024 * 1024))} MiB`);
}
}
function getRequiredZipEntries(db: BackupPayload['db']): string[] {
const entries: string[] = [];
for (const row of db.attachments) {
const cipherId = String(row.cipher_id || '').trim();
const attachmentId = String(row.id || '').trim();
if (!cipherId || !attachmentId) continue;
entries.push(`attachments/${cipherId}/${attachmentId}.bin`);
}
return entries;
}
function ensureRowArray(value: unknown, table: string): SqlRow[] {
if (!Array.isArray(value)) {
throw new Error(`Backup archive table ${table} is invalid`);
}
return value as SqlRow[];
}
function createZipEntries(files: Record<string, Uint8Array>): Record<string, Uint8Array | [Uint8Array, { level: 0 | 1 | 6 }]> {
const entries: Record<string, Uint8Array | [Uint8Array, { level: 0 | 1 | 6 }]> = {};
for (const [path, bytes] of Object.entries(files)) {
entries[path] = [bytes, { level: BACKUP_TEXT_COMPRESSION_LEVEL }];
}
return entries;
}
export interface ParseBackupArchiveOptions {
allowExternalAttachmentBlobs?: boolean;
}
export function parseBackupArchive(
bytes: Uint8Array,
options: ParseBackupArchiveOptions = {}
): { payload: BackupPayload; files: Record<string, Uint8Array> } {
validateArchiveSize(bytes);
let zipped: Record<string, Uint8Array>;
try {
zipped = unzipSync(bytes);
} catch {
throw new Error('Invalid backup archive');
}
const entryNames = Object.keys(zipped);
if (entryNames.length > MAX_BACKUP_ARCHIVE_ENTRY_COUNT) {
throw new Error('Backup archive contains too many files');
}
let totalExtractedBytes = 0;
for (const entry of entryNames) {
const entryBytes = zipped[entry];
totalExtractedBytes += entryBytes.byteLength;
if (entry === 'db.json' && entryBytes.byteLength > MAX_BACKUP_DB_JSON_BYTES) {
throw new Error('Backup archive database payload is too large');
}
if (totalExtractedBytes > MAX_BACKUP_EXTRACTED_BYTES) {
throw new Error('Backup archive expands beyond the current restore limit');
}
}
const manifestBytes = zipped['manifest.json'];
const dbBytes = zipped['db.json'];
if (!manifestBytes || !dbBytes) {
throw new Error('Backup archive is missing manifest.json or db.json');
}
const decoder = new TextDecoder();
let manifest: BackupManifest;
let db: BackupPayload['db'];
try {
manifest = JSON.parse(decoder.decode(manifestBytes)) as BackupManifest;
db = JSON.parse(decoder.decode(dbBytes)) as BackupPayload['db'];
} catch {
throw new Error('Backup archive contains invalid JSON metadata');
}
if (manifest?.formatVersion !== BACKUP_FORMAT_VERSION) {
throw new Error('Unsupported backup format version');
}
if (!db || typeof db !== 'object') {
throw new Error('Backup archive database payload is invalid');
}
const externalAttachmentKeys = new Set<string>(
options.allowExternalAttachmentBlobs
? (manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`)
: []
);
const requiredEntries = getRequiredZipEntries(db).filter((entry) => !externalAttachmentKeys.has(entry));
for (const entry of requiredEntries) {
if (!zipped[entry]) {
throw new Error(`Backup archive is missing required file: ${entry}`);
}
}
return {
payload: { manifest, db },
files: zipped,
};
}
export interface ValidateBackupPayloadOptions {
allowExternalAttachmentBlobs?: boolean;
}
export function validateBackupPayloadContents(
payload: BackupPayload,
files: Record<string, Uint8Array>,
options: ValidateBackupPayloadOptions = {}
): void {
const configRows = ensureRowArray(payload.db.config, 'config');
const userRows = ensureRowArray(payload.db.users, 'users');
const revisionRows = ensureRowArray(payload.db.user_revisions, 'user_revisions');
const folderRows = ensureRowArray(payload.db.folders, 'folders');
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
const externalAttachmentKeys = new Set<string>(
options.allowExternalAttachmentBlobs
? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`)
: []
);
const userIds = new Set<string>();
for (const row of userRows) {
const id = String(row.id || '').trim();
const email = String(row.email || '').trim();
if (!id || !email) throw new Error('Backup archive contains an invalid user row');
if (userIds.has(id)) throw new Error(`Backup archive contains duplicate user id: ${id}`);
userIds.add(id);
}
for (const row of configRows) {
const key = String(row.key || '').trim();
if (!key) throw new Error('Backup archive contains an invalid config row');
}
for (const row of revisionRows) {
const userId = String(row.user_id || '').trim();
if (!userId || !userIds.has(userId)) {
throw new Error(`Backup archive contains a revision for an unknown user: ${userId || '(empty)'}`);
}
}
const folderIds = new Set<string>();
for (const row of folderRows) {
const id = String(row.id || '').trim();
const userId = String(row.user_id || '').trim();
if (!id || !userIds.has(userId)) throw new Error('Backup archive contains an invalid folder row');
if (folderIds.has(id)) throw new Error(`Backup archive contains duplicate folder id: ${id}`);
folderIds.add(id);
}
const cipherIds = new Set<string>();
for (const row of cipherRows) {
const id = String(row.id || '').trim();
const userId = String(row.user_id || '').trim();
const folderId = String(row.folder_id || '').trim();
if (!id || !userIds.has(userId)) throw new Error('Backup archive contains an invalid cipher row');
if (folderId && !folderIds.has(folderId)) {
throw new Error(`Backup archive contains a cipher for an unknown folder: ${folderId}`);
}
if (cipherIds.has(id)) throw new Error(`Backup archive contains duplicate cipher id: ${id}`);
cipherIds.add(id);
}
for (const row of attachmentRows) {
const id = String(row.id || '').trim();
const cipherId = String(row.cipher_id || '').trim();
if (!id || !cipherId || !cipherIds.has(cipherId)) {
throw new Error('Backup archive contains an invalid attachment row');
}
const attachmentPath = `attachments/${cipherId}/${id}.bin`;
if (!files[attachmentPath] && !externalAttachmentKeys.has(attachmentPath)) {
throw new Error(`Backup archive is missing required file: attachments/${cipherId}/${id}.bin`);
}
}
}
export async function buildBackupArchive(
env: Env,
date: Date = new Date(),
options: BuildBackupArchiveOptions = {}
): Promise<BackupArchiveBundle> {
const encoder = new TextEncoder();
const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows] = await Promise.all([
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
]);
const includeAttachments = options.includeAttachments !== false;
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
const attachmentBlobs: BackupManifestAttachmentBlob[] = exportedAttachmentRows.map((row) => {
const cipherId = String(row.cipher_id || '').trim();
const attachmentId = String(row.id || '').trim();
return {
cipherId,
attachmentId,
blobName: getAttachmentObjectKey(cipherId, attachmentId),
sizeBytes: Number(row.size || 0) || 0,
};
});
const manifestBase = {
formatVersion: BACKUP_FORMAT_VERSION,
exportedAt: date.toISOString(),
appVersion: APP_VERSION,
storageKind: getBlobStorageKind(env),
tableCounts: {
config: configRows.length,
users: userRows.length,
user_revisions: revisionRows.length,
folders: folderRows.length,
ciphers: cipherRows.length,
attachments: exportedAttachmentRows.length,
},
includes: {
attachments: includeAttachments,
},
blobSummary: {
attachmentFiles: attachmentBlobs.length,
totalBytes: attachmentBlobs.reduce((sum, item) => sum + item.sizeBytes, 0),
largestObjectBytes: attachmentBlobs.reduce((max, item) => Math.max(max, item.sizeBytes), 0),
},
attachmentBlobs: includeAttachments ? attachmentBlobs : [],
} satisfies BackupManifest;
const files: Record<string, Uint8Array> = {
'manifest.json': encoder.encode(JSON.stringify(manifestBase, null, BACKUP_JSON_INDENT)),
'db.json': encoder.encode(JSON.stringify({
config: configRows,
users: userRows,
user_revisions: revisionRows,
folders: folderRows,
ciphers: cipherRows,
attachments: exportedAttachmentRows,
}, null, BACKUP_JSON_INDENT)),
};
return {
bytes: zipSync(createZipEntries(files)),
fileName: buildBackupFileName(date),
manifest: manifestBase,
};
}
+602
View File
@@ -0,0 +1,602 @@
import type { Env } from '../types';
import { StorageService } from './storage';
import {
type BackupSettingsPortableEnvelope,
decryptBackupSettingsRuntime,
encryptBackupSettingsEnvelope,
parseBackupSettingsEnvelope,
} from './backup-settings-crypto';
import {
BACKUP_DEFAULT_INTERVAL_HOURS,
BACKUP_DEFAULT_START_TIME,
BACKUP_DEFAULT_TIMEZONE,
type BackupDestinationConfig,
type BackupDestinationRecord,
type BackupDestinationType,
type BackupRuntimeState,
type BackupScheduleConfig,
type BackupSettings,
type E3BackupDestination,
type WebDavBackupDestination,
createBackupRandomId,
createDefaultBackupDestinationName,
createDefaultBackupScheduleConfig,
createDefaultBackupSettings as createSharedDefaultBackupSettings,
} from '../../shared/backup-schema';
export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1';
export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5;
const MAX_BACKUP_DESTINATIONS = 24;
export type {
BackupDestinationConfig,
BackupDestinationRecord,
BackupDestinationType,
BackupRuntimeState,
BackupScheduleConfig,
BackupSettings,
E3BackupDestination,
WebDavBackupDestination,
} from '../../shared/backup-schema';
export interface BackupSettingsInput {
destinations?: unknown;
}
export interface BackupSettingsRepairState {
needsRepair: boolean;
portable: BackupSettingsPortableEnvelope | null;
}
function defaultScheduleConfig(timezone: string = 'UTC'): BackupScheduleConfig {
return { ...createDefaultBackupScheduleConfig(assertValidTimeZone(timezone)) };
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function asTrimmedString(value: unknown): string {
return String(value ?? '').trim();
}
function normalizePath(value: unknown): string {
return asTrimmedString(value).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
}
function assertValidTimeZone(timezone: string): string {
try {
new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(new Date());
return timezone;
} catch {
throw new Error('Invalid backup timezone');
}
}
function normalizeRetentionCount(value: unknown, fallback: number | null = 30): number | null {
if (value === undefined) return fallback;
if (value === null || String(value).trim() === '') return null;
const count = Number(value);
if (!Number.isInteger(count) || count < 1 || count > 1000) {
throw new Error('Backup retention count must be between 1 and 1000');
}
return count;
}
function normalizeIntervalHours(value: unknown, fallback: number = BACKUP_DEFAULT_INTERVAL_HOURS): number {
const raw = value === undefined || value === null || value === '' ? fallback : Number(value);
if (!Number.isInteger(raw) || raw < 1 || raw > 99) {
throw new Error('Backup interval hours must be between 1 and 99');
}
return raw;
}
function normalizeStartTime(value: unknown, fallback: string = BACKUP_DEFAULT_START_TIME): string {
const raw = asTrimmedString(value) || fallback;
const match = raw.match(/^(\d{1,2})(?::(\d{1,2}))?$/);
if (!match) {
throw new Error('Backup start time must be in HH:mm format');
}
const hour = Number(match[1]);
const minute = Number(match[2] ?? '0');
if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
throw new Error('Backup start time must be in HH:mm format');
}
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}
function normalizeE3Destination(value: unknown, allowIncomplete = false): E3BackupDestination {
const source = isPlainObject(value) ? value : {};
const endpoint = asTrimmedString(source.endpoint);
const bucket = asTrimmedString(source.bucket);
const accessKeyId = asTrimmedString(source.accessKeyId);
const secretAccessKey = asTrimmedString(source.secretAccessKey);
const region = asTrimmedString(source.region) || 'auto';
const rootPath = normalizePath(source.rootPath);
if (!allowIncomplete || endpoint) {
if (!endpoint) throw new Error('E3 endpoint is required');
if (!/^https?:\/\//i.test(endpoint)) throw new Error('E3 endpoint must start with http:// or https://');
}
if (!allowIncomplete || bucket) {
if (!bucket) throw new Error('E3 bucket is required');
}
if (!allowIncomplete || accessKeyId) {
if (!accessKeyId) throw new Error('E3 access key is required');
}
if (!allowIncomplete || secretAccessKey) {
if (!secretAccessKey) throw new Error('E3 secret key is required');
}
return {
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
bucket,
region,
accessKeyId,
secretAccessKey,
rootPath,
};
}
function normalizeWebDavDestination(value: unknown, allowIncomplete = false): WebDavBackupDestination {
const source = isPlainObject(value) ? value : {};
const baseUrl = asTrimmedString(source.baseUrl);
const username = asTrimmedString(source.username);
const password = String(source.password ?? '');
const remotePath = normalizePath(source.remotePath);
if (!allowIncomplete || baseUrl) {
if (!baseUrl) throw new Error('WebDAV server URL is required');
if (!/^https?:\/\//i.test(baseUrl)) throw new Error('WebDAV server URL must start with http:// or https://');
}
if (!allowIncomplete || username) {
if (!username) throw new Error('WebDAV username is required');
}
if (!allowIncomplete || password) {
if (!password) throw new Error('WebDAV password is required');
}
return {
baseUrl: baseUrl ? baseUrl.replace(/\/+$/, '') : '',
username,
password,
remotePath,
};
}
function normalizeDestination(
destinationType: BackupDestinationType,
destination: unknown,
allowIncomplete = false
): BackupDestinationConfig {
if (destinationType === 'e3') return normalizeE3Destination(destination, allowIncomplete);
return normalizeWebDavDestination(destination, allowIncomplete);
}
function normalizeRuntime(value: unknown): BackupRuntimeState {
const source = isPlainObject(value) ? value : {};
const asIso = (input: unknown): string | null => {
const raw = asTrimmedString(input);
if (!raw) return null;
const date = new Date(raw);
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
};
const asMaybeNumber = (input: unknown): number | null => {
if (input === null || input === undefined || input === '') return null;
const n = Number(input);
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
};
return {
lastAttemptAt: asIso(source.lastAttemptAt),
lastAttemptLocalDate: asTrimmedString(source.lastAttemptLocalDate) || null,
lastSuccessAt: asIso(source.lastSuccessAt),
lastErrorAt: asIso(source.lastErrorAt),
lastErrorMessage: asTrimmedString(source.lastErrorMessage) || null,
lastUploadedFileName: asTrimmedString(source.lastUploadedFileName) || null,
lastUploadedSizeBytes: asMaybeNumber(source.lastUploadedSizeBytes),
lastUploadedDestination: asTrimmedString(source.lastUploadedDestination) || null,
};
}
function defaultDestinationName(type: BackupDestinationType, index: number): string {
return createDefaultBackupDestinationName(type, index);
}
function getDestinationType(raw: unknown): BackupDestinationType {
const value = asTrimmedString(raw);
if (value === 'e3' || value === 'webdav') return value;
throw new Error('Backup destination type is invalid');
}
function normalizeDestinationRecord(
input: unknown,
previousById: Map<string, BackupDestinationRecord>,
index: number,
fallbackTimezone: string
): BackupDestinationRecord {
if (!isPlainObject(input)) {
throw new Error('Backup destination is invalid');
}
const id = asTrimmedString(input.id) || createBackupRandomId();
const type = getDestinationType(input.type);
const previous = previousById.get(id);
const runtime = previous?.runtime ? normalizeRuntime(previous.runtime) : normalizeRuntime(input.runtime);
const name = asTrimmedString(input.name) || previous?.name || defaultDestinationName(type, index + 1);
const scheduleSource = isPlainObject(input.schedule) ? input.schedule : {};
const previousSchedule = previous?.schedule || defaultScheduleConfig(fallbackTimezone);
const retentionSource = Object.prototype.hasOwnProperty.call(scheduleSource, 'retentionCount')
? scheduleSource.retentionCount
: previousSchedule.retentionCount;
const schedule: BackupScheduleConfig = {
enabled: !!(scheduleSource.enabled ?? previousSchedule.enabled),
intervalHours: normalizeIntervalHours(
scheduleSource.intervalHours ?? previousSchedule.intervalHours,
previousSchedule.intervalHours || BACKUP_DEFAULT_INTERVAL_HOURS
),
startTime: normalizeStartTime(
scheduleSource.startTime ?? previousSchedule.startTime,
previousSchedule.startTime || BACKUP_DEFAULT_START_TIME
),
timezone: assertValidTimeZone(asTrimmedString(scheduleSource.timezone ?? previousSchedule.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount),
};
const destination = normalizeDestination(type, input.destination, !schedule.enabled);
return {
id,
name,
type,
includeAttachments: typeof input.includeAttachments === 'boolean'
? input.includeAttachments
: previous?.includeAttachments ?? false,
destination,
schedule,
runtime,
};
}
function parseLegacyBackupSettings(rawValue: Record<string, unknown>, fallbackTimezone: string): BackupSettings {
const legacyFrequency = asTrimmedString(rawValue.frequency).toLowerCase();
const intervalHours = legacyFrequency === 'weekly'
? 24 * 7
: legacyFrequency === 'monthly'
? 24 * 30
: BACKUP_DEFAULT_INTERVAL_HOURS;
const destinationTypeRaw = asTrimmedString(rawValue.destinationType);
const destinationType: BackupDestinationType =
destinationTypeRaw === 'e3' || destinationTypeRaw === 'webdav'
? destinationTypeRaw
: 'webdav';
const destination = {
id: createBackupRandomId(),
name: defaultDestinationName(destinationType, 1),
type: destinationType,
includeAttachments: false,
destination: normalizeDestination(destinationType, rawValue.destination),
schedule: {
enabled: !!rawValue.enabled,
intervalHours,
startTime: BACKUP_DEFAULT_START_TIME,
timezone: assertValidTimeZone(asTrimmedString(rawValue.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
retentionCount: 30,
},
runtime: normalizeRuntime(rawValue.runtime),
} satisfies BackupDestinationRecord;
return {
destinations: [destination],
};
}
function parseDestinations(
rawDestinations: unknown,
previousById: Map<string, BackupDestinationRecord>,
fallbackTimezone: string
): BackupDestinationRecord[] {
if (!Array.isArray(rawDestinations)) {
throw new Error('Backup destinations are invalid');
}
if (rawDestinations.length > MAX_BACKUP_DESTINATIONS) {
throw new Error(`You can save up to ${MAX_BACKUP_DESTINATIONS} backup destinations`);
}
const destinations = rawDestinations.map((entry, index) => normalizeDestinationRecord(entry, previousById, index, fallbackTimezone));
const ids = new Set<string>();
for (const destination of destinations) {
if (ids.has(destination.id)) {
throw new Error('Backup destination ids must be unique');
}
ids.add(destination.id);
}
return destinations;
}
function mapDestinationsById(destinations: BackupDestinationRecord[]): Map<string, BackupDestinationRecord> {
return new Map(destinations.map((destination) => [destination.id, destination]));
}
export function getDefaultBackupSettings(timezone: string = 'UTC'): BackupSettings {
return createSharedDefaultBackupSettings(assertValidTimeZone(timezone));
}
export function parseBackupSettings(raw: string | null, fallbackTimezone: string = 'UTC'): BackupSettings {
if (!raw) return getDefaultBackupSettings(fallbackTimezone);
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
if (Array.isArray(parsed.destinations)) {
const globalTimezone = assertValidTimeZone(asTrimmedString(parsed.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE);
const globalEnabled = !!parsed.enabled;
const activeDestinationIdRaw = asTrimmedString(parsed.activeDestinationId);
const globalFrequency = asTrimmedString(parsed.frequency).toLowerCase();
const globalIntervalHours = globalFrequency === 'weekly'
? 24 * 7
: globalFrequency === 'monthly'
? 24 * 30
: BACKUP_DEFAULT_INTERVAL_HOURS;
const previousById = new Map<string, BackupDestinationRecord>();
const normalizedEntries = (parsed.destinations as unknown[]).map((entry) => {
if (!isPlainObject(entry)) return entry;
if (isPlainObject(entry.schedule)) return entry;
const entryId = asTrimmedString(entry.id);
const scheduleEnabled = globalEnabled && (!activeDestinationIdRaw || entryId === activeDestinationIdRaw);
return {
...entry,
schedule: {
enabled: scheduleEnabled,
intervalHours: globalIntervalHours,
startTime: BACKUP_DEFAULT_START_TIME,
timezone: globalTimezone,
retentionCount: 30,
},
};
});
return {
destinations: parseDestinations(normalizedEntries, previousById, fallbackTimezone),
};
}
return parseLegacyBackupSettings(parsed, fallbackTimezone);
} catch {
return getDefaultBackupSettings(fallbackTimezone);
}
}
export function normalizeBackupSettingsInput(
input: BackupSettingsInput,
previous: BackupSettings
): BackupSettings {
if (!isPlainObject(input)) {
throw new Error('Backup settings payload is invalid');
}
const previousById = mapDestinationsById(previous.destinations);
const rawDestinations = input.destinations ?? previous.destinations;
const destinations = parseDestinations(rawDestinations, previousById, BACKUP_DEFAULT_TIMEZONE);
return {
destinations,
};
}
export function serializeBackupSettings(settings: BackupSettings): string {
return JSON.stringify(settings);
}
export async function loadBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettings> {
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
if (!raw) {
const settings = getDefaultBackupSettings(fallbackTimezone);
await saveBackupSettings(storage, env, settings);
return settings;
}
const envelope = parseBackupSettingsEnvelope(raw);
if (!envelope) {
const settings = parseBackupSettings(raw, fallbackTimezone);
await saveBackupSettings(storage, env, settings);
return settings;
}
try {
const decrypted = await decryptBackupSettingsRuntime(raw, env);
return parseBackupSettings(decrypted, fallbackTimezone);
} catch {
throw new Error('Backup settings need administrator reactivation after restore');
}
}
export async function saveBackupSettings(storage: StorageService, env: Env, settings: BackupSettings): Promise<void> {
const users = await storage.getAllUsers();
const hasPortableAdmins = users.some(
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
);
if (!hasPortableAdmins) {
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, serializeBackupSettings(settings));
return;
}
const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted);
}
export async function normalizeImportedBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<void> {
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
if (!raw) return;
const envelope = parseBackupSettingsEnvelope(raw);
if (envelope) {
try {
const decrypted = await decryptBackupSettingsRuntime(raw, env);
const settings = parseBackupSettings(decrypted, fallbackTimezone);
await saveBackupSettings(storage, env, settings);
return;
} catch {
// Keep imported portable recovery data intact until an admin signs in and repairs it.
return;
}
}
const settings = parseBackupSettings(raw, fallbackTimezone);
await saveBackupSettings(storage, env, settings);
}
export async function getBackupSettingsRepairState(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettingsRepairState> {
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
if (!raw) {
const settings = getDefaultBackupSettings(fallbackTimezone);
await saveBackupSettings(storage, env, settings);
return { needsRepair: false, portable: null };
}
const envelope = parseBackupSettingsEnvelope(raw);
if (!envelope) {
const settings = parseBackupSettings(raw, fallbackTimezone);
await saveBackupSettings(storage, env, settings);
return { needsRepair: false, portable: null };
}
try {
await decryptBackupSettingsRuntime(raw, env);
return { needsRepair: false, portable: null };
} catch {
return {
needsRepair: true,
portable: envelope.portable,
};
}
}
export async function repairBackupSettings(storage: StorageService, env: Env, settings: BackupSettings): Promise<void> {
await saveBackupSettings(storage, env, settings);
}
export function findBackupDestination(
settings: BackupSettings,
destinationId: string | null | undefined
): BackupDestinationRecord | null {
const normalizedId = asTrimmedString(destinationId);
if (!normalizedId) return null;
return settings.destinations.find((destination) => destination.id === normalizedId) || null;
}
export function requireBackupDestination(settings: BackupSettings, destinationId?: string | null): BackupDestinationRecord {
const destination = destinationId ? findBackupDestination(settings, destinationId) : settings.destinations[0] || null;
if (!destination) {
throw new Error('Backup destination not found');
}
return destination;
}
function getDateTimeParts(date: Date, timezone: string): { year: string; month: string; day: string; hour: string; minute: string } {
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
});
const parts = formatter.formatToParts(date);
const pick = (type: string): string => parts.find((part) => part.type === type)?.value || '';
return {
year: pick('year'),
month: pick('month'),
day: pick('day'),
hour: pick('hour'),
minute: pick('minute'),
};
}
export function getBackupLocalDateKey(date: Date, timezone: string): string {
const parts = getDateTimeParts(date, timezone);
return `${parts.year}-${parts.month}-${parts.day}`;
}
export function getBackupLocalTime(date: Date, timezone: string): string {
const parts = getDateTimeParts(date, timezone);
return `${parts.hour}:${parts.minute}`;
}
function parseLocalDateKey(dateKey: string): { year: number; month: number; day: number } | null {
const match = String(dateKey || '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return null;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return null;
return { year, month, day };
}
function getUtcDateForLocalTime(timezone: string, year: number, month: number, day: number, hour: number, minute: number): Date {
const utcGuess = Date.UTC(year, month - 1, day, hour, minute, 0, 0);
const actual = getDateTimeParts(new Date(utcGuess), timezone);
const actualUtc = Date.UTC(
Number(actual.year),
Number(actual.month) - 1,
Number(actual.day),
Number(actual.hour),
Number(actual.minute),
0,
0
);
const desiredUtc = Date.UTC(year, month - 1, day, hour, minute, 0, 0);
return new Date(utcGuess - (actualUtc - desiredUtc));
}
function getBackupSlotStartsForLocalDay(
dateKey: string,
timezone: string,
startTime: string,
intervalHours: number
): Date[] {
const parsedDate = parseLocalDateKey(dateKey);
const parsedTime = normalizeStartTime(startTime).split(':').map((value) => Number(value));
if (!parsedDate || parsedTime.length !== 2) return [];
const [hour, minute] = parsedTime;
const firstSlot = getUtcDateForLocalTime(timezone, parsedDate.year, parsedDate.month, parsedDate.day, hour, minute);
const nextLocalDay = new Date(Date.UTC(parsedDate.year, parsedDate.month - 1, parsedDate.day, 0, 0, 0, 0));
nextLocalDay.setUTCDate(nextLocalDay.getUTCDate() + 1);
const nextDay = getUtcDateForLocalTime(
timezone,
nextLocalDay.getUTCFullYear(),
nextLocalDay.getUTCMonth() + 1,
nextLocalDay.getUTCDate(),
0,
0
);
const intervalMs = intervalHours * 60 * 60 * 1000;
const slots: Date[] = [];
for (let slotMs = firstSlot.getTime(); slotMs < nextDay.getTime(); slotMs += intervalMs) {
slots.push(new Date(slotMs));
}
return slots;
}
export function isBackupDueNow(
destination: BackupDestinationRecord,
now: Date,
windowMinutes: number = BACKUP_SCHEDULER_WINDOW_MINUTES
): boolean {
if (!destination.schedule.enabled) return false;
const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000;
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime())
? lastAttemptAt.getTime()
: Number.NEGATIVE_INFINITY;
const localDateKey = getBackupLocalDateKey(now, destination.schedule.timezone);
const slotStarts = getBackupSlotStartsForLocalDay(
localDateKey,
destination.schedule.timezone,
destination.schedule.startTime,
destination.schedule.intervalHours
);
for (const slotStart of slotStarts) {
const slotStartMs = slotStart.getTime();
if (now.getTime() < slotStartMs || now.getTime() >= slotStartMs + toleranceMs) continue;
if (lastAttemptMs >= slotStartMs) return false;
return true;
}
return false;
}
+547
View File
@@ -0,0 +1,547 @@
import type { Env } from '../types';
import { StorageService } from './storage';
import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobStorageKind, putBlobObject } from './blob-store';
import { normalizeImportedBackupSettings } from './backup-config';
import {
type BackupManifestAttachmentBlob,
type BackupPayload,
parseBackupArchive,
validateBackupPayloadContents,
} from './backup-archive';
type SqlRow = Record<string, string | number | null>;
export interface BackupImportResultBody {
object: 'instance-backup-import';
imported: {
config: number;
users: number;
userRevisions: number;
folders: number;
ciphers: number;
attachments: number;
attachmentFiles: number;
};
skipped: {
reason: string | null;
attachments: number;
items: Array<{
kind: 'attachment';
path: string;
sizeBytes: number;
}>;
};
}
export interface BackupImportExecutionResult {
result: BackupImportResultBody;
auditActorUserId: string | null;
}
async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Promise<SqlRow[]> {
const response = await db.prepare(sql).bind(...values).all<SqlRow>();
return (response.results || []).map((row) => ({ ...row }));
}
async function ensureImportTargetIsFresh(db: D1Database): Promise<void> {
const counts = await Promise.all([
db.prepare('SELECT COUNT(*) AS count FROM ciphers').first<{ count: number }>(),
db.prepare('SELECT COUNT(*) AS count FROM folders').first<{ count: number }>(),
db.prepare('SELECT COUNT(*) AS count FROM attachments').first<{ count: number }>(),
db.prepare('SELECT COUNT(*) AS count FROM sends').first<{ count: number }>(),
]);
const total = counts.reduce((sum, row) => sum + Number(row?.count || 0), 0);
if (total > 0) {
throw new Error('Backup import requires a fresh instance with no vault or send data');
}
}
function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[] {
return [
'DELETE FROM attachments',
'DELETE FROM ciphers',
'DELETE FROM folders',
'DELETE FROM sends',
'DELETE FROM trusted_two_factor_device_tokens',
'DELETE FROM devices',
'DELETE FROM refresh_tokens',
'DELETE FROM invites',
'DELETE FROM audit_logs',
'DELETE FROM user_revisions',
'DELETE FROM users',
'DELETE FROM config',
'DELETE FROM login_attempts_ip',
'DELETE FROM api_rate_limits',
'DELETE FROM used_attachment_download_tokens',
].map((sql) => db.prepare(sql));
}
async function collectCurrentBlobKeys(db: D1Database): Promise<Set<string>> {
const keys = new Set<string>();
const attachmentRows = await queryRows(
db,
`SELECT a.id, a.cipher_id
FROM attachments a
INNER JOIN ciphers c ON c.id = a.cipher_id`
);
for (const row of attachmentRows) {
const cipherId = String(row.cipher_id || '').trim();
const attachmentId = String(row.id || '').trim();
if (!cipherId || !attachmentId) continue;
keys.add(getAttachmentObjectKey(cipherId, attachmentId));
}
return keys;
}
const KV_BLOB_SKIP_REASON = 'Cloudflare KV object size limit (25 MB)';
const BLOB_STORAGE_UNAVAILABLE_SKIP_REASON = 'Attachment storage is not configured';
const ATTACHMENT_RESTORE_FAILED_REASON = 'Some attachments could not be restored and were skipped';
interface BackupImportSkipSummary {
reason: string | null;
attachments: number;
items: Array<{
kind: 'attachment';
path: string;
sizeBytes: number;
}>;
}
interface PreparedBackupImportPayload {
payload: BackupPayload;
skipped: BackupImportSkipSummary;
}
interface AttachmentRestoreResult {
imported: number;
restoredAttachments: SqlRow[];
skipped: BackupImportSkipSummary;
}
interface RemoteAttachmentSource {
hasAttachment(blobName: string): Promise<boolean>;
loadAttachment(blobName: string): Promise<Uint8Array | null>;
}
function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files: Record<string, Uint8Array>): PreparedBackupImportPayload {
const storageKind = getBlobStorageKind(env);
if (storageKind === 'r2') {
return {
payload,
skipped: {
reason: null,
attachments: 0,
items: [],
},
};
}
if (storageKind === null) {
const skippedItems = (payload.db.attachments || []).map((row) => {
const cipherId = String(row.cipher_id || '').trim();
const attachmentId = String(row.id || '').trim();
return {
kind: 'attachment' as const,
path: `attachments/${cipherId}/${attachmentId}.bin`,
sizeBytes: Number(row.size || 0) || 0,
};
});
return {
payload: {
...payload,
db: {
...payload.db,
attachments: [],
},
},
skipped: {
reason: skippedItems.length ? BLOB_STORAGE_UNAVAILABLE_SKIP_REASON : null,
attachments: skippedItems.length,
items: skippedItems,
},
};
}
const oversizedAttachmentPaths = new Set<string>();
const skippedItems: BackupImportSkipSummary['items'] = [];
for (const entry of Object.keys(files)) {
if (!entry.endsWith('.bin')) continue;
const sizeBytes = files[entry].byteLength;
if (sizeBytes <= KV_MAX_OBJECT_BYTES) continue;
if (entry.startsWith('attachments/')) {
oversizedAttachmentPaths.add(entry);
skippedItems.push({ kind: 'attachment', path: entry, sizeBytes });
}
}
const nextAttachments = (payload.db.attachments || []).filter((row) => {
const cipherId = String(row.cipher_id || '').trim();
const attachmentId = String(row.id || '').trim();
if (!cipherId || !attachmentId) return false;
return !oversizedAttachmentPaths.has(`attachments/${cipherId}/${attachmentId}.bin`);
});
const nextPayload: BackupPayload = {
...payload,
db: {
...payload.db,
attachments: nextAttachments,
},
};
const needsKvBlobStorage = nextAttachments.length > 0;
if (needsKvBlobStorage && !env.ATTACHMENTS_KV) {
throw new Error('Backup restore requires ATTACHMENTS_KV when using KV blob storage');
}
return {
payload: nextPayload,
skipped: {
reason: skippedItems.length ? KV_BLOB_SKIP_REASON : null,
attachments: skippedItems.length,
items: skippedItems,
},
};
}
function buildInsertStatements(db: D1Database, table: string, columns: string[], rows: SqlRow[], upsert = false): D1PreparedStatement[] {
if (!rows.length) return [];
const placeholders = `(${columns.map(() => '?').join(', ')})`;
const sql = `INSERT ${upsert ? 'OR REPLACE ' : ''}INTO ${table} (${columns.join(', ')}) VALUES ${placeholders}`;
return rows.map((row) => db.prepare(sql).bind(...columns.map((column) => row[column] ?? null)));
}
async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record<string, Uint8Array>): Promise<AttachmentRestoreResult> {
const restoredAttachments: SqlRow[] = [];
const skippedItems: BackupImportSkipSummary['items'] = [];
for (const row of db.attachments || []) {
const cipherId = String(row.cipher_id || '').trim();
const attachmentId = String(row.id || '').trim();
if (!cipherId || !attachmentId) continue;
const key = `attachments/${cipherId}/${attachmentId}.bin`;
const bytes = files[key];
if (!bytes) {
skippedItems.push({
kind: 'attachment',
path: key,
sizeBytes: Number(row.size || 0) || 0,
});
continue;
}
try {
await putBlobObject(env, getAttachmentObjectKey(cipherId, attachmentId), bytes, {
size: bytes.byteLength,
contentType: 'application/octet-stream',
});
restoredAttachments.push(row);
} catch {
skippedItems.push({
kind: 'attachment',
path: key,
sizeBytes: bytes.byteLength,
});
}
}
return {
imported: restoredAttachments.length,
restoredAttachments,
skipped: {
reason: skippedItems.length ? ATTACHMENT_RESTORE_FAILED_REASON : null,
attachments: skippedItems.length,
items: skippedItems,
},
};
}
function buildAttachmentBlobLookup(manifest: BackupPayload['manifest']): Map<string, BackupManifestAttachmentBlob> {
return new Map(
(manifest.attachmentBlobs || []).map((item) => [`${item.cipherId}/${item.attachmentId}`, item])
);
}
async function prepareRemoteAttachmentPayload(
env: Env,
payload: BackupPayload,
files: Record<string, Uint8Array>,
source: RemoteAttachmentSource
): Promise<PreparedBackupImportPayload> {
const manifestLookup = buildAttachmentBlobLookup(payload.manifest);
const storageKind = getBlobStorageKind(env);
const nextAttachments: SqlRow[] = [];
const skippedItems: BackupImportSkipSummary['items'] = [];
for (const row of payload.db.attachments || []) {
const cipherId = String(row.cipher_id || '').trim();
const attachmentId = String(row.id || '').trim();
const lookupKey = `${cipherId}/${attachmentId}`;
const ref = manifestLookup.get(lookupKey);
const sizeBytes = ref?.sizeBytes || Number(row.size || 0) || 0;
const path = ref ? `attachments/${ref.blobName}` : `attachments/${lookupKey}`;
const inlinePath = `attachments/${cipherId}/${attachmentId}.bin`;
if (files[inlinePath]) {
nextAttachments.push(row);
continue;
}
if (!ref) {
skippedItems.push({ kind: 'attachment', path, sizeBytes });
continue;
}
if (storageKind === 'kv' && sizeBytes > KV_MAX_OBJECT_BYTES) {
skippedItems.push({ kind: 'attachment', path, sizeBytes });
continue;
}
if (storageKind === null) {
skippedItems.push({ kind: 'attachment', path, sizeBytes });
continue;
}
if (!(await source.hasAttachment(ref.blobName))) {
skippedItems.push({ kind: 'attachment', path, sizeBytes });
continue;
}
nextAttachments.push(row);
}
return {
payload: {
...payload,
db: {
...payload.db,
attachments: nextAttachments,
},
},
skipped: {
reason: skippedItems.length ? 'Some remote attachments were unavailable and were skipped' : null,
attachments: skippedItems.length,
items: skippedItems,
},
};
}
async function removeAttachmentRows(db: D1Database, attachmentRows: SqlRow[]): Promise<void> {
if (!attachmentRows.length) return;
const statements = attachmentRows
.map((row) => {
const attachmentId = String(row.id || '').trim();
const cipherId = String(row.cipher_id || '').trim();
if (!attachmentId || !cipherId) return null;
return db.prepare('DELETE FROM attachments WHERE id = ? AND cipher_id = ?').bind(attachmentId, cipherId);
})
.filter((statement): statement is D1PreparedStatement => !!statement);
if (!statements.length) return;
await db.batch(statements);
}
async function restoreRemoteAttachmentFiles(
env: Env,
payload: BackupPayload,
files: Record<string, Uint8Array>,
source: RemoteAttachmentSource
): Promise<{
imported: number;
skipped: BackupImportSkipSummary;
restoredAttachments: SqlRow[];
}> {
const manifestLookup = buildAttachmentBlobLookup(payload.manifest);
const restoredAttachments: SqlRow[] = [];
const skippedItems: BackupImportSkipSummary['items'] = [];
for (const row of payload.db.attachments || []) {
const cipherId = String(row.cipher_id || '').trim();
const attachmentId = String(row.id || '').trim();
const inlinePath = `attachments/${cipherId}/${attachmentId}.bin`;
const ref = manifestLookup.get(`${cipherId}/${attachmentId}`);
if (!ref && !files[inlinePath]) {
skippedItems.push({
kind: 'attachment',
path: `attachments/${cipherId}/${attachmentId}`,
sizeBytes: Number(row.size || 0) || 0,
});
continue;
}
const bytes = files[inlinePath] || (ref ? await source.loadAttachment(ref.blobName) : null);
if (!bytes) {
skippedItems.push({
kind: 'attachment',
path: ref ? `attachments/${ref.blobName}` : inlinePath,
sizeBytes: ref?.sizeBytes || Number(row.size || 0) || 0,
});
continue;
}
try {
await putBlobObject(env, getAttachmentObjectKey(cipherId, attachmentId), bytes, {
size: bytes.byteLength,
contentType: 'application/octet-stream',
});
restoredAttachments.push(row);
} catch {
skippedItems.push({
kind: 'attachment',
path: ref ? `attachments/${ref.blobName}` : inlinePath,
sizeBytes: bytes.byteLength,
});
}
}
return {
imported: restoredAttachments.length,
restoredAttachments,
skipped: {
reason: skippedItems.length ? ATTACHMENT_RESTORE_FAILED_REASON : null,
attachments: skippedItems.length,
items: skippedItems,
},
};
}
async function cleanupOrphanedBlobFiles(env: Env, beforeKeys: Set<string>, afterKeys: Set<string>): Promise<void> {
const staleKeys = Array.from(beforeKeys).filter((key) => !afterKeys.has(key));
for (const key of staleKeys) {
await deleteBlobObject(env, key);
}
}
async function importBackupRows(db: D1Database, payload: BackupPayload['db']): Promise<void> {
const statements: D1PreparedStatement[] = [
...buildResetImportTargetStatements(db),
...buildInsertStatements(db, 'config', ['key', 'value'], payload.config || [], true),
...buildInsertStatements(
db,
'users',
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'],
payload.users || []
),
...buildInsertStatements(db, 'user_revisions', ['user_id', 'revision_date'], payload.user_revisions || [], true),
...buildInsertStatements(db, 'folders', ['id', 'user_id', 'name', 'created_at', 'updated_at'], payload.folders || []),
...buildInsertStatements(
db,
'ciphers',
['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'deleted_at'],
payload.ciphers || []
),
...buildInsertStatements(db, 'attachments', ['id', 'cipher_id', 'file_name', 'size', 'size_name', 'key'], payload.attachments || []),
];
await db.batch(statements);
}
export async function importBackupArchiveBytes(
archiveBytes: Uint8Array,
env: Env,
actorUserId: string,
replaceExisting: boolean
): Promise<BackupImportExecutionResult> {
const storage = new StorageService(env.DB);
const parsed = parseBackupArchive(archiveBytes);
validateBackupPayloadContents(parsed.payload, parsed.files);
const prepared = prepareImportPayloadForTarget(env, parsed.payload, parsed.files);
try {
await ensureImportTargetIsFresh(env.DB);
} catch (error) {
if (!replaceExisting) {
throw error instanceof Error ? error : new Error('Backup import requires a fresh instance');
}
}
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
const { db } = prepared.payload;
await importBackupRows(env.DB, db);
await normalizeImportedBackupSettings(storage, env, 'UTC');
const restored = await restoreBlobFiles(env, db, parsed.files);
const failedRestoreRows = (db.attachments || []).filter((row) => !restored.restoredAttachments.includes(row));
await removeAttachmentRows(env.DB, failedRestoreRows);
if (replaceExisting && previousBlobKeys.size) {
await cleanupOrphanedBlobFiles(env, previousBlobKeys, await collectCurrentBlobKeys(env.DB));
}
await storage.setRegistered();
return {
auditActorUserId: (db.users || []).some((row) => String(row.id || '').trim() === actorUserId) ? actorUserId : null,
result: {
object: 'instance-backup-import',
imported: {
config: (db.config || []).length,
users: (db.users || []).length,
userRevisions: (db.user_revisions || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: restored.restoredAttachments.length,
attachmentFiles: restored.imported,
},
skipped: {
reason: restored.skipped.reason || prepared.skipped.reason,
attachments: prepared.skipped.attachments + restored.skipped.attachments,
items: [...prepared.skipped.items, ...restored.skipped.items],
},
},
};
}
export async function importRemoteBackupArchiveBytes(
archiveBytes: Uint8Array,
env: Env,
actorUserId: string,
replaceExisting: boolean,
source: RemoteAttachmentSource
): Promise<BackupImportExecutionResult> {
const storage = new StorageService(env.DB);
const parsed = parseBackupArchive(archiveBytes, { allowExternalAttachmentBlobs: true });
const preparedRemote = await prepareRemoteAttachmentPayload(env, parsed.payload, parsed.files, source);
validateBackupPayloadContents(preparedRemote.payload, parsed.files, { allowExternalAttachmentBlobs: true });
try {
await ensureImportTargetIsFresh(env.DB);
} catch (error) {
if (!replaceExisting) {
throw error instanceof Error ? error : new Error('Backup import requires a fresh instance');
}
}
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
const { db } = preparedRemote.payload;
await importBackupRows(env.DB, db);
await normalizeImportedBackupSettings(storage, env, 'UTC');
const restored = await restoreRemoteAttachmentFiles(env, preparedRemote.payload, parsed.files, source);
const failedRestoreRows = (db.attachments || []).filter((row) => !restored.restoredAttachments.includes(row));
await removeAttachmentRows(env.DB, failedRestoreRows);
if (replaceExisting && previousBlobKeys.size) {
await cleanupOrphanedBlobFiles(env, previousBlobKeys, await collectCurrentBlobKeys(env.DB));
}
await storage.setRegistered();
const finalSkippedItems = [...preparedRemote.skipped.items, ...restored.skipped.items];
const finalSkippedReason = finalSkippedItems.length
? restored.skipped.reason || preparedRemote.skipped.reason
: null;
return {
auditActorUserId: (db.users || []).some((row) => String(row.id || '').trim() === actorUserId) ? actorUserId : null,
result: {
object: 'instance-backup-import',
imported: {
config: (db.config || []).length,
users: (db.users || []).length,
userRevisions: (db.user_revisions || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: restored.restoredAttachments.length,
attachmentFiles: restored.imported,
},
skipped: {
reason: finalSkippedReason,
attachments: finalSkippedItems.length,
items: finalSkippedItems,
},
},
};
}
+226
View File
@@ -0,0 +1,226 @@
import type { Env, User } from '../types';
const RUNTIME_SALT = 'nodewarden.backup-settings.runtime.v2';
const RUNTIME_INFO = 'runtime';
const PORTABLE_ALGORITHM = 'RSA-OAEP';
const PORTABLE_HASH = 'SHA-1';
const AES_GCM_ALGORITHM = 'AES-GCM';
const AES_GCM_IV_BYTES = 12;
const PORTABLE_DEK_BYTES = 32;
export interface BackupSettingsRuntimeEnvelope {
iv: string;
ciphertext: string;
}
export interface BackupSettingsPortableWrap {
userId: string;
wrappedKey: string;
}
export interface BackupSettingsPortableEnvelope {
iv: string;
ciphertext: string;
wraps: BackupSettingsPortableWrap[];
}
export interface BackupSettingsEnvelopeV2 {
version: 2;
runtime: BackupSettingsRuntimeEnvelope;
portable: BackupSettingsPortableEnvelope;
}
function bytesToBase64(bytes: Uint8Array): string {
let text = '';
for (let index = 0; index < bytes.length; index += 1) {
text += String.fromCharCode(bytes[index]);
}
return btoa(text);
}
function base64ToBytes(value: string): Uint8Array {
const normalized = String(value || '').trim();
const binary = atob(normalized);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
async function deriveRuntimeKey(secret: string): Promise<CryptoKey> {
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
'HKDF',
false,
['deriveBits']
);
const bits = await crypto.subtle.deriveBits(
{
name: 'HKDF',
hash: 'SHA-256',
salt: encoder.encode(RUNTIME_SALT),
info: encoder.encode(RUNTIME_INFO),
},
keyMaterial,
256
);
return crypto.subtle.importKey('raw', bits, { name: AES_GCM_ALGORITHM }, false, ['encrypt', 'decrypt']);
}
async function encryptAesGcm(plaintext: Uint8Array, key: CryptoKey): Promise<{ iv: Uint8Array; ciphertext: Uint8Array }> {
const iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_BYTES));
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{ name: AES_GCM_ALGORITHM, iv },
key,
plaintext
)
);
return { iv, ciphertext };
}
async function decryptAesGcm(ciphertext: Uint8Array, iv: Uint8Array, key: CryptoKey): Promise<Uint8Array> {
return new Uint8Array(
await crypto.subtle.decrypt(
{ name: AES_GCM_ALGORITHM, iv },
key,
ciphertext
)
);
}
async function importPortablePublicKey(publicKeyBase64: string): Promise<CryptoKey> {
return crypto.subtle.importKey(
'spki',
base64ToBytes(publicKeyBase64),
{ name: PORTABLE_ALGORITHM, hash: PORTABLE_HASH },
false,
['encrypt']
);
}
function getEligiblePortableUsers(users: Pick<User, 'id' | 'publicKey' | 'role' | 'status'>[]): Array<Pick<User, 'id' | 'publicKey'>> {
return users
.filter(
(user) =>
user.role === 'admin' &&
user.status === 'active' &&
typeof user.publicKey === 'string' &&
user.publicKey.trim().length > 0
)
.map((user) => ({
id: user.id,
publicKey: user.publicKey!,
}));
}
export function parseBackupSettingsEnvelope(raw: string | null): BackupSettingsEnvelopeV2 | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
if (!isPlainObject(parsed) || Number(parsed.version) !== 2) return null;
const runtime = parsed.runtime;
const portable = parsed.portable;
if (!isPlainObject(runtime) || !isPlainObject(portable)) return null;
if (!Array.isArray(portable.wraps)) return null;
if (typeof runtime.iv !== 'string' || typeof runtime.ciphertext !== 'string') return null;
if (typeof portable.iv !== 'string' || typeof portable.ciphertext !== 'string') return null;
return {
version: 2,
runtime: {
iv: runtime.iv,
ciphertext: runtime.ciphertext,
},
portable: {
iv: portable.iv,
ciphertext: portable.ciphertext,
wraps: portable.wraps
.filter((entry): entry is Record<string, unknown> => isPlainObject(entry))
.map((entry) => ({
userId: String(entry.userId || '').trim(),
wrappedKey: String(entry.wrappedKey || '').trim(),
}))
.filter((entry) => entry.userId && entry.wrappedKey),
},
};
} catch {
return null;
}
}
export async function encryptBackupSettingsEnvelope(
plaintext: string,
env: Env,
users: Pick<User, 'id' | 'publicKey' | 'role' | 'status'>[]
): Promise<string> {
const encoder = new TextEncoder();
const eligibleUsers = getEligiblePortableUsers(users);
if (!eligibleUsers.length) {
throw new Error('No active administrator public keys are available for backup settings recovery');
}
const runtimeKey = await deriveRuntimeKey(env.JWT_SECRET);
const runtime = await encryptAesGcm(encoder.encode(plaintext), runtimeKey);
const portableDek = crypto.getRandomValues(new Uint8Array(PORTABLE_DEK_BYTES));
const portableKey = await crypto.subtle.importKey(
'raw',
portableDek,
{ name: AES_GCM_ALGORITHM },
false,
['encrypt']
);
const portableCipher = await encryptAesGcm(encoder.encode(plaintext), portableKey);
const wraps: BackupSettingsPortableWrap[] = [];
for (const user of eligibleUsers) {
const publicKey = await importPortablePublicKey(user.publicKey!);
const wrappedKey = new Uint8Array(
await crypto.subtle.encrypt(
{ name: PORTABLE_ALGORITHM },
publicKey,
portableDek
)
);
wraps.push({
userId: user.id,
wrappedKey: bytesToBase64(wrappedKey),
});
}
const envelope: BackupSettingsEnvelopeV2 = {
version: 2,
runtime: {
iv: bytesToBase64(runtime.iv),
ciphertext: bytesToBase64(runtime.ciphertext),
},
portable: {
iv: bytesToBase64(portableCipher.iv),
ciphertext: bytesToBase64(portableCipher.ciphertext),
wraps,
},
};
return JSON.stringify(envelope);
}
export async function decryptBackupSettingsRuntime(raw: string, env: Env): Promise<string> {
const envelope = parseBackupSettingsEnvelope(raw);
if (!envelope) {
throw new Error('Backup settings envelope is invalid');
}
const runtimeKey = await deriveRuntimeKey(env.JWT_SECRET);
const plaintext = await decryptAesGcm(
base64ToBytes(envelope.runtime.ciphertext),
base64ToBytes(envelope.runtime.iv),
runtimeKey
);
return new TextDecoder().decode(plaintext);
}
+722
View File
@@ -0,0 +1,722 @@
import {
BackupDestinationRecord,
BackupDestinationType,
E3BackupDestination,
WebDavBackupDestination,
} from './backup-config';
export interface BackupUploadResult {
provider: BackupDestinationType;
remotePath: string;
}
export interface RemoteBackupItem {
path: string;
name: string;
isDirectory: boolean;
size: number | null;
modifiedAt: string | null;
}
export interface RemoteBackupListResult {
provider: BackupDestinationType;
currentPath: string;
parentPath: string | null;
items: RemoteBackupItem[];
}
export interface RemoteBackupFile {
provider: BackupDestinationType;
remotePath: string;
fileName: string;
contentType: string;
bytes: Uint8Array;
}
export interface RemoteBackupFilePutOptions {
contentType?: string;
}
function isBackupArchiveName(name: string): boolean {
return /\.zip$/i.test(String(name || '').trim());
}
function encodePathSegments(path: string): string {
return path
.split('/')
.filter(Boolean)
.map((segment) => encodeURIComponent(segment))
.join('/');
}
function trimSlashes(value: string): string {
let next = String(value || '');
while (next.startsWith('/')) next = next.slice(1);
while (next.endsWith('/')) next = next.slice(0, -1);
return next;
}
function buildJoinedPath(...segments: string[]): string {
return segments.map(trimSlashes).filter(Boolean).join('/');
}
function normalizeRelativePath(path: string): string {
const normalized = trimSlashes(path).replace(/\\/g, '/');
if (!normalized) return '';
const parts = normalized.split('/').filter(Boolean);
if (parts.some((part) => part === '.' || part === '..')) {
throw new Error('Invalid remote backup path');
}
return parts.join('/');
}
function basename(path: string): string {
const normalized = trimSlashes(path);
if (!normalized) return '';
const parts = normalized.split('/').filter(Boolean);
return parts[parts.length - 1] || '';
}
function parentPath(path: string): string | null {
const normalized = normalizeRelativePath(path);
if (!normalized) return null;
const parts = normalized.split('/');
parts.pop();
return parts.length ? parts.join('/') : '';
}
function sortRemoteItems(items: RemoteBackupItem[]): RemoteBackupItem[] {
return items.slice().sort((a, b) => {
const aIsAttachmentsDir = a.isDirectory && a.name === 'attachments';
const bIsAttachmentsDir = b.isDirectory && b.name === 'attachments';
if (aIsAttachmentsDir !== bIsAttachmentsDir) return aIsAttachmentsDir ? -1 : 1;
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
return a.name.localeCompare(b.name, 'en');
});
}
function decodeXmlText(value: string): string {
return value.replace(/&(amp|lt|gt|quot|#39);/g, (_match, entity) => {
switch (entity) {
case 'amp':
return '&';
case 'lt':
return '<';
case 'gt':
return '>';
case 'quot':
return '"';
case '#39':
return "'";
default:
return _match;
}
});
}
function parseHttpDate(value: string): string | null {
const parsed = new Date(value);
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null;
}
function extractXmlBlocks(xml: string, tagName: string): string[] {
const pattern = new RegExp(`<(?:[^:>]+:)?${tagName}\\b[^>]*>([\\s\\S]*?)</(?:[^:>]+:)?${tagName}>`, 'gi');
const blocks: string[] = [];
let match: RegExpExecArray | null;
while ((match = pattern.exec(xml))) {
blocks.push(match[1]);
}
return blocks;
}
function extractXmlFirst(xml: string, tagName: string): string | null {
const pattern = new RegExp(`<(?:[^:>]+:)?${tagName}\\b[^>]*>([\\s\\S]*?)</(?:[^:>]+:)?${tagName}>`, 'i');
const match = xml.match(pattern);
return match?.[1] ? decodeXmlText(match[1].trim()) : null;
}
async function sha256Hex(value: Uint8Array | string): Promise<string> {
const bytes = typeof value === 'string' ? new TextEncoder().encode(value) : value;
const digest = await crypto.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
}
async function hmacSha256Raw(keyBytes: Uint8Array, message: string): Promise<Uint8Array> {
const key = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message));
return new Uint8Array(signature);
}
function toBasicAuthHeader(username: string, password: string): string {
const token = btoa(`${username}:${password}`);
return `Basic ${token}`;
}
function buildCanonicalQueryString(url: URL): string {
const params = Array.from(url.searchParams.entries()).sort(([aKey, aValue], [bKey, bValue]) => {
if (aKey === bKey) return aValue.localeCompare(bValue);
return aKey.localeCompare(bKey);
});
return params
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
}
async function buildAwsV4Authorization(
method: string,
url: URL,
headers: Record<string, string>,
payloadHashHex: string,
accessKeyId: string,
secretAccessKey: string,
region: string
): Promise<string> {
const amzDate = headers['x-amz-date'];
const shortDate = amzDate.slice(0, 8);
const headerEntries = Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value] as const).sort(([a], [b]) => a.localeCompare(b));
const canonicalHeaders = headerEntries
.map(([name, value]) => `${name}:${String(value).trim().replace(/\s+/g, ' ')}`)
.join('\n');
const signedHeaders = headerEntries.map(([name]) => name).join(';');
const canonicalRequest = [
method.toUpperCase(),
url.pathname || '/',
buildCanonicalQueryString(url),
`${canonicalHeaders}\n`,
signedHeaders,
payloadHashHex,
].join('\n');
const credentialScope = `${shortDate}/${region}/s3/aws4_request`;
const stringToSign = [
'AWS4-HMAC-SHA256',
amzDate,
credentialScope,
await sha256Hex(canonicalRequest),
].join('\n');
const kDate = await hmacSha256Raw(new TextEncoder().encode(`AWS4${secretAccessKey}`), shortDate);
const kRegion = await hmacSha256Raw(kDate, region);
const kService = await hmacSha256Raw(kRegion, 's3');
const kSigning = await hmacSha256Raw(kService, 'aws4_request');
const signatureBytes = await hmacSha256Raw(kSigning, stringToSign);
const signature = Array.from(signatureBytes).map((byte) => byte.toString(16).padStart(2, '0')).join('');
return `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
}
function ensureDestinationConfigReady(destination: BackupDestinationRecord): void {
if (destination.type === 'webdav') {
const config = destination.destination as WebDavBackupDestination;
if (!String(config.baseUrl || '').trim()) throw new Error('WebDAV server URL is required');
if (!/^https?:\/\//i.test(String(config.baseUrl || '').trim())) throw new Error('WebDAV server URL must start with http:// or https://');
if (!String(config.username || '').trim()) throw new Error('WebDAV username is required');
if (!String(config.password || '')) throw new Error('WebDAV password is required');
return;
}
if (destination.type === 'e3') {
const config = destination.destination as E3BackupDestination;
if (!String(config.endpoint || '').trim()) throw new Error('E3 endpoint is required');
if (!/^https?:\/\//i.test(String(config.endpoint || '').trim())) throw new Error('E3 endpoint must start with http:// or https://');
if (!String(config.bucket || '').trim()) throw new Error('E3 bucket is required');
if (!String(config.accessKeyId || '').trim()) throw new Error('E3 access key is required');
if (!String(config.secretAccessKey || '')) throw new Error('E3 secret key is required');
}
}
function buildWebDavUrl(baseUrl: string, relativePath: string): string {
const trimmedBase = baseUrl.replace(/\/+$/, '');
const normalized = normalizeRelativePath(relativePath);
return normalized ? `${trimmedBase}/${encodePathSegments(normalized)}` : trimmedBase;
}
function webDavFullPath(config: WebDavBackupDestination, relativePath: string): string {
return buildJoinedPath(config.remotePath, normalizeRelativePath(relativePath));
}
async function ensureWebDavDirectory(baseUrl: string, directoryPath: string, authHeader: string): Promise<void> {
const segments = trimSlashes(directoryPath).split('/').filter(Boolean);
let current = '';
for (const segment of segments) {
current = buildJoinedPath(current, segment);
const url = buildWebDavUrl(baseUrl, current);
const response = await fetch(url, {
method: 'MKCOL',
headers: {
Authorization: authHeader,
},
});
if ([200, 201, 204, 301, 302, 405].includes(response.status)) continue;
throw new Error(`WebDAV directory creation failed: ${response.status}`);
}
}
async function putToWebDav(
config: WebDavBackupDestination,
relativePath: string,
bytes: Uint8Array,
options: RemoteBackupFilePutOptions = {}
): Promise<void> {
const authHeader = toBasicAuthHeader(config.username, config.password);
const remoteFilePath = buildJoinedPath(config.remotePath, relativePath);
const remoteDir = parentPath(remoteFilePath);
if (remoteDir) {
await ensureWebDavDirectory(config.baseUrl, remoteDir, authHeader);
}
const response = await fetch(buildWebDavUrl(config.baseUrl, remoteFilePath), {
method: 'PUT',
headers: {
Authorization: authHeader,
'Content-Type': options.contentType || 'application/octet-stream',
'Content-Length': String(bytes.byteLength),
},
body: bytes,
});
if (!response.ok) {
throw new Error(`WebDAV upload failed: ${response.status}`);
}
}
async function uploadToWebDav(config: WebDavBackupDestination, archive: Uint8Array, fileName: string): Promise<BackupUploadResult> {
await putToWebDav(config, fileName, archive, { contentType: 'application/zip' });
return {
provider: 'webdav',
remotePath: buildJoinedPath(config.remotePath, fileName),
};
}
function parseWebDavResponsePath(baseUrl: string, href: string): string {
const base = new URL(baseUrl);
const target = new URL(href, base);
const basePath = trimSlashes(decodeURIComponent(base.pathname));
const entryPath = trimSlashes(decodeURIComponent(target.pathname));
if (!basePath) return entryPath;
if (entryPath === basePath) return '';
return entryPath.startsWith(`${basePath}/`) ? entryPath.slice(basePath.length + 1) : entryPath;
}
async function listWebDavEntries(config: WebDavBackupDestination, relativePath: string): Promise<RemoteBackupListResult> {
const currentPath = normalizeRelativePath(relativePath);
const targetFullPath = webDavFullPath(config, currentPath);
const authHeader = toBasicAuthHeader(config.username, config.password);
const response = await fetch(buildWebDavUrl(config.baseUrl, targetFullPath), {
method: 'PROPFIND',
headers: {
Authorization: authHeader,
Depth: '1',
'Content-Type': 'application/xml; charset=utf-8',
},
body: `<?xml version="1.0" encoding="utf-8"?><propfind xmlns="DAV:"><prop><resourcetype/><getcontentlength/><getlastmodified/></prop></propfind>`,
});
if (response.status === 404) {
return {
provider: 'webdav',
currentPath,
parentPath: parentPath(currentPath),
items: [],
};
}
if (!response.ok) {
throw new Error(`WebDAV listing failed: ${response.status}`);
}
const xml = await response.text();
const rootFullPath = trimSlashes(config.remotePath);
const items: RemoteBackupItem[] = [];
for (const block of extractXmlBlocks(xml, 'response')) {
const href = extractXmlFirst(block, 'href');
if (!href) continue;
const fullPath = trimSlashes(parseWebDavResponsePath(config.baseUrl, href));
if (!fullPath) continue;
if (fullPath === targetFullPath) continue;
if (rootFullPath && !(fullPath === rootFullPath || fullPath.startsWith(`${rootFullPath}/`))) continue;
const relative = rootFullPath
? fullPath === rootFullPath
? ''
: fullPath.slice(rootFullPath.length + 1)
: fullPath;
if (!relative) continue;
const directParent = parentPath(relative);
if ((directParent || '') !== currentPath) continue;
const resourceTypeBlock = extractXmlFirst(block, 'resourcetype') || '';
const isDirectory = /<(?:[^:>]+:)?collection\b/i.test(resourceTypeBlock);
const sizeRaw = extractXmlFirst(block, 'getcontentlength');
const modifiedAtRaw = extractXmlFirst(block, 'getlastmodified');
items.push({
path: relative,
name: basename(relative) || relative,
isDirectory,
size: !isDirectory && sizeRaw && Number.isFinite(Number(sizeRaw)) ? Number(sizeRaw) : null,
modifiedAt: modifiedAtRaw ? parseHttpDate(modifiedAtRaw) : null,
});
}
return {
provider: 'webdav',
currentPath,
parentPath: parentPath(currentPath),
items: sortRemoteItems(items),
};
}
async function downloadFromWebDav(config: WebDavBackupDestination, relativePath: string): Promise<RemoteBackupFile> {
const normalized = normalizeRelativePath(relativePath);
if (!normalized || normalized.endsWith('/')) {
throw new Error('Please select a backup file');
}
const authHeader = toBasicAuthHeader(config.username, config.password);
const remotePath = webDavFullPath(config, normalized);
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
method: 'GET',
headers: {
Authorization: authHeader,
},
});
if (!response.ok) {
throw new Error(`WebDAV download failed: ${response.status}`);
}
return {
provider: 'webdav',
remotePath: normalized,
fileName: basename(normalized) || 'backup.zip',
contentType: String(response.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip',
bytes: new Uint8Array(await response.arrayBuffer()),
};
}
async function deleteFromWebDav(config: WebDavBackupDestination, relativePath: string): Promise<void> {
const authHeader = toBasicAuthHeader(config.username, config.password);
const remotePath = webDavFullPath(config, relativePath);
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
method: 'DELETE',
headers: {
Authorization: authHeader,
},
});
if (!response.ok && response.status !== 404) {
throw new Error(`WebDAV delete failed: ${response.status}`);
}
}
async function existsInWebDav(config: WebDavBackupDestination, relativePath: string): Promise<boolean> {
const authHeader = toBasicAuthHeader(config.username, config.password);
const remotePath = webDavFullPath(config, relativePath);
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
method: 'HEAD',
headers: {
Authorization: authHeader,
},
});
if (response.status === 404) return false;
if (!response.ok) {
throw new Error(`WebDAV existence check failed: ${response.status}`);
}
return true;
}
function e3BucketBaseUrl(config: E3BackupDestination): URL {
return new URL(`${config.endpoint.replace(/\/+$/, '')}/${encodeURIComponent(config.bucket)}`);
}
function normalizeE3ObjectKey(config: E3BackupDestination, relativePath: string): string {
return buildJoinedPath(config.rootPath, normalizeRelativePath(relativePath));
}
async function signedE3Request(
config: E3BackupDestination,
method: 'GET' | 'PUT' | 'DELETE' | 'HEAD',
url: URL,
body?: Uint8Array,
contentType?: string
): Promise<Response> {
const payloadHashHex = await sha256Hex(body || new Uint8Array());
const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
const headers: Record<string, string> = {
host: url.host,
'x-amz-content-sha256': payloadHashHex,
'x-amz-date': amzDate,
};
if (method === 'PUT') headers['content-type'] = contentType || 'application/octet-stream';
const authorization = await buildAwsV4Authorization(
method,
url,
headers,
payloadHashHex,
config.accessKeyId,
config.secretAccessKey,
config.region || 'auto'
);
return fetch(url.toString(), {
method,
headers: {
Authorization: authorization,
'X-Amz-Content-Sha256': headers['x-amz-content-sha256'],
'X-Amz-Date': headers['x-amz-date'],
...(method === 'PUT' ? { 'Content-Type': headers['content-type'] } : {}),
},
body,
});
}
async function putToE3(
config: E3BackupDestination,
relativePath: string,
bytes: Uint8Array,
options: RemoteBackupFilePutOptions = {}
): Promise<void> {
const objectKey = normalizeE3ObjectKey(config, relativePath);
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
const response = await signedE3Request(config, 'PUT', url, bytes, options.contentType);
if (!response.ok) {
throw new Error(`E3 upload failed: ${response.status}`);
}
}
async function uploadToE3(config: E3BackupDestination, archive: Uint8Array, fileName: string): Promise<BackupUploadResult> {
await putToE3(config, fileName, archive, { contentType: 'application/zip' });
return {
provider: 'e3',
remotePath: normalizeE3ObjectKey(config, fileName),
};
}
async function listE3Entries(config: E3BackupDestination, relativePath: string): Promise<RemoteBackupListResult> {
const currentPath = normalizeRelativePath(relativePath);
const targetPrefixBase = normalizeE3ObjectKey(config, currentPath);
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
const url = e3BucketBaseUrl(config);
url.searchParams.set('list-type', '2');
url.searchParams.set('delimiter', '/');
if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
const response = await signedE3Request(config, 'GET', url);
if (!response.ok) {
throw new Error(`E3 listing failed: ${response.status}`);
}
const xml = await response.text();
const rootPrefix = trimSlashes(config.rootPath);
const items: RemoteBackupItem[] = [];
for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) {
const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || '');
if (!fullPrefix) continue;
const relative = rootPrefix
? fullPrefix === rootPrefix
? ''
: fullPrefix.startsWith(`${rootPrefix}/`)
? fullPrefix.slice(rootPrefix.length + 1)
: ''
: fullPrefix;
const normalizedRelative = trimSlashes(relative);
if (!normalizedRelative) continue;
const itemPath = normalizedRelative.replace(/\/+$/, '');
if ((parentPath(itemPath) || '') !== currentPath) continue;
items.push({
path: itemPath,
name: basename(itemPath) || itemPath,
isDirectory: true,
size: null,
modifiedAt: null,
});
}
for (const content of extractXmlBlocks(xml, 'Contents')) {
const fullKey = trimSlashes(extractXmlFirst(content, 'Key') || '');
if (!fullKey || (targetPrefix && fullKey === trimSlashes(targetPrefix))) continue;
const relative = rootPrefix
? fullKey.startsWith(`${rootPrefix}/`)
? fullKey.slice(rootPrefix.length + 1)
: ''
: fullKey;
const normalizedRelative = trimSlashes(relative);
if (!normalizedRelative || (parentPath(normalizedRelative) || '') !== currentPath) continue;
items.push({
path: normalizedRelative,
name: basename(normalizedRelative) || normalizedRelative,
isDirectory: false,
size: Number(extractXmlFirst(content, 'Size') || 0) || null,
modifiedAt: parseHttpDate(extractXmlFirst(content, 'LastModified') || '') || null,
});
}
const deduped = new Map<string, RemoteBackupItem>();
for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item);
return {
provider: 'e3',
currentPath,
parentPath: parentPath(currentPath),
items: sortRemoteItems(Array.from(deduped.values())),
};
}
async function downloadFromE3(config: E3BackupDestination, relativePath: string): Promise<RemoteBackupFile> {
const normalized = normalizeRelativePath(relativePath);
if (!normalized || normalized.endsWith('/')) {
throw new Error('Please select a backup file');
}
const objectKey = normalizeE3ObjectKey(config, normalized);
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
const response = await signedE3Request(config, 'GET', url);
if (!response.ok) {
throw new Error(`E3 download failed: ${response.status}`);
}
return {
provider: 'e3',
remotePath: normalized,
fileName: basename(normalized) || 'backup.zip',
contentType: String(response.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip',
bytes: new Uint8Array(await response.arrayBuffer()),
};
}
async function deleteFromE3(config: E3BackupDestination, relativePath: string): Promise<void> {
const objectKey = normalizeE3ObjectKey(config, relativePath);
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
const response = await signedE3Request(config, 'DELETE', url);
if (!response.ok && response.status !== 404) {
throw new Error(`E3 delete failed: ${response.status}`);
}
}
async function existsInE3(config: E3BackupDestination, relativePath: string): Promise<boolean> {
const objectKey = normalizeE3ObjectKey(config, relativePath);
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
const response = await signedE3Request(config, 'HEAD', url);
if (response.status === 404) return false;
if (!response.ok) {
throw new Error(`E3 existence check failed: ${response.status}`);
}
return true;
}
interface ConfiguredDestinationAdapter {
provider: 'webdav' | 'e3';
config: WebDavBackupDestination | E3BackupDestination;
upload: (config: WebDavBackupDestination | E3BackupDestination, archive: Uint8Array, fileName: string) => Promise<BackupUploadResult>;
putFile: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions) => Promise<void>;
list: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<RemoteBackupListResult>;
download: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
deleteFile: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<void>;
exists: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<boolean>;
}
function resolveConfiguredDestinationAdapter(
destination: BackupDestinationRecord
): ConfiguredDestinationAdapter {
ensureDestinationConfigReady(destination);
if (destination.type === 'webdav') {
return {
provider: 'webdav',
config: destination.destination as WebDavBackupDestination,
upload: (config, archive, fileName) => uploadToWebDav(config as WebDavBackupDestination, archive, fileName),
putFile: (config, relativePath, bytes, options) => putToWebDav(config as WebDavBackupDestination, relativePath, bytes, options),
list: (config, relativePath) => listWebDavEntries(config as WebDavBackupDestination, relativePath),
download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath),
deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath),
exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath),
};
}
if (destination.type === 'e3') {
return {
provider: 'e3',
config: destination.destination as E3BackupDestination,
upload: (config, archive, fileName) => uploadToE3(config as E3BackupDestination, archive, fileName),
putFile: (config, relativePath, bytes, options) => putToE3(config as E3BackupDestination, relativePath, bytes, options),
list: (config, relativePath) => listE3Entries(config as E3BackupDestination, relativePath),
download: (config, relativePath) => downloadFromE3(config as E3BackupDestination, relativePath),
deleteFile: (config, relativePath) => deleteFromE3(config as E3BackupDestination, relativePath),
exists: (config, relativePath) => existsInE3(config as E3BackupDestination, relativePath),
};
}
throw new Error('Unsupported backup destination type');
}
export async function uploadBackupArchive(
destination: BackupDestinationRecord,
archive: Uint8Array,
fileName: string
): Promise<BackupUploadResult> {
const adapter = resolveConfiguredDestinationAdapter(destination);
return adapter.upload(adapter.config, archive, fileName);
}
export async function listRemoteBackupEntries(destination: BackupDestinationRecord, relativePath: string): Promise<RemoteBackupListResult> {
const adapter = resolveConfiguredDestinationAdapter(destination);
return adapter.list(adapter.config, relativePath);
}
export async function downloadRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise<RemoteBackupFile> {
const adapter = resolveConfiguredDestinationAdapter(destination);
return adapter.download(adapter.config, relativePath);
}
export async function deleteRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise<void> {
const normalized = ensureRemoteRestoreCandidate(relativePath);
const adapter = resolveConfiguredDestinationAdapter(destination);
await adapter.deleteFile(adapter.config, normalized);
}
export async function remoteBackupFileExists(destination: BackupDestinationRecord, relativePath: string): Promise<boolean> {
const normalized = normalizeRelativePath(relativePath);
const adapter = resolveConfiguredDestinationAdapter(destination);
return adapter.exists(adapter.config, normalized);
}
export async function uploadRemoteBackupFile(
destination: BackupDestinationRecord,
relativePath: string,
bytes: Uint8Array,
options: RemoteBackupFilePutOptions = {}
): Promise<void> {
const normalized = normalizeRelativePath(relativePath);
const adapter = resolveConfiguredDestinationAdapter(destination);
await adapter.putFile(adapter.config, normalized, bytes, options);
}
function compareBackupItemsByRecency(a: RemoteBackupItem, b: RemoteBackupItem, preferredFileName?: string): number {
if (preferredFileName) {
const aPreferred = a.name === preferredFileName ? 1 : 0;
const bPreferred = b.name === preferredFileName ? 1 : 0;
if (aPreferred !== bPreferred) return bPreferred - aPreferred;
}
const aTime = a.modifiedAt ? new Date(a.modifiedAt).getTime() : 0;
const bTime = b.modifiedAt ? new Date(b.modifiedAt).getTime() : 0;
if (aTime !== bTime) return bTime - aTime;
return b.name.localeCompare(a.name, 'en');
}
export async function pruneRemoteBackupArchives(
destination: BackupDestinationRecord,
retentionCount: number | null,
preferredFileName?: string
): Promise<number> {
if (retentionCount === null) return 0;
const adapter = resolveConfiguredDestinationAdapter(destination);
const listing = await adapter.list(adapter.config, '');
const backupFiles = listing.items
.filter((item) => !item.isDirectory && isBackupArchiveName(item.name))
.sort((a, b) => compareBackupItemsByRecency(a, b, preferredFileName));
if (backupFiles.length <= retentionCount) return 0;
for (const item of backupFiles.slice(retentionCount)) {
await adapter.deleteFile(adapter.config, item.path);
}
return backupFiles.length - retentionCount;
}
export function ensureRemoteRestoreCandidate(relativePath: string): string {
const normalized = normalizeRelativePath(relativePath);
if (!normalized || !/\.zip$/i.test(normalized)) {
throw new Error('Please select a backup ZIP file');
}
return normalized;
}
+124
View File
@@ -0,0 +1,124 @@
import { Env } from '../types';
const DEFAULT_CONTENT_TYPE = 'application/octet-stream';
export const KV_MAX_OBJECT_BYTES = 25 * 1024 * 1024;
interface KVBlobMetadata {
size?: number;
contentType?: string;
customMetadata?: Record<string, string> | null;
}
export interface BlobObject {
body: ReadableStream | null;
size: number;
contentType: string;
}
export interface PutBlobOptions {
size: number;
contentType?: string;
customMetadata?: Record<string, string>;
}
function hasR2Storage(env: Env): env is Env & { ATTACHMENTS: R2Bucket } {
return !!env.ATTACHMENTS;
}
function hasKvStorage(env: Env): env is Env & { ATTACHMENTS_KV: KVNamespace } {
return !!env.ATTACHMENTS_KV;
}
export function getBlobStorageKind(env: Env): 'r2' | 'kv' | null {
// Keep R2 as preferred backend when both are bound.
if (hasR2Storage(env)) return 'r2';
if (hasKvStorage(env)) return 'kv';
return null;
}
export function getBlobStorageMaxBytes(env: Env, configuredLimit: number): number {
if (getBlobStorageKind(env) === 'kv') {
return Math.min(configuredLimit, KV_MAX_OBJECT_BYTES);
}
return configuredLimit;
}
export function getAttachmentObjectKey(cipherId: string, attachmentId: string): string {
return `${cipherId}/${attachmentId}`;
}
export function getSendFileObjectKey(sendId: string, fileId: string): string {
return `sends/${sendId}/${fileId}`;
}
export async function putBlobObject(
env: Env,
key: string,
value: string | ArrayBuffer | ArrayBufferView | ReadableStream,
options: PutBlobOptions
): Promise<void> {
const contentType = options.contentType || DEFAULT_CONTENT_TYPE;
if (hasR2Storage(env)) {
await env.ATTACHMENTS.put(key, value, {
httpMetadata: { contentType },
customMetadata: options.customMetadata,
});
return;
}
if (hasKvStorage(env)) {
if (options.size > KV_MAX_OBJECT_BYTES) {
throw new Error('KV object too large');
}
const metadata: KVBlobMetadata = {
size: options.size,
contentType,
customMetadata: options.customMetadata || null,
};
await env.ATTACHMENTS_KV.put(key, value, { metadata });
return;
}
throw new Error('Attachment storage is not configured');
}
export async function getBlobObject(env: Env, key: string): Promise<BlobObject | null> {
if (hasR2Storage(env)) {
const object = await env.ATTACHMENTS.get(key);
if (!object) return null;
return {
body: object.body,
size: Number(object.size) || 0,
contentType: object.httpMetadata?.contentType || DEFAULT_CONTENT_TYPE,
};
}
if (hasKvStorage(env)) {
const result = await env.ATTACHMENTS_KV.getWithMetadata<KVBlobMetadata>(key, 'arrayBuffer');
if (!result.value) return null;
const sizeFromMeta = Number(result.metadata?.size || 0);
const size = sizeFromMeta > 0 ? sizeFromMeta : result.value.byteLength;
const body = new Response(result.value).body;
return {
body,
size,
contentType: result.metadata?.contentType || DEFAULT_CONTENT_TYPE,
};
}
return null;
}
export async function deleteBlobObject(env: Env, key: string): Promise<void> {
if (hasR2Storage(env)) {
await env.ATTACHMENTS.delete(key);
return;
}
if (hasKvStorage(env)) {
await env.ATTACHMENTS_KV.delete(key);
return;
}
}
+167 -7
View File
@@ -184,14 +184,174 @@ export class RateLimitService {
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
return this.consumeFixedWindowBudget(identifier, maxRequests, CONFIG.API_WINDOW_SECONDS);
}
async consumeBudgetWithWindow(
identifier: string,
maxRequests: number,
windowSeconds: number
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
return this.consumeFixedWindowBudget(identifier, maxRequests, windowSeconds);
}
}
export function getClientIdentifier(request: Request): string {
const cfIp = request.headers.get('CF-Connecting-IP');
if (cfIp) return cfIp;
function parseIpv4Octets(input: string): number[] | null {
const parts = input.split('.');
if (parts.length !== 4) return null;
const forwardedFor = request.headers.get('X-Forwarded-For');
if (forwardedFor) return forwardedFor.split(',')[0].trim();
return 'unknown';
const octets: number[] = [];
for (const part of parts) {
if (!/^\d{1,3}$/.test(part)) return null;
const value = Number(part);
if (!Number.isInteger(value) || value < 0 || value > 255) return null;
octets.push(value);
}
return octets;
}
function parseIpv6Hextets(input: string): number[] | null {
let value = input.trim().toLowerCase();
if (!value) return null;
if (value.startsWith('[') && value.endsWith(']')) {
value = value.slice(1, -1);
}
const zoneIndex = value.indexOf('%');
if (zoneIndex >= 0) {
value = value.slice(0, zoneIndex);
}
if (!value.includes(':')) return null;
// Handle IPv4-mapped tail (e.g. ::ffff:192.0.2.1).
if (value.includes('.')) {
const lastColon = value.lastIndexOf(':');
if (lastColon < 0) return null;
const ipv4Tail = value.slice(lastColon + 1);
const octets = parseIpv4Octets(ipv4Tail);
if (!octets) return null;
const high = ((octets[0] << 8) | octets[1]).toString(16);
const low = ((octets[2] << 8) | octets[3]).toString(16);
value = `${value.slice(0, lastColon)}:${high}:${low}`;
}
const doubleColon = value.indexOf('::');
if (doubleColon !== value.lastIndexOf('::')) return null;
const parsePart = (part: string): number | null => {
if (!/^[0-9a-f]{1,4}$/.test(part)) return null;
const n = parseInt(part, 16);
return Number.isNaN(n) ? null : n;
};
const parseParts = (parts: string[]): number[] | null => {
const out: number[] = [];
for (const p of parts) {
if (!p) return null;
const n = parsePart(p);
if (n === null) return null;
out.push(n);
}
return out;
};
if (doubleColon >= 0) {
const [headRaw, tailRaw] = value.split('::');
const head = headRaw ? headRaw.split(':') : [];
const tail = tailRaw ? tailRaw.split(':') : [];
const headNums = parseParts(head);
const tailNums = parseParts(tail);
if (!headNums || !tailNums) return null;
const missing = 8 - (headNums.length + tailNums.length);
if (missing < 1) return null;
return [...headNums, ...new Array<number>(missing).fill(0), ...tailNums];
}
const all = parseParts(value.split(':'));
if (!all || all.length !== 8) return null;
return all;
}
function normalizeClientIpForRateLimit(rawIp: string): string | null {
const input = rawIp.trim();
if (!input) return null;
const ipv4 = parseIpv4Octets(input);
if (ipv4) {
return `ip4:${ipv4.join('.')}`;
}
const ipv6 = parseIpv6Hextets(input);
if (!ipv6) return null;
// Handle IPv4-mapped / IPv4-compatible IPv6 as IPv4 identity.
// Examples: ::ffff:192.0.2.1, ::192.0.2.1
if (
ipv6[0] === 0 &&
ipv6[1] === 0 &&
ipv6[2] === 0 &&
ipv6[3] === 0 &&
ipv6[4] === 0 &&
(ipv6[5] === 0xffff || ipv6[5] === 0)
) {
const octets = [ipv6[6] >> 8, ipv6[6] & 0xff, ipv6[7] >> 8, ipv6[7] & 0xff];
return `ip4:${octets.join('.')}`;
}
// Collapse to /64 to reduce brute-force bypass via IPv6 address rotation.
const prefix64 = ipv6
.slice(0, 4)
.map(part => part.toString(16).padStart(4, '0'))
.join(':');
return `ip6:${prefix64}`;
}
function isLocalRequest(request: Request): boolean {
const isLoopbackHost = (host: string | null): boolean => {
if (!host) return false;
const normalized = host.split(':')[0].trim().toLowerCase();
return (
normalized === 'localhost' ||
normalized.endsWith('.localhost') ||
normalized === '127.0.0.1' ||
normalized === '0.0.0.0' ||
normalized === '::1' ||
normalized === '[::1]'
);
};
try {
if (isLoopbackHost(new URL(request.url).hostname)) return true;
} catch {
// Ignore malformed URL and fall back to Host header check.
}
return isLoopbackHost(request.headers.get('Host'));
}
export function getClientIdentifier(request: Request): string | null {
// Strict fallback order:
// 1) CF-Connecting-IP
// 2) X-Real-IP
// 3) first item of X-Forwarded-For
// If none are present/valid, treat client IP as unavailable.
const candidates: Array<string | null> = [
request.headers.get('CF-Connecting-IP'),
request.headers.get('X-Real-IP'),
request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim() || null,
];
for (const raw of candidates) {
if (!raw) continue;
const normalized = normalizeClientIpForRateLimit(raw);
if (normalized) return normalized;
}
// Local dev (wrangler dev / localhost): allow a deterministic loopback identifier.
if (isLocalRequest(request)) {
return 'ip4:127.0.0.1';
}
return null;
}
+84
View File
@@ -0,0 +1,84 @@
import type { AuditLog, Invite } from '../types';
export async function createInvite(db: D1Database, invite: Invite): Promise<void> {
await db
.prepare(
'INSERT INTO invites(code, created_by, used_by, expires_at, status, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?)'
)
.bind(invite.code, invite.createdBy, invite.usedBy, invite.expiresAt, invite.status, invite.createdAt, invite.updatedAt)
.run();
}
export async function getInvite(db: D1Database, code: string): Promise<Invite | null> {
const row = await db
.prepare('SELECT code, created_by, used_by, expires_at, status, created_at, updated_at FROM invites WHERE code = ?')
.bind(code)
.first<any>();
if (!row) return null;
return {
code: row.code,
createdBy: row.created_by,
usedBy: row.used_by ?? null,
expiresAt: row.expires_at,
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export async function listInvites(db: D1Database, includeInactive: boolean = false): Promise<Invite[]> {
const now = new Date().toISOString();
const predicate = includeInactive
? '1 = 1'
: "(status = 'active' AND expires_at > ?)";
const query =
'SELECT code, created_by, used_by, expires_at, status, created_at, updated_at FROM invites ' +
`WHERE ${predicate} ORDER BY created_at DESC`;
const res = includeInactive
? await db.prepare(query).all<any>()
: await db.prepare(query).bind(now).all<any>();
return (res.results || []).map((row) => ({
code: row.code,
createdBy: row.created_by,
usedBy: row.used_by ?? null,
expiresAt: row.expires_at,
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
}
export async function markInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
const now = new Date().toISOString();
const result = await db
.prepare(
"UPDATE invites SET status = 'used', used_by = ?, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?"
)
.bind(userId, now, code, now)
.run();
return (result.meta.changes ?? 0) > 0;
}
export async function revokeInvite(db: D1Database, code: string): Promise<boolean> {
const now = new Date().toISOString();
const result = await db
.prepare("UPDATE invites SET status = 'revoked', updated_at = ? WHERE code = ? AND status = 'active'")
.bind(now, code)
.run();
return (result.meta.changes ?? 0) > 0;
}
export async function deleteAllInvites(db: D1Database): Promise<number> {
const result = await db.prepare('DELETE FROM invites').run();
return Number(result.meta.changes ?? 0);
}
export async function createAuditLog(db: D1Database, log: AuditLog): Promise<void> {
await db
.prepare(
'INSERT INTO audit_logs(id, actor_user_id, action, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?)'
)
.bind(log.id, log.actorUserId, log.action, log.targetType, log.targetId, log.metadata, log.createdAt)
.run();
}
+143
View File
@@ -0,0 +1,143 @@
import type { Attachment, Cipher } from '../types';
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
type SqlChunkSize = (fixedBindCount: number) => number;
type GetCipher = (id: string) => Promise<Cipher | null>;
type SaveCipher = (cipher: Cipher) => Promise<void>;
type UpdateRevisionDate = (userId: string) => Promise<string>;
export async function getAttachment(db: D1Database, id: string): Promise<Attachment | null> {
const row = await db
.prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE id = ?')
.bind(id)
.first<any>();
if (!row) return null;
return {
id: row.id,
cipherId: row.cipher_id,
fileName: row.file_name,
size: row.size,
sizeName: row.size_name,
key: row.key,
};
}
export async function saveAttachment(db: D1Database, safeBind: SafeBind, attachment: Attachment): Promise<void> {
const stmt = db.prepare(
'INSERT INTO attachments(id, cipher_id, file_name, size, size_name, key) VALUES(?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET cipher_id=excluded.cipher_id, file_name=excluded.file_name, size=excluded.size, size_name=excluded.size_name, key=excluded.key'
);
await safeBind(stmt, attachment.id, attachment.cipherId, attachment.fileName, attachment.size, attachment.sizeName, attachment.key).run();
}
export async function deleteAttachment(db: D1Database, id: string): Promise<void> {
await db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run();
}
export async function getAttachmentsByCipher(db: D1Database, cipherId: string): Promise<Attachment[]> {
const res = await db
.prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id = ?')
.bind(cipherId)
.all<any>();
return (res.results || []).map((r) => ({
id: r.id,
cipherId: r.cipher_id,
fileName: r.file_name,
size: r.size,
sizeName: r.size_name,
key: r.key,
}));
}
export async function getAttachmentsByCipherIds(
db: D1Database,
sqlChunkSize: SqlChunkSize,
cipherIds: string[]
): Promise<Map<string, Attachment[]>> {
const grouped = new Map<string, Attachment[]>();
if (cipherIds.length === 0) return grouped;
const uniqueCipherIds = [...new Set(cipherIds)];
const chunkSize = sqlChunkSize(0);
for (let i = 0; i < uniqueCipherIds.length; i += chunkSize) {
const chunk = uniqueCipherIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
const res = await db
.prepare(`SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id IN (${placeholders})`)
.bind(...chunk)
.all<any>();
for (const row of res.results || []) {
const item: Attachment = {
id: row.id,
cipherId: row.cipher_id,
fileName: row.file_name,
size: row.size,
sizeName: row.size_name,
key: row.key,
};
const list = grouped.get(item.cipherId);
if (list) list.push(item);
else grouped.set(item.cipherId, [item]);
}
}
return grouped;
}
export async function getAttachmentsByUserId(db: D1Database, userId: string): Promise<Map<string, Attachment[]>> {
const grouped = new Map<string, Attachment[]>();
const res = await db
.prepare(
`SELECT a.id, a.cipher_id, a.file_name, a.size, a.size_name, a.key
FROM attachments a
INNER JOIN ciphers c ON c.id = a.cipher_id
WHERE c.user_id = ?`
)
.bind(userId)
.all<any>();
for (const row of res.results || []) {
const item: Attachment = {
id: row.id,
cipherId: row.cipher_id,
fileName: row.file_name,
size: row.size,
sizeName: row.size_name,
key: row.key,
};
const list = grouped.get(item.cipherId);
if (list) list.push(item);
else grouped.set(item.cipherId, [item]);
}
return grouped;
}
export async function addAttachmentToCipher(db: D1Database, cipherId: string, attachmentId: string): Promise<void> {
await db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run();
}
export async function removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise<void> {
void cipherId;
void attachmentId;
}
export async function deleteAllAttachmentsByCipher(db: D1Database, cipherId: string): Promise<void> {
await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run();
}
export async function updateCipherRevisionDate(
getCipherById: GetCipher,
saveCipherRecord: SaveCipher,
updateRevisionDate: UpdateRevisionDate,
cipherId: string
): Promise<{ userId: string; revisionDate: string } | null> {
const cipher = await getCipherById(cipherId);
if (!cipher) return null;
cipher.updatedAt = new Date().toISOString();
await saveCipherRecord(cipher);
const revisionDate = await updateRevisionDate(cipher.userId);
return { userId: cipher.userId, revisionDate };
}
@@ -0,0 +1,46 @@
type ShouldRunPeriodicCleanup = (lastRunAt: number, intervalMs: number) => boolean;
export async function ensureUsedAttachmentDownloadTokenTable(db: D1Database): Promise<void> {
await db
.prepare(
'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' +
'jti TEXT PRIMARY KEY, ' +
'expires_at INTEGER NOT NULL' +
')'
)
.run();
}
export async function consumeAttachmentDownloadToken(
db: D1Database,
shouldRunPeriodicCleanup: ShouldRunPeriodicCleanup,
lastCleanupAt: number,
cleanupIntervalMs: number,
jti: string,
expUnixSeconds: number
): Promise<{ consumed: boolean; cleanedUpAt: number | null }> {
const nowMs = Date.now();
let cleanedUpAt: number | null = null;
if (shouldRunPeriodicCleanup(lastCleanupAt, cleanupIntervalMs)) {
await db
.prepare('DELETE FROM used_attachment_download_tokens WHERE expires_at < ?')
.bind(nowMs)
.run();
cleanedUpAt = nowMs;
}
const expiresAtMs = expUnixSeconds * 1000;
const result = await db
.prepare(
'INSERT INTO used_attachment_download_tokens(jti, expires_at) VALUES(?, ?) ' +
'ON CONFLICT(jti) DO NOTHING'
)
.bind(jti, expiresAtMs)
.run();
return {
consumed: (result.meta.changes ?? 0) > 0,
cleanedUpAt,
};
}
+332
View File
@@ -0,0 +1,332 @@
import type { Cipher } from '../types';
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
type SqlChunkSize = (fixedBindCount: number) => number;
type UpdateRevisionDate = (userId: string) => Promise<string>;
interface CipherRow {
id: string;
user_id: string;
type: number | null;
folder_id: string | null;
name: string | null;
notes: string | null;
favorite: number | null;
data: string;
reprompt: number | null;
key: string | null;
created_at: string;
updated_at: string;
archived_at: string | null;
deleted_at: string | null;
}
function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
if (!row?.data) return null;
try {
const parsed = JSON.parse(row.data) as Cipher;
return {
...parsed,
id: row.id,
userId: row.user_id,
type: Number(row.type) || Number(parsed.type) || 1,
folderId: row.folder_id ?? parsed.folderId ?? null,
name: row.name ?? parsed.name ?? null,
notes: row.notes ?? parsed.notes ?? null,
favorite: row.favorite != null ? !!row.favorite : !!parsed.favorite,
reprompt: row.reprompt ?? parsed.reprompt ?? 0,
key: row.key ?? parsed.key ?? null,
createdAt: row.created_at,
updatedAt: row.updated_at,
archivedAt: row.archived_at ?? parsed.archivedAt ?? parsed.archivedDate ?? null,
deletedAt: row.deleted_at ?? null,
};
} catch {
console.error('Corrupted cipher data, id:', row.id);
return null;
}
}
function selectCipherColumns(): string {
return 'id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at';
}
export async function getCipher(db: D1Database, id: string): Promise<Cipher | null> {
const row = await db
.prepare(`SELECT ${selectCipherColumns()} FROM ciphers WHERE id = ?`)
.bind(id)
.first<CipherRow>();
return parseCipherRow(row);
}
export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> {
const data = JSON.stringify(cipher);
const stmt = db.prepare(
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' +
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at'
);
await safeBind(
stmt,
cipher.id,
cipher.userId,
Number(cipher.type) || 1,
cipher.folderId,
cipher.name,
cipher.notes,
cipher.favorite ? 1 : 0,
data,
cipher.reprompt ?? 0,
cipher.key,
cipher.createdAt,
cipher.updatedAt,
cipher.archivedAt ?? null,
cipher.deletedAt
).run();
}
function sanitizeIds(ids: string[]): string[] {
return Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
}
export async function deleteCipher(db: D1Database, id: string, userId: string): Promise<void> {
await db.prepare('DELETE FROM ciphers WHERE id = ? AND user_id = ?').bind(id, userId).run();
}
export async function bulkSoftDeleteCiphers(
db: D1Database,
sqlChunkSize: SqlChunkSize,
updateRevisionDate: UpdateRevisionDate,
ids: string[],
userId: string
): Promise<string | null> {
if (ids.length === 0) return null;
const uniqueIds = sanitizeIds(ids);
if (!uniqueIds.length) return null;
const now = new Date().toISOString();
const patch = JSON.stringify({ deletedAt: now, updatedAt: now });
const chunkSize = sqlChunkSize(4);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
await db
.prepare(
`UPDATE ciphers
SET deleted_at = ?, updated_at = ?, data = json_patch(data, ?)
WHERE user_id = ? AND id IN (${placeholders})`
)
.bind(now, now, patch, userId, ...chunk)
.run();
}
return updateRevisionDate(userId);
}
export async function bulkRestoreCiphers(
db: D1Database,
sqlChunkSize: SqlChunkSize,
updateRevisionDate: UpdateRevisionDate,
ids: string[],
userId: string
): Promise<string | null> {
if (ids.length === 0) return null;
const uniqueIds = sanitizeIds(ids);
if (!uniqueIds.length) return null;
const now = new Date().toISOString();
const patch = JSON.stringify({ deletedAt: null, updatedAt: now });
const chunkSize = sqlChunkSize(3);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
await db
.prepare(
`UPDATE ciphers
SET deleted_at = NULL, updated_at = ?, data = json_patch(data, ?)
WHERE user_id = ? AND id IN (${placeholders})`
)
.bind(now, patch, userId, ...chunk)
.run();
}
return updateRevisionDate(userId);
}
export async function bulkDeleteCiphers(
db: D1Database,
sqlChunkSize: SqlChunkSize,
updateRevisionDate: UpdateRevisionDate,
ids: string[],
userId: string
): Promise<string | null> {
if (ids.length === 0) return null;
const uniqueIds = sanitizeIds(ids);
if (!uniqueIds.length) return null;
const chunkSize = sqlChunkSize(1);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
await db.prepare(`DELETE FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`).bind(userId, ...chunk).run();
}
return updateRevisionDate(userId);
}
export async function getAllCiphers(db: D1Database, userId: string): Promise<Cipher[]> {
const res = await db
.prepare(`SELECT ${selectCipherColumns()} FROM ciphers WHERE user_id = ? ORDER BY updated_at DESC`)
.bind(userId)
.all<CipherRow>();
return (res.results || []).flatMap((row) => {
const cipher = parseCipherRow(row);
return cipher ? [cipher] : [];
});
}
export async function getCiphersPage(
db: D1Database,
userId: string,
includeDeleted: boolean,
limit: number,
offset: number
): Promise<Cipher[]> {
const whereDeleted = includeDeleted ? '' : 'AND deleted_at IS NULL';
const res = await db
.prepare(
`SELECT ${selectCipherColumns()} FROM ciphers
WHERE user_id = ?
${whereDeleted}
ORDER BY updated_at DESC
LIMIT ? OFFSET ?`
)
.bind(userId, limit, offset)
.all<CipherRow>();
return (res.results || []).flatMap((row) => {
const cipher = parseCipherRow(row);
return cipher ? [cipher] : [];
});
}
export async function getCiphersByIds(
db: D1Database,
sqlChunkSize: SqlChunkSize,
ids: string[],
userId: string
): Promise<Cipher[]> {
if (ids.length === 0) return [];
const uniqueIds = sanitizeIds(ids);
if (!uniqueIds.length) return [];
const chunkSize = sqlChunkSize(1);
const out: Cipher[] = [];
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
const stmt = db.prepare(`SELECT ${selectCipherColumns()} FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`);
const res = await stmt.bind(userId, ...chunk).all<CipherRow>();
out.push(
...(res.results || []).flatMap((row) => {
const cipher = parseCipherRow(row);
return cipher ? [cipher] : [];
})
);
}
return out;
}
export async function bulkMoveCiphers(
db: D1Database,
sqlChunkSize: SqlChunkSize,
updateRevisionDate: UpdateRevisionDate,
ids: string[],
folderId: string | null,
userId: string
): Promise<string | null> {
if (ids.length === 0) return null;
const now = new Date().toISOString();
const uniqueIds = sanitizeIds(ids);
const patch = JSON.stringify({ folderId, updatedAt: now });
const chunkSize = sqlChunkSize(4);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
await db
.prepare(
`UPDATE ciphers
SET folder_id = ?, updated_at = ?, data = json_patch(data, ?)
WHERE user_id = ? AND id IN (${placeholders})`
)
.bind(folderId, now, patch, userId, ...chunk)
.run();
}
return updateRevisionDate(userId);
}
export async function bulkArchiveCiphers(
db: D1Database,
sqlChunkSize: SqlChunkSize,
updateRevisionDate: UpdateRevisionDate,
ids: string[],
userId: string
): Promise<string | null> {
if (ids.length === 0) return null;
const uniqueIds = sanitizeIds(ids);
if (!uniqueIds.length) return null;
const now = new Date().toISOString();
const patch = JSON.stringify({ archivedAt: now, archivedDate: now, updatedAt: now });
const chunkSize = sqlChunkSize(4);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
await db
.prepare(
`UPDATE ciphers
SET archived_at = ?, updated_at = ?, data = json_patch(data, ?)
WHERE user_id = ? AND id IN (${placeholders}) AND deleted_at IS NULL`
)
.bind(now, now, patch, userId, ...chunk)
.run();
}
return updateRevisionDate(userId);
}
export async function bulkUnarchiveCiphers(
db: D1Database,
sqlChunkSize: SqlChunkSize,
updateRevisionDate: UpdateRevisionDate,
ids: string[],
userId: string
): Promise<string | null> {
if (ids.length === 0) return null;
const uniqueIds = sanitizeIds(ids);
if (!uniqueIds.length) return null;
const now = new Date().toISOString();
const patch = JSON.stringify({ archivedAt: null, archivedDate: null, updatedAt: now });
const chunkSize = sqlChunkSize(3);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
await db
.prepare(
`UPDATE ciphers
SET archived_at = NULL, updated_at = ?, data = json_patch(data, ?)
WHERE user_id = ? AND id IN (${placeholders})`
)
.bind(now, patch, userId, ...chunk)
.run();
}
return updateRevisionDate(userId);
}
+22
View File
@@ -0,0 +1,22 @@
export async function isRegistered(db: D1Database): Promise<boolean> {
const row = await db.prepare('SELECT value FROM config WHERE key = ?').bind('registered').first<{ value: string }>();
return row?.value === 'true';
}
export async function getConfigValue(db: D1Database, key: string): Promise<string | null> {
const row = await db.prepare('SELECT value FROM config WHERE key = ?').bind(key).first<{ value: string }>();
return typeof row?.value === 'string' ? row.value : null;
}
export async function setConfigValue(db: D1Database, key: string, value: string): Promise<void> {
await db
.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
.bind(key, value)
.run();
}
export async function setRegistered(db: D1Database): Promise<void> {
await db.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
.bind('registered', 'true')
.run();
}
+241
View File
@@ -0,0 +1,241 @@
import type { Device, TrustedDeviceTokenSummary, User } from '../types';
type GetUserByEmail = (email: string) => Promise<User | null>;
type TrustedTokenKeyFn = (token: string) => Promise<string>;
function mapDeviceRow(row: any): Device {
return {
userId: row.user_id,
deviceIdentifier: row.device_identifier,
name: row.name,
type: row.type,
sessionStamp: row.session_stamp || '',
encryptedUserKey: row.encrypted_user_key ?? null,
encryptedPublicKey: row.encrypted_public_key ?? null,
encryptedPrivateKey: row.encrypted_private_key ?? null,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export async function upsertDevice(
db: D1Database,
getDeviceById: (userId: string, deviceIdentifier: string) => Promise<Device | null>,
userId: string,
deviceIdentifier: string,
name: string,
type: number,
sessionStamp?: string,
keys?: {
encryptedUserKey?: string | null;
encryptedPublicKey?: string | null;
encryptedPrivateKey?: string | null;
}
): Promise<void> {
const now = new Date().toISOString();
const effectiveSessionStamp = String(sessionStamp || '').trim() || (await getDeviceById(userId, deviceIdentifier))?.sessionStamp || '';
await db
.prepare(
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?) ' +
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' +
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
'updated_at=excluded.updated_at'
)
.bind(
userId,
deviceIdentifier,
name,
type,
effectiveSessionStamp,
keys?.encryptedUserKey ?? null,
keys?.encryptedPublicKey ?? null,
keys?.encryptedPrivateKey ?? null,
now,
now
)
.run();
}
export async function updateDeviceKeys(
db: D1Database,
userId: string,
deviceIdentifier: string,
keys: {
encryptedUserKey?: string | null;
encryptedPublicKey?: string | null;
encryptedPrivateKey?: string | null;
}
): Promise<boolean> {
const now = new Date().toISOString();
const result = await db
.prepare(
'UPDATE devices SET encrypted_user_key = ?, encrypted_public_key = ?, encrypted_private_key = ?, updated_at = ? ' +
'WHERE user_id = ? AND device_identifier = ?'
)
.bind(
keys.encryptedUserKey ?? null,
keys.encryptedPublicKey ?? null,
keys.encryptedPrivateKey ?? null,
now,
userId,
deviceIdentifier
)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function clearDeviceKeys(
db: D1Database,
userId: string,
deviceIdentifiers: string[]
): Promise<number> {
const uniqueIds = Array.from(
new Set(deviceIdentifiers.map((id) => String(id || '').trim()).filter(Boolean))
);
if (!uniqueIds.length) return 0;
const placeholders = uniqueIds.map(() => '?').join(',');
const result = await db
.prepare(
`UPDATE devices
SET encrypted_user_key = NULL,
encrypted_public_key = NULL,
encrypted_private_key = NULL,
updated_at = ?
WHERE user_id = ? AND device_identifier IN (${placeholders})`
)
.bind(new Date().toISOString(), userId, ...uniqueIds)
.run();
return Number(result.meta.changes ?? 0);
}
export async function isKnownDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> {
const row = await db
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
.bind(userId, deviceIdentifier)
.first<{ '1': number }>();
return !!row;
}
export async function isKnownDeviceByEmail(
getUserByEmail: GetUserByEmail,
isKnownDeviceForUser: (userId: string, deviceIdentifier: string) => Promise<boolean>,
email: string,
deviceIdentifier: string
): Promise<boolean> {
const user = await getUserByEmail(email);
if (!user) return false;
return isKnownDeviceForUser(user.id, deviceIdentifier);
}
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
const res = await db
.prepare(
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' +
'FROM devices WHERE user_id = ? ORDER BY updated_at DESC'
)
.bind(userId)
.all<any>();
return (res.results || []).map(mapDeviceRow);
}
export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> {
const row = await db
.prepare(
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' +
'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
)
.bind(userId, deviceIdentifier)
.first<any>();
return row ? mapDeviceRow(row) : null;
}
export async function deleteDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> {
const result = await db
.prepare('DELETE FROM devices WHERE user_id = ? AND device_identifier = ?')
.bind(userId, deviceIdentifier)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function deleteDevicesByUserId(db: D1Database, userId: string): Promise<number> {
const result = await db.prepare('DELETE FROM devices WHERE user_id = ?').bind(userId).run();
return Number(result.meta.changes ?? 0);
}
export async function getTrustedDeviceTokenSummariesByUserId(db: D1Database, userId: string): Promise<TrustedDeviceTokenSummary[]> {
const now = Date.now();
await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(now).run();
const res = await db
.prepare(
'SELECT device_identifier, MAX(expires_at) AS expires_at, COUNT(*) AS token_count ' +
'FROM trusted_two_factor_device_tokens WHERE user_id = ? GROUP BY device_identifier ORDER BY expires_at DESC'
)
.bind(userId)
.all<any>();
return (res.results || []).map((row) => ({
deviceIdentifier: row.device_identifier,
expiresAt: Number(row.expires_at || 0),
tokenCount: Number(row.token_count || 0),
}));
}
export async function deleteTrustedTwoFactorTokensByDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<number> {
const result = await db
.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE user_id = ? AND device_identifier = ?')
.bind(userId, deviceIdentifier)
.run();
return Number(result.meta.changes ?? 0);
}
export async function deleteTrustedTwoFactorTokensByUserId(db: D1Database, userId: string): Promise<number> {
const result = await db
.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE user_id = ?')
.bind(userId)
.run();
return Number(result.meta.changes ?? 0);
}
export async function saveTrustedTwoFactorDeviceToken(
db: D1Database,
trustedTokenKey: TrustedTokenKeyFn,
token: string,
userId: string,
deviceIdentifier: string,
expiresAtMs: number
): Promise<void> {
const tokenKey = await trustedTokenKey(token);
await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(Date.now()).run();
await db
.prepare(
'INSERT INTO trusted_two_factor_device_tokens(token, user_id, device_identifier, expires_at) VALUES(?, ?, ?, ?) ' +
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, device_identifier=excluded.device_identifier, expires_at=excluded.expires_at'
)
.bind(tokenKey, userId, deviceIdentifier, expiresAtMs)
.run();
}
export async function getTrustedTwoFactorDeviceTokenUserId(
db: D1Database,
trustedTokenKey: TrustedTokenKeyFn,
token: string,
deviceIdentifier: string
): Promise<string | null> {
const now = Date.now();
const tokenKey = await trustedTokenKey(token);
const row = await db
.prepare('SELECT user_id, expires_at FROM trusted_two_factor_device_tokens WHERE token = ? AND device_identifier = ?')
.bind(tokenKey, deviceIdentifier)
.first<{ user_id: string; expires_at: number }>();
if (!row) return null;
if (row.expires_at && row.expires_at < now) {
await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE token = ?').bind(tokenKey).run();
return null;
}
return row.user_id;
}
+120
View File
@@ -0,0 +1,120 @@
import type { Cipher, Folder } from '../types';
function mapFolderRow(row: any): Folder {
return {
id: row.id,
userId: row.user_id,
name: row.name,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export async function getFolder(db: D1Database, id: string): Promise<Folder | null> {
const row = await db
.prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE id = ?')
.bind(id)
.first<any>();
if (!row) return null;
return mapFolderRow(row);
}
export async function saveFolder(db: D1Database, folder: Folder): Promise<void> {
await db
.prepare(
'INSERT INTO folders(id, user_id, name, created_at, updated_at) VALUES(?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET user_id=excluded.user_id, name=excluded.name, updated_at=excluded.updated_at'
)
.bind(folder.id, folder.userId, folder.name, folder.createdAt, folder.updatedAt)
.run();
}
export async function deleteFolder(db: D1Database, id: string, userId: string): Promise<void> {
await db.prepare('DELETE FROM folders WHERE id = ? AND user_id = ?').bind(id, userId).run();
}
export async function clearFolderFromCiphers(
db: D1Database,
userId: string,
folderId: string,
saveCipher: (cipher: Cipher) => Promise<void>
): Promise<void> {
const now = new Date().toISOString();
const res = await db
.prepare('SELECT data FROM ciphers WHERE user_id = ? AND folder_id = ?')
.bind(userId, folderId)
.all<{ data: string }>();
for (const row of (res.results || [])) {
let cipher: Cipher;
try {
cipher = JSON.parse(row.data) as Cipher;
} catch {
continue;
}
cipher.folderId = null;
cipher.updatedAt = now;
await saveCipher(cipher);
}
}
export async function bulkDeleteFolders(
db: D1Database,
userId: string,
ids: string[],
sqlChunkSize: (fixedBindCount: number) => number,
saveCipher: (cipher: Cipher) => Promise<void>,
updateRevisionDate: (userId: string) => Promise<string>
): Promise<string | null> {
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
if (!uniqueIds.length) return null;
const chunkSize = sqlChunkSize(1);
const now = new Date().toISOString();
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
const res = await db
.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND folder_id IN (${placeholders})`)
.bind(userId, ...chunk)
.all<{ data: string }>();
for (const row of res.results || []) {
let cipher: Cipher;
try {
cipher = JSON.parse(row.data) as Cipher;
} catch {
continue;
}
cipher.folderId = null;
cipher.updatedAt = now;
await saveCipher(cipher);
}
await db
.prepare(`DELETE FROM folders WHERE user_id = ? AND id IN (${placeholders})`)
.bind(userId, ...chunk)
.run();
}
return updateRevisionDate(userId);
}
export async function getAllFolders(db: D1Database, userId: string): Promise<Folder[]> {
const res = await db
.prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE user_id = ? ORDER BY updated_at DESC')
.bind(userId)
.all<any>();
return (res.results || []).map((row) => mapFolderRow(row));
}
export async function getFoldersPage(db: D1Database, userId: string, limit: number, offset: number): Promise<Folder[]> {
const res = await db
.prepare(
'SELECT id, user_id, name, created_at, updated_at FROM folders WHERE user_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?'
)
.bind(userId, limit, offset)
.all<any>();
return (res.results || []).map((row) => mapFolderRow(row));
}
+135
View File
@@ -0,0 +1,135 @@
import type { RefreshTokenRecord } from '../types';
type RefreshTokenKeyFn = (token: string) => Promise<string>;
type CleanupExpiredFn = (nowMs: number) => Promise<void>;
export async function saveRefreshToken(
db: D1Database,
refreshTokenKey: RefreshTokenKeyFn,
maybeCleanupExpiredRefreshTokens: CleanupExpiredFn,
token: string,
userId: string,
expiresAtMs: number,
deviceIdentifier?: string | null,
deviceSessionStamp?: string | null
): Promise<void> {
await maybeCleanupExpiredRefreshTokens(Date.now());
const tokenKey = await refreshTokenKey(token);
await db
.prepare(
'INSERT INTO refresh_tokens(token, user_id, expires_at, device_identifier, device_session_stamp) VALUES(?, ?, ?, ?, ?) ' +
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at, device_identifier=excluded.device_identifier, device_session_stamp=excluded.device_session_stamp'
)
.bind(tokenKey, userId, expiresAtMs, deviceIdentifier ?? null, deviceSessionStamp ?? null)
.run();
}
export async function getRefreshTokenRecord(
db: D1Database,
refreshTokenKey: RefreshTokenKeyFn,
maybeCleanupExpiredRefreshTokens: CleanupExpiredFn,
saveRefreshTokenRecord: (
token: string,
userId: string,
expiresAtMs?: number,
deviceIdentifier?: string | null,
deviceSessionStamp?: string | null
) => Promise<void>,
deleteRefreshTokenRecord: (token: string) => Promise<void>,
token: string
): Promise<RefreshTokenRecord | null> {
const now = Date.now();
await maybeCleanupExpiredRefreshTokens(now);
const tokenKey = await refreshTokenKey(token);
let row = await db
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
.bind(tokenKey)
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
if (!row) {
const legacyRow = await db
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
.bind(token)
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
if (legacyRow) {
if (legacyRow.expires_at && legacyRow.expires_at < now) {
await deleteRefreshTokenRecord(token);
return null;
}
await saveRefreshTokenRecord(
token,
legacyRow.user_id,
legacyRow.expires_at,
legacyRow.device_identifier ?? null,
legacyRow.device_session_stamp ?? null
);
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
return {
userId: legacyRow.user_id,
expiresAt: legacyRow.expires_at,
deviceIdentifier: legacyRow.device_identifier ?? null,
deviceSessionStamp: legacyRow.device_session_stamp ?? null,
};
}
}
if (!row) return null;
if (row.expires_at && row.expires_at < now) {
await deleteRefreshTokenRecord(token);
return null;
}
return {
userId: row.user_id,
expiresAt: row.expires_at,
deviceIdentifier: row.device_identifier ?? null,
deviceSessionStamp: row.device_session_stamp ?? null,
};
}
export async function deleteRefreshToken(db: D1Database, refreshTokenKey: RefreshTokenKeyFn, token: string): Promise<void> {
const tokenKey = await refreshTokenKey(token);
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(tokenKey).run();
}
export async function deleteRefreshTokensByUserId(db: D1Database, userId: string): Promise<number> {
const result = await db.prepare('DELETE FROM refresh_tokens WHERE user_id = ?').bind(userId).run();
return Number(result.meta.changes ?? 0);
}
export async function deleteRefreshTokensByDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<number> {
const result = await db
.prepare('DELETE FROM refresh_tokens WHERE user_id = ? AND device_identifier = ?')
.bind(userId, deviceIdentifier)
.run();
return Number(result.meta.changes ?? 0);
}
export async function constrainRefreshTokenExpiry(
db: D1Database,
refreshTokenKey: RefreshTokenKeyFn,
token: string,
maxExpiresAtMs: number
): Promise<void> {
const tokenKey = await refreshTokenKey(token);
await db
.prepare(
'UPDATE refresh_tokens ' +
'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' +
'WHERE token = ?'
)
.bind(maxExpiresAtMs, maxExpiresAtMs, tokenKey)
.run();
await db
.prepare(
'UPDATE refresh_tokens ' +
'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' +
'WHERE token = ?'
)
.bind(maxExpiresAtMs, maxExpiresAtMs, token)
.run();
}
+31
View File
@@ -0,0 +1,31 @@
export async function getRevisionDate(db: D1Database, userId: string): Promise<string> {
const row = await db
.prepare('SELECT revision_date FROM user_revisions WHERE user_id = ?')
.bind(userId)
.first<{ revision_date: string }>();
if (row?.revision_date) return row.revision_date;
const date = new Date().toISOString();
await db
.prepare(
'INSERT INTO user_revisions(user_id, revision_date) VALUES(?, ?) ' +
'ON CONFLICT(user_id) DO NOTHING'
)
.bind(userId, date)
.run();
return date;
}
export async function updateRevisionDate(db: D1Database, userId: string): Promise<string> {
const date = new Date().toISOString();
await db
.prepare(
'INSERT INTO user_revisions(user_id, revision_date) VALUES(?, ?) ' +
'ON CONFLICT(user_id) DO UPDATE SET revision_date = excluded.revision_date'
)
.bind(userId, date)
.run();
return date;
}
+137
View File
@@ -0,0 +1,137 @@
// IMPORTANT:
// Keep this schema list in sync with migrations/0001_init.sql.
// Any new table/column/index must be added to both places together.
const SCHEMA_STATEMENTS: readonly string[] = [
'CREATE TABLE IF NOT EXISTS users (' +
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1',
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
'CREATE TABLE IF NOT EXISTS user_revisions (' +
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE TABLE IF NOT EXISTS ciphers (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, folder_id TEXT, name TEXT, notes TEXT, ' +
'favorite INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL, reprompt INTEGER, key TEXT, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, archived_at TEXT, deleted_at TEXT, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'ALTER TABLE ciphers ADD COLUMN archived_at TEXT',
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)',
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at)',
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
'CREATE TABLE IF NOT EXISTS folders (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_folders_user_updated ON folders(user_id, updated_at)',
'CREATE TABLE IF NOT EXISTS attachments (' +
'id TEXT PRIMARY KEY, cipher_id TEXT NOT NULL, file_name TEXT NOT NULL, size INTEGER NOT NULL, ' +
'size_name TEXT NOT NULL, key TEXT, ' +
'FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id)',
'CREATE TABLE IF NOT EXISTS sends (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, name TEXT NOT NULL, notes TEXT, data TEXT NOT NULL, ' +
'key TEXT NOT NULL, password_hash TEXT, password_salt TEXT, password_iterations INTEGER, auth_type INTEGER NOT NULL DEFAULT 2, emails TEXT, ' +
'max_access_count INTEGER, access_count INTEGER NOT NULL DEFAULT 0, disabled INTEGER NOT NULL DEFAULT 0, hide_email INTEGER, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, expiration_date TEXT, deletion_date TEXT NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at)',
'CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date)',
'ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2',
'ALTER TABLE sends ADD COLUMN emails TEXT',
'CREATE TABLE IF NOT EXISTS refresh_tokens (' +
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, device_identifier TEXT, device_session_stamp TEXT, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)',
'ALTER TABLE refresh_tokens ADD COLUMN device_identifier TEXT',
'ALTER TABLE refresh_tokens ADD COLUMN device_session_stamp TEXT',
'CREATE TABLE IF NOT EXISTS invites (' +
'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE, ' +
'FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL)',
'CREATE INDEX IF NOT EXISTS idx_invites_status_expires ON invites(status, expires_at)',
'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)',
'CREATE TABLE IF NOT EXISTS audit_logs (' +
'id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, ' +
'FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL)',
'CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at)',
'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
'CREATE TABLE IF NOT EXISTS devices (' +
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'PRIMARY KEY (user_id, device_identifier), ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at)',
'ALTER TABLE devices ADD COLUMN session_stamp TEXT',
'ALTER TABLE devices ADD COLUMN encrypted_user_key TEXT',
'ALTER TABLE devices ADD COLUMN encrypted_public_key TEXT',
'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT',
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE devices ADD COLUMN banned_at TEXT',
'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' +
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)',
'CREATE TABLE IF NOT EXISTS api_rate_limits (' +
'identifier TEXT NOT NULL, window_start INTEGER NOT NULL, count INTEGER NOT NULL, ' +
'PRIMARY KEY (identifier, window_start))',
'CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start)',
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' +
'jti TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)',
];
async function executeSchemaStatement(db: D1Database, statement: string): Promise<void> {
try {
await db.prepare(statement).run();
} catch (error) {
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
if (msg.includes('already exists') || msg.includes('duplicate column name')) {
return;
}
throw error;
}
}
async function ensureAdminUserExists(db: D1Database): Promise<void> {
const admin = await db.prepare("SELECT id FROM users WHERE role = 'admin' LIMIT 1").first<{ id: string }>();
if (admin?.id) return;
const firstUser = await db
.prepare('SELECT id FROM users ORDER BY created_at ASC LIMIT 1')
.first<{ id: string }>();
if (!firstUser?.id) return;
await db
.prepare("UPDATE users SET role = 'admin', updated_at = ? WHERE id = ?")
.bind(new Date().toISOString(), firstUser.id)
.run();
}
export async function ensureStorageSchema(db: D1Database): Promise<void> {
await db.prepare('PRAGMA foreign_keys = ON').run();
await db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run();
for (const stmt of SCHEMA_STATEMENTS) {
await executeSchemaStatement(db, stmt);
}
await ensureAdminUserExists(db);
}
+163
View File
@@ -0,0 +1,163 @@
import type { Send } from '../types';
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
type SqlChunkSize = (fixedBindCount: number) => number;
type UpdateRevisionDate = (userId: string) => Promise<string>;
function mapSendRow(row: any): Send {
return {
id: row.id,
userId: row.user_id,
type: row.type,
name: row.name,
notes: row.notes,
data: row.data,
key: row.key,
passwordHash: row.password_hash,
passwordSalt: row.password_salt,
passwordIterations: row.password_iterations,
authType: row.auth_type ?? 0,
emails: row.emails ?? null,
maxAccessCount: row.max_access_count,
accessCount: row.access_count,
disabled: !!row.disabled,
hideEmail: row.hide_email === null || row.hide_email === undefined ? null : !!row.hide_email,
createdAt: row.created_at,
updatedAt: row.updated_at,
expirationDate: row.expiration_date,
deletionDate: row.deletion_date,
};
}
export async function getSend(db: D1Database, id: string): Promise<Send | null> {
const row = await db
.prepare(
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE id = ?'
)
.bind(id)
.first<any>();
if (!row) return null;
return mapSendRow(row);
}
export async function saveSend(db: D1Database, safeBind: SafeBind, send: Send): Promise<void> {
const stmt = db.prepare(
'INSERT INTO sends(id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' +
'user_id=excluded.user_id, type=excluded.type, name=excluded.name, notes=excluded.notes, data=excluded.data, key=excluded.key, ' +
'password_hash=excluded.password_hash, password_salt=excluded.password_salt, password_iterations=excluded.password_iterations, auth_type=excluded.auth_type, emails=excluded.emails, ' +
'max_access_count=excluded.max_access_count, access_count=excluded.access_count, disabled=excluded.disabled, hide_email=excluded.hide_email, ' +
'updated_at=excluded.updated_at, expiration_date=excluded.expiration_date, deletion_date=excluded.deletion_date'
);
await safeBind(
stmt,
send.id,
send.userId,
Number(send.type) || 0,
send.name,
send.notes,
send.data,
send.key,
send.passwordHash,
send.passwordSalt,
send.passwordIterations,
send.authType,
send.emails,
send.maxAccessCount,
send.accessCount,
send.disabled ? 1 : 0,
send.hideEmail === null || send.hideEmail === undefined ? null : send.hideEmail ? 1 : 0,
send.createdAt,
send.updatedAt,
send.expirationDate,
send.deletionDate
).run();
}
export async function incrementSendAccessCount(db: D1Database, sendId: string): Promise<boolean> {
const now = new Date().toISOString();
const result = await db
.prepare(
'UPDATE sends SET access_count = access_count + 1, updated_at = ? ' +
'WHERE id = ? AND (max_access_count IS NULL OR access_count < max_access_count)'
)
.bind(now, sendId)
.run();
return (result.meta.changes ?? 0) > 0;
}
export async function deleteSend(db: D1Database, id: string, userId: string): Promise<void> {
await db.prepare('DELETE FROM sends WHERE id = ? AND user_id = ?').bind(id, userId).run();
}
export async function getSendsByIds(
db: D1Database,
sqlChunkSize: SqlChunkSize,
ids: string[],
userId: string
): Promise<Send[]> {
if (ids.length === 0) return [];
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
if (!uniqueIds.length) return [];
const chunkSize = sqlChunkSize(1);
const out: Send[] = [];
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
const res = await db
.prepare(
`SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date
FROM sends
WHERE user_id = ? AND id IN (${placeholders})`
)
.bind(userId, ...chunk)
.all<any>();
out.push(...(res.results || []).map((row) => mapSendRow(row)));
}
return out;
}
export async function bulkDeleteSends(
db: D1Database,
sqlChunkSize: SqlChunkSize,
updateRevisionDate: UpdateRevisionDate,
ids: string[],
userId: string
): Promise<string | null> {
if (ids.length === 0) return null;
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
if (!uniqueIds.length) return null;
const chunkSize = sqlChunkSize(1);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
await db.prepare(`DELETE FROM sends WHERE user_id = ? AND id IN (${placeholders})`).bind(userId, ...chunk).run();
}
return updateRevisionDate(userId);
}
export async function getAllSends(db: D1Database, userId: string): Promise<Send[]> {
const res = await db
.prepare(
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE user_id = ? ORDER BY updated_at DESC'
)
.bind(userId)
.all<any>();
return (res.results || []).map((row) => mapSendRow(row));
}
export async function getSendsPage(db: D1Database, userId: string, limit: number, offset: number): Promise<Send[]> {
const res = await db
.prepare(
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE user_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?'
)
.bind(userId, limit, offset)
.all<any>();
return (res.results || []).map((row) => mapSendRow(row));
}
+139
View File
@@ -0,0 +1,139 @@
import type { User } from '../types';
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
const USER_SELECT_COLUMNS =
'id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, ' +
'kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, ' +
'totp_secret, totp_recovery_code, created_at, updated_at';
function mapUserRow(row: any): User {
return {
id: row.id,
email: row.email,
name: row.name,
masterPasswordHint: row.master_password_hint ?? null,
masterPasswordHash: row.master_password_hash,
key: row.key,
privateKey: row.private_key,
publicKey: row.public_key,
kdfType: row.kdf_type,
kdfIterations: row.kdf_iterations,
kdfMemory: row.kdf_memory ?? undefined,
kdfParallelism: row.kdf_parallelism ?? undefined,
securityStamp: row.security_stamp,
role: row.role === 'admin' ? 'admin' : 'user',
status: row.status === 'banned' ? 'banned' : 'active',
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
totpSecret: row.totp_secret ?? null,
totpRecoveryCode: row.totp_recovery_code ?? null,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export async function getUser(db: D1Database, email: string): Promise<User | null> {
const row = await db
.prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users WHERE email = ?`)
.bind(email.toLowerCase())
.first<any>();
if (!row) return null;
return mapUserRow(row);
}
export async function getUserById(db: D1Database, id: string): Promise<User | null> {
const row = await db
.prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users WHERE id = ?`)
.bind(id)
.first<any>();
if (!row) return null;
return mapUserRow(row);
}
export async function getUserCount(db: D1Database): Promise<number> {
const row = await db.prepare('SELECT COUNT(*) AS count FROM users').first<{ count: number }>();
return Number(row?.count || 0);
}
export async function getAllUsers(db: D1Database): Promise<User[]> {
const res = await db
.prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users ORDER BY created_at ASC`)
.all<any>();
return (res.results || []).map((row) => mapUserRow(row));
}
export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
const email = user.email.toLowerCase();
const stmt = db.prepare(
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' +
'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' +
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at'
);
await safeBind(
stmt,
user.id,
email,
user.name,
user.masterPasswordHint,
user.masterPasswordHash,
user.key,
user.privateKey,
user.publicKey,
user.kdfType,
user.kdfIterations,
user.kdfMemory,
user.kdfParallelism,
user.securityStamp,
user.role,
user.status,
user.verifyDevices ? 1 : 0,
user.totpSecret,
user.totpRecoveryCode,
user.createdAt,
user.updatedAt
).run();
}
export async function createUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
await saveUser(db, safeBind, user);
}
export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
const email = user.email.toLowerCase();
const stmt = db.prepare(
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at) ' +
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
);
const result = await safeBind(
stmt,
user.id,
email,
user.name,
user.masterPasswordHint,
user.masterPasswordHash,
user.key,
user.privateKey,
user.publicKey,
user.kdfType,
user.kdfIterations,
user.kdfMemory,
user.kdfParallelism,
user.securityStamp,
user.role,
user.status,
user.verifyDevices ? 1 : 0,
user.totpSecret,
user.totpRecoveryCode,
user.createdAt,
user.updatedAt
).run();
return (result.meta.changes ?? 0) > 0;
}
export async function deleteUserById(db: D1Database, id: string): Promise<boolean> {
const result = await db.prepare('DELETE FROM users WHERE id = ?').bind(id).run();
return (result.meta.changes ?? 0) > 0;
}
+316 -828
View File
File diff suppressed because it is too large Load Diff
+75 -1
View File
@@ -1,7 +1,14 @@
// Environment bindings
export interface Env {
DB: D1Database;
ATTACHMENTS: R2Bucket;
NOTIFICATIONS_HUB: DurableObjectNamespace;
ASSETS?: {
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
};
// Prefer R2 when available. Optional to support KV-only deployments.
ATTACHMENTS?: R2Bucket;
// Optional fallback for attachment/send file storage (no credit card required).
ATTACHMENTS_KV?: KVNamespace;
JWT_SECRET: string;
TOTP_SECRET?: string;
}
@@ -28,6 +35,7 @@ export interface User {
id: string;
email: string;
name: string | null;
masterPasswordHint: string | null;
masterPasswordHash: string;
key: string;
privateKey: string | null;
@@ -39,6 +47,7 @@ export interface User {
securityStamp: string;
role: UserRole;
status: UserStatus;
verifyDevices?: boolean;
totpSecret: string | null;
totpRecoveryCode: string | null;
createdAt: string;
@@ -161,6 +170,7 @@ export interface Cipher {
key: string | null;
createdAt: string;
updatedAt: string;
archivedAt: string | null;
deletedAt: string | null;
/** Allow unknown fields from Bitwarden clients to be stored and passed through transparently. */
[key: string]: any;
@@ -180,10 +190,55 @@ export interface Device {
deviceIdentifier: string;
name: string;
type: number;
sessionStamp: string;
encryptedUserKey: string | null;
encryptedPublicKey: string | null;
encryptedPrivateKey: string | null;
devicePendingAuthRequest?: DevicePendingAuthRequest | null;
createdAt: string;
updatedAt: string;
}
export interface DevicePendingAuthRequest {
id: string;
creationDate: string;
}
export interface DeviceResponse {
id: string;
userId?: string | null;
name: string;
identifier: string;
type: number;
creationDate: string;
revisionDate: string;
isTrusted: boolean;
encryptedUserKey: string | null;
encryptedPublicKey: string | null;
devicePendingAuthRequest: DevicePendingAuthRequest | null;
object: string;
[key: string]: any;
}
export interface ProtectedDeviceResponse {
id: string;
name: string;
identifier: string;
type: number;
creationDate: string;
encryptedUserKey: string | null;
encryptedPublicKey: string | null;
object: string;
[key: string]: any;
}
export interface RefreshTokenRecord {
userId: string;
expiresAt: number;
deviceIdentifier: string | null;
deviceSessionStamp: string | null;
}
export interface TrustedDeviceTokenSummary {
deviceIdentifier: string;
expiresAt: number;
@@ -254,6 +309,8 @@ export interface JWTPayload {
email_verified: boolean; // required by mobile client
amr: string[]; // authentication methods reference - required by mobile client
sstamp: string; // security stamp - invalidates token when user changes password
did?: string; // device identifier - invalidates per-device sessions
dstamp?: string; // device session stamp
iat: number;
exp: number;
iss: string;
@@ -281,6 +338,8 @@ export interface UserDecryptionOptions {
Object: string;
// Bitwarden Android 2026.1.x expects this to exist; missing it breaks unlock when the vault is empty.
MasterPasswordUnlock: MasterPasswordUnlock;
TrustedDeviceOption: null;
KeyConnectorOption: null;
}
// API Response types
@@ -300,7 +359,14 @@ export interface TokenResponse {
ResetMasterPassword: boolean;
scope: string;
unofficialServer: boolean;
MasterPasswordPolicy?: {
Object: string;
} | null;
ApiUseKeyConnector?: boolean;
AccountKeys?: any | null;
accountKeys?: any | null;
UserDecryptionOptions: UserDecryptionOptions;
userDecryptionOptions?: UserDecryptionOptions;
}
export interface ProfileResponse {
@@ -324,6 +390,7 @@ export interface ProfileResponse {
forcePasswordReset: boolean;
avatarColor: string | null;
creationDate: string;
verifyDevices?: boolean;
role?: UserRole;
status?: UserStatus;
object: string;
@@ -382,6 +449,13 @@ export interface SyncResponse {
domains: any;
policies: any[];
sends: SendResponse[];
UserDecryption?: {
MasterPasswordUnlock: MasterPasswordUnlock | null;
TrustedDeviceOption?: null;
KeyConnectorOption?: null;
WebAuthnPrfOption?: null;
Object?: string;
} | null;
// PascalCase for desktop/browser clients
UserDecryptionOptions: UserDecryptionOptions | null;
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
+4
View File
@@ -72,3 +72,7 @@ export function readKnownDeviceProbe(request: Request): { email: string | null;
return { email, deviceIdentifier };
}
export function readActingDeviceIdentifier(request: Request): string | null {
return normalizeDeviceIdentifier(request.headers.get('X-NodeWarden-Acting-Device-Id'));
}
+104
View File
@@ -0,0 +1,104 @@
import { LIMITS } from '../config/limits';
import { DEFAULT_DEV_SECRET, Env } from '../types';
import { errorResponse } from './response';
export interface DirectUploadPayload {
body: ReadableStream;
contentType: string;
size: number;
}
interface ParseDirectUploadOptions {
expectedSize?: number | null;
expectedFileName?: string | null;
maxFileSize: number;
tooLargeMessage: string;
missingBodyMessage?: string;
contentLengthRequiredMessage?: string;
sizeMismatchMessage?: string;
fileNameMismatchMessage?: string;
}
export function buildDirectUploadUrl(request: Request, path: string, token: string): string {
const version = '2023-11-03';
const expiresAt = '2099-12-31T23:59:59Z';
const origin = new URL(request.url).origin;
return `${origin}${path}?sv=${encodeURIComponent(version)}&se=${encodeURIComponent(expiresAt)}&token=${encodeURIComponent(token)}`;
}
export function getSafeJwtSecret(env: Env): string | null {
const secret = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
return null;
}
return secret;
}
function parseContentLength(request: Request): number | null {
const raw = request.headers.get('content-length');
if (!raw) return null;
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) return null;
return Math.floor(value);
}
export async function parseDirectUploadPayload(
request: Request,
options: ParseDirectUploadOptions
): Promise<DirectUploadPayload | Response> {
const {
expectedSize = null,
expectedFileName = null,
maxFileSize,
tooLargeMessage,
missingBodyMessage = 'No file uploaded',
contentLengthRequiredMessage = 'Content-Length is required for direct uploads',
sizeMismatchMessage,
fileNameMismatchMessage,
} = options;
const contentType = request.headers.get('content-type') || '';
if (contentType.includes('multipart/form-data')) {
const formData = await request.formData();
const file = formData.get('data') as File | null;
if (!file) {
return errorResponse(missingBodyMessage, 400);
}
if (file.size > maxFileSize) {
return errorResponse(tooLargeMessage, 413);
}
if (expectedFileName && file.name !== expectedFileName) {
return errorResponse(fileNameMismatchMessage || 'File name does not match.', 400);
}
if (expectedSize !== null && expectedSize !== undefined && file.size !== expectedSize) {
return errorResponse(sizeMismatchMessage || 'File size does not match.', 400);
}
return {
body: file.stream(),
contentType: file.type || 'application/octet-stream',
size: file.size,
};
}
if (!request.body) {
return errorResponse(missingBodyMessage, 400);
}
const declaredSize = parseContentLength(request);
const uploadSize = declaredSize ?? (expectedSize && expectedSize > 0 ? expectedSize : null);
if (uploadSize === null) {
return errorResponse(contentLengthRequiredMessage, 400);
}
if (uploadSize > maxFileSize) {
return errorResponse(tooLargeMessage, 413);
}
if (expectedSize !== null && expectedSize !== undefined && uploadSize !== expectedSize) {
return errorResponse(sizeMismatchMessage || 'File size does not match.', 400);
}
return {
body: request.body,
contentType: contentType || 'application/octet-stream',
size: uploadSize,
};
}
+159
View File
@@ -104,6 +104,13 @@ export interface FileDownloadClaims {
exp: number;
}
export interface AttachmentUploadClaims {
userId: string;
cipherId: string;
attachmentId: string;
exp: number;
}
// Create file download token (short-lived, 5 minutes)
export async function createFileDownloadToken(
cipherId: string,
@@ -178,7 +185,82 @@ export async function verifyFileDownloadToken(
}
}
export async function createAttachmentUploadToken(
userId: string,
cipherId: string,
attachmentId: string,
secret: string
): Promise<string> {
const header = { alg: 'HS256', typ: 'JWT' };
const now = Math.floor(Date.now() / 1000);
const payload: AttachmentUploadClaims = {
userId,
cipherId,
attachmentId,
exp: now + LIMITS.auth.fileDownloadTokenTtlSeconds,
};
const encoder = new TextEncoder();
const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header)));
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
const data = `${headerB64}.${payloadB64}`;
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
return `${data}.${signatureB64}`;
}
export async function verifyAttachmentUploadToken(
token: string,
secret: string
): Promise<AttachmentUploadClaims | null> {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const [headerB64, payloadB64, signatureB64] = parts;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const data = `${headerB64}.${payloadB64}`;
const signature = base64UrlDecode(signatureB64);
const valid = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(data));
if (!valid) return null;
const payload: AttachmentUploadClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) return null;
if (!payload.userId || !payload.cipherId || !payload.attachmentId) return null;
return payload;
} catch {
return null;
}
}
export interface SendFileDownloadClaims {
sendId: string;
fileId: string;
jti: string;
exp: number;
}
export interface SendFileUploadClaims {
userId: string;
sendId: string;
fileId: string;
exp: number;
@@ -194,6 +276,7 @@ export async function createSendFileDownloadToken(
const payload: SendFileDownloadClaims = {
sendId,
fileId,
jti: createRefreshToken(),
exp: now + LIMITS.auth.fileDownloadTokenTtlSeconds,
};
@@ -240,6 +323,15 @@ export async function verifySendFileDownloadToken(
if (!valid) return null;
const payload: SendFileDownloadClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
if (
typeof payload.sendId !== 'string' ||
typeof payload.fileId !== 'string' ||
typeof payload.jti !== 'string' ||
!payload.jti ||
typeof payload.exp !== 'number'
) {
return null;
}
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) return null;
@@ -249,6 +341,73 @@ export async function verifySendFileDownloadToken(
}
}
export async function createSendFileUploadToken(
userId: string,
sendId: string,
fileId: string,
secret: string
): Promise<string> {
const header = { alg: 'HS256', typ: 'JWT' };
const now = Math.floor(Date.now() / 1000);
const payload: SendFileUploadClaims = {
userId,
sendId,
fileId,
exp: now + LIMITS.auth.fileDownloadTokenTtlSeconds,
};
const encoder = new TextEncoder();
const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header)));
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
const data = `${headerB64}.${payloadB64}`;
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
return `${data}.${signatureB64}`;
}
export async function verifySendFileUploadToken(
token: string,
secret: string
): Promise<SendFileUploadClaims | null> {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const [headerB64, payloadB64, signatureB64] = parts;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const data = `${headerB64}.${payloadB64}`;
const signature = base64UrlDecode(signatureB64);
const valid = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(data));
if (!valid) return null;
const payload: SendFileUploadClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) return null;
if (!payload.userId || !payload.sendId || !payload.fileId) return null;
return payload;
} catch {
return null;
}
}
export interface SendAccessTokenClaims {
sub: string; // send id
typ: 'send_access';
+8 -7
View File
@@ -1,4 +1,6 @@
const RECOVERY_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const RECOVERY_ALPHABET_LENGTH = RECOVERY_ALPHABET.length;
const RECOVERY_MAX_UNBIASED_BYTE = Math.floor(256 / RECOVERY_ALPHABET_LENGTH) * RECOVERY_ALPHABET_LENGTH;
function normalizeRecoveryCode(raw: string): string {
return String(raw || '').toUpperCase().replace(/[^A-Z2-7]/g, '');
@@ -9,15 +11,14 @@ function formatRecoveryCode(compact: string): string {
}
export function createRecoveryCode(): string {
const bytes = crypto.getRandomValues(new Uint8Array(20));
let compact = '';
for (const b of bytes) {
compact += RECOVERY_ALPHABET[b % RECOVERY_ALPHABET.length];
}
// 20 bytes -> 20 chars in this simple mapping. Expand to 32 chars for friendlier grouping.
while (compact.length < 32) {
const extra = crypto.getRandomValues(new Uint8Array(1))[0];
compact += RECOVERY_ALPHABET[extra % RECOVERY_ALPHABET.length];
const bytes = crypto.getRandomValues(new Uint8Array(32));
for (const b of bytes) {
if (b >= RECOVERY_MAX_UNBIASED_BYTE) continue;
compact += RECOVERY_ALPHABET[b % RECOVERY_ALPHABET_LENGTH];
if (compact.length >= 32) break;
}
}
return formatRecoveryCode(compact.slice(0, 32));
}
+35 -29
View File
@@ -1,40 +1,48 @@
import { LIMITS } from '../config/limits';
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
const CORS_HEADERS = 'Content-Type, Authorization, Accept, Device-Type, Bitwarden-Client-Name, Bitwarden-Client-Version, X-Request-Email, X-Device-Identifier, X-Device-Name';
function isTrustedClientOrigin(origin: string): boolean {
// Official browser extension / desktop-webview common origins.
if (origin.startsWith('chrome-extension://')) return true;
if (origin.startsWith('moz-extension://')) return true;
if (origin.startsWith('safari-web-extension://')) return true;
if (origin.startsWith('app://')) return true;
if (origin.startsWith('capacitor://')) return true;
if (origin.startsWith('ionic://')) return true;
return false;
}
const DEFAULT_CORS_HEADERS = [
'Content-Type',
'Authorization',
'Accept',
'Device-Type',
'Device-Identifier',
'Device-Name',
'Bitwarden-Client-Name',
'Bitwarden-Client-Version',
'Bitwarden-Package-Type',
'Is-Prerelease',
'X-Request-Email',
'X-Device-Identifier',
'X-Device-Name',
];
function getAllowedOrigin(request: Request): string | null {
const origin = request.headers.get('Origin');
if (!origin) return null;
const targetOrigin = new URL(request.url).origin;
if (origin === targetOrigin) return origin;
if (isTrustedClientOrigin(origin)) return origin;
return null;
if (!origin) return '*';
return origin;
}
function buildCorsHeaders(request: Request): Record<string, string> {
const requestedHeaders = String(request.headers.get('Access-Control-Request-Headers') || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
const allowHeaders = Array.from(new Set([...DEFAULT_CORS_HEADERS, ...requestedHeaders]));
const headers: Record<string, string> = {
'Access-Control-Allow-Methods': CORS_METHODS,
'Access-Control-Allow-Headers': CORS_HEADERS,
'Access-Control-Allow-Headers': allowHeaders.join(', '),
'Access-Control-Expose-Headers': '*',
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
'Access-Control-Allow-Private-Network': 'true',
};
const allowedOrigin = getAllowedOrigin(request);
if (allowedOrigin) {
headers['Access-Control-Allow-Origin'] = allowedOrigin;
headers['Vary'] = 'Origin';
headers['Access-Control-Allow-Credentials'] = 'true';
headers['Vary'] = 'Origin, Access-Control-Request-Headers';
}
return headers;
@@ -44,6 +52,12 @@ export function applyCors(
request: Request,
response: Response
): Response {
// WebSocket upgrade responses must be returned untouched.
const webSocket = (response as Response & { webSocket?: unknown }).webSocket;
if (response.status === 101 || webSocket) {
return response;
}
const headers = new Headers(response.headers);
const corsHeaders = buildCorsHeaders(request);
for (const [k, v] of Object.entries(corsHeaders)) {
@@ -53,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,
@@ -104,14 +118,6 @@ export function identityErrorResponse(message: string, error: string = 'invalid_
// Handle CORS preflight
export function handleCors(request: Request): Response {
const origin = request.headers.get('Origin');
if (origin) {
const allowedOrigin = getAllowedOrigin(request);
if (!allowedOrigin) {
return new Response(null, { status: 403 });
}
}
return new Response(null, {
status: 204,
headers: buildCorsHeaders(request),
+10 -1
View File
@@ -3,7 +3,16 @@ const TOTP_DIGITS = 6;
const TOTP_WINDOW = 1; // allow previous/current/next step for small clock drift
function normalizeBase32(input: string): string {
return input.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
const raw = String(input || '').toUpperCase();
let out = '';
for (const char of raw) {
if (char === ' ' || char === '\t' || char === '\n' || char === '\r' || char === '-') continue;
out += char;
}
while (out.endsWith('=')) {
out = out.slice(0, -1);
}
return out;
}
function base32Decode(input: string): Uint8Array | null {
+63
View File
@@ -0,0 +1,63 @@
import { User, UserDecryptionOptions } from '../types';
export function buildAccountKeys(user: Pick<User, 'privateKey' | 'publicKey'>): Record<string, unknown> | null {
if (!user.privateKey || !user.publicKey) {
return null;
}
return {
publicKeyEncryptionKeyPair: {
wrappedPrivateKey: user.privateKey,
publicKey: user.publicKey,
Object: 'publicKeyEncryptionKeyPair',
},
Object: 'privateKeys',
};
}
export function buildMasterPasswordUnlock(
user: Pick<User, 'email' | 'key' | 'kdfType' | 'kdfIterations' | 'kdfMemory' | 'kdfParallelism'>
): UserDecryptionOptions['MasterPasswordUnlock'] {
return {
Kdf: {
KdfType: user.kdfType,
Iterations: user.kdfIterations,
Memory: user.kdfMemory ?? null,
Parallelism: user.kdfParallelism ?? null,
},
MasterKeyEncryptedUserKey: user.key,
MasterKeyWrappedUserKey: user.key,
Salt: user.email.toLowerCase(),
Object: 'masterPasswordUnlock',
};
}
export function buildUserDecryptionOptions(
user: Pick<User, 'email' | 'key' | 'kdfType' | 'kdfIterations' | 'kdfMemory' | 'kdfParallelism'>
): UserDecryptionOptions {
return {
HasMasterPassword: true,
Object: 'userDecryptionOptions',
MasterPasswordUnlock: buildMasterPasswordUnlock(user),
TrustedDeviceOption: null,
KeyConnectorOption: null,
};
}
export function buildUserDecryptionCompat(
user: Pick<User, 'email' | 'key' | 'kdfType' | 'kdfIterations' | 'kdfMemory' | 'kdfParallelism'>
): Record<string, unknown> {
return {
masterPasswordUnlock: {
kdf: {
kdfType: user.kdfType,
iterations: user.kdfIterations,
memory: user.kdfMemory ?? null,
parallelism: user.kdfParallelism ?? null,
},
masterKeyWrappedUserKey: user.key,
masterKeyEncryptedUserKey: user.key,
salt: user.email.toLowerCase(),
},
};
}
+1 -1
View File
@@ -15,6 +15,6 @@
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src/**/*"],
"include": ["src/**/*", "shared/**/*"],
"exclude": ["node_modules"]
}
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://icons.bitwarden.net; connect-src 'self' https://cloudflareinsights.com; font-src 'self'; form-action 'self'; base-uri 'self';" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' https://cloudflareinsights.com https://*.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://cloudflareinsights.com https://*.cloudflareinsights.com; connect-src 'self' https://api.pwnedpasswords.com https://cloudflareinsights.com https://*.cloudflareinsights.com; font-src 'self'; form-action 'self'; base-uri 'self';" />
<link rel="icon" type="image/png" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>NodeWarden</title>
+788 -707
View File
File diff suppressed because it is too large Load Diff
+12 -11
View File
@@ -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';
@@ -10,7 +11,7 @@ interface AdminPageProps {
onRefresh: () => void;
onCreateInvite: (hours: number) => Promise<void>;
onDeleteAllInvites: () => Promise<void>;
onToggleUserStatus: (userId: string, currentStatus: string) => Promise<void>;
onToggleUserStatus: (userId: string, currentStatus: 'active' | 'banned') => Promise<void>;
onDeleteUser: (userId: string) => Promise<void>;
onRevokeInvite: (code: string) => Promise<void>;
}
@@ -56,11 +57,11 @@ export default function AdminPage(props: AdminPageProps) {
<tbody>
{props.users.map((user) => (
<tr key={user.id}>
<td>{user.email}</td>
<td>{user.name || t('txt_dash')}</td>
<td>{roleText(user.role)}</td>
<td>{statusText(user.status)}</td>
<td>
<td data-label={t('txt_email')}>{user.email}</td>
<td data-label={t('txt_name')}>{user.name || t('txt_dash')}</td>
<td data-label={t('txt_role')}>{roleText(user.role)}</td>
<td data-label={t('txt_status')}>{statusText(user.status)}</td>
<td data-label={t('txt_actions')}>
<div className="actions">
<button
type="button"
@@ -126,15 +127,15 @@ export default function AdminPage(props: AdminPageProps) {
<tbody>
{pagedInvites.map((invite) => (
<tr key={invite.code}>
<td>{invite.code}</td>
<td>{statusText(invite.status)}</td>
<td>{formatExpiresAt(invite.expiresAt)}</td>
<td>
<td data-label={t('txt_code')}>{invite.code}</td>
<td data-label={t('txt_status')}>{statusText(invite.status)}</td>
<td data-label={t('txt_expires_at')}>{formatExpiresAt(invite.expiresAt)}</td>
<td data-label={t('txt_actions')}>
<div className="actions invite-row-actions">
<button
type="button"
className="btn btn-secondary"
onClick={() => navigator.clipboard.writeText(invite.inviteLink || '')}
onClick={() => void copyTextToClipboard(invite.inviteLink || '', { successMessage: t('txt_link_copied') })}
>
<Clipboard size={14} className="btn-icon" /> {t('txt_copy_link')}
</button>
@@ -0,0 +1,138 @@
import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
import { Link } from 'wouter';
import AppMainRoutes from '@/components/AppMainRoutes';
import ThemeSwitch from '@/components/ThemeSwitch';
import type { AppMainRoutesProps } from '@/components/AppMainRoutes';
import { t } from '@/lib/i18n';
import type { Profile } from '@/lib/types';
interface AppAuthenticatedShellProps {
profile: Profile | null;
location: string;
mobilePrimaryRoute: string;
currentPageTitle: string;
showSidebarToggle: boolean;
sidebarToggleTitle: string;
settingsAccountRoute: string;
importRoute: string;
isImportRoute: boolean;
darkMode: boolean;
themeToggleTitle: string;
onLock: () => void;
onLogout: () => void;
onToggleTheme: () => void;
mainRoutesProps: AppMainRoutesProps;
}
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location;
return (
<div className="app-page">
<div className="app-shell">
<header className="topbar">
<div className="brand">
<img src="/logo-64.png" alt="NodeWarden logo" className="brand-logo" />
<span className="brand-name">NodeWarden</span>
<span className="mobile-page-title">{props.currentPageTitle}</span>
</div>
<div className="topbar-actions">
<div className="user-chip">
<ShieldUser size={16} />
<span>{props.profile?.email}</span>
</div>
<ThemeSwitch checked={props.darkMode} title={props.themeToggleTitle} onToggle={props.onToggleTheme} />
<button type="button" className="btn btn-secondary small" onClick={props.onLock}>
<Lock size={14} className="btn-icon" /> {t('txt_lock')}
</button>
{props.showSidebarToggle && (
<button
type="button"
className="btn btn-secondary small mobile-sidebar-toggle"
aria-label={props.sidebarToggleTitle}
title={props.sidebarToggleTitle}
onClick={() => window.dispatchEvent(new CustomEvent('nodewarden:toggle-sidebar'))}
>
<FolderIcon size={16} className="btn-icon" />
</button>
)}
<div className="mobile-theme-btn">
<ThemeSwitch checked={props.darkMode} title={props.themeToggleTitle} onToggle={props.onToggleTheme} />
</div>
<button type="button" className="btn btn-secondary small mobile-lock-btn" aria-label={t('txt_lock')} title={t('txt_lock')} onClick={props.onLock}>
<Lock size={14} className="btn-icon" />
</button>
<button type="button" className="btn btn-secondary small" onClick={props.onLogout}>
<LogOut size={14} className="btn-icon" /> {t('txt_sign_out')}
</button>
</div>
</header>
<div className="app-main">
<aside className="app-side">
<Link href="/vault" className={`side-link ${props.location === '/vault' ? 'active' : ''}`}>
<KeyRound size={16} />
<span>{t('nav_my_vault')}</span>
</Link>
<Link href="/vault/totp" className={`side-link ${props.location === '/vault/totp' ? 'active' : ''}`}>
<Clock3 size={16} />
<span>{t('txt_verification_code')}</span>
</Link>
<Link href="/sends" className={`side-link ${props.location === '/sends' ? 'active' : ''}`}>
<SendIcon size={16} />
<span>{t('nav_sends')}</span>
</Link>
{props.profile?.role === 'admin' && (
<Link href="/admin" className={`side-link ${props.location === '/admin' ? 'active' : ''}`}>
<ShieldUser size={16} />
<span>{t('nav_admin_panel')}</span>
</Link>
)}
<Link href={props.settingsAccountRoute} className={`side-link ${props.location === props.settingsAccountRoute ? 'active' : ''}`}>
<SettingsIcon size={16} />
<span>{t('nav_account_settings')}</span>
</Link>
<Link href="/security/devices" className={`side-link ${props.location === '/security/devices' ? 'active' : ''}`}>
<Shield size={16} />
<span>{t('nav_device_management')}</span>
</Link>
{props.profile?.role === 'admin' && (
<Link href="/backup" className={`side-link ${props.location === '/backup' ? 'active' : ''}`}>
<Cloud size={16} />
<span>{t('nav_backup_strategy')}</span>
</Link>
)}
<Link href={props.importRoute} className={`side-link ${props.isImportRoute ? 'active' : ''}`}>
<ArrowUpDown size={14} />
<span>{t('nav_import_export')}</span>
</Link>
</aside>
<main className="content">
<div key={routeAnimationKey} className="route-stage">
<AppMainRoutes {...props.mainRoutesProps} />
</div>
</main>
</div>
<nav className="mobile-tabbar" aria-label={t('txt_menu')}>
<Link href="/vault" className={`mobile-tab ${props.mobilePrimaryRoute === '/vault' ? 'active' : ''}`}>
<KeyRound size={18} />
<span>{t('nav_my_vault')}</span>
</Link>
<Link href="/vault/totp" className={`mobile-tab ${props.mobilePrimaryRoute === '/vault/totp' ? 'active' : ''}`}>
<Clock3 size={18} />
<span>{t('txt_verification_code')}</span>
</Link>
<Link href="/sends" className={`mobile-tab ${props.mobilePrimaryRoute === '/sends' ? 'active' : ''}`}>
<SendIcon size={18} />
<span>{t('nav_sends')}</span>
</Link>
<Link href="/settings" className={`mobile-tab ${props.mobilePrimaryRoute === '/settings' ? 'active' : ''}`}>
<SettingsIcon size={18} />
<span>{t('txt_settings')}</span>
</Link>
</nav>
</div>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
import ConfirmDialog from '@/components/ConfirmDialog';
import ToastHost from '@/components/ToastHost';
import { t } from '@/lib/i18n';
import type { ToastMessage } from '@/lib/types';
export interface AppConfirmState {
title: string;
message: string;
danger?: boolean;
showIcon?: boolean;
confirmText?: string;
cancelText?: string;
hideCancel?: boolean;
onConfirm: () => void;
}
interface AppGlobalOverlaysProps {
toasts: ToastMessage[];
onCloseToast: (id: string) => void;
confirm: AppConfirmState | null;
onCancelConfirm: () => void;
pendingTotpOpen: boolean;
totpCode: string;
rememberDevice: boolean;
onTotpCodeChange: (value: string) => void;
onRememberDeviceChange: (checked: boolean) => void;
onConfirmTotp: () => void;
onCancelTotp: () => void;
onUseRecoveryCode: () => void;
disableTotpOpen: boolean;
disableTotpPassword: string;
onDisableTotpPasswordChange: (value: string) => void;
onConfirmDisableTotp: () => void;
onCancelDisableTotp: () => void;
}
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
return (
<>
<ConfirmDialog
open={!!props.confirm}
title={props.confirm?.title || ''}
message={props.confirm?.message || ''}
danger={props.confirm?.danger}
showIcon={props.confirm?.showIcon}
confirmText={props.confirm?.confirmText}
cancelText={props.confirm?.cancelText}
hideCancel={props.confirm?.hideCancel}
onConfirm={() => props.confirm?.onConfirm()}
onCancel={props.onCancelConfirm}
/>
<ConfirmDialog
open={props.pendingTotpOpen}
title={t('txt_two_step_verification')}
message={t('txt_password_is_already_verified')}
confirmText={t('txt_verify')}
cancelText={t('txt_cancel')}
showIcon={false}
onConfirm={props.onConfirmTotp}
onCancel={props.onCancelTotp}
afterActions={(
<div className="dialog-extra">
<div className="dialog-divider" />
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onUseRecoveryCode}>
{t('txt_use_recovery_code')}
</button>
</div>
)}
>
<label className="field">
<span>{t('txt_totp_code')}</span>
<input className="input" value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
</label>
<label className="check-line" style={{ marginBottom: 0 }}>
<input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} />
<span>{t('txt_trust_this_device_for_30_days')}</span>
</label>
</ConfirmDialog>
<ConfirmDialog
open={props.disableTotpOpen}
title={t('txt_disable_totp')}
message={t('txt_enter_master_password_to_disable_two_step_verification')}
confirmText={t('txt_disable_totp')}
cancelText={t('txt_cancel')}
danger
showIcon={false}
onConfirm={props.onConfirmDisableTotp}
onCancel={props.onCancelDisableTotp}
>
<label className="field">
<span>{t('txt_master_password')}</span>
<input className="input" type="password" autoComplete="current-password" value={props.disableTotpPassword} onInput={(e) => props.onDisableTotpPasswordChange((e.currentTarget as HTMLInputElement).value)} />
</label>
</ConfirmDialog>
<ToastHost toasts={props.toasts} onClose={props.onCloseToast} />
</>
);
}
+351
View File
@@ -0,0 +1,351 @@
import { lazy, Suspense } from 'preact/compat';
import { useEffect } from 'preact/hooks';
import { Link, Route, Switch } from 'wouter';
import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
import type { CiphersImportPayload } from '@/lib/api/vault';
import { t } from '@/lib/i18n';
import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
import type { ExportRequest } from '@/lib/export-formats';
const SendsPage = lazy(() => import('@/components/SendsPage'));
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
const VaultPage = lazy(() => import('@/components/VaultPage'));
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage'));
const AdminPage = lazy(() => import('@/components/AdminPage'));
const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage'));
const ImportPage = lazy(() => import('@/components/ImportPage'));
function RouteContentFallback() {
return <div className="loading-screen">{t('txt_loading_nodewarden')}</div>;
}
function LegacyBackupRedirect(props: { onNavigate: (path: string) => void }) {
useEffect(() => {
props.onNavigate('/backup');
}, [props]);
return null;
}
export interface AppMainRoutesProps {
profile: Profile | null;
session: SessionState | null;
mobileLayout: boolean;
importRoute: string;
settingsHomeRoute: string;
settingsAccountRoute: string;
decryptedCiphers: Cipher[];
decryptedFolders: VaultFolder[];
decryptedSends: Send[];
ciphersLoading: boolean;
foldersLoading: boolean;
sendsLoading: boolean;
users: AdminUser[];
invites: AdminInvite[];
totpEnabled: boolean;
authorizedDevices: AuthorizedDevice[];
authorizedDevicesLoading: boolean;
onNavigate: (path: string) => void;
onLogout: () => void;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
onImport: (
payload: CiphersImportPayload,
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
attachments?: ImportAttachmentFile[]
) => Promise<ImportResultSummary>;
onImportEncryptedRaw: (
payload: CiphersImportPayload,
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
attachments?: ImportAttachmentFile[]
) => Promise<ImportResultSummary>;
onExport: (request: ExportRequest) => Promise<void>;
onCreateVaultItem: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
onUpdateVaultItem: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>;
onDeleteVaultItem: (cipher: Cipher) => Promise<void>;
onArchiveVaultItem: (cipher: Cipher) => Promise<void>;
onUnarchiveVaultItem: (cipher: Cipher) => Promise<void>;
onBulkDeleteVaultItems: (ids: string[]) => Promise<void>;
onBulkPermanentDeleteVaultItems: (ids: string[]) => Promise<void>;
onBulkRestoreVaultItems: (ids: string[]) => Promise<void>;
onBulkArchiveVaultItems: (ids: string[]) => Promise<void>;
onBulkUnarchiveVaultItems: (ids: string[]) => Promise<void>;
onBulkMoveVaultItems: (ids: string[], folderId: string | null) => Promise<void>;
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
onCreateFolder: (name: string) => Promise<void>;
onDeleteFolder: (folderId: string) => Promise<void>;
onBulkDeleteFolders: (folderIds: string[]) => Promise<void>;
onDownloadVaultAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
downloadingAttachmentKey: string;
attachmentDownloadPercent: number | null;
uploadingAttachmentName: string;
attachmentUploadPercent: number | null;
onRefreshVault: () => Promise<void>;
onCreateSend: (draft: SendDraft, autoCopyLink: boolean) => Promise<void>;
onUpdateSend: (send: Send, draft: SendDraft, autoCopyLink: boolean) => Promise<void>;
onDeleteSend: (send: Send) => Promise<void>;
onBulkDeleteSends: (ids: string[]) => Promise<void>;
uploadingSendFileName: string;
sendUploadPercent: number | null;
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
onEnableTotp: (secret: string, token: string) => Promise<void>;
onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onRefreshAuthorizedDevices: () => Promise<void>;
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
onRemoveDevice: (device: AuthorizedDevice) => void;
onRevokeAllDeviceTrust: () => void;
onRemoveAllDevices: () => void;
onCreateInvite: (hours: number) => Promise<void>;
onRefreshAdmin: () => void;
onDeleteAllInvites: () => Promise<void>;
onToggleUserStatus: (userId: string, status: 'active' | 'banned') => Promise<void>;
onDeleteUser: (userId: string) => Promise<void>;
onRevokeInvite: (code: string) => Promise<void>;
onExportBackup: (includeAttachments?: boolean) => Promise<void>;
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onLoadBackupSettings: () => Promise<AdminBackupSettings>;
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
}
export default function AppMainRoutes(props: AppMainRoutesProps) {
const importRoutePaths = [props.importRoute, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const;
const importPageContent = (
<Suspense fallback={<RouteContentFallback />}>
<ImportPage
onImport={props.onImport}
onImportEncryptedRaw={props.onImportEncryptedRaw}
accountKeys={props.session?.symEncKey && props.session?.symMacKey ? { encB64: props.session.symEncKey, macB64: props.session.symMacKey } : null}
onNotify={props.onNotify}
folders={props.decryptedFolders}
onExport={props.onExport}
/>
</Suspense>
);
const renderImportPageRoute = () => (
<div className="stack">
{props.mobileLayout && (
<div className="mobile-settings-subhead">
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
<span className="btn-icon" aria-hidden="true">{"<"}</span>
{t('txt_back')}
</button>
</div>
)}
{importPageContent}
</div>
);
return (
<Switch>
<Route path="/sends">
<Suspense fallback={<RouteContentFallback />}>
<SendsPage
sends={props.decryptedSends}
loading={props.sendsLoading}
onRefresh={props.onRefreshVault}
onCreate={props.onCreateSend}
onUpdate={props.onUpdateSend}
onDelete={props.onDeleteSend}
onBulkDelete={props.onBulkDeleteSends}
uploadingSendFileName={props.uploadingSendFileName}
sendUploadPercent={props.sendUploadPercent}
onNotify={props.onNotify}
/>
</Suspense>
</Route>
<Route path="/vault/totp">
<Suspense fallback={<RouteContentFallback />}>
<TotpCodesPage ciphers={props.decryptedCiphers} loading={props.ciphersLoading} onNotify={props.onNotify} />
</Suspense>
</Route>
<Route path="/vault">
<Suspense fallback={<RouteContentFallback />}>
<VaultPage
ciphers={props.decryptedCiphers}
folders={props.decryptedFolders}
loading={props.ciphersLoading || props.foldersLoading}
emailForReprompt={props.profile?.email || props.session?.email || ''}
onRefresh={props.onRefreshVault}
onCreate={props.onCreateVaultItem}
onUpdate={props.onUpdateVaultItem}
onDelete={props.onDeleteVaultItem}
onArchive={props.onArchiveVaultItem}
onUnarchive={props.onUnarchiveVaultItem}
onBulkDelete={props.onBulkDeleteVaultItems}
onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems}
onBulkRestore={props.onBulkRestoreVaultItems}
onBulkArchive={props.onBulkArchiveVaultItems}
onBulkUnarchive={props.onBulkUnarchiveVaultItems}
onBulkMove={props.onBulkMoveVaultItems}
onVerifyMasterPassword={props.onVerifyMasterPassword}
onNotify={props.onNotify}
onCreateFolder={props.onCreateFolder}
onDeleteFolder={props.onDeleteFolder}
onBulkDeleteFolders={props.onBulkDeleteFolders}
onDownloadAttachment={props.onDownloadVaultAttachment}
downloadingAttachmentKey={props.downloadingAttachmentKey}
attachmentDownloadPercent={props.attachmentDownloadPercent}
uploadingAttachmentName={props.uploadingAttachmentName}
attachmentUploadPercent={props.attachmentUploadPercent}
/>
</Suspense>
</Route>
<Route path={props.settingsAccountRoute}>
{props.profile && (
<div className="stack">
{props.mobileLayout && (
<div className="mobile-settings-subhead">
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
<span className="btn-icon" aria-hidden="true">{"<"}</span>
{t('txt_back')}
</button>
</div>
)}
<Suspense fallback={<RouteContentFallback />}>
<SettingsPage
profile={props.profile}
totpEnabled={props.totpEnabled}
onChangePassword={props.onChangePassword}
onSavePasswordHint={props.onSavePasswordHint}
onEnableTotp={props.onEnableTotp}
onOpenDisableTotp={props.onOpenDisableTotp}
onGetRecoveryCode={props.onGetRecoveryCode}
onNotify={props.onNotify}
/>
</Suspense>
</div>
)}
</Route>
<Route path="/settings">
{props.profile && (
<section className="card mobile-settings-card">
<div className="mobile-settings-links">
<Link href={props.settingsAccountRoute} className="mobile-settings-link">
<SettingsIcon size={18} />
<span>{t('nav_account_settings')}</span>
</Link>
<Link href="/security/devices" className="mobile-settings-link">
<Shield size={18} />
<span>{t('nav_device_management')}</span>
</Link>
<Link href={props.importRoute} className="mobile-settings-link">
<ArrowUpDown size={18} />
<span>{t('nav_import_export')}</span>
</Link>
{props.profile.role === 'admin' && (
<Link href="/admin" className="mobile-settings-link">
<ShieldUser size={18} />
<span>{t('nav_admin_panel')}</span>
</Link>
)}
{props.profile.role === 'admin' && (
<Link href="/backup" className="mobile-settings-link">
<Cloud size={18} />
<span>{t('nav_backup_strategy')}</span>
</Link>
)}
</div>
<button type="button" className="btn btn-secondary mobile-settings-logout" onClick={props.onLogout}>
<LogOut size={14} className="btn-icon" />
{t('txt_sign_out')}
</button>
</section>
)}
</Route>
<Route path="/security/devices">
<div className="stack">
{props.mobileLayout && (
<div className="mobile-settings-subhead">
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
<span className="btn-icon" aria-hidden="true">{"<"}</span>
{t('txt_back')}
</button>
</div>
)}
<Suspense fallback={<RouteContentFallback />}>
<SecurityDevicesPage
devices={props.authorizedDevices}
loading={props.authorizedDevicesLoading}
onRefresh={() => void props.onRefreshAuthorizedDevices()}
onRevokeTrust={props.onRevokeDeviceTrust}
onRemoveDevice={props.onRemoveDevice}
onRevokeAll={props.onRevokeAllDeviceTrust}
onRemoveAll={props.onRemoveAllDevices}
/>
</Suspense>
</div>
</Route>
<Route path="/admin">
<div className="stack">
{props.mobileLayout && (
<div className="mobile-settings-subhead">
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
<span className="btn-icon" aria-hidden="true">{"<"}</span>
{t('txt_back')}
</button>
</div>
)}
<Suspense fallback={<RouteContentFallback />}>
<AdminPage
currentUserId={props.profile?.id || ''}
users={props.users}
invites={props.invites}
onRefresh={props.onRefreshAdmin}
onCreateInvite={props.onCreateInvite}
onDeleteAllInvites={props.onDeleteAllInvites}
onToggleUserStatus={props.onToggleUserStatus}
onDeleteUser={props.onDeleteUser}
onRevokeInvite={props.onRevokeInvite}
/>
</Suspense>
</div>
</Route>
{importRoutePaths.map((path) => (
<Route key={path} path={path}>
{renderImportPageRoute()}
</Route>
))}
<Route path="/help">
<LegacyBackupRedirect onNavigate={props.onNavigate} />
</Route>
<Route path="/backup">
{props.profile?.role === 'admin' ? (
<div className="stack">
{props.mobileLayout && (
<div className="mobile-settings-subhead">
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
<span className="btn-icon" aria-hidden="true">{"<"}</span>
{t('txt_back')}
</button>
</div>
)}
<Suspense fallback={<RouteContentFallback />}>
<BackupCenterPage
currentUserId={props.profile?.id || null}
onExport={props.onExportBackup}
onImport={props.onImportBackup}
onLoadSettings={props.onLoadBackupSettings}
onListRemoteBackups={props.onListRemoteBackups}
onDownloadRemoteBackup={props.onDownloadRemoteBackup}
onDeleteRemoteBackup={props.onDeleteRemoteBackup}
onRestoreRemoteBackup={props.onRestoreRemoteBackup}
onSaveSettings={props.onSaveBackupSettings}
onRunRemoteBackup={props.onRunRemoteBackup}
onNotify={props.onNotify}
/>
</Suspense>
</div>
) : null}
</Route>
</Switch>
);
}
+165 -87
View File
@@ -13,15 +13,19 @@ interface RegisterValues {
email: string;
password: string;
password2: string;
passwordHint: string;
inviteCode: string;
}
interface AuthViewsProps {
mode: 'login' | 'register' | 'locked';
pendingAction: 'login' | 'register' | 'unlock' | null;
unlockReady: boolean;
loginValues: LoginValues;
registerValues: RegisterValues;
unlockPassword: string;
emailForLock: string;
loginHintLoading: boolean;
onChangeLogin: (next: LoginValues) => void;
onChangeRegister: (next: RegisterValues) => void;
onChangeUnlock: (password: string) => void;
@@ -31,6 +35,8 @@ interface AuthViewsProps {
onGotoLogin: () => void;
onGotoRegister: () => void;
onLogout: () => void;
onTogglePasswordHint: () => void;
onShowLockedPasswordHint: () => void;
}
function PasswordField(props: {
@@ -38,6 +44,7 @@ function PasswordField(props: {
value: string;
onInput: (v: string) => void;
autoFocus?: boolean;
autoComplete?: string;
}) {
const [show, setShow] = useState(false);
return (
@@ -50,6 +57,7 @@ function PasswordField(props: {
value={props.value}
onInput={(e) => props.onInput((e.currentTarget as HTMLInputElement).value)}
autoFocus={props.autoFocus}
autoComplete={props.autoComplete}
/>
<button type="button" className="eye-btn" onClick={() => setShow((v) => !v)}>
{show ? <EyeOff size={16} /> : <Eye size={16} />}
@@ -60,26 +68,50 @@ function PasswordField(props: {
}
export default function AuthViews(props: AuthViewsProps) {
const loginBusy = props.pendingAction === 'login';
const registerBusy = props.pendingAction === 'register';
const unlockBusy = props.pendingAction === 'unlock';
if (props.mode === 'locked') {
return (
<div className="auth-page">
<StandalonePageFrame title={t('txt_unlock_vault')}>
<p className="muted standalone-muted">{props.emailForLock}</p>
<PasswordField
label={t('txt_master_password')}
value={props.unlockPassword}
autoFocus
onInput={props.onChangeUnlock}
/>
<button type="button" className="btn btn-primary full" onClick={props.onSubmitUnlock}>
<Unlock size={16} className="btn-icon" />
{t('txt_unlock')}
</button>
<div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onLogout}>
<LogOut size={16} className="btn-icon" />
{t('txt_log_out')}
</button>
<form
onSubmit={(e) => {
e.preventDefault();
props.onSubmitUnlock();
}}
>
<p className="muted standalone-muted">{props.emailForLock}</p>
<input type="text" value={props.emailForLock} autoComplete="username" readOnly hidden tabIndex={-1} aria-hidden="true" />
<PasswordField
label={t('txt_master_password')}
value={props.unlockPassword}
autoFocus
autoComplete="current-password"
onInput={props.onChangeUnlock}
/>
<div className="auth-support-row">
<span />
<button
type="button"
className="auth-link-btn"
onClick={props.onShowLockedPasswordHint}
disabled={unlockBusy}
>
{t('txt_show_password_hint')}
</button>
</div>
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || !props.unlockReady}>
<Unlock size={16} className="btn-icon" />
{unlockBusy ? t('txt_unlocking') : t('txt_unlock')}
</button>
<div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy}>
<LogOut size={16} className="btn-icon" />
{t('txt_log_out')}
</button>
</form>
</StandalonePageFrame>
</div>
);
@@ -89,56 +121,80 @@ export default function AuthViews(props: AuthViewsProps) {
return (
<div className="auth-page">
<StandalonePageFrame title={t('txt_create_account')}>
<label className="field">
<span>{t('txt_name')}</span>
<input
className="input"
value={props.registerValues.name}
onInput={(e) =>
props.onChangeRegister({ ...props.registerValues, name: (e.currentTarget as HTMLInputElement).value })
}
<form
onSubmit={(e) => {
e.preventDefault();
props.onSubmitRegister();
}}
>
<label className="field">
<span>{t('txt_name')}</span>
<input
className="input"
value={props.registerValues.name}
autoComplete="name"
onInput={(e) =>
props.onChangeRegister({ ...props.registerValues, name: (e.currentTarget as HTMLInputElement).value })
}
/>
</label>
<label className="field">
<span>{t('txt_email')}</span>
<input
className="input"
type="email"
value={props.registerValues.email}
autoComplete="email"
onInput={(e) =>
props.onChangeRegister({ ...props.registerValues, email: (e.currentTarget as HTMLInputElement).value })
}
/>
</label>
<PasswordField
label={t('txt_master_password')}
value={props.registerValues.password}
autoComplete="new-password"
onInput={(v) => props.onChangeRegister({ ...props.registerValues, password: v })}
/>
</label>
<label className="field">
<span>{t('txt_email')}</span>
<input
className="input"
type="email"
value={props.registerValues.email}
onInput={(e) =>
props.onChangeRegister({ ...props.registerValues, email: (e.currentTarget as HTMLInputElement).value })
}
<PasswordField
label={t('txt_confirm_master_password')}
value={props.registerValues.password2}
autoComplete="new-password"
onInput={(v) => props.onChangeRegister({ ...props.registerValues, password2: v })}
/>
</label>
<PasswordField
label={t('txt_master_password')}
value={props.registerValues.password}
onInput={(v) => props.onChangeRegister({ ...props.registerValues, password: v })}
/>
<PasswordField
label={t('txt_confirm_master_password')}
value={props.registerValues.password2}
onInput={(v) => props.onChangeRegister({ ...props.registerValues, password2: v })}
/>
<label className="field">
<span>{t('txt_invite_code_optional')}</span>
<input
className="input"
value={props.registerValues.inviteCode}
onInput={(e) =>
props.onChangeRegister({ ...props.registerValues, inviteCode: (e.currentTarget as HTMLInputElement).value })
}
/>
</label>
<button type="button" className="btn btn-primary full" onClick={props.onSubmitRegister}>
<UserPlus size={16} className="btn-icon" />
{t('txt_create_account')}
</button>
<div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onGotoLogin}>
<ArrowLeft size={16} className="btn-icon" />
{t('txt_back_to_login')}
</button>
<label className="field">
<span>{t('txt_password_hint_optional')}</span>
<input
className="input"
maxLength={120}
value={props.registerValues.passwordHint}
placeholder={t('txt_password_hint_register_placeholder')}
onInput={(e) =>
props.onChangeRegister({ ...props.registerValues, passwordHint: (e.currentTarget as HTMLInputElement).value })
}
/>
</label>
<label className="field">
<span>{t('txt_invite_code_optional')}</span>
<input
className="input"
value={props.registerValues.inviteCode}
autoComplete="off"
onInput={(e) =>
props.onChangeRegister({ ...props.registerValues, inviteCode: (e.currentTarget as HTMLInputElement).value })
}
/>
</label>
<button type="submit" className="btn btn-primary full" disabled={registerBusy}>
<UserPlus size={16} className="btn-icon" />
{registerBusy ? t('txt_registering') : t('txt_create_account')}
</button>
<div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onGotoLogin} disabled={registerBusy}>
<ArrowLeft size={16} className="btn-icon" />
{t('txt_back_to_login')}
</button>
</form>
</StandalonePageFrame>
</div>
);
@@ -147,30 +203,52 @@ export default function AuthViews(props: AuthViewsProps) {
return (
<div className="auth-page">
<StandalonePageFrame title={t('txt_log_in')}>
<label className="field">
<span>{t('txt_email')}</span>
<input
className="input"
type="email"
value={props.loginValues.email}
onInput={(e) => props.onChangeLogin({ ...props.loginValues, email: (e.currentTarget as HTMLInputElement).value })}
<form
onSubmit={(e) => {
e.preventDefault();
props.onSubmitLogin();
}}
>
<label className="field">
<span>{t('txt_email')}</span>
<input
className="input"
type="email"
value={props.loginValues.email}
autoComplete="username"
onInput={(e) => props.onChangeLogin({ ...props.loginValues, email: (e.currentTarget as HTMLInputElement).value })}
/>
</label>
<PasswordField
label={t('txt_master_password')}
value={props.loginValues.password}
autoComplete="current-password"
onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })}
autoFocus
/>
</label>
<PasswordField
label={t('txt_master_password')}
value={props.loginValues.password}
onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })}
autoFocus
/>
<button type="button" className="btn btn-primary full" onClick={props.onSubmitLogin}>
<LogIn size={16} className="btn-icon" />
{t('txt_log_in')}
</button>
<div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onGotoRegister}>
<UserPlus size={16} className="btn-icon" />
{t('txt_create_account')}
</button>
<div className="auth-support-row">
<span />
<button
type="button"
className="auth-link-btn"
onClick={props.onTogglePasswordHint}
disabled={loginBusy || !props.loginValues.email.trim()}
>
{props.loginHintLoading
? t('txt_loading_password_hint')
: t('txt_show_password_hint')}
</button>
</div>
<button type="submit" className="btn btn-primary full" disabled={loginBusy}>
<LogIn size={16} className="btn-icon" />
{loginBusy ? t('txt_logging_in') : t('txt_log_in')}
</button>
<div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onGotoRegister} disabled={loginBusy}>
<UserPlus size={16} className="btn-icon" />
{t('txt_create_account')}
</button>
</form>
</StandalonePageFrame>
</div>
);
+618
View File
@@ -0,0 +1,618 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import ConfirmDialog from '@/components/ConfirmDialog';
import {
type AdminBackupImportResponse,
type AdminBackupRunResponse,
type AdminBackupSettings,
type BackupDestinationRecord,
type BackupDestinationType,
type RemoteBackupBrowserResponse,
} from '@/lib/api/backup';
import {
REMOTE_BROWSER_ITEMS_PER_PAGE,
compareRemoteItems,
createDraftBackupSettings,
createDraftDestinationRecord,
getDestinationById,
getFirstVisibleDestinationId,
getRemoteBrowserCacheKey,
getVisibleDestinations,
invalidateRemoteBrowserCacheForDestination,
isReplaceRequiredError,
loadPersistedRemoteBrowserState,
persistRemoteBrowserState,
} from '@/lib/backup-center';
import { RECOMMENDED_PROVIDERS, type RecommendedProvider } from '@/lib/backup-recommendations';
import { t } from '@/lib/i18n';
import { BackupDestinationDetail } from './backup-center/BackupDestinationDetail';
import { BackupDestinationSidebar } from './backup-center/BackupDestinationSidebar';
import { BackupOperationsSidebar } from './backup-center/BackupOperationsSidebar';
interface BackupCenterPageProps {
currentUserId: string | null;
onExport: (includeAttachments?: boolean) => Promise<void>;
onImport: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onLoadSettings: () => Promise<AdminBackupSettings>;
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
}
function buildSkippedImportMessage(result: AdminBackupImportResponse): string | null {
const skipped = result.skipped;
if (!skipped || !skipped.attachments) return null;
return t('txt_backup_restore_skipped_summary', {
reason: skipped.reason || t('txt_backup_restore_skipped_reason_default'),
attachments: String(skipped.attachments),
});
}
export default function BackupCenterPage(props: BackupCenterPageProps) {
const persistedRemoteStateRef = useRef(loadPersistedRemoteBrowserState(props.currentUserId));
const persistedRemoteState = persistedRemoteStateRef.current;
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [exporting, setExporting] = useState(false);
const [exportIncludeAttachments, setExportIncludeAttachments] = useState(false);
const [importing, setImporting] = useState(false);
const [loadingSettings, setLoadingSettings] = useState(true);
const [savingSettings, setSavingSettings] = useState(false);
const [runningRemoteBackup, setRunningRemoteBackup] = useState(false);
const [loadingRemoteBrowser, setLoadingRemoteBrowser] = useState(false);
const [downloadingRemotePath, setDownloadingRemotePath] = useState('');
const [downloadingRemotePercent, setDownloadingRemotePercent] = useState<number | null>(null);
const [restoringRemotePath, setRestoringRemotePath] = useState('');
const [remoteRestoreStatusText, setRemoteRestoreStatusText] = useState('');
const [deletingRemotePath, setDeletingRemotePath] = useState('');
const [localError, setLocalError] = useState('');
const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false);
const [confirmReplaceOpen, setConfirmReplaceOpen] = useState(false);
const [confirmRemoteReplaceOpen, setConfirmRemoteReplaceOpen] = useState(false);
const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false);
const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false);
const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState('');
const [savedSettings, setSavedSettings] = useState<AdminBackupSettings | null>(null);
const [settings, setSettings] = useState<AdminBackupSettings>(createDraftBackupSettings);
const [selectedDestinationId, setSelectedDestinationId] = useState<string | null>(persistedRemoteState.selectedDestinationId);
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(null);
const [remoteBrowserCache, setRemoteBrowserCache] = useState<Record<string, RemoteBackupBrowserResponse>>(persistedRemoteState.cache);
const [remoteBrowserPathByDestination, setRemoteBrowserPathByDestination] = useState<Record<string, string>>(persistedRemoteState.pathByDestination);
const [remoteBrowserPageByKey, setRemoteBrowserPageByKey] = useState<Record<string, number>>(persistedRemoteState.pageByKey);
const [showAddChooser, setShowAddChooser] = useState(false);
const visibleDestinations = getVisibleDestinations(settings);
const selectedDestination = getDestinationById(settings, selectedDestinationId);
const savedSelectedDestination = getDestinationById(savedSettings, selectedDestinationId);
const selectedDestinationIsSaved = !!savedSelectedDestination;
const disableWhileBusy = exporting || importing || savingSettings || runningRemoteBackup;
const currentRemoteBrowserPath = savedSelectedDestination ? (remoteBrowserPathByDestination[savedSelectedDestination.id] || '') : '';
const currentRemoteBrowserKey = savedSelectedDestination ? getRemoteBrowserCacheKey(savedSelectedDestination.id, currentRemoteBrowserPath) : '';
const remoteBrowser = currentRemoteBrowserKey ? remoteBrowserCache[currentRemoteBrowserKey] || null : null;
const remoteBrowserItems = remoteBrowser?.items || [];
const remoteBrowserTotalPages = Math.max(1, Math.ceil(remoteBrowserItems.length / REMOTE_BROWSER_ITEMS_PER_PAGE));
const currentRemoteBrowserPage = Math.min(remoteBrowserPageByKey[currentRemoteBrowserKey] || 1, remoteBrowserTotalPages);
const remoteBrowserVisibleItems = remoteBrowserItems.slice(
(currentRemoteBrowserPage - 1) * REMOTE_BROWSER_ITEMS_PER_PAGE,
currentRemoteBrowserPage * REMOTE_BROWSER_ITEMS_PER_PAGE
);
const selectedRecommendedProvider = RECOMMENDED_PROVIDERS.find((provider) => provider.id === selectedProviderId) || null;
const recommendedWebDavProviders = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 'webdav');
const recommendedS3Providers = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 's3');
const canRunSelectedDestination = !!selectedDestination && selectedDestinationIsSaved;
const canBrowseSelectedDestination = !!savedSelectedDestination;
useEffect(() => {
let cancelled = false;
setLoadingSettings(true);
void props.onLoadSettings()
.then((loaded) => {
if (cancelled) return;
setSavedSettings(loaded);
setSettings(loaded);
const nextSelectedDestinationId =
(persistedRemoteState.selectedDestinationId
&& getVisibleDestinations(loaded).some((destination) => destination.id === persistedRemoteState.selectedDestinationId)
? persistedRemoteState.selectedDestinationId
: null)
|| getFirstVisibleDestinationId(loaded);
setSelectedDestinationId(nextSelectedDestinationId);
setLocalError('');
})
.catch((error) => {
if (cancelled) return;
const message = error instanceof Error ? error.message : t('txt_backup_settings_load_failed');
setLocalError(message);
props.onNotify('error', message);
})
.finally(() => {
if (!cancelled) setLoadingSettings(false);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
persistRemoteBrowserState(props.currentUserId, {
cache: remoteBrowserCache,
pathByDestination: remoteBrowserPathByDestination,
pageByKey: remoteBrowserPageByKey,
selectedDestinationId,
});
}, [props.currentUserId, remoteBrowserCache, remoteBrowserPageByKey, remoteBrowserPathByDestination, selectedDestinationId]);
function updateSettings(mutator: (current: AdminBackupSettings) => AdminBackupSettings) {
setSettings((current) => {
const next = mutator(current);
if (selectedDestinationId && !next.destinations.some((destination) => destination.id === selectedDestinationId)) {
setSelectedDestinationId(getFirstVisibleDestinationId(next));
}
return next;
});
}
function updateSelectedDestination(mutator: (destination: BackupDestinationRecord) => BackupDestinationRecord) {
if (!selectedDestinationId) return;
updateSettings((current) => ({
...current,
destinations: current.destinations.map((destination) => (
destination.id === selectedDestinationId ? mutator(destination) : destination
)),
}));
}
async function loadRemoteBrowser(destinationId: string, path: string = '', options?: { force?: boolean }): Promise<void> {
const cacheKey = getRemoteBrowserCacheKey(destinationId, path);
setRemoteBrowserPathByDestination((current) => ({ ...current, [destinationId]: path }));
if (!options?.force && remoteBrowserCache[cacheKey]) return;
setLoadingRemoteBrowser(true);
try {
const browser = await props.onListRemoteBackups(destinationId, path);
const nextBrowser = {
...browser,
items: browser.items.slice().sort(compareRemoteItems),
};
setRemoteBrowserCache((current) => ({ ...current, [cacheKey]: nextBrowser }));
setRemoteBrowserPageByKey((current) => ({ ...current, [cacheKey]: 1 }));
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_remote_load_failed');
setLocalError(message);
props.onNotify('error', message);
} finally {
setLoadingRemoteBrowser(false);
}
}
function showRemoteBrowserPath(destinationId: string, path: string = ''): void {
setRemoteBrowserPathByDestination((current) => ({ ...current, [destinationId]: path }));
}
function buildSettingsPayloadForSelectedDestination(): AdminBackupSettings {
if (!selectedDestinationId || !selectedDestination) {
return savedSettings || { destinations: [] };
}
const persistedDestinations = (savedSettings?.destinations || []).filter((destination) => destination.id !== selectedDestinationId);
return {
destinations: [...persistedDestinations, selectedDestination],
};
}
function applySavedDestinationToDrafts(saved: AdminBackupSettings, destinationId: string | null) {
if (!destinationId) {
setSettings((current) => ({
destinations: current.destinations.filter((destination) => !savedSettings?.destinations.some((savedDestination) => savedDestination.id === destination.id)),
}));
return;
}
const savedDestination = getDestinationById(saved, destinationId);
setSettings((current) => ({
destinations: current.destinations.map((destination) => (
destination.id === destinationId && savedDestination ? savedDestination : destination
)),
}));
}
function resetSelectedFile() {
setSelectedFile(null);
if (fileInputRef.current) fileInputRef.current.value = '';
}
function handleAddDestination(type: BackupDestinationType) {
updateSettings((current) => {
const nextDestination = createDraftDestinationRecord(type, current.destinations.filter((destination) => destination.type === type).length + 1);
setSelectedProviderId(null);
setSelectedDestinationId(nextDestination.id);
return {
...current,
destinations: [...current.destinations, nextDestination],
};
});
setShowAddChooser(false);
}
async function handleDeleteDestination() {
if (!selectedDestinationId || savingSettings) return;
const destinationIdToDelete = selectedDestinationId;
const nextSettings: AdminBackupSettings = {
destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete),
};
setSavingSettings(true);
setLocalError('');
try {
const saved = await props.onSaveSettings(nextSettings);
const nextDraftDestinations = settings.destinations.filter((destination) => destination.id !== destinationIdToDelete);
const nextSelected = getFirstVisibleDestinationId({ destinations: nextDraftDestinations }) || getFirstVisibleDestinationId(saved);
setSavedSettings(saved);
setSettings({ destinations: nextDraftDestinations });
setRemoteBrowserCache((current) => invalidateRemoteBrowserCacheForDestination(
destinationIdToDelete,
current,
remoteBrowserPathByDestination,
remoteBrowserPageByKey
).cache);
setRemoteBrowserPathByDestination((current) => Object.fromEntries(Object.entries(current).filter(([key]) => key !== destinationIdToDelete)));
setRemoteBrowserPageByKey((current) => Object.fromEntries(Object.entries(current).filter(([key]) => !key.startsWith(`${destinationIdToDelete}:`))));
setSelectedDestinationId(nextSelected);
setConfirmDeleteDestinationOpen(false);
props.onNotify('success', t('txt_backup_destination_deleted'));
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_settings_save_failed');
setLocalError(message);
props.onNotify('error', message);
} finally {
setSavingSettings(false);
}
}
async function handleExport() {
setLocalError('');
setExporting(true);
try {
await props.onExport(exportIncludeAttachments);
props.onNotify('success', t('txt_backup_export_success'));
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_export_failed');
setLocalError(message);
props.onNotify('error', message);
} finally {
setExporting(false);
}
}
async function runLocalRestore(replaceExisting: boolean) {
if (!selectedFile) {
const message = t('txt_backup_file_required');
setLocalError(message);
props.onNotify('error', message);
return;
}
setLocalError('');
setImporting(true);
try {
const result = await props.onImport(selectedFile, replaceExisting);
props.onNotify('success', t('txt_backup_restore_success_relogin'));
const skippedMessage = buildSkippedImportMessage(result);
if (skippedMessage) props.onNotify('warning', skippedMessage);
resetSelectedFile();
setConfirmLocalRestoreOpen(false);
setConfirmReplaceOpen(false);
} catch (error) {
if (!replaceExisting && isReplaceRequiredError(error)) {
setConfirmLocalRestoreOpen(false);
setConfirmReplaceOpen(true);
return;
}
const message = error instanceof Error ? error.message : t('txt_backup_restore_failed');
setLocalError(message);
props.onNotify('error', message);
} finally {
setImporting(false);
}
}
async function handleSaveSettings() {
const payload = buildSettingsPayloadForSelectedDestination();
const destinationIdToInvalidate = selectedDestinationId;
setSavingSettings(true);
setLocalError('');
try {
const saved = await props.onSaveSettings(payload);
const nextSelected =
(selectedDestinationId && saved.destinations.some((destination) => destination.id === selectedDestinationId) && selectedDestinationId)
|| getFirstVisibleDestinationId(saved)
|| null;
setSavedSettings(saved);
applySavedDestinationToDrafts(saved, nextSelected);
if (destinationIdToInvalidate) {
setRemoteBrowserCache((current) => Object.fromEntries(Object.entries(current).filter(([key]) => !key.startsWith(`${destinationIdToInvalidate}:`))));
setRemoteBrowserPathByDestination((current) => Object.fromEntries(Object.entries(current).filter(([key]) => key !== destinationIdToInvalidate)));
setRemoteBrowserPageByKey((current) => Object.fromEntries(Object.entries(current).filter(([key]) => !key.startsWith(`${destinationIdToInvalidate}:`))));
}
setSelectedDestinationId(nextSelected);
props.onNotify('success', t('txt_backup_settings_saved'));
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_settings_save_failed');
setLocalError(message);
props.onNotify('error', message);
} finally {
setSavingSettings(false);
}
}
function handleToggleSelectedSchedule() {
if (!selectedDestination) return;
updateSelectedDestination((destination) => ({
...destination,
schedule: {
...destination.schedule,
enabled: !destination.schedule.enabled,
},
}));
}
async function handleRunRemoteBackup() {
if (!selectedDestination) return;
setRunningRemoteBackup(true);
setLocalError('');
try {
const result = await props.onRunRemoteBackup(selectedDestination.id);
setSavedSettings(result.settings);
setSettings(result.settings);
setSelectedDestinationId(selectedDestination.id);
await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true });
props.onNotify('success', t('txt_backup_remote_run_success'));
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed');
setLocalError(message);
props.onNotify('error', message);
} finally {
setRunningRemoteBackup(false);
}
}
async function handleDownloadRemote(path: string) {
if (!savedSelectedDestination) return;
setDownloadingRemotePath(path);
setDownloadingRemotePercent(null);
setLocalError('');
try {
await props.onDownloadRemoteBackup(savedSelectedDestination.id, path, setDownloadingRemotePercent);
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed');
setLocalError(message);
props.onNotify('error', message);
} finally {
setDownloadingRemotePath('');
setDownloadingRemotePercent(null);
}
}
async function handleDeleteRemote(path: string) {
if (!savedSelectedDestination) return;
setDeletingRemotePath(path);
setLocalError('');
try {
await props.onDeleteRemoteBackup(savedSelectedDestination.id, path);
setConfirmRemoteDeleteOpen(false);
setPendingRemoteDeletePath('');
await loadRemoteBrowser(savedSelectedDestination.id, currentRemoteBrowserPath, { force: true });
props.onNotify('success', t('txt_backup_remote_delete_success'));
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_remote_delete_failed');
setLocalError(message);
props.onNotify('error', message);
} finally {
setDeletingRemotePath('');
}
}
async function runRemoteRestore(path: string, replaceExisting: boolean) {
if (!savedSelectedDestination) return;
setRestoringRemotePath(path);
setRemoteRestoreStatusText(replaceExisting ? t('txt_backup_remote_restore_stage_replace') : t('txt_backup_remote_restore_stage_prepare'));
setLocalError('');
try {
const result = await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting);
setConfirmRemoteReplaceOpen(false);
setPendingRemoteRestorePath('');
setRemoteRestoreStatusText('');
props.onNotify('success', t('txt_backup_restore_success_relogin'));
const skippedMessage = buildSkippedImportMessage(result);
if (skippedMessage) props.onNotify('warning', skippedMessage);
} catch (error) {
if (!replaceExisting && isReplaceRequiredError(error)) {
setPendingRemoteRestorePath(path);
setConfirmRemoteReplaceOpen(true);
setRemoteRestoreStatusText('');
return;
}
const message = error instanceof Error ? error.message : t('txt_backup_remote_restore_failed');
setRemoteRestoreStatusText('');
setLocalError(message);
props.onNotify('error', message);
} finally {
setRestoringRemotePath('');
}
}
return (
<div className="backup-grid">
<input
ref={fileInputRef}
type="file"
hidden
accept=".zip,application/zip"
disabled={disableWhileBusy}
onChange={(event) => {
const nextFile = (event.currentTarget as HTMLInputElement).files?.[0] || null;
setSelectedFile(nextFile);
setLocalError('');
if (nextFile) setConfirmLocalRestoreOpen(true);
}}
/>
<BackupOperationsSidebar
disableWhileBusy={disableWhileBusy}
exporting={exporting}
importing={importing}
exportIncludeAttachments={exportIncludeAttachments}
selectedProviderId={selectedProviderId}
recommendedWebDavProviders={recommendedWebDavProviders}
recommendedS3Providers={recommendedS3Providers}
onExport={() => void handleExport()}
onImport={() => fileInputRef.current?.click()}
onExportIncludeAttachmentsChange={setExportIncludeAttachments}
onSelectProvider={(providerId) => setSelectedProviderId(providerId)}
/>
<BackupDestinationSidebar
destinations={visibleDestinations}
selectedDestinationId={selectedDestinationId}
disableWhileBusy={disableWhileBusy}
showAddChooser={showAddChooser}
onSelectDestination={(destinationId) => {
setSelectedProviderId(null);
setSelectedDestinationId(destinationId);
}}
onToggleAddChooser={() => setShowAddChooser((current) => !current)}
onAddDestination={handleAddDestination}
/>
<BackupDestinationDetail
selectedRecommendedProvider={selectedRecommendedProvider}
selectedDestination={selectedDestination}
selectedDestinationIsSaved={selectedDestinationIsSaved}
canRunSelectedDestination={canRunSelectedDestination}
canBrowseSelectedDestination={canBrowseSelectedDestination}
disableWhileBusy={disableWhileBusy}
loadingSettings={loadingSettings}
savingSettings={savingSettings}
runningRemoteBackup={runningRemoteBackup}
availableTimeZones={selectedDestination?.schedule.timezone ? [selectedDestination.schedule.timezone] : []}
remoteBrowser={remoteBrowser}
remoteBrowserVisibleItems={remoteBrowserVisibleItems}
remoteBrowserCurrentPage={currentRemoteBrowserPage}
remoteBrowserTotalPages={remoteBrowserTotalPages}
loadingRemoteBrowser={loadingRemoteBrowser}
downloadingRemotePath={downloadingRemotePath}
downloadingRemotePercent={downloadingRemotePercent}
restoringRemotePath={restoringRemotePath}
deletingRemotePath={deletingRemotePath}
onSaveSettings={() => void handleSaveSettings()}
onToggleSchedule={handleToggleSelectedSchedule}
onRunRemoteBackup={() => void handleRunRemoteBackup()}
onPromptDeleteDestination={() => setConfirmDeleteDestinationOpen(true)}
onUpdateDestination={updateSelectedDestination}
onRefreshRemoteBrowser={() => {
if (savedSelectedDestination) {
void loadRemoteBrowser(savedSelectedDestination.id, currentRemoteBrowserPath, { force: true });
}
}}
onShowRemoteBrowserPath={(path) => {
if (savedSelectedDestination) showRemoteBrowserPath(savedSelectedDestination.id, path);
}}
onDownloadRemoteBackup={(path) => void handleDownloadRemote(path)}
onRestoreRemoteBackup={(path) => void runRemoteRestore(path, false)}
onPromptDeleteRemoteBackup={(path) => {
setPendingRemoteDeletePath(path);
setConfirmRemoteDeleteOpen(true);
}}
onChangeRemoteBrowserPage={(page) => {
if (!currentRemoteBrowserKey) return;
setRemoteBrowserPageByKey((current) => ({ ...current, [currentRemoteBrowserKey]: page }));
}}
/>
{localError ? <div className="local-error">{localError}</div> : null}
{!localError && remoteRestoreStatusText ? <div className="status-ok">{remoteRestoreStatusText}</div> : null}
<ConfirmDialog
open={confirmLocalRestoreOpen}
title={t('txt_backup_import')}
message={selectedFile ? t('txt_backup_selected_file_name', { name: selectedFile.name }) : t('txt_backup_restore_note')}
confirmText={t('txt_backup_import')}
cancelText={t('txt_cancel')}
danger
onConfirm={() => void runLocalRestore(false)}
onCancel={() => {
setConfirmLocalRestoreOpen(false);
resetSelectedFile();
}}
/>
<ConfirmDialog
open={confirmReplaceOpen}
title={t('txt_backup_replace_confirm_title')}
message={t('txt_backup_replace_confirm_message')}
confirmText={importing ? t('txt_backup_restoring') : t('txt_backup_clear_and_restore')}
cancelText={t('txt_cancel')}
confirmDisabled={importing}
cancelDisabled={importing}
danger
onConfirm={() => void runLocalRestore(true)}
onCancel={() => {
if (importing) return;
setConfirmReplaceOpen(false);
resetSelectedFile();
}}
/>
<ConfirmDialog
open={confirmRemoteReplaceOpen}
title={t('txt_backup_replace_confirm_title')}
message={t('txt_backup_replace_confirm_message')}
confirmText={restoringRemotePath ? t('txt_backup_restoring') : t('txt_backup_clear_and_restore')}
cancelText={t('txt_cancel')}
confirmDisabled={!!restoringRemotePath}
cancelDisabled={!!restoringRemotePath}
danger
onConfirm={() => void runRemoteRestore(pendingRemoteRestorePath, true)}
onCancel={() => {
if (restoringRemotePath) return;
setConfirmRemoteReplaceOpen(false);
setPendingRemoteRestorePath('');
}}
/>
<ConfirmDialog
open={confirmRemoteDeleteOpen}
title={t('txt_delete')}
message={t('txt_backup_remote_delete_confirm_message', { name: pendingRemoteDeletePath.split('/').pop() || pendingRemoteDeletePath })}
confirmText={t('txt_delete')}
cancelText={t('txt_cancel')}
danger
onConfirm={() => void handleDeleteRemote(pendingRemoteDeletePath)}
onCancel={() => {
if (deletingRemotePath) return;
setConfirmRemoteDeleteOpen(false);
setPendingRemoteDeletePath('');
}}
/>
<ConfirmDialog
open={confirmDeleteDestinationOpen}
title={t('txt_delete')}
message={t('txt_backup_delete_destination_confirm_message', {
name: selectedDestination?.name || t('txt_backup_delete_destination'),
})}
confirmText={t('txt_delete')}
cancelText={t('txt_cancel')}
danger
onConfirm={() => void handleDeleteDestination()}
onCancel={() => {
if (savingSettings) return;
setConfirmDeleteDestinationOpen(false);
}}
/>
</div>
);
}
+48 -9
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
import { t } from '@/lib/i18n';
@@ -9,6 +10,9 @@ interface ConfirmDialogProps {
confirmText?: string;
cancelText?: string;
danger?: boolean;
hideCancel?: boolean;
confirmDisabled?: boolean;
cancelDisabled?: boolean;
onConfirm: () => void;
onCancel: () => void;
children?: ComponentChildren;
@@ -16,25 +20,60 @@ interface ConfirmDialogProps {
}
export default function ConfirmDialog(props: ConfirmDialogProps) {
if (!props.open) return null;
const [present, setPresent] = useState(props.open);
const [closing, setClosing] = useState(false);
useEffect(() => {
if (props.open) {
setPresent(true);
setClosing(false);
return;
}
if (!present) return;
setClosing(true);
const timer = window.setTimeout(() => {
setPresent(false);
setClosing(false);
}, 240);
return () => window.clearTimeout(timer);
}, [props.open, present]);
if (!present) return null;
return (
<div className="dialog-mask">
<div className="dialog-card">
<div className={`dialog-mask ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}>
<form
className={`dialog-card ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
onSubmit={(e) => {
e.preventDefault();
if (props.confirmDisabled || closing) return;
props.onConfirm();
}}
>
<h3 className="dialog-title">{props.title}</h3>
<div className="dialog-message">{props.message}</div>
{props.children}
<button
type="button"
type="submit"
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
onClick={props.onConfirm}
disabled={props.confirmDisabled}
>
{props.confirmText || t('txt_yes')}
</button>
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onCancel}>
{props.cancelText || t('txt_no')}
</button>
{!props.hideCancel && (
<button
type="button"
className="btn btn-secondary dialog-btn"
disabled={props.cancelDisabled}
onClick={() => {
if (props.cancelDisabled) return;
props.onCancel();
}}
>
{props.cancelText || t('txt_no')}
</button>
)}
{props.afterActions}
</div>
</form>
</div>
);
}
-18
View File
@@ -1,18 +0,0 @@
import { Cloud } from 'lucide-preact';
import { t } from '@/lib/i18n';
export default function HelpPage() {
return (
<div className="stack">
<section className="card">
<h3>{t('backup_strategy_title')}</h3>
<div className="empty" style={{ minHeight: 180 }}>
<div style={{ textAlign: 'center' }}>
<Cloud size={34} style={{ color: '#64748b', marginBottom: 8 }} />
<div>{t('backup_strategy_under_construction')}</div>
</div>
</div>
</section>
</div>
);
}
@@ -1,19 +0,0 @@
import { ArrowUpDown } from 'lucide-preact';
import { t } from '@/lib/i18n';
export default function ImportExportPage() {
return (
<div className="stack">
<section className="card">
<h3>{t('import_export_title')}</h3>
<div className="empty" style={{ minHeight: 180 }}>
<div style={{ textAlign: 'center' }}>
<ArrowUpDown size={34} style={{ color: '#64748b', marginBottom: 8 }} />
<div>{t('import_export_under_construction')}</div>
</div>
</div>
</section>
</div>
);
}
+872
View File
@@ -0,0 +1,872 @@
import { useState } from 'preact/hooks';
import { argon2idAsync } from '@noble/hashes/argon2.js';
import { strFromU8, unzipSync } from 'fflate';
import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js';
import { Download, FileUp } from 'lucide-preact';
import ConfirmDialog from '@/components/ConfirmDialog';
import type { CiphersImportPayload } from '@/lib/api/vault';
import {
type EncryptedJsonMode,
EXPORT_FORMATS,
type ExportFormatId,
type ExportRequest,
} from '@/lib/export-formats';
import {
parseImportPayloadBySource,
} from '@/lib/import-formats';
import { getFileAcceptBySource, IMPORT_SOURCES, type ImportSourceId } from '@/lib/import-format-sources';
import {
type BitwardenJsonInput,
normalizeBitwardenEncryptedAccountImport,
normalizeBitwardenImport,
} from '@/lib/import-formats-bitwarden';
import { base64ToBytes, decryptStr, hkdfExpand, pbkdf2 } from '@/lib/crypto';
import { t } from '@/lib/i18n';
import type { Folder } from '@/lib/types';
configureZipJs({ useWebWorkers: false });
export interface ImportAttachmentFile {
sourceCipherId: string | null;
sourceCipherIndex: number | null;
fileName: string;
bytes: Uint8Array;
}
interface ImportPageProps {
onImport: (
payload: CiphersImportPayload,
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
attachments?: ImportAttachmentFile[]
) => Promise<ImportResultSummary>;
onImportEncryptedRaw: (
payload: CiphersImportPayload,
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
attachments?: ImportAttachmentFile[]
) => Promise<ImportResultSummary>;
accountKeys?: { encB64: string; macB64: string } | null;
onNotify: (type: 'success' | 'error', text: string) => void;
folders: Folder[];
onExport: (request: ExportRequest) => Promise<void>;
}
export interface ImportResultSummary {
totalItems: number;
folderCount: number;
typeCounts: Array<{ label: string; count: number }>;
attachmentCount: number;
importedAttachmentCount: number;
failedAttachments: Array<{ fileName: string; reason: string }>;
}
interface BitwardenPasswordProtectedInput extends BitwardenJsonInput {
encrypted: true;
passwordProtected: true;
salt?: string;
kdfIterations?: number;
kdfMemory?: number;
kdfParallelism?: number;
kdfType?: number;
data?: string;
}
const COMMON_IMPORT_SOURCE_IDS: ImportSourceId[] = [
'bitwarden_json',
'bitwarden_csv',
'bitwarden_zip',
'nodewarden_json',
'onepassword_1pux',
'onepassword_1pif',
'onepassword_mac_csv',
'onepassword_win_csv',
'protonpass_json',
'chrome',
'edge',
'brave',
'opera',
'vivaldi',
'firefox_csv',
'safari_csv',
'lastpass',
'dashlane_csv',
'dashlane_json',
'keepass_xml',
'keepassx_csv',
];
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
function isPasswordProtectedExport(value: unknown): value is BitwardenPasswordProtectedInput {
return isRecord(value) && value.encrypted === true && value.passwordProtected === true;
}
async function derivePasswordProtectedFileKey(
parsed: BitwardenPasswordProtectedInput,
password: string
): Promise<{ enc: Uint8Array; mac: Uint8Array }> {
const salt = String(parsed.salt || '').trim();
const iterations = Number(parsed.kdfIterations || 0);
const kdfType = Number(parsed.kdfType);
if (!salt || !Number.isFinite(iterations) || iterations <= 0) {
throw new Error(t('txt_import_invalid_password_protected_file'));
}
let keyMaterial: Uint8Array;
if (kdfType === 0) {
keyMaterial = await pbkdf2(password, salt, iterations, 32);
} else if (kdfType === 1) {
const memoryMiB = Number(parsed.kdfMemory || 0);
const parallelism = Number(parsed.kdfParallelism || 0);
if (!Number.isFinite(memoryMiB) || memoryMiB <= 0 || !Number.isFinite(parallelism) || parallelism <= 0) {
throw new Error(t('txt_invalid_argon2id_params'));
}
const memoryKiB = Math.floor(memoryMiB * 1024);
const maxmem = memoryKiB * 1024 + 1024 * 1024;
keyMaterial = await argon2idAsync(new TextEncoder().encode(password), new TextEncoder().encode(salt), {
t: Math.floor(iterations),
m: memoryKiB,
p: Math.floor(parallelism),
dkLen: 32,
maxmem,
asyncTick: 10,
});
} else {
throw new Error(t('txt_unsupported_kdf_type', { type: String(kdfType) }));
}
const enc = await hkdfExpand(keyMaterial, 'enc', 32);
const mac = await hkdfExpand(keyMaterial, 'mac', 32);
return { enc, mac };
}
async function decryptPasswordProtectedExport(parsed: BitwardenPasswordProtectedInput, password: string): Promise<unknown> {
if (!parsed.encKeyValidation_DO_NOT_EDIT || !parsed.data) {
throw new Error(t('txt_import_invalid_password_protected_file'));
}
const pass = String(password || '').trim();
if (!pass) {
throw new Error(t('txt_import_file_password_required'));
}
const key = await derivePasswordProtectedFileKey(parsed, pass);
try {
await decryptStr(parsed.encKeyValidation_DO_NOT_EDIT, key.enc, key.mac);
} catch {
throw new Error(t('txt_invalid_file_password'));
}
const plainJson = await decryptStr(parsed.data, key.enc, key.mac);
try {
return JSON.parse(plainJson);
} catch {
throw new Error(t('txt_import_decrypt_failed'));
}
}
function isZipPayload(bytes: Uint8Array): boolean {
return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04;
}
function readZipText(bytes: Uint8Array, source: ImportSourceId): string {
const unzipped = unzipSync(bytes);
const fileNames = Object.keys(unzipped);
if (!fileNames.length) throw new Error(t('txt_import_empty_zip_archive'));
const preferred = source === 'onepassword_1pux' ? ['export.data', 'export.json'] : ['protonpass.json', 'export.json'];
for (const p of preferred) {
const hit = fileNames.find((n) => n.toLowerCase().endsWith(p.toLowerCase()));
if (hit) return strFromU8(unzipped[hit]);
}
const firstJson = fileNames.find((n) => n.toLowerCase().endsWith('.json') || n.toLowerCase().endsWith('.data'));
if (firstJson) return strFromU8(unzipped[firstJson]);
throw new Error(t('txt_import_no_json_found_in_zip'));
}
async function readImportText(file: File, source: ImportSourceId): Promise<string> {
if (source !== 'onepassword_1pux' && source !== 'protonpass_json') {
return file.text();
}
const bytes = new Uint8Array(await file.arrayBuffer());
if (isZipPayload(bytes)) return readZipText(bytes, source);
return new TextDecoder().decode(bytes);
}
interface PendingPasswordImportContext {
parsed: BitwardenPasswordProtectedInput;
source: 'bitwarden_json' | 'nodewarden_json' | 'bitwarden_zip';
attachments: ImportAttachmentFile[];
}
class ZipNeedsPasswordError extends Error {}
class ZipInvalidPasswordError extends Error {}
function looksLikeZipPasswordError(error: unknown): boolean {
const message = error instanceof Error ? String(error.message || '').toLowerCase() : '';
if (!message) return false;
return message.includes('password') || message.includes('encrypted');
}
async function readBitwardenZipPayload(
file: File,
passwordRaw: string
): Promise<{ jsonText: string; attachments: ImportAttachmentFile[] }> {
const password = String(passwordRaw || '').trim();
const reader = new ZipReader(new BlobReader(file), { useWebWorkers: false });
try {
const entries = await reader.getEntries();
if (!entries.length) throw new Error(t('txt_import_empty_zip_archive'));
let jsonText = '';
const attachments: ImportAttachmentFile[] = [];
const options = password ? { password } : undefined;
for (const entry of entries) {
if (entry.directory) continue;
const name = String(entry.filename || '').trim().replace(/\\/g, '/');
if (!name) continue;
const bytes = await entry.getData(new Uint8ArrayWriter(), options);
const lower = name.toLowerCase();
if (lower === 'data.json') {
jsonText = new TextDecoder().decode(bytes);
continue;
}
const attachmentMatch = name.match(/^attachments\/([^/]+)\/(.+)$/i);
if (!attachmentMatch) continue;
const sourceCipherId = String(attachmentMatch[1] || '').trim() || null;
const fileName = String(attachmentMatch[2] || '').trim() || 'attachment.bin';
attachments.push({
sourceCipherId,
sourceCipherIndex: null,
fileName,
bytes,
});
}
if (!jsonText) throw new Error(t('txt_import_data_json_not_found'));
return { jsonText, attachments };
} catch (error) {
if (looksLikeZipPasswordError(error)) {
if (!password) throw new ZipNeedsPasswordError(t('txt_import_zip_password_required'));
throw new ZipInvalidPasswordError(t('txt_import_invalid_zip_password'));
}
if (!password && error instanceof Error && /invalid|corrupt|unsupported/.test(error.message.toLowerCase())) {
throw error;
}
throw error;
} finally {
await reader.close();
}
}
function parseNodeWardenAttachmentArray(raw: unknown): ImportAttachmentFile[] {
if (!Array.isArray(raw)) return [];
const out: ImportAttachmentFile[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== 'object') continue;
const row = entry as Record<string, unknown>;
const fileName = String(row.fileName || '').trim() || 'attachment.bin';
const base64 = String(row.data || '').trim();
if (!base64) continue;
try {
const bytes = base64ToBytes(base64);
const sourceCipherId = String(row.cipherId || '').trim() || null;
const indexRaw = Number(row.cipherIndex);
out.push({
sourceCipherId,
sourceCipherIndex: Number.isFinite(indexRaw) ? indexRaw : null,
fileName,
bytes,
});
} catch {
// skip malformed attachment row
}
}
return out;
}
export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys, onNotify, folders, onExport }: ImportPageProps) {
const [source, setSource] = useState<ImportSourceId>('bitwarden_json');
const [file, setFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isPasswordSubmitting, setIsPasswordSubmitting] = useState(false);
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
const [importPassword, setImportPassword] = useState('');
const [pendingPasswordImport, setPendingPasswordImport] = useState<PendingPasswordImportContext | null>(null);
const [zipPasswordDialogOpen, setZipPasswordDialogOpen] = useState(false);
const [zipImportPassword, setZipImportPassword] = useState('');
const [pendingZipFile, setPendingZipFile] = useState<File | null>(null);
const [isZipPasswordSubmitting, setIsZipPasswordSubmitting] = useState(false);
const [folderMode, setFolderMode] = useState<'original' | 'none' | 'target'>('original');
const [targetFolderId, setTargetFolderId] = useState('');
const [exportFormat, setExportFormat] = useState<ExportFormatId>('bitwarden_json');
const [encryptedJsonMode, setEncryptedJsonMode] = useState<EncryptedJsonMode>('account');
const [exportPassword, setExportPassword] = useState('');
const [zipPassword, setZipPassword] = useState('');
const [isExporting, setIsExporting] = useState(false);
const [exportAuthDialogOpen, setExportAuthDialogOpen] = useState(false);
const [exportAuthPassword, setExportAuthPassword] = useState('');
const [importSummary, setImportSummary] = useState<ImportResultSummary | null>(null);
const commonSourceSet = new Set<ImportSourceId>(COMMON_IMPORT_SOURCE_IDS);
const commonSources = IMPORT_SOURCES.filter((item) => commonSourceSet.has(item.id as ImportSourceId));
const otherSources = IMPORT_SOURCES.filter((item) => !commonSourceSet.has(item.id as ImportSourceId));
async function runBitwardenJsonImport(parsed: unknown, attachments: ImportAttachmentFile[] = []): Promise<ImportResultSummary> {
if (isRecord(parsed) && parsed.encrypted === true) {
const accountEncrypted = parsed as BitwardenJsonInput;
if (!accountKeys?.encB64 || !accountKeys?.macB64) {
throw new Error(t('txt_vault_key_unavailable'));
}
const validation = String(accountEncrypted.encKeyValidation_DO_NOT_EDIT || '').trim();
if (!validation) throw new Error(t('txt_invalid_encrypted_export'));
const accountEncKey = base64ToBytes(accountKeys.encB64);
const accountMacKey = base64ToBytes(accountKeys.macB64);
try {
await decryptStr(validation, accountEncKey, accountMacKey);
} catch {
throw new Error(t('txt_export_belongs_to_another_account'));
}
return onImportEncryptedRaw(
normalizeBitwardenEncryptedAccountImport(accountEncrypted),
{
folderMode,
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
},
attachments
);
}
return onImport(
normalizeBitwardenImport(parsed),
{
folderMode,
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
},
attachments
);
}
async function extractNodeWardenAttachments(parsed: unknown): Promise<ImportAttachmentFile[]> {
if (!isRecord(parsed)) return [];
const direct = parseNodeWardenAttachmentArray(parsed.nodewardenAttachments);
if (direct.length) return direct;
const encryptedPayload = String(parsed.nodewardenAttachmentsEnc || '').trim();
if (!encryptedPayload) return [];
if (!accountKeys?.encB64 || !accountKeys?.macB64) {
throw new Error(t('txt_vault_key_unavailable'));
}
const accountEnc = base64ToBytes(accountKeys.encB64);
const accountMac = base64ToBytes(accountKeys.macB64);
const plain = await decryptStr(encryptedPayload, accountEnc, accountMac);
const unpacked = JSON.parse(plain) as Record<string, unknown>;
return parseNodeWardenAttachmentArray(unpacked.nodewardenAttachments);
}
async function runNodeWardenJsonImport(parsed: unknown, extraAttachments: ImportAttachmentFile[] = []): Promise<ImportResultSummary> {
const bundled = await extractNodeWardenAttachments(parsed);
return runBitwardenJsonImport(parsed, [...bundled, ...extraAttachments]);
}
async function processPasswordProtectedImport(ctx: PendingPasswordImportContext): Promise<ImportResultSummary> {
const parsed = await decryptPasswordProtectedExport(ctx.parsed, importPassword);
if (ctx.source === 'nodewarden_json') {
return runNodeWardenJsonImport(parsed, ctx.attachments);
}
return runBitwardenJsonImport(parsed, ctx.attachments);
}
async function handleSubmit() {
if (!file) {
onNotify('error', t('txt_please_select_a_file'));
return;
}
setIsSubmitting(true);
try {
if (source === 'bitwarden_zip') {
try {
const bundle = await readBitwardenZipPayload(file, '');
let parsed: unknown;
try {
parsed = JSON.parse(bundle.jsonText);
} catch {
throw new Error(t('txt_import_invalid_json_file'));
}
if (isPasswordProtectedExport(parsed)) {
setPendingPasswordImport({
parsed,
source: 'bitwarden_zip',
attachments: bundle.attachments,
});
setImportPassword('');
setPasswordDialogOpen(true);
return;
}
const summary = await runBitwardenJsonImport(parsed, bundle.attachments);
setImportSummary(summary);
setFile(null);
return;
} catch (error) {
if (error instanceof ZipNeedsPasswordError) {
setPendingZipFile(file);
setZipImportPassword('');
setZipPasswordDialogOpen(true);
return;
}
throw error;
}
}
const text = await readImportText(file, source);
if (source === 'bitwarden_json' || source === 'nodewarden_json') {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
throw new Error(t('txt_import_invalid_json_file'));
}
if (isPasswordProtectedExport(parsed)) {
setPendingPasswordImport({
parsed,
source,
attachments: [],
});
setImportPassword('');
setPasswordDialogOpen(true);
return;
}
const summary =
source === 'nodewarden_json'
? await runNodeWardenJsonImport(parsed)
: await runBitwardenJsonImport(parsed);
setImportSummary(summary);
} else {
const summary = await onImport(
parseImportPayloadBySource(source, text),
{
folderMode,
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
},
[]
);
setImportSummary(summary);
}
setFile(null);
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_import_failed');
onNotify('error', message);
} finally {
setIsSubmitting(false);
}
}
async function handlePasswordImportConfirm() {
if (!pendingPasswordImport) return;
setIsPasswordSubmitting(true);
try {
const summary = await processPasswordProtectedImport(pendingPasswordImport);
setImportSummary(summary);
setFile(null);
setImportPassword('');
setPendingPasswordImport(null);
setPasswordDialogOpen(false);
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_import_failed');
onNotify('error', message);
} finally {
setIsPasswordSubmitting(false);
}
}
async function handleZipPasswordImportConfirm() {
if (!pendingZipFile) return;
setIsZipPasswordSubmitting(true);
try {
const bundle = await readBitwardenZipPayload(pendingZipFile, zipImportPassword);
let parsed: unknown;
try {
parsed = JSON.parse(bundle.jsonText);
} catch {
throw new Error(t('txt_import_invalid_json_file'));
}
if (isPasswordProtectedExport(parsed)) {
setPendingPasswordImport({
parsed,
source: 'bitwarden_zip',
attachments: bundle.attachments,
});
setImportPassword('');
setPasswordDialogOpen(true);
} else {
const summary = await runBitwardenJsonImport(parsed, bundle.attachments);
setImportSummary(summary);
setFile(null);
}
setZipPasswordDialogOpen(false);
setPendingZipFile(null);
setZipImportPassword('');
} catch (error) {
if (error instanceof ZipInvalidPasswordError) {
onNotify('error', t('txt_import_invalid_zip_password'));
return;
}
const message = error instanceof Error ? error.message : t('txt_import_failed');
onNotify('error', message);
} finally {
setIsZipPasswordSubmitting(false);
}
}
const exportNeedsMode =
exportFormat === 'bitwarden_encrypted_json' ||
exportFormat === 'bitwarden_encrypted_json_zip' ||
exportFormat === 'nodewarden_encrypted_json';
const exportNeedsFilePassword = exportNeedsMode && encryptedJsonMode === 'password';
const exportIsZip = exportFormat === 'bitwarden_json_zip' || exportFormat === 'bitwarden_encrypted_json_zip';
async function runExportWithMasterPassword(masterPassword: string) {
const filePassword = exportPassword.trim();
const zipPass = zipPassword.trim();
if (exportNeedsFilePassword && !filePassword) {
onNotify('error', t('txt_import_file_password_required'));
return;
}
setIsExporting(true);
try {
await onExport({
format: exportFormat,
encryptedJsonMode: exportNeedsMode ? encryptedJsonMode : undefined,
filePassword,
zipPassword: exportIsZip ? zipPass : '',
masterPassword,
});
onNotify('success', t('txt_export_completed'));
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_export_failed');
onNotify('error', message);
} finally {
setIsExporting(false);
}
}
async function handleExportConfirmPassword() {
const masterPassword = String(exportAuthPassword || '').trim();
if (!masterPassword) {
onNotify('error', t('txt_master_password_is_required'));
return;
}
await runExportWithMasterPassword(masterPassword);
if (!isExporting) {
setExportAuthPassword('');
setExportAuthDialogOpen(false);
}
}
function handleExport() {
setExportAuthPassword('');
setExportAuthDialogOpen(true);
}
return (
<div className="import-export-page">
<div className="import-export-panels">
<section className="card import-export-panel">
<h3>{t('txt_import')}</h3>
<p className="backup-inline-note">{t('txt_import_vault_data_hint')}</p>
<div className="field-grid">
<label className="field field-span-2">
<span>{t('txt_format')}</span>
<select className="input" value={source} onChange={(e) => setSource((e.currentTarget as HTMLSelectElement).value as ImportSourceId)}>
{commonSources.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
{otherSources.length > 0 && (
<option disabled value="__separator__">
--------------------
</option>
)}
{otherSources.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
</select>
</label>
<label className="field field-span-2">
<span>{t('txt_source_file')}</span>
<input
className="input"
type="file"
accept={getFileAcceptBySource(source)}
onChange={(e) => {
const next = (e.currentTarget as HTMLInputElement).files?.[0] || null;
setFile(next);
}}
/>
</label>
<label className="field field-span-2">
<span>{t('txt_folder_handling')}</span>
<select
className="input"
value={folderMode}
onChange={(e) => setFolderMode((e.currentTarget as HTMLSelectElement).value as 'original' | 'none' | 'target')}
>
<option value="original">{t('txt_import_folder_mode_original')}</option>
<option value="none">{t('txt_import_folder_mode_none')}</option>
<option value="target">{t('txt_import_folder_mode_target')}</option>
</select>
</label>
{folderMode === 'target' && (
<label className="field field-span-2">
<span>{t('txt_target_folder')}</span>
<select className="input" value={targetFolderId} onChange={(e) => setTargetFolderId((e.currentTarget as HTMLSelectElement).value)}>
<option value="">{t('txt_select_folder_placeholder')}</option>
{folders
.slice()
.sort((a, b) => String(a.decName || a.name || '').localeCompare(String(b.decName || b.name || '')))
.map((folder) => (
<option key={folder.id} value={folder.id}>
{folder.decName || folder.name || folder.id}
</option>
))}
</select>
</label>
)}
</div>
<div className="actions">
<button
type="button"
className="btn btn-primary"
disabled={isSubmitting || (folderMode === 'target' && !targetFolderId)}
onClick={() => void handleSubmit()}
>
<FileUp size={15} /> {isSubmitting ? t('txt_loading') : t('txt_import')}
</button>
</div>
</section>
<section className="card import-export-panel">
<h3>{t('txt_export')}</h3>
<p className="backup-inline-note">{t('txt_export_vault_data_hint')}</p>
<div className="field-grid">
<label className="field field-span-2">
<span>{t('txt_format')}</span>
<select
className="input"
value={exportFormat}
onChange={(e) => {
const next = (e.currentTarget as HTMLSelectElement).value as ExportFormatId;
setExportFormat(next);
}}
>
{EXPORT_FORMATS.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
</select>
</label>
{exportNeedsMode && (
<label className="field field-span-2">
<span>{t('txt_encrypted_mode')}</span>
<select
className="input"
value={encryptedJsonMode}
onChange={(e) => setEncryptedJsonMode((e.currentTarget as HTMLSelectElement).value as EncryptedJsonMode)}
>
<option value="account">{t('txt_account_verification')}</option>
<option value="password">{t('txt_password_verification')}</option>
</select>
</label>
)}
{exportNeedsFilePassword && (
<label className="field field-span-2">
<span>{t('txt_file_password')}</span>
<input
className="input"
type="password"
value={exportPassword}
onInput={(e) => setExportPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
)}
{exportIsZip && (
<label className="field field-span-2">
<span>{t('txt_zip_password_optional')}</span>
<input
className="input"
type="password"
value={zipPassword}
onInput={(e) => setZipPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
)}
</div>
<div className="actions">
<button type="button" className="btn btn-primary" disabled={isExporting} onClick={() => void handleExport()}>
<Download size={15} className="btn-icon" />
{isExporting ? t('txt_loading') : t('txt_export')}
</button>
</div>
</section>
</div>
<ConfirmDialog
open={exportAuthDialogOpen}
title={t('txt_export')}
message={t('txt_enter_master_password_to_view_this_item')}
confirmText={isExporting ? t('txt_loading') : t('txt_verify')}
cancelText={t('txt_cancel')}
showIcon={false}
onConfirm={() => void handleExportConfirmPassword()}
onCancel={() => {
if (isExporting) return;
setExportAuthDialogOpen(false);
setExportAuthPassword('');
}}
>
<label className="field">
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
value={exportAuthPassword}
onInput={(e) => setExportAuthPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
</ConfirmDialog>
<ConfirmDialog
open={passwordDialogOpen}
title={t('txt_import_encrypted_file_title')}
message={t('txt_import_encrypted_file_message')}
confirmText={isPasswordSubmitting ? t('txt_loading') : t('txt_import')}
cancelText={t('txt_cancel')}
showIcon={false}
onConfirm={() => void handlePasswordImportConfirm()}
onCancel={() => {
if (isPasswordSubmitting) return;
setPasswordDialogOpen(false);
setImportPassword('');
setPendingPasswordImport(null);
}}
>
<label className="field">
<span>{t('txt_file_password')}</span>
<input
className="input"
type="password"
value={importPassword}
onInput={(e) => setImportPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
</ConfirmDialog>
<ConfirmDialog
open={zipPasswordDialogOpen}
title={t('txt_import_encrypted_zip_title')}
message={t('txt_import_encrypted_zip_message')}
confirmText={isZipPasswordSubmitting ? t('txt_loading') : t('txt_import')}
cancelText={t('txt_cancel')}
showIcon={false}
onConfirm={() => void handleZipPasswordImportConfirm()}
onCancel={() => {
if (isZipPasswordSubmitting) return;
setZipPasswordDialogOpen(false);
setZipImportPassword('');
setPendingZipFile(null);
}}
>
<label className="field">
<span>{t('txt_zip_password')}</span>
<input
className="input"
type="password"
value={zipImportPassword}
onInput={(e) => setZipImportPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
</ConfirmDialog>
{importSummary && (
<div className="dialog-mask">
<section className="dialog-card import-summary-dialog">
<button
type="button"
className="import-summary-close"
onClick={() => setImportSummary(null)}
aria-label={t('txt_close')}
>
X
</button>
<h3 className="dialog-title">{t('txt_import_success')}</h3>
<div className="dialog-message">{t('txt_import_success_number_of_items', { count: importSummary.totalItems })}</div>
{importSummary.attachmentCount > 0 && (
<div className="dialog-message">
{t('txt_import_attachment_summary', {
imported: String(importSummary.importedAttachmentCount),
total: String(importSummary.attachmentCount),
})}
</div>
)}
{importSummary.failedAttachments.length > 0 && (
<div className="import-summary-failed-list">
<div className="import-summary-failed-title">
{t('txt_import_failed_attachments_title', { count: String(importSummary.failedAttachments.length) })}
</div>
<ul>
{importSummary.failedAttachments.map((row, index) => (
<li key={`${row.fileName}-${index}`}>
<strong>{row.fileName}</strong>
{`: ${row.reason}`}
</li>
))}
</ul>
</div>
)}
<div className="import-summary-table-wrap">
<table className="import-summary-table">
<thead>
<tr>
<th>{t('txt_type')}</th>
<th>{t('txt_total')}</th>
</tr>
</thead>
<tbody>
{importSummary.typeCounts.map((row) => (
<tr key={row.label}>
<td>{row.label}</td>
<td>{row.count}</td>
</tr>
))}
<tr>
<td>{t('txt_folder')}</td>
<td>{importSummary.folderCount}</td>
</tr>
</tbody>
</table>
</div>
<button type="button" className="btn btn-primary dialog-btn" onClick={() => setImportSummary(null)}>
{t('txt_confirm')}
</button>
</section>
</div>
)}
</div>
);
}
+47 -7
View File
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'preact/hooks';
import { AlertTriangle, Copy, RefreshCw } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard';
import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n';
@@ -8,6 +9,9 @@ interface JwtWarningPageProps {
minLength: number;
}
const CLOUDFLARE_SETTINGS_URL =
'https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings';
export default function JwtWarningPage(props: JwtWarningPageProps) {
const [seed, setSeed] = useState(0);
const [copyHint, setCopyHint] = useState('');
@@ -24,7 +28,8 @@ export default function JwtWarningPage(props: JwtWarningPageProps) {
const isMissing = props.reason === 'missing';
const fixTitle = isMissing ? t('txt_jwt_how_to_fix_add') : t('txt_jwt_how_to_fix_replace');
const fixStep1 = isMissing ? t('txt_jwt_add_step_1') : t('txt_jwt_replace_step_1', { min: props.minLength });
const fixStep2 = isMissing ? t('txt_jwt_add_step_2') : t('txt_jwt_replace_step_2');
const fixStep2Prefix = isMissing ? t('txt_jwt_add_step_2_prefix') : t('txt_jwt_replace_step_2_prefix');
const fixStep2Suffix = isMissing ? t('txt_jwt_add_step_2_suffix') : t('txt_jwt_replace_step_2_suffix');
const fixStep3 = isMissing ? t('txt_jwt_add_step_3') : t('txt_jwt_replace_step_3');
return (
@@ -36,10 +41,38 @@ export default function JwtWarningPage(props: JwtWarningPageProps) {
</div>
<div className="jwt-warning-box">
<div className="jwt-warning-label">{t('txt_jwt_what_is')}</div>
<p className="jwt-warning-copy">{t('txt_jwt_what_is_body')}</p>
<div className="jwt-warning-label">{fixTitle}</div>
<ol className="jwt-warning-list">
<li>{fixStep1}</li>
<li>{fixStep2}</li>
<li>
{fixStep2Prefix}
<a
href={CLOUDFLARE_SETTINGS_URL}
className="jwt-inline-link"
target="_blank"
rel="noreferrer"
>
{t('txt_settings')}
</a>
{fixStep2Suffix}
<div className="jwt-secret-fields">
<div className="jwt-secret-row">
<span>{t('txt_jwt_secret_type_label')}</span>
<strong>{t('txt_jwt_secret_type_value')}</strong>
</div>
<div className="jwt-secret-row">
<span>{t('txt_jwt_secret_name_label')}</span>
<strong>JWT_SECRET</strong>
</div>
<div className="jwt-secret-row">
<span>{t('txt_jwt_secret_value_label')}</span>
<strong>{t('txt_jwt_secret_value_requirement', { min: props.minLength })}</strong>
</div>
</div>
</li>
<li>{fixStep3}</li>
</ol>
@@ -55,8 +88,10 @@ export default function JwtWarningPage(props: JwtWarningPageProps) {
type="button"
className="btn btn-secondary"
onClick={async () => {
await navigator.clipboard.writeText(generatedSecret);
setCopyHint(t('txt_copied'));
await copyTextToClipboard(generatedSecret, {
onSuccess: () => setCopyHint(t('txt_copied')),
onError: () => setCopyHint(t('txt_copy_failed')),
});
window.setTimeout(() => setCopyHint(''), 1500);
}}
>
@@ -74,10 +109,15 @@ export default function JwtWarningPage(props: JwtWarningPageProps) {
function generateJwtSecret(length: number): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
const bytes = crypto.getRandomValues(new Uint8Array(length));
let out = '';
for (let i = 0; i < length; i += 1) {
out += chars[bytes[i] % chars.length];
const maxUnbiasedByte = Math.floor(256 / chars.length) * chars.length;
while (out.length < length) {
const bytes = crypto.getRandomValues(new Uint8Array(length));
for (const value of bytes) {
if (value >= maxUnbiasedByte) continue;
out += chars[value % chars.length];
if (out.length >= length) break;
}
}
return out;
}
+21 -14
View File
@@ -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';
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send';
import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download';
import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n';
@@ -16,6 +17,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
const [error, setError] = useState('');
const [sendData, setSendData] = useState<any>(null);
const [busy, setBusy] = useState(false);
const [downloadPercent, setDownloadPercent] = useState<number | null>(null);
async function loadSend(pass?: string): Promise<void> {
setBusy(true);
@@ -48,12 +50,13 @@ export default function PublicSendPage(props: PublicSendPageProps) {
async function downloadFile(): Promise<void> {
if (!sendData?.id || !sendData?.file?.id) return;
setBusy(true);
setDownloadPercent(null);
setError('');
try {
const url = await accessPublicSendFile(sendData.id, sendData.file.id, props.keyPart, password || undefined);
const resp = await fetch(url);
if (!resp.ok) throw new Error(t('txt_download_failed'));
const encryptedBytes = await resp.arrayBuffer();
const encryptedBytes = await readResponseBytesWithProgress(resp, (progress) => setDownloadPercent(progress.percent));
let blob: Blob;
if (props.keyPart) {
try {
@@ -66,19 +69,17 @@ export default function PublicSendPage(props: PublicSendPageProps) {
} else {
blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
}
const obj = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = obj;
a.download = sendData.decFileName || sendData.file?.fileName || t('txt_send_file');
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(obj);
downloadBytesAsFile(
new Uint8Array(await blob.arrayBuffer()),
sendData.decFileName || sendData.file?.fileName || t('txt_send_file'),
'application/octet-stream'
);
} catch (e) {
const err = e as Error;
setError(err.message || t('txt_download_failed'));
} finally {
setBusy(false);
setDownloadPercent(null);
}
}
@@ -92,7 +93,12 @@ export default function PublicSendPage(props: PublicSendPageProps) {
{loading && <p className="muted">{t('txt_loading')}</p>}
{!loading && needPassword && (
<>
<form
onSubmit={(e) => {
e.preventDefault();
void loadSend(password);
}}
>
<label className="field">
<span>{t('txt_password')}</span>
<div className="password-wrap">
@@ -100,14 +106,15 @@ export default function PublicSendPage(props: PublicSendPageProps) {
className="input"
type="password"
value={password}
autoComplete="current-password"
onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}
/>
</div>
</label>
<button type="button" className="btn btn-primary full" disabled={busy} onClick={() => void loadSend(password)}>
<button type="submit" className="btn btn-primary full" disabled={busy}>
<Lock size={14} className="btn-icon" /> {t('txt_unlock_send')}
</button>
</>
</form>
)}
{!loading && sendData && (
@@ -124,7 +131,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
<strong>{sendData.decFileName || sendData.file?.fileName || sendData.file?.sizeName || t('txt_encrypted_file')}</strong>
</div>
<button type="button" className="btn btn-primary full" disabled={busy} onClick={() => void downloadFile()}>
<Download size={14} className="btn-icon" /> {t('txt_download')}
<Download size={14} className="btn-icon" /> {downloadPercent == null ? (busy ? t('txt_downloading') : t('txt_download')) : t('txt_downloading_percent', { percent: downloadPercent })}
</button>
</div>
)}
+50 -40
View File
@@ -16,52 +16,62 @@ export default function RecoverTwoFactorPage(props: RecoverTwoFactorPageProps) {
return (
<div className="auth-page">
<StandalonePageFrame title={t('txt_recover_two_step_login')}>
<p className="muted standalone-muted">{t('txt_use_your_one_time_recovery_code_to_disable_two_step_verification')}</p>
<form
onSubmit={(e) => {
e.preventDefault();
props.onSubmit();
}}
>
<p className="muted standalone-muted">{t('txt_use_your_one_time_recovery_code_to_disable_two_step_verification')}</p>
<label className="field">
<span>{t('txt_email')}</span>
<input
className="input"
type="email"
value={props.values.email}
onInput={(e) => props.onChange({ ...props.values, email: (e.currentTarget as HTMLInputElement).value })}
/>
</label>
<label className="field">
<span>{t('txt_master_password')}</span>
<div className="password-wrap">
<label className="field">
<span>{t('txt_email')}</span>
<input
className="input"
type={showPassword ? 'text' : 'password'}
value={props.values.password}
onInput={(e) => props.onChange({ ...props.values, password: (e.currentTarget as HTMLInputElement).value })}
type="email"
value={props.values.email}
autoComplete="username"
onInput={(e) => props.onChange({ ...props.values, email: (e.currentTarget as HTMLInputElement).value })}
/>
<button type="button" className="eye-btn" onClick={() => setShowPassword((v) => !v)}>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</label>
<label className="field">
<span>{t('txt_master_password')}</span>
<div className="password-wrap">
<input
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 })}
/>
<button type="button" className="eye-btn" onClick={() => setShowPassword((v) => !v)}>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</label>
<label className="field">
<span>{t('txt_recovery_code')}</span>
<input
className="input"
value={props.values.recoveryCode}
autoComplete="one-time-code"
onInput={(e) => props.onChange({ ...props.values, recoveryCode: (e.currentTarget as HTMLInputElement).value.toUpperCase() })}
/>
</label>
<div className="field-grid">
<button type="submit" className="btn btn-primary">
<Send size={14} className="btn-icon" />
{t('txt_submit')}
</button>
<button type="button" className="btn btn-secondary" onClick={props.onCancel}>
<X size={14} className="btn-icon" />
{t('txt_cancel')}
</button>
</div>
</label>
<label className="field">
<span>{t('txt_recovery_code')}</span>
<input
className="input"
value={props.values.recoveryCode}
onInput={(e) => props.onChange({ ...props.values, recoveryCode: (e.currentTarget as HTMLInputElement).value.toUpperCase() })}
/>
</label>
<div className="field-grid">
<button type="button" className="btn btn-primary" onClick={props.onSubmit}>
<Send size={14} className="btn-icon" />
{t('txt_submit')}
</button>
<button type="button" className="btn btn-secondary" onClick={props.onCancel}>
<X size={14} className="btn-icon" />
{t('txt_cancel')}
</button>
</div>
</form>
</StandalonePageFrame>
</div>
);
+19 -8
View File
@@ -9,6 +9,7 @@ interface SecurityDevicesPageProps {
onRevokeTrust: (device: AuthorizedDevice) => void;
onRemoveDevice: (device: AuthorizedDevice) => void;
onRevokeAll: () => void;
onRemoveAll: () => void;
}
function formatDateTime(value: string | null | undefined): string {
@@ -47,7 +48,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<div>
<h3 style={{ margin: 0 }}>{t('txt_device_management')}</h3>
<div className="muted-inline" style={{ marginTop: 4 }}>
{t('txt_manage_authorized_devices_and_30_day_totp_trusted_sessions')}
{t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')}
</div>
</div>
<div className="actions">
@@ -59,6 +60,10 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<ShieldOff size={14} className="btn-icon" />
{t('txt_revoke_all_trusted')}
</button>
<button type="button" className="btn btn-danger small" onClick={props.onRemoveAll}>
<Trash2 size={14} className="btn-icon" />
{t('txt_remove_all_devices')}
</button>
</div>
</div>
</section>
@@ -70,6 +75,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<tr>
<th>{t('txt_device')}</th>
<th>{t('txt_type')}</th>
<th>{t('txt_status')}</th>
<th>{t('txt_added')}</th>
<th>{t('txt_last_seen')}</th>
<th>{t('txt_trusted_until')}</th>
@@ -79,14 +85,19 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<tbody>
{props.devices.map((device) => (
<tr key={device.identifier}>
<td>
<td data-label={t('txt_device')}>
<div>{device.name || t('txt_unknown_device')}</div>
<div className="muted-inline">{device.identifier}</div>
</td>
<td>{mapDeviceTypeName(device.type)}</td>
<td>{formatDateTime(device.creationDate)}</td>
<td>{formatDateTime(device.revisionDate)}</td>
<td>
<td data-label={t('txt_type')}>{mapDeviceTypeName(device.type)}</td>
<td data-label={t('txt_status')}>
<span className={`device-status-pill ${device.online ? 'online' : 'offline'}`}>
{device.online ? t('txt_online') : t('txt_offline')}
</span>
</td>
<td data-label={t('txt_added')}>{formatDateTime(device.creationDate)}</td>
<td data-label={t('txt_last_seen')}>{formatDateTime(device.revisionDate)}</td>
<td data-label={t('txt_trusted_until')}>
{device.trusted ? (
<div className="trusted-cell">
<Clock3 size={13} />
@@ -96,7 +107,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<span className="muted-inline">{t('txt_not_trusted')}</span>
)}
</td>
<td>
<td data-label={t('txt_actions')}>
<div className="actions">
<button
type="button"
@@ -117,7 +128,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
))}
{!props.loading && props.devices.length === 0 && (
<tr>
<td colSpan={6}>
<td colSpan={7}>
<div className="empty" style={{ minHeight: 80 }}>{t('txt_no_devices_found')}</div>
</td>
</tr>
+156 -23
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Send as SendIcon, Trash2 } from 'lucide-preact';
import { CheckCheck, ChevronLeft, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard';
import type { Send, SendDraft } from '@/lib/types';
import { t } from '@/lib/i18n';
@@ -11,11 +12,14 @@ interface SendsPageProps {
onUpdate: (send: Send, draft: SendDraft, autoCopyLink: boolean) => Promise<void>;
onDelete: (send: Send) => Promise<void>;
onBulkDelete: (ids: string[]) => Promise<void>;
uploadingSendFileName: string;
sendUploadPercent: number | null;
onNotify: (type: 'success' | 'error', text: string) => void;
}
type SendTypeFilter = 'all' | 'text' | 'file';
const AUTO_COPY_KEY = 'nodewarden.send.auto_copy_link.v1';
const MOBILE_LAYOUT_QUERY = '(max-width: 900px)';
function daysFromNow(iso: string | null | undefined, fallback: number): string {
if (!iso) return String(fallback);
@@ -58,6 +62,10 @@ function draftFromSend(send: Send): SendDraft {
}
export default function SendsPage(props: SendsPageProps) {
const getInitialIsMobileLayout = () =>
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia(MOBILE_LAYOUT_QUERY).matches
: false;
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<SendTypeFilter>('all');
const [selectedId, setSelectedId] = useState<string | null>(null);
@@ -67,6 +75,9 @@ export default function SendsPage(props: SendsPageProps) {
const [draft, setDraft] = useState<SendDraft | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
const [isMobileLayout, setIsMobileLayout] = useState(getInitialIsMobileLayout);
const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list');
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [autoCopyLink, setAutoCopyLink] = useState<boolean>(() => {
try {
return localStorage.getItem(AUTO_COPY_KEY) === '1';
@@ -74,6 +85,34 @@ export default function SendsPage(props: SendsPageProps) {
return false;
}
});
const sendUploadLabel =
props.sendUploadPercent == null
? t('txt_uploading_file_named', { name: props.uploadingSendFileName || t('txt_file') })
: t('txt_uploading_file_named_percent', {
name: props.uploadingSendFileName || t('txt_file'),
percent: props.sendUploadPercent,
});
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
const media = window.matchMedia(MOBILE_LAYOUT_QUERY);
const sync = () => setIsMobileLayout(media.matches);
sync();
if (typeof media.addEventListener === 'function') {
media.addEventListener('change', sync);
return () => media.removeEventListener('change', sync);
}
media.addListener(sync);
return () => media.removeListener(sync);
}, []);
useEffect(() => {
const onToggleSidebar = () => {
setMobileSidebarOpen((open) => !open);
};
window.addEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
return () => window.removeEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
}, []);
useEffect(() => {
try {
@@ -83,6 +122,19 @@ export default function SendsPage(props: SendsPageProps) {
}
}, [autoCopyLink]);
useEffect(() => {
if (!isMobileLayout) {
setMobilePanel('list');
setMobileSidebarOpen(false);
return;
}
if (isEditing) {
setMobilePanel('edit');
} else if (!selectedId) {
setMobilePanel('list');
}
}, [isMobileLayout, isEditing, selectedId]);
const filteredSends = useMemo(() => {
const q = search.trim().toLowerCase();
return props.sends.filter((send) => {
@@ -141,6 +193,7 @@ export default function SendsPage(props: SendsPageProps) {
setIsCreating(false);
setDraft(null);
setShowPassword(false);
if (isMobileLayout) setMobilePanel('detail');
} finally {
setBusy(false);
}
@@ -153,6 +206,7 @@ export default function SendsPage(props: SendsPageProps) {
if (selectedId === send.id) setSelectedId(null);
setIsEditing(false);
setDraft(null);
if (isMobileLayout) setMobilePanel('list');
} finally {
setBusy(false);
}
@@ -171,13 +225,29 @@ export default function SendsPage(props: SendsPageProps) {
function copyAccessUrl(send: Send): void {
const url = send.shareUrl || `${window.location.origin}/#/send/${send.accessId}`;
void navigator.clipboard.writeText(url);
props.onNotify('success', t('txt_link_copied'));
void copyTextToClipboard(url, { successMessage: t('txt_link_copied') });
}
return (
<div className="vault-grid">
<aside className="sidebar">
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
{isMobileLayout && (
<div
className={`mobile-sidebar-mask ${mobileSidebarOpen ? 'open' : ''}`}
onClick={() => {
if (!mobileSidebarOpen) return;
setMobileSidebarOpen(false);
}}
/>
)}
<aside className={`sidebar ${isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${isMobileLayout && mobileSidebarOpen ? 'open' : ''}`}>
{isMobileLayout && (
<div className="mobile-sidebar-head">
<div className="mobile-sidebar-title">{t('txt_all_sends')}</div>
<button type="button" className="mobile-sidebar-close" onClick={() => setMobileSidebarOpen(false)} aria-label={t('txt_close')}>
<X size={16} />
</button>
</div>
)}
<div className="sidebar-block">
<div className="sidebar-title">{t('txt_all_sends')}</div>
<button type="button" className={`tree-btn ${typeFilter === 'all' ? 'active' : ''}`} onClick={() => setTypeFilter('all')}>
@@ -206,7 +276,7 @@ export default function SendsPage(props: SendsPageProps) {
value={search}
onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}
/>
<button type="button" className="btn btn-secondary small" disabled={busy || props.loading} onClick={() => void props.onRefresh()}>
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={busy || props.loading} onClick={() => void props.onRefresh()}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_refresh')}
</button>
</div>
@@ -224,34 +294,55 @@ export default function SendsPage(props: SendsPageProps) {
setSelectedMap(map);
}}
>
<CheckCheck size={14} className="btn-icon" />
{t('txt_select_all')}
</button>
{!!selectedCount && (
<button type="button" className="btn btn-secondary small" onClick={() => setSelectedMap({})}>
<X size={14} className="btn-icon" />
{t('txt_cancel')}
</button>
)}
<button
type="button"
className="btn btn-primary small"
className="btn btn-primary small mobile-fab-trigger"
disabled={busy}
aria-label={t('txt_add')}
title={t('txt_add')}
onClick={() => {
setIsCreating(true);
setIsEditing(true);
setDraft(buildDefaultDraft());
setShowPassword(false);
if (isMobileLayout) setMobilePanel('edit');
setMobileSidebarOpen(false);
}}
>
<Plus size={14} className="btn-icon" /> {t('txt_add')}
<Plus size={14} className="btn-icon" />
</button>
</div>
<div className="list-panel">
{filteredSends.map((send) => (
<div key={send.id} className={`list-item ${selectedId === send.id ? 'active' : ''}`}>
{filteredSends.map((send, index) => (
<div
key={send.id}
className={`list-item stagger-item ${selectedId === send.id ? 'active' : ''}`}
style={{ animationDelay: `${Math.min(index, 10) * 26}ms` }}
onClick={(event) => {
const target = event.target as HTMLElement;
if (target.closest('.row-check')) return;
setSelectedId(send.id);
setIsEditing(false);
setIsCreating(false);
setDraft(null);
if (isMobileLayout) setMobilePanel('detail');
setMobileSidebarOpen(false);
}}
>
<input
type="checkbox"
className="row-check"
checked={!!selectedMap[send.id]}
onClick={(event) => event.stopPropagation()}
onInput={(e) =>
setSelectedMap((prev) => ({
...prev,
@@ -267,6 +358,8 @@ export default function SendsPage(props: SendsPageProps) {
setIsEditing(false);
setIsCreating(false);
setDraft(null);
if (isMobileLayout) setMobilePanel('detail');
setMobileSidebarOpen(false);
}}
>
<div className="list-icon-wrap">
@@ -287,11 +380,35 @@ export default function SendsPage(props: SendsPageProps) {
</div>
</section>
<section className="detail-col">
<section className={`detail-col ${isMobileLayout ? 'mobile-detail-sheet' : ''} ${isMobileLayout && mobilePanel !== 'list' ? 'open' : ''}`}>
{isMobileLayout && mobilePanel !== 'list' && (
<div className="mobile-panel-head">
<button
type="button"
className="btn btn-secondary small mobile-panel-back"
onClick={() => {
if (isEditing) {
setIsEditing(false);
setIsCreating(false);
setDraft(null);
setShowPassword(false);
setMobilePanel(selectedSend ? 'detail' : 'list');
} else {
setMobilePanel('list');
}
}}
>
<ChevronLeft size={14} className="btn-icon" />
{t('txt_back')}
</button>
</div>
)}
{isEditing && draft && (
<div className="card">
<h3 className="detail-title">{isCreating ? t('txt_new_send') : t('txt_edit_send')}</h3>
<div className="field-grid">
<div key={`send-editor-${draft.id || selectedSend?.id || 'new'}-${draft.type}`} className="detail-switch-stage">
<div className="card stagger-item" style={{ animationDelay: '0ms' }}>
<h3 className="detail-title">{isCreating ? t('txt_new_send') : t('txt_edit_send')}</h3>
{!!props.uploadingSendFileName && <div className="detail-sub">{sendUploadLabel}</div>}
<div className="field-grid">
<label className="field field-span-2">
<span>{t('txt_name')}</span>
<input className="input" value={draft.name} onInput={(e) => setDraft({ ...draft, name: (e.currentTarget as HTMLInputElement).value })} />
@@ -362,22 +479,38 @@ export default function SendsPage(props: SendsPageProps) {
<label><input type="checkbox" checked={autoCopyLink} onInput={(e) => setAutoCopyLink((e.currentTarget as HTMLInputElement).checked)} /> {t('txt_auto_copy_link_after_save')}</label>
</div>
</label>
</div>
<div className="detail-actions">
<button type="button" className="btn btn-primary small" disabled={busy} onClick={() => void saveDraft()}>{t('txt_save')}</button>
<button type="button" className="btn btn-secondary small" disabled={busy} onClick={() => { setIsEditing(false); setIsCreating(false); setDraft(null); setShowPassword(false); }}>{t('txt_cancel')}</button>
</div>
<div className="detail-actions">
<button type="button" className="btn btn-primary small" disabled={busy} onClick={() => void saveDraft()}>
<Save size={14} className="btn-icon" /> {t('txt_save')}
</button>
<button
type="button"
className="btn btn-secondary small"
disabled={busy}
onClick={() => {
setIsEditing(false);
setIsCreating(false);
setDraft(null);
setShowPassword(false);
if (isMobileLayout) setMobilePanel(selectedSend ? 'detail' : 'list');
}}
>
<X size={14} className="btn-icon" /> {t('txt_cancel')}
</button>
</div>
</div>
</div>
)}
{!isEditing && selectedSend && (
<>
<div className="card">
<div key={`send-detail-${selectedSend.id}`} className="detail-switch-stage">
<div className="card stagger-item" style={{ animationDelay: '36ms' }}>
<h3 className="detail-title">{selectedSend.decName || t('txt_no_name')}</h3>
<div className="detail-sub">{Number(selectedSend.type) === 1 ? t('txt_file_send') : t('txt_text_send')}</div>
</div>
<div className="card">
<div className="card stagger-item" style={{ animationDelay: '72ms' }}>
<h4>{t('txt_send_details')}</h4>
<div className="kv-line"><span>{t('txt_access_count')}</span><strong>{selectedSend.accessCount || 0}</strong></div>
<div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{selectedSend.deletionDate || t('txt_dash')}</strong></div>
@@ -400,7 +533,7 @@ export default function SendsPage(props: SendsPageProps) {
</div>
{!!(selectedSend.decNotes || '').trim() && (
<div className="card">
<div className="card stagger-item" style={{ animationDelay: '108ms' }}>
<h4>{t('txt_notes')}</h4>
<div className="notes">{selectedSend.decNotes || ''}</div>
</div>
@@ -419,7 +552,7 @@ export default function SendsPage(props: SendsPageProps) {
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
</button>
</div>
</>
</div>
)}
</section>
</div>
+49 -10
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard';
import qrcode from 'qrcode-generator';
import type { Profile } from '@/lib/types';
import { t } from '@/lib/i18n';
@@ -8,6 +9,7 @@ interface SettingsPageProps {
profile: Profile;
totpEnabled: boolean;
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
onEnableTotp: (secret: string, token: string) => Promise<void>;
onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
@@ -16,9 +18,16 @@ interface SettingsPageProps {
function randomBase32Secret(length: number): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const random = crypto.getRandomValues(new Uint8Array(length));
let out = '';
for (const x of random) out += alphabet[x % alphabet.length];
const maxUnbiasedByte = Math.floor(256 / alphabet.length) * alphabet.length;
while (out.length < length) {
const random = crypto.getRandomValues(new Uint8Array(length));
for (const x of random) {
if (x >= maxUnbiasedByte) continue;
out += alphabet[x % alphabet.length];
if (out.length >= length) break;
}
}
return out;
}
@@ -32,6 +41,7 @@ export default function SettingsPage(props: SettingsPageProps) {
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newPassword2, setNewPassword2] = useState('');
const [passwordHint, setPasswordHint] = useState(props.profile.masterPasswordHint || '');
const [secret, setSecret] = useState(() => localStorage.getItem(totpSecretStorageKey) || randomBase32Secret(32));
const [token, setToken] = useState('');
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
@@ -46,6 +56,10 @@ export default function SettingsPage(props: SettingsPageProps) {
setTotpLocked(true);
}, [props.totpEnabled]);
useEffect(() => {
setPasswordHint(props.profile.masterPasswordHint || '');
}, [props.profile.masterPasswordHint]);
const qrDataUrl = useMemo(() => {
const qr = qrcode(0, 'M');
qr.addData(buildOtpUri(props.profile.email, secret));
@@ -55,10 +69,14 @@ export default function SettingsPage(props: SettingsPageProps) {
}, [props.profile.email, secret]);
async function enableTotp(): Promise<void> {
await props.onEnableTotp(secret, token);
// Secret is now stored on the server; remove plaintext copy from localStorage.
localStorage.removeItem(totpSecretStorageKey);
setTotpLocked(true);
try {
await props.onEnableTotp(secret, token);
// Secret is now stored on the server; remove plaintext copy from localStorage.
localStorage.removeItem(totpSecretStorageKey);
setTotpLocked(true);
} catch {
// Keep inputs editable after a failed attempt.
}
}
async function loadRecoveryCode(): Promise<void> {
@@ -69,6 +87,28 @@ export default function SettingsPage(props: SettingsPageProps) {
return (
<div className="stack">
<section className="card">
<h3>{t('txt_profile')}</h3>
<label className="field">
<span>{t('txt_password_hint_optional')}</span>
<input
className="input"
maxLength={120}
value={passwordHint}
placeholder={t('txt_password_hint_placeholder')}
onInput={(e) => setPasswordHint((e.currentTarget as HTMLInputElement).value)}
/>
<div className="field-help">{t('txt_password_hint_register_help')}</div>
</label>
<button
type="button"
className="btn btn-secondary"
onClick={() => void props.onSavePasswordHint(passwordHint)}
>
{t('txt_save_profile')}
</button>
</section>
<section className="card">
<h3>{t('txt_change_master_password')}</h3>
<label className="field">
@@ -133,8 +173,7 @@ export default function SettingsPage(props: SettingsPageProps) {
className="btn btn-secondary"
disabled={totpLocked}
onClick={() => {
void navigator.clipboard.writeText(secret);
props.onNotify?.('success', t('txt_secret_copied'));
void copyTextToClipboard(secret, { successMessage: t('txt_secret_copied') });
}}
>
<Clipboard size={14} className="btn-icon" />
@@ -174,10 +213,10 @@ export default function SettingsPage(props: SettingsPageProps) {
className="btn btn-secondary"
disabled={!recoveryCode}
onClick={() => {
void navigator.clipboard.writeText(recoveryCode);
props.onNotify?.('success', t('txt_recovery_code_copied'));
void copyTextToClipboard(recoveryCode, { successMessage: t('txt_recovery_code_copied') });
}}
>
<Clipboard size={14} className="btn-icon" />
{t('txt_copy_code')}
</button>
</div>
@@ -1,4 +1,5 @@
import type { ComponentChildren } from 'preact';
import { APP_VERSION } from '@shared/app-version';
interface StandalonePageFrameProps {
title: string;
@@ -24,6 +25,8 @@ export default function StandalonePageFrame(props: StandalonePageFrameProps) {
<a href="https://github.com/shuaiplus/NodeWarden" target="_blank" rel="noreferrer">NodeWarden Repository</a>
<span> | </span>
<a href="https://github.com/shuaiplus" target="_blank" rel="noreferrer">Author: @shuaiplus</a>
<span> | </span>
<span className="standalone-version">v{APP_VERSION}</span>
</div>
</div>
);
+29
View File
@@ -0,0 +1,29 @@
interface ThemeSwitchProps {
checked: boolean;
title: string;
onToggle: () => void;
}
export default function ThemeSwitch(props: ThemeSwitchProps) {
return (
<div className="theme-switch-wrap" title={props.title}>
<label className="theme-switch" aria-label={props.title}>
<span className="sun" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g fill="#ffd43b">
<circle r={5} cy={12} cx={12} />
<path d="m21 13h-1a1 1 0 0 1 0-2h1a1 1 0 0 1 0 2zm-17 0h-1a1 1 0 0 1 0-2h1a1 1 0 0 1 0 2zm13.66-5.66a1 1 0 0 1 -.66-.29 1 1 0 0 1 0-1.41l.71-.71a1 1 0 1 1 1.41 1.41l-.71.71a1 1 0 0 1 -.75.29zm-12.02 12.02a1 1 0 0 1 -.71-.29 1 1 0 0 1 0-1.41l.71-.66a1 1 0 0 1 1.41 1.41l-.71.71a1 1 0 0 1 -.7.24zm6.36-14.36a1 1 0 0 1 -1-1v-1a1 1 0 0 1 2 0v1a1 1 0 0 1 -1 1zm0 17a1 1 0 0 1 -1-1v-1a1 1 0 0 1 2 0v1a1 1 0 0 1 -1 1zm-5.66-14.66a1 1 0 0 1 -.7-.29l-.71-.71a1 1 0 0 1 1.41-1.41l.71.71a1 1 0 0 1 0 1.41 1 1 0 0 1 -.71.29zm12.02 12.02a1 1 0 0 1 -.7-.29l-.66-.71a1 1 0 0 1 1.36-1.36l.71.71a1 1 0 0 1 0 1.41 1 1 0 0 1 -.71.24z" />
</g>
</svg>
</span>
<span className="moon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512">
<path d="m223.5 32c-123.5 0-223.5 100.3-223.5 224s100 224 223.5 224c60.6 0 115.5-24.2 155.8-63.4 5-4.9 6.3-12.5 3.1-18.7s-10.1-9.7-17-8.5c-9.8 1.7-19.8 2.6-30.1 2.6-96.9 0-175.5-78.8-175.5-176 0-65.8 36-123.1 89.3-153.3 6.1-3.5 9.2-10.5 7.7-17.3s-7.3-11.9-14.3-12.5c-6.3-.5-12.6-.8-19-.8z" />
</svg>
</span>
<input type="checkbox" className="theme-switch-input" checked={props.checked} onInput={props.onToggle} />
<span className="theme-switch-slider" />
</label>
</div>
);
}
+333
View File
@@ -0,0 +1,333 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { Clipboard, Globe, GripVertical } from 'lucide-preact';
import {
closestCenter,
DndContext,
type DragEndEvent,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
rectSortingStrategy,
SortableContext,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
import { calcTotpNow } from '@/lib/crypto';
import { t } from '@/lib/i18n';
import type { Cipher } from '@/lib/types';
import { isCipherVisibleInNormalVault, websiteIconUrl } from '@/components/vault/vault-page-helpers';
interface TotpCodesPageProps {
ciphers: Cipher[];
loading: boolean;
onNotify: (type: 'success' | 'error', text: string) => void;
}
const TOTP_PERIOD_SECONDS = 30;
const TOTP_RING_RADIUS = 14;
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order';
const failedIconHosts = new Set<string>();
function formatTotp(code: string): string {
if (!code) return code;
if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`;
if (code.length < 6) return code;
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
}
function firstCipherUri(cipher: Cipher): string {
const uris = cipher.login?.uris || [];
for (const uri of uris) {
const raw = uri.decUri || uri.uri || '';
if (raw.trim()) return raw.trim();
}
return '';
}
function hostFromUri(uri: string): string {
if (!uri.trim()) return '';
try {
const normalized = /^https?:\/\//i.test(uri) ? uri : `https://${uri}`;
return new URL(normalized).hostname || '';
} catch {
return '';
}
}
function TotpListIcon({ cipher }: { cipher: Cipher }) {
const uri = firstCipherUri(cipher);
const host = hostFromUri(uri);
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
if (host && !errored) {
return (
<img
className="list-icon"
src={websiteIconUrl(host)}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
onError={() => {
failedIconHosts.add(host);
setErrored(true);
}}
/>
);
}
return (
<span className="list-icon-fallback">
<Globe size={18} />
</span>
);
}
interface SortableTotpRowProps {
cipher: Cipher;
live: { code: string; remain: number } | null;
onCopy: (value: string) => void;
}
function SortableTotpRow(props: SortableTotpRowProps) {
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.cipher.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const name = props.cipher.decName || props.cipher.name || t('txt_no_name');
const username = props.cipher.login?.decUsername || '';
return (
<div ref={setNodeRef} style={style} className={`totp-code-row${isDragging ? ' is-dragging' : ''}`}>
<button
type="button"
ref={setActivatorNodeRef}
className="btn btn-secondary small totp-drag-btn"
title={t('txt_drag_to_reorder')}
aria-label={t('txt_drag_to_reorder')}
{...attributes}
{...listeners}
>
<GripVertical size={14} className="btn-icon" />
</button>
<div className="totp-code-info">
<div className="list-icon-wrap">
<TotpListIcon cipher={props.cipher} />
</div>
<div className="totp-code-meta">
<div className="totp-code-name" title={name}>{name}</div>
<div className="totp-code-username" title={username}>{username || t('txt_no_username')}</div>
</div>
</div>
<div className="totp-code-main">
<strong>{props.live ? formatTotp(props.live.code) : t('txt_text_3')}</strong>
<div
className="totp-timer"
title={t('txt_refresh_in_seconds_s', { seconds: props.live ? props.live.remain : 0 })}
aria-label={t('txt_refresh_in_seconds_s', { seconds: props.live ? props.live.remain : 0 })}
>
<svg viewBox="0 0 36 36" className="totp-ring" role="presentation" aria-hidden="true">
<circle className="totp-ring-track" cx="18" cy="18" r={TOTP_RING_RADIUS} />
<circle
className="totp-ring-progress"
cx="18"
cy="18"
r={TOTP_RING_RADIUS}
style={{
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
strokeDashoffset: String(
TOTP_RING_CIRCUMFERENCE -
TOTP_RING_CIRCUMFERENCE *
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.live?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
),
}}
/>
</svg>
<span className="totp-timer-value">{props.live ? props.live.remain : 0}</span>
</div>
<button type="button" className="btn btn-secondary small totp-copy-btn" onClick={() => props.onCopy(props.live?.code || '')} aria-label={t('txt_copy')}>
<Clipboard size={14} className="btn-icon" />
</button>
</div>
</div>
);
}
export default function TotpCodesPage(props: TotpCodesPageProps) {
const [totpMap, setTotpMap] = useState<Record<string, { code: string; remain: number } | null>>({});
const [columnCount, setColumnCount] = useState(1);
const [orderedIds, setOrderedIds] = useState<string[]>(() => {
if (typeof window === 'undefined') return [];
try {
const parsed = JSON.parse(String(window.localStorage.getItem(TOTP_ORDER_STORAGE_KEY) || '[]'));
return Array.isArray(parsed) ? parsed.map((id) => String(id || '').trim()).filter(Boolean) : [];
} catch {
return [];
}
});
const listRef = useRef<HTMLDivElement | null>(null);
const hasLoadedTotpItemsRef = useRef(false);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 6,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 120,
tolerance: 8,
},
}),
);
async function copyToClipboard(value: string): Promise<void> {
await copyTextWithFeedback(value, { successMessage: t('txt_code_copied') });
}
const baseTotpItems = useMemo(
() =>
props.ciphers
.filter((cipher) => isCipherVisibleInNormalVault(cipher) && !!cipher.login?.decTotp)
.sort((a, b) => {
const nameA = (a.decName || a.name || '').trim().toLowerCase();
const nameB = (b.decName || b.name || '').trim().toLowerCase();
return nameA.localeCompare(nameB);
}),
[props.ciphers]
);
const totpItems = useMemo(() => {
if (!baseTotpItems.length) return [];
const orderMap = new Map(orderedIds.map((id, index) => [id, index]));
return [...baseTotpItems].sort((a, b) => {
const orderA = orderMap.get(a.id);
const orderB = orderMap.get(b.id);
if (orderA != null && orderB != null) return orderA - orderB;
if (orderA != null) return -1;
if (orderB != null) return 1;
const nameA = (a.decName || a.name || '').trim().toLowerCase();
const nameB = (b.decName || b.name || '').trim().toLowerCase();
return nameA.localeCompare(nameB);
});
}, [baseTotpItems, orderedIds]);
useEffect(() => {
if (!baseTotpItems.length) return;
hasLoadedTotpItemsRef.current = true;
const validIds = new Set(baseTotpItems.map((cipher) => cipher.id));
setOrderedIds((prev) => {
const filtered = prev.filter((id) => validIds.has(id));
const missing = baseTotpItems.map((cipher) => cipher.id).filter((id) => !filtered.includes(id));
const next = [...filtered, ...missing];
if (next.length === prev.length && next.every((id, index) => id === prev[index])) return prev;
return next;
});
}, [baseTotpItems]);
useEffect(() => {
if (typeof window === 'undefined') return;
if (!hasLoadedTotpItemsRef.current) return;
try {
window.localStorage.setItem(TOTP_ORDER_STORAGE_KEY, JSON.stringify(orderedIds));
} catch {
// ignore storage write failures
}
}, [orderedIds]);
useEffect(() => {
if (!totpItems.length) {
setTotpMap({});
return;
}
let stopped = false;
let timer = 0;
const tick = async () => {
const entries = await Promise.all(
totpItems.map(async (cipher) => {
try {
const next = await calcTotpNow(cipher.login?.decTotp || '');
return [cipher.id, next] as const;
} catch {
return [cipher.id, null] as const;
}
})
);
if (!stopped) setTotpMap(Object.fromEntries(entries));
};
void tick();
timer = window.setInterval(() => void tick(), 1000);
return () => {
stopped = true;
window.clearInterval(timer);
};
}, [totpItems]);
useEffect(() => {
const element = listRef.current;
if (!element) return;
const gap = 10;
const minCardWidth = 320;
const maxColumns = 4;
const updateColumns = () => {
const width = element.clientWidth;
if (!width) return;
const next = Math.max(1, Math.min(maxColumns, Math.floor((width + gap) / (minCardWidth + gap))));
setColumnCount(next);
};
updateColumns();
const observer = new ResizeObserver(() => updateColumns());
observer.observe(element);
return () => observer.disconnect();
}, []);
const handleDragEnd = (event: DragEndEvent) => {
const activeId = String(event.active.id);
const overId = event.over ? String(event.over.id) : null;
if (!overId || activeId === overId) return;
const fromIndex = orderedIds.indexOf(activeId);
const toIndex = orderedIds.indexOf(overId);
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return;
setOrderedIds((prev) => arrayMove(prev, fromIndex, toIndex));
};
return (
<div className="totp-codes-page">
<div className="card">
<div className="section-head">
<h3 className="detail-title">{t('txt_verification_code')}</h3>
</div>
<div
ref={listRef}
className="totp-codes-list"
style={{ '--totp-columns': String(columnCount) } as Record<string, string>}
>
{!totpItems.length && !props.loading && <div className="empty">{t('txt_no_verification_codes')}</div>}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={totpItems.map((cipher) => cipher.id)} strategy={rectSortingStrategy}>
{totpItems.map((cipher) => (
<SortableTotpRow
key={cipher.id}
cipher={cipher}
live={totpMap[cipher.id] || null}
onCopy={(value) => void copyToClipboard(value)}
/>
))}
</SortableContext>
</DndContext>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,524 @@
import { CloudUpload, Save, Trash2 } from 'lucide-preact';
import type {
BackupDestinationRecord,
E3BackupDestination,
RemoteBackupBrowserResponse,
WebDavBackupDestination,
} from '@/lib/api/backup';
import { COMMON_TIME_ZONES, getDestinationTypeLabel } from '@/lib/backup-center';
import type { RecommendedProvider } from '@/lib/backup-recommendations';
import { RemoteBackupBrowser } from './RemoteBackupBrowser';
import { t } from '@/lib/i18n';
import { BackupIncludeAttachmentsField } from './BackupIncludeAttachmentsField';
const INTERVAL_HOUR_PRESETS = [1, 6, 12, 24];
interface BackupDestinationDetailProps {
selectedRecommendedProvider: RecommendedProvider | null;
selectedDestination: BackupDestinationRecord | null;
selectedDestinationIsSaved: boolean;
canRunSelectedDestination: boolean;
canBrowseSelectedDestination: boolean;
disableWhileBusy: boolean;
loadingSettings: boolean;
savingSettings: boolean;
runningRemoteBackup: boolean;
availableTimeZones: string[];
remoteBrowser: RemoteBackupBrowserResponse | null;
remoteBrowserVisibleItems: RemoteBackupBrowserResponse['items'];
remoteBrowserCurrentPage: number;
remoteBrowserTotalPages: number;
loadingRemoteBrowser: boolean;
downloadingRemotePath: string;
downloadingRemotePercent: number | null;
restoringRemotePath: string;
deletingRemotePath: string;
onSaveSettings: () => void;
onToggleSchedule: () => void;
onRunRemoteBackup: () => void;
onPromptDeleteDestination: () => void;
onUpdateDestination: (mutator: (destination: BackupDestinationRecord) => BackupDestinationRecord) => void;
onRefreshRemoteBrowser: () => void;
onShowRemoteBrowserPath: (path: string) => void;
onDownloadRemoteBackup: (path: string) => void;
onRestoreRemoteBackup: (path: string) => void;
onPromptDeleteRemoteBackup: (path: string) => void;
onChangeRemoteBrowserPage: (page: number) => void;
}
function renderRecommendedProviderDetails(provider: RecommendedProvider) {
switch (provider.id) {
case 'koofr':
return (
<>
<div className="backup-recommendation-steps">
<div className="backup-recommendation-step">
<strong>1.</strong> {t('txt_backup_recommend_koofr_step_1')}
</div>
<div className="backup-recommendation-step">
<strong>2.</strong> {t('txt_backup_recommend_koofr_step_2_prefix')}{' '}
<a href={provider.passwordUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_koofr_password_link')}</a>
{t('txt_backup_recommend_koofr_step_2_suffix')}
</div>
<div className="backup-recommendation-step">
<strong>3.</strong> {t('txt_backup_recommend_koofr_step_3')}
</div>
<div className="backup-recommendation-step">
<strong>4.</strong> {t('txt_backup_recommend_koofr_step_4')}
</div>
<div className="backup-recommendation-step">
<strong>5.</strong> {t('txt_backup_recommend_koofr_step_5_prefix')}{' '}
<a href={provider.storageUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_koofr_storage_link')}</a>
{t('txt_backup_recommend_koofr_step_5_suffix')}
</div>
</div>
<div className="backup-recommendation-inline-note">{t('txt_backup_recommend_koofr_dav_intro')}</div>
<div className="backup-recommendation-dav-list">
<div className="backup-recommendation-dav-item">
<strong>{t('txt_backup_recommend_koofr_dav_self')}</strong>
<code>https://app.koofr.net/dav/Koofr</code>
</div>
<div className="backup-recommendation-dav-item">
<strong>Google Drive</strong>
<code>https://app.koofr.net/dav/Google Drive</code>
</div>
<div className="backup-recommendation-dav-item">
<strong>OneDrive</strong>
<code>https://app.koofr.net/dav/OneDrive</code>
</div>
<div className="backup-recommendation-dav-item">
<strong>Dropbox</strong>
<code>https://app.koofr.net/dav/Dropbox</code>
</div>
</div>
</>
);
case 'pcloud':
return (
<div className="backup-recommendation-steps">
<div className="backup-recommendation-step">
<strong>1.</strong> {t('txt_backup_recommend_pcloud_step_1')}
</div>
<div className="backup-recommendation-step">
<strong>2.</strong> {t('txt_backup_recommend_pcloud_step_2')}
</div>
<div className="backup-recommendation-step">
<strong>3.</strong> {t('txt_backup_recommend_pcloud_step_3')}
</div>
</div>
);
case 'infinicloud':
return (
<div className="backup-recommendation-steps">
<div className="backup-recommendation-step">
<strong>1.</strong> {t('txt_backup_recommend_infinicloud_step_1')}
</div>
<div className="backup-recommendation-step">
<strong>2.</strong> {t('txt_backup_recommend_infinicloud_step_2_prefix')}{' '}
<a href="https://infini-cloud.net/en/modules/mypage/usage/" target="_blank" rel="noreferrer">My Page</a>
{t('txt_backup_recommend_infinicloud_step_2_suffix')}
</div>
<div className="backup-recommendation-step">
<strong>3.</strong> {t('txt_backup_recommend_infinicloud_step_3')}
</div>
<div className="backup-recommendation-step">
<strong>4.</strong> {t('txt_backup_recommend_infinicloud_step_4')}
</div>
</div>
);
}
}
export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
const timeZones = Array.from(new Set([
...COMMON_TIME_ZONES,
...props.availableTimeZones,
]));
if (props.selectedRecommendedProvider) {
return (
<section className="backup-detail-panel">
<div className="backup-recommendation-card">
<div className="backup-recommendation-header">
<div>
<strong>{props.selectedRecommendedProvider.name}</strong>
<div className="backup-inline-note">
{props.selectedRecommendedProvider.id === 'infinicloud' ? t('txt_backup_recommend_infinicloud_summary')
: props.selectedRecommendedProvider.id === 'koofr' ? t('txt_backup_recommend_koofr_summary')
: t('txt_backup_recommend_pcloud_summary')}
</div>
</div>
<span className="backup-destination-type">{props.selectedRecommendedProvider.capacity}</span>
</div>
<div className="backup-recommendation-actions">
<a className="btn btn-primary small" href={props.selectedRecommendedProvider.signupUrl} target="_blank" rel="noreferrer">
{props.selectedRecommendedProvider.hasAffiliateLink ? t('txt_backup_recommend_open_signup_aff') : t('txt_backup_recommend_open_signup')}
</a>
</div>
{renderRecommendedProviderDetails(props.selectedRecommendedProvider)}
</div>
</section>
);
}
return (
<section className="backup-detail-panel">
<div className="section-head">
<h3>{t('txt_backup_destination_detail_title')}</h3>
{props.selectedDestination ? (
<div className="actions">
<button type="button" className="btn btn-primary small" disabled={props.loadingSettings || props.disableWhileBusy} onClick={props.onSaveSettings}>
<Save size={14} className="btn-icon" />
{props.savingSettings ? t('txt_backup_saving') : t('txt_backup_save_settings')}
</button>
<button type="button" className="btn btn-secondary small" disabled={props.loadingSettings || props.disableWhileBusy} onClick={props.onToggleSchedule}>
{props.selectedDestination.schedule.enabled ? t('txt_backup_disable_action') : t('txt_backup_enable_action')}
</button>
<button type="button" className="btn btn-secondary small" disabled={props.disableWhileBusy || !props.canRunSelectedDestination} onClick={props.onRunRemoteBackup}>
<CloudUpload size={14} className="btn-icon" />
{props.runningRemoteBackup ? t('txt_backup_running_now') : t('txt_backup_run_manual')}
</button>
<button type="button" className="btn btn-danger small" disabled={props.loadingSettings || props.disableWhileBusy} onClick={props.onPromptDeleteDestination}>
<Trash2 size={14} className="btn-icon" />
{t('txt_backup_delete_destination')}
</button>
</div>
) : null}
</div>
{!props.selectedDestination ? (
<div className="backup-browser-empty">{t('txt_backup_select_destination')}</div>
) : (
<>
<div className="backup-name-row">
<label className="field backup-name-field">
<span>{t('txt_backup_destination_name')}</span>
<input
className="input"
value={props.selectedDestination.name}
disabled={props.loadingSettings || props.disableWhileBusy}
onInput={(event) => props.onUpdateDestination((destination) => ({ ...destination, name: (event.currentTarget as HTMLInputElement).value }))}
/>
</label>
<label className="field backup-type-field">
<span>{t('txt_backup_type')}</span>
<input className="input" value={getDestinationTypeLabel(props.selectedDestination.type)} disabled />
</label>
</div>
<div className="field-grid backup-detail-schedule-grid">
<label className="field">
<span>{t('txt_backup_interval_hours')}</span>
<div className="backup-interval-row">
<div className="backup-inline-suffix-wrap">
<input
className="input backup-inline-suffix-input"
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={String(props.selectedDestination.schedule.intervalHours || 24)}
disabled={props.loadingSettings || props.disableWhileBusy}
onInput={(event) => {
const raw = (event.currentTarget as HTMLInputElement).value.replace(/[^\d]/g, '');
const value = Math.min(99, Math.max(1, Number(raw || 1)));
props.onUpdateDestination((destination) => ({
...destination,
schedule: {
...destination.schedule,
intervalHours: value,
},
}));
}}
/>
<span className="backup-inline-suffix">{t('txt_backup_interval_hours_suffix')}</span>
</div>
<div className="backup-interval-presets" aria-label={t('txt_backup_interval_hours_presets')}>
{INTERVAL_HOUR_PRESETS.map((preset) => {
const active = preset === props.selectedDestination.schedule.intervalHours;
return (
<button
key={preset}
type="button"
className={`backup-interval-preset${active ? ' active' : ''}`}
disabled={props.loadingSettings || props.disableWhileBusy}
onClick={() => props.onUpdateDestination((destination) => ({
...destination,
schedule: {
...destination.schedule,
intervalHours: preset,
},
}))}
>
{preset}
</button>
);
})}
</div>
</div>
</label>
<label className="field">
<span>{t('txt_backup_start_time')}</span>
<input
className="input"
type="time"
step={300}
value={props.selectedDestination.schedule.startTime || '03:00'}
disabled={props.loadingSettings || props.disableWhileBusy}
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
schedule: {
...destination.schedule,
startTime: (event.currentTarget as HTMLInputElement).value || '03:00',
},
}))}
/>
</label>
<label className="field">
<span>{t('txt_backup_timezone')}</span>
<select
className="input"
value={props.selectedDestination.schedule.timezone}
disabled={props.loadingSettings || props.disableWhileBusy}
onChange={(event) => props.onUpdateDestination((destination) => ({
...destination,
schedule: {
...destination.schedule,
timezone: (event.currentTarget as HTMLSelectElement).value,
},
}))}
>
{timeZones.map((timezone) => (
<option key={timezone} value={timezone}>{timezone}</option>
))}
</select>
</label>
<label className="field">
<span>{t('txt_backup_retention_count')}</span>
<div className="backup-inline-suffix-wrap">
<input
className="input backup-inline-suffix-input"
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={props.selectedDestination.schedule.retentionCount === null ? '' : String(props.selectedDestination.schedule.retentionCount)}
disabled={props.loadingSettings || props.disableWhileBusy}
placeholder="30"
onInput={(event) => {
const nextValue = (event.currentTarget as HTMLInputElement).value.replace(/[^\d]/g, '').trim();
props.onUpdateDestination((destination) => ({
...destination,
schedule: {
...destination.schedule,
retentionCount: nextValue ? Number(nextValue) : null,
},
}));
}}
/>
<span className="backup-inline-suffix">{t('txt_backup_retention_count_suffix')}</span>
</div>
</label>
</div>
<div className="backup-schedule-attachments-row">
<BackupIncludeAttachmentsField
checked={props.selectedDestination.includeAttachments}
disabled={props.loadingSettings || props.disableWhileBusy}
onChange={(checked) => props.onUpdateDestination((destination) => ({
...destination,
includeAttachments: checked,
}))}
/>
</div>
{props.selectedDestination.type === 'webdav' ? (
<div className="field-grid">
<label className="field field-span-2">
<span>{t('txt_backup_webdav_url')}</span>
<input
className="input"
value={(props.selectedDestination.destination as WebDavBackupDestination).baseUrl}
disabled={props.loadingSettings || props.disableWhileBusy}
placeholder="https://dav.example.com/remote.php/dav/files/admin"
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as WebDavBackupDestination),
baseUrl: (event.currentTarget as HTMLInputElement).value,
},
}))}
/>
</label>
<label className="field">
<span>{t('txt_backup_webdav_username')}</span>
<input
className="input"
value={(props.selectedDestination.destination as WebDavBackupDestination).username}
disabled={props.loadingSettings || props.disableWhileBusy}
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as WebDavBackupDestination),
username: (event.currentTarget as HTMLInputElement).value,
},
}))}
/>
</label>
<label className="field">
<span>{t('txt_backup_webdav_password')}</span>
<input
className="input"
type="password"
value={(props.selectedDestination.destination as WebDavBackupDestination).password}
disabled={props.loadingSettings || props.disableWhileBusy}
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as WebDavBackupDestination),
password: (event.currentTarget as HTMLInputElement).value,
},
}))}
/>
</label>
<label className="field field-span-2">
<span>{t('txt_backup_webdav_path')}</span>
<input
className="input"
value={(props.selectedDestination.destination as WebDavBackupDestination).remotePath}
disabled={props.loadingSettings || props.disableWhileBusy}
placeholder="nodewarden/backups"
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as WebDavBackupDestination),
remotePath: (event.currentTarget as HTMLInputElement).value,
},
}))}
/>
</label>
</div>
) : null}
{props.selectedDestination.type === 'e3' ? (
<div className="field-grid">
<label className="field field-span-2">
<span>{t('txt_backup_e3_endpoint')}</span>
<input
className="input"
value={(props.selectedDestination.destination as E3BackupDestination).endpoint}
disabled={props.loadingSettings || props.disableWhileBusy}
placeholder="https://s3.example.com"
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as E3BackupDestination),
endpoint: (event.currentTarget as HTMLInputElement).value,
},
}))}
/>
</label>
<label className="field">
<span>{t('txt_backup_e3_bucket')}</span>
<input
className="input"
value={(props.selectedDestination.destination as E3BackupDestination).bucket}
disabled={props.loadingSettings || props.disableWhileBusy}
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as E3BackupDestination),
bucket: (event.currentTarget as HTMLInputElement).value,
},
}))}
/>
</label>
<label className="field">
<span>{t('txt_backup_e3_region')}</span>
<input
className="input"
value={(props.selectedDestination.destination as E3BackupDestination).region}
disabled={props.loadingSettings || props.disableWhileBusy}
placeholder="auto"
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as E3BackupDestination),
region: (event.currentTarget as HTMLInputElement).value,
},
}))}
/>
</label>
<label className="field">
<span>{t('txt_backup_e3_access_key')}</span>
<input
className="input"
value={(props.selectedDestination.destination as E3BackupDestination).accessKeyId}
disabled={props.loadingSettings || props.disableWhileBusy}
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as E3BackupDestination),
accessKeyId: (event.currentTarget as HTMLInputElement).value,
},
}))}
/>
</label>
<label className="field">
<span>{t('txt_backup_e3_secret_key')}</span>
<input
className="input"
type="password"
value={(props.selectedDestination.destination as E3BackupDestination).secretAccessKey}
disabled={props.loadingSettings || props.disableWhileBusy}
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as E3BackupDestination),
secretAccessKey: (event.currentTarget as HTMLInputElement).value,
},
}))}
/>
</label>
<label className="field field-span-2">
<span>{t('txt_backup_e3_path')}</span>
<input
className="input"
value={(props.selectedDestination.destination as E3BackupDestination).rootPath}
disabled={props.loadingSettings || props.disableWhileBusy}
placeholder="nodewarden/backups"
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as E3BackupDestination),
rootPath: (event.currentTarget as HTMLInputElement).value,
},
}))}
/>
</label>
</div>
) : null}
<RemoteBackupBrowser
canBrowse={props.canBrowseSelectedDestination}
destinationIsSaved={props.selectedDestinationIsSaved}
disableWhileBusy={props.disableWhileBusy}
loadingRemoteBrowser={props.loadingRemoteBrowser}
remoteBrowser={props.remoteBrowser}
visibleItems={props.remoteBrowserVisibleItems}
currentPage={props.remoteBrowserCurrentPage}
totalPages={props.remoteBrowserTotalPages}
downloadingRemotePath={props.downloadingRemotePath}
downloadingRemotePercent={props.downloadingRemotePercent}
restoringRemotePath={props.restoringRemotePath}
deletingRemotePath={props.deletingRemotePath}
onRefresh={props.onRefreshRemoteBrowser}
onShowPath={props.onShowRemoteBrowserPath}
onDownload={props.onDownloadRemoteBackup}
onRestore={props.onRestoreRemoteBackup}
onPromptDelete={props.onPromptDeleteRemoteBackup}
onChangePage={props.onChangeRemoteBrowserPage}
/>
</>
)}
</section>
);
}
@@ -0,0 +1,70 @@
import { Plus } from 'lucide-preact';
import type { BackupDestinationRecord, BackupDestinationType } from '@/lib/api/backup';
import { formatDateTime, getDestinationTypeLabel } from '@/lib/backup-center';
import { t } from '@/lib/i18n';
interface BackupDestinationSidebarProps {
destinations: BackupDestinationRecord[];
selectedDestinationId: string | null;
disableWhileBusy: boolean;
showAddChooser: boolean;
onSelectDestination: (destinationId: string) => void;
onToggleAddChooser: () => void;
onAddDestination: (type: BackupDestinationType) => void;
}
export function BackupDestinationSidebar(props: BackupDestinationSidebarProps) {
return (
<aside className="backup-destination-sidebar">
<div className="section-head">
<h3>{t('txt_backup_destinations_title')}</h3>
</div>
<div className="backup-destination-list">
{props.destinations.map((destination) => {
const isSelected = destination.id === props.selectedDestinationId;
const isScheduled = destination.schedule.enabled;
return (
<button
key={destination.id}
type="button"
className={`backup-destination-item ${isSelected ? 'active' : ''}`}
onClick={() => props.onSelectDestination(destination.id)}
>
<span className="backup-destination-top">
<span className="backup-destination-name">{destination.name || getDestinationTypeLabel(destination.type)}</span>
<span className="backup-destination-type">{getDestinationTypeLabel(destination.type)}</span>
</span>
<span className="backup-destination-meta">
{isScheduled ? t('txt_backup_destination_active_badge') : t('txt_backup_destination_idle_badge')}
</span>
<span className="backup-destination-meta">
{destination.runtime.lastSuccessAt
? t('txt_backup_destination_last_success', { time: formatDateTime(destination.runtime.lastSuccessAt) })
: t('txt_backup_destination_never_run')}
</span>
</button>
);
})}
</div>
<div className="actions backup-destination-addbar">
<button type="button" className="btn btn-secondary small" disabled={props.disableWhileBusy} onClick={props.onToggleAddChooser}>
<Plus size={14} className="btn-icon" />
{t('txt_backup_add_destination')}
</button>
</div>
{props.showAddChooser ? (
<div className="backup-add-chooser">
<button type="button" className="btn btn-secondary small" onClick={() => props.onAddDestination('webdav')}>
{t('txt_backup_protocol_webdav')}
</button>
<button type="button" className="btn btn-secondary small" onClick={() => props.onAddDestination('e3')}>
{t('txt_backup_protocol_e3')}
</button>
</div>
) : null}
</aside>
);
}
@@ -0,0 +1,58 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { t } from '@/lib/i18n';
interface BackupIncludeAttachmentsFieldProps {
checked: boolean;
disabled?: boolean;
showHelp?: boolean;
showLabel?: boolean;
onChange: (checked: boolean) => void;
}
export function BackupIncludeAttachmentsField(props: BackupIncludeAttachmentsFieldProps) {
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
function handlePointerDown(event: PointerEvent) {
if (!wrapRef.current?.contains(event.target as Node)) {
setOpen(false);
}
}
document.addEventListener('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, [open]);
return (
<div className="backup-option-field">
<label className="backup-option-label">
<input
type="checkbox"
checked={props.checked}
disabled={props.disabled}
onInput={(event) => props.onChange((event.currentTarget as HTMLInputElement).checked)}
/>
{props.showLabel !== false ? <span>{t('txt_backup_include_attachments')}</span> : null}
</label>
{props.showHelp !== false ? (
<div ref={wrapRef} className={`backup-help-wrap ${open ? 'open' : ''}`}>
<button
type="button"
className="backup-help-trigger"
aria-label={t('txt_backup_include_attachments_help_button')}
aria-expanded={open ? 'true' : 'false'}
onClick={() => setOpen((current) => !current)}
>
?
</button>
<div className="backup-help-bubble" role="tooltip">
{t('txt_backup_include_attachments_help')}
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,101 @@
import { Download, FileUp } from 'lucide-preact';
import type { RecommendedProvider } from '@/lib/backup-recommendations';
import { hasLinkedStorages } from '@/lib/backup-recommendations';
import { t } from '@/lib/i18n';
import { BackupIncludeAttachmentsField } from './BackupIncludeAttachmentsField';
interface BackupOperationsSidebarProps {
disableWhileBusy: boolean;
exporting: boolean;
importing: boolean;
exportIncludeAttachments: boolean;
selectedProviderId: string | null;
recommendedWebDavProviders: RecommendedProvider[];
recommendedS3Providers: RecommendedProvider[];
onExport: () => void;
onImport: () => void;
onExportIncludeAttachmentsChange: (checked: boolean) => void;
onSelectProvider: (providerId: string) => void;
}
export function BackupOperationsSidebar(props: BackupOperationsSidebarProps) {
return (
<aside className="backup-operations-sidebar">
<div className="section-head">
<h3>{t('txt_backup_manual')}</h3>
</div>
<div className="backup-actions-stack">
<button type="button" className="btn btn-primary" disabled={props.disableWhileBusy} onClick={props.onExport}>
<Download size={14} className="btn-icon" />
{props.exporting ? t('txt_backup_exporting') : t('txt_backup_export')}
</button>
<BackupIncludeAttachmentsField
checked={props.exportIncludeAttachments}
disabled={props.disableWhileBusy}
showHelp={false}
onChange={props.onExportIncludeAttachmentsChange}
/>
<button type="button" className="btn btn-secondary" disabled={props.disableWhileBusy} onClick={props.onImport}>
<FileUp size={14} className="btn-icon" />
{props.importing ? t('txt_backup_restoring') : t('txt_backup_import')}
</button>
</div>
<div className="backup-divider" />
<div className="section-head">
<h3>{t('txt_backup_recommend_title')}</h3>
</div>
<div className="backup-recommendation-group">
<h4 className="backup-recommendation-group-title">{t('txt_backup_recommend_group_webdav')}</h4>
<div className="backup-recommendation-list">
{props.recommendedWebDavProviders.map((provider) => (
<button
key={provider.id}
type="button"
className={`backup-destination-item ${props.selectedProviderId === provider.id ? 'active' : ''}`}
onClick={() => props.onSelectProvider(provider.id)}
>
<span className="backup-recommendation-row">
<span className="backup-destination-name">{provider.name}</span>
<span className="backup-destination-meta">{provider.capacity}</span>
</span>
{hasLinkedStorages(provider) && provider.linkedStorages.length ? (
<span className="backup-recommendation-linked">
{provider.linkedStorages.map((storage) => (
<span key={`${provider.id}-${storage.name}`} className="backup-recommendation-linked-item">
<span>{storage.name}</span>
<span>{storage.capacity}</span>
</span>
))}
</span>
) : null}
</button>
))}
</div>
</div>
<div className="backup-recommendation-group">
<h4 className="backup-recommendation-group-title">{t('txt_backup_recommend_group_s3')}</h4>
{props.recommendedS3Providers.length ? (
<div className="backup-recommendation-list">
{props.recommendedS3Providers.map((provider) => (
<button
key={provider.id}
type="button"
className={`backup-destination-item ${props.selectedProviderId === provider.id ? 'active' : ''}`}
onClick={() => props.onSelectProvider(provider.id)}
>
<span className="backup-recommendation-row">
<span className="backup-destination-name">{provider.name}</span>
<span className="backup-destination-meta">{provider.capacity}</span>
</span>
</button>
))}
</div>
) : (
<div className="backup-browser-empty">{t('txt_backup_recommend_empty')}</div>
)}
</div>
</aside>
);
}
@@ -0,0 +1,146 @@
import { Download, FileArchive, FolderOpen, RefreshCw, RotateCcw, Trash2 } from 'lucide-preact';
import type { RemoteBackupBrowserResponse } from '@/lib/api/backup';
import { formatBytes, formatDateTime, isZipCandidate } from '@/lib/backup-center';
import { t } from '@/lib/i18n';
interface RemoteBackupBrowserProps {
canBrowse: boolean;
destinationIsSaved: boolean;
disableWhileBusy: boolean;
loadingRemoteBrowser: boolean;
remoteBrowser: RemoteBackupBrowserResponse | null;
visibleItems: RemoteBackupBrowserResponse['items'];
currentPage: number;
totalPages: number;
downloadingRemotePath: string;
downloadingRemotePercent: number | null;
restoringRemotePath: string;
deletingRemotePath: string;
onRefresh: () => void;
onShowPath: (path: string) => void;
onDownload: (path: string) => void;
onRestore: (path: string) => void;
onPromptDelete: (path: string) => void;
onChangePage: (page: number) => void;
}
export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
const getDownloadLabel = (path: string) => {
if (props.downloadingRemotePath !== path) return t('txt_backup_remote_download');
return props.downloadingRemotePercent == null
? t('txt_downloading')
: t('txt_downloading_percent', { percent: props.downloadingRemotePercent });
};
return (
<>
<div className="backup-divider" />
<div className="section-head">
<h3>{t('txt_backup_remote_title')}</h3>
{props.canBrowse ? (
<div className="actions">
<button type="button" className="btn btn-secondary small" disabled={props.loadingRemoteBrowser || props.disableWhileBusy} onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" />
{t('txt_backup_remote_refresh')}
</button>
</div>
) : null}
</div>
{!props.destinationIsSaved ? (
<div className="backup-browser-empty">{t('txt_backup_remote_save_first')}</div>
) : !props.remoteBrowser ? (
<div className="backup-browser-empty">{t('txt_backup_remote_cached_empty')}</div>
) : (
<>
<div className="backup-browser-path">
<strong>{t('txt_backup_remote_current_path')}</strong>
<span>{props.remoteBrowser.currentPath ? `/${props.remoteBrowser.currentPath}` : '/'}</span>
</div>
<div className="actions backup-browser-nav">
<button type="button" className="btn btn-secondary small" disabled={props.loadingRemoteBrowser || props.disableWhileBusy} onClick={() => props.onShowPath('')}>
<FolderOpen size={14} className="btn-icon" />
{t('txt_backup_remote_root')}
</button>
<button
type="button"
className="btn btn-secondary small"
disabled={props.loadingRemoteBrowser || props.disableWhileBusy || props.remoteBrowser.parentPath === null}
onClick={() => props.onShowPath(props.remoteBrowser?.parentPath || '')}
>
<RotateCcw size={14} className="btn-icon" />
{t('txt_backup_remote_up')}
</button>
</div>
{props.loadingRemoteBrowser ? (
<div className="backup-browser-empty">{t('txt_backup_remote_loading')}</div>
) : props.remoteBrowser.items.length ? (
<>
<div className="backup-browser-list">
{props.visibleItems.map((item) => (
<div key={`${item.isDirectory ? 'd' : 'f'}:${item.path}`} className="backup-browser-row">
<button
type="button"
className={`backup-browser-entry ${item.isDirectory ? 'dir' : 'file'}`}
onClick={() => {
if (item.isDirectory) props.onShowPath(item.path);
}}
>
{item.isDirectory ? <FolderOpen size={16} className="btn-icon" /> : <FileArchive size={16} className="btn-icon" />}
<span className="backup-browser-name">{item.name}</span>
</button>
<div className="backup-browser-meta">
<span>{item.modifiedAt ? formatDateTime(item.modifiedAt) : t('txt_backup_remote_unknown_time')}</span>
<span>{item.isDirectory ? t('txt_backup_remote_folder') : formatBytes(item.size)}</span>
</div>
<div className="actions backup-browser-actions">
{item.isDirectory ? (
<button type="button" className="btn btn-secondary small" onClick={() => props.onShowPath(item.path)}>
<FolderOpen size={14} className="btn-icon" />
{t('txt_backup_remote_open')}
</button>
) : isZipCandidate(item) ? (
<>
<button type="button" className="btn btn-secondary small" disabled={props.disableWhileBusy || props.downloadingRemotePath === item.path} onClick={() => props.onDownload(item.path)}>
<Download size={14} className="btn-icon" />
{getDownloadLabel(item.path)}
</button>
<button type="button" className="btn btn-primary small" disabled={props.disableWhileBusy || props.restoringRemotePath === item.path} onClick={() => props.onRestore(item.path)}>
<RotateCcw size={14} className="btn-icon" />
{props.restoringRemotePath === item.path ? t('txt_backup_restoring') : t('txt_backup_remote_restore')}
</button>
<button type="button" className="btn btn-danger small" disabled={props.disableWhileBusy || props.deletingRemotePath === item.path} onClick={() => props.onPromptDelete(item.path)}>
<Trash2 size={14} className="btn-icon" />
{props.deletingRemotePath === item.path ? t('txt_backup_remote_deleting') : t('txt_delete')}
</button>
</>
) : null}
</div>
</div>
))}
</div>
{props.totalPages > 1 ? (
<div className="backup-browser-pagination">
<button type="button" className="btn btn-secondary small" disabled={props.currentPage <= 1} onClick={() => props.onChangePage(props.currentPage - 1)}>
{t('txt_prev')}
</button>
<span className="backup-browser-page-indicator">
{props.currentPage} / {props.totalPages}
</span>
<button type="button" className="btn btn-secondary small" disabled={props.currentPage >= props.totalPages} onClick={() => props.onChangePage(props.currentPage + 1)}>
{t('txt_next')}
</button>
</div>
) : null}
</>
) : (
<div className="backup-browser-empty">{t('txt_backup_remote_empty')}</div>
)}
</>
)}
</>
);
}
@@ -0,0 +1,384 @@
import { useState } from 'preact/hooks';
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, RotateCcw, Trash2 } from 'lucide-preact';
import type { Cipher } from '@/lib/types';
import { t } from '@/lib/i18n';
import {
TOTP_PERIOD_SECONDS,
TOTP_RING_CIRCUMFERENCE,
copyToClipboard,
formatAttachmentSize,
formatHistoryTime,
formatTotp,
maskSecret,
openUri,
parseFieldType,
toBooleanFieldValue,
} from '@/components/vault/vault-page-helpers';
interface VaultDetailViewProps {
selectedCipher: Cipher;
repromptApprovedCipherId: string | null;
showPassword: boolean;
totpLive: { code: string; remain: number } | null;
passkeyCreatedAt: string | null;
hiddenFieldVisibleMap: Record<number, boolean>;
folderName: (id: string | null | undefined) => string;
downloadingAttachmentKey: string;
attachmentDownloadPercent: number | null;
onOpenReprompt: () => void;
onToggleShowPassword: () => void;
onToggleHiddenField: (index: number) => void;
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => void;
onStartEdit: () => void;
onDelete: (cipher: Cipher) => void;
onArchive: (cipher: Cipher) => void | Promise<void>;
onUnarchive: (cipher: Cipher) => void | Promise<void>;
}
export default function VaultDetailView(props: VaultDetailViewProps) {
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt);
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 (
<>
{Number(props.selectedCipher.reprompt || 0) === 1 && props.repromptApprovedCipherId !== props.selectedCipher.id && (
<div className="card">
<h4>{t('txt_master_password_reprompt_2')}</h4>
<div className="detail-sub">{t('txt_this_item_requires_master_password_every_time_before_viewing_details')}</div>
<div className="actions" style={{ marginTop: '10px' }}>
<button type="button" className="btn btn-primary" onClick={props.onOpenReprompt}>
<Eye size={14} className="btn-icon" /> {t('txt_unlock_details')}
</button>
</div>
</div>
)}
{(Number(props.selectedCipher.reprompt || 0) !== 1 || props.repromptApprovedCipherId === props.selectedCipher.id) && (
<>
<div className="card">
<h3 className="detail-title">{props.selectedCipher.decName || t('txt_no_name')}</h3>
<div className="detail-sub">{props.folderName(props.selectedCipher.folderId)}</div>
{isArchived && <div className="list-badge" style={{ marginTop: '8px', width: 'fit-content' }}>{t('txt_archived')}</div>}
</div>
{props.selectedCipher.login && (
<div className="card">
<h4>{t('txt_login_credentials')}</h4>
<div className="kv-row">
<span className="kv-label">{t('txt_username')}</span>
<div className="kv-main">
<strong className="value-ellipsis" title={props.selectedCipher.login.decUsername || ''}>{props.selectedCipher.login.decUsername || ''}</strong>
</div>
<div className="kv-actions">
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.selectedCipher.login?.decUsername || '')}>
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
</button>
</div>
</div>
<div className="kv-row">
<span className="kv-label">{t('txt_password')}</span>
<div className="kv-main">
<strong>{props.showPassword ? props.selectedCipher.login.decPassword || '' : maskSecret(props.selectedCipher.login.decPassword || '')}</strong>
</div>
<div className="kv-actions">
<button type="button" className="btn btn-secondary small" onClick={props.onToggleShowPassword}>
{props.showPassword ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
{props.showPassword ? t('txt_hide') : t('txt_reveal')}
</button>
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.selectedCipher.login?.decPassword || '')}>
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
</button>
</div>
</div>
{!!props.selectedCipher.login.decTotp && (
<div className="kv-row">
<span className="kv-label">{t('txt_totp')}</span>
<div className="kv-main">
<div className="totp-inline">
<strong>{props.totpLive ? formatTotp(props.totpLive.code) : t('txt_text_3')}</strong>
<div
className="totp-timer"
title={t('txt_refresh_in_seconds_s', { seconds: props.totpLive ? props.totpLive.remain : 0 })}
aria-label={t('txt_refresh_in_seconds_s', { seconds: props.totpLive ? props.totpLive.remain : 0 })}
>
<svg viewBox="0 0 36 36" className="totp-ring" role="presentation" aria-hidden="true">
<circle className="totp-ring-track" cx="18" cy="18" r="15.9155" />
<circle
className="totp-ring-progress"
cx="18"
cy="18"
r="15.9155"
style={{
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
strokeDashoffset: String(
TOTP_RING_CIRCUMFERENCE -
TOTP_RING_CIRCUMFERENCE *
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.totpLive?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
),
}}
/>
</svg>
<span className="totp-timer-value">{props.totpLive ? props.totpLive.remain : 0}</span>
</div>
</div>
</div>
<div className="kv-actions">
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.totpLive?.code || '')}>
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
</button>
</div>
</div>
)}
{!!props.passkeyCreatedAt && (
<div className="kv-row">
<span className="kv-label">{t('txt_passkey')}</span>
<div className="kv-main">
<strong>{t('txt_passkey_created_at_value', { value: formatHistoryTime(props.passkeyCreatedAt) })}</strong>
</div>
<div className="kv-actions" />
</div>
)}
</div>
)}
{(props.selectedCipher.login?.uris || []).length > 0 && (
<div className="card">
<h4>{t('txt_autofill_options')}</h4>
{(props.selectedCipher.login?.uris || []).map((uri, index) => {
const value = uri.decUri || uri.uri || '';
if (!value.trim()) return null;
return (
<div key={`view-uri-${index}`} className="kv-row">
<span className="kv-label">{t('txt_website')}</span>
<div className="kv-main">
<strong className="value-ellipsis" title={value}>{value}</strong>
</div>
<div className="kv-actions">
<button type="button" className="btn btn-secondary small" onClick={() => openUri(value)}>
<ExternalLink size={14} className="btn-icon" /> {t('txt_open')}
</button>
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(value)}>
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
</button>
</div>
</div>
);
})}
</div>
)}
{props.selectedCipher.card && (
<div className="card">
<h4>{t('txt_card_details')}</h4>
<div className="kv-line"><span>{t('txt_cardholder_name')}</span><strong>{props.selectedCipher.card.decCardholderName || ''}</strong></div>
<div className="kv-line"><span>{t('txt_number')}</span><strong>{props.selectedCipher.card.decNumber || ''}</strong></div>
<div className="kv-line"><span>{t('txt_brand')}</span><strong>{props.selectedCipher.card.decBrand || ''}</strong></div>
<div className="kv-line"><span>{t('txt_expiry')}</span><strong>{`${props.selectedCipher.card.decExpMonth || ''}/${props.selectedCipher.card.decExpYear || ''}`}</strong></div>
<div className="kv-line"><span>{t('txt_security_code')}</span><strong>{props.selectedCipher.card.decCode || ''}</strong></div>
</div>
)}
{props.selectedCipher.identity && (
<div className="card">
<h4>{t('txt_identity_details')}</h4>
<div className="kv-line"><span>{t('txt_name')}</span><strong>{`${props.selectedCipher.identity.decFirstName || ''} ${props.selectedCipher.identity.decLastName || ''}`.trim()}</strong></div>
<div className="kv-line"><span>{t('txt_username')}</span><strong>{props.selectedCipher.identity.decUsername || ''}</strong></div>
<div className="kv-line"><span>{t('txt_email')}</span><strong>{props.selectedCipher.identity.decEmail || ''}</strong></div>
<div className="kv-line"><span>{t('txt_phone')}</span><strong>{props.selectedCipher.identity.decPhone || ''}</strong></div>
<div className="kv-line"><span>{t('txt_company')}</span><strong>{props.selectedCipher.identity.decCompany || ''}</strong></div>
<div className="kv-line"><span>{t('txt_address')}</span><strong>{[props.selectedCipher.identity.decAddress1, props.selectedCipher.identity.decAddress2, props.selectedCipher.identity.decAddress3, props.selectedCipher.identity.decCity, props.selectedCipher.identity.decState, props.selectedCipher.identity.decPostalCode, props.selectedCipher.identity.decCountry].filter(Boolean).join(', ')}</strong></div>
</div>
)}
{props.selectedCipher.sshKey && (
<div className="card">
<h4>{t('txt_ssh_key')}</h4>
<div className="kv-row">
<span className="kv-label">{t('txt_private_key')}</span>
<div className="kv-main">
<strong
className="value-ellipsis"
title={showSshPrivateKey ? props.selectedCipher.sshKey.decPrivateKey || '' : maskSecret(props.selectedCipher.sshKey.decPrivateKey || '')}
>
{showSshPrivateKey ? props.selectedCipher.sshKey.decPrivateKey || '' : maskSecret(props.selectedCipher.sshKey.decPrivateKey || '')}
</strong>
</div>
<div className="kv-actions">
<button type="button" className="btn btn-secondary small" onClick={() => setShowSshPrivateKey((value) => !value)}>
{showSshPrivateKey ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
{showSshPrivateKey ? t('txt_hide') : t('txt_reveal')}
</button>
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.selectedCipher.sshKey?.decPrivateKey || '')}>
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
</button>
</div>
</div>
<div className="kv-row">
<span className="kv-label">{t('txt_public_key')}</span>
<div className="kv-main">
<strong className="value-ellipsis" title={props.selectedCipher.sshKey.decPublicKey || ''}>
{props.selectedCipher.sshKey.decPublicKey || ''}
</strong>
</div>
<div className="kv-actions">
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.selectedCipher.sshKey?.decPublicKey || '')}>
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
</button>
</div>
</div>
<div className="kv-row">
<span className="kv-label">{t('txt_fingerprint')}</span>
<div className="kv-main">
<strong className="value-ellipsis" title={props.selectedCipher.sshKey.decFingerprint || ''}>
{props.selectedCipher.sshKey.decFingerprint || ''}
</strong>
</div>
<div className="kv-actions">
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.selectedCipher.sshKey?.decFingerprint || '')}>
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
</button>
</div>
</div>
</div>
)}
{!!(props.selectedCipher.decNotes || '').trim() && (
<div className="card">
<h4>{t('txt_notes')}</h4>
<div className="notes">{props.selectedCipher.decNotes || ''}</div>
</div>
)}
{(props.selectedCipher.fields || []).some((x) => parseFieldType(x.type) !== 3) && (
<div className="card">
<h4>{t('txt_custom_fields')}</h4>
{(props.selectedCipher.fields || [])
.filter((x) => parseFieldType(x.type) !== 3)
.map((field, index) => {
const fieldType = parseFieldType(field.type);
const fieldName = field.decName || t('txt_field');
const rawValue = field.decValue || '';
const isHiddenVisible = !!props.hiddenFieldVisibleMap[index];
if (fieldType === 2) {
const checked = toBooleanFieldValue(rawValue);
return (
<div key={`view-field-${index}`} className="custom-field-card">
<div className="custom-field-label">{fieldName}</div>
<div className="custom-field-body">
<div className="custom-field-value">
<label className="check-line cf-check view custom-field-check">
<input type="checkbox" checked={checked} disabled />
<span className="boolean-text value-ellipsis" title={checked ? t('txt_checked') : t('txt_unchecked')}>
{checked ? t('txt_checked') : t('txt_unchecked')}
</span>
</label>
</div>
<div className="kv-actions">
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(rawValue)}>
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
</button>
</div>
</div>
</div>
);
}
return (
<div key={`view-field-${index}`} className="custom-field-card">
<div className="custom-field-label" title={fieldName}>{fieldName}</div>
<div className="custom-field-body">
<div className="custom-field-value">
<strong className="value-ellipsis" title={fieldType === 1 && !isHiddenVisible ? '' : rawValue}>
{fieldType === 1 && !isHiddenVisible ? maskSecret(rawValue) : rawValue}
</strong>
</div>
<div className="kv-actions">
{fieldType === 1 && (
<button type="button" className="btn btn-secondary small" onClick={() => props.onToggleHiddenField(index)}>
{isHiddenVisible ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
{isHiddenVisible ? t('txt_hide') : t('txt_reveal')}
</button>
)}
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(rawValue)}>
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
</button>
</div>
</div>
</div>
);
})}
</div>
)}
{selectedAttachments.some((attachment) => String(attachment?.id || '').trim()) && (
<div className="card">
<h4>{t('txt_attachments')}</h4>
<div className="attachment-list">
{selectedAttachments.map((attachment) => {
const attachmentId = String(attachment?.id || '').trim();
if (!attachmentId) return null;
const fileName = String(attachment.decFileName || attachment.fileName || attachmentId).trim() || attachmentId;
return (
<div key={`view-attachment-${attachmentId}`} className="attachment-row">
<div className="attachment-main">
<Paperclip size={14} />
<div className="attachment-text">
<strong className="value-ellipsis" title={fileName}>{fileName}</strong>
<span>{formatAttachmentSize(attachment)}</span>
</div>
</div>
<div className="kv-actions">
<button
type="button"
className="btn btn-secondary small"
disabled={props.downloadingAttachmentKey === `${props.selectedCipher.id}:${attachmentId}`}
onClick={() => props.onDownloadAttachment(props.selectedCipher, attachmentId)}
>
<Download size={14} className="btn-icon" /> {formatDownloadLabel(attachmentId)}
</button>
</div>
</div>
);
})}
</div>
</div>
)}
{(props.selectedCipher.creationDate || props.selectedCipher.revisionDate) && (
<div className="card">
<h4>{t('txt_item_history')}</h4>
<div className="detail-sub">{t('txt_last_edited_value', { value: formatHistoryTime(props.selectedCipher.revisionDate) })}</div>
<div className="detail-sub">{t('txt_created_value', { value: formatHistoryTime(props.selectedCipher.creationDate) })}</div>
</div>
)}
<div className="detail-actions">
<div className="actions">
<button type="button" className="btn btn-secondary" onClick={props.onStartEdit}>
<Pencil size={14} className="btn-icon" /> {t('txt_edit')}
</button>
{isArchived ? (
<button type="button" className="btn btn-secondary" onClick={() => void props.onUnarchive(props.selectedCipher)}>
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
</button>
) : (
<button type="button" className="btn btn-secondary" onClick={() => void props.onArchive(props.selectedCipher)}>
<Archive size={14} className="btn-icon" /> {t('txt_archive')}
</button>
)}
</div>
<button type="button" className="btn btn-danger" onClick={() => props.onDelete(props.selectedCipher)}>
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
</button>
</div>
</>
)}
</>
);
}
@@ -0,0 +1,174 @@
import ConfirmDialog from '@/components/ConfirmDialog';
import type { CustomFieldType, Folder } from '@/lib/types';
import { FIELD_TYPE_OPTIONS, toBooleanFieldValue } from '@/components/vault/vault-page-helpers';
import { t } from '@/lib/i18n';
interface VaultDialogsProps {
fieldModalOpen: boolean;
fieldType: CustomFieldType;
fieldLabel: string;
fieldValue: string;
archiveConfirmOpen: boolean;
bulkArchiveOpen: boolean;
pendingDeleteOpen: boolean;
bulkDeleteOpen: boolean;
sidebarTrashMode: boolean;
selectedCount: number;
moveOpen: boolean;
moveFolderId: string;
folders: Folder[];
createFolderOpen: boolean;
newFolderName: string;
pendingDeleteFolder: Folder | null;
deleteAllFoldersOpen: boolean;
repromptOpen: boolean;
repromptPassword: string;
onConfirmAddField: () => void;
onCancelFieldModal: () => void;
onFieldTypeChange: (value: CustomFieldType) => void;
onFieldLabelChange: (value: string) => void;
onFieldValueChange: (value: string) => void;
onConfirmArchive: () => void;
onCancelArchive: () => void;
onConfirmBulkArchive: () => void;
onCancelBulkArchive: () => void;
onConfirmDelete: () => void;
onCancelDelete: () => void;
onConfirmBulkDelete: () => void;
onCancelBulkDelete: () => void;
onConfirmMove: () => void;
onCancelMove: () => void;
onMoveFolderIdChange: (value: string) => void;
onConfirmCreateFolder: () => void;
onCancelCreateFolder: () => void;
onNewFolderNameChange: (value: string) => void;
onConfirmDeleteFolder: () => void;
onCancelDeleteFolder: () => void;
onConfirmDeleteAllFolders: () => void;
onCancelDeleteAllFolders: () => void;
onConfirmReprompt: () => void;
onCancelReprompt: () => void;
onRepromptPasswordChange: (value: string) => void;
}
export default function VaultDialogs(props: VaultDialogsProps) {
return (
<>
<ConfirmDialog
open={props.fieldModalOpen}
title={t('txt_add_field')}
message={t('txt_configure_custom_field_values')}
confirmText={t('txt_add')}
cancelText={t('txt_cancel')}
onConfirm={props.onConfirmAddField}
onCancel={props.onCancelFieldModal}
>
<label className="field">
<span>{t('txt_field_type')}</span>
<select className="input" value={props.fieldType} onInput={(e) => props.onFieldTypeChange(Number((e.currentTarget as HTMLSelectElement).value) as CustomFieldType)}>
{FIELD_TYPE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="field">
<span>{t('txt_field_label')}</span>
<input className="input" value={props.fieldLabel} onInput={(e) => props.onFieldLabelChange((e.currentTarget as HTMLInputElement).value)} />
</label>
{props.fieldType === 2 ? (
<label className="check-line">
<input
type="checkbox"
checked={toBooleanFieldValue(props.fieldValue)}
onInput={(e) => props.onFieldValueChange((e.currentTarget as HTMLInputElement).checked ? 'true' : 'false')}
/>
{t('txt_enabled')}
</label>
) : (
<label className="field">
<span>{t('txt_field_value')}</span>
<input className="input" value={props.fieldValue} onInput={(e) => props.onFieldValueChange((e.currentTarget as HTMLInputElement).value)} />
</label>
)}
</ConfirmDialog>
<ConfirmDialog
open={props.archiveConfirmOpen}
title={t('txt_archive_item')}
message={t('txt_archive_item_message')}
confirmText={t('txt_archive')}
cancelText={t('txt_cancel')}
onConfirm={props.onConfirmArchive}
onCancel={props.onCancelArchive}
/>
<ConfirmDialog
open={props.bulkArchiveOpen}
title={t('txt_archive_selected_items')}
message={t('txt_archive_selected_items_message', { count: props.selectedCount })}
confirmText={t('txt_archive')}
cancelText={t('txt_cancel')}
onConfirm={props.onConfirmBulkArchive}
onCancel={props.onCancelBulkArchive}
/>
<ConfirmDialog open={props.pendingDeleteOpen} title={t('txt_delete_item')} message={t('txt_are_you_sure_you_want_to_delete_this_item')} danger onConfirm={props.onConfirmDelete} onCancel={props.onCancelDelete} />
<ConfirmDialog
open={props.bulkDeleteOpen}
title={props.sidebarTrashMode ? t('txt_delete_selected_items_permanently') : t('txt_delete_selected_items')}
message={
props.sidebarTrashMode
? t('txt_are_you_sure_you_want_to_delete_count_selected_items_permanently', { count: props.selectedCount })
: t('txt_are_you_sure_you_want_to_delete_count_selected_items', { count: props.selectedCount })
}
danger
onConfirm={props.onConfirmBulkDelete}
onCancel={props.onCancelBulkDelete}
/>
<ConfirmDialog open={props.moveOpen} title={t('txt_move_selected_items')} message={t('txt_choose_destination_folder')} confirmText={t('txt_move')} cancelText={t('txt_cancel')} onConfirm={props.onConfirmMove} onCancel={props.onCancelMove}>
<label className="field">
<span>{t('txt_folder')}</span>
<select className="input" value={props.moveFolderId} onInput={(e) => props.onMoveFolderIdChange((e.currentTarget as HTMLSelectElement).value)}>
<option value="__none__">{t('txt_no_folder')}</option>
{props.folders.map((folder) => (
<option key={folder.id} value={folder.id}>
{folder.decName || folder.name || folder.id}
</option>
))}
</select>
</label>
</ConfirmDialog>
<ConfirmDialog open={props.createFolderOpen} title={t('txt_create_folder')} message={t('txt_enter_a_folder_name')} confirmText={t('txt_create')} cancelText={t('txt_cancel')} onConfirm={props.onConfirmCreateFolder} onCancel={props.onCancelCreateFolder}>
<label className="field">
<span>{t('txt_folder_name')}</span>
<input className="input" value={props.newFolderName} onInput={(e) => props.onNewFolderNameChange((e.currentTarget as HTMLInputElement).value)} />
</label>
</ConfirmDialog>
<ConfirmDialog
open={!!props.pendingDeleteFolder}
title={t('txt_delete_folder')}
message={t('txt_delete_folder_message', { name: props.pendingDeleteFolder?.decName || props.pendingDeleteFolder?.name || props.pendingDeleteFolder?.id || '' })}
confirmText={t('txt_delete')}
cancelText={t('txt_cancel')}
danger
onConfirm={props.onConfirmDeleteFolder}
onCancel={props.onCancelDeleteFolder}
/>
<ConfirmDialog open={props.deleteAllFoldersOpen} title={t('txt_delete_all_folders')} message={t('txt_delete_all_folders_message')} confirmText={t('txt_delete')} cancelText={t('txt_cancel')} danger onConfirm={props.onConfirmDeleteAllFolders} onCancel={props.onCancelDeleteAllFolders} />
<ConfirmDialog open={props.repromptOpen} title={t('txt_unlock_item')} message={t('txt_enter_master_password_to_view_this_item')} confirmText={t('txt_unlock')} cancelText={t('txt_cancel')} showIcon={false} onConfirm={props.onConfirmReprompt} onCancel={props.onCancelReprompt}>
<label className="field">
<span>{t('txt_master_password')}</span>
<input className="input" type="password" value={props.repromptPassword} onInput={(e) => props.onRepromptPasswordChange((e.currentTarget as HTMLInputElement).value)} />
</label>
</ConfirmDialog>
</>
);
}
+528
View File
@@ -0,0 +1,528 @@
import type { RefObject } from 'preact';
import { CheckCheck, Download, GripVertical, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import {
closestCenter,
DndContext,
type DragEndEvent,
type DragStartEvent,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
import { t } from '@/lib/i18n';
import { CREATE_TYPE_OPTIONS, cipherTypeLabel, createEmptyLoginUri, formatAttachmentSize, toBooleanFieldValue, WEBSITE_MATCH_OPTIONS } from '@/components/vault/vault-page-helpers';
interface VaultEditorProps {
draft: VaultDraft;
isCreating: boolean;
busy: boolean;
folders: Folder[];
selectedCipher: Cipher | null;
editExistingAttachments: Array<any>;
removedAttachmentIds: Record<string, boolean>;
removedAttachmentCount: number;
attachmentQueue: File[];
attachmentInputRef: RefObject<HTMLInputElement>;
localError: string;
downloadingAttachmentKey: string;
attachmentDownloadPercent: number | null;
uploadingAttachmentName: string;
attachmentUploadPercent: number | null;
onUpdateDraft: (patch: Partial<VaultDraft>) => void;
onSeedSshDefaults: (force?: boolean) => void;
onUpdateSshPublicKey: (value: string) => void;
onUpdateDraftLoginUri: (index: number, value: string) => void;
onUpdateDraftLoginUriMatch: (index: number, value: number | null) => void;
onReorderDraftLoginUri: (fromIndex: number, toIndex: number) => void;
onQueueAttachmentFiles: (list: FileList | null) => void;
onToggleExistingAttachmentRemoval: (attachmentId: string) => void;
onRemoveQueuedAttachment: (index: number) => void;
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => void;
onPatchDraftCustomField: (index: number, patch: Partial<VaultDraftField>) => void;
onUpdateDraftCustomFields: (fields: VaultDraftField[]) => void;
onOpenFieldModal: () => void;
onSave: () => void;
onCancel: () => void;
onDeleteSelected: () => void;
}
interface SortableWebsiteRowProps {
id: string;
uriEntry: VaultDraft['loginUris'][number];
index: number;
canRemove: boolean;
isDragging: boolean;
onUpdateUri: (index: number, value: string) => void;
onUpdateMatch: (index: number, value: number | null) => void;
onRemove: (index: number) => void;
}
function SortableWebsiteRow(props: SortableWebsiteRowProps) {
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={`website-row${isDragging || props.isDragging ? ' is-dragging' : ''}`}
>
<button
type="button"
ref={setActivatorNodeRef}
className="btn btn-secondary small website-drag-btn"
title={t('txt_drag_to_reorder')}
aria-label={t('txt_drag_to_reorder')}
{...attributes}
{...listeners}
>
<GripVertical size={14} className="btn-icon" />
</button>
<input
className="input"
value={props.uriEntry.uri}
onInput={(e) => props.onUpdateUri(props.index, (e.currentTarget as HTMLInputElement).value)}
/>
<select
className="input website-match-select"
value={props.uriEntry.match == null ? '' : String(props.uriEntry.match)}
onInput={(e) => {
const raw = (e.currentTarget as HTMLSelectElement).value;
props.onUpdateMatch(props.index, raw === '' ? null : Number(raw));
}}
>
{WEBSITE_MATCH_OPTIONS.map((option) => (
<option key={`website-match-${String(option.value)}`} value={option.value == null ? '' : String(option.value)}>
{option.label}
</option>
))}
</select>
{props.canRemove && (
<button type="button" className="btn btn-secondary small" onClick={() => props.onRemove(props.index)}>
<X size={14} className="btn-icon" />
{t('txt_remove')}
</button>
)}
</div>
);
}
export default function VaultEditor(props: VaultEditorProps) {
const uriIdSeedRef = useRef(0);
const [uriItemIds, setUriItemIds] = useState<string[]>([]);
const [activeUriId, setActiveUriId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 6,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 120,
tolerance: 8,
},
}),
);
const createUriId = () => `login-uri-${uriIdSeedRef.current++}`;
useEffect(() => {
setUriItemIds((prev) => {
if (prev.length === props.draft.loginUris.length) return prev;
if (prev.length < props.draft.loginUris.length) {
return [...prev, ...Array.from({ length: props.draft.loginUris.length - prev.length }, () => createUriId())];
}
return prev.slice(0, props.draft.loginUris.length);
});
}, [props.draft.loginUris.length]);
useEffect(() => {
setUriItemIds(props.draft.loginUris.map(() => createUriId()));
setActiveUriId(null);
}, [props.draft.id, props.isCreating]);
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 });
};
const uploadLabel =
props.attachmentUploadPercent == null
? t('txt_uploading_attachment_named', { name: props.uploadingAttachmentName || t('txt_attachment') })
: t('txt_uploading_attachment_named_percent', {
name: props.uploadingAttachmentName || t('txt_attachment'),
percent: props.attachmentUploadPercent,
});
const addLoginUri = () => {
setUriItemIds((prev) => [...prev, createUriId()]);
props.onUpdateDraft({ loginUris: [...props.draft.loginUris, createEmptyLoginUri()] });
};
const removeLoginUri = (index: number) => {
setUriItemIds((prev) => prev.filter((_, itemIndex) => itemIndex !== index));
props.onUpdateDraft({ loginUris: props.draft.loginUris.filter((_, itemIndex) => itemIndex !== index) });
};
const handleWebsiteDragStart = (event: DragStartEvent) => {
setActiveUriId(String(event.active.id));
};
const handleWebsiteDragEnd = (event: DragEndEvent) => {
const activeId = String(event.active.id);
const overId = event.over ? String(event.over.id) : null;
setActiveUriId(null);
if (!overId || activeId === overId) return;
const fromIndex = uriItemIds.indexOf(activeId);
const toIndex = uriItemIds.indexOf(overId);
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return;
setUriItemIds((prev) => arrayMove(prev, fromIndex, toIndex));
props.onReorderDraftLoginUri(fromIndex, toIndex);
};
return (
<>
<div className="card">
<div className="section-head">
<h3 className="detail-title">{props.isCreating ? t('txt_new_type_header', { type: cipherTypeLabel(props.draft.type) }) : t('txt_edit_type_header', { type: cipherTypeLabel(props.draft.type) })}</h3>
<button type="button" className={`btn btn-secondary small ${props.draft.favorite ? 'star-on' : ''}`} onClick={() => props.onUpdateDraft({ favorite: !props.draft.favorite })}>
{props.draft.favorite ? <Star size={14} className="btn-icon" /> : <StarOff size={14} className="btn-icon" />}
{t('txt_favorite')}
</button>
</div>
<div className="field-grid">
<label className="field">
<span>{t('txt_type')}</span>
<select
className="input"
value={props.draft.type}
disabled={!props.isCreating}
onInput={(e) => {
const nextType = Number((e.currentTarget as HTMLSelectElement).value);
props.onUpdateDraft({ type: nextType });
if (nextType === 5) props.onSeedSshDefaults();
}}
>
{CREATE_TYPE_OPTIONS.map((option) => (
<option key={option.type} value={option.type}>
{option.label}
</option>
))}
</select>
</label>
<label className="field">
<span>{t('txt_folder')}</span>
<select className="input" value={props.draft.folderId} onInput={(e) => props.onUpdateDraft({ folderId: (e.currentTarget as HTMLSelectElement).value })}>
<option value="">{t('txt_no_folder')}</option>
{props.folders.map((folder) => (
<option key={folder.id} value={folder.id}>
{folder.decName || folder.name || folder.id}
</option>
))}
</select>
</label>
</div>
<label className="field">
<span>{t('txt_name')}</span>
<input className="input" value={props.draft.name} onInput={(e) => props.onUpdateDraft({ name: (e.currentTarget as HTMLInputElement).value })} />
</label>
</div>
{props.draft.type === 1 && (
<div className="card">
<h4>{t('txt_login_credentials')}</h4>
<div className="field-grid">
<label className="field">
<span>{t('txt_username')}</span>
<input className="input" value={props.draft.loginUsername} onInput={(e) => props.onUpdateDraft({ loginUsername: (e.currentTarget as HTMLInputElement).value })} />
</label>
<label className="field">
<span>{t('txt_password')}</span>
<input className="input" value={props.draft.loginPassword} onInput={(e) => props.onUpdateDraft({ loginPassword: (e.currentTarget as HTMLInputElement).value })} />
</label>
</div>
<label className="field">
<span>{t('txt_totp_secret')}</span>
<input className="input" value={props.draft.loginTotp} onInput={(e) => props.onUpdateDraft({ loginTotp: (e.currentTarget as HTMLInputElement).value })} />
</label>
<div className="section-head">
<h4>{t('txt_websites')}</h4>
<button type="button" className="btn btn-secondary small" onClick={addLoginUri}>
<Plus size={14} className="btn-icon" /> {t('txt_add_website')}
</button>
</div>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragStart={handleWebsiteDragStart} onDragEnd={handleWebsiteDragEnd}>
<SortableContext items={uriItemIds} strategy={verticalListSortingStrategy}>
{props.draft.loginUris.map((uriEntry, index) => (
<SortableWebsiteRow
key={uriItemIds[index] ?? `uri-${index}`}
id={uriItemIds[index] ?? `uri-fallback-${index}`}
uriEntry={uriEntry}
index={index}
canRemove={props.draft.loginUris.length > 1}
isDragging={activeUriId === uriItemIds[index]}
onUpdateUri={props.onUpdateDraftLoginUri}
onUpdateMatch={props.onUpdateDraftLoginUriMatch}
onRemove={removeLoginUri}
/>
))}
</SortableContext>
</DndContext>
</div>
)}
{props.draft.type === 3 && (
<div className="card">
<h4>{t('txt_card_details')}</h4>
<div className="field-grid">
<label className="field"><span>{t('txt_cardholder_name')}</span><input className="input" value={props.draft.cardholderName} onInput={(e) => props.onUpdateDraft({ cardholderName: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_number')}</span><input className="input" value={props.draft.cardNumber} onInput={(e) => props.onUpdateDraft({ cardNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_brand')}</span><input className="input" value={props.draft.cardBrand} onInput={(e) => props.onUpdateDraft({ cardBrand: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_security_code_cvv')}</span><input className="input" value={props.draft.cardCode} onInput={(e) => props.onUpdateDraft({ cardCode: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_expiry_month')}</span><input className="input" value={props.draft.cardExpMonth} onInput={(e) => props.onUpdateDraft({ cardExpMonth: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_expiry_year')}</span><input className="input" value={props.draft.cardExpYear} onInput={(e) => props.onUpdateDraft({ cardExpYear: (e.currentTarget as HTMLInputElement).value })} /></label>
</div>
</div>
)}
{props.draft.type === 4 && (
<div className="card">
<h4>{t('txt_identity_details')}</h4>
<div className="field-grid">
<label className="field"><span>{t('txt_title')}</span><input className="input" value={props.draft.identTitle} onInput={(e) => props.onUpdateDraft({ identTitle: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_first_name')}</span><input className="input" value={props.draft.identFirstName} onInput={(e) => props.onUpdateDraft({ identFirstName: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_middle_name')}</span><input className="input" value={props.draft.identMiddleName} onInput={(e) => props.onUpdateDraft({ identMiddleName: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_last_name')}</span><input className="input" value={props.draft.identLastName} onInput={(e) => props.onUpdateDraft({ identLastName: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_username')}</span><input className="input" value={props.draft.identUsername} onInput={(e) => props.onUpdateDraft({ identUsername: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_company')}</span><input className="input" value={props.draft.identCompany} onInput={(e) => props.onUpdateDraft({ identCompany: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_ssn')}</span><input className="input" value={props.draft.identSsn} onInput={(e) => props.onUpdateDraft({ identSsn: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_passport_number')}</span><input className="input" value={props.draft.identPassportNumber} onInput={(e) => props.onUpdateDraft({ identPassportNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_license_number')}</span><input className="input" value={props.draft.identLicenseNumber} onInput={(e) => props.onUpdateDraft({ identLicenseNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_email')}</span><input className="input" value={props.draft.identEmail} onInput={(e) => props.onUpdateDraft({ identEmail: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_phone')}</span><input className="input" value={props.draft.identPhone} onInput={(e) => props.onUpdateDraft({ identPhone: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_address_1')}</span><input className="input" value={props.draft.identAddress1} onInput={(e) => props.onUpdateDraft({ identAddress1: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_address_2')}</span><input className="input" value={props.draft.identAddress2} onInput={(e) => props.onUpdateDraft({ identAddress2: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_address_3')}</span><input className="input" value={props.draft.identAddress3} onInput={(e) => props.onUpdateDraft({ identAddress3: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_city_town')}</span><input className="input" value={props.draft.identCity} onInput={(e) => props.onUpdateDraft({ identCity: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_state_province')}</span><input className="input" value={props.draft.identState} onInput={(e) => props.onUpdateDraft({ identState: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_postal_code')}</span><input className="input" value={props.draft.identPostalCode} onInput={(e) => props.onUpdateDraft({ identPostalCode: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_country')}</span><input className="input" value={props.draft.identCountry} onInput={(e) => props.onUpdateDraft({ identCountry: (e.currentTarget as HTMLInputElement).value })} /></label>
</div>
</div>
)}
{props.draft.type === 5 && (
<div className="card">
<div className="section-head">
<h4>{t('txt_ssh_key')}</h4>
<button
type="button"
className="btn btn-secondary small"
disabled={!props.isCreating}
onClick={() => props.onSeedSshDefaults(true)}
>
<RefreshCw size={14} className="btn-icon" /> {t('txt_regenerate')}
</button>
</div>
<label className="field">
<span>{t('txt_private_key')}</span>
<textarea
className="input textarea"
value={props.draft.sshPrivateKey}
disabled={!props.isCreating}
onInput={(e) => props.onUpdateDraft({ sshPrivateKey: (e.currentTarget as HTMLTextAreaElement).value })}
/>
</label>
<label className="field">
<span>{t('txt_public_key')}</span>
<textarea
className="input textarea"
value={props.draft.sshPublicKey}
disabled={!props.isCreating}
onInput={(e) => props.onUpdateSshPublicKey((e.currentTarget as HTMLTextAreaElement).value)}
/>
</label>
<label className="field">
<span>{t('txt_fingerprint')}</span>
<input className="input input-readonly" value={props.draft.sshFingerprint} readOnly />
</label>
</div>
)}
<div className="card">
<div className="section-head attachment-head">
<h4>{t('txt_attachments')}</h4>
<button
type="button"
className="btn btn-secondary small attachment-add-btn"
disabled={props.busy}
onClick={() => props.attachmentInputRef.current?.click()}
title={t('txt_upload_attachments')}
aria-label={t('txt_upload_attachments')}
>
<Plus size={14} className="btn-icon" />
</button>
</div>
{!!props.uploadingAttachmentName && <div className="detail-sub">{uploadLabel}</div>}
{!props.isCreating && props.selectedCipher && props.editExistingAttachments.length > 0 && (
<div className="attachment-list">
{props.editExistingAttachments.map((attachment) => {
const attachmentId = String(attachment?.id || '').trim();
if (!attachmentId) return null;
const removed = !!props.removedAttachmentIds[attachmentId];
const fileName = String(attachment.decFileName || attachment.fileName || attachmentId).trim() || attachmentId;
return (
<div key={`edit-attachment-${attachmentId}`} className={`attachment-row ${removed ? 'is-removed' : ''}`}>
<div className="attachment-main">
<Paperclip size={14} />
<div className="attachment-text">
<strong className="value-ellipsis" title={fileName}>{fileName}</strong>
<span>{formatAttachmentSize(attachment)}</span>
</div>
</div>
<div className="kv-actions">
<button
type="button"
className="btn btn-secondary small"
disabled={props.busy || removed || props.downloadingAttachmentKey === `${props.selectedCipher?.id || ''}:${attachmentId}`}
onClick={() => props.onDownloadAttachment(props.selectedCipher as Cipher, attachmentId)}
>
<Download size={14} className="btn-icon" /> {formatDownloadLabel(attachmentId)}
</button>
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={() => props.onToggleExistingAttachmentRemoval(attachmentId)}>
<X size={14} className="btn-icon" />
{removed ? t('txt_cancel') : t('txt_remove')}
</button>
</div>
</div>
);
})}
</div>
)}
{!!props.removedAttachmentCount && <div className="detail-sub">{t('txt_marked_for_removal_count', { count: props.removedAttachmentCount })}</div>}
<input
ref={props.attachmentInputRef}
type="file"
className="attachment-file-input"
multiple
disabled={props.busy}
onChange={(e) => {
const input = e.currentTarget as HTMLInputElement;
props.onQueueAttachmentFiles(input.files);
input.value = '';
}}
/>
{!!props.attachmentQueue.length && (
<div className="attachment-list">
<div className="attachment-queue-title">{t('txt_new_attachments')}</div>
{props.attachmentQueue.map((file, index) => (
<div key={`queued-attachment-${index}-${file.name}`} className="attachment-row">
<div className="attachment-main">
<Upload size={14} />
<div className="attachment-text">
<strong className="value-ellipsis" title={file.name}>{file.name}</strong>
<span>{formatAttachmentSize({ size: file.size })}</span>
</div>
</div>
<div className="kv-actions">
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={() => props.onRemoveQueuedAttachment(index)}>
<X size={14} className="btn-icon" />
{t('txt_remove')}
</button>
</div>
</div>
))}
</div>
)}
</div>
<div className="card">
<h4>{t('txt_additional_options')}</h4>
<label className="field">
<span>{t('txt_notes')}</span>
<textarea className="input textarea" value={props.draft.notes} onInput={(e) => props.onUpdateDraft({ notes: (e.currentTarget as HTMLTextAreaElement).value })} />
</label>
<label className="check-line">
<input type="checkbox" checked={props.draft.reprompt} onInput={(e) => props.onUpdateDraft({ reprompt: (e.currentTarget as HTMLInputElement).checked })} />
{t('txt_master_password_reprompt')}
</label>
<div className="section-head">
<h4>{t('txt_custom_fields')}</h4>
<button type="button" className="btn btn-secondary small" onClick={props.onOpenFieldModal}>
<Plus size={14} className="btn-icon" /> {t('txt_add_field')}
</button>
</div>
{props.draft.customFields
.map((field, originalIndex) => ({ field, originalIndex }))
.filter((entry) => entry.field.type !== 3)
.map(({ field, originalIndex }) => (
<div key={`field-${originalIndex}`} className="custom-field-card">
<label className="field custom-field-label">
<span>{t('txt_field_label')}</span>
<input className="input" value={field.label} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { label: (e.currentTarget as HTMLInputElement).value })} />
</label>
<div className="custom-field-body">
<div className="custom-field-value">
{field.type === 2 ? (
<label className="check-line cf-check custom-field-check">
<input
type="checkbox"
checked={toBooleanFieldValue(field.value)}
onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).checked ? 'true' : 'false' })}
/>
<span>{toBooleanFieldValue(field.value) ? t('txt_checked') : t('txt_unchecked')}</span>
</label>
) : (
<input className="input" value={field.value} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).value })} />
)}
</div>
<button type="button" className="btn btn-secondary small custom-field-remove" onClick={() => props.onUpdateDraftCustomFields(props.draft.customFields.filter((_, i) => i !== originalIndex))}>
<X size={14} className="btn-icon" />
{t('txt_remove')}
</button>
</div>
</div>
))}
</div>
<div className="detail-actions">
<div className="actions">
<button type="button" className="btn btn-primary" disabled={props.busy} onClick={props.onSave}>
<CheckCheck size={14} className="btn-icon" />
{t('txt_confirm')}
</button>
<button type="button" className="btn btn-secondary" disabled={props.busy} onClick={props.onCancel}>
<X size={14} className="btn-icon" />
{t('txt_cancel')}
</button>
</div>
{!props.isCreating && props.selectedCipher && (
<button type="button" className="btn btn-danger" disabled={props.busy} onClick={props.onDeleteSelected}>
<Trash2 size={14} className="btn-icon" />
{t('txt_delete')}
</button>
)}
</div>
{props.localError && <div className="local-error">{props.localError}</div>}
</>
);
}
@@ -0,0 +1,206 @@
import type { RefObject } from 'preact';
import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact';
import type { Cipher } from '@/lib/types';
import { t } from '@/lib/i18n';
import {
CREATE_TYPE_OPTIONS,
CreateTypeIcon,
VAULT_SORT_OPTIONS,
VaultListIcon,
type SidebarFilter,
type VaultSortMode,
} from '@/components/vault/vault-page-helpers';
interface VirtualRange {
start: number;
end: number;
padTop: number;
padBottom: number;
}
interface VaultListPanelProps {
busy: boolean;
loading: boolean;
searchInput: string;
sortMode: VaultSortMode;
sortMenuOpen: boolean;
selectedCount: number;
totalCipherCount: number;
filteredCiphers: Cipher[];
visibleCiphers: Cipher[];
virtualRange: VirtualRange;
selectedCipherId: string;
selectedMap: Record<string, boolean>;
sidebarFilter: SidebarFilter;
createMenuOpen: boolean;
createMenuRef: RefObject<HTMLDivElement>;
sortMenuRef: RefObject<HTMLDivElement>;
listPanelRef: RefObject<HTMLDivElement>;
onSearchInput: (value: string) => void;
onSearchCompositionStart: () => void;
onSearchCompositionEnd: (value: string) => void;
onToggleSortMenu: () => void;
onSelectSortMode: (value: VaultSortMode) => void;
onSyncVault: () => void;
onOpenBulkDelete: () => void;
onSelectDuplicates: () => void;
onSelectAll: () => void;
onToggleCreateMenu: () => void;
onStartCreate: (type: number) => void;
onBulkRestore: () => void;
onBulkArchive: () => void;
onBulkUnarchive: () => void;
onOpenMove: () => void;
onClearSelection: () => void;
onScroll: (top: number) => void;
onToggleSelected: (cipherId: string, checked: boolean) => void;
onSelectCipher: (cipherId: string) => void;
listSubtitle: (cipher: Cipher) => string;
}
export default function VaultListPanel(props: VaultListPanelProps) {
return (
<section className="list-col">
<div className="list-head">
<input
className="search-input"
placeholder={t('txt_search_your_secure_vault')}
value={props.searchInput}
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
onCompositionStart={props.onSearchCompositionStart}
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
/>
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
<button
type="button"
className={`btn btn-secondary small sort-trigger ${props.sortMenuOpen ? 'active' : ''}`}
aria-label={t('txt_sort')}
title={t('txt_sort')}
onClick={props.onToggleSortMenu}
>
<ArrowUpDown size={14} className="btn-icon" />
</button>
{props.sortMenuOpen && (
<div className="sort-menu">
{VAULT_SORT_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className={`sort-menu-item ${props.sortMode === option.value ? 'active' : ''}`}
onClick={() => props.onSelectSortMode(option.value)}
>
<span>{option.label}</span>
{props.sortMode === option.value ? <Check size={14} /> : <span className="sort-menu-check-placeholder" />}
</button>
))}
</div>
)}
</div>
<div className="list-count" title={t('txt_total_items_count', { count: props.totalCipherCount })}>
{t('txt_total_items_count', { count: props.totalCipherCount })}
</div>
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={props.busy || props.loading} onClick={props.onSyncVault}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
</button>
</div>
<div className="toolbar actions">
{props.sidebarFilter.kind === 'duplicates' && (
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length || props.busy} onClick={props.onSelectDuplicates}>
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
</button>
)}
{props.selectedCount > 0 && props.sidebarFilter.kind === 'trash' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkRestore}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
</button>
)}
{props.selectedCount > 0 && props.sidebarFilter.kind === 'archive' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkUnarchive}>
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
</button>
)}
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkArchive}>
<Archive size={14} className="btn-icon" /> {t('txt_archive_selected')}
</button>
)}
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}>
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
</button>
)}
{props.selectedCount > 0 && (
<button type="button" className="btn btn-secondary small" onClick={props.onClearSelection}>
<X size={14} className="btn-icon" /> {t('txt_cancel')}
</button>
)}
<button type="button" className="btn btn-danger small" disabled={!props.selectedCount || props.busy} onClick={props.onOpenBulkDelete}>
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
</button>
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
</button>
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}>
<button
type="button"
className="btn btn-primary small mobile-fab-trigger"
aria-label={t('txt_add')}
title={t('txt_add')}
onClick={props.onToggleCreateMenu}
>
<Plus size={14} className="btn-icon" />
</button>
{props.createMenuOpen && (
<div className="create-menu">
{CREATE_TYPE_OPTIONS.map((option) => (
<button key={option.type} type="button" className="create-menu-item" onClick={() => props.onStartCreate(option.type)}>
<CreateTypeIcon type={option.type} />
<span>{option.label}</span>
</button>
))}
</div>
)}
</div>
</div>
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
{!!props.filteredCiphers.length && (
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
{props.visibleCiphers.map((cipher, index) => (
<div
key={cipher.id}
className={`list-item stagger-item ${props.selectedCipherId === cipher.id ? 'active' : ''}`}
style={{ animationDelay: `${Math.min(index, 10) * 26}ms` }}
onClick={(event) => {
const target = event.target as HTMLElement;
if (target.closest('.row-check')) return;
props.onSelectCipher(cipher.id);
}}
>
<input
type="checkbox"
className="row-check"
checked={!!props.selectedMap[cipher.id]}
onClick={(event) => event.stopPropagation()}
onInput={(e) => props.onToggleSelected(cipher.id, (e.currentTarget as HTMLInputElement).checked)}
/>
<button type="button" className="row-main" onClick={() => props.onSelectCipher(cipher.id)}>
<div className="list-icon-wrap">
<VaultListIcon cipher={cipher} />
</div>
<div className="list-text">
<span className="list-title" title={cipher.decName || t('txt_no_name')}>
<span className="list-title-text">{cipher.decName || t('txt_no_name')}</span>
</span>
<span className="list-sub" title={props.listSubtitle(cipher)}>{props.listSubtitle(cipher)}</span>
</div>
</button>
</div>
))}
</div>
)}
{!props.filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>}
</div>
</section>
);
}
@@ -0,0 +1,135 @@
import {
Archive,
Copy,
CreditCard,
Folder as FolderIcon,
FolderPlus,
FolderX,
Globe,
KeyRound,
LayoutGrid,
ShieldUser,
Star,
StickyNote,
Trash2,
X,
} from 'lucide-preact';
import type { Folder } from '@/lib/types';
import { t } from '@/lib/i18n';
import type { SidebarFilter } from '@/components/vault/vault-page-helpers';
interface VaultSidebarProps {
folders: Folder[];
sidebarFilter: SidebarFilter;
busy: boolean;
isMobileLayout: boolean;
mobileSidebarOpen: boolean;
onCloseMobileSidebar: () => void;
onChangeFilter: (filter: SidebarFilter) => void;
onOpenDeleteAllFolders: () => void;
onOpenCreateFolder: () => void;
onOpenDeleteFolder: (folder: Folder) => void;
}
export default function VaultSidebar(props: VaultSidebarProps) {
return (
<aside className={`sidebar ${props.isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${props.isMobileLayout && props.mobileSidebarOpen ? 'open' : ''}`}>
{props.isMobileLayout && (
<div className="mobile-sidebar-head">
<div className="mobile-sidebar-title">{t('txt_folders')}</div>
<button type="button" className="mobile-sidebar-close" onClick={props.onCloseMobileSidebar} aria-label={t('txt_close')}>
<X size={16} />
</button>
</div>
)}
<div className="sidebar-block">
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'all' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'all' })}>
<LayoutGrid size={14} className="tree-icon" /> <span className="tree-label">{t('txt_all_items')}</span>
</button>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'favorite' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'favorite' })}>
<Star size={14} className="tree-icon" /> <span className="tree-label">{t('txt_favorites')}</span>
</button>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'archive' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'archive' })}>
<Archive size={14} className="tree-icon" /> <span className="tree-label">{t('txt_archive')}</span>
</button>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'trash' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'trash' })}>
<Trash2 size={14} className="tree-icon" /> <span className="tree-label">{t('txt_trash')}</span>
</button>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'duplicates' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'duplicates' })}>
<Copy size={14} className="tree-icon" /> <span className="tree-label">{t('txt_duplicates')}</span>
</button>
</div>
<div className="sidebar-block">
<div className="sidebar-title">{t('txt_type')}</div>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'login' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'login' })}>
<Globe size={14} className="tree-icon" /> <span className="tree-label">{t('txt_login')}</span>
</button>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'card' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'card' })}>
<CreditCard size={14} className="tree-icon" /> <span className="tree-label">{t('txt_card')}</span>
</button>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'identity' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'identity' })}>
<ShieldUser size={14} className="tree-icon" /> <span className="tree-label">{t('txt_identity')}</span>
</button>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'note' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'note' })}>
<StickyNote size={14} className="tree-icon" /> <span className="tree-label">{t('txt_note')}</span>
</button>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'ssh' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'ssh' })}>
<KeyRound size={14} className="tree-icon" /> <span className="tree-label">{t('txt_ssh_key')}</span>
</button>
</div>
<div className="sidebar-block">
<div className="sidebar-title-row">
<div className="sidebar-title">{t('txt_folders')}</div>
<div className="folder-title-actions">
<button
type="button"
className="folder-delete-btn"
title={t('txt_delete_all_folders')}
aria-label={t('txt_delete_all_folders')}
disabled={props.busy || props.folders.length === 0}
onClick={props.onOpenDeleteAllFolders}
>
<X size={14} />
</button>
<button type="button" className="folder-add-btn" onClick={props.onOpenCreateFolder}>
<FolderPlus size={14} />
</button>
</div>
</div>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'folder' && props.sidebarFilter.folderId === null ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'folder', folderId: null })}>
<FolderX size={14} className="tree-icon" /> <span className="tree-label">{t('txt_no_folder')}</span>
</button>
{props.folders.map((folder) => (
<div key={folder.id} className="folder-row">
<button
type="button"
className={`tree-btn ${props.sidebarFilter.kind === 'folder' && props.sidebarFilter.folderId === folder.id ? 'active' : ''}`}
onClick={() => props.onChangeFilter({ kind: 'folder', folderId: folder.id })}
>
<FolderIcon size={14} className="tree-icon" />
<span className="tree-label" title={folder.decName || folder.name || folder.id}>
{folder.decName || folder.name || folder.id}
</span>
</button>
<button
type="button"
className="folder-delete-btn"
title={t('txt_delete')}
aria-label={t('txt_delete')}
disabled={props.busy}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
props.onOpenDeleteFolder(folder);
}}
>
<X size={12} />
</button>
</div>
))}
</div>
</aside>
);
}
@@ -0,0 +1,464 @@
import { useState } from 'preact/hooks';
import {
CreditCard,
FileKey2,
Globe,
KeyRound,
ShieldUser,
StickyNote,
} from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard';
import { t } from '@/lib/i18n';
import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField, VaultDraftLoginUri } from '@/lib/types';
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
export type VaultSortMode = 'edited' | 'created' | 'name';
export type SidebarFilter =
| { kind: 'all' }
| { kind: 'favorite' }
| { kind: 'archive' }
| { kind: 'trash' }
| { kind: 'duplicates' }
| { kind: 'type'; value: TypeFilter }
| { kind: 'folder'; folderId: string | null };
interface TypeOption {
type: number;
label: string;
}
export const CREATE_TYPE_OPTIONS: TypeOption[] = [
{ type: 1, label: t('txt_login') },
{ type: 3, label: t('txt_card') },
{ type: 4, label: t('txt_identity') },
{ type: 2, label: t('txt_note') },
{ type: 5, label: t('txt_ssh_key') },
];
export const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1';
export const MOBILE_LAYOUT_QUERY = '(max-width: 900px)';
export const VAULT_LIST_ROW_HEIGHT = 66;
export const VAULT_LIST_OVERSCAN = 10;
export const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [
{ value: 'edited', label: t('txt_sort_last_edited') },
{ value: 'created', label: t('txt_sort_created') },
{ value: 'name', label: t('txt_sort_name') },
];
export const FIELD_TYPE_OPTIONS: Array<{ value: CustomFieldType; label: string }> = [
{ value: 0, label: t('txt_text') },
{ value: 1, label: t('txt_hidden') },
{ value: 2, label: t('txt_boolean') },
];
export const WEBSITE_MATCH_OPTIONS: Array<{ value: number | null; label: string }> = [
{ value: null, label: t('txt_uri_match_default_base_domain') },
{ value: 0, label: t('txt_uri_match_base_domain') },
{ value: 1, label: t('txt_uri_match_host') },
{ value: 3, label: t('txt_uri_match_exact') },
{ value: 5, label: t('txt_uri_match_never') },
{ value: 2, label: t('txt_uri_match_starts_with') },
{ value: 4, label: t('txt_uri_match_regular_expression') },
];
export const TOTP_PERIOD_SECONDS = 30;
export const TOTP_RING_RADIUS = 14;
export const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
export function CreateTypeIcon({ type }: { type: number }) {
if (type === 1) return <Globe size={15} />;
if (type === 3) return <CreditCard size={15} />;
if (type === 4) return <ShieldUser size={15} />;
if (type === 2) return <StickyNote size={15} />;
if (type === 5) return <KeyRound size={15} />;
return <FileKey2 size={15} />;
}
export function cipherTypeKey(type: number): TypeFilter {
if (type === 1) return 'login';
if (type === 3) return 'card';
if (type === 4) return 'identity';
if (type === 2) return 'note';
return 'ssh';
}
function cipherDeletedValue(cipher: Cipher): boolean {
return !!(cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt);
}
function cipherArchivedValue(cipher: Cipher): boolean {
return !!(cipher.archivedDate || (cipher as { archivedAt?: string | null }).archivedAt);
}
export function isCipherDeleted(cipher: Cipher): boolean {
return cipherDeletedValue(cipher);
}
export function isCipherArchived(cipher: Cipher): boolean {
return cipherArchivedValue(cipher) && !cipherDeletedValue(cipher);
}
export function isCipherVisibleInNormalVault(cipher: Cipher): boolean {
return !cipherDeletedValue(cipher) && !cipherArchivedValue(cipher);
}
export function isCipherVisibleInArchive(cipher: Cipher): boolean {
return !cipherDeletedValue(cipher) && cipherArchivedValue(cipher);
}
export function isCipherVisibleInTrash(cipher: Cipher): boolean {
return cipherDeletedValue(cipher);
}
export function cipherTypeLabel(type: number): string {
if (type === 1) return t('txt_login');
if (type === 3) return t('txt_card');
if (type === 4) return t('txt_identity');
if (type === 2) return t('txt_secure_note');
if (type === 5) return t('txt_ssh_key');
return t('txt_item');
}
export function TypeIcon({ type }: { type: number }) {
if (type === 1) return <Globe size={18} />;
if (type === 3) return <CreditCard size={18} />;
if (type === 4) return <ShieldUser size={18} />;
if (type === 2) return <StickyNote size={18} />;
if (type === 5) return <KeyRound size={18} />;
return <FileKey2 size={18} />;
}
export function parseFieldType(value: number | string | null | undefined): CustomFieldType {
if (value === 1 || value === 2 || value === 3) return value;
if (value === '1' || String(value).toLowerCase() === 'hidden') return 1;
if (value === '2' || String(value).toLowerCase() === 'boolean') return 2;
if (value === '3' || String(value).toLowerCase() === 'linked') return 3;
return 0;
}
export function toBooleanFieldValue(raw: string): boolean {
const v = String(raw || '').trim().toLowerCase();
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
}
export function firstCipherUri(cipher: Cipher): string {
const uris = cipher.login?.uris || [];
for (const uri of uris) {
const raw = uri.decUri || uri.uri || '';
if (raw.trim()) return raw.trim();
}
return '';
}
export function hostFromUri(uri: string): string {
if (!uri.trim()) return '';
try {
const normalized = /^https?:\/\//i.test(uri) ? uri : `https://${uri}`;
return new URL(normalized).hostname || '';
} catch {
return '';
}
}
export function websiteIconUrl(host: string): string {
return `/icons/${encodeURIComponent(host)}/icon.png`;
}
export function createEmptyLoginUri(): VaultDraftLoginUri {
return { uri: '', match: null };
}
export function websiteMatchLabel(value: number | null | undefined): string {
const normalized = typeof value === 'number' && Number.isFinite(value) ? value : null;
return WEBSITE_MATCH_OPTIONS.find((option) => option.value === normalized)?.label || t('txt_uri_match_default_base_domain');
}
function valueOrFallback(value: string | null | undefined): string {
return String(value || '');
}
export function buildCipherDuplicateSignature(cipher: Cipher): string {
const normalized = {
type: Number(cipher.type || 1),
folderId: cipher.folderId || null,
favorite: !!cipher.favorite,
reprompt: Number(cipher.reprompt || 0),
name: valueOrFallback(cipher.decName ?? cipher.name),
notes: valueOrFallback(cipher.decNotes ?? cipher.notes),
login: cipher.login
? {
username: valueOrFallback(cipher.login.decUsername ?? cipher.login.username),
password: valueOrFallback(cipher.login.decPassword ?? cipher.login.password),
totp: valueOrFallback(cipher.login.decTotp ?? cipher.login.totp),
uris: (cipher.login.uris || []).map((uri) => ({
uri: valueOrFallback(uri.decUri ?? uri.uri),
match: uri.match ?? null,
})),
fido2Credentials: (cipher.login.fido2Credentials || []).map((credential) => ({
creationDate: valueOrFallback(credential.creationDate),
})),
}
: null,
card: cipher.card
? {
cardholderName: valueOrFallback(cipher.card.decCardholderName ?? cipher.card.cardholderName),
number: valueOrFallback(cipher.card.decNumber ?? cipher.card.number),
brand: valueOrFallback(cipher.card.decBrand ?? cipher.card.brand),
expMonth: valueOrFallback(cipher.card.decExpMonth ?? cipher.card.expMonth),
expYear: valueOrFallback(cipher.card.decExpYear ?? cipher.card.expYear),
code: valueOrFallback(cipher.card.decCode ?? cipher.card.code),
}
: null,
identity: cipher.identity
? {
title: valueOrFallback(cipher.identity.decTitle ?? cipher.identity.title),
firstName: valueOrFallback(cipher.identity.decFirstName ?? cipher.identity.firstName),
middleName: valueOrFallback(cipher.identity.decMiddleName ?? cipher.identity.middleName),
lastName: valueOrFallback(cipher.identity.decLastName ?? cipher.identity.lastName),
username: valueOrFallback(cipher.identity.decUsername ?? cipher.identity.username),
company: valueOrFallback(cipher.identity.decCompany ?? cipher.identity.company),
ssn: valueOrFallback(cipher.identity.decSsn ?? cipher.identity.ssn),
passportNumber: valueOrFallback(cipher.identity.decPassportNumber ?? cipher.identity.passportNumber),
licenseNumber: valueOrFallback(cipher.identity.decLicenseNumber ?? cipher.identity.licenseNumber),
email: valueOrFallback(cipher.identity.decEmail ?? cipher.identity.email),
phone: valueOrFallback(cipher.identity.decPhone ?? cipher.identity.phone),
address1: valueOrFallback(cipher.identity.decAddress1 ?? cipher.identity.address1),
address2: valueOrFallback(cipher.identity.decAddress2 ?? cipher.identity.address2),
address3: valueOrFallback(cipher.identity.decAddress3 ?? cipher.identity.address3),
city: valueOrFallback(cipher.identity.decCity ?? cipher.identity.city),
state: valueOrFallback(cipher.identity.decState ?? cipher.identity.state),
postalCode: valueOrFallback(cipher.identity.decPostalCode ?? cipher.identity.postalCode),
country: valueOrFallback(cipher.identity.decCountry ?? cipher.identity.country),
}
: null,
sshKey: cipher.sshKey
? {
privateKey: valueOrFallback(cipher.sshKey.decPrivateKey ?? cipher.sshKey.privateKey),
publicKey: valueOrFallback(cipher.sshKey.decPublicKey ?? cipher.sshKey.publicKey),
fingerprint: valueOrFallback(cipher.sshKey.decFingerprint ?? cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint),
}
: null,
secureNoteType: cipher.secureNote?.type ?? null,
fields: (cipher.fields || []).map((field) => ({
type: field.type ?? null,
name: valueOrFallback(field.decName ?? field.name),
value: valueOrFallback(field.decValue ?? field.value),
linkedId: field.linkedId ?? null,
})),
passwordHistory: (cipher.passwordHistory || []).map((entry) => ({
password: valueOrFallback(entry.password),
lastUsedDate: valueOrFallback(entry.lastUsedDate),
})),
};
return JSON.stringify(normalized);
}
export function createEmptyDraft(type: number): VaultDraft {
return {
type,
favorite: false,
name: '',
folderId: '',
notes: '',
reprompt: false,
loginUsername: '',
loginPassword: '',
loginTotp: '',
loginUris: [createEmptyLoginUri()],
loginFido2Credentials: [],
cardholderName: '',
cardNumber: '',
cardBrand: '',
cardExpMonth: '',
cardExpYear: '',
cardCode: '',
identTitle: '',
identFirstName: '',
identMiddleName: '',
identLastName: '',
identUsername: '',
identCompany: '',
identSsn: '',
identPassportNumber: '',
identLicenseNumber: '',
identEmail: '',
identPhone: '',
identAddress1: '',
identAddress2: '',
identAddress3: '',
identCity: '',
identState: '',
identPostalCode: '',
identCountry: '',
sshPrivateKey: '',
sshPublicKey: '',
sshFingerprint: '',
customFields: [],
};
}
export function draftFromCipher(cipher: Cipher): VaultDraft {
const draft = createEmptyDraft(Number(cipher.type || 1));
draft.id = cipher.id;
draft.favorite = !!cipher.favorite;
draft.name = cipher.decName || '';
draft.folderId = cipher.folderId || '';
draft.notes = cipher.decNotes || '';
draft.reprompt = Number(cipher.reprompt || 0) === 1;
if (cipher.login) {
draft.loginUsername = cipher.login.decUsername || '';
draft.loginPassword = cipher.login.decPassword || '';
draft.loginTotp = cipher.login.decTotp || '';
draft.loginUris = (cipher.login.uris || []).map((x) => ({
uri: x.decUri || x.uri || '',
match: x.match ?? null,
}));
draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials)
? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
: [];
if (!draft.loginUris.length) draft.loginUris = [createEmptyLoginUri()];
}
if (cipher.card) {
draft.cardholderName = cipher.card.decCardholderName || '';
draft.cardNumber = cipher.card.decNumber || '';
draft.cardBrand = cipher.card.decBrand || '';
draft.cardExpMonth = cipher.card.decExpMonth || '';
draft.cardExpYear = cipher.card.decExpYear || '';
draft.cardCode = cipher.card.decCode || '';
}
if (cipher.identity) {
draft.identTitle = cipher.identity.decTitle || '';
draft.identFirstName = cipher.identity.decFirstName || '';
draft.identMiddleName = cipher.identity.decMiddleName || '';
draft.identLastName = cipher.identity.decLastName || '';
draft.identUsername = cipher.identity.decUsername || '';
draft.identCompany = cipher.identity.decCompany || '';
draft.identSsn = cipher.identity.decSsn || '';
draft.identPassportNumber = cipher.identity.decPassportNumber || '';
draft.identLicenseNumber = cipher.identity.decLicenseNumber || '';
draft.identEmail = cipher.identity.decEmail || '';
draft.identPhone = cipher.identity.decPhone || '';
draft.identAddress1 = cipher.identity.decAddress1 || '';
draft.identAddress2 = cipher.identity.decAddress2 || '';
draft.identAddress3 = cipher.identity.decAddress3 || '';
draft.identCity = cipher.identity.decCity || '';
draft.identState = cipher.identity.decState || '';
draft.identPostalCode = cipher.identity.decPostalCode || '';
draft.identCountry = cipher.identity.decCountry || '';
}
if (cipher.sshKey) {
draft.sshPrivateKey = cipher.sshKey.decPrivateKey || '';
draft.sshPublicKey = cipher.sshKey.decPublicKey || '';
draft.sshFingerprint = cipher.sshKey.decFingerprint || '';
}
draft.customFields = (cipher.fields || []).map((field) => ({
type: parseFieldType(field.type),
label: field.decName || '',
value: field.decValue || '',
}));
return draft;
}
export function maskSecret(value: string): string {
if (!value) return '';
return '*'.repeat(Math.max(8, Math.min(24, value.length)));
}
export function formatTotp(code: string): string {
if (!code) return code;
if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`;
if (code.length < 6) return code;
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
}
export function formatHistoryTime(value: string | null | undefined): string {
if (!value) return t('txt_dash');
const date = new Date(value);
if (!Number.isFinite(date.getTime())) return value;
return date.toLocaleString();
}
export function parseAttachmentSizeBytes(attachment: CipherAttachment): number {
const raw = attachment?.size;
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw;
const parsed = Number(raw);
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
return 0;
}
export function formatAttachmentSize(attachment: CipherAttachment): string {
const sizeName = String(attachment?.sizeName || '').trim();
if (sizeName) return sizeName;
const bytes = parseAttachmentSizeBytes(attachment);
if (bytes <= 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
export function sortTimeValue(cipher: Cipher): number {
const candidates = [cipher.revisionDate, cipher.creationDate];
for (const value of candidates) {
const time = new Date(String(value || '')).getTime();
if (Number.isFinite(time)) return time;
}
return 0;
}
export function creationTimeValue(cipher: Cipher): number {
const time = new Date(String(cipher.creationDate || '')).getTime();
return Number.isFinite(time) ? time : 0;
}
export function firstPasskeyCreationTime(cipher: Cipher | null): string | null {
const credentials = cipher?.login?.fido2Credentials;
if (!Array.isArray(credentials) || credentials.length === 0) return null;
for (const credential of credentials) {
const raw = String(credential?.creationDate || '').trim();
if (raw) return raw;
}
return null;
}
const failedIconHosts = new Set<string>();
export function VaultListIcon({ cipher }: { cipher: Cipher }) {
const uri = firstCipherUri(cipher);
const host = hostFromUri(uri);
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
if (host && !errored) {
return (
<img
className="list-icon"
src={websiteIconUrl(host)}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
onError={() => {
failedIconHosts.add(host);
setErrored(true);
}}
/>
);
}
return (
<span className="list-icon-fallback">
<TypeIcon type={Number(cipher.type || 1)} />
</span>
);
}
export function copyToClipboard(value: string): void {
if (!value.trim()) return;
void copyTextToClipboard(value);
}
export function openUri(raw: string): void {
const value = raw.trim();
if (!value) return;
const url = /^https?:\/\//i.test(value) ? value : `https://${value}`;
window.open(url, '_blank', 'noopener');
}
@@ -0,0 +1,237 @@
import { useMemo } from 'preact/hooks';
import {
changeMasterPassword,
deleteAllAuthorizedDevices,
deleteAuthorizedDevice,
deriveLoginHash,
getCurrentDeviceIdentifier,
getTotpRecoveryCode,
revokeAuthorizedDeviceTrust,
revokeAllAuthorizedDeviceTrust,
setTotp,
updateProfile,
} from '@/lib/api/auth';
import { t } from '@/lib/i18n';
import type { AppConfirmState } from '@/components/AppGlobalOverlays';
import type { AuthedFetch } from '@/lib/api/shared';
import type { AuthorizedDevice, Profile } from '@/lib/types';
type Notify = (type: 'success' | 'error' | 'warning', text: string) => void;
interface UseAccountSecurityActionsOptions {
authedFetch: AuthedFetch;
profile: Profile | null;
defaultKdfIterations: number;
disableTotpPassword: string;
clearDisableTotpDialog: () => void;
onLogoutNow: () => void;
onNotify: Notify;
onProfileUpdated: (profile: Profile) => void;
onSetConfirm: (next: AppConfirmState | null) => void;
refetchTotpStatus: () => Promise<unknown>;
refetchAuthorizedDevices: () => Promise<unknown>;
}
export default function useAccountSecurityActions(options: UseAccountSecurityActionsOptions) {
const {
authedFetch,
profile,
defaultKdfIterations,
disableTotpPassword,
clearDisableTotpDialog,
onLogoutNow,
onNotify,
onProfileUpdated,
onSetConfirm,
refetchTotpStatus,
refetchAuthorizedDevices,
} = options;
return useMemo(
() => ({
async changePassword(currentPassword: string, nextPassword: string, nextPassword2: string) {
if (!profile) return;
if (!currentPassword || !nextPassword) {
onNotify('error', t('txt_current_new_password_is_required'));
return;
}
if (nextPassword.length < 12) {
onNotify('error', t('txt_new_password_must_be_at_least_12_chars'));
return;
}
if (nextPassword !== nextPassword2) {
onNotify('error', t('txt_new_passwords_do_not_match'));
return;
}
onSetConfirm({
title: t('txt_change_master_password'),
message: t('txt_change_password_confirm_and_sign_out_all_devices'),
danger: true,
onConfirm: () => {
onSetConfirm(null);
void (async () => {
try {
await changeMasterPassword(authedFetch, {
email: profile.email,
currentPassword,
newPassword: nextPassword,
currentIterations: defaultKdfIterations,
profileKey: profile.key,
});
onNotify('success', t('txt_master_password_changed_signing_out_everywhere'));
onLogoutNow();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_change_password_failed'));
}
})();
},
});
},
async savePasswordHint(masterPasswordHint: string) {
if (!profile) return;
const normalized = String(masterPasswordHint || '').trim();
if (normalized.length > 120) {
onNotify('error', t('txt_password_hint_too_long'));
return;
}
try {
const nextProfile = await updateProfile(authedFetch, { masterPasswordHint: normalized });
onProfileUpdated(nextProfile);
onNotify('success', t('txt_profile_updated'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_save_profile_failed'));
}
},
async enableTotp(secret: string, token: string) {
if (!secret.trim() || !token.trim()) {
const error = new Error(t('txt_secret_and_code_are_required'));
onNotify('error', error.message);
throw error;
}
try {
await setTotp(authedFetch, { enabled: true, secret: secret.trim(), token: token.trim() });
onNotify('success', t('txt_totp_enabled'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_enable_totp_failed'));
throw error;
}
},
async disableTotp() {
if (!profile) return;
if (!disableTotpPassword) {
onNotify('error', t('txt_please_input_master_password'));
return;
}
try {
const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations);
await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash });
if (profile.id) localStorage.removeItem(`nodewarden.totp.secret.${profile.id}`);
clearDisableTotpDialog();
await refetchTotpStatus();
onNotify('success', t('txt_totp_disabled'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_disable_totp_failed'));
}
},
async getRecoveryCode(masterPassword: string): Promise<string> {
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalized = String(masterPassword || '');
if (!normalized) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
const code = await getTotpRecoveryCode(authedFetch, derived.hash);
if (!code) throw new Error(t('txt_recovery_code_is_empty'));
return code;
},
async refreshAuthorizedDevices() {
await refetchAuthorizedDevices();
},
openRevokeDeviceTrust(device: AuthorizedDevice) {
onSetConfirm({
title: t('txt_revoke_device_authorization'),
message: t('txt_revoke_30_day_totp_trust_for_name', { name: device.name }),
danger: true,
onConfirm: () => {
onSetConfirm(null);
void (async () => {
await revokeAuthorizedDeviceTrust(authedFetch, device.identifier);
await refetchAuthorizedDevices();
onNotify('success', t('txt_device_authorization_revoked'));
})();
},
});
},
openRemoveDevice(device: AuthorizedDevice) {
onSetConfirm({
title: t('txt_remove_device'),
message: t('txt_remove_device_and_sign_out_name', { name: device.name }),
danger: true,
onConfirm: () => {
onSetConfirm(null);
void (async () => {
await deleteAuthorizedDevice(authedFetch, device.identifier);
if (device.identifier === getCurrentDeviceIdentifier()) {
onNotify('success', t('txt_device_removed'));
onLogoutNow();
return;
}
await refetchAuthorizedDevices();
onNotify('success', t('txt_device_removed'));
})();
},
});
},
openRevokeAllDeviceTrust() {
onSetConfirm({
title: t('txt_revoke_all_trusted_devices'),
message: t('txt_revoke_30_day_totp_trust_from_all_devices'),
danger: true,
onConfirm: () => {
onSetConfirm(null);
void (async () => {
await revokeAllAuthorizedDeviceTrust(authedFetch);
await refetchAuthorizedDevices();
onNotify('success', t('txt_all_device_authorizations_revoked'));
})();
},
});
},
openRemoveAllDevices() {
onSetConfirm({
title: t('txt_remove_all_devices'),
message: t('txt_remove_all_devices_and_sign_out_all_sessions'),
danger: true,
onConfirm: () => {
onSetConfirm(null);
void (async () => {
await deleteAllAuthorizedDevices(authedFetch);
onNotify('success', t('txt_all_devices_removed'));
onLogoutNow();
})();
},
});
},
}),
[
authedFetch,
clearDisableTotpDialog,
defaultKdfIterations,
disableTotpPassword,
onLogoutNow,
onNotify,
onProfileUpdated,
onSetConfirm,
profile,
refetchAuthorizedDevices,
refetchTotpStatus,
]
);
}

Some files were not shown because too many files have changed in this diff Show More