308 Commits

Author SHA1 Message Date
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 5dab96f40e feat: add Import & Export page and update Help page with new navigation 2026-03-02 00:10:44 +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 dc12a73ab3 fix: update deploy script to use consistent build command 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 9c9c76d82e chore: ensure newline at end of .gitignore file 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 a1d38b76c6 chore: remove accidental tmp submodules 2026-03-02 00:10:44 +08:00
shuaiplus 65b57b00e2 chore: remove accidental tmp submodules 2026-03-02 00:10:44 +08:00
shuaiplus 705a716a80 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 1a1b334f6c feat: add build script for consistent project building 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 8d6835b665 feat: remove deprecated Bitwarden subprojects from the repository 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 189a7b9285 feat: update routing regex patterns for improved API path matching 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 23a45913e0 feat: update favicon and logo images for improved branding 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 ace9f4f5ac feat: enhance security headers and update content security policy in response and HTML files 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 c0683016c3 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 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 e9ace523e6 feat: enhance password security with server-side hashing and constant-time comparisons 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 4390251c1e feat: unify API rate limiting and enhance request budgets 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 aef0c2f688 docs: update capability descriptions in README files for clarity 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 594ca0c7ea feat: add TOTP recovery code field to users table 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 26447cd9b4 docs: update README files for clarity on deployment steps and features 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 f5a2523f91 feat: add JWT secret safety checks and warning page for insecure configurations 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 bbf4094943 fix: remove unnecessary zoom property from html in styles.css 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 9f14bca99a feat(i18n): add internationalization support with English and Chinese translations 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 8641df3cff feat: add recovery code functionality and device management 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 8852127743 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 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 053ce887f9 fix: update README to clarify NodeWarden as a third-party Bitwarden server 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 2fbe29a0d9 feat: add NodeWarden logo to README files for improved branding 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 15b87025ad 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 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 0e823e80a6 feat: enhance SendsPage with notes display and update VaultPage for improved filtering and history tracking 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 bb50617b16 feat: add PublicSendPage and SendsPage components for managing sends 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 be3b68956b feat: add favicon and logo assets, update App component to use logo 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 0f132f4f43 feat: add SSH key utilities and improve field decryption 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 32c695c81f feat: enhance VaultPage and App layout with new UI components and styles 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 651eb69bd6 feat: enhance authentication and settings UI 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 0cf8028087 feat: add cryptographic utilities and types for secure data handling 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 3494471cad feat: add toast notifications and dialog components for improved user interaction 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 59566f88e3 feat: implement vault locking mechanism with auto-lock settings and unlock functionality 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 172f6626c0 feat: add QR code generation support and rate limiting for known device probes 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 829008db7f 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 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 363aec1652 Add runtime configuration loader and styles for web application 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 b8c4bcef0c Enhance styles for app layout and components 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 d0c8516021 Add global styles for web client interface 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 1f4933c5d5 Implement code changes to enhance functionality and improve performance 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 4a37d742eb feat: 更新网页客户端样式和布局,提升用户体验 2026-03-02 00:10:44 +08:00
shuaiplus ec9be40d6c feat: 更新网页客户端样式和布局,提升用户体验 2026-03-02 00:10:44 +08:00
shuaiplus 6bbc7554c1 Refactor code structure for improved readability and maintainability 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 d80821edeb feat: enhance registration and password management UI with additional state handling 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 6e95d7a235 feat: implement admin user management and invite system 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 f9b084d09d feat: remove setup disabling functionality and related UI elements 2026-02-25 01:30:08 +08:00
shuaiplus 9359ce2a2c feat: remove setup disabling functionality and related UI elements 2026-02-25 01:30:08 +08:00
shuaiplus 4f82cf9d43 feat: add overlap grace period for refresh tokens to handle concurrent requests 2026-02-25 00:22:31 +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 bc0fd65b6b feat: add compatibility for custom fields handling in cipher creation and update 2026-02-25 00:10:11 +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 08114762bc feat: add compatibility for fido2Credentials counter and implement no-op device token update handler 2026-02-23 23:29:00 +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 1dfa96611a feat: add CLI deployment instructions 2026-02-23 20:01:55 +08:00
shuaiplus 2226bdd9ef feat: add CLI deployment instructions 2026-02-23 20:01:55 +08:00
shuaiplus 36715645c6 feat: add compatibility mode for deleting ciphers to support Bitwarden clients 2026-02-23 19:35:06 +08:00
shuaiplus f7a5966104 feat: add compatibility mode for deleting ciphers to support Bitwarden clients 2026-02-23 19:35:06 +08:00
shuaiplus 3873d347aa enhance README with badges and project links 2026-02-23 16:56:00 +08:00
shuaiplus 747cad35f5 enhance README with badges and project links 2026-02-23 16:56:00 +08:00
shuaiplus 874d31e86b fix: ensure attachment size is formatted as string for compatibility with Bitwarden clients 2026-02-23 14:07:11 +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 cd7b5a361c feat: add TOTP code generation and display functionality with UI enhancements 2026-02-21 15:13:21 +08:00
shuaiplus a3f074f38a feat: add TOTP code generation and display functionality with UI enhancements 2026-02-21 15:13:21 +08:00
shuaiplus 9eddb91237 feat: enhance two-factor authentication handling and improve error responses 2026-02-21 14:13:22 +08:00
shuaiplus 8106364650 feat: enhance two-factor authentication handling and improve error responses 2026-02-21 14:13:22 +08:00
shuaiplus b2e8d3e00b feat: enhance registration page with TOTP support and UI improvements 2026-02-20 20:28:08 +08:00
shuaiplus 2934ebd36d feat: enhance registration page with TOTP support and UI improvements 2026-02-20 20:28:08 +08:00
shuaiplus a83e0d259e fix: increase max login attempts and improve two-factor token error response 2026-02-20 18:53:10 +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 b6f2882cdf chore: update version to 1.1.0 and improve two-factor provider validation 2026-02-20 18:39:18 +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 aaf5078c8a feat: add token revocation endpoint and enhance ciphers import request structure 2026-02-20 18:16:07 +08:00
shuaiplus 3f8a6d78d5 feat: add token revocation endpoint and enhance ciphers import request structure 2026-02-20 18:16:07 +08:00
shuaiplus 76d766d5d6 feat: extend CiphersImportRequest with additional fields for enhanced import functionality 2026-02-20 16:54:42 +08:00
shuaiplus 269055867b feat: extend CiphersImportRequest with additional fields for enhanced import functionality 2026-02-20 16:54:42 +08:00
shuaiplus cdbe87aac2 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 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 d1a43f2e95 fix: update TOTP field description for clarity in README files 2026-02-20 00:41:47 +08:00
shuaiplus 2b6852fb7f fix: update TOTP field description for clarity in README files 2026-02-20 00:41:47 +08:00
shuaiplus 8d6bcc327d fix: update JWT_SECRET description for clarity 2026-02-20 00:04:14 +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
146 changed files with 35753 additions and 2984 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
+3
View File
@@ -7,6 +7,7 @@ node_modules/
wrangler.my.toml
RELEASE_NOTES.md
tests/selfcheck.ts
problem.md
# Build output
dist/
@@ -36,3 +37,5 @@ npm-debug.log*
# Package lock (optional - remove if you want to commit it)
# package-lock.json
tmp/
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

+69 -23
View File
@@ -1,7 +1,20 @@
# NodeWarden
<p align="center">
<img src="./NodeWarden.png" alt="NodeWarden Logo" />
</p>
<p align="center">
运行在 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)
[![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)
运行在 **Cloudflare Workers** 上的 **Bitwarden 第三方服务端**
> **免责声明**
> 本项目仅供学习交流使用。我们不对任何数据丢失负责,强烈建议定期备份您的密码库。
@@ -10,19 +23,20 @@ English[`README_EN.md`](./README_EN.md)
---
## 与 Bitwarden 官方服务端能力对比
| 能力项 | Bitwarden | NodeWarden | 说明 |
| 能力项 | Bitwarden | NodeWarden | 说明 |
|---|---|---|---|
| 单用户保管库(登录/笔记/卡片/身份) | ✅ | ✅ | 基于Cloudflare D1 |
| Web Vault(登录/笔记/卡片/身份) | ✅ | ✅ | 网页端密码库管理页面 |
| 文件夹 / 收藏 | ✅ | ✅ | 常用管理能力可用 |
| 全量同步 `/api/sync` | ✅ | ✅ | 已做兼容与性能优化 |
| 附件上传/下载 | ✅ | ✅ | 基于 Cloudflare R2 |
| 导入功能 | ✅ | ✅ | 覆盖常见导入路径 |
| 附件上传/下载 | ✅ | ✅ | Cloudflare R2 和 KV 二选一 |
| 导入导出功能 | ✅ | ✅ | 完整实现,含 Bitwarden 密码库+附件 ZIP 导入 |
| 网站图标代理 | ✅ | ✅ | 通过 `/icons/{hostname}/icon.png` |
| 多用户 | ✅ | | NodeWarden 定位单用户 |
| passkey、TOTP 字段 | ✅ | | 完全支持,无需高级版 |
| Send | ✅ | ✅ | Cloudflare R2 和 KV 二选一 |
| 多用户 | ✅ | ✅ | 完整的用户管理,邀请机制 |
| 组织/集合/成员权限 | ✅ | ❌ | 没必要实现 |
| 完整 2FATOTP/WebAuthn/Duo/Email | ✅ | ❌ | 没必要实现 |
| 登录 2FATOTP/WebAuthn/Duo/Email | ✅ | ⚠️ 部分支持 | 仅支持用户级 TOTP |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 没必要实现 |
| Send | ✅ | ❌ | 基本没人用 |
| 紧急访问 | ✅ | ❌ | 没必要实现 |
| 管理后台 / 计费订阅 | ✅ | ❌ | 纯免费 |
| 推送通知完整链路 | ✅ | ❌ | 没必要实现 |
@@ -32,43 +46,74 @@ English[`README_EN.md`](./README_EN.md)
- ✅ Windows 客户端(v2026.1.0
- ✅ 手机 Appv2026.1.0
- ✅ 浏览器扩展(v2026.1.0
- ✅ Linux 客户端(v2026.1.0
- ⬜ macOS 客户端(未测试)
- ⬜ Linux 客户端(未测试)
---
# 快速开始
### 一键部署
## 网页部署
**部署步骤:**
1. 先在右上角fork此项目(若后续不需要更新,可不fork)
2. [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
3. 打开部署后生成的链接,并根据网页提示完成后续操作。
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 点自动同步上游。
这是一个 Cloudflare Workers 的 TypeScript 项目(Wrangler)。
```bash
## CLI 部署
```powershell
# 先把仓库拉到本地
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
# 安装依赖
npm install
# Cloudflare CLI 登录
npx wrangler login
# 部署到 Cloudflare
npm run deploy
# (可选)KV 模式(无 R2 / 无信用卡)
npm run deploy:kv
# 本地开发
npm run dev
npm run dev:kv
# 后续更新时重新拉取仓库并重新部署即可,无需重复创建云资源
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
npm run deploy
```
---
---
## 常见问题
**Q: 如何备份数据?**
A: 在客户端中选择「导出密码库」,保存 JSON 文件。
**Q: 导入导出支持哪些格式?**
A: 支持 Bitwarden `json/csv/密码库+附件 zip` 和 NodeWarden `密码库+附件 json`(均含加密模式),且导入下拉中看到的格式都可直接导入。
A: 另外还支持直接导入 Bitwarden `密码库+附件 zip`,这条路径官方 Bitwarden Web 暂不支持。
**Q: 忘记主密码怎么办?**
A: 无法恢复,这是端到端加密的特性。建议妥善保管主密码。
**Q: 可以多人使用吗?**
A: 不建议。本项目为单用户设计,多人使用请选择 Vaultwarden
A: 支持。第一个注册的用户会自动成为管理员;管理员可在管理页面生成邀请码,其他用户凭邀请码注册
---
@@ -83,7 +128,8 @@ LGPL-3.0 License
- [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)
[![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)
+69 -31
View File
@@ -1,12 +1,23 @@
# NodeWarden
<p align="center">
<img src="./NodeWarden.png" alt="NodeWarden Logo" />
</p>
<p align="center">
A third-party Bitwarden server running on Cloudflare Workers, fully compatible with official clients.
</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)
[![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)
中文文档:[`README.md`](./README.md)
A **Bitwarden-compatible** server that runs on **Cloudflare Workers**.
> Disclaimer
> - This project is for learning and communication 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.
> **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.
---
@@ -14,52 +25,75 @@ A **Bitwarden-compatible** server that runs on **Cloudflare Workers**.
| Capability | Bitwarden | NodeWarden | Notes |
|---|---|---|---|
| Single-user vault (logins/notes/cards/identities) | ✅ | ✅ | Core vault model supported |
| Web Vault (logins/notes/cards/identities) | ✅ | ✅ | Web-based vault management UI |
| Folders / Favorites | ✅ | ✅ | Common vault organization supported |
| Full sync `/api/sync` | ✅ | ✅ | Compatibility-focused implementation |
| Attachment upload/download | ✅ | ✅ | Backed by Cloudflare R2 |
| Import flow (common clients) | ✅ | ✅ | Common import paths covered |
| Full sync `/api/sync` | ✅ | ✅ | Compatibility and performance optimized |
| Attachment upload/download | ✅ | ✅ | Choose either Cloudflare R2 or KV |
| Import / export | ✅ | ✅ | Fully implemented, including Bitwarden vault + attachments ZIP import |
| Website icon proxy | ✅ | ✅ | Via `/icons/{hostname}/icon.png` |
| Multi-user | ✅ | | NodeWarden is single-user by design |
| passkey / TOTP fields | ✅ | | Fully supported, no premium required |
| Send | ✅ | ✅ | Choose either Cloudflare R2 or KV |
| Multi-user | ✅ | ✅ | Full user management with invitation mechanism |
| Organizations / Collections / Member roles | ✅ | ❌ | Not necessary to implement |
| Full 2FA (TOTP/WebAuthn/Duo/Email) | ✅ | ❌ | Not necessary to implement |
| Login 2FA (TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ Partial | User-level TOTP only |
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not necessary to implement |
| Send | ✅ | ❌ | 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)
-Android app (v2026.1.0)
-Mobile app (v2026.1.0)
- ✅ Browser extension (v2026.1.0)
- ✅ Linux desktop client (v2026.1.0)
- ⬜ macOS desktop client (not tested)
- ⬜ Linux desktop client (not tested)
---
# Quick start
## Web deploy
### One-click deploy
1. Fork this repository. If you find this project helpful, 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` -> (R2 storage is used by default; if R2 is unavailable for your account, switch to KV and change the deploy command to `npm run deploy:kv`) -> deploy -> open the generated URL.
**Deploy steps:**
| Storage | Card required | Single attachment / Send file limit | Free tier |
|---|---|---|---|
| R2 | Yes | 100 MB (soft limit, can be changed) | 10 GB |
| KV | No | 25 MiB (Cloudflare limit, cannot be changed) | 1 GB |
1. Fork this project (you don't need to fork it if you don't need to update it later).
2. [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
3. Open the generated service URL and follow the on-page instructions.
> [!TIP]
> Sync upstream (keep your fork updated):
>- Manual: open your fork on GitHub, click `Sync fork`, then click `Update branch`.
>- Automatic: in your fork, go to `Actions` -> `Sync upstream` -> `Enable workflow`. It will automatically sync from upstream every day at 3 AM.
## CLI deploy
## Local development
```powershell
# Clone repository
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
This repo is a Cloudflare Workers TypeScript project (Wrangler).
```bash
# Install dependencies
npm install
npm run dev
```
# Cloudflare CLI login
npx wrangler login
# Deploy to Cloudflare
npm run deploy
# (Optional) KV mode (no R2 / no credit card)
npm run deploy:kv
# Local development
npm run dev
npm run dev:kv
# To update later, pull the repository again and redeploy
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
npm run deploy
```
---
## FAQ
@@ -67,11 +101,15 @@ npm run dev
**Q: How do I back up my data?**
A: Use **Export vault** in your client and save the JSON file.
**Q: Which import/export formats are supported?**
A: NodeWarden supports Bitwarden `json/csv/vault + attachments zip` and NodeWarden `vault + attachments json` in both plain and encrypted modes, and every format visible in the import selector is directly importable.
A: It also supports direct import of Bitwarden `vault + attachments zip`, which is not directly supported by official Bitwarden Web import.
**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: Not recommended. This project is designed for single-user usage. For multi-user usage, choose Vaultwarden.
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.
---
@@ -92,4 +130,4 @@ LGPL-3.0 License
---
## 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)
[![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)
+84
View File
@@ -1,5 +1,9 @@
PRAGMA foreign_keys = ON;
-- IMPORTANT:
-- Keep this file in sync with src/services/storage.ts (SCHEMA_STATEMENTS).
-- Any new table/column/index must be added to both places together.
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
@@ -9,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,
@@ -18,6 +23,10 @@ CREATE TABLE IF NOT EXISTS users (
kdf_memory INTEGER,
kdf_parallelism INTEGER,
security_stamp TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'active',
totp_secret TEXT,
totp_recovery_code TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -69,6 +78,32 @@ CREATE TABLE IF NOT EXISTS attachments (
);
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);
CREATE TABLE IF NOT EXISTS refresh_tokens (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
@@ -77,6 +112,55 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
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,
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);
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);
-- Rate limiting
CREATE TABLE IF NOT EXISTS login_attempts_ip (
ip TEXT PRIMARY KEY,
+1838 -158
View File
File diff suppressed because it is too large Load Diff
+22 -5
View File
@@ -1,6 +1,6 @@
{
"name": "nodewarden",
"version": "1.0.0",
"version": "1.4.0",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus",
"license": "LGPL-3.0",
@@ -8,8 +8,10 @@
"type": "module",
"scripts": {
"dev": "wrangler dev -c wrangler.toml",
"deploymy": "wrangler deploy -c wrangler.my.toml",
"deploy": "wrangler deploy"
"dev:kv": "wrangler dev -c wrangler.kv.toml",
"build": "vite build --config webapp/vite.config.ts",
"deploy": "wrangler deploy",
"deploy:kv": "wrangler deploy -c wrangler.kv.toml"
},
"keywords": [
"bitwarden",
@@ -21,21 +23,36 @@
"cloudflare": {
"bindings": {
"JWT_SECRET": {
"description": "Secret used to sign JWTs. Use a strong random string (32+ characters recommended)"
"description": "Use a strong random string (32+ characters recommended)"
},
"DB": {
"description": "D1 database for storing vault data"
},
"ATTACHMENTS": {
"description": "R2 bucket for storing file attachments"
},
"ATTACHMENTS_KV": {
"description": "Optional KV namespace fallback for attachment/send-file storage"
}
}
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260131.0",
"@preact/preset-vite": "^2.10.3",
"@types/node": "^25.2.3",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"wrangler": "^4.61.1"
"vite": "^7.3.1",
"wrangler": "^4.71.0"
},
"dependencies": {
"@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",
"wouter": "^3.9.0"
}
}
+1
View File
@@ -0,0 +1 @@
export const APP_VERSION = '1.4.0';
+148
View File
@@ -0,0 +1,148 @@
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 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;
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,
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,
}),
],
};
}
+53 -13
View File
@@ -6,12 +6,18 @@
// Refresh token lifetime in milliseconds.
// 刷新令牌有效期(毫秒)。
refreshTokenTtlMs: 30 * 24 * 60 * 60 * 1000,
// Grace window for previous refresh token after rotation (ms).
// 刷新令牌轮换后的旧令牌宽限窗口(毫秒)。
refreshTokenOverlapGraceMs: 60 * 1000,
// Refresh token random byte length.
// 刷新令牌随机字节长度。
refreshTokenRandomBytes: 32,
// Attachment download token lifetime in seconds.
// 附件下载令牌有效期(秒)。
fileDownloadTokenTtlSeconds: 300,
// Send access token lifetime in seconds.
// Send 访问令牌有效期(秒)。
sendAccessTokenTtlSeconds: 300,
// Minimum required JWT secret length.
// JWT 密钥最小长度要求。
jwtSecretMinLength: 32,
@@ -22,16 +28,34 @@
rateLimit: {
// Max failed login attempts before temporary lock.
// 触发临时锁定前允许的最大登录失败次数。
loginMaxAttempts: 5,
loginMaxAttempts: 10,
// Login lock duration in minutes.
// 登录锁定时长(分钟)。
loginLockoutMinutes: 2,
// Write API request budget per minute.
// 写操作 API 每分钟请求配额。
apiWriteRequestsPerMinute: 120,
// /api/sync read request budget per minute.
// /api/sync 读请求每分钟配额。
syncReadRequestsPerMinute: 1000,
// Authenticated API request budget per user per minute (all reads & writes combined).
// 认证 API 每用户每分钟请求配额(读写合计)
apiRequestsPerMinute: 200,
// 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,
@@ -41,15 +65,9 @@
// Minimum interval between login-attempt cleanup runs.
// 登录尝试表清理的最小间隔。
loginIpCleanupIntervalMs: 10 * 60 * 1000,
// Minimum interval between API-window cleanup runs.
// API 窗口计数清理的最小间隔。
apiWindowCleanupIntervalMs: 5 * 60 * 1000,
// Retention window for login IP records.
// 登录 IP 记录保留时长。
loginIpRetentionMs: 30 * 24 * 60 * 60 * 1000,
// Number of historical API windows to keep.
// 保留的历史 API 窗口数量。
apiWindowRetentionWindows: 120,
},
cleanup: {
// Minimum interval between refresh-token cleanup runs.
@@ -67,6 +85,14 @@
// 附件上传大小上限(字节)。
maxFileSizeBytes: 100 * 1024 * 1024,
},
send: {
// Max file size allowed for Send file uploads.
// Send 文件上传大小上限。
maxFileSizeBytes: 100 * 1024 * 1024,
// Max days allowed between now and deletion date.
// 允许的最远删除日期(距当前天数)。
maxDeletionDays: 31,
},
pagination: {
// Default page size when client does not specify pageSize.
// 客户端未传 pageSize 时的默认分页大小。
@@ -87,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,
@@ -95,6 +127,14 @@
// Max IDs per SQL batch when moving ciphers in bulk.
// 批量移动密码项时每批 SQL 的最大 ID 数量。
bulkMoveChunkSize: 200,
// Max total items (folders + ciphers) allowed in a single import.
// 单次导入允许的最大条目数(文件夹 + 密码项合计)。
importItemLimit: 5000,
},
request: {
// Hard body size limit for JSON API endpoints (bytes). File upload paths are exempt.
// JSON 接口请求 body 大小上限(字节),文件上传接口除外。
maxBodyBytes: 25 * 1024 * 1024,
},
compatibility: {
// Single source of truth for /config.version and /api/version.
+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);
}
}
+529 -71
View File
@@ -1,9 +1,13 @@
import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types';
import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse } from '../utils/response';
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;
@@ -15,6 +19,54 @@ function looksLikeEncString(value: string): boolean {
return parts.length >= 2;
}
/**
* Validate KDF parameters according to Bitwarden minimum requirements.
* Returns an error message if invalid, or null if OK.
*/
function validateKdfParams(kdfType: number | undefined, kdfIterations: number | undefined, kdfMemory?: number | undefined, kdfParallelism?: number | undefined): string | null {
const type = kdfType ?? 0;
if (type === 0) {
// PBKDF2-SHA256: minimum 100 000 iterations
if (typeof kdfIterations === 'number' && kdfIterations < 100_000) {
return 'PBKDF2 iterations must be at least 100000';
}
} else if (type === 1) {
// Argon2id: iterations >= 2, memory >= 16 MiB, parallelism >= 1
if (typeof kdfIterations === 'number' && kdfIterations < 2) {
return 'Argon2id iterations must be at least 2';
}
if (typeof kdfMemory === 'number' && kdfMemory < 16) {
return 'Argon2id memory must be at least 16 MiB';
}
if (typeof kdfParallelism === 'number' && kdfParallelism < 1) {
return 'Argon2id parallelism must be at least 1';
}
}
return null;
}
function normalizeTotpSecret(input: string): string {
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';
@@ -23,11 +75,41 @@ function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' |
return null;
}
// POST /api/accounts/register (only used from setup page, not client)
function toProfile(user: User, env: Env): ProfileResponse {
void env;
return {
id: user.id,
name: user.name,
email: user.email,
emailVerified: true,
premium: true,
premiumFromOrganization: false,
usesKeyConnector: false,
masterPasswordHint: user.masterPasswordHint,
culture: 'en-US',
twoFactorEnabled: !!user.totpSecret,
key: user.key,
privateKey: user.privateKey,
accountKeys: buildAccountKeys(user),
securityStamp: user.securityStamp || user.id,
organizations: [],
providers: [],
providerOrganizations: [],
forcePasswordReset: false,
avatarColor: null,
creationDate: user.createdAt,
role: user.role,
status: user.status,
object: 'profile',
};
}
// POST /api/accounts/register
// - First user becomes admin.
// - Any subsequent user must provide a valid inviteCode.
export async function handleRegister(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
// Enforce safe JWT_SECRET before allowing first registration.
const unsafe = jwtSecretUnsafeReason(env);
if (unsafe) {
const message = unsafe === 'missing'
@@ -42,12 +124,13 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
email?: string;
name?: string;
masterPasswordHash?: string;
masterPasswordHint?: string;
key?: string;
kdf?: number;
kdfIterations?: number;
kdfMemory?: number;
kdfParallelism?: number;
inviteCode?: string;
masterPasswordHint?: string;
keys?: {
publicKey?: string;
encryptedPrivateKey?: string;
@@ -60,17 +143,21 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
return errorResponse('Invalid JSON', 400);
}
const email = body.email?.toLowerCase();
const name = body.name || email;
const email = body.email?.toLowerCase().trim();
const name = body.name?.trim() || email;
const masterPasswordHash = body.masterPasswordHash;
const key = body.key;
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);
}
if (!email.includes('@') || email.length < 3) {
return errorResponse('Invalid email address', 400);
}
if (!privateKey || !publicKey) {
return errorResponse('Private key and public key are required', 400);
}
@@ -80,100 +167,206 @@ 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);
const now = new Date().toISOString();
const auth = new AuthService(env);
const serverHash = await auth.hashPasswordServer(masterPasswordHash, email);
// Create user
const user: User = {
id: generateUUID(),
email: email,
email,
name: name || email,
masterPasswordHash: masterPasswordHash,
key: key,
privateKey: privateKey,
publicKey: publicKey,
masterPasswordHint,
masterPasswordHash: serverHash,
key,
privateKey,
publicKey,
kdfType: body.kdf ?? 0,
kdfIterations: body.kdfIterations ?? LIMITS.auth.defaultKdfIterations,
kdfMemory: body.kdfMemory,
kdfParallelism: body.kdfParallelism,
securityStamp: generateUUID(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
role: 'user',
status: 'active',
totpSecret: null,
totpRecoveryCode: null,
createdAt: now,
updatedAt: now,
};
const created = await storage.createFirstUser(user);
if (!created) {
return errorResponse('Registration is closed', 403);
const userCount = await storage.getUserCount();
if (userCount === 0) {
user.role = 'admin';
const created = await storage.createFirstUser(user);
if (!created) {
return errorResponse('Registration is temporarily unavailable, retry once', 409);
}
await storage.setRegistered();
await storage.createAuditLog({
id: generateUUID(),
actorUserId: user.id,
action: 'user.register.first_admin',
targetType: 'user',
targetId: user.id,
metadata: JSON.stringify({ email: user.email }),
createdAt: now,
});
return jsonResponse({ success: true, role: user.role }, 200);
}
await storage.setRegistered();
if (!inviteCode) {
return errorResponse('Invite code is required', 403);
}
return jsonResponse({ success: true }, 200);
try {
await storage.createUser(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;
}
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id);
if (!inviteMarked) {
await storage.deleteUserById(user.id);
return errorResponse('Invite code is invalid or expired', 403);
}
await storage.createAuditLog({
id: generateUUID(),
actorUserId: user.id,
action: 'user.register.invite',
targetType: 'user',
targetId: user.id,
metadata: JSON.stringify({ email: user.email, inviteCode }),
createdAt: now,
});
return jsonResponse({ success: true, role: user.role }, 200);
}
// GET /api/accounts/profile
export async function handleGetProfile(request: Request, env: Env, userId: string): Promise<Response> {
// POST /api/accounts/password-hint
export async function handleGetPasswordHint(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) {
return errorResponse('User not found', 404);
const clientIdentifier = getClientIdentifier(request);
if (!clientIdentifier) {
return errorResponse('Client IP is required', 403);
}
const profile: ProfileResponse = {
id: user.id,
name: user.name,
email: user.email,
emailVerified: true,
premium: true,
premiumFromOrganization: false,
usesKeyConnector: false,
masterPasswordHint: null,
culture: 'en-US',
twoFactorEnabled: false,
key: user.key,
privateKey: user.privateKey,
accountKeys: null,
securityStamp: user.securityStamp || user.id,
organizations: [],
providers: [],
providerOrganizations: [],
forcePasswordReset: false,
avatarColor: null,
creationDate: user.createdAt,
object: 'profile',
};
return jsonResponse(profile);
}
// PUT /api/accounts/profile
export async function handleUpdateProfile(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) {
return errorResponse('User not found', 404);
}
let body: { name?: string; masterPasswordHint?: string };
let body: { email?: string };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
if (body.name) {
user.name = body.name;
const email = String(body.email || '').trim().toLowerCase();
if (!email) {
return errorResponse('Email is required', 400);
}
user.updatedAt = new Date().toISOString();
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;
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
return jsonResponse(toProfile(user, env));
}
// PUT /api/accounts/profile
export async function handleUpdateProfile(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: {
masterPasswordHint?: string | null;
};
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
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 handleGetProfile(request, env, userId);
return jsonResponse(toProfile(user, env));
}
// POST /api/accounts/keys
export async function handleSetKeys(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) {
@@ -181,6 +374,7 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
}
let body: {
masterPasswordHash?: string;
key?: string;
encryptedPrivateKey?: string;
publicKey?: string;
@@ -192,15 +386,25 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
return errorResponse('Invalid JSON', 400);
}
if (body.key) user.key = body.key;
if (body.encryptedPrivateKey) user.privateKey = body.encryptedPrivateKey;
if (body.publicKey) user.publicKey = body.publicKey;
// Require password verification before allowing key replacement.
if (!body.masterPasswordHash) {
return errorResponse('masterPasswordHash is required', 400);
}
const passwordValid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash, user.email);
if (!passwordValid) {
return errorResponse('Invalid password', 400);
}
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);
@@ -208,11 +412,265 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
return handleGetProfile(request, env, userId);
}
// POST/PUT /api/accounts/password
export async function handleChangePassword(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: {
masterPasswordHash?: string;
currentPasswordHash?: string;
newMasterPasswordHash?: string;
key?: string;
newKey?: string;
encryptedPrivateKey?: string;
newEncryptedPrivateKey?: string;
publicKey?: string;
newPublicKey?: string;
kdf?: number;
kdfIterations?: number;
kdfMemory?: number;
kdfParallelism?: number;
};
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const currentHash = body.currentPasswordHash || body.masterPasswordHash;
if (!currentHash) return errorResponse('Current password hash is required', 400);
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
if (!valid) return errorResponse('Invalid password', 400);
if (!body.newMasterPasswordHash) {
return errorResponse('newMasterPasswordHash is required', 400);
}
const nextKey = body.newKey || body.key;
const nextPrivateKey = body.newEncryptedPrivateKey || body.encryptedPrivateKey;
const nextPublicKey = body.newPublicKey || body.publicKey;
if (nextKey && !looksLikeEncString(nextKey)) {
return errorResponse('new key is not a valid encrypted string', 400);
}
if (nextPrivateKey && !looksLikeEncString(nextPrivateKey)) {
return errorResponse('new encryptedPrivateKey is not a valid encrypted string', 400);
}
const kdfErr = validateKdfParams(body.kdf ?? user.kdfType, body.kdfIterations, body.kdfMemory, body.kdfParallelism);
if (kdfErr) return errorResponse(kdfErr, 400);
user.masterPasswordHash = await auth.hashPasswordServer(body.newMasterPasswordHash, user.email);
if (nextKey) user.key = nextKey;
if (nextPrivateKey) user.privateKey = nextPrivateKey;
if (nextPublicKey) user.publicKey = nextPublicKey;
if (typeof body.kdf === 'number') user.kdfType = body.kdf;
if (typeof body.kdfIterations === 'number') user.kdfIterations = body.kdfIterations;
if (typeof body.kdfMemory === 'number') user.kdfMemory = body.kdfMemory;
if (typeof body.kdfParallelism === 'number') user.kdfParallelism = body.kdfParallelism;
user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
await storage.createAuditLog({
id: generateUUID(),
actorUserId: user.id,
action: 'user.password.change',
targetType: 'user',
targetId: user.id,
metadata: JSON.stringify({ email: user.email }),
createdAt: user.updatedAt,
});
return new Response(null, { status: 200 });
}
// GET /api/accounts/totp
export async function handleGetTotpStatus(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);
return jsonResponse({
enabled: !!user.totpSecret,
object: 'twoFactor',
});
}
// PUT /api/accounts/totp
// enable: { enabled: true, secret: "...", token: "123456" }
// disable: { enabled: false, masterPasswordHash: "..." }
export async function handleSetTotpStatus(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: { enabled?: boolean; secret?: string; token?: string; masterPasswordHash?: string };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
if (body.enabled === true) {
const normalizedSecret = normalizeTotpSecret(body.secret || '');
if (!isTotpEnabled(normalizedSecret)) {
return errorResponse('Invalid TOTP secret', 400);
}
if (!body.token) {
return errorResponse('TOTP token is required', 400);
}
const verified = await verifyTotpToken(normalizedSecret, body.token);
if (!verified) {
return errorResponse('Invalid TOTP token', 400);
}
user.totpSecret = normalizedSecret;
if (!user.totpRecoveryCode) {
user.totpRecoveryCode = createRecoveryCode();
}
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
return jsonResponse({ enabled: true, recoveryCode: user.totpRecoveryCode, object: 'twoFactor' });
}
if (body.enabled === false) {
if (!body.masterPasswordHash) {
return errorResponse('masterPasswordHash is required to disable TOTP', 400);
}
const valid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash, user.email);
if (!valid) return errorResponse('Invalid password', 400);
user.totpSecret = null;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
return jsonResponse({ enabled: false, object: 'twoFactor' });
}
return errorResponse('enabled must be true or false', 400);
}
// POST /api/accounts/totp/recovery-code
export async function handleGetTotpRecoveryCode(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: Record<string, string | undefined>;
try {
const contentType = request.headers.get('content-type') || '';
if (contentType.includes('application/x-www-form-urlencoded')) {
const formData = await request.formData();
body = Object.fromEntries(formData.entries()) as Record<string, string>;
} else {
body = await request.json();
}
} catch {
return errorResponse('Invalid JSON', 400);
}
const currentHash = String(body.masterPasswordHash || body.master_password_hash || body.password || '').trim();
if (!currentHash) return errorResponse('masterPasswordHash is required', 400);
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
if (!valid) return errorResponse('Invalid password', 400);
if (!user.totpRecoveryCode) {
user.totpRecoveryCode = createRecoveryCode();
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
}
return jsonResponse({
code: user.totpRecoveryCode,
object: 'twoFactorRecover',
});
}
// POST /identity/accounts/recover-2fa
// Disable TOTP by recovery code + password, then rotate recovery code.
export async function handleRecoverTwoFactor(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const rateLimit = new RateLimitService(env.DB);
let body: Record<string, string | undefined>;
try {
const contentType = request.headers.get('content-type') || '';
if (contentType.includes('application/x-www-form-urlencoded')) {
const formData = await request.formData();
body = Object.fromEntries(formData.entries()) as Record<string, string>;
} else {
body = await request.json();
}
} catch {
return errorResponse('Invalid JSON', 400);
}
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 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) {
return errorResponse(
`Too many failed recovery attempts. Try again in ${Math.ceil((recoverAttemptCheck.retryAfterSeconds || 60) / 60)} minutes.`,
429
);
}
if (!email || !masterPasswordHash || !recoveryCode) {
return errorResponse('Email, masterPasswordHash and recoveryCode are required', 400);
}
const user = await storage.getUser(email);
if (!user || user.status !== 'active') {
await rateLimit.recordFailedLogin(recoverLimitKey);
return errorResponse('Invalid credentials or recovery code', 400);
}
const validPassword = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
if (!validPassword) {
await rateLimit.recordFailedLogin(recoverLimitKey);
return errorResponse('Invalid credentials or recovery code', 400);
}
if (!recoveryCodeEquals(recoveryCode, user.totpRecoveryCode)) {
await rateLimit.recordFailedLogin(recoverLimitKey);
return errorResponse('Invalid credentials or recovery code', 400);
}
user.totpSecret = null;
user.totpRecoveryCode = createRecoveryCode();
user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
await rateLimit.clearLoginAttempts(recoverLimitKey);
return jsonResponse({
success: true,
twoFactorEnabled: false,
newRecoveryCode: user.totpRecoveryCode,
object: 'twoFactorRecovery',
});
}
// GET /api/accounts/revision-date
export async function handleGetRevisionDate(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const revisionDate = await storage.getRevisionDate(userId);
// Return as milliseconds timestamp (Bitwarden format)
const timestamp = new Date(revisionDate).getTime();
return jsonResponse(timestamp);
@@ -239,7 +697,7 @@ export async function handleVerifyPassword(request: Request, env: Env, userId: s
return errorResponse('masterPasswordHash is required', 400);
}
const valid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash);
const valid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash, user.email);
if (!valid) {
return errorResponse('Invalid password', 400);
}
+288
View File
@@ -0,0 +1,288 @@
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';
}
function randomHex(bytes: number): string {
const data = crypto.getRandomValues(new Uint8Array(bytes));
return Array.from(data).map(v => v.toString(16).padStart(2, '0')).join('');
}
function buildInviteLink(request: Request, code: string): string {
const url = new URL(request.url);
return `${url.origin}/?invite=${encodeURIComponent(code)}`;
}
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 toInviteResponse(request: Request, invite: Invite): Record<string, unknown> {
return {
code: invite.code,
status: invite.status,
createdBy: invite.createdBy,
usedBy: invite.usedBy,
createdAt: invite.createdAt,
updatedAt: invite.updatedAt,
expiresAt: invite.expiresAt,
inviteLink: buildInviteLink(request, invite.code),
object: 'invite',
};
}
// GET /api/admin/users
export async function handleAdminListUsers(
request: Request,
env: Env,
actorUser: User
): Promise<Response> {
void request;
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const storage = new StorageService(env.DB);
const users = await storage.getAllUsers();
return jsonResponse({
data: users.map(user => ({
id: user.id,
email: user.email,
name: user.name,
role: user.role,
status: user.status,
twoFactorEnabled: !!user.totpSecret,
creationDate: user.createdAt,
revisionDate: user.updatedAt,
object: 'user',
})),
object: 'list',
continuationToken: null,
});
}
// POST /api/admin/invites
export async function handleAdminCreateInvite(
request: Request,
env: Env,
actorUser: User
): Promise<Response> {
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const storage = new StorageService(env.DB);
let body: { expiresInHours?: number } = {};
try {
body = await request.json();
} catch {
body = {};
}
const expiresInHours = Number.isFinite(body.expiresInHours)
? Math.max(1, Math.min(24 * 30, Math.floor(Number(body.expiresInHours))))
: 24 * 7;
const now = new Date();
const expiresAt = new Date(now.getTime() + expiresInHours * 60 * 60 * 1000);
const invite: Invite = {
code: randomHex(20),
createdBy: actorUser.id,
usedBy: null,
expiresAt: expiresAt.toISOString(),
status: 'active',
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
};
await storage.createInvite(invite);
await writeAuditLog(storage, actorUser.id, 'admin.invite.create', 'invite', invite.code, {
expiresInHours,
});
return jsonResponse(toInviteResponse(request, invite), 201);
}
// GET /api/admin/invites
export async function handleAdminListInvites(
request: Request,
env: Env,
actorUser: User
): Promise<Response> {
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const storage = new StorageService(env.DB);
const url = new URL(request.url);
const includeInactive = url.searchParams.get('includeInactive') === 'true';
const invites = await storage.listInvites(includeInactive);
return jsonResponse({
data: invites.map(invite => toInviteResponse(request, invite)),
object: 'list',
continuationToken: null,
});
}
// DELETE /api/admin/invites/:code
export async function handleAdminRevokeInvite(
request: Request,
env: Env,
actorUser: User,
code: string
): Promise<Response> {
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const storage = new StorageService(env.DB);
const revoked = await storage.revokeInvite(code);
if (!revoked) {
return errorResponse('Invite not found or already inactive', 404);
}
await writeAuditLog(storage, actorUser.id, 'admin.invite.revoke', 'invite', code, null);
return new Response(null, { status: 204 });
}
// DELETE /api/admin/invites
export async function handleAdminDeleteAllInvites(
request: Request,
env: Env,
actorUser: User
): Promise<Response> {
void request;
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const storage = new StorageService(env.DB);
const deleted = await storage.deleteAllInvites();
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_all', 'invite', null, {
deleted,
});
return jsonResponse({ deleted }, 200);
}
// PUT /api/admin/users/:id/status
export async function handleAdminSetUserStatus(
request: Request,
env: Env,
actorUser: User,
targetUserId: string
): Promise<Response> {
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
let body: { status?: string };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const nextStatus = body.status === 'banned' ? 'banned' : body.status === 'active' ? 'active' : null;
if (!nextStatus) {
return errorResponse('status must be active or banned', 400);
}
if (targetUserId === actorUser.id && nextStatus !== 'active') {
return errorResponse('You cannot ban yourself', 400);
}
const storage = new StorageService(env.DB);
const target = await storage.getUserById(targetUserId);
if (!target) {
return errorResponse('User not found', 404);
}
target.status = nextStatus;
target.updatedAt = new Date().toISOString();
await storage.saveUser(target);
if (nextStatus === 'banned') {
await storage.deleteRefreshTokensByUserId(target.id);
}
await writeAuditLog(storage, actorUser.id, 'admin.user.status', 'user', target.id, {
status: nextStatus,
});
return jsonResponse({
id: target.id,
email: target.email,
role: target.role,
status: target.status,
object: 'user',
});
}
// DELETE /api/admin/users/:id
export async function handleAdminDeleteUser(
request: Request,
env: Env,
actorUser: User,
targetUserId: string
): Promise<Response> {
void request;
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
if (targetUserId === actorUser.id) {
return errorResponse('You cannot delete yourself', 400);
}
const storage = new StorageService(env.DB);
const target = await storage.getUserById(targetUserId);
if (!target) {
return errorResponse('User not found', 404);
}
// Clean up R2 files before DB cascade deletes the metadata rows.
// 1. Attachment files (keyed by cipherId/attachmentId)
const attachmentMap = await storage.getAttachmentsByUserId(target.id);
for (const [cipherId, attachments] of attachmentMap) {
for (const att of attachments) {
await deleteBlobObject(env, getAttachmentObjectKey(cipherId, att.id));
}
}
// 2. Send files (keyed by sends/sendId/fileId)
const sends = await storage.getAllSends(target.id);
for (const send of sends) {
if (send.type === 1) { // SendType.File
try {
const parsed = JSON.parse(send.data) as Record<string, unknown>;
const fileId = typeof parsed.id === 'string' ? parsed.id : null;
if (fileId) {
await deleteBlobObject(env, getSendFileObjectKey(send.id, fileId));
}
} catch { /* non-file send or bad data, skip */ }
}
}
await storage.deleteRefreshTokensByUserId(target.id);
await storage.deleteUserById(target.id);
await writeAuditLog(storage, actorUser.id, 'admin.user.delete', 'user', target.id, {
email: target.email,
});
return new Response(null, { status: 204 });
}
+131 -62
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}
@@ -198,7 +264,7 @@ export async function handleGetAttachment(
url: downloadUrl,
fileName: attachment.fileName,
key: attachment.key,
size: Number(attachment.size) || 0,
size: String(Number(attachment.size) || 0),
sizeName: attachment.sizeName,
});
}
@@ -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'));
}
+325 -16
View File
@@ -1,9 +1,128 @@
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 };
for (const key of aliases) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
return { present: true, value: source[key] };
}
}
return { present: false, value: undefined };
}
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: 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,
};
}
// Format attachments for API response
export function formatAttachments(attachments: Attachment[]): any[] | null {
@@ -11,7 +130,8 @@ export function formatAttachments(attachments: Attachment[]): any[] | null {
return attachments.map(a => ({
id: a.id,
fileName: a.fileName,
size: Number(a.size) || 0, // Android expects Int, not String
// Bitwarden clients decode attachment size as string in cipher payloads.
size: String(Number(a.size) || 0),
sizeName: a.sizeName,
key: a.key,
url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`, // Android requires non-null url!
@@ -23,9 +143,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, options);
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
return {
// Pass through ALL stored cipher fields (known + unknown)
@@ -47,6 +173,8 @@ export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = [])
object: 'cipher',
collectionIds: [],
attachments: formatAttachments(attachments),
login: normalizedLogin,
sshKey: normalizedSshKey,
encryptedFor: null,
};
}
@@ -57,6 +185,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;
@@ -83,7 +212,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({
@@ -103,7 +232,17 @@ 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> {
if (!folderId) return true;
const folder = await storage.getFolder(folderId);
return !!(folder && folder.userId === userId);
}
// POST /api/ciphers
@@ -136,11 +275,27 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
updatedAt: now,
deletedAt: null,
};
cipher.login = normalizeCipherLoginForStorage(cipher.login);
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
// Prevent referencing a folder owned by another user.
if (cipher.folderId) {
const folderOk = await verifyFolderOwnership(storage, cipher.folderId, userId);
if (!folderOk) return errorResponse('Folder not found', 404);
}
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
@@ -178,11 +333,35 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
updatedAt: new Date().toISOString(),
deletedAt: existingCipher.deletedAt,
};
cipher.login = normalizeCipherLoginForStorage(cipher.login);
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
// Custom fields deletion compatibility:
// - Accept both camelCase "fields" and PascalCase "Fields".
// - For full update (PUT/POST on this endpoint), missing fields means cleared fields.
// This prevents stale custom fields from being resurrected by merge fallback.
const incomingFields = getAliasedProp(cipherData, ['fields', 'Fields']);
if (incomingFields.present) {
cipher.fields = incomingFields.value ?? null;
} else if (request.method === 'PUT' || request.method === 'POST') {
cipher.fields = null;
}
// Prevent referencing a folder owned by another user.
if (cipher.folderId) {
const folderOk = await verifyFolderOwnership(storage, cipher.folderId, userId);
if (!folderOk) return errorResponse('Folder not found', 404);
}
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
@@ -198,9 +377,38 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
cipher.deletedAt = new Date().toISOString();
cipher.updatedAt = cipher.deletedAt;
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)
// Bitwarden clients may call DELETE on a trashed item to purge it permanently.
// For compatibility:
// - If item is active -> soft delete.
// - If item is already soft-deleted -> hard delete.
export async function handleDeleteCipherCompat(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) {
await deleteAllAttachmentsForCipher(env, id);
await storage.deleteCipher(id, userId);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return new Response(null, { status: 204 });
}
return handleDeleteCipher(request, env, userId, id);
}
// DELETE /api/ciphers/:id (permanent)
@@ -216,7 +424,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 });
}
@@ -233,9 +442,14 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
cipher.deletedAt = null;
cipher.updatedAt = new Date().toISOString();
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
@@ -255,6 +469,10 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
}
if (body.folderId !== undefined) {
if (body.folderId) {
const folderOk = await verifyFolderOwnership(storage, body.folderId, userId);
if (!folderOk) return errorResponse('Folder not found', 404);
}
cipher.folderId = body.folderId;
}
if (body.favorite !== undefined) {
@@ -263,9 +481,14 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
cipher.updatedAt = new Date().toISOString();
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
@@ -283,7 +506,93 @@ export async function handleBulkMoveCiphers(request: Request, env: Env, userId:
return errorResponse('ids array is required', 400);
}
await storage.bulkMoveCiphers(body.ids, body.folderId || null, userId);
if (body.folderId) {
const folderOk = await verifyFolderOwnership(storage, body.folderId, userId);
if (!folderOk) return errorResponse('Folder not found', 404);
}
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 });
}
// 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 });
}
+184
View File
@@ -0,0 +1,184 @@
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';
// GET /api/devices/knowndevice
// Compatible with Bitwarden/Vaultwarden behavior:
// - X-Request-Email: base64url(email) without padding
// - X-Device-Identifier: client device identifier
export async function handleKnownDevice(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
const { email, deviceIdentifier } = readKnownDeviceProbe(request);
if (!email || !deviceIdentifier) {
return jsonResponse(false);
}
const known = await storage.isKnownDeviceByEmail(email, deviceIdentifier);
return jsonResponse(known);
}
// GET /api/devices
export async function handleGetDevices(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
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',
})),
object: 'list',
continuationToken: null,
});
}
// 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, 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) {
trustedByIdentifier.set(row.deviceIdentifier, { expiresAt: row.expiresAt, tokenCount: row.tokenCount });
}
const knownIdentifiers = new Set<string>();
const data = devices.map(device => {
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,
online: onlineSet.has(device.deviceIdentifier),
trusted: !!trustedInfo,
trustedTokenCount: trustedInfo?.tokenCount || 0,
trustedUntil: trustedInfo?.expiresAt ? new Date(trustedInfo.expiresAt).toISOString() : null,
object: 'device',
};
});
for (const row of trusted) {
if (knownIdentifiers.has(row.deviceIdentifier)) continue;
data.push({
id: row.deviceIdentifier,
name: 'Unknown device',
identifier: row.deviceIdentifier,
type: 14,
creationDate: '',
revisionDate: '',
online: onlineSet.has(row.deviceIdentifier),
trusted: true,
trustedTokenCount: row.tokenCount,
trustedUntil: row.expiresAt ? new Date(row.expiresAt).toISOString() : null,
object: 'device',
});
}
return jsonResponse({
data,
object: 'list',
continuationToken: null,
});
}
// DELETE /api/devices/authorized
export async function handleRevokeAllTrustedDevices(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const removed = await storage.deleteTrustedTwoFactorTokensByUserId(userId);
return jsonResponse({ success: true, removed });
}
// DELETE /api/devices/authorized/:deviceIdentifier
export async function handleRevokeTrustedDevice(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
const normalized = String(deviceIdentifier || '').trim();
if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB);
const removed = await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
return jsonResponse({ success: true, removed });
}
// DELETE /api/devices/:deviceIdentifier
export async function handleDeleteDevice(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
const normalized = String(deviceIdentifier || '').trim();
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 });
}
// 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 /api/devices/identifier/{deviceIdentifier}/token
// Bitwarden mobile reports push token updates to this endpoint.
// NodeWarden does not implement push notifications, so accept and no-op.
export async function handleUpdateDeviceToken(
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 });
}
+314 -50
View File
@@ -4,6 +4,101 @@ import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
import { LIMITS } from '../config/limits';
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;
// 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): string | null {
if (userSecret && isTotpEnabled(userSecret)) {
return userSecret;
}
return null;
}
function twoFactorRequiredResponse(message: string = 'Two factor required.', includeRecoveryCode: boolean = false): Response {
const providers = includeRecoveryCode
? [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,
Error: 'invalid_grant',
ErrorDescription: message,
ErrorMessage: message,
TwoFactorProviders: customResponse.TwoFactorProviders,
TwoFactorProviders2: customResponse.TwoFactorProviders2,
// Required by current Android parser (nullable value is acceptable).
SsoEmail2faSessionToken: customResponse.SsoEmail2faSessionToken,
MasterPasswordPolicy: customResponse.MasterPasswordPolicy,
CustomResponse: customResponse,
ErrorModel: {
Message: message,
Object: 'error',
},
},
400
);
}
async function recordFailedLoginAndBuildResponse(
rateLimit: RateLimitService,
loginIdentifier: string,
message: string
): Promise<Response> {
const result = await rateLimit.recordFailedLogin(loginIdentifier);
if (result.locked) {
return identityErrorResponse(
`Too many failed login attempts. Account locked for ${Math.ceil(result.retryAfterSeconds! / 60)} minutes.`,
'TooManyRequests',
429
);
}
return identityErrorResponse(message, 'invalid_grant', 400);
}
async function recordFailedTwoFactorAndBuildResponse(
rateLimit: RateLimitService,
loginIdentifier: string
): Promise<Response> {
const failed = await rateLimit.recordFailedLogin(loginIdentifier);
if (failed.locked) {
return identityErrorResponse(
`Too many failed login attempts. Account locked for ${Math.ceil(failed.retryAfterSeconds! / 60)} minutes.`,
'TooManyRequests',
429
);
}
return identityErrorResponse('Two-step token is invalid. Try again.', 'invalid_grant', 400);
}
// POST /identity/connect/token
export async function handleToken(request: Request, env: Env): Promise<Response> {
@@ -25,12 +120,20 @@ 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
const email = body.username?.toLowerCase();
const passwordHash = body.password;
const loginIdentifier = getClientIdentifier(request);
const twoFactorToken = body.twoFactorToken;
const twoFactorProvider = body.twoFactorProvider;
const twoFactorRemember = body.twoFactorRemember;
const loginIdentifier = `${clientIdentifier}:${email}`;
const deviceInfo = readAuthRequestDeviceInfo(body, request);
if (!email || !passwordHash) {
// Bitwarden clients expect OAuth-style error fields.
@@ -52,63 +155,199 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
await rateLimit.recordFailedLogin(loginIdentifier);
return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400);
}
if (user.status !== 'active') {
await rateLimit.recordFailedLogin(loginIdentifier);
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
}
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash);
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
if (!valid) {
// Record failed login attempt
const result = await rateLimit.recordFailedLogin(loginIdentifier);
if (result.locked) {
return identityErrorResponse(
`Too many failed login attempts. Account locked for ${Math.ceil(result.retryAfterSeconds! / 60)} minutes.`,
'TooManyRequests',
429
return recordFailedLoginAndBuildResponse(
rateLimit,
loginIdentifier,
'Username or password is incorrect. Try again'
);
}
// Optional 2FA: enabled only by per-user secret.
let trustedTwoFactorTokenToReturn: string | undefined;
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret);
if (effectiveTotpSecret) {
const canUseRecoveryCode = !!user.totpRecoveryCode;
const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim();
const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim();
let rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim());
const hasProvider = normalizedTwoFactorProvider.length > 0;
const hasToken = normalizedTwoFactorToken.length > 0;
// Upstream-compatible behavior: if 2FA is required and either provider or token is missing,
// respond with a 2FA challenge payload.
if (!hasProvider || !hasToken) {
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
}
let passedByRememberToken = false;
if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_REMEMBER)) {
if (deviceInfo.deviceIdentifier) {
const trustedUserId = await storage.getTrustedTwoFactorDeviceTokenUserId(
normalizedTwoFactorToken,
deviceInfo.deviceIdentifier
);
passedByRememberToken = trustedUserId === user.id;
}
// Remember token missing/invalid/expired should re-enter the 2FA challenge flow.
if (!passedByRememberToken) {
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
}
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken);
if (!totpOk) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
} 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);
}
user.totpSecret = null;
user.totpRecoveryCode = createRecoveryCode();
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
rememberRequested = false;
} else {
// Unsupported provider for this server profile behaves as an invalid 2FA attempt.
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
// Upstream behavior: do not issue a new remember token when auth itself used remember provider.
if (rememberRequested && !passedByRememberToken && deviceInfo.deviceIdentifier) {
trustedTwoFactorTokenToReturn = createRefreshToken();
await storage.saveTrustedTwoFactorDeviceToken(
trustedTwoFactorTokenToReturn,
user.id,
deviceInfo.deviceIdentifier,
Date.now() + TWO_FACTOR_REMEMBER_TTL_MS
);
}
return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400);
}
// Persist device only after successful password + (optional) 2FA verification.
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,
expires_in: LIMITS.auth.accessTokenTtlSeconds,
token_type: 'Bearer',
refresh_token: refreshToken,
...(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);
} else if (grantType === 'send_access') {
const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
if (!sendAccessLimit.allowed) {
return identityErrorResponse(
`Rate limit exceeded. Try again in ${sendAccessLimit.retryAfterSeconds} seconds.`,
'TooManyRequests',
429
);
}
const sendId = String(body.send_id || body.sendId || '').trim();
if (!sendId) {
return jsonResponse(
{
error: 'invalid_request',
error_description: 'send_id is required',
send_access_error_type: 'invalid_send_id',
ErrorModel: {
Message: 'send_id is required',
Object: 'error',
},
},
400
);
}
const passwordHashB64 = String(
body.password_hash_b64 || body.passwordHashB64 || body.passwordHash || body.password_hash || ''
).trim() || null;
const password = String(body.password || '').trim() || null;
const result = await issueSendAccessToken(
env,
sendId,
passwordHashB64,
password,
rateLimit,
`${clientIdentifier}:send-password`
);
if ('error' in result) {
return result.error;
}
return jsonResponse({
access_token: result.token,
expires_in: LIMITS.auth.sendAccessTokenTtlSeconds,
token_type: 'Bearer',
scope: 'api.send',
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) {
@@ -120,11 +359,15 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return identityErrorResponse('Invalid refresh token', 'invalid_grant', 400);
}
// Revoke old refresh token (prevent reuse)
await storage.deleteRefreshToken(refreshToken);
// Keep a short overlap window for old refresh token to absorb
// concurrent refresh requests from multiple client contexts.
await storage.constrainRefreshTokenExpiry(
refreshToken,
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,
@@ -133,30 +376,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);
@@ -186,8 +421,10 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
// Return default KDF settings even if user doesn't exist (to prevent user enumeration)
const kdfType = user?.kdfType ?? 0;
const kdfIterations = user?.kdfIterations ?? LIMITS.auth.defaultKdfIterations;
const kdfMemory = user?.kdfMemory;
const kdfParallelism = user?.kdfParallelism;
// Use ?? null so non-existent users return null (not undefined/omitted) for these fields,
// matching the response shape of real PBKDF2 users and reducing enumeration signal.
const kdfMemory = user?.kdfMemory ?? null;
const kdfParallelism = user?.kdfParallelism ?? null;
return jsonResponse({
kdf: kdfType,
@@ -196,3 +433,30 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
kdfParallelism: kdfParallelism,
});
}
// POST /identity/connect/revocation
// Best-effort OAuth token revocation endpoint.
// RFC 7009 allows returning 200 even if token is unknown.
export async function handleRevocation(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
let body: Record<string, string>;
const contentType = request.headers.get('content-type') || '';
try {
if (contentType.includes('application/x-www-form-urlencoded')) {
const formData = await request.formData();
body = Object.fromEntries(formData.entries()) as Record<string, string>;
} else {
body = await request.json();
}
} catch {
return new Response(null, { status: 200 });
}
const token = String(body.token || '').trim();
if (token) {
await storage.deleteRefreshToken(token);
}
return new Response(null, { status: 200 });
}
+82 -45
View File
@@ -1,22 +1,33 @@
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 { normalizeCipherLoginForStorage, normalizeCipherSshKeyForCompatibility } from './ciphers';
// Bitwarden client import request format
interface CiphersImportRequest {
ciphers: Array<{
id?: string | null;
type: number;
name: string;
name?: string | null;
notes?: string | null;
favorite?: boolean;
reprompt?: number;
sshKey?: any | null;
key?: string | null;
login?: {
uris?: Array<{ uri: string | null; match?: number | null }> | null;
username?: string | null;
password?: string | null;
totp?: string | null;
autofillOnPageLoad?: boolean | null;
fido2Credentials?: any[] | null;
uri?: string | null;
passwordRevisionDate?: string | null;
[key: string]: any;
} | null;
card?: {
cardholderName?: string | null;
@@ -57,6 +68,7 @@ interface CiphersImportRequest {
password: string;
lastUsedDate: string;
}> | null;
[key: string]: any;
}>;
folders: Array<{
name: string;
@@ -81,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 {
@@ -93,6 +107,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
const ciphers = importData.ciphers || [];
const folderRelationships = importData.folderRelationships || [];
if (folders.length + ciphers.length > LIMITS.performance.importItemLimit) {
return errorResponse(`Import exceeds maximum of ${LIMITS.performance.importItemLimit} items`, 400);
}
const now = new Date().toISOString();
const batchChunkSize = LIMITS.performance.bulkMoveChunkSize;
@@ -138,77 +156,88 @@ 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,
id: generateUUID(),
userId: userId,
type: c.type as CipherType,
folderId: folderId,
name: c.name || 'Untitled',
notes: c.notes || null,
favorite: c.favorite || false,
name: c.name ?? 'Untitled',
notes: c.notes ?? null,
favorite: c.favorite ?? false,
login: c.login ? {
username: c.login.username || null,
password: c.login.password || null,
...c.login,
username: c.login.username ?? null,
password: c.login.password ?? null,
uris: c.login.uris?.map(u => ({
uri: u.uri || null,
...u,
uri: u.uri ?? null,
uriChecksum: null,
match: u.match ?? null,
})) || null,
totp: c.login.totp || null,
autofillOnPageLoad: null,
fido2Credentials: null,
uri: null,
passwordRevisionDate: null,
totp: c.login.totp ?? null,
autofillOnPageLoad: c.login.autofillOnPageLoad ?? null,
fido2Credentials: c.login.fido2Credentials ?? null,
uri: c.login.uri ?? null,
passwordRevisionDate: c.login.passwordRevisionDate ?? null,
} : null,
card: c.card ? {
cardholderName: c.card.cardholderName || null,
brand: c.card.brand || null,
number: c.card.number || null,
expMonth: c.card.expMonth || null,
expYear: c.card.expYear || null,
code: c.card.code || null,
...c.card,
cardholderName: c.card.cardholderName ?? null,
brand: c.card.brand ?? null,
number: c.card.number ?? null,
expMonth: c.card.expMonth ?? null,
expYear: c.card.expYear ?? null,
code: c.card.code ?? null,
} : null,
identity: c.identity ? {
title: c.identity.title || null,
firstName: c.identity.firstName || null,
middleName: c.identity.middleName || null,
lastName: c.identity.lastName || null,
address1: c.identity.address1 || null,
address2: c.identity.address2 || null,
address3: c.identity.address3 || null,
city: c.identity.city || null,
state: c.identity.state || null,
postalCode: c.identity.postalCode || null,
country: c.identity.country || null,
company: c.identity.company || null,
email: c.identity.email || null,
phone: c.identity.phone || null,
ssn: c.identity.ssn || null,
username: c.identity.username || null,
passportNumber: c.identity.passportNumber || null,
licenseNumber: c.identity.licenseNumber || null,
...c.identity,
title: c.identity.title ?? null,
firstName: c.identity.firstName ?? null,
middleName: c.identity.middleName ?? null,
lastName: c.identity.lastName ?? null,
address1: c.identity.address1 ?? null,
address2: c.identity.address2 ?? null,
address3: c.identity.address3 ?? null,
city: c.identity.city ?? null,
state: c.identity.state ?? null,
postalCode: c.identity.postalCode ?? null,
country: c.identity.country ?? null,
company: c.identity.company ?? null,
email: c.identity.email ?? null,
phone: c.identity.phone ?? null,
ssn: c.identity.ssn ?? null,
username: c.identity.username ?? null,
passportNumber: c.identity.passportNumber ?? null,
licenseNumber: c.identity.licenseNumber ?? null,
} : null,
secureNote: c.secureNote || null,
secureNote: c.secureNote ?? null,
fields: c.fields?.map(f => ({
name: f.name || null,
value: f.value || null,
...f,
name: f.name ?? null,
value: f.value ?? null,
type: f.type,
linkedId: f.linkedId ?? null,
})) || null,
passwordHistory: c.passwordHistory || null,
reprompt: c.reprompt || 0,
sshKey: null,
key: null,
passwordHistory: c.passwordHistory ?? null,
reprompt: c.reprompt ?? 0,
sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null),
key: (c as any).key ?? null,
createdAt: now,
updatedAt: now,
deletedAt: null,
};
cipher.login = normalizeCipherLoginForStorage(cipher.login);
cipherRows.push(cipher);
cipherMapRows.push({ index: i, sourceId, id: cipher.id });
}
if (cipherRows.length > 0) {
@@ -241,7 +270,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
View File
@@ -0,0 +1,3 @@
export * from './sends-shared';
export * from './sends-private';
export * from './sends-public';
-57
View File
@@ -1,57 +0,0 @@
import { Env, DEFAULT_DEV_SECRET } from '../types';
import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse, htmlResponse } from '../utils/response';
import { renderRegisterPageHTML } from '../setup/pageTemplate';
import { LIMITS } from '../config/limits';
type JwtSecretState = 'missing' | 'default' | 'too_short';
function getJwtSecretState(env: Env): JwtSecretState | null {
const secret = (env.JWT_SECRET || '').trim();
if (!secret) return 'missing';
// Block common "forgot to change" sample value (matches .dev.vars.example)
if (secret === DEFAULT_DEV_SECRET) return 'default';
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
return null;
}
async function handleRegisterPage(request: Request, env: Env, jwtState: JwtSecretState | null): Promise<Response> {
const storage = new StorageService(env.DB);
const disabled = await storage.isSetupDisabled();
if (disabled) {
return new Response(null, { status: 404 });
}
return htmlResponse(renderRegisterPageHTML(jwtState));
}
// GET / - Setup page
export async function handleSetupPage(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
const disabled = await storage.isSetupDisabled();
if (disabled) {
return new Response(null, { status: 404 });
}
// 引导页内会处理 JWT_SECRET 检测与分流(坏密钥停留在修复步骤)。
const jwtState = getJwtSecretState(env);
return handleRegisterPage(request, env, jwtState);
}
// GET /setup/status
export async function handleSetupStatus(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
const registered = await storage.isRegistered();
const disabled = await storage.isSetupDisabled();
return jsonResponse({ registered, disabled });
}
// POST /setup/disable
export async function handleDisableSetup(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
const registered = await storage.isRegistered();
if (!registered) {
return errorResponse('Registration required', 403);
}
await storage.setSetupDisabled();
return jsonResponse({ success: true });
}
+78 -41
View File
@@ -2,14 +2,25 @@ import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } fr
import { StorageService } from '../services/storage';
import { errorResponse } from '../utils/response';
import { cipherToResponse } from './ciphers';
import { sendToResponse } from './sends';
import { LIMITS } from '../config/limits';
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'}`;
@@ -19,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
@@ -42,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) {
@@ -60,6 +123,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
const ciphers = await storage.getAllCiphers(userId);
const folders = await storage.getAllFolders(userId);
const sends = await storage.getAllSends(userId);
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId);
// Build profile response
@@ -71,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: false,
twoFactorEnabled: !!user.totpSecret,
key: user.key,
privateKey: user.privateKey,
accountKeys: null,
accountKeys: buildAccountKeys(user),
securityStamp: user.securityStamp || user.id,
organizations: [],
providers: [],
@@ -91,7 +155,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
@@ -115,43 +179,16 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
object: 'domains',
},
policies: [],
sends: [],
sends: sends.map(sendToResponse),
// 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,
+70 -15
View File
@@ -1,35 +1,76 @@
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';
// Per-isolate flags. Each Worker isolate may have its own copy of these flags.
// initializeDatabase() only validates schema presence, so retries are cheap.
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;
if (!dbInitPromise) {
dbInitPromise = (async () => {
const storage = new StorageService(env.DB);
await storage.initializeDatabase();
dbInitialized = true;
dbInitError = null;
})()
.catch((error: unknown) => {
console.error('Failed to initialize database:', error);
dbInitError = error instanceof Error ? error.message : 'Unknown database initialization error';
})
.finally(() => {
dbInitPromise = null;
});
}
await dbInitPromise;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
// Auto-initialize database on first request
if (!dbInitialized) {
try {
const storage = new StorageService(env.DB);
await storage.initializeDatabase();
dbInitialized = true;
dbInitError = null;
} catch (error) {
console.error('Failed to initialize database:', error);
dbInitError = error instanceof Error ? error.message : 'Unknown database initialization error';
}
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.
console.error('DB init error (not forwarded to client):', dbInitError);
const resp = jsonResponse(
{
error: 'Database not initialized',
error_description: dbInitError,
error_description: 'Database initialization failed. Check server logs for details.',
ErrorModel: {
Message: dbInitError,
Message: 'Service temporarily unavailable',
Object: 'error',
},
},
@@ -41,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;
}
+283
View File
@@ -0,0 +1,283 @@
import type { Env, User } from './types';
import { errorResponse, jsonResponse } from './utils/response';
import {
handleGetProfile,
handleUpdateProfile,
handleSetKeys,
handleGetRevisionDate,
handleVerifyPassword,
handleChangePassword,
handleGetTotpStatus,
handleSetTotpStatus,
handleGetTotpRecoveryCode,
} from './handlers/accounts';
import {
handleGetCiphers,
handleGetCipher,
handleCreateCipher,
handleUpdateCipher,
handleDeleteCipher,
handleDeleteCipherCompat,
handlePermanentDeleteCipher,
handleRestoreCipher,
handlePartialUpdateCipher,
handleBulkMoveCiphers,
handleBulkDeleteCiphers,
handleBulkPermanentDeleteCiphers,
handleBulkRestoreCiphers,
} 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/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/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 === '/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;
}
+50
View File
@@ -0,0 +1,50 @@
import type { Env } from './types';
import {
handleGetAuthorizedDevices,
handleGetDevices,
handleRevokeAllTrustedDevices,
handleRevokeTrustedDevice,
handleDeleteAllDevices,
handleDeleteDevice,
handleUpdateDeviceToken,
} 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 === 'DELETE') {
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
return handleDeleteDevice(request, env, userId, deviceIdentifier);
}
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);
}
return null;
}
+328
View File
@@ -0,0 +1,328 @@
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 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);
}
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/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({
version: LIMITS.compatibility.bitwardenServerVersion,
gitHash: 'nodewarden',
server: null,
environment: {
vault: origin,
api: origin + '/api',
identity: origin + '/identity',
notifications: origin + '/notifications',
icons: origin,
sso: '',
},
_icon_service_url: buildIconServiceTemplate(origin),
_icon_service_csp: buildIconServiceCsp(origin),
featureStates: {
'duo-redirect': true,
'email-verification': true,
'pm-19051-send-email-verification': false,
'unauth-ui-refresh': true,
},
object: 'config',
});
}
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;
}
+98 -508
View File
@@ -1,553 +1,143 @@
import { Env, DEFAULT_DEV_SECRET } from './types';
import { DEFAULT_DEV_SECRET, Env } from './types';
import { AuthService } from './services/auth';
import { RateLimitService, getClientIdentifier } from './services/ratelimit';
import { handleCors, errorResponse, jsonResponse } from './utils/response';
import { handleCors, errorResponse } from './utils/response';
import { LIMITS } from './config/limits';
import { handleAuthenticatedRoute } from './router-authenticated';
import { handlePublicRoute } from './router-public';
// Identity handlers
import { handleToken, handlePrelogin } from './handlers/identity';
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
const secret = (env.JWT_SECRET || '').trim();
if (!secret) return 'missing';
if (secret === DEFAULT_DEV_SECRET) return 'default';
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
return null;
}
// Account handlers
import { handleRegister, handleGetProfile, handleUpdateProfile, handleSetKeys, handleGetRevisionDate, handleVerifyPassword } from './handlers/accounts';
function isImportBypassRequest(request: Request, path: string, method: string): boolean {
if (request.headers.get('X-NodeWarden-Import') !== '1') return false;
// Cipher handlers
import {
handleGetCiphers,
handleGetCipher,
handleCreateCipher,
handleUpdateCipher,
handleDeleteCipher,
handlePermanentDeleteCipher,
handleRestoreCipher,
handlePartialUpdateCipher,
handleBulkMoveCiphers,
} from './handlers/ciphers';
// Folder handlers
import {
handleGetFolders,
handleGetFolder,
handleCreateFolder,
handleUpdateFolder,
handleDeleteFolder
} from './handlers/folders';
// Sync handler
import { handleSync } from './handlers/sync';
// Setup handlers
import { handleSetupPage, handleSetupStatus, handleDisableSetup } from './handlers/setup';
// Import handler
import { handleCiphersImport } from './handlers/import';
// Attachment handlers
import {
handleCreateAttachment,
handleUploadAttachment,
handleGetAttachment,
handleDeleteAttachment,
handlePublicDownloadAttachment,
} from './handlers/attachments';
function isSameOriginWriteRequest(request: Request): boolean {
const targetOrigin = new URL(request.url).origin;
const origin = request.headers.get('Origin');
if (origin) {
return origin === targetOrigin;
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;
}
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;
}
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 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 });
}
}
export async function handleRequest(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;
const method = request.method;
const clientId = getClientIdentifier(request);
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}:${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',
},
}
);
}
// Handle CORS preflight
if (method === 'OPTIONS') {
return handleCors(request);
}
// Route matching
try {
// Setup page (root)
if (path === '/' && method === 'GET') {
return handleSetupPage(request, env);
}
// Setup status
if (path === '/setup/status' && method === 'GET') {
return handleSetupStatus(request, env);
}
// Disable setup page (one-way)
if (path === '/setup/disable' && method === 'POST') {
if (!isSameOriginWriteRequest(request)) {
return errorResponse('Forbidden origin', 403);
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) ||
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);
}
return handleDisableSetup(request, env);
}
// 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',
},
});
}
const publicResponse = await handlePublicRoute(request, env, path, method, enforcePublicRateLimit);
if (publicResponse) return publicResponse;
// 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);
}
// Notifications hub (stub - no auth required, return 200 for connection)
if (path.startsWith('/notifications/')) {
return new Response(null, { status: 200 });
}
// Known device check (no auth required) - returns plain string "true" or "false"
if (path.startsWith('/api/devices/knowndevice')) {
return new Response('true', {
headers: {
'Content-Type': 'text/plain',
},
});
}
// Identity endpoints (no auth required)
if (path === '/identity/connect/token' && method === 'POST') {
return handleToken(request, env);
}
if (path === '/identity/accounts/prelogin' && method === 'POST') {
return handlePrelogin(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,
'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, but only works once)
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 = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_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 clientId = getClientIdentifier(request);
if (currentUser.status !== 'active') {
return errorResponse('Account is disabled', 403);
}
// Dedicated read rate limiting for heavy sync endpoint.
if (path === '/api/sync' && method === 'GET') {
if (!isImportBypassRequest(request, path, method)) {
const rateLimit = new RateLimitService(env.DB);
const rateLimitCheck = await rateLimit.consumeSyncReadBudget(userId + ':' + clientId + ':sync');
const rateLimitCheck = await rateLimit.consumeBudget(`${userId}:api`, LIMITS.rateLimit.apiRequestsPerMinute);
if (!rateLimitCheck.allowed) {
return new Response(JSON.stringify({
error: 'Too many requests',
error_description: `Sync 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',
},
}
);
}
}
// API rate limiting only for write operations (keep reads frictionless)
const isWriteMethod = method === 'POST' || method === 'PUT' || method === 'DELETE' || method === 'PATCH';
if (isWriteMethod) {
const rateLimit = new RateLimitService(env.DB);
const rateLimitCheck = await rateLimit.consumeApiWriteBudget(userId + ':' + clientId + ':write');
const authenticatedResponse = await handleAuthenticatedRoute(request, env, userId, currentUser, path, method);
if (authenticatedResponse) return authenticatedResponse;
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',
},
});
}
}
// Block account operations that could change password or delete user
if (method === 'POST' || method === 'PUT' || method === 'DELETE') {
const blockedAccountPaths = new Set([
'/api/accounts/password',
'/api/accounts/change-password',
'/api/accounts/set-password',
'/api/accounts/master-password',
'/api/accounts/delete',
'/api/accounts/delete-account',
'/api/accounts/delete-vault',
]);
if (blockedAccountPaths.has(path)) {
return errorResponse('Not implemented in single-user mode', 501);
}
}
// Account endpoints
if (path === '/api/accounts/profile') {
if (method === 'GET') return handleGetProfile(request, env, userId);
if (method === 'PUT') return handleUpdateProfile(request, env, userId);
}
if (path === '/api/accounts/keys' && method === 'POST') {
return handleSetKeys(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 handleDeleteCipher(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 });
}
}
// Sends endpoint (stub - not implemented)
if (path === '/api/sends' || path.startsWith('/api/sends/')) {
if (method === 'GET') {
return jsonResponse({ data: [], object: 'list', continuationToken: null });
}
}
// 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 (stub) - for authenticated requests
if (path === '/api/devices' && method === 'GET') {
return jsonResponse({ data: [], object: 'list', continuationToken: null });
}
// Not found
return errorResponse('Not found', 404);
} catch (error) {
console.error('Request error:', error);
return errorResponse('Internal server error', 500);
+96 -22
View File
@@ -2,6 +2,16 @@ import { Env, JWTPayload, User } from '../types';
import { verifyJWT, createJWT, createRefreshToken } from '../utils/jwt';
import { StorageService } from './storage';
// Server-side iterations for second-layer hashing.
// The client already does heavy PBKDF2 (600k iterations).
// 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;
@@ -9,41 +19,74 @@ export class AuthService {
this.storage = new StorageService(env.DB);
}
// Verify password hash (compare with stored hash)
async verifyPassword(inputHash: string, storedHash: string): Promise<boolean> {
const input = new TextEncoder().encode(inputHash);
const stored = new TextEncoder().encode(storedHash);
if (input.length !== stored.length) return false;
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
// Result is prefixed with "$s$" to distinguish from legacy raw client hashes.
async hashPasswordServer(clientHash: string, email: string): Promise<string> {
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(clientHash),
'PBKDF2',
false,
['deriveBits']
);
const salt = new TextEncoder().encode(email.toLowerCase().trim());
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: SERVER_HASH_ITERATIONS },
keyMaterial,
256
);
const bytes = new Uint8Array(bits);
let binary = '';
for (const b of bytes) binary += String.fromCharCode(b);
return '$s$' + btoa(binary);
}
// Verify password: hash the input the same way, then constant-time compare.
async verifyPassword(inputHash: string, storedHash: string, email?: string): Promise<boolean> {
// New server-hashed passwords are prefixed with "$s$".
// Legacy accounts (created before the upgrade) store raw client hashes without prefix.
if (email && storedHash.startsWith('$s$')) {
const serverHash = await this.hashPasswordServer(inputHash, email);
return this.constantTimeEquals(serverHash, storedHash);
}
// Legacy path: direct constant-time comparison of raw client hashes.
return this.constantTimeEquals(inputHash, storedHash);
}
private constantTimeEquals(a: string, b: string): boolean {
const encA = new TextEncoder().encode(a);
const encB = new TextEncoder().encode(b);
if (encA.length !== encB.length) return false;
let diff = 0;
for (let i = 0; i < input.length; i++) {
diff |= input[i] ^ stored[i];
for (let i = 0; i < encA.length; i++) {
diff |= encA[i] ^ encB[i];
}
return diff === 0;
}
// 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(' ');
@@ -54,26 +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,
};
}
+509
View File
@@ -0,0 +1,509 @@
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_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 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
),
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,
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,
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}`;
}
export function isBackupDueNow(
destination: BackupDestinationRecord,
now: Date,
windowMinutes: number = BACKUP_SCHEDULER_WINDOW_MINUTES
): boolean {
if (!destination.schedule.enabled) return false;
const intervalMs = destination.schedule.intervalHours * 60 * 60 * 1000;
const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000;
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
if (!lastAttemptAt || !Number.isFinite(lastAttemptAt.getTime())) return true;
return now.getTime() - lastAttemptAt.getTime() >= Math.max(0, intervalMs - toleranceMs);
}
+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;
}
}
+198 -76
View File
@@ -1,33 +1,22 @@
import { LIMITS } from '../config/limits';
// D1-backed rate limiting.
// Notes:
// - Login attempts are tracked per client IP.
// - API rate is tracked per identifier per fixed window.
// Rate limiting service.
// - Login attempts: D1-backed (low volume, security-critical, needs cross-colo persistence).
// - API budgets: Cloudflare Cache API (high volume, auto-expires, zero D1 writes).
// Rate limit configuration
const CONFIG = {
// Friendly default: short cooldown instead of long lockouts.
LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts,
LOGIN_LOCKOUT_MINUTES: LIMITS.rateLimit.loginLockoutMinutes,
// Write operations only (POST/PUT/DELETE/PATCH) should use this budget.
API_WRITE_REQUESTS_PER_MINUTE: LIMITS.rateLimit.apiWriteRequestsPerMinute,
// Dedicated budget for GET /api/sync reads.
SYNC_READ_REQUESTS_PER_MINUTE: LIMITS.rateLimit.syncReadRequestsPerMinute,
API_WINDOW_SECONDS: LIMITS.rateLimit.apiWindowSeconds,
};
export class RateLimitService {
private static loginIpTableReady = false;
private static lastLoginIpCleanupAt = 0;
private static lastApiWindowCleanupAt = 0;
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.rateLimit.cleanupProbability;
private static readonly LOGIN_IP_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs;
private static readonly API_WINDOW_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.apiWindowCleanupIntervalMs;
private static readonly LOGIN_IP_RETENTION_MS = LIMITS.rateLimit.loginIpRetentionMs;
private static readonly API_WINDOW_RETENTION_WINDOWS = LIMITS.rateLimit.apiWindowRetentionWindows;
constructor(private db: D1Database) {}
@@ -52,16 +41,6 @@ export class RateLimitService {
RateLimitService.lastLoginIpCleanupAt = nowMs;
}
private async maybeCleanupApiWindows(windowStart: number, windowSeconds: number): Promise<void> {
if (!this.shouldRunCleanup(RateLimitService.lastApiWindowCleanupAt, RateLimitService.API_WINDOW_CLEANUP_INTERVAL_MS)) {
return;
}
const cutoff = windowStart - (windowSeconds * RateLimitService.API_WINDOW_RETENTION_WINDOWS);
await this.db.prepare('DELETE FROM api_rate_limits WHERE window_start < ?').bind(cutoff).run();
RateLimitService.lastApiWindowCleanupAt = Date.now();
}
private async ensureLoginIpTable(): Promise<void> {
if (RateLimitService.loginIpTableReady) return;
@@ -158,8 +137,9 @@ export class RateLimitService {
await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run();
}
// Atomically consume one budget unit for the current fixed window.
// Uses SQLite UPSERT-with-WHERE so requests at/over limit do not increment.
// Cache API-backed fixed-window rate limiter.
// Uses Cloudflare edge cache instead of D1 — zero database writes, auto-expires via TTL.
// Per-colo isolation is acceptable (matches Cloudflare's own rate limiting behaviour).
private async consumeFixedWindowBudget(
identifier: string,
maxRequests: number,
@@ -168,68 +148,210 @@ export class RateLimitService {
const nowSec = Math.floor(Date.now() / 1000);
const windowStart = nowSec - (nowSec % windowSeconds);
const windowEnd = windowStart + windowSeconds;
await this.maybeCleanupApiWindows(windowStart, windowSeconds);
const ttl = Math.max(1, windowEnd - nowSec);
const writeResult = await this.db
.prepare(
'INSERT INTO api_rate_limits(identifier, window_start, count) VALUES(?, ?, 1) ' +
'ON CONFLICT(identifier, window_start) DO UPDATE SET count = count + 1 ' +
'WHERE api_rate_limits.count < ?'
)
.bind(identifier, windowStart, maxRequests)
.run();
const cache = await caches.open('rate-limit');
const cacheKey = new Request(`https://rl/${identifier}/${windowStart}`);
// No changed row means conflict happened and WHERE prevented increment:
// current count is already at/above configured limit.
if ((writeResult.meta.changes ?? 0) === 0) {
return {
allowed: false,
remaining: 0,
retryAfterSeconds: windowEnd - nowSec,
};
const cached = await cache.match(cacheKey);
let count = 0;
if (cached) {
count = parseInt(await cached.text(), 10) || 0;
}
const row = await this.db
.prepare('SELECT count FROM api_rate_limits WHERE identifier = ? AND window_start = ?')
.bind(identifier, windowStart)
.first<{ count: number }>();
if (!row) {
return {
allowed: true,
remaining: 0,
};
if (count >= maxRequests) {
return { allowed: false, remaining: 0, retryAfterSeconds: ttl };
}
const remaining = Math.max(0, maxRequests - row.count);
return { allowed: true, remaining };
count++;
await cache.put(
cacheKey,
new Response(String(count), {
headers: { 'Cache-Control': `public, max-age=${ttl}` },
})
);
return { allowed: true, remaining: Math.max(0, maxRequests - count) };
}
// Write budget for POST/PUT/DELETE/PATCH requests.
async consumeApiWriteBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
return this.consumeFixedWindowBudget(
identifier,
CONFIG.API_WRITE_REQUESTS_PER_MINUTE,
CONFIG.API_WINDOW_SECONDS
);
// General-purpose fixed-window budget.
// Callers supply an identifier (must be unique per rate-limit category) and the
// per-window maximum. This single method replaces all previous specialised
// budget helpers (write / sync / knownDevice / publicSend).
async consumeBudget(
identifier: string,
maxRequests: number
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
return this.consumeFixedWindowBudget(identifier, maxRequests, CONFIG.API_WINDOW_SECONDS);
}
// Read budget for GET /api/sync.
async consumeSyncReadBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
return this.consumeFixedWindowBudget(
identifier,
CONFIG.SYNC_READ_REQUESTS_PER_MINUTE,
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,
};
}
+263
View File
@@ -0,0 +1,263 @@
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;
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,
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, 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, 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'
);
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.deletedAt
).run();
}
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 = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
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 = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
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 = 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 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 = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
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 = Array.from(new Set(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);
}
+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();
}
+165
View File
@@ -0,0 +1,165 @@
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 || '',
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
): 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, 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, updated_at=excluded.updated_at'
)
.bind(userId, deviceIdentifier, name, type, effectiveSessionStamp, now, now)
.run();
}
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, 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, 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;
}
+131
View File
@@ -0,0 +1,131 @@
// 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\', 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 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, 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_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, 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 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));
}
+138
View File
@@ -0,0 +1,138 @@
import type { User } from '../types';
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
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',
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 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 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 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 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 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'
)
.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, 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, 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.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, 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.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;
}
+393 -488
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+131 -2
View File
@@ -1,10 +1,21 @@
// 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;
}
export type UserRole = 'admin' | 'user';
export type UserStatus = 'active' | 'banned';
// Sample JWT secret used by `.dev.vars.example`.
// If runtime JWT_SECRET equals this value, treat it as unsafe.
export const DEFAULT_DEV_SECRET = 'Enter-your-JWT-key-here-at-least-32-characters';
@@ -24,6 +35,7 @@ export interface User {
id: string;
email: string;
name: string | null;
masterPasswordHint: string | null;
masterPasswordHash: string;
key: string;
privateKey: string | null;
@@ -33,10 +45,34 @@ export interface User {
kdfMemory?: number;
kdfParallelism?: number;
securityStamp: string;
role: UserRole;
status: UserStatus;
totpSecret: string | null;
totpRecoveryCode: string | null;
createdAt: string;
updatedAt: string;
}
export interface Invite {
code: string;
createdBy: string;
usedBy: string | null;
expiresAt: string;
status: 'active' | 'used' | 'revoked' | 'expired';
createdAt: string;
updatedAt: string;
}
export interface AuditLog {
id: string;
actorUserId: string | null;
action: string;
targetType: string | null;
targetId: string | null;
metadata: string | null;
createdAt: string;
}
// Cipher types
export enum CipherType {
Login = 1,
@@ -147,6 +183,85 @@ export interface Folder {
updatedAt: string;
}
export interface Device {
userId: string;
deviceIdentifier: string;
name: string;
type: number;
sessionStamp: string;
createdAt: string;
updatedAt: string;
}
export interface RefreshTokenRecord {
userId: string;
expiresAt: number;
deviceIdentifier: string | null;
deviceSessionStamp: string | null;
}
export interface TrustedDeviceTokenSummary {
deviceIdentifier: string;
expiresAt: number;
tokenCount: number;
}
export enum SendType {
Text = 0,
File = 1,
}
export enum SendAuthType {
Email = 0,
Password = 1,
None = 2,
}
export interface Send {
id: string;
userId: string;
type: SendType;
name: string;
notes: string | null;
data: string;
key: string;
passwordHash: string | null;
passwordSalt: string | null;
passwordIterations: number | null;
authType: SendAuthType;
emails: string | null;
maxAccessCount: number | null;
accessCount: number;
disabled: boolean;
hideEmail: boolean | null;
createdAt: string;
updatedAt: string;
expirationDate: string | null;
deletionDate: string;
}
export interface SendResponse {
id: string;
accessId: string;
type: number;
name: string;
notes: string | null;
text: any | null;
file: any | null;
key: string;
maxAccessCount: number | null;
accessCount: number;
password: string | null;
emails: string | null;
authType: SendAuthType;
disabled: boolean;
hideEmail: boolean | null;
revisionDate: string;
expirationDate: string | null;
deletionDate: string;
object: string;
}
// JWT Payload
export interface JWTPayload {
sub: string; // user id
@@ -155,6 +270,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;
@@ -182,6 +299,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
@@ -190,6 +309,7 @@ export interface TokenResponse {
expires_in: number;
token_type: string;
refresh_token: string;
TwoFactorToken?: string;
Key: string;
PrivateKey: string | null;
Kdf: number;
@@ -200,7 +320,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 {
@@ -224,6 +351,8 @@ export interface ProfileResponse {
forcePasswordReset: boolean;
avatarColor: string | null;
creationDate: string;
role?: UserRole;
status?: UserStatus;
object: string;
}
@@ -279,7 +408,7 @@ export interface SyncResponse {
ciphers: CipherResponse[];
domains: any;
policies: any[];
sends: any[];
sends: SendResponse[];
// PascalCase for desktop/browser clients
UserDecryptionOptions: UserDecryptionOptions | null;
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
+78
View File
@@ -0,0 +1,78 @@
const DEFAULT_DEVICE_NAME = 'Unknown device';
const DEFAULT_DEVICE_TYPE = 14;
function decodeBase64UrlUtf8(value: string): string | null {
try {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
const padding = normalized.length % 4;
const padded = padding === 0 ? normalized : normalized + '='.repeat(4 - padding);
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new TextDecoder().decode(bytes);
} catch {
return null;
}
}
function normalizeDeviceIdentifier(value: string | undefined | null): string | null {
if (!value) return null;
const normalized = String(value).trim();
if (!normalized) return null;
return normalized.slice(0, 128);
}
function normalizeDeviceName(value: string | undefined | null): string {
const normalized = String(value || '').trim();
if (!normalized) return DEFAULT_DEVICE_NAME;
return normalized.slice(0, 128);
}
function parseDeviceType(value: string | number | undefined | null): number {
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.max(0, Math.floor(value));
}
const parsed = Number.parseInt(String(value || ''), 10);
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
return DEFAULT_DEVICE_TYPE;
}
export interface AuthRequestDeviceInfo {
deviceIdentifier: string | null;
deviceName: string;
deviceType: number;
}
export function readAuthRequestDeviceInfo(
body: Record<string, string | undefined>,
request: Request
): AuthRequestDeviceInfo {
const bodyIdentifier = body.deviceIdentifier || body.device_identifier;
const headerIdentifier = request.headers.get('X-Device-Identifier') || undefined;
const bodyName = body.deviceName || body.device_name;
const headerName = request.headers.get('X-Device-Name') || undefined;
const bodyType = body.deviceType || body.device_type;
const headerType = request.headers.get('Device-Type') || undefined;
return {
deviceIdentifier: normalizeDeviceIdentifier(bodyIdentifier || headerIdentifier),
deviceName: normalizeDeviceName(bodyName || headerName),
deviceType: parseDeviceType(bodyType || headerType),
};
}
export function readKnownDeviceProbe(request: Request): { email: string | null; deviceIdentifier: string | null } {
const encodedEmail = request.headers.get('X-Request-Email') || '';
const decodedEmail = decodeBase64UrlUtf8(encodedEmail);
const fallbackRawEmail = request.headers.get('X-Request-Email');
const email = (decodedEmail || fallbackRawEmail || '').trim().toLowerCase() || null;
const deviceIdentifier = normalizeDeviceIdentifier(request.headers.get('X-Device-Identifier'));
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,
};
}
+296
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,
@@ -177,3 +184,292 @@ export async function verifyFileDownloadToken(
return null;
}
}
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;
}
export async function createSendFileDownloadToken(
sendId: string,
fileId: string,
secret: string
): Promise<string> {
const header = { alg: 'HS256', typ: 'JWT' };
const now = Math.floor(Date.now() / 1000);
const payload: SendFileDownloadClaims = {
sendId,
fileId,
jti: createRefreshToken(),
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 verifySendFileDownloadToken(
token: string,
secret: string
): Promise<SendFileDownloadClaims | 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: 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;
return payload;
} catch {
return null;
}
}
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';
iat: number;
exp: number;
}
export async function createSendAccessToken(sendId: string, secret: string): Promise<string> {
const header = { alg: 'HS256', typ: 'JWT' };
const now = Math.floor(Date.now() / 1000);
const payload: SendAccessTokenClaims = {
sub: sendId,
typ: 'send_access',
iat: now,
exp: now + LIMITS.auth.sendAccessTokenTtlSeconds,
};
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 verifySendAccessToken(token: string, secret: string): Promise<SendAccessTokenClaims | 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: SendAccessTokenClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) return null;
if (payload.typ !== 'send_access') return null;
if (!payload.sub) return null;
return payload;
} catch {
return null;
}
}
+36
View File
@@ -0,0 +1,36 @@
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, '');
}
function formatRecoveryCode(compact: string): string {
return compact.replace(/(.{4})/g, '$1 ').trim();
}
export function createRecoveryCode(): string {
let compact = '';
while (compact.length < 32) {
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));
}
export function recoveryCodeEquals(input: string, storedCode: string | null | undefined): boolean {
if (!storedCode) return false;
const a = new TextEncoder().encode(normalizeRecoveryCode(input));
const b = new TextEncoder().encode(normalizeRecoveryCode(storedCode));
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;
}
+39 -29
View File
@@ -1,41 +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';
function isTrustedClientOrigin(origin: string): boolean {
// Official browser extension / desktop-webview common origins.
if (origin === 'null') return true;
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;
@@ -45,11 +52,22 @@ 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)) {
headers.set(k, v);
}
// Security headers applied to every response.
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'; img-src 'self' data:");
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
@@ -100,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),
+98
View File
@@ -0,0 +1,98 @@
const TOTP_STEP_SECONDS = 30;
const TOTP_DIGITS = 6;
const TOTP_WINDOW = 1; // allow previous/current/next step for small clock drift
function normalizeBase32(input: string): string {
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 {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const normalized = normalizeBase32(input);
if (!normalized) return null;
let bits = 0;
let value = 0;
const output: number[] = [];
for (const char of normalized) {
const idx = alphabet.indexOf(char);
if (idx === -1) return null;
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
bits -= 8;
output.push((value >> bits) & 0xff);
}
}
return output.length > 0 ? new Uint8Array(output) : null;
}
async function hotp(secret: Uint8Array, counter: number): Promise<string> {
const counterBytes = new Uint8Array(8);
let c = counter;
for (let i = 7; i >= 0; i--) {
counterBytes[i] = c & 0xff;
c = Math.floor(c / 256);
}
const key = await crypto.subtle.importKey(
'raw',
secret,
{ name: 'HMAC', hash: 'SHA-1' },
false,
['sign']
);
const signature = new Uint8Array(await crypto.subtle.sign('HMAC', key, counterBytes));
const offset = signature[signature.length - 1] & 0x0f;
const binary =
((signature[offset] & 0x7f) << 24) |
((signature[offset + 1] & 0xff) << 16) |
((signature[offset + 2] & 0xff) << 8) |
(signature[offset + 3] & 0xff);
const otp = binary % (10 ** TOTP_DIGITS);
return otp.toString().padStart(TOTP_DIGITS, '0');
}
function normalizeToken(token: string): string {
return token.replace(/\s+/g, '');
}
export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs: number = Date.now()): Promise<boolean> {
const token = normalizeToken(tokenRaw);
if (!/^\d{6}$/.test(token)) return false;
const secret = base32Decode(secretRaw);
if (!secret) return false;
const currentCounter = Math.floor(nowMs / 1000 / TOTP_STEP_SECONDS);
let matched = false;
for (let delta = -TOTP_WINDOW; delta <= TOTP_WINDOW; delta++) {
const expected = await hotp(secret, currentCounter + delta);
// Constant-time comparison: always check all windows, never short-circuit.
const a = new TextEncoder().encode(expected);
const b = new TextEncoder().encode(token);
let diff = a.length ^ b.length;
for (let i = 0; i < a.length && i < b.length; i++) {
diff |= a[i] ^ b[i];
}
if (diff === 0) matched = true;
}
return matched;
}
export function isTotpEnabled(secretRaw: string | undefined | null): boolean {
return Boolean(secretRaw && normalizeBase32(secretRaw).length > 0);
}
+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"]
}
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<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' '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>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+1163
View File
File diff suppressed because it is too large Load Diff
+167
View File
@@ -0,0 +1,167 @@
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';
interface AdminPageProps {
currentUserId: string;
users: AdminUser[];
invites: AdminInvite[];
onRefresh: () => void;
onCreateInvite: (hours: number) => Promise<void>;
onDeleteAllInvites: () => Promise<void>;
onToggleUserStatus: (userId: string, currentStatus: 'active' | 'banned') => Promise<void>;
onDeleteUser: (userId: string) => Promise<void>;
onRevokeInvite: (code: string) => Promise<void>;
}
export default function AdminPage(props: AdminPageProps) {
const [inviteHours, setInviteHours] = useState(168);
const [page, setPage] = useState(1);
const pageSize = 20;
const formatExpiresAt = (x?: string) => (x ? new Date(x).toLocaleString() : t('txt_dash'));
const totalPages = Math.max(1, Math.ceil(props.invites.length / pageSize));
const safePage = Math.min(page, totalPages);
const pagedInvites = props.invites.slice((safePage - 1) * pageSize, safePage * pageSize);
const roleText = (role: string) => {
const normalized = String(role || '').toLowerCase();
if (normalized === 'admin') return t('txt_role_admin');
if (normalized === 'user') return t('txt_role_user');
return role || '-';
};
const statusText = (status: string) => {
const normalized = String(status || '').toLowerCase();
if (normalized === 'active') return t('txt_status_active');
if (normalized === 'banned') return t('txt_status_banned');
if (normalized === 'inactive') return t('txt_status_inactive');
return status || '-';
};
return (
<div className="stack">
<section className="card">
<h3>{t('txt_users')}</h3>
<table className="table">
<thead>
<tr>
<th>{t('txt_email')}</th>
<th>{t('txt_name')}</th>
<th>{t('txt_role')}</th>
<th>{t('txt_status')}</th>
<th>{t('txt_actions')}</th>
</tr>
</thead>
<tbody>
{props.users.map((user) => (
<tr key={user.id}>
<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"
className="btn btn-secondary"
disabled={user.id === props.currentUserId}
onClick={() => void props.onToggleUserStatus(user.id, user.status)}
>
{user.status === 'active' ? <UserX size={14} className="btn-icon" /> : <UserCheck size={14} className="btn-icon" />}
{user.status === 'active' ? t('txt_ban') : t('txt_unban')}
</button>
{user.role !== 'admin' && (
<button type="button" className="btn btn-danger" onClick={() => void props.onDeleteUser(user.id)}>
<Trash2 size={14} className="btn-icon" />
{t('txt_delete')}
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</section>
<section className="card">
<div className="section-head">
<h3>{t('txt_invites')}</h3>
<button type="button" className="btn btn-secondary" onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync')}
</button>
</div>
<div className="invite-toolbar">
<div className="actions invite-create-group">
<label className="field invite-hours-field">
<span>{t('txt_invite_validity_hours')}</span>
<input
className="input small"
type="number"
value={inviteHours}
min={1}
max={720}
onInput={(e) => setInviteHours(Number((e.currentTarget as HTMLInputElement).value || 168))}
/>
</label>
<button type="button" className="btn btn-primary" onClick={() => void props.onCreateInvite(inviteHours)}>
<Plus size={14} className="btn-icon" />
{t('txt_create_timed_invite')}
</button>
</div>
<button type="button" className="btn btn-danger" onClick={() => void props.onDeleteAllInvites()}>
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_all')}
</button>
</div>
<table className="table">
<thead>
<tr>
<th>{t('txt_code')}</th>
<th>{t('txt_status')}</th>
<th>{t('txt_expires_at')}</th>
<th className="invite-actions-head">{t('txt_actions')}</th>
</tr>
</thead>
<tbody>
{pagedInvites.map((invite) => (
<tr key={invite.code}>
<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={() => void copyTextToClipboard(invite.inviteLink || '', { successMessage: t('txt_link_copied') })}
>
<Clipboard size={14} className="btn-icon" /> {t('txt_copy_link')}
</button>
{invite.status === 'active' && (
<button type="button" className="btn btn-danger" onClick={() => void props.onRevokeInvite(invite.code)}>
<Trash2 size={14} className="btn-icon" /> {t('txt_revoke')}
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
<div className="actions">
<button type="button" className="btn btn-secondary small" disabled={safePage <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
<ChevronLeft size={14} className="btn-icon" />
{t('txt_prev')}
</button>
<span className="muted-inline">{safePage} / {totalPages}</span>
<button type="button" className="btn btn-secondary small" disabled={safePage >= totalPages} onClick={() => setPage((p) => Math.min(totalPages, p + 1))}>
{t('txt_next')}
<ChevronRight size={14} className="btn-icon" />
</button>
</div>
</section>
</div>
);
}
@@ -0,0 +1,126 @@
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 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;
onLock: () => void;
onLogout: () => void;
mainRoutesProps: AppMainRoutesProps;
}
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
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>
<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>
)}
<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">
<AppMainRoutes {...props.mainRoutesProps} />
</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} />
</>
);
}
+343
View File
@@ -0,0 +1,343 @@
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>;
onBulkDeleteVaultItems: (ids: string[]) => Promise<void>;
onBulkPermanentDeleteVaultItems: (ids: string[]) => Promise<void>;
onBulkRestoreVaultItems: (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}
onBulkDelete={props.onBulkDeleteVaultItems}
onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems}
onBulkRestore={props.onBulkRestoreVaultItems}
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>
);
}
+255
View File
@@ -0,0 +1,255 @@
import { useState } from 'preact/hooks';
import { ArrowLeft, Eye, EyeOff, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n';
interface LoginValues {
email: string;
password: string;
}
interface RegisterValues {
name: string;
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;
onSubmitLogin: () => void;
onSubmitRegister: () => void;
onSubmitUnlock: () => void;
onGotoLogin: () => void;
onGotoRegister: () => void;
onLogout: () => void;
onTogglePasswordHint: () => void;
onShowLockedPasswordHint: () => void;
}
function PasswordField(props: {
label: string;
value: string;
onInput: (v: string) => void;
autoFocus?: boolean;
autoComplete?: string;
}) {
const [show, setShow] = useState(false);
return (
<label className="field">
<span>{props.label}</span>
<div className="password-wrap">
<input
className="input"
type={show ? 'text' : 'password'}
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} />}
</button>
</div>
</label>
);
}
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')}>
<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>
);
}
if (props.mode === 'register') {
return (
<div className="auth-page">
<StandalonePageFrame title={t('txt_create_account')}>
<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 })}
/>
<PasswordField
label={t('txt_confirm_master_password')}
value={props.registerValues.password2}
autoComplete="new-password"
onInput={(v) => props.onChangeRegister({ ...props.registerValues, password2: v })}
/>
<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>
);
}
return (
<div className="auth-page">
<StandalonePageFrame title={t('txt_log_in')}>
<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
/>
<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>
);
}
+63
View File
@@ -0,0 +1,63 @@
import type { ComponentChildren } from 'preact';
import { Check, X } from 'lucide-preact';
import { t } from '@/lib/i18n';
interface ConfirmDialogProps {
open: boolean;
title: string;
message: string;
showIcon?: boolean;
confirmText?: string;
cancelText?: string;
danger?: boolean;
hideCancel?: boolean;
confirmDisabled?: boolean;
cancelDisabled?: boolean;
onConfirm: () => void;
onCancel: () => void;
children?: ComponentChildren;
afterActions?: ComponentChildren;
}
export default function ConfirmDialog(props: ConfirmDialogProps) {
if (!props.open) return null;
return (
<div className="dialog-mask">
<form
className="dialog-card"
onSubmit={(e) => {
e.preventDefault();
if (props.confirmDisabled) return;
props.onConfirm();
}}
>
<h3 className="dialog-title">{props.title}</h3>
<div className="dialog-message">{props.message}</div>
{props.children}
<button
type="submit"
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
disabled={props.confirmDisabled}
>
<Check size={14} className="btn-icon" />
{props.confirmText || t('txt_yes')}
</button>
{!props.hideCancel && (
<button
type="button"
className="btn btn-secondary dialog-btn"
disabled={props.cancelDisabled}
onClick={() => {
if (props.cancelDisabled) return;
props.onCancel();
}}
>
<X size={14} className="btn-icon" />
{props.cancelText || t('txt_no')}
</button>
)}
{props.afterActions}
</form>
</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>
);
}
+123
View File
@@ -0,0 +1,123 @@
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';
interface JwtWarningPageProps {
reason: 'missing' | 'default' | 'too_short';
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('');
const generatedSecret = useMemo(() => generateJwtSecret(32), [seed]);
const title =
props.reason === 'missing'
? t('txt_jwt_title_missing')
: props.reason === 'default'
? t('txt_jwt_title_default')
: t('txt_jwt_title_too_short');
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 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 (
<div className="auth-page">
<StandalonePageFrame title={title}>
<div className="jwt-warning-head">
<AlertTriangle size={20} />
<strong>{t('txt_jwt_warning_subtitle')}</strong>
</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>
{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>
<div className="jwt-generator">
<div className="jwt-warning-label">{t('txt_random_secret_generator')}</div>
<input className="input input-readonly" readOnly value={generatedSecret} />
<div className="jwt-generator-actions">
<button type="button" className="btn btn-primary" onClick={() => setSeed((v) => v + 1)}>
<RefreshCw size={15} className="btn-icon" />
{t('txt_regenerate')}
</button>
<button
type="button"
className="btn btn-secondary"
onClick={async () => {
await copyTextToClipboard(generatedSecret, {
onSuccess: () => setCopyHint(t('txt_copied')),
onError: () => setCopyHint(t('txt_copy_failed')),
});
window.setTimeout(() => setCopyHint(''), 1500);
}}
>
<Copy size={15} className="btn-icon" />
{t('txt_copy')}
</button>
{copyHint && <span className="jwt-copy-hint">{copyHint}</span>}
</div>
</div>
</div>
</StandalonePageFrame>
</div>
);
}
function generateJwtSecret(length: number): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
let out = '';
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;
}
+151
View File
@@ -0,0 +1,151 @@
import { useEffect, useState } from 'preact/hooks';
import { Download, Eye, Lock } from 'lucide-preact';
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';
interface PublicSendPageProps {
accessId: string;
keyPart: string | null;
}
export default function PublicSendPage(props: PublicSendPageProps) {
const [loading, setLoading] = useState(true);
const [password, setPassword] = useState('');
const [needPassword, setNeedPassword] = useState(false);
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);
setError('');
try {
const data = await accessPublicSend(props.accessId, props.keyPart, pass);
if (!props.keyPart) {
setError(t('txt_this_link_is_missing_decryption_key'));
setSendData(null);
return;
}
const decrypted = await decryptPublicSend(data, props.keyPart);
setSendData(decrypted);
setNeedPassword(false);
} catch (e) {
const err = e as Error & { status?: number };
if (err.status === 401) {
setNeedPassword(true);
setError(t('txt_this_send_is_password_protected'));
} else {
setError(err.message || t('txt_failed_to_open_send'));
}
setSendData(null);
} finally {
setBusy(false);
setLoading(false);
}
}
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 readResponseBytesWithProgress(resp, (progress) => setDownloadPercent(progress.percent));
let blob: Blob;
if (props.keyPart) {
try {
const decryptedBytes = await decryptPublicSendFileBytes(encryptedBytes, props.keyPart);
blob = new Blob([decryptedBytes as unknown as BlobPart], { type: 'application/octet-stream' });
} catch {
// Legacy compatibility: early web-created file sends uploaded plaintext bytes.
blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
}
} else {
blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
}
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);
}
}
useEffect(() => {
void loadSend();
}, [props.accessId, props.keyPart]);
return (
<div className="auth-page public-send-page">
<StandalonePageFrame title={t('txt_nodewarden_send')}>
{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">
<input
className="input"
type="password"
value={password}
autoComplete="current-password"
onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}
/>
</div>
</label>
<button type="submit" className="btn btn-primary full" disabled={busy}>
<Lock size={14} className="btn-icon" /> {t('txt_unlock_send')}
</button>
</form>
)}
{!loading && sendData && (
<>
<h2 style={{ marginTop: '8px' }}>{sendData.decName || t('txt_no_name')}</h2>
{sendData.type === 0 ? (
<div className="card" style={{ marginTop: '10px' }}>
<div className="notes">{sendData.decText || ''}</div>
</div>
) : (
<div className="card" style={{ marginTop: '10px' }}>
<div className="kv-line">
<span>{t('txt_file')}</span>
<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" /> {downloadPercent == null ? (busy ? t('txt_downloading') : t('txt_download')) : t('txt_downloading_percent', { percent: downloadPercent })}
</button>
</div>
)}
{!!sendData.expirationDate && <p className="muted">{t('txt_expires_at_value', { value: sendData.expirationDate })}</p>}
</>
)}
{!loading && !sendData && !needPassword && !error && (
<p className="muted">
<Eye size={14} style={{ verticalAlign: 'text-bottom' }} /> {t('txt_send_unavailable')}
</p>
)}
{!!error && <p className="local-error">{error}</p>}
</StandalonePageFrame>
</div>
);
}
@@ -0,0 +1,78 @@
import { useState } from 'preact/hooks';
import { Eye, EyeOff, Send, X } from 'lucide-preact';
import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n';
interface RecoverTwoFactorPageProps {
values: { email: string; password: string; recoveryCode: string };
onChange: (next: { email: string; password: string; recoveryCode: string }) => void;
onSubmit: () => void;
onCancel: () => void;
}
export default function RecoverTwoFactorPage(props: RecoverTwoFactorPageProps) {
const [showPassword, setShowPassword] = useState(false);
return (
<div className="auth-page">
<StandalonePageFrame title={t('txt_recover_two_step_login')}>
<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}
autoComplete="username"
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">
<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>
</form>
</StandalonePageFrame>
</div>
);
}
@@ -0,0 +1,141 @@
import { Clock3, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact';
import type { AuthorizedDevice } from '@/lib/types';
import { t } from '@/lib/i18n';
interface SecurityDevicesPageProps {
devices: AuthorizedDevice[];
loading: boolean;
onRefresh: () => void;
onRevokeTrust: (device: AuthorizedDevice) => void;
onRemoveDevice: (device: AuthorizedDevice) => void;
onRevokeAll: () => void;
onRemoveAll: () => void;
}
function formatDateTime(value: string | null | undefined): string {
if (!value) return t('txt_dash');
const date = new Date(value);
if (Number.isNaN(date.getTime())) return t('txt_dash');
return date.toLocaleString();
}
function mapDeviceTypeName(type: number): string {
switch (type) {
case 0: return t('txt_android');
case 1: return t('txt_ios');
case 2: return t('txt_chrome_extension');
case 3: return t('txt_firefox_extension');
case 4: return t('txt_opera_extension');
case 5: return t('txt_edge_extension');
case 6: return t('txt_windows_desktop');
case 7: return t('txt_macos_desktop');
case 8: return t('txt_linux_desktop');
case 9: return t('txt_chrome_browser');
case 10: return t('txt_firefox_browser');
case 11: return t('txt_opera_browser');
case 12: return t('txt_edge_browser');
case 13: return t('txt_ie_browser');
case 14: return t('txt_web');
default: return t('txt_type_type', { type });
}
}
export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
return (
<div className="stack">
<section className="card">
<div className="section-head">
<div>
<h3 style={{ margin: 0 }}>{t('txt_device_management')}</h3>
<div className="muted-inline" style={{ marginTop: 4 }}>
{t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')}
</div>
</div>
<div className="actions">
<button type="button" className="btn btn-secondary small" onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" />
{t('txt_refresh')}
</button>
<button type="button" className="btn btn-danger small" onClick={props.onRevokeAll}>
<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>
<section className="card">
<h3 style={{ marginTop: 0 }}>{t('txt_authorized_devices')}</h3>
<table className="table">
<thead>
<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>
<th>{t('txt_actions')}</th>
</tr>
</thead>
<tbody>
{props.devices.map((device) => (
<tr key={device.identifier}>
<td data-label={t('txt_device')}>
<div>{device.name || t('txt_unknown_device')}</div>
<div className="muted-inline">{device.identifier}</div>
</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} />
<span>{formatDateTime(device.trustedUntil)}</span>
</div>
) : (
<span className="muted-inline">{t('txt_not_trusted')}</span>
)}
</td>
<td data-label={t('txt_actions')}>
<div className="actions">
<button
type="button"
className="btn btn-secondary small"
disabled={!device.trusted}
onClick={() => props.onRevokeTrust(device)}
>
<ShieldOff size={14} className="btn-icon" />
{t('txt_revoke_trust')}
</button>
<button type="button" className="btn btn-danger small" onClick={() => props.onRemoveDevice(device)}>
<Trash2 size={14} className="btn-icon" />
{t('txt_remove_device_2')}
</button>
</div>
</td>
</tr>
))}
{!props.loading && props.devices.length === 0 && (
<tr>
<td colSpan={7}>
<div className="empty" style={{ minHeight: 80 }}>{t('txt_no_devices_found')}</div>
</td>
</tr>
)}
</tbody>
</table>
</section>
</div>
);
}
+531
View File
@@ -0,0 +1,531 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
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';
interface SendsPageProps {
sends: Send[];
loading: boolean;
onRefresh: () => Promise<void>;
onCreate: (draft: SendDraft, autoCopyLink: boolean) => Promise<void>;
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);
const d = new Date(iso).getTime();
if (!Number.isFinite(d)) return String(fallback);
const diff = d - Date.now();
const days = Math.ceil(diff / (24 * 60 * 60 * 1000));
return String(Math.max(days, 0));
}
function buildDefaultDraft(): SendDraft {
return {
type: 'text',
name: '',
notes: '',
text: '',
file: null,
deletionDays: '7',
expirationDays: '0',
maxAccessCount: '',
password: '',
disabled: false,
};
}
function draftFromSend(send: Send): SendDraft {
return {
id: send.id,
type: Number(send.type) === 1 ? 'file' : 'text',
name: send.decName || '',
notes: send.decNotes || '',
text: send.decText || '',
file: null,
deletionDays: daysFromNow(send.deletionDate, 7),
expirationDays: daysFromNow(send.expirationDate, 0),
maxAccessCount: send.maxAccessCount !== null && send.maxAccessCount !== undefined ? String(send.maxAccessCount) : '',
password: '',
disabled: !!send.disabled,
};
}
export default function SendsPage(props: SendsPageProps) {
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<SendTypeFilter>('all');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [busy, setBusy] = useState(false);
const [draft, setDraft] = useState<SendDraft | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
const [isMobileLayout, setIsMobileLayout] = useState(false);
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';
} catch {
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 {
localStorage.setItem(AUTO_COPY_KEY, autoCopyLink ? '1' : '0');
} catch {
// ignore storage errors
}
}, [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) => {
if (typeFilter === 'text' && Number(send.type) !== 0) return false;
if (typeFilter === 'file' && Number(send.type) !== 1) return false;
if (!q) return true;
const name = (send.decName || '').toLowerCase();
const text = (send.decText || '').toLowerCase();
return name.includes(q) || text.includes(q);
});
}, [props.sends, search, typeFilter]);
useEffect(() => {
if (!filteredSends.length) {
setSelectedId(null);
return;
}
if (!selectedId || !filteredSends.some((x) => x.id === selectedId)) {
setSelectedId(filteredSends[0].id);
setIsEditing(false);
setIsCreating(false);
setDraft(null);
}
}, [filteredSends, selectedId]);
const selectedSend = useMemo(
() => props.sends.find((x) => x.id === selectedId) || null,
[props.sends, selectedId]
);
const selectedIds = useMemo(() => Object.keys(selectedMap).filter((id) => selectedMap[id]), [selectedMap]);
const selectedCount = selectedIds.length;
async function saveDraft(): Promise<void> {
if (!draft) return;
if (!draft.name.trim()) {
props.onNotify('error', t('txt_name_is_required'));
return;
}
if (draft.type === 'text' && !draft.text.trim()) {
props.onNotify('error', t('txt_text_is_required'));
return;
}
if (draft.type === 'file' && isCreating && !draft.file) {
props.onNotify('error', t('txt_please_select_a_file'));
return;
}
setBusy(true);
try {
if (isCreating) {
await props.onCreate(draft, autoCopyLink);
setSelectedId(null);
} else if (selectedSend) {
await props.onUpdate(selectedSend, draft, autoCopyLink);
}
setIsEditing(false);
setIsCreating(false);
setDraft(null);
setShowPassword(false);
if (isMobileLayout) setMobilePanel('detail');
} finally {
setBusy(false);
}
}
async function removeSend(send: Send): Promise<void> {
setBusy(true);
try {
await props.onDelete(send);
if (selectedId === send.id) setSelectedId(null);
setIsEditing(false);
setDraft(null);
if (isMobileLayout) setMobilePanel('list');
} finally {
setBusy(false);
}
}
async function removeSelected(): Promise<void> {
if (!selectedCount) return;
setBusy(true);
try {
await props.onBulkDelete(selectedIds);
setSelectedMap({});
} finally {
setBusy(false);
}
}
function copyAccessUrl(send: Send): void {
const url = send.shareUrl || `${window.location.origin}/#/send/${send.accessId}`;
void copyTextToClipboard(url, { successMessage: t('txt_link_copied') });
}
return (
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
{isMobileLayout && mobileSidebarOpen && <div className="mobile-sidebar-mask" onClick={() => 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')}>
<LayoutGrid size={14} className="tree-icon" />
<span className="tree-label">{t('txt_all_sends')}</span>
</button>
</div>
<div className="sidebar-block">
<div className="sidebar-title">{t('txt_type')}</div>
<button type="button" className={`tree-btn ${typeFilter === 'text' ? 'active' : ''}`} onClick={() => setTypeFilter('text')}>
<FileText size={14} className="tree-icon" />
<span className="tree-label">{t('txt_text')}</span>
</button>
<button type="button" className={`tree-btn ${typeFilter === 'file' ? 'active' : ''}`} onClick={() => setTypeFilter('file')}>
<File size={14} className="tree-icon" />
<span className="tree-label">{t('txt_file')}</span>
</button>
</div>
</aside>
<section className="list-col">
<div className="list-head">
<input
className="search-input"
placeholder={t('txt_search_sends')}
value={search}
onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}
/>
<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>
<div className="toolbar actions">
<button type="button" className="btn btn-danger small" disabled={!selectedCount || busy} onClick={() => void removeSelected()}>
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_selected')}
</button>
<button
type="button"
className="btn btn-secondary small"
disabled={!filteredSends.length}
onClick={() => {
const map: Record<string, boolean> = {};
for (const send of filteredSends) map[send.id] = true;
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 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" />
</button>
</div>
<div className="list-panel">
{filteredSends.map((send) => (
<div key={send.id} className={`list-item ${selectedId === send.id ? 'active' : ''}`}>
<input
type="checkbox"
className="row-check"
checked={!!selectedMap[send.id]}
onInput={(e) =>
setSelectedMap((prev) => ({
...prev,
[send.id]: (e.currentTarget as HTMLInputElement).checked,
}))
}
/>
<button
type="button"
className="row-main"
onClick={() => {
setSelectedId(send.id);
setIsEditing(false);
setIsCreating(false);
setDraft(null);
if (isMobileLayout) setMobilePanel('detail');
setMobileSidebarOpen(false);
}}
>
<div className="list-icon-wrap">
<span className="list-icon-fallback">
<SendIcon />
</span>
</div>
<div className="list-text">
<span className="list-title" title={send.decName || t('txt_no_name')}>{send.decName || t('txt_no_name')}</span>
<span className="list-sub">
{Number(send.type) === 1 ? t('txt_file') : t('txt_text')} - {t('txt_accessed_count_times', { count: send.accessCount || 0 })}
</span>
</div>
</button>
</div>
))}
{!filteredSends.length && <div className="empty">{t('txt_no_sends')}</div>}
</div>
</section>
<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>
{!!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 })} />
</label>
<label className="field field-span-2">
<span>{t('txt_type')}</span>
<div className="send-options">
<label>
<input
type="radio"
checked={draft.type === 'file'}
disabled={!isCreating}
onInput={() => setDraft({ ...draft, type: 'file' })}
/>
{t('txt_file')}
</label>
<label>
<input
type="radio"
checked={draft.type === 'text'}
disabled={!isCreating}
onInput={() => setDraft({ ...draft, type: 'text' })}
/>
{t('txt_text')}
</label>
</div>
</label>
{draft.type === 'file' ? (
<label className="field field-span-2">
<span>{t('txt_file')}</span>
<input className="input" type="file" onInput={(e) => setDraft({ ...draft, file: (e.currentTarget as HTMLInputElement).files?.[0] || null })} />
</label>
) : (
<label className="field field-span-2">
<span>{t('txt_text')}</span>
<textarea className="input textarea" rows={8} value={draft.text} onInput={(e) => setDraft({ ...draft, text: (e.currentTarget as HTMLTextAreaElement).value })} />
</label>
)}
<label className="field">
<span>{t('txt_deletion_days')}</span>
<input className="input" type="number" min="1" max="31" value={draft.deletionDays} onInput={(e) => setDraft({ ...draft, deletionDays: (e.currentTarget as HTMLInputElement).value })} />
</label>
<label className="field">
<span>{t('txt_expiration_days_0_never')}</span>
<input className="input" type="number" min="0" max="3650" value={draft.expirationDays} onInput={(e) => setDraft({ ...draft, expirationDays: (e.currentTarget as HTMLInputElement).value })} />
</label>
<label className="field">
<span>{t('txt_max_access_count')}</span>
<input className="input" value={draft.maxAccessCount} onInput={(e) => setDraft({ ...draft, maxAccessCount: (e.currentTarget as HTMLInputElement).value })} />
</label>
<label className="field">
<span>{t('txt_password')}</span>
<div className="password-wrap">
<input className="input" type={showPassword ? 'text' : 'password'} value={draft.password} onInput={(e) => setDraft({ ...draft, password: (e.currentTarget as HTMLInputElement).value })} />
<button type="button" className="password-toggle" onClick={() => setShowPassword((v) => !v)}>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</label>
<label className="field field-span-2">
<span>{t('txt_notes')}</span>
<textarea className="input textarea" rows={5} value={draft.notes} onInput={(e) => setDraft({ ...draft, notes: (e.currentTarget as HTMLTextAreaElement).value })} />
</label>
<label className="field field-span-2">
<span>{t('txt_options')}</span>
<div className="send-options">
<label><input type="checkbox" checked={draft.disabled} onInput={(e) => setDraft({ ...draft, disabled: (e.currentTarget as HTMLInputElement).checked })} /> {t('txt_disable_this_send')}</label>
<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()}>
<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>
)}
{!isEditing && selectedSend && (
<>
<div className="card">
<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">
<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>
<div className="kv-line"><span>{t('txt_expiration_date')}</span><strong>{selectedSend.expirationDate || t('txt_dash')}</strong></div>
</div>
<div className="card">
{Number(selectedSend.type) === 1 ? (
<>
<h4>{t('txt_file')}</h4>
<div className="kv-line"><span>{t('txt_file_name')}</span><strong>{selectedSend.file?.fileName || t('txt_encrypted_file_2')}</strong></div>
<div className="kv-line"><span>{t('txt_file_size')}</span><strong>{selectedSend.file?.sizeName || t('txt_dash')}</strong></div>
</>
) : (
<>
<h4>{t('txt_text')}</h4>
<div className="notes">{selectedSend.decText || ''}</div>
</>
)}
</div>
{!!(selectedSend.decNotes || '').trim() && (
<div className="card">
<h4>{t('txt_notes')}</h4>
<div className="notes">{selectedSend.decNotes || ''}</div>
</div>
)}
<div className="detail-actions">
<div className="actions">
<button type="button" className="btn btn-secondary small" onClick={() => copyAccessUrl(selectedSend)}>
<Copy size={14} className="btn-icon" /> {t('txt_copy_link')}
</button>
<button type="button" className="btn btn-secondary small" onClick={() => { setDraft(draftFromSend(selectedSend)); setIsCreating(false); setIsEditing(true); }}>
<Pencil size={14} className="btn-icon" /> {t('txt_edit')}
</button>
</div>
<button type="button" className="btn btn-danger small detail-delete-btn" disabled={busy} onClick={() => void removeSend(selectedSend)}>
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
</button>
</div>
</>
)}
</section>
</div>
);
}
+233
View File
@@ -0,0 +1,233 @@
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';
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>;
onNotify?: (type: 'success' | 'error', text: string) => void;
}
function randomBase32Secret(length: number): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let out = '';
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;
}
function buildOtpUri(email: string, secret: string): string {
const issuer = 'NodeWarden';
return `otpauth://totp/${encodeURIComponent(`${issuer}:${email}`)}?secret=${encodeURIComponent(secret)}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
}
export default function SettingsPage(props: SettingsPageProps) {
const totpSecretStorageKey = `nodewarden.totp.secret.${props.profile.id}`;
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);
const [recoveryMasterPassword, setRecoveryMasterPassword] = useState('');
const [recoveryCode, setRecoveryCode] = useState('');
useEffect(() => {
if (!props.totpEnabled) {
setTotpLocked(false);
return;
}
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));
qr.make();
const svg = qr.createSvgTag({ scalable: true, margin: 0 });
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}, [props.profile.email, secret]);
async function enableTotp(): Promise<void> {
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> {
const code = await props.onGetRecoveryCode(recoveryMasterPassword);
setRecoveryCode(code);
props.onNotify?.('success', t('txt_recovery_code_loaded'));
}
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">
<span>{t('txt_current_password')}</span>
<input
className="input"
type="password"
value={currentPassword}
onInput={(e) => setCurrentPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
<div className="field-grid">
<label className="field">
<span>{t('txt_new_password')}</span>
<input className="input" type="password" value={newPassword} onInput={(e) => setNewPassword((e.currentTarget as HTMLInputElement).value)} />
</label>
<label className="field">
<span>{t('txt_confirm_password')}</span>
<input className="input" type="password" value={newPassword2} onInput={(e) => setNewPassword2((e.currentTarget as HTMLInputElement).value)} />
</label>
</div>
<button
type="button"
className="btn btn-danger"
onClick={() => void props.onChangePassword(currentPassword, newPassword, newPassword2)}
>
<KeyRound size={14} className="btn-icon" />
{t('txt_change_password')}
</button>
</section>
<section className="card">
<div className="settings-twofactor-grid">
<div className="settings-subcard">
<h3>{t('txt_totp')}</h3>
{totpLocked && <div className="status-ok">{t('txt_totp_is_enabled_for_this_account')}</div>}
<div className="totp-grid">
<div className="totp-qr">
<img src={qrDataUrl} alt="TOTP QR" />
</div>
<div>
<div>
<label className="field">
<span>{t('txt_authenticator_key')}</span>
<input className="input" value={secret} disabled={totpLocked} onInput={(e) => setSecret((e.currentTarget as HTMLInputElement).value.toUpperCase())} />
</label>
<label className="field">
<span>{t('txt_verification_code')}</span>
<input className="input" value={token} disabled={totpLocked} onInput={(e) => setToken((e.currentTarget as HTMLInputElement).value)} />
</label>
<div className="actions">
<button type="button" className="btn btn-primary" disabled={totpLocked} onClick={() => void enableTotp()}>
<ShieldCheck size={14} className="btn-icon" />
{totpLocked ? t('txt_enabled') : t('txt_enable_totp')}
</button>
<button type="button" className="btn btn-secondary" disabled={totpLocked} onClick={() => setSecret(randomBase32Secret(32))}>
<RefreshCw size={14} className="btn-icon" />
{t('txt_regenerate')}
</button>
<button
type="button"
className="btn btn-secondary"
disabled={totpLocked}
onClick={() => {
void copyTextToClipboard(secret, { successMessage: t('txt_secret_copied') });
}}
>
<Clipboard size={14} className="btn-icon" />
{t('txt_copy_secret')}
</button>
</div>
</div>
</div>
</div>
<button type="button" className="btn btn-danger" disabled={!totpLocked} onClick={props.onOpenDisableTotp}>
<ShieldOff size={14} className="btn-icon" />
{t('txt_disable_totp')}
</button>
</div>
<div className="settings-subcard">
<h3>{t('txt_recovery_code')}</h3>
<p className="muted-inline" style={{ marginBottom: 8 }}>
{t('txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically')}
</p>
<label className="field">
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
value={recoveryMasterPassword}
onInput={(e) => setRecoveryMasterPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
<div className="actions">
<button type="button" className="btn btn-secondary" onClick={() => void loadRecoveryCode()}>
<ShieldCheck size={14} className="btn-icon" />
{t('txt_view_recovery_code')}
</button>
<button
type="button"
className="btn btn-secondary"
disabled={!recoveryCode}
onClick={() => {
void copyTextToClipboard(recoveryCode, { successMessage: t('txt_recovery_code_copied') });
}}
>
<Clipboard size={14} className="btn-icon" />
{t('txt_copy_code')}
</button>
</div>
{recoveryCode && (
<div className="card" style={{ marginTop: 10, marginBottom: 0 }}>
<div style={{ fontWeight: 800, letterSpacing: '0.08em' }}>{recoveryCode}</div>
</div>
)}
</div>
</div>
</section>
</div>
);
}
@@ -0,0 +1,33 @@
import type { ComponentChildren } from 'preact';
import { APP_VERSION } from '@shared/app-version';
interface StandalonePageFrameProps {
title: string;
children: ComponentChildren;
}
export default function StandalonePageFrame(props: StandalonePageFrameProps) {
return (
<div className="standalone-shell">
<div className="standalone-brand standalone-brand-outside">
<img src="/logo-64.png" alt="NodeWarden logo" className="standalone-brand-logo" />
<div>
<div className="standalone-brand-title">NodeWarden</div>
</div>
</div>
<div className="auth-card">
<h1 className="standalone-title">{props.title}</h1>
{props.children}
</div>
<div className="standalone-footer">
<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>
);
}
+23
View File
@@ -0,0 +1,23 @@
import type { ToastMessage } from '@/lib/types';
interface ToastHostProps {
toasts: ToastMessage[];
onClose: (id: string) => void;
}
export default function ToastHost({ toasts, onClose }: ToastHostProps) {
if (!toasts.length) return null;
return (
<ul className="toast-stack">
{toasts.map((toast) => (
<li key={toast.id} className={`toast-item ${toast.type}`}>
<div className="toast-text">{toast.text}</div>
<button type="button" className="toast-close" onClick={() => onClose(toast.id)}>
x
</button>
<div className="toast-progress" />
</li>
))}
</ul>
);
}
+209
View File
@@ -0,0 +1,209 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { Clipboard, Globe } from 'lucide-preact';
import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
import { calcTotpNow } from '@/lib/crypto';
import { t } from '@/lib/i18n';
import type { Cipher } from '@/lib/types';
import { 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 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>
);
}
export default function TotpCodesPage(props: TotpCodesPageProps) {
const [totpMap, setTotpMap] = useState<Record<string, { code: string; remain: number } | null>>({});
const [columnCount, setColumnCount] = useState(1);
const listRef = useRef<HTMLDivElement | null>(null);
async function copyToClipboard(value: string): Promise<void> {
await copyTextWithFeedback(value, { successMessage: t('txt_code_copied') });
}
const totpItems = useMemo(
() =>
props.ciphers
.filter((cipher) => {
const isDeleted = !!(cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt);
return !isDeleted && !!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]
);
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();
}, []);
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>}
{totpItems.map((cipher) => {
const live = totpMap[cipher.id] || null;
const name = cipher.decName || cipher.name || t('txt_no_name');
const username = cipher.login?.decUsername || '';
return (
<div key={cipher.id} className="totp-code-row">
<div className="totp-code-info">
<div className="list-icon-wrap">
<TotpListIcon cipher={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>{live ? formatTotp(live.code) : t('txt_text_3')}</strong>
<div
className="totp-timer"
title={t('txt_refresh_in_seconds_s', { seconds: live ? live.remain : 0 })}
aria-label={t('txt_refresh_in_seconds_s', { seconds: live ? 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, live?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
),
}}
/>
</svg>
<span className="totp-timer-value">{live ? live.remain : 0}</span>
</div>
<button type="button" className="btn btn-secondary small totp-copy-btn" onClick={() => void copyToClipboard(live?.code || '')} aria-label={t('txt_copy')}>
<Clipboard size={14} className="btn-icon" />
</button>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
+939
View File
@@ -0,0 +1,939 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import VaultDialogs from '@/components/vault/VaultDialogs';
import VaultDetailView from '@/components/vault/VaultDetailView';
import VaultEditor from '@/components/vault/VaultEditor';
import VaultListPanel from '@/components/vault/VaultListPanel';
import VaultSidebar from '@/components/vault/VaultSidebar';
import {
MOBILE_LAYOUT_QUERY,
VAULT_LIST_OVERSCAN,
VAULT_LIST_ROW_HEIGHT,
VAULT_SORT_STORAGE_KEY,
cipherTypeKey,
cipherTypeLabel,
createEmptyDraft,
creationTimeValue,
draftFromCipher,
buildCipherDuplicateSignature,
firstCipherUri,
firstPasskeyCreationTime,
sortTimeValue,
type SidebarFilter,
type VaultSortMode,
} from '@/components/vault/vault-page-helpers';
import { calcTotpNow } from '@/lib/crypto';
import { computeSshFingerprint, generateDefaultSshKeyMaterial } from '@/lib/ssh';
import { ChevronLeft } from 'lucide-preact';
import type { Cipher, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
import { t } from '@/lib/i18n';
interface VaultPageProps {
ciphers: Cipher[];
folders: Folder[];
loading: boolean;
emailForReprompt: string;
onRefresh: () => Promise<void>;
onCreate: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
onUpdate: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>;
onDelete: (cipher: Cipher) => Promise<void>;
onBulkDelete: (ids: string[]) => Promise<void>;
onBulkPermanentDelete: (ids: string[]) => Promise<void>;
onBulkRestore: (ids: string[]) => Promise<void>;
onBulkMove: (ids: string[], folderId: string | null) => Promise<void>;
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
onCreateFolder: (name: string) => Promise<void>;
onDeleteFolder: (folderId: string) => Promise<void>;
onBulkDeleteFolders: (folderIds: string[]) => Promise<void>;
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
downloadingAttachmentKey: string;
attachmentDownloadPercent: number | null;
uploadingAttachmentName: string;
attachmentUploadPercent: number | null;
}
export default function VaultPage(props: VaultPageProps) {
const [searchInput, setSearchInput] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [searchComposing, setSearchComposing] = useState(false);
const [sortMode, setSortMode] = useState<VaultSortMode>('edited');
const [sortMenuOpen, setSortMenuOpen] = useState(false);
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
const [selectedCipherId, setSelectedCipherId] = useState('');
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
const [showPassword, setShowPassword] = useState(false);
const [createMenuOpen, setCreateMenuOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [draft, setDraft] = useState<VaultDraft | null>(null);
const [fieldModalOpen, setFieldModalOpen] = useState(false);
const [fieldType, setFieldType] = useState<CustomFieldType>(0);
const [fieldLabel, setFieldLabel] = useState('');
const [fieldValue, setFieldValue] = useState('');
const [localError, setLocalError] = useState('');
const [pendingDelete, setPendingDelete] = useState<Cipher | null>(null);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [moveOpen, setMoveOpen] = useState(false);
const [moveFolderId, setMoveFolderId] = useState('__none__');
const [createFolderOpen, setCreateFolderOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [pendingDeleteFolder, setPendingDeleteFolder] = useState<Folder | null>(null);
const [deleteAllFoldersOpen, setDeleteAllFoldersOpen] = useState(false);
const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null);
const [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState<Record<number, boolean>>({});
const [attachmentQueue, setAttachmentQueue] = useState<File[]>([]);
const [removedAttachmentIds, setRemovedAttachmentIds] = useState<Record<string, boolean>>({});
const [busy, setBusy] = useState(false);
const [repromptOpen, setRepromptOpen] = useState(false);
const [repromptPassword, setRepromptPassword] = useState('');
const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null);
const [isMobileLayout, setIsMobileLayout] = useState(false);
const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list');
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const createMenuRef = useRef<HTMLDivElement | null>(null);
const sortMenuRef = useRef<HTMLDivElement | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const listPanelRef = useRef<HTMLDivElement | null>(null);
const sshSeedTicketRef = useRef(0);
const sshFingerprintTicketRef = useRef(0);
const [listScrollTop, setListScrollTop] = useState(0);
const [listViewportHeight, setListViewportHeight] = useState(0);
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(() => {
const onQuickAdd = () => {
startCreate(1);
};
window.addEventListener('nodewarden:add-item', onQuickAdd);
return () => window.removeEventListener('nodewarden:add-item', onQuickAdd);
}, []);
useEffect(() => {
try {
const saved = String(localStorage.getItem(VAULT_SORT_STORAGE_KEY) || '').trim() as VaultSortMode;
if (saved === 'edited' || saved === 'created' || saved === 'name') {
setSortMode(saved);
}
} catch {
// ignore storage read failures
}
}, []);
useEffect(() => {
try {
localStorage.setItem(VAULT_SORT_STORAGE_KEY, sortMode);
} catch {
// ignore storage write failures
}
}, [sortMode]);
useEffect(() => {
const node = listPanelRef.current;
if (!node) return;
const updateSize = () => setListViewportHeight(node.clientHeight || 0);
updateSize();
const resizeObserver = new ResizeObserver(updateSize);
resizeObserver.observe(node);
return () => resizeObserver.disconnect();
}, []);
useEffect(() => {
const onPointerDown = (event: Event) => {
if (!createMenuOpen) return;
const target = event.target as Node | null;
if (createMenuRef.current && target && !createMenuRef.current.contains(target)) {
setCreateMenuOpen(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setCreateMenuOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [createMenuOpen]);
useEffect(() => {
const onPointerDown = (event: Event) => {
if (!sortMenuOpen) return;
const target = event.target as Node | null;
if (sortMenuRef.current && target && !sortMenuRef.current.contains(target)) {
setSortMenuOpen(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setSortMenuOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [sortMenuOpen]);
useEffect(() => {
setRepromptApprovedCipherId(null);
setRepromptPassword('');
setRepromptOpen(false);
}, [selectedCipherId]);
useEffect(() => {
if (!isMobileLayout) {
setMobilePanel('list');
setMobileSidebarOpen(false);
return;
}
if (isEditing) {
setMobilePanel('edit');
} else if (!selectedCipherId) {
setMobilePanel('list');
}
}, [isMobileLayout, isEditing, selectedCipherId]);
useEffect(() => {
if (searchComposing) return;
const timer = window.setTimeout(() => setSearchQuery(searchInput.trim().toLowerCase()), 90);
return () => window.clearTimeout(timer);
}, [searchInput, searchComposing]);
useEffect(() => {
if (!isEditing || !draft || draft.type !== 5) return;
void recalculateSshFingerprint(draft.sshPublicKey);
}, [isEditing, draft?.id, draft?.type]);
const duplicateSignatureCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const cipher of props.ciphers) {
const isDeleted = !!(cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt);
if (isDeleted) continue;
const signature = buildCipherDuplicateSignature(cipher);
counts.set(signature, (counts.get(signature) || 0) + 1);
}
return counts;
}, [props.ciphers]);
const filteredCiphers = useMemo(() => {
const next = props.ciphers.filter((cipher) => {
const isDeleted = !!(cipher.deletedDate || (cipher as any).deletedAt);
if (sidebarFilter.kind === 'trash') {
if (!isDeleted) return false;
} else {
if (isDeleted) return false;
if (sidebarFilter.kind === 'duplicates' && (duplicateSignatureCounts.get(buildCipherDuplicateSignature(cipher)) || 0) < 2) {
return false;
}
if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false;
if (sidebarFilter.kind === 'type' && cipherTypeKey(Number(cipher.type || 1)) !== sidebarFilter.value) return false;
if (sidebarFilter.kind === 'folder') {
if (sidebarFilter.folderId === null) {
if (cipher.folderId) return false;
} else if (cipher.folderId !== sidebarFilter.folderId) {
return false;
}
}
}
if (!searchQuery) return true;
const name = (cipher.decName || '').toLowerCase();
const username = (cipher.login?.decUsername || '').toLowerCase();
const uri = firstCipherUri(cipher).toLowerCase();
return name.includes(searchQuery) || username.includes(searchQuery) || uri.includes(searchQuery);
});
next.sort((a, b) => {
if (sortMode === 'edited') {
const diff = sortTimeValue(b) - sortTimeValue(a);
if (diff !== 0) return diff;
} else if (sortMode === 'created') {
const diff = creationTimeValue(b) - creationTimeValue(a);
if (diff !== 0) return diff;
} else {
const nameDiff = String(a.decName || a.name || '').localeCompare(String(b.decName || b.name || ''), undefined, {
sensitivity: 'base',
numeric: true,
});
if (nameDiff !== 0) return nameDiff;
}
return String(a.id || '').localeCompare(String(b.id || ''));
});
return next;
}, [props.ciphers, sidebarFilter, searchQuery, sortMode, duplicateSignatureCounts]);
const sidebarFilterKey = useMemo(() => {
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
if (sidebarFilter.kind === 'type') return `type:${sidebarFilter.value}`;
return sidebarFilter.kind;
}, [sidebarFilter]);
useEffect(() => {
setListScrollTop(0);
listPanelRef.current?.scrollTo({ top: 0 });
}, [searchQuery, sortMode, sidebarFilterKey]);
useEffect(() => {
if (sidebarFilter.kind === 'duplicates' && sortMode !== 'name') {
setSortMode('name');
}
}, [sidebarFilter.kind, sortMode]);
useEffect(() => {
if (isCreating) return;
if (!filteredCiphers.length) {
if (selectedCipherId) setSelectedCipherId('');
return;
}
if (!selectedCipherId || !filteredCiphers.some((x) => x.id === selectedCipherId)) {
setSelectedCipherId(filteredCiphers[0].id);
}
}, [filteredCiphers, selectedCipherId, isCreating]);
const selectedCipher = useMemo(
() => props.ciphers.find((x) => x.id === selectedCipherId) || null,
[props.ciphers, selectedCipherId]
);
const virtualRange = useMemo(() => {
if (!filteredCiphers.length) {
return { start: 0, end: 0, padTop: 0, padBottom: 0 };
}
const viewport = Math.max(listViewportHeight, VAULT_LIST_ROW_HEIGHT * 8);
const visibleCount = Math.ceil(viewport / VAULT_LIST_ROW_HEIGHT);
const start = Math.max(0, Math.floor(listScrollTop / VAULT_LIST_ROW_HEIGHT) - VAULT_LIST_OVERSCAN);
const end = Math.min(filteredCiphers.length, start + visibleCount + VAULT_LIST_OVERSCAN * 2);
return {
start,
end,
padTop: start * VAULT_LIST_ROW_HEIGHT,
padBottom: Math.max(0, (filteredCiphers.length - end) * VAULT_LIST_ROW_HEIGHT),
};
}, [filteredCiphers.length, listScrollTop, listViewportHeight]);
const visibleCiphers = useMemo(
() => filteredCiphers.slice(virtualRange.start, virtualRange.end),
[filteredCiphers, virtualRange.start, virtualRange.end]
);
const passkeyCreatedAt = firstPasskeyCreationTime(selectedCipher);
const selectedAttachments = useMemo(
() => (Array.isArray(selectedCipher?.attachments) ? selectedCipher.attachments : []),
[selectedCipher]
);
const editExistingAttachments = useMemo(
() =>
selectedAttachments.filter((attachment) => {
const id = String(attachment?.id || '').trim();
return !!id;
}),
[selectedAttachments]
);
const removedAttachmentCount = useMemo(() => Object.values(removedAttachmentIds).filter(Boolean).length, [removedAttachmentIds]);
useEffect(() => {
const raw = selectedCipher?.login?.decTotp || '';
if (!raw) {
setTotpLive(null);
return;
}
let stopped = false;
let timer = 0;
const tick = async () => {
try {
const now = await calcTotpNow(raw);
if (!stopped) setTotpLive(now);
} catch {
if (!stopped) setTotpLive(null);
}
};
void tick();
timer = window.setInterval(() => void tick(), 1000);
return () => {
stopped = true;
window.clearInterval(timer);
};
}, [selectedCipher?.id, selectedCipher?.login?.decTotp]);
const selectedCount = useMemo(
() => Object.values(selectedMap).reduce((sum, v) => sum + (v ? 1 : 0), 0),
[selectedMap]
);
const totalCipherCount = filteredCiphers.length;
function folderName(id: string | null | undefined): string {
if (!id) return t('txt_no_folder');
const folder = props.folders.find((x) => x.id === id);
return folder?.decName || folder?.name || id;
}
function listSubtitle(cipher: Cipher): string {
if (Number(cipher.type || 1) === 1) {
return cipher.login?.decUsername || firstCipherUri(cipher) || '';
}
return cipherTypeLabel(Number(cipher.type || 1));
}
function startCreate(type: number): void {
setDraft(createEmptyDraft(type));
setIsCreating(true);
setIsEditing(true);
setCreateMenuOpen(false);
setSelectedCipherId('');
setShowPassword(false);
setLocalError('');
setAttachmentQueue([]);
setRemovedAttachmentIds({});
if (isMobileLayout) setMobilePanel('edit');
setMobileSidebarOpen(false);
if (type === 5) void seedSshDefaults();
}
function startEdit(): void {
if (!selectedCipher) return;
setDraft(draftFromCipher(selectedCipher));
setIsCreating(false);
setIsEditing(true);
setShowPassword(false);
setLocalError('');
setAttachmentQueue([]);
setRemovedAttachmentIds({});
if (isMobileLayout) setMobilePanel('edit');
setMobileSidebarOpen(false);
}
function cancelEdit(): void {
const returnToDetail = isMobileLayout && !isCreating && !!selectedCipher;
setDraft(null);
setIsEditing(false);
setIsCreating(false);
setLocalError('');
setAttachmentQueue([]);
setRemovedAttachmentIds({});
if (isMobileLayout) setMobilePanel(returnToDetail ? 'detail' : 'list');
}
function updateDraft(patch: Partial<VaultDraft>): void {
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
}
async function seedSshDefaults(force = false): Promise<void> {
const ticket = ++sshSeedTicketRef.current;
try {
const generated = await generateDefaultSshKeyMaterial();
if (ticket !== sshSeedTicketRef.current) return;
setDraft((prev) => {
if (!prev || prev.type !== 5) return prev;
if (!force && (prev.sshPrivateKey.trim() || prev.sshPublicKey.trim())) return prev;
return {
...prev,
sshPrivateKey: generated.privateKey,
sshPublicKey: generated.publicKey,
sshFingerprint: generated.fingerprint,
};
});
} catch {
// Browser may not support Ed25519 generation; user can still paste keys manually.
}
}
async function recalculateSshFingerprint(publicKey: string): Promise<void> {
const ticket = ++sshFingerprintTicketRef.current;
const fingerprint = await computeSshFingerprint(publicKey);
if (ticket !== sshFingerprintTicketRef.current) return;
setDraft((prev) => {
if (!prev || prev.type !== 5) return prev;
if (prev.sshPublicKey !== publicKey) return prev;
if (prev.sshFingerprint === fingerprint) return prev;
return { ...prev, sshFingerprint: fingerprint };
});
}
function updateSshPublicKey(nextValue: string): void {
setDraft((prev) => {
if (!prev || prev.type !== 5) return prev;
return { ...prev, sshPublicKey: nextValue };
});
void recalculateSshFingerprint(nextValue);
}
function updateDraftCustomFields(nextFields: VaultDraftField[]): void {
setDraft((prev) => (prev ? { ...prev, customFields: nextFields } : prev));
}
function patchDraftCustomField(index: number, patch: Partial<VaultDraftField>): void {
setDraft((prev) => {
if (!prev) return prev;
const next = [...prev.customFields];
next[index] = { ...next[index], ...patch };
return { ...prev, customFields: next };
});
}
function updateDraftLoginUri(index: number, value: string): void {
setDraft((prev) => {
if (!prev) return prev;
const next = [...prev.loginUris];
next[index] = value;
return { ...prev, loginUris: next };
});
}
function queueAttachmentFiles(list: FileList | null): void {
if (!list || !list.length) return;
const next = Array.from(list).filter((file) => file && file.size >= 0);
if (!next.length) return;
setAttachmentQueue((prev) => [...prev, ...next]);
}
function removeQueuedAttachment(index: number): void {
setAttachmentQueue((prev) => prev.filter((_, i) => i !== index));
}
function toggleExistingAttachmentRemoval(attachmentId: string): void {
const id = String(attachmentId || '').trim();
if (!id) return;
setRemovedAttachmentIds((prev) => {
const next = { ...prev };
if (next[id]) delete next[id];
else next[id] = true;
return next;
});
}
async function saveDraft(): Promise<void> {
if (!draft) return;
let nextDraft = draft;
if (nextDraft.type === 5) {
const computedFingerprint = await computeSshFingerprint(nextDraft.sshPublicKey);
if (computedFingerprint !== nextDraft.sshFingerprint) {
nextDraft = { ...nextDraft, sshFingerprint: computedFingerprint };
setDraft(nextDraft);
}
}
if (!nextDraft.name.trim()) {
setLocalError(t('txt_item_name_is_required'));
return;
}
setBusy(true);
try {
if (isCreating) {
await props.onCreate(nextDraft, attachmentQueue);
} else if (selectedCipher) {
const removeAttachmentIds = Object.keys(removedAttachmentIds).filter((id) => !!removedAttachmentIds[id]);
await props.onUpdate(selectedCipher, nextDraft, {
addFiles: attachmentQueue,
removeAttachmentIds,
});
}
setIsCreating(false);
setIsEditing(false);
setDraft(null);
setLocalError('');
setAttachmentQueue([]);
setRemovedAttachmentIds({});
if (isMobileLayout) setMobilePanel('detail');
} finally {
setBusy(false);
}
}
async function deleteSelected(): Promise<void> {
if (!pendingDelete) return;
setBusy(true);
try {
await props.onDelete(pendingDelete);
setPendingDelete(null);
cancelEdit();
if (isMobileLayout) setMobilePanel('list');
} finally {
setBusy(false);
}
}
async function confirmBulkDelete(): Promise<void> {
const ids = Object.entries(selectedMap)
.filter(([, selected]) => selected)
.map(([id]) => id);
if (!ids.length) return;
setBusy(true);
try {
if (sidebarFilter.kind === 'trash') {
await props.onBulkPermanentDelete(ids);
} else {
await props.onBulkDelete(ids);
}
setSelectedMap({});
setBulkDeleteOpen(false);
} finally {
setBusy(false);
}
}
async function confirmBulkMove(): Promise<void> {
const ids = Object.entries(selectedMap)
.filter(([, selected]) => selected)
.map(([id]) => id);
if (!ids.length) return;
const folderId = moveFolderId === '__none__' ? null : moveFolderId;
setBusy(true);
try {
await props.onBulkMove(ids, folderId);
setSelectedMap({});
setMoveOpen(false);
} finally {
setBusy(false);
}
}
async function syncVault(): Promise<void> {
setBusy(true);
try {
await props.onRefresh();
} finally {
setBusy(false);
}
}
async function verifyReprompt(): Promise<void> {
if (!selectedCipher) return;
if (!repromptPassword) {
props.onNotify('error', t('txt_master_password_is_required_2'));
return;
}
setBusy(true);
try {
await props.onVerifyMasterPassword(props.emailForReprompt, repromptPassword);
setRepromptApprovedCipherId(selectedCipher.id);
setRepromptOpen(false);
setRepromptPassword('');
} catch (error) {
props.onNotify('error', error instanceof Error ? error.message : t('txt_unlock_failed'));
} finally {
setBusy(false);
}
}
async function confirmCreateFolder(): Promise<void> {
if (!newFolderName.trim()) {
props.onNotify('error', t('txt_folder_name_is_required'));
return;
}
setBusy(true);
try {
await props.onCreateFolder(newFolderName);
setCreateFolderOpen(false);
setNewFolderName('');
} finally {
setBusy(false);
}
}
async function confirmDeleteFolder(): Promise<void> {
if (!pendingDeleteFolder) return;
setBusy(true);
try {
await props.onDeleteFolder(pendingDeleteFolder.id);
if (sidebarFilter.kind === 'folder' && sidebarFilter.folderId === pendingDeleteFolder.id) {
setSidebarFilter({ kind: 'all' });
}
setPendingDeleteFolder(null);
} finally {
setBusy(false);
}
}
async function confirmBulkRestore(): Promise<void> {
const ids = Object.entries(selectedMap)
.filter(([, selected]) => selected)
.map(([id]) => id);
if (!ids.length) return;
setBusy(true);
try {
await props.onBulkRestore(ids);
setSelectedMap({});
} finally {
setBusy(false);
}
}
async function confirmDeleteAllFolders(): Promise<void> {
if (!props.folders.length) return;
setBusy(true);
try {
await props.onBulkDeleteFolders(props.folders.map((folder) => folder.id));
if (sidebarFilter.kind === 'folder') {
setSidebarFilter({ kind: 'all' });
}
setDeleteAllFoldersOpen(false);
} finally {
setBusy(false);
}
}
return (
<>
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
{isMobileLayout && mobileSidebarOpen && <div className="mobile-sidebar-mask" onClick={() => setMobileSidebarOpen(false)} />}
<VaultSidebar
folders={props.folders}
sidebarFilter={sidebarFilter}
busy={busy}
isMobileLayout={isMobileLayout}
mobileSidebarOpen={mobileSidebarOpen}
onCloseMobileSidebar={() => setMobileSidebarOpen(false)}
onChangeFilter={setSidebarFilter}
onOpenDeleteAllFolders={() => setDeleteAllFoldersOpen(true)}
onOpenCreateFolder={() => setCreateFolderOpen(true)}
onOpenDeleteFolder={setPendingDeleteFolder}
/>
<VaultListPanel
busy={busy}
loading={props.loading}
searchInput={searchInput}
sortMode={sortMode}
sortMenuOpen={sortMenuOpen}
selectedCount={selectedCount}
totalCipherCount={totalCipherCount}
filteredCiphers={filteredCiphers}
visibleCiphers={visibleCiphers}
virtualRange={virtualRange}
selectedCipherId={selectedCipherId}
selectedMap={selectedMap}
sidebarFilter={sidebarFilter}
createMenuOpen={createMenuOpen}
createMenuRef={createMenuRef}
sortMenuRef={sortMenuRef}
listPanelRef={listPanelRef}
onSearchInput={setSearchInput}
onSearchCompositionStart={() => setSearchComposing(true)}
onSearchCompositionEnd={(value) => {
setSearchComposing(false);
setSearchInput(value);
}}
onToggleSortMenu={() => setSortMenuOpen((open) => !open)}
onSelectSortMode={(value) => {
setSortMode(value);
setSortMenuOpen(false);
}}
onSyncVault={() => void syncVault()}
onOpenBulkDelete={() => setBulkDeleteOpen(true)}
onSelectDuplicates={() => {
const map: Record<string, boolean> = {};
const seen = new Set<string>();
for (const cipher of filteredCiphers) {
const signature = buildCipherDuplicateSignature(cipher);
if (seen.has(signature)) {
map[cipher.id] = true;
continue;
}
seen.add(signature);
}
setSelectedMap(map);
}}
onSelectAll={() => {
const map: Record<string, boolean> = {};
for (const cipher of filteredCiphers) map[cipher.id] = true;
setSelectedMap(map);
}}
onToggleCreateMenu={() => setCreateMenuOpen((open) => !open)}
onStartCreate={startCreate}
onBulkRestore={() => void confirmBulkRestore()}
onOpenMove={() => {
setMoveFolderId('__none__');
setMoveOpen(true);
}}
onClearSelection={() => setSelectedMap({})}
onScroll={setListScrollTop}
onToggleSelected={(cipherId, checked) =>
setSelectedMap((prev) => ({
...prev,
[cipherId]: checked,
}))
}
onSelectCipher={(cipherId) => {
if (isEditing || isCreating) {
cancelEdit();
}
setSelectedCipherId(cipherId);
setRepromptApprovedCipherId(null);
if (isMobileLayout) setMobilePanel('detail');
setMobileSidebarOpen(false);
}}
listSubtitle={listSubtitle}
/>
<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) cancelEdit();
else setMobilePanel('list');
}}
>
<ChevronLeft size={14} className="btn-icon" />
{t('txt_back')}
</button>
</div>
)}
{isEditing && draft && (
<VaultEditor
draft={draft}
isCreating={isCreating}
busy={busy}
folders={props.folders}
selectedCipher={selectedCipher}
editExistingAttachments={editExistingAttachments}
removedAttachmentIds={removedAttachmentIds}
removedAttachmentCount={removedAttachmentCount}
attachmentQueue={attachmentQueue}
attachmentInputRef={attachmentInputRef}
localError={localError}
onUpdateDraft={updateDraft}
onSeedSshDefaults={(force) => void seedSshDefaults(force)}
onUpdateSshPublicKey={updateSshPublicKey}
onUpdateDraftLoginUri={updateDraftLoginUri}
onQueueAttachmentFiles={queueAttachmentFiles}
onToggleExistingAttachmentRemoval={toggleExistingAttachmentRemoval}
onRemoveQueuedAttachment={removeQueuedAttachment}
onDownloadAttachment={(cipher, attachmentId) => void props.onDownloadAttachment(cipher, attachmentId)}
downloadingAttachmentKey={props.downloadingAttachmentKey}
attachmentDownloadPercent={props.attachmentDownloadPercent}
uploadingAttachmentName={props.uploadingAttachmentName}
attachmentUploadPercent={props.attachmentUploadPercent}
onPatchDraftCustomField={patchDraftCustomField}
onUpdateDraftCustomFields={updateDraftCustomFields}
onOpenFieldModal={() => setFieldModalOpen(true)}
onSave={() => void saveDraft()}
onCancel={cancelEdit}
onDeleteSelected={() => selectedCipher && setPendingDelete(selectedCipher)}
/>
)}
{!isEditing && selectedCipher && (
<VaultDetailView
selectedCipher={selectedCipher}
repromptApprovedCipherId={repromptApprovedCipherId}
showPassword={showPassword}
totpLive={totpLive}
passkeyCreatedAt={passkeyCreatedAt}
hiddenFieldVisibleMap={hiddenFieldVisibleMap}
folderName={folderName}
onOpenReprompt={() => setRepromptOpen(true)}
onToggleShowPassword={() => setShowPassword((value) => !value)}
onToggleHiddenField={(index) => setHiddenFieldVisibleMap((prev) => ({ ...prev, [index]: !prev[index] }))}
onDownloadAttachment={(cipher, attachmentId) => void props.onDownloadAttachment(cipher, attachmentId)}
downloadingAttachmentKey={props.downloadingAttachmentKey}
attachmentDownloadPercent={props.attachmentDownloadPercent}
onStartEdit={startEdit}
onDelete={setPendingDelete}
/>
)}
{!isEditing && !selectedCipher && <div className="empty card">{t('txt_select_an_item')}</div>}
</section>
</div>
<VaultDialogs
fieldModalOpen={fieldModalOpen}
fieldType={fieldType}
fieldLabel={fieldLabel}
fieldValue={fieldValue}
pendingDeleteOpen={!!pendingDelete}
bulkDeleteOpen={bulkDeleteOpen}
sidebarTrashMode={sidebarFilter.kind === 'trash'}
selectedCount={selectedCount}
moveOpen={moveOpen}
moveFolderId={moveFolderId}
folders={props.folders}
createFolderOpen={createFolderOpen}
newFolderName={newFolderName}
pendingDeleteFolder={pendingDeleteFolder}
deleteAllFoldersOpen={deleteAllFoldersOpen}
repromptOpen={repromptOpen}
repromptPassword={repromptPassword}
onConfirmAddField={() => {
if (!draft) return;
if (!fieldLabel.trim()) {
setLocalError(t('txt_field_label_is_required'));
return;
}
updateDraftCustomFields([
...draft.customFields,
{
type: fieldType,
label: fieldLabel.trim(),
value: fieldType === 2 ? (fieldValue === 'true' ? 'true' : 'false') : fieldValue,
},
]);
setFieldModalOpen(false);
setFieldType(0);
setFieldLabel('');
setFieldValue('');
setLocalError('');
}}
onCancelFieldModal={() => {
setFieldModalOpen(false);
setFieldType(0);
setFieldLabel('');
setFieldValue('');
}}
onFieldTypeChange={setFieldType}
onFieldLabelChange={setFieldLabel}
onFieldValueChange={setFieldValue}
onConfirmDelete={() => void deleteSelected()}
onCancelDelete={() => setPendingDelete(null)}
onConfirmBulkDelete={() => void confirmBulkDelete()}
onCancelBulkDelete={() => setBulkDeleteOpen(false)}
onConfirmMove={() => void confirmBulkMove()}
onCancelMove={() => setMoveOpen(false)}
onMoveFolderIdChange={setMoveFolderId}
onConfirmCreateFolder={() => void confirmCreateFolder()}
onCancelCreateFolder={() => {
setCreateFolderOpen(false);
setNewFolderName('');
}}
onNewFolderNameChange={setNewFolderName}
onConfirmDeleteFolder={() => void confirmDeleteFolder()}
onCancelDeleteFolder={() => setPendingDeleteFolder(null)}
onConfirmDeleteAllFolders={() => void confirmDeleteAllFolders()}
onCancelDeleteAllFolders={() => setDeleteAllFoldersOpen(false)}
onConfirmReprompt={() => void verifyReprompt()}
onCancelReprompt={() => {
setRepromptOpen(false);
setRepromptPassword('');
}}
onRepromptPasswordChange={setRepromptPassword}
/>
</>
);
}
@@ -0,0 +1,507 @@
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_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,363 @@
import { useState } from 'preact/hooks';
import { Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, 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;
}
export default function VaultDetailView(props: VaultDetailViewProps) {
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
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>
</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="kv-row custom-field-row">
<span className="kv-label" title={fieldName}>{fieldName}</span>
<div className="kv-main boolean-main">
<label className="check-line cf-check view">
<input type="checkbox" checked={checked} disabled />
</label>
<span className="boolean-text value-ellipsis" title={checked ? t('txt_checked') : t('txt_unchecked')}>
{checked ? t('txt_checked') : t('txt_unchecked')}
</span>
</div>
<div className="kv-actions" />
</div>
);
}
return (
<div key={`view-field-${index}`} className="kv-row custom-field-row">
<span className="kv-label" title={fieldName}>{fieldName}</span>
<div className="kv-main">
<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>
)}
{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>
</div>
<button type="button" className="btn btn-danger" onClick={() => props.onDelete(props.selectedCipher)}>
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
</button>
</div>
</>
)}
</>
);
}

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