19 Commits

Author SHA1 Message Date
shuaiplus 3d4e95ef66 feat: update version to 1.4.2 across package files 2026-03-28 06:01:07 +08:00
shuaiplus 2a7879efaa feat: enhance backup and restore functionality with integrity checks and progress tracking
- Added support for backup integrity verification during export and restore processes.
- Introduced progress dispatching for backup export and restore operations.
- Implemented new API endpoints for inspecting remote backup integrity.
- Enhanced user interface with progress indicators and warning dialogs for integrity issues.
- Updated localization strings for new features and user feedback.
- Refactored backup-related functions for better clarity and maintainability.
2026-03-28 05:52:47 +08:00
shuaiplus bd8e26d2ab feat: add search clear functionality and improve search input styling 2026-03-28 01:58:47 +08:00
shuaiplus 783fcbbe4b feat: add normalization functions for optional IDs and public keys in cipher and user decryption handling 2026-03-28 01:18:40 +08:00
shuaiplus 9e892e85a2 feat: update version to 1.4.1 and enhance drag-and-drop functionality for TOTP and website entries 2026-03-27 00:54:24 +08:00
shuaiplus 3e5a80e498 Refactor code structure for improved readability and maintainability 2026-03-27 00:08:29 +08:00
shuaiplus 89308fc8a6 feat: enhance login URI handling with match options and improve UI components 2026-03-26 21:59:50 +08:00
shuaiplus fe0bd80f43 feat: improve handling of archived timestamps in cipher storage normalization 2026-03-24 00:56:56 +08:00
shuaiplus 0062fd6c48 feat: enhance dark theme styles for mobile settings and table elements 2026-03-23 09:06:36 +08:00
shuaiplus 7373eeb501 feat: add backup start time configuration and theme switch functionality
- Introduced BACKUP_DEFAULT_START_TIME constant for backup scheduling.
- Updated BackupScheduleConfig interface to include startTime.
- Implemented normalizeStartTime function for validating and normalizing start time input.
- Enhanced backup settings parsing to accommodate start time.
- Added start time input field in BackupDestinationDetail component.
- Created ThemeSwitch component for toggling between light and dark themes.
- Integrated theme preference management in App component.
- Updated styles for dark mode support across the application.
- Added translations for theme toggle and backup start time labels.
2026-03-23 08:53:18 +08:00
shuaiplus 8b07cd4409 feat: refactor unarchive handling to support bulk unarchive and update prop types 2026-03-23 08:40:40 +08:00
shuaiplus 0fc7bd7985 feat: implement unarchive functionality for selected ciphers with state management 2026-03-23 08:32:43 +08:00
shuaiplus 58c029beba feat: add .tmp/ directory to .gitignore 2026-03-23 08:28:15 +08:00
shuaiplus ac79cbd8bd feat: remove temporary subproject references for bitwarden components 2026-03-23 08:28:07 +08:00
shuaiplus 96fc3ae485 feat: implement archive and bulk archive functionality with confirmation dialogs 2026-03-23 08:22:08 +08:00
shuaiplus cb4632cd04 feat: add bulk unarchive functionality for ciphers 2026-03-23 08:18:15 +08:00
shuaiplus f7b5534cd0 feat: add archiving functionality for ciphers
- Introduced `archive` and `unarchive` endpoints in the API for ciphers.
- Implemented bulk archiving and unarchiving of ciphers in the vault.
- Updated the storage schema to include `archived_at` timestamps for ciphers.
- Enhanced user interface to support archiving actions in the vault.
- Added necessary translations for archive-related actions.
- Updated user and device models to accommodate new fields related to archiving.
2026-03-23 01:10:48 +08:00
shuaiplus b50673f7d9 feat: update README files to clarify cloud backup center and password hint features 2026-03-20 06:55:20 +08:00
shuaiplus 98e94e766f feat: update README files for clarity and consistency in descriptions 2026-03-20 06:47:25 +08:00
57 changed files with 6316 additions and 831 deletions
+2 -1
View File
@@ -38,4 +38,5 @@ npm-debug.log*
# Package lock (optional - remove if you want to commit it)
# package-lock.json
tmp/
tmp/
.tmp/
+63 -54
View File
@@ -3,7 +3,7 @@
</p>
<p align="center">
运行在 Cloudflare Workers Bitwarden 第三方服务端,兼容官方客户端
运行在 Cloudflare Workers 上的第三方 Bitwarden 兼容服务端。
</p>
[![Powered by Cloudflare](https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white)](https://workers.cloudflare.com/)
@@ -11,51 +11,50 @@
[![Latest Release](https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag)](https://github.com/shuaiplus/NodeWarden/releases/latest)
[![Sync Upstream](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg)](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
[更新日志](./RELEASE_NOTES.md) [提交问题](https://github.com/shuaiplus/NodeWarden/issues/new/choose) [最新发布](https://github.com/shuaiplus/NodeWarden/releases/latest)
English[`README_EN.md`](./README_EN.md)
[更新日志](./RELEASE_NOTES.md) | [提交问题](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [最新发布](https://github.com/shuaiplus/NodeWarden/releases/latest)
English: [`README_EN.md`](./README_EN.md)
> **免责声明**
> 本项目仅供学习交流使用。我们不对任何数据丢失负责,强烈建议定期备份的密码库。
> 本项目与 Bitwarden 官方无关,请向 Bitwarden 官方反馈问题。
> 本项目仅供学习交流使用,请定期备份的密码库。
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
---
## 与 Bitwarden 官方服务端能力对比
| 能力 | Bitwarden | NodeWarden | 说明 |
| 能力 | Bitwarden | NodeWarden | 说明 |
|---|---|---|---|
| Web Vault(登录/笔记/卡片/身份) | ✅ | ✅ | 网页端密码库管理页面 |
| 文件夹 / 收藏 | ✅ | ✅ | 常用管理能力可用 |
| 全量同步 `/api/sync` | ✅ | ✅ | 已做兼容与性能优化 |
| 附件上传/下载 | ✅ | ✅ | Cloudflare R2 和 KV 二选一 |
| 导入导出功能 | ✅ | ✅ | 完整实现,含 Bitwarden 密码库+附件 ZIP 导入 |
| 网站图标代理 | | ✅ | 通过 `/icons/{hostname}/icon.png` |
| passkey、TOTP 字段 | ✅ | ✅ | 完全支持,无需高级版 |
| Send | ✅ | ✅ | Cloudflare R2 和 KV 二选一 |
| 多用户 | ✅ | ✅ | 完整的用户管理,邀请机制 |
| 组织/集合/成员权限 | ✅ | ❌ | 没必要实现 |
| 登录 2FATOTP/WebAuthn/Duo/Email | ✅ | ⚠️ 部分支持 | 仅支持用户级 TOTP |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 没必要实现 |
| 紧急访问 | ✅ | ❌ | 没必要实现 |
| 管理后台 / 计费订阅 | ✅ | ❌ | 纯免费 |
| 推送通知完整链路 | ✅ | ❌ | 没必要实现 |
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
| 全量同步 `/api/sync` | ✅ | ✅ | 已针对官方客户端做兼容优化 |
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
| Send | ✅ | ✅ | 支持文本与文件 Send |
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
| **云端备份中心** | | ✅ | **支持 WebDAV / E3 定时备份** |
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
| TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 |
| 多用户 | ✅ | ✅ | 支持邀请码注册 |
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 实现 |
| 登录 2FA | ✅ | ⚠️ 部分支持 | 当前仅支持用户级 TOTP |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 实现 |
## 测试情况:
- ✅ Windows 客户端(v2026.1.0
- ✅ 手机 Appv2026.1.0
- ✅ 浏览器扩展(v2026.1.0
- ✅ Linux 客户端(v2026.1.0
- ⬜ macOS 客户端(未测试)
---
## 已测试客户端
- ✅ Windows 桌面端
- ✅ 手机 App
- ✅ 浏览器扩展
- ✅ Linux 桌面端
- ⚠️ macOS 桌面端尚未完整验证
---
## 网页部署
1. Fork 本仓库。若本项目对你有帮助,欢迎点个 Star。
2. 打开 [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) ➜ `Continue with GitHub` ➜ 选择你 Fork 后的仓库(`NodeWarden`)➜ 下一步 ➜ (默认使用 R2 存储;若未开通,可切换为 KV,并将部署命令改为 `npm run deploy:kv`)➜ 部署 ➜ 打开生成的链接
2. 打开 [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) ➜ `Continue with GitHub` ➜ 选择你 Fork 后的仓库(`NodeWarden`)➜ 下一步 ➜ (默认使用 R2 存储;若未开通,可用 KV 来代替,将**部署命令**改为 `npm run deploy:kv`)➜ 部署 ➜ 打开生成的链接
| 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|---|---|---|---|
@@ -72,51 +71,60 @@ English[`README_EN.md`](./README_EN.md)
## CLI 部署
```powershell
# 先把仓库拉到本地
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
# 安装依赖
npm install
# Cloudflare CLI 登录
npx wrangler login
# 部署到 Cloudflare
npm run deploy
# 默认:R2 模式
npm run deploy
# 可选KV 模式(无 R2 / 无信用卡)
# 可选KV 模式
npm run deploy:kv
# 本地开发
npm run dev
npm run dev:kv
# 后续更新时重新拉取仓库并重新部署即可,无需重复创建云资源
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
npm run deploy
```
---
## 云端备份说明
- 远程备份支持 **WebDAV****E3**
- 勾选“包含附件”后:
- ZIP 内仍只包含 `db.json``manifest.json`
- 真实附件单独存放在 `attachments/`
- 后续备份会按稳定 blob 名复用已有附件,不会每次全量重传
- 远程还原时:
- 会从 `attachments/` 目录按需读取附件
- 缺失的附件会被安全跳过
- 被跳过的附件不会在恢复后的数据库中留下脏记录
---
## 常见问题
**Q: 如何备份数据?**
A: 在客户端中选择「导出密码库」,保存 JSON 文件。
## 导入 / 导出
**Q: 导入导出支持哪些格式?**
A: 支持 Bitwarden `json/csv/密码库+附件 zip` 和 NodeWarden `密码库+附件 json`(均含加密模式),且导入下拉中看到的格式都可直接导入。
A: 另外还支持直接导入 Bitwarden `密码库+附件 zip`,这条路径官方 Bitwarden Web 暂不支持。
当前支持的导入来源包括:
**Q: 忘记主密码怎么办?**
A: 无法恢复,这是端到端加密的特性。建议妥善保管主密码。
- Bitwarden JSON
- Bitwarden CSV
- Bitwarden 密码库 + 附件 ZIP
- NodeWarden JSON
- 网页导入器里可见的多种浏览器 / 密码管理器格式
**Q: 可以多人使用吗?**
A: 支持。第一个注册的用户会自动成为管理员;管理员可在管理页面生成邀请码,其他用户凭邀请码注册。
当前支持的导出方式包括:
- Bitwarden JSON
- Bitwarden 加密 JSON
- 带附件的 ZIP 导出
- NodeWarden JSON 系列
- 备份中心中的实例级完整手动导出
---
## 开源协议
LGPL-3.0 License
@@ -125,11 +133,12 @@ LGPL-3.0 License
## 致谢
- [Bitwarden](https://bitwarden.com/) - 原始设计客户端
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务实现参考
- [Bitwarden](https://bitwarden.com/) - 原始设计客户端
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务实现参考
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left)](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
+72 -64
View File
@@ -3,7 +3,7 @@
</p>
<p align="center">
A third-party Bitwarden server running on Cloudflare Workers, fully compatible with official clients.
A third-party Bitwarden-compatible server running on Cloudflare Workers.
</p>
[![Powered by Cloudflare](https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white)](https://workers.cloudflare.com/)
@@ -11,105 +11,114 @@
[![Latest Release](https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag)](https://github.com/shuaiplus/NodeWarden/releases/latest)
[![Sync Upstream](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg)](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
[Release Notes](./RELEASE_NOTES.md) [Report an Issue](https://github.com/shuaiplus/NodeWarden/issues/new/choose) [Latest Release](https://github.com/shuaiplus/NodeWarden/releases/latest)
[Release Notes](./RELEASE_NOTES.md) | [Report an Issue](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [Latest Release](https://github.com/shuaiplus/NodeWarden/releases/latest)
中文文档:[`README.md`](./README.md)
English: [`README.md`](./README.md)
> **Disclaimer**
> This project is for learning and communication purposes only. We are not responsible for any data loss; regular vault backups are strongly recommended.
> This project is not affiliated with Bitwarden. Please do not report issues to the official Bitwarden team.
> This project is for learning and communication purposes only. Please back up your vault regularly.
> This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team.
---
## Feature Comparison Table (vs Official Bitwarden Server)
## Feature Comparison with Official Bitwarden Server
| Capability | Bitwarden | NodeWarden | Notes |
| Capability | Bitwarden | NodeWarden | Notes |
|---|---|---|---|
| Web Vault (logins/notes/cards/identities) | ✅ | ✅ | Web-based vault management UI |
| Folders / Favorites | ✅ | ✅ | Common vault organization supported |
| Full sync `/api/sync` | ✅ | ✅ | Compatibility and performance optimized |
| Attachment upload/download | ✅ | ✅ | Choose either Cloudflare R2 or KV |
| Import / export | ✅ | ✅ | Fully implemented, including Bitwarden vault + attachments ZIP import |
| Website icon proxy | | ✅ | Via `/icons/{hostname}/icon.png` |
| 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 |
| Login 2FA (TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ Partial | User-level TOTP only |
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not necessary to implement |
| Emergency access | ✅ | ❌ | Not necessary to implement |
| Admin console / Billing & subscription | ✅ | ❌ | Free only |
| Full push notification pipeline | ✅ | ❌ | Not necessary to implement |
## Tested clients / platforms
- ✅ Windows desktop client (v2026.1.0)
- ✅ Mobile app (v2026.1.0)
- ✅ Browser extension (v2026.1.0)
- ✅ Linux desktop client (v2026.1.0)
- ⬜ macOS desktop client (not tested)
| Web Vault | ✅ | ✅ | **Original Web Vault interface** |
| Full sync `/api/sync` | ✅ | ✅ | Optimized for official clients |
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
| Send | ✅ | ✅ | Supports both text and file Sends |
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
| **Cloud Backup Center** | | ✅ | **Supports scheduled backups with WebDAV / E3** |
| Password hint (web) | ⚠️ Limited | ✅ | **No email required** |
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
| Multi-user | ✅ | ✅ | Invite-based registration |
| Organizations / Collections / Member roles | ✅ | ❌ | Not implemented |
| Login 2FA | ✅ | ⚠️ Partial | Currently only user-level TOTP |
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not implemented |
---
## Web deploy
## Tested Clients
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.
- ✅ Windows desktop client
- ✅ Mobile app
- ✅ Browser extension
- ✅ Linux desktop client
- ⚠️ macOS desktop client not fully verified
---
## Web Deploy
1. Fork this repository. If this project helps you, please consider giving it a Star.
2. Open [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) -> `Continue with GitHub` -> select your forked repository (`NodeWarden`) -> `Next` -> deploy.
R2 is used by default. If R2 is unavailable for your account, you can use KV instead by changing the **deploy command** to `npm run deploy:kv`.
| 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 |
| R2 | Yes | 100 MB (soft limit, can be adjusted) | 10 GB |
| KV | No | 25 MiB (Cloudflare limit) | 1 GB |
> [!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.
> [!TIP]
> How to keep your fork updated:
> - Manual: open your fork on GitHub, click `Sync fork`, then `Update branch`
> - Automatic: go to your fork -> `Actions` -> `Sync upstream` -> `Enable workflow`; it will sync upstream automatically every day at 3 AM
## CLI deploy
## CLI Deploy
```powershell
# Clone repository
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
# Install dependencies
npm install
# Cloudflare CLI login
npx wrangler login
# Deploy to Cloudflare
npm run deploy
# Default: R2 mode
npm run deploy
# (Optional) KV mode (no R2 / no credit card)
# Optional: KV mode
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
## Cloud Backup Notes
**Q: How do I back up my data?**
A: Use **Export vault** in your client and save the JSON file.
- Remote backup supports **WebDAV** and **E3**
- When `Include attachments` is enabled:
- the ZIP still contains only `db.json` and `manifest.json`
- real attachment files are stored separately under `attachments/`
- later backups reuse existing attachments by stable blob name instead of uploading everything again
- During remote restore:
- required attachment files are loaded from `attachments/`
- missing attachments are skipped safely
- skipped attachments do not leave broken rows in the restored database
**Q: 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.
## Import / Export
**Q: Can multiple people use it?**
A: Yes. The first registered user becomes the admin. The admin can generate invite codes from the admin panel, and other users register with those codes.
Current supported import sources include:
- Bitwarden JSON
- Bitwarden CSV
- Bitwarden vault + attachments ZIP
- NodeWarden JSON
- Multiple browser / password-manager formats visible in the web import selector
Current supported export formats include:
- Bitwarden JSON
- Bitwarden encrypted JSON
- ZIP export with attachments
- NodeWarden JSON variants
- Full manual instance export from the backup center
---
@@ -125,9 +134,8 @@ LGPL-3.0 License
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - server implementation reference
- [Cloudflare Workers](https://workers.cloudflare.com/) - serverless platform
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left)](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
+7
View File
@@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS users (
security_stamp TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'active',
verify_devices INTEGER NOT NULL DEFAULT 1,
totp_secret TEXT,
totp_recovery_code TEXT,
created_at TEXT NOT NULL,
@@ -51,10 +52,12 @@ CREATE TABLE IF NOT EXISTS ciphers (
key TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
archived_at TEXT,
deleted_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at);
CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at);
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at);
CREATE TABLE IF NOT EXISTS folders (
@@ -144,6 +147,10 @@ CREATE TABLE IF NOT EXISTS devices (
device_identifier TEXT NOT NULL,
name TEXT NOT NULL,
type INTEGER NOT NULL,
session_stamp TEXT,
encrypted_user_key TEXT,
encrypted_public_key TEXT,
encrypted_private_key TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, device_identifier),
+79 -5
View File
@@ -1,14 +1,17 @@
{
"name": "nodewarden",
"version": "1.4.0",
"version": "1.4.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nodewarden",
"version": "1.4.0",
"version": "1.4.2",
"license": "LGPL-3.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@noble/hashes": "^2.0.1",
"@tanstack/react-query": "^5.90.21",
"@zip.js/zip.js": "^2.8.22",
@@ -507,6 +510,60 @@
"node": ">=12"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmmirror.com/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmmirror.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.8.1",
"resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz",
@@ -2746,6 +2803,19 @@
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.4"
}
},
"node_modules/regexparam": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/regexparam/-/regexparam-3.0.0.tgz",
@@ -2811,6 +2881,12 @@
"fsevents": "~2.3.2"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz",
@@ -2943,9 +3019,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.21.0",
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nodewarden",
"version": "1.4.0",
"version": "1.4.2",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus",
"license": "LGPL-3.0",
@@ -46,6 +46,9 @@
"wrangler": "^4.71.0"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@noble/hashes": "^2.0.1",
"@tanstack/react-query": "^5.90.21",
"@zip.js/zip.js": "^2.8.22",
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.4.0';
export const APP_VERSION = '1.4.2';
+3
View File
@@ -3,6 +3,7 @@ export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
export const BACKUP_DEFAULT_E3_REGION = 'auto';
export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden';
export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
export const BACKUP_DEFAULT_START_TIME = '03:00';
export type BackupDestinationType = 'e3' | 'webdav';
@@ -40,6 +41,7 @@ export interface BackupRuntimeState {
export interface BackupScheduleConfig {
enabled: boolean;
intervalHours: number;
startTime: string;
timezone: string;
retentionCount: number | null;
}
@@ -82,6 +84,7 @@ export function createDefaultBackupScheduleConfig(timezone: string = BACKUP_DEFA
return {
enabled: false,
intervalHours: BACKUP_DEFAULT_INTERVAL_HOURS,
startTime: BACKUP_DEFAULT_START_TIME,
timezone,
retentionCount: BACKUP_DEFAULT_RETENTION_COUNT,
};
+102 -24
View File
@@ -5,6 +5,7 @@ const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARAT
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
const SIGNALR_PING_INTERVAL_MS = 15_000;
type HubProtocol = 'json' | 'messagepack';
@@ -127,25 +128,21 @@ function frameSignalRBinary(payload: Uint8Array): Uint8Array {
}
function buildSignalRJsonInvocation(
userId: string,
updateType: number,
revisionDate: string,
payload: Record<string, unknown>,
contextId: string | null
): string {
return JSON.stringify({
type: 1,
target: 'ReceiveMessage',
arguments: [
{
ContextId: contextId,
Type: updateType,
Payload: {
UserId: userId,
Date: revisionDate,
{
ContextId: contextId,
Type: updateType,
Payload: payload,
},
},
],
}) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
],
}) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
}
function buildSignalRJsonPing(): string {
@@ -153,14 +150,13 @@ function buildSignalRJsonPing(): string {
}
function buildSignalRMessagePackInvocation(
userId: string,
updateType: number,
revisionDate: string,
messagePayload: Record<string, unknown>,
contextId: string | null
): Uint8Array {
// SignalR MessagePack hub protocol uses an array-based invocation shape:
// [type, headers, invocationId, target, arguments]
const payload = encodeMsgPack([
const encodedPayload = encodeMsgPack([
1,
{},
null,
@@ -169,14 +165,11 @@ function buildSignalRMessagePackInvocation(
{
ContextId: contextId,
Type: updateType,
Payload: {
UserId: userId,
Date: new Date(revisionDate),
},
Payload: messagePayload,
},
],
]);
return frameSignalRBinary(payload);
return frameSignalRBinary(encodedPayload);
}
function buildSignalRMessagePackPing(): Uint8Array {
@@ -209,13 +202,20 @@ export class NotificationsHub {
contextId?: string | null;
updateType?: number;
targetDeviceIdentifier?: string | null;
payload?: Record<string, unknown> | 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);
const payload = body?.payload && typeof body.payload === 'object'
? body.payload
: {
UserId: this.userId,
Date: revisionDate,
};
this.broadcastMessage(updateType, payload, contextId, targetDeviceIdentifier);
return new Response(null, { status: 204 });
}
@@ -360,7 +360,7 @@ export class NotificationsHub {
private broadcastMessage(
updateType: number,
revisionDate: string,
payload: Record<string, unknown>,
contextId: string | null,
targetDeviceIdentifier: string | null
): void {
@@ -371,9 +371,9 @@ export class NotificationsHub {
if (targetDeviceIdentifier && connection.deviceIdentifier !== targetDeviceIdentifier) continue;
try {
if (connection.protocol === 'json') {
socket.send(buildSignalRJsonInvocation(this.userId, updateType, revisionDate, contextId));
socket.send(buildSignalRJsonInvocation(updateType, payload, contextId));
} else {
socket.send(buildSignalRMessagePackInvocation(this.userId, updateType, revisionDate, contextId));
socket.send(buildSignalRMessagePackInvocation(updateType, payload, contextId));
}
} catch {
this.connections.delete(socket);
@@ -389,7 +389,15 @@ export class NotificationsHub {
}
private broadcastDeviceStatus(): void {
this.broadcastMessage(SIGNALR_UPDATE_TYPE_DEVICE_STATUS, new Date().toISOString(), null, null);
this.broadcastMessage(
SIGNALR_UPDATE_TYPE_DEVICE_STATUS,
{
UserId: this.userId,
Date: new Date().toISOString(),
},
null,
null
);
}
}
@@ -445,9 +453,79 @@ async function notifyUserUpdate(
contextId: contextId || null,
updateType,
targetDeviceIdentifier: targetDeviceIdentifier || null,
payload: {
UserId: userId,
Date: revisionDate,
},
}),
});
} catch (error) {
console.error('Failed to broadcast realtime notification:', error);
}
}
export async function notifyUserBackupProgress(
env: Env,
userId: string,
progress: {
operation: 'backup-restore' | 'backup-export' | 'backup-remote-run';
source?: 'local' | 'remote';
step: string;
fileName: string;
stageTitle?: string;
stageDetail?: string;
replaceExisting?: boolean;
done?: boolean;
ok?: boolean;
error?: string | null;
timestamp?: string;
},
targetDeviceIdentifier?: string | null
): Promise<void> {
const revisionDate = progress.timestamp || new Date().toISOString();
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: null,
updateType: SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS,
targetDeviceIdentifier: targetDeviceIdentifier || null,
payload: {
UserId: userId,
Date: revisionDate,
...progress,
},
}),
});
} catch (error) {
console.error('Failed to broadcast backup progress:', error);
}
}
export async function notifyUserBackupRestoreProgress(
env: Env,
userId: string,
progress: {
operation: 'backup-restore';
source: 'local' | 'remote';
step: string;
fileName: string;
stageTitle?: string;
stageDetail?: string;
replaceExisting?: boolean;
done?: boolean;
ok?: boolean;
error?: string | null;
timestamp?: string;
},
targetDeviceIdentifier?: string | null
): Promise<void> {
return notifyUserBackupProgress(env, userId, progress, targetDeviceIdentifier);
}
+46
View File
@@ -75,6 +75,16 @@ function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' |
return null;
}
async function verifyUserSecret(
auth: AuthService,
user: User,
secret: string | null | undefined
): Promise<boolean> {
const normalized = String(secret || '').trim();
if (!normalized) return false;
return auth.verifyPassword(normalized, user.masterPasswordHash, user.email);
}
function toProfile(user: User, env: Env): ProfileResponse {
void env;
return {
@@ -98,6 +108,7 @@ function toProfile(user: User, env: Env): ProfileResponse {
forcePasswordReset: false,
avatarColor: null,
creationDate: user.createdAt,
verifyDevices: user.verifyDevices,
role: user.role,
status: user.status,
object: 'profile',
@@ -194,6 +205,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
securityStamp: generateUUID(),
role: 'user',
status: 'active',
verifyDevices: true,
totpSecret: null,
totpRecoveryCode: null,
createdAt: now,
@@ -363,6 +375,40 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
return jsonResponse(toProfile(user, env));
}
// PUT/POST /api/accounts/verify-devices
export async function handleSetVerifyDevices(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: {
secret?: string;
masterPasswordHash?: string;
verifyDevices?: boolean;
};
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
if (typeof body.verifyDevices !== 'boolean') {
return errorResponse('verifyDevices must be true or false', 400);
}
const verified = await verifyUserSecret(auth, user, body.secret || body.masterPasswordHash);
if (!verified) {
return errorResponse('User verification failed.', 400);
}
user.verifyDevices = body.verifyDevices;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
return new Response(null, { status: 200 });
}
// POST /api/accounts/keys
export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
+333 -18
View File
@@ -1,7 +1,12 @@
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 BackupArchiveBundle,
buildBackupArchive,
inspectBackupArchiveFileNameChecksum,
verifyBackupArchiveFileNameChecksum,
} from '../services/backup-archive';
import {
type BackupDestinationRecord,
type BackupSettingsInput,
@@ -17,19 +22,25 @@ import {
requireBackupDestination,
saveBackupSettings,
} from '../services/backup-config';
import { type BackupImportExecutionResult, importBackupArchiveBytes, importRemoteBackupArchiveBytes } from '../services/backup-import';
import {
type BackupImportExecutionResult,
type BackupRestoreProgressReporter,
importBackupArchiveBytes,
importRemoteBackupArchiveBytes,
} from '../services/backup-import';
import {
type RemoteBackupTransferSession,
createRemoteBackupTransferSession,
deleteRemoteBackupFile,
downloadRemoteBackupFile,
ensureRemoteRestoreCandidate,
listRemoteBackupEntries,
pruneRemoteBackupArchives,
remoteBackupFileExists,
uploadRemoteBackupFile,
uploadBackupArchive,
} from '../services/backup-uploader';
import { StorageService } from '../services/storage';
import { getBlobObject } from '../services/blob-store';
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active';
@@ -81,13 +92,74 @@ function ensureBackupBlobName(value: string): string {
return parts.join('/');
}
const REMOTE_ATTACHMENT_INDEX_PATH = 'attachments/.nodewarden-attachment-index.v1.json';
interface RemoteAttachmentIndexPayload {
version: 1;
blobs: Record<string, { sizeBytes: number; updatedAt: string }>;
}
async function loadRemoteAttachmentIndex(session: RemoteBackupTransferSession): Promise<Map<string, number>> {
try {
const file = await session.download(REMOTE_ATTACHMENT_INDEX_PATH);
const payload = JSON.parse(new TextDecoder().decode(file.bytes)) as RemoteAttachmentIndexPayload;
if (payload?.version !== 1 || !payload.blobs || typeof payload.blobs !== 'object') {
return new Map<string, number>();
}
return new Map(
Object.entries(payload.blobs)
.filter(([key, value]) => !!String(key || '').trim() && Number.isFinite(Number(value?.sizeBytes || 0)))
.map(([key, value]) => [key, Number(value.sizeBytes || 0)])
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('404') || message.includes('Please select a backup file')) {
return new Map<string, number>();
}
throw error;
}
}
async function saveRemoteAttachmentIndex(
session: RemoteBackupTransferSession,
index: Map<string, number>
): Promise<void> {
const payload: RemoteAttachmentIndexPayload = {
version: 1,
blobs: Object.fromEntries(
Array.from(index.entries()).map(([blobName, sizeBytes]) => [
blobName,
{
sizeBytes,
updatedAt: new Date().toISOString(),
},
])
),
};
const bytes = new TextEncoder().encode(JSON.stringify(payload));
await session.putFile(REMOTE_ATTACHMENT_INDEX_PATH, bytes, {
contentType: 'application/json; charset=utf-8',
});
}
async function executeConfiguredBackup(
env: Env,
storage: StorageService,
actorUserId: string | null,
trigger: 'manual' | 'scheduled',
destinationId?: string | null
destinationId?: string | null,
progress?: ((event: {
operation: 'backup-remote-run';
step: string;
fileName: string;
stageTitle: string;
stageDetail: string;
done?: boolean;
ok?: boolean;
error?: string | null;
}) => Promise<void>) | null
): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> {
const maxArchiveUploadAttempts = 3;
const currentSettings = await loadBackupSettings(storage, env, 'UTC');
const destination = requireBackupDestination(currentSettings, destinationId);
@@ -99,25 +171,109 @@ async function executeConfiguredBackup(
await saveBackupSettings(storage, env, currentSettings);
try {
await progress?.({
operation: 'backup-remote-run',
step: 'remote_run_prepare',
fileName: '',
stageTitle: 'txt_backup_remote_run_progress_prepare_title',
stageDetail: 'txt_backup_remote_run_progress_prepare_detail',
});
const archive = await buildBackupArchive(env, now, {
includeAttachments: destination.includeAttachments,
progress: progress
? async (event) => {
if (event.step === 'archive_ready') {
return;
}
await progress({
operation: 'backup-remote-run',
step: `remote_run_${event.step}`,
fileName: event.fileName || '',
stageTitle: event.stageTitle,
stageDetail: event.stageDetail,
});
}
: undefined,
});
await progress?.({
operation: 'backup-remote-run',
step: 'remote_run_sync_attachments',
fileName: archive.fileName,
stageTitle: 'txt_backup_remote_run_progress_sync_attachments_title',
stageDetail: destination.includeAttachments
? 'txt_backup_remote_run_progress_sync_attachments_detail'
: 'txt_backup_remote_run_progress_sync_attachments_skipped_detail',
});
const remoteSession = createRemoteBackupTransferSession(destination);
const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession);
let attachmentIndexChanged = false;
for (const attachment of archive.manifest.attachmentBlobs || []) {
if (remoteAttachmentIndex.get(attachment.blobName) === attachment.sizeBytes) {
continue;
}
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, {
await remoteSession.putFile(remotePath, bytes, {
contentType: object.contentType,
});
remoteAttachmentIndex.set(attachment.blobName, attachment.sizeBytes);
attachmentIndexChanged = true;
}
if (attachmentIndexChanged) {
await saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex);
}
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
await progress?.({
operation: 'backup-remote-run',
step: 'remote_run_upload_archive',
fileName: archive.fileName,
stageTitle: 'txt_backup_remote_run_progress_upload_title',
stageDetail: 'txt_backup_remote_run_progress_upload_detail',
});
upload = await remoteSession.uploadArchive(archive.bytes, archive.fileName);
try {
await progress?.({
operation: 'backup-remote-run',
step: 'remote_run_verify_archive',
fileName: archive.fileName,
stageTitle: 'txt_backup_remote_run_progress_verify_title',
stageDetail: 'txt_backup_remote_run_progress_verify_detail',
});
const remoteFile = await remoteSession.download(archive.fileName);
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, archive.fileName);
if (!checksumOk) {
throw new Error('Remote backup ZIP checksum verification failed');
}
if (remoteFile.bytes.byteLength !== archive.bytes.byteLength) {
throw new Error('Remote backup ZIP size verification failed');
}
break;
} catch (error) {
await remoteSession.deleteFile(archive.fileName).catch(() => undefined);
if (attempt === maxArchiveUploadAttempts) {
const message = error instanceof Error ? error.message : 'Remote backup ZIP verification failed';
throw new Error(`Backup archive upload verification failed after ${maxArchiveUploadAttempts} attempts: ${message}`);
}
}
}
if (!upload) {
throw new Error('Backup archive upload failed');
}
const upload = await uploadBackupArchive(destination, archive.bytes, archive.fileName);
let prunedFileCount = 0;
let pruneErrorMessage: string | null = null;
try {
await progress?.({
operation: 'backup-remote-run',
step: 'remote_run_cleanup',
fileName: archive.fileName,
stageTitle: 'txt_backup_remote_run_progress_cleanup_title',
stageDetail: 'txt_backup_remote_run_progress_cleanup_detail',
});
prunedFileCount = await pruneRemoteBackupArchives(destination, destination.schedule.retentionCount, archive.fileName);
} catch (error) {
pruneErrorMessage = error instanceof Error ? error.message : 'Old backup cleanup failed';
@@ -137,10 +293,21 @@ async function executeConfiguredBackup(
remotePath: upload.remotePath,
fileName: archive.fileName,
fileBytes: archive.bytes.byteLength,
uploadVerificationAttempts: maxArchiveUploadAttempts,
prunedFileCount,
pruneError: pruneErrorMessage,
});
await progress?.({
operation: 'backup-remote-run',
step: 'remote_run_complete',
fileName: archive.fileName,
stageTitle: 'txt_backup_remote_run_progress_complete_title',
stageDetail: 'txt_backup_remote_run_progress_complete_detail',
done: true,
ok: true,
});
return {
fileName: archive.fileName,
fileSize: archive.bytes.byteLength,
@@ -156,6 +323,16 @@ async function executeConfiguredBackup(
...getBackupDestinationSummary(destination),
error: destination.runtime.lastErrorMessage,
});
await progress?.({
operation: 'backup-remote-run',
step: 'remote_run_failed',
fileName: '',
stageTitle: 'txt_backup_remote_run_progress_failed_title',
stageDetail: 'txt_backup_remote_run_progress_failed_detail',
done: true,
ok: false,
error: destination.runtime.lastErrorMessage,
});
throw error;
}
}
@@ -170,13 +347,35 @@ function toImportStatusCode(message: string): number {
async function runImportAndAudit(
env: Env,
request: Request,
actorUser: User,
archiveBytes: Uint8Array,
fileName: string,
replaceExisting: boolean,
metadata: Record<string, unknown>
): Promise<BackupImportExecutionResult> {
const storage = new StorageService(env.DB);
const imported = await importBackupArchiveBytes(archiveBytes, env, actorUser.id, replaceExisting);
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
const progress: BackupRestoreProgressReporter = async (event) => {
await notifyUserBackupRestoreProgress(
env,
actorUser.id,
{
operation: 'backup-restore',
...event,
},
targetDeviceIdentifier
);
};
await progress({
source: 'local',
step: 'local_upload_received',
fileName,
stageTitle: 'txt_backup_restore_progress_local_upload_title',
stageDetail: 'txt_backup_restore_progress_local_upload_detail',
replaceExisting,
});
const imported = await importBackupArchiveBytes(archiveBytes, env, actorUser.id, replaceExisting, progress, fileName);
await writeAuditLog(storage, imported.auditActorUserId, 'admin.backup.import', 'backup', null, {
users: imported.result.imported.users,
ciphers: imported.result.imported.ciphers,
@@ -309,7 +508,20 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env,
return errorResponse('Backup run payload is invalid', 400);
}
const result = await executeConfiguredBackup(env, storage, actorUser.id, 'manual', body?.destinationId || null);
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
const progress = async (event: {
operation: 'backup-remote-run';
step: string;
fileName: string;
stageTitle: string;
stageDetail: string;
done?: boolean;
ok?: boolean;
error?: string | null;
}) => {
await notifyUserBackupProgress(env, actorUser.id, event, targetDeviceIdentifier);
};
const result = await executeConfiguredBackup(env, storage, actorUser.id, 'manual', body?.destinationId || null, progress);
const settings = await loadBackupSettings(storage, env, 'UTC');
return jsonResponse({
object: 'backup-run',
@@ -369,6 +581,29 @@ export async function handleDownloadAdminRemoteBackup(request: Request, env: Env
}
}
export async function handleInspectAdminRemoteBackup(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);
const integrity = await inspectBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
return jsonResponse({
object: 'backup-remote-integrity',
destinationId: destination.id,
path,
fileName: remoteFile.fileName || path.split('/').pop() || path,
integrity,
});
} catch (error) {
return errorResponse(error instanceof Error ? error.message : 'Remote backup integrity inspection failed', 409);
}
}
export async function handleDeleteAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
@@ -392,7 +627,7 @@ export async function handleDeleteAdminRemoteBackup(request: Request, env: Env,
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 };
let body: { destinationId?: string; path?: string; replaceExisting?: boolean; allowChecksumMismatch?: boolean };
try {
body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>();
} catch {
@@ -404,7 +639,39 @@ export async function handleRestoreAdminRemoteBackup(request: Request, env: Env,
const settings = await loadBackupSettings(storage, env, 'UTC');
const destination = requireBackupDestination(settings, body.destinationId || null);
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
const restoreFileNameFromPath = path.split('/').pop() || path;
await notifyUserBackupRestoreProgress(
env,
actorUser.id,
{
operation: 'backup-restore',
source: 'remote',
step: 'remote_fetch_archive',
fileName: restoreFileNameFromPath,
stageTitle: 'txt_backup_restore_progress_remote_fetch_title',
stageDetail: 'txt_backup_restore_progress_remote_fetch_detail',
replaceExisting: !!body.replaceExisting,
},
targetDeviceIdentifier
);
const remoteFile = await downloadRemoteBackupFile(destination, path);
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
if (!checksumOk && !body.allowChecksumMismatch) {
return errorResponse('Remote backup file checksum does not match its filename', 400);
}
const restoreFileName = remoteFile.fileName || path.split('/').pop() || path;
const progress: BackupRestoreProgressReporter = async (event) => {
await notifyUserBackupRestoreProgress(
env,
actorUser.id,
{
operation: 'backup-restore',
...event,
},
targetDeviceIdentifier
);
};
const imported = await (async () => {
const storage = new StorageService(env.DB);
const result = await importRemoteBackupArchiveBytes(
@@ -413,12 +680,13 @@ export async function handleRestoreAdminRemoteBackup(request: Request, env: 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;
},
}
loadAttachment: async (blobName) => {
const file = await downloadRemoteBackupFile(destination, `attachments/${blobName}`).catch(() => null);
return file?.bytes || null;
},
},
progress,
restoreFileName
);
await writeAuditLog(storage, result.auditActorUserId, 'admin.backup.import', 'backup', null, {
users: result.result.imported.users,
@@ -431,6 +699,7 @@ export async function handleRestoreAdminRemoteBackup(request: Request, env: Env,
remotePath: path,
bytes: remoteFile.bytes.byteLength,
trigger: 'remote',
checksumMismatchAccepted: !checksumOk,
});
return result;
})();
@@ -445,6 +714,7 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
const storage = new StorageService(env.DB);
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
let body: { includeAttachments?: boolean } | null = null;
try {
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
@@ -455,11 +725,49 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
}
let archive: BackupArchiveBundle;
try {
const progress = async (event: {
step: string;
fileName?: string;
stageTitle: string;
stageDetail: string;
includeAttachments: boolean;
}) => {
await notifyUserBackupProgress(
env,
actorUser.id,
{
operation: 'backup-export',
source: 'local',
step: `export_${event.step}`,
fileName: event.fileName || '',
stageTitle: event.stageTitle,
stageDetail: event.stageDetail,
},
targetDeviceIdentifier
);
};
archive = await buildBackupArchive(env, new Date(), {
includeAttachments: !!body?.includeAttachments,
progress,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Backup export failed';
await notifyUserBackupProgress(
env,
actorUser.id,
{
operation: 'backup-export',
source: 'local',
step: 'export_failed',
fileName: '',
stageTitle: 'txt_backup_export_progress_failed_title',
stageDetail: 'txt_backup_export_progress_failed_detail',
done: true,
ok: false,
error: message,
},
targetDeviceIdentifier
);
return errorResponse(message, message.includes('blob missing') ? 409 : 500);
}
@@ -520,6 +828,7 @@ export async function handleAdminImportBackup(request: Request, env: Env, actorU
}
const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1';
const allowChecksumMismatch = String(formData.get('allowChecksumMismatch') || '').trim() === '1';
let archiveBytes: Uint8Array;
try {
archiveBytes = new Uint8Array(await (file as { arrayBuffer(): Promise<ArrayBuffer> }).arrayBuffer());
@@ -528,9 +837,15 @@ export async function handleAdminImportBackup(request: Request, env: Env, actorU
}
try {
const imported = await runImportAndAudit(env, actorUser, archiveBytes, replaceExisting, {
const fileName = 'name' in file ? String((file as File).name || '') : '';
const checksumOk = await verifyBackupArchiveFileNameChecksum(archiveBytes, fileName);
if (!checksumOk && !allowChecksumMismatch) {
return errorResponse('Backup file checksum does not match its filename', 400);
}
const imported = await runImportAndAudit(env, request, actorUser, archiveBytes, fileName || 'nodewarden_backup.zip', replaceExisting, {
trigger: 'local',
bytes: archiveBytes.byteLength,
checksumMismatchAccepted: !checksumOk,
});
return jsonResponse(imported.result);
} catch (error) {
+178 -12
View File
@@ -7,6 +7,12 @@ import { deleteAllAttachmentsForCipher } from './attachments';
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
import { readActingDeviceIdentifier } from '../utils/device';
function normalizeOptionalId(value: unknown): string | null {
if (value == null) return null;
const normalized = String(value).trim();
return normalized ? normalized : null;
}
async function notifyVaultSyncForRequest(
request: Request,
env: Env,
@@ -26,6 +32,35 @@ function getAliasedProp(source: any, aliases: string[]): { present: boolean; val
return { present: false, value: undefined };
}
function normalizeCipherTimestamp(value: unknown): string | null {
if (value == null || value === '') return null;
const parsed = new Date(String(value));
if (Number.isNaN(parsed.getTime())) return null;
return parsed.toISOString();
}
function readCipherArchivedAt(source: any, fallback: string | null = null): string | null {
const archived = getAliasedProp(source, ['archivedAt', 'ArchivedAt', 'archivedDate', 'ArchivedDate']);
return archived.present ? normalizeCipherTimestamp(archived.value) : fallback;
}
function syncCipherComputedAliases(cipher: Cipher): Cipher {
cipher.archivedDate = cipher.archivedAt ?? null;
cipher.deletedDate = cipher.deletedAt ?? null;
return cipher;
}
function normalizeCipherForStorage(cipher: Cipher): Cipher {
cipher.login = normalizeCipherLoginForStorage(cipher.login);
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
cipher.folderId = normalizeOptionalId(cipher.folderId);
const hasArchivedAt = Object.prototype.hasOwnProperty.call(cipher as object, 'archivedAt');
cipher.archivedAt = hasArchivedAt
? normalizeCipherTimestamp(cipher.archivedAt) ?? null
: normalizeCipherTimestamp(cipher.archivedDate) ?? null;
return syncCipherComputedAliases(cipher);
}
function looksLikeCipherString(value: unknown): boolean {
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
}
@@ -149,7 +184,7 @@ export function cipherToResponse(
options?: { omitFido2Credentials?: boolean }
): CipherResponse {
// Strip internal-only fields that must not appear in the API response
const { userId, createdAt, updatedAt, deletedAt, ...passthrough } = cipher;
const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher;
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null, options);
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
@@ -157,13 +192,14 @@ export function cipherToResponse(
// Pass through ALL stored cipher fields (known + unknown)
...passthrough,
// Server-computed / enforced fields (always override)
folderId: normalizeOptionalId(cipher.folderId),
type: Number(cipher.type) || 1,
organizationId: null,
organizationUseTotp: false,
creationDate: createdAt,
revisionDate: updatedAt,
deletedDate: deletedAt,
archivedDate: null,
archivedDate: archivedAt ?? null,
edit: true,
viewPassword: true,
permissions: {
@@ -273,12 +309,12 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
reprompt: cipherData.reprompt || 0,
createdAt: now,
updatedAt: now,
archivedAt: readCipherArchivedAt(cipherData, null),
deletedAt: null,
};
cipher.login = normalizeCipherLoginForStorage(cipher.login);
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
normalizeCipherForStorage(cipher);
// Prevent referencing a folder owned by another user.
if (cipher.folderId) {
@@ -331,10 +367,9 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
createdAt: existingCipher.createdAt,
updatedAt: new Date().toISOString(),
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
deletedAt: existingCipher.deletedAt,
};
cipher.login = normalizeCipherLoginForStorage(cipher.login);
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
// Custom fields deletion compatibility:
// - Accept both camelCase "fields" and PascalCase "Fields".
@@ -346,6 +381,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
} else if (request.method === 'PUT' || request.method === 'POST') {
cipher.fields = null;
}
normalizeCipherForStorage(cipher);
// Prevent referencing a folder owned by another user.
if (cipher.folderId) {
@@ -376,6 +412,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
// Soft delete
cipher.deletedAt = new Date().toISOString();
cipher.updatedAt = cipher.deletedAt;
syncCipherComputedAliases(cipher);
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
@@ -441,6 +478,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
cipher.deletedAt = null;
cipher.updatedAt = new Date().toISOString();
syncCipherComputedAliases(cipher);
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
@@ -469,16 +507,18 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
}
if (body.folderId !== undefined) {
if (body.folderId) {
const folderOk = await verifyFolderOwnership(storage, body.folderId, userId);
const folderId = normalizeOptionalId(body.folderId);
if (folderId) {
const folderOk = await verifyFolderOwnership(storage, folderId, userId);
if (!folderOk) return errorResponse('Folder not found', 404);
}
cipher.folderId = body.folderId;
cipher.folderId = folderId;
}
if (body.favorite !== undefined) {
cipher.favorite = body.favorite;
}
cipher.updatedAt = new Date().toISOString();
syncCipherComputedAliases(cipher);
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
@@ -506,12 +546,13 @@ export async function handleBulkMoveCiphers(request: Request, env: Env, userId:
return errorResponse('ids array is required', 400);
}
if (body.folderId) {
const folderOk = await verifyFolderOwnership(storage, body.folderId, userId);
const folderId = normalizeOptionalId(body.folderId);
if (folderId) {
const folderOk = await verifyFolderOwnership(storage, folderId, userId);
if (!folderOk) return errorResponse('Folder not found', 404);
}
const revisionDate = await storage.bulkMoveCiphers(body.ids, body.folderId || null, userId);
const revisionDate = await storage.bulkMoveCiphers(body.ids, folderId, userId);
if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
@@ -519,6 +560,131 @@ export async function handleBulkMoveCiphers(request: Request, env: Env, userId:
return new Response(null, { status: 204 });
}
async function buildCipherListResponse(
request: Request,
storage: StorageService,
userId: string,
ids: string[]
): Promise<Response> {
const ciphers = await storage.getCiphersByIds(ids, userId);
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map((cipher) => cipher.id));
const omitFido2Credentials = shouldOmitPasskeysForResponse(request);
return jsonResponse({
data: ciphers.map((cipher) =>
cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], {
omitFido2Credentials,
})
),
object: 'list',
continuationToken: null,
});
}
function parseCipherIdList(body: { ids?: unknown }): string[] | null {
if (!Array.isArray(body.ids)) return null;
return Array.from(new Set(body.ids.map((id) => String(id || '').trim()).filter(Boolean)));
}
// PUT/POST /api/ciphers/:id/archive
export async function handleArchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id);
if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
}
if (cipher.deletedAt) {
return errorResponse('Cannot archive a deleted cipher', 400);
}
cipher.archivedAt = new Date().toISOString();
cipher.updatedAt = cipher.archivedAt;
normalizeCipherForStorage(cipher);
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse(
cipherToResponse(cipher, attachments, {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
);
}
// PUT/POST /api/ciphers/:id/unarchive
export async function handleUnarchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id);
if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
}
cipher.archivedAt = null;
cipher.updatedAt = new Date().toISOString();
normalizeCipherForStorage(cipher);
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse(
cipherToResponse(cipher, attachments, {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
);
}
// PUT/POST /api/ciphers/archive
export async function handleBulkArchiveCiphers(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
let body: { ids?: unknown };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const ids = parseCipherIdList(body);
if (!ids) {
return errorResponse('ids array is required', 400);
}
const revisionDate = await storage.bulkArchiveCiphers(ids, userId);
if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return buildCipherListResponse(request, storage, userId, ids);
}
// PUT/POST /api/ciphers/unarchive
export async function handleBulkUnarchiveCiphers(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
let body: { ids?: unknown };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const ids = parseCipherIdList(body);
if (!ids) {
return errorResponse('ids array is required', 400);
}
const revisionDate = await storage.bulkUnarchiveCiphers(ids, userId);
if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return buildCipherListResponse(request, storage, userId, ids);
}
// POST /api/ciphers/delete - Bulk soft delete
export async function handleBulkDeleteCiphers(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
+302 -20
View File
@@ -1,3 +1,4 @@
import type { Device, DevicePendingAuthRequest, DeviceResponse, ProtectedDeviceResponse as ProtectedDeviceWireResponse } from '../types';
import { Env } from '../types';
import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub';
import { StorageService } from '../services/storage';
@@ -5,6 +6,101 @@ import { errorResponse, jsonResponse } from '../utils/response';
import { readKnownDeviceProbe } from '../utils/device';
import { generateUUID } from '../utils/uuid';
function normalizeIdentifier(value: string | null | undefined): string {
return String(value || '').trim();
}
function buildDevicePendingAuthRequest(value?: { id?: string | null; creationDate?: string | null } | null): DevicePendingAuthRequest | null {
if (!value?.id || !value.creationDate) return null;
return {
id: String(value.id),
creationDate: String(value.creationDate),
};
}
function isTrustedDevice(device: Pick<Device, 'encryptedUserKey' | 'encryptedPublicKey'>): boolean {
return !!(device.encryptedUserKey && device.encryptedPublicKey);
}
function buildDeviceResponse(device: Device): DeviceResponse {
const response = {
Id: device.deviceIdentifier,
id: device.deviceIdentifier,
UserId: device.userId,
userId: device.userId,
Name: device.name,
name: device.name,
Identifier: device.deviceIdentifier,
identifier: device.deviceIdentifier,
Type: device.type,
type: device.type,
CreationDate: device.createdAt,
creationDate: device.createdAt,
RevisionDate: device.updatedAt,
revisionDate: device.updatedAt,
IsTrusted: isTrustedDevice(device),
isTrusted: isTrustedDevice(device),
EncryptedUserKey: device.encryptedUserKey,
encryptedUserKey: device.encryptedUserKey,
EncryptedPublicKey: device.encryptedPublicKey,
encryptedPublicKey: device.encryptedPublicKey,
DevicePendingAuthRequest: buildDevicePendingAuthRequest(device.devicePendingAuthRequest),
devicePendingAuthRequest: buildDevicePendingAuthRequest(device.devicePendingAuthRequest),
object: 'device',
};
return response as DeviceResponse;
}
function buildProtectedDeviceResponse(device: Device): ProtectedDeviceWireResponse {
const response = {
Id: device.deviceIdentifier,
id: device.deviceIdentifier,
Name: device.name,
name: device.name,
Identifier: device.deviceIdentifier,
identifier: device.deviceIdentifier,
Type: device.type,
type: device.type,
CreationDate: device.createdAt,
creationDate: device.createdAt,
EncryptedUserKey: device.encryptedUserKey,
encryptedUserKey: device.encryptedUserKey,
EncryptedPublicKey: device.encryptedPublicKey,
encryptedPublicKey: device.encryptedPublicKey,
object: 'protectedDevice',
};
return response as ProtectedDeviceWireResponse;
}
function parseKeysBody(body: any, fallback?: Device): {
encryptedUserKey?: string | null;
encryptedPublicKey?: string | null;
encryptedPrivateKey?: string | null;
} {
return {
encryptedUserKey:
Object.prototype.hasOwnProperty.call(body || {}, 'encryptedUserKey')
? body?.encryptedUserKey ?? null
: fallback?.encryptedUserKey ?? null,
encryptedPublicKey:
Object.prototype.hasOwnProperty.call(body || {}, 'encryptedPublicKey')
? body?.encryptedPublicKey ?? null
: fallback?.encryptedPublicKey ?? null,
encryptedPrivateKey:
Object.prototype.hasOwnProperty.call(body || {}, 'encryptedPrivateKey')
? body?.encryptedPrivateKey ?? null
: fallback?.encryptedPrivateKey ?? null,
};
}
async function readJsonBody(request: Request): Promise<any> {
try {
return await request.json();
} catch {
return null;
}
}
// GET /api/devices/knowndevice
// Compatible with Bitwarden/Vaultwarden behavior:
// - X-Request-Email: base64url(email) without padding
@@ -28,20 +124,42 @@ export async function handleGetDevices(request: Request, env: Env, userId: strin
const devices = await storage.getDevicesByUserId(userId);
return jsonResponse({
data: devices.map(device => ({
id: device.deviceIdentifier,
name: device.name,
identifier: device.deviceIdentifier,
type: device.type,
creationDate: device.createdAt,
revisionDate: device.updatedAt,
object: 'device',
})),
data: devices.map((device) => buildDeviceResponse(device)),
object: 'list',
continuationToken: null,
});
}
// GET /api/devices/identifier/:deviceIdentifier
export async function handleGetDeviceByIdentifier(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
const normalized = normalizeIdentifier(deviceIdentifier);
if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB);
const device = await storage.getDevice(userId, normalized);
if (!device) {
return errorResponse('Device not found', 404);
}
return jsonResponse(buildDeviceResponse(device));
}
// GET /api/devices/:deviceIdentifier
export async function handleGetDevice(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier);
}
// GET /api/devices/authorized
// Returns known devices together with active 2FA remember-token expiry.
export async function handleGetAuthorizedDevices(request: Request, env: Env, userId: string): Promise<Response> {
@@ -64,12 +182,7 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
knownIdentifiers.add(device.deviceIdentifier);
const trustedInfo = trustedByIdentifier.get(device.deviceIdentifier);
return {
id: device.deviceIdentifier,
name: device.name,
identifier: device.deviceIdentifier,
type: device.type,
creationDate: device.createdAt,
revisionDate: device.updatedAt,
...buildDeviceResponse(device),
online: onlineSet.has(device.deviceIdentifier),
trusted: !!trustedInfo,
trustedTokenCount: trustedInfo?.tokenCount || 0,
@@ -80,13 +193,22 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
for (const row of trusted) {
if (knownIdentifiers.has(row.deviceIdentifier)) continue;
data.push({
id: row.deviceIdentifier,
const placeholderDevice: Device = {
userId,
deviceIdentifier: row.deviceIdentifier,
name: 'Unknown device',
identifier: row.deviceIdentifier,
type: 14,
creationDate: '',
revisionDate: '',
sessionStamp: '',
encryptedUserKey: null,
encryptedPublicKey: null,
encryptedPrivateKey: null,
devicePendingAuthRequest: null,
createdAt: '',
updatedAt: '',
};
data.push({
...buildDeviceResponse(placeholderDevice),
isTrusted: true,
online: onlineSet.has(row.deviceIdentifier),
trusted: true,
trustedTokenCount: row.tokenCount,
@@ -166,6 +288,138 @@ export async function handleDeleteAllDevices(request: Request, env: Env, userId:
return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices });
}
// PUT/POST /api/devices/identifier/:deviceIdentifier/keys
export async function handleUpdateDeviceKeys(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
const normalized = normalizeIdentifier(deviceIdentifier);
if (!normalized) return errorResponse('Invalid device identifier', 400);
const body = await readJsonBody(request);
const storage = new StorageService(env.DB);
const device = await storage.getDevice(userId, normalized);
if (!device) {
return errorResponse('Device not found', 404);
}
const updated = await storage.updateDeviceKeys(userId, normalized, parseKeysBody(body, device));
if (!updated) {
return errorResponse('Device not found', 404);
}
const nextDevice = await storage.getDevice(userId, normalized);
return jsonResponse(buildDeviceResponse(nextDevice || device));
}
// POST /api/devices/update-trust
export async function handleUpdateDeviceTrust(
request: Request,
env: Env,
userId: string
): Promise<Response> {
const body = await readJsonBody(request);
const storage = new StorageService(env.DB);
const currentDeviceIdentifier =
normalizeIdentifier(request.headers.get('Device-Identifier')) ||
normalizeIdentifier(request.headers.get('X-Device-Identifier'));
const updates: Array<{
deviceIdentifier: string;
keys: {
encryptedUserKey?: string | null;
encryptedPublicKey?: string | null;
encryptedPrivateKey?: string | null;
};
}> = [];
if (currentDeviceIdentifier && body?.currentDevice) {
updates.push({
deviceIdentifier: currentDeviceIdentifier,
keys: parseKeysBody(body.currentDevice, await storage.getDevice(userId, currentDeviceIdentifier) || undefined),
});
}
if (Array.isArray(body?.otherDevices)) {
for (const item of body.otherDevices) {
const deviceIdentifier = normalizeIdentifier(item?.deviceId);
if (!deviceIdentifier) continue;
updates.push({
deviceIdentifier,
keys: parseKeysBody(item, await storage.getDevice(userId, deviceIdentifier) || undefined),
});
}
}
let updatedCount = 0;
for (const update of updates) {
const ok = await storage.updateDeviceKeys(userId, update.deviceIdentifier, update.keys);
if (ok) updatedCount++;
}
return jsonResponse({ success: true, updated: updatedCount });
}
// POST /api/devices/untrust
export async function handleUntrustDevices(
request: Request,
env: Env,
userId: string
): Promise<Response> {
const body = await readJsonBody(request);
const storage = new StorageService(env.DB);
const devices = Array.isArray(body?.devices) ? body.devices.map((id: unknown) => normalizeIdentifier(String(id))) : [];
const removed = await storage.clearDeviceKeys(userId, devices);
for (const deviceIdentifier of devices) {
if (!deviceIdentifier) continue;
await storage.deleteTrustedTwoFactorTokensByDevice(userId, deviceIdentifier);
}
return jsonResponse({ success: true, removed });
}
// POST /api/devices/:deviceIdentifier/retrieve-keys
export async function handleRetrieveDeviceKeys(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
const normalized = normalizeIdentifier(deviceIdentifier);
if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB);
const device = await storage.getDevice(userId, normalized);
if (!device) {
return errorResponse('Device not found', 404);
}
return jsonResponse(buildProtectedDeviceResponse(device));
}
// POST /api/devices/:id/deactivate
export async function handleDeactivateDevice(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
const normalized = normalizeIdentifier(deviceIdentifier);
if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB);
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
await storage.deleteRefreshTokensByDevice(userId, normalized);
const deleted = await storage.deleteDevice(userId, normalized);
if (deleted) {
await notifyUserLogout(env, userId, normalized);
}
return jsonResponse({ success: deleted });
}
// 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.
@@ -182,3 +436,31 @@ export async function handleUpdateDeviceToken(
return new Response(null, { status: 200 });
}
// PUT/POST /api/devices/:deviceIdentifier/web-push-auth
export async function handleUpdateDeviceWebPushAuth(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
void env;
void userId;
void deviceIdentifier;
return new Response(null, { status: 200 });
}
// PUT/POST /api/devices/:deviceIdentifier/clear-token
export async function handleClearDeviceToken(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
void env;
void userId;
void deviceIdentifier;
return new Response(null, { status: 200 });
}
+23 -6
View File
@@ -31,6 +31,28 @@ function resolveTotpSecret(userSecret: string | null): string | null {
return null;
}
function buildPreloginResponse(
email: string,
kdfType: number,
kdfIterations: number,
kdfMemory: number | null,
kdfParallelism: number | null
): Record<string, unknown> {
return {
kdf: kdfType,
kdfIterations,
kdfMemory,
kdfParallelism,
KdfSettings: {
KdfType: kdfType,
Iterations: kdfIterations,
Memory: kdfMemory,
Parallelism: kdfParallelism,
},
Salt: email.toLowerCase(),
};
}
function twoFactorRequiredResponse(message: string = 'Two factor required.', includeRecoveryCode: boolean = false): Response {
const providers = includeRecoveryCode
? [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR), TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE]
@@ -426,12 +448,7 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
const kdfMemory = user?.kdfMemory ?? null;
const kdfParallelism = user?.kdfParallelism ?? null;
return jsonResponse({
kdf: kdfType,
kdfIterations: kdfIterations,
kdfMemory: kdfMemory,
kdfParallelism: kdfParallelism,
});
return jsonResponse(buildPreloginResponse(email, kdfType, kdfIterations, kdfMemory, kdfParallelism));
}
// POST /identity/connect/revocation
+5 -3
View File
@@ -232,6 +232,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
key: (c as any).key ?? null,
createdAt: now,
updatedAt: now,
archivedAt: null,
deletedAt: null,
};
cipher.login = normalizeCipherLoginForStorage(cipher.login);
@@ -245,10 +246,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
const data = JSON.stringify(cipher);
return env.DB
.prepare(
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' +
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at'
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at'
)
.bind(
cipher.id,
@@ -263,6 +264,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
bindNull(cipher.key),
cipher.createdAt,
cipher.updatedAt,
bindNull(cipher.archivedAt),
bindNull(cipher.deletedAt)
);
});
+7
View File
@@ -148,6 +148,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
forcePasswordReset: false,
avatarColor: null,
creationDate: user.createdAt,
verifyDevices: user.verifyDevices,
object: 'profile',
};
@@ -180,6 +181,12 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
},
policies: [],
sends: sends.map(sendToResponse),
UserDecryption: {
MasterPasswordUnlock: buildUserDecryptionOptions(user).MasterPasswordUnlock,
TrustedDeviceOption: null,
KeyConnectorOption: null,
Object: 'userDecryption',
},
// PascalCase for desktop/browser clients
UserDecryptionOptions: buildUserDecryptionOptions(user),
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
+5
View File
@@ -6,6 +6,7 @@ import {
handleDownloadAdminBackupAttachment,
handleGetAdminBackupSettings,
handleGetAdminBackupSettingsRepairState,
handleInspectAdminRemoteBackup,
handleAdminImportBackup,
handleListAdminRemoteBackups,
handleRepairAdminBackupSettings,
@@ -53,6 +54,10 @@ export async function handleAdminBackupRoute(
return handleDownloadAdminRemoteBackup(request, env, actorUser);
}
if (path === '/api/admin/backup/remote/integrity' && method === 'GET') {
return handleInspectAdminRemoteBackup(request, env, actorUser);
}
if (path === '/api/admin/backup/remote/file' && method === 'DELETE') {
return handleDeleteAdminRemoteBackup(request, env, actorUser);
}
+19
View File
@@ -7,6 +7,7 @@ import {
handleGetRevisionDate,
handleVerifyPassword,
handleChangePassword,
handleSetVerifyDevices,
handleGetTotpStatus,
handleSetTotpStatus,
handleGetTotpRecoveryCode,
@@ -20,11 +21,15 @@ import {
handleDeleteCipherCompat,
handlePermanentDeleteCipher,
handleRestoreCipher,
handleBulkArchiveCiphers,
handlePartialUpdateCipher,
handleBulkUnarchiveCiphers,
handleBulkMoveCiphers,
handleBulkDeleteCiphers,
handleBulkPermanentDeleteCiphers,
handleBulkRestoreCiphers,
handleArchiveCipher,
handleUnarchiveCipher,
} from './handlers/ciphers';
import {
handleGetFolders,
@@ -110,6 +115,10 @@ export async function handleAuthenticatedRoute(
return handleVerifyPassword(request, env, userId);
}
if (path === '/api/accounts/verify-devices' && (method === 'PUT' || method === 'POST')) {
return handleSetVerifyDevices(request, env, userId);
}
if (path === '/api/sync' && method === 'GET') {
return handleSync(request, env, userId);
}
@@ -140,6 +149,14 @@ export async function handleAuthenticatedRoute(
return handleBulkRestoreCiphers(request, env, userId);
}
if (path === '/api/ciphers/archive' && (method === 'PUT' || method === 'POST')) {
return handleBulkArchiveCiphers(request, env, userId);
}
if (path === '/api/ciphers/unarchive' && (method === 'PUT' || method === 'POST')) {
return handleBulkUnarchiveCiphers(request, env, userId);
}
if (path === '/api/ciphers/move' && (method === 'POST' || method === 'PUT')) {
return handleBulkMoveCiphers(request, env, userId);
}
@@ -158,6 +175,8 @@ export async function handleAuthenticatedRoute(
if (subPath === '/delete' && method === 'PUT') return handleDeleteCipher(request, env, userId, cipherId);
if (subPath === '/delete' && method === 'DELETE') return handlePermanentDeleteCipher(request, env, userId, cipherId);
if (subPath === '/restore' && method === 'PUT') return handleRestoreCipher(request, env, userId, cipherId);
if (subPath === '/archive' && (method === 'PUT' || method === 'POST')) return handleArchiveCipher(request, env, userId, cipherId);
if (subPath === '/unarchive' && (method === 'PUT' || method === 'POST')) return handleUnarchiveCipher(request, env, userId, cipherId);
if (subPath === '/partial' && (method === 'PUT' || method === 'POST')) return handlePartialUpdateCipher(request, env, userId, cipherId);
if (subPath === '/share' && method === 'POST') return handleGetCipher(request, env, userId, cipherId);
if (subPath === '/details' && method === 'GET') return handleGetCipher(request, env, userId, cipherId);
+60 -3
View File
@@ -1,12 +1,21 @@
import type { Env } from './types';
import {
handleGetAuthorizedDevices,
handleGetDevice,
handleGetDevices,
handleGetDeviceByIdentifier,
handleUpdateDeviceKeys,
handleUpdateDeviceTrust,
handleUntrustDevices,
handleRetrieveDeviceKeys,
handleDeactivateDevice,
handleRevokeAllTrustedDevices,
handleRevokeTrustedDevice,
handleDeleteAllDevices,
handleDeleteDevice,
handleUpdateDeviceToken,
handleUpdateDeviceWebPushAuth,
handleClearDeviceToken,
} from './handlers/devices';
export async function handleAuthenticatedDeviceRoute(
@@ -35,16 +44,64 @@ export async function handleAuthenticatedDeviceRoute(
}
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i);
if (deleteDeviceMatch && method === 'GET') {
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
return handleGetDevice(request, env, userId, deviceIdentifier);
}
if (deleteDeviceMatch && method === 'DELETE') {
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
return handleDeleteDevice(request, env, userId, deviceIdentifier);
}
const deviceTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/token$/i);
if (deviceTokenMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(deviceTokenMatch[1]);
const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i);
if (identifierMatch && method === 'GET') {
const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier);
}
const deviceKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/keys$/i) || path.match(/^\/api\/devices\/identifier\/([^/]+)\/keys$/i);
if (deviceKeysMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(deviceKeysMatch[1]);
return handleUpdateDeviceKeys(request, env, userId, deviceIdentifier);
}
const identifierTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/token$/i);
if (identifierTokenMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(identifierTokenMatch[1]);
return handleUpdateDeviceToken(request, env, userId, deviceIdentifier);
}
const identifierWebPushMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/web-push-auth$/i);
if (identifierWebPushMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(identifierWebPushMatch[1]);
return handleUpdateDeviceWebPushAuth(request, env, userId, deviceIdentifier);
}
const identifierClearTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/clear-token$/i);
if (identifierClearTokenMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(identifierClearTokenMatch[1]);
return handleClearDeviceToken(request, env, userId, deviceIdentifier);
}
const identifierRetrieveKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/retrieve-keys$/i);
if (identifierRetrieveKeysMatch && method === 'POST') {
const deviceIdentifier = decodeURIComponent(identifierRetrieveKeysMatch[1]);
return handleRetrieveDeviceKeys(request, env, userId, deviceIdentifier);
}
const identifierDeactivateMatch = path.match(/^\/api\/devices\/([^/]+)\/deactivate$/i);
if (identifierDeactivateMatch && (method === 'POST' || method === 'DELETE')) {
const deviceIdentifier = decodeURIComponent(identifierDeactivateMatch[1]);
return handleDeactivateDevice(request, env, userId, deviceIdentifier);
}
if (path === '/api/devices/update-trust' && method === 'POST') {
return handleUpdateDeviceTrust(request, env, userId);
}
if (path === '/api/devices/untrust' && method === 'POST') {
return handleUntrustDevices(request, env, userId);
}
return null;
}
+49 -22
View File
@@ -78,6 +78,43 @@ function buildIconServiceCsp(origin: string): string {
return `img-src 'self' data: ${origin}`;
}
function buildConfigResponse(origin: string) {
return {
version: LIMITS.compatibility.bitwardenServerVersion,
gitHash: 'nodewarden',
server: null,
environment: {
cloudRegion: 'self-hosted',
vault: origin,
api: origin + '/api',
identity: origin + '/identity',
notifications: origin + '/notifications',
icons: origin,
sso: '',
fillAssistRules: null,
},
push: {
pushTechnology: 0,
vapidPublicKey: null,
},
communication: null,
settings: {
disableUserRegistration: false,
},
_icon_service_url: buildIconServiceTemplate(origin),
_icon_service_csp: buildIconServiceCsp(origin),
featureStates: {
'duo-redirect': true,
'email-verification': true,
'pm-19051-send-email-verification': false,
'pm-19148-innovation-archive': true,
'unauth-ui-refresh': true,
'web-push': false,
},
object: 'config',
};
}
function normalizeIconHost(rawHost: string): string | null {
const decoded = decodeURIComponent(String(rawHost || '').trim()).toLowerCase().replace(/\.+$/, '');
if (!decoded || decoded.includes('/') || decoded.includes('\\')) return null;
@@ -243,6 +280,11 @@ export async function handlePublicRoute(
return handleKnownDevice(request, env);
}
const clearDeviceTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/clear-token$/i);
if (clearDeviceTokenMatch && (method === 'PUT' || method === 'POST')) {
return new Response(null, { status: 200 });
}
if ((path === '/identity/connect/revocation' || path === '/identity/connect/revoke') && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
@@ -255,6 +297,12 @@ export async function handlePublicRoute(
return handlePrelogin(request, env);
}
if (path === '/identity/accounts/prelogin/password' && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handlePrelogin(request, env);
}
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
return handleRecoverTwoFactor(request, env);
}
@@ -275,28 +323,7 @@ export async function handlePublicRoute(
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',
});
return jsonResponse(buildConfigResponse(origin));
}
if (path === '/api/version' && method === 'GET') {
+87 -7
View File
@@ -9,6 +9,7 @@ import {
type SqlRow = Record<string, string | number | null>;
const BACKUP_FORMAT_VERSION = 1;
const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
// 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;
@@ -60,16 +61,39 @@ export interface BackupArchiveBundle {
manifest: BackupManifest;
}
export interface BackupFileIntegrityCheckResult {
hasChecksumPrefix: boolean;
expectedPrefix: string | null;
actualPrefix: string;
matches: boolean;
}
export interface BuildBackupArchiveOptions {
includeAttachments?: boolean;
progress?: BackupArchiveBuildProgressReporter;
}
export interface BackupArchiveBuildProgressEvent {
step: string;
fileName?: string;
stageTitle: string;
stageDetail: string;
includeAttachments: boolean;
}
export type BackupArchiveBuildProgressReporter = (event: BackupArchiveBuildProgressEvent) => Promise<void>;
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 {
async function sha256Hex(bytes: Uint8Array): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
}
function buildBackupFileName(date: Date = new Date(), checksumPrefix: string | null = null): string {
const parts = [
date.getUTCFullYear().toString().padStart(4, '0'),
(date.getUTCMonth() + 1).toString().padStart(2, '0'),
@@ -78,7 +102,34 @@ function buildBackupFileName(date: Date = new Date()): string {
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`;
const suffix = checksumPrefix ? `_${checksumPrefix}` : '';
return `nodewarden_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}${suffix}.zip`;
}
export function extractBackupFileChecksumPrefix(fileName: string): string | null {
const normalized = String(fileName || '').trim();
const match = normalized.match(/_([0-9a-f]{5})\.zip$/i);
return match ? match[1].toLowerCase() : null;
}
export async function inspectBackupArchiveFileNameChecksum(
bytes: Uint8Array,
fileName: string
): Promise<BackupFileIntegrityCheckResult> {
const expectedPrefix = extractBackupFileChecksumPrefix(fileName);
const actualHash = await sha256Hex(bytes);
const actualPrefix = actualHash.slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
return {
hasChecksumPrefix: !!expectedPrefix,
expectedPrefix,
actualPrefix,
matches: !expectedPrefix || actualPrefix === expectedPrefix,
};
}
export async function verifyBackupArchiveFileNameChecksum(bytes: Uint8Array, fileName: string): Promise<boolean> {
const result = await inspectBackupArchiveFileNameChecksum(bytes, fileName);
return result.matches;
}
function validateArchiveSize(bytes: Uint8Array): void {
@@ -269,16 +320,25 @@ export async function buildBackupArchive(
date: Date = new Date(),
options: BuildBackupArchiveOptions = {}
): Promise<BackupArchiveBundle> {
const includeAttachments = options.includeAttachments !== false;
await options.progress?.({
step: 'collect_data',
fileName: '',
stageTitle: 'txt_backup_archive_progress_collect_title',
stageDetail: includeAttachments
? 'txt_backup_archive_progress_collect_with_attachments_detail'
: 'txt_backup_archive_progress_collect_detail',
includeAttachments,
});
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 id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at 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, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_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();
@@ -327,9 +387,29 @@ export async function buildBackupArchive(
}, null, BACKUP_JSON_INDENT)),
};
await options.progress?.({
step: 'package_archive',
fileName: '',
stageTitle: 'txt_backup_archive_progress_package_title',
stageDetail: includeAttachments
? 'txt_backup_archive_progress_package_with_attachments_detail'
: 'txt_backup_archive_progress_package_detail',
includeAttachments,
});
const bytes = zipSync(createZipEntries(files));
const fileHashPrefix = (await sha256Hex(bytes)).slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
const fileName = buildBackupFileName(date, fileHashPrefix);
await options.progress?.({
step: 'archive_ready',
fileName,
stageTitle: 'txt_backup_archive_progress_ready_title',
stageDetail: 'txt_backup_archive_progress_ready_detail',
includeAttachments,
});
return {
bytes: zipSync(createZipEntries(files)),
fileName: buildBackupFileName(date),
bytes,
fileName,
manifest: manifestBase,
};
}
+126 -8
View File
@@ -1,4 +1,4 @@
import type { Env } from '../types';
import type { Env, User } from '../types';
import { StorageService } from './storage';
import {
type BackupSettingsPortableEnvelope,
@@ -8,6 +8,7 @@ import {
} from './backup-settings-crypto';
import {
BACKUP_DEFAULT_INTERVAL_HOURS,
BACKUP_DEFAULT_START_TIME,
BACKUP_DEFAULT_TIMEZONE,
type BackupDestinationConfig,
type BackupDestinationRecord,
@@ -90,6 +91,20 @@ function normalizeIntervalHours(value: unknown, fallback: number = BACKUP_DEFAUL
return raw;
}
function normalizeStartTime(value: unknown, fallback: string = BACKUP_DEFAULT_START_TIME): string {
const raw = asTrimmedString(value) || fallback;
const match = raw.match(/^(\d{1,2})(?::(\d{1,2}))?$/);
if (!match) {
throw new Error('Backup start time must be in HH:mm format');
}
const hour = Number(match[1]);
const minute = Number(match[2] ?? '0');
if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
throw new Error('Backup start time must be in HH:mm format');
}
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}
function normalizeE3Destination(value: unknown, allowIncomplete = false): E3BackupDestination {
const source = isPlainObject(value) ? value : {};
const endpoint = asTrimmedString(source.endpoint);
@@ -219,6 +234,10 @@ function normalizeDestinationRecord(
scheduleSource.intervalHours ?? previousSchedule.intervalHours,
previousSchedule.intervalHours || BACKUP_DEFAULT_INTERVAL_HOURS
),
startTime: normalizeStartTime(
scheduleSource.startTime ?? previousSchedule.startTime,
previousSchedule.startTime || BACKUP_DEFAULT_START_TIME
),
timezone: assertValidTimeZone(asTrimmedString(scheduleSource.timezone ?? previousSchedule.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount),
};
@@ -259,6 +278,7 @@ function parseLegacyBackupSettings(rawValue: Record<string, unknown>, fallbackTi
schedule: {
enabled: !!rawValue.enabled,
intervalHours,
startTime: BACKUP_DEFAULT_START_TIME,
timezone: assertValidTimeZone(asTrimmedString(rawValue.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
retentionCount: 30,
},
@@ -326,6 +346,7 @@ export function parseBackupSettings(raw: string | null, fallbackTimezone: string
schedule: {
enabled: scheduleEnabled,
intervalHours: globalIntervalHours,
startTime: BACKUP_DEFAULT_START_TIME,
timezone: globalTimezone,
retentionCount: 30,
},
@@ -401,20 +422,45 @@ export async function saveBackupSettings(storage: StorageService, env: Env, sett
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 users = await storage.getAllUsers();
const normalized = await normalizeImportedBackupSettingsValue(raw, env, users, fallbackTimezone);
if (normalized !== null) {
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, normalized);
}
}
export async function normalizeImportedBackupSettingsValue(
raw: string | null,
env: Env,
users: Pick<User, 'id' | 'publicKey' | 'role' | 'status'>[],
fallbackTimezone: string = 'UTC'
): Promise<string | null> {
if (!raw) return null;
const envelope = parseBackupSettingsEnvelope(raw);
if (envelope) {
try {
const decrypted = await decryptBackupSettingsRuntime(raw, env);
const settings = parseBackupSettings(decrypted, fallbackTimezone);
await saveBackupSettings(storage, env, settings);
return;
const hasPortableAdmins = users.some(
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
);
if (!hasPortableAdmins) {
return serializeBackupSettings(settings);
}
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
} catch {
// Keep imported portable recovery data intact until an admin signs in and repairs it.
return;
return raw;
}
}
const settings = parseBackupSettings(raw, fallbackTimezone);
await saveBackupSettings(storage, env, settings);
const hasPortableAdmins = users.some(
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
);
if (!hasPortableAdmins) {
return serializeBackupSettings(settings);
}
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
}
export async function getBackupSettingsRepairState(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettingsRepairState> {
@@ -495,15 +541,87 @@ export function getBackupLocalTime(date: Date, timezone: string): string {
return `${parts.hour}:${parts.minute}`;
}
function parseLocalDateKey(dateKey: string): { year: number; month: number; day: number } | null {
const match = String(dateKey || '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return null;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return null;
return { year, month, day };
}
function getUtcDateForLocalTime(timezone: string, year: number, month: number, day: number, hour: number, minute: number): Date {
const utcGuess = Date.UTC(year, month - 1, day, hour, minute, 0, 0);
const actual = getDateTimeParts(new Date(utcGuess), timezone);
const actualUtc = Date.UTC(
Number(actual.year),
Number(actual.month) - 1,
Number(actual.day),
Number(actual.hour),
Number(actual.minute),
0,
0
);
const desiredUtc = Date.UTC(year, month - 1, day, hour, minute, 0, 0);
return new Date(utcGuess - (actualUtc - desiredUtc));
}
function getBackupSlotStartsForLocalDay(
dateKey: string,
timezone: string,
startTime: string,
intervalHours: number
): Date[] {
const parsedDate = parseLocalDateKey(dateKey);
const parsedTime = normalizeStartTime(startTime).split(':').map((value) => Number(value));
if (!parsedDate || parsedTime.length !== 2) return [];
const [hour, minute] = parsedTime;
const firstSlot = getUtcDateForLocalTime(timezone, parsedDate.year, parsedDate.month, parsedDate.day, hour, minute);
const nextLocalDay = new Date(Date.UTC(parsedDate.year, parsedDate.month - 1, parsedDate.day, 0, 0, 0, 0));
nextLocalDay.setUTCDate(nextLocalDay.getUTCDate() + 1);
const nextDay = getUtcDateForLocalTime(
timezone,
nextLocalDay.getUTCFullYear(),
nextLocalDay.getUTCMonth() + 1,
nextLocalDay.getUTCDate(),
0,
0
);
const intervalMs = intervalHours * 60 * 60 * 1000;
const slots: Date[] = [];
for (let slotMs = firstSlot.getTime(); slotMs < nextDay.getTime(); slotMs += intervalMs) {
slots.push(new Date(slotMs));
}
return slots;
}
export function isBackupDueNow(
destination: BackupDestinationRecord,
now: Date,
windowMinutes: number = BACKUP_SCHEDULER_WINDOW_MINUTES
): boolean {
if (!destination.schedule.enabled) return false;
const 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);
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime())
? lastAttemptAt.getTime()
: Number.NEGATIVE_INFINITY;
const localDateKey = getBackupLocalDateKey(now, destination.schedule.timezone);
const slotStarts = getBackupSlotStartsForLocalDay(
localDateKey,
destination.schedule.timezone,
destination.schedule.startTime,
destination.schedule.intervalHours
);
for (const slotStart of slotStarts) {
const slotStartMs = slotStart.getTime();
if (now.getTime() < slotStartMs || now.getTime() >= slotStartMs + toleranceMs) continue;
if (lastAttemptMs >= slotStartMs) return false;
return true;
}
return false;
}
+464 -110
View File
@@ -1,7 +1,6 @@
import type { Env } from '../types';
import { StorageService } from './storage';
import type { Env, User } from '../types';
import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobStorageKind, putBlobObject } from './blob-store';
import { normalizeImportedBackupSettings } from './backup-config';
import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from './backup-config';
import {
type BackupManifestAttachmentBlob,
type BackupPayload,
@@ -10,6 +9,26 @@ import {
} from './backup-archive';
type SqlRow = Record<string, string | number | null>;
type BackupTableName =
| 'config'
| 'users'
| 'user_revisions'
| 'folders'
| 'ciphers'
| 'attachments';
const BACKUP_TABLES: BackupTableName[] = [
'config',
'users',
'user_revisions',
'folders',
'ciphers',
'attachments',
];
function shadowTableName(table: BackupTableName): string {
return `${table}__restore`;
}
export interface BackupImportResultBody {
object: 'instance-backup-import';
@@ -43,6 +62,81 @@ async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Pro
return (response.results || []).map((row) => ({ ...row }));
}
async function getTableCreateSql(db: D1Database, table: BackupTableName): Promise<string> {
const row = await db
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?")
.bind(table)
.first<{ sql: string | null }>();
const sql = String(row?.sql || '').trim();
if (!sql) {
throw new Error(`Restore shadow schema is missing table definition for ${table}`);
}
return sql;
}
function buildShadowTableCreateSql(createSql: string, table: BackupTableName): string {
const tablePattern = new RegExp(`^CREATE TABLE(?:\\s+IF NOT EXISTS)?\\s+(?:\"${table}\"|${table})(?=\\s*\\()`, 'i');
let next = createSql.replace(tablePattern, `CREATE TABLE "${shadowTableName(table)}"`);
if (next === createSql) {
throw new Error(`Restore shadow schema could not rewrite CREATE TABLE statement for ${table}`);
}
for (const currentTable of BACKUP_TABLES) {
const referencePattern = new RegExp(`\\bREFERENCES\\s+(?:\"${currentTable}\"|${currentTable})(?=\\s*\\()`, 'gi');
next = next.replace(
referencePattern,
`REFERENCES "${shadowTableName(currentTable)}"`
);
}
return next;
}
async function resetRestoreArtifacts(db: D1Database): Promise<void> {
const dropStatements = BACKUP_TABLES
.slice()
.reverse()
.map((table) => db.prepare(`DROP TABLE IF EXISTS ${shadowTableName(table)}`));
if (dropStatements.length) {
await db.batch(dropStatements);
}
}
async function createShadowTables(db: D1Database): Promise<void> {
const createStatements: D1PreparedStatement[] = [];
for (const table of BACKUP_TABLES) {
const createSql = await getTableCreateSql(db, table);
createStatements.push(db.prepare(buildShadowTableCreateSql(createSql, table)));
}
await db.batch(createStatements);
}
async function validateShadowTableCounts(
db: D1Database,
expectedCounts: Partial<Record<BackupTableName, number>>
): Promise<void> {
await Promise.all(BACKUP_TABLES.map(async (table) => {
const expected = expectedCounts[table] ?? 0;
const row = await db.prepare(`SELECT COUNT(*) AS count FROM ${shadowTableName(table)}`).first<{ count: number }>();
const actual = Number(row?.count || 0);
if (actual !== expected) {
throw new Error(`Restore shadow validation failed for ${table}: expected ${expected}, received ${actual}`);
}
}));
}
async function swapShadowTablesIntoPlace(db: D1Database): Promise<void> {
const statements: D1PreparedStatement[] = [];
// Commit by replacing live table contents from validated shadow tables.
// This avoids D1 schema-rename edge cases while keeping current data intact
// until the final batch succeeds.
for (const sql of buildResetImportTargetStatements(db)) {
statements.push(sql);
}
for (const table of BACKUP_TABLES) {
statements.push(db.prepare(`INSERT INTO ${table} SELECT * FROM ${shadowTableName(table)}`));
}
await db.batch(statements);
}
async function ensureImportTargetIsFresh(db: D1Database): Promise<void> {
const counts = await Promise.all([
db.prepare('SELECT COUNT(*) AS count FROM ciphers').first<{ count: number }>(),
@@ -61,18 +155,9 @@ function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[]
'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));
}
@@ -119,10 +204,90 @@ interface AttachmentRestoreResult {
}
interface RemoteAttachmentSource {
hasAttachment(blobName: string): Promise<boolean>;
loadAttachment(blobName: string): Promise<Uint8Array | null>;
}
export interface BackupRestoreProgressEvent {
source: 'local' | 'remote';
step: string;
fileName: string;
stageTitle: string;
stageDetail: string;
replaceExisting: boolean;
done?: boolean;
ok?: boolean;
error?: string | null;
}
export type BackupRestoreProgressReporter = (event: BackupRestoreProgressEvent) => Promise<void> | void;
function attachmentRowKey(row: SqlRow): string {
const attachmentId = String(row.id || '').trim();
const cipherId = String(row.cipher_id || '').trim();
return `${cipherId}/${attachmentId}`;
}
function cloneRows(rows: SqlRow[]): SqlRow[] {
return rows.map((row) => ({ ...row }));
}
function upsertConfigRow(rows: SqlRow[], key: string, value: string): SqlRow[] {
let replaced = false;
const nextRows = rows.map((row) => {
if (String(row.key || '').trim() !== key) return { ...row };
replaced = true;
return { ...row, key, value };
});
if (!replaced) {
nextRows.push({ key, value });
}
return nextRows;
}
async function prepareImportedConfigRows(
env: Env,
configRows: SqlRow[],
userRows: SqlRow[]
): Promise<SqlRow[]> {
let nextConfigRows = cloneRows(configRows || []);
const rawBackupSettings = nextConfigRows.find((row) => String(row.key || '').trim() === BACKUP_SETTINGS_CONFIG_KEY);
const normalizedBackupSettings = await normalizeImportedBackupSettingsValue(
typeof rawBackupSettings?.value === 'string' ? rawBackupSettings.value : null,
env,
userRows.map((row) => ({
id: String(row.id || '').trim(),
publicKey: typeof row.public_key === 'string' ? row.public_key : null,
role: String(row.role || '').trim() as User['role'],
status: String(row.status || '').trim() as User['status'],
})),
'UTC'
);
if (normalizedBackupSettings !== null) {
nextConfigRows = upsertConfigRow(nextConfigRows, BACKUP_SETTINGS_CONFIG_KEY, normalizedBackupSettings);
}
nextConfigRows = upsertConfigRow(nextConfigRows, 'registered', 'true');
return nextConfigRows;
}
async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['db'], env: Env): Promise<BackupPayload['db']> {
const preparedDb: BackupPayload['db'] = {
config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []),
users: cloneRows(payload.users || []).map((row) => ({
...row,
verify_devices: row.verify_devices ?? 1,
})),
user_revisions: cloneRows(payload.user_revisions || []),
folders: cloneRows(payload.folders || []),
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
...row,
archived_at: row.archived_at ?? null,
})),
attachments: cloneRows(payload.attachments || []),
};
await importBackupRows(db, preparedDb, true);
return preparedDb;
}
function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files: Record<string, Uint8Array>): PreparedBackupImportPayload {
const storageKind = getBlobStorageKind(env);
if (storageKind === 'r2') {
@@ -147,7 +312,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files:
};
});
return {
const result = {
payload: {
...payload,
db: {
@@ -161,6 +326,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files:
items: skippedItems,
},
};
return result;
}
const oversizedAttachmentPaths = new Set<string>();
@@ -197,7 +363,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files:
throw new Error('Backup restore requires ATTACHMENTS_KV when using KV blob storage');
}
return {
const result = {
payload: nextPayload,
skipped: {
reason: skippedItems.length ? KV_BLOB_SKIP_REASON : null,
@@ -205,6 +371,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files:
items: skippedItems,
},
};
return result;
}
function buildInsertStatements(db: D1Database, table: string, columns: string[], rows: SqlRow[], upsert = false): D1PreparedStatement[] {
@@ -214,6 +381,16 @@ function buildInsertStatements(db: D1Database, table: string, columns: string[],
return rows.map((row) => db.prepare(sql).bind(...columns.map((column) => row[column] ?? null)));
}
async function runInsertBatch(db: D1Database, table: string, statements: D1PreparedStatement[]): Promise<void> {
if (!statements.length) return;
try {
await db.batch(statements);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Restore insert failed for ${table}: ${message}`);
}
}
async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record<string, Uint8Array>): Promise<AttachmentRestoreResult> {
const restoredAttachments: SqlRow[] = [];
const skippedItems: BackupImportSkipSummary['items'] = [];
@@ -300,14 +477,10 @@ async function prepareRemoteAttachmentPayload(
skippedItems.push({ kind: 'attachment', path, sizeBytes });
continue;
}
if (!(await source.hasAttachment(ref.blobName))) {
skippedItems.push({ kind: 'attachment', path, sizeBytes });
continue;
}
nextAttachments.push(row);
}
return {
const result = {
payload: {
...payload,
db: {
@@ -321,16 +494,18 @@ async function prepareRemoteAttachmentPayload(
items: skippedItems,
},
};
return result;
}
async function removeAttachmentRows(db: D1Database, attachmentRows: SqlRow[]): Promise<void> {
async function removeAttachmentRows(db: D1Database, attachmentRows: SqlRow[], useShadowTable: boolean = false): Promise<void> {
if (!attachmentRows.length) return;
const tableName = useShadowTable ? shadowTableName('attachments') : 'attachments';
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);
return db.prepare(`DELETE FROM ${tableName} WHERE id = ? AND cipher_id = ?`).bind(attachmentId, cipherId);
})
.filter((statement): statement is D1PreparedStatement => !!statement);
if (!statements.length) return;
@@ -406,36 +581,58 @@ async function cleanupOrphanedBlobFiles(env: Env, beforeKeys: Set<string>, after
}
}
async function importBackupRows(db: D1Database, payload: BackupPayload['db']): Promise<void> {
const statements: D1PreparedStatement[] = [
...buildResetImportTargetStatements(db),
...buildInsertStatements(db, 'config', ['key', 'value'], payload.config || [], true),
...buildInsertStatements(
async function importBackupRows(db: D1Database, payload: BackupPayload['db'], useShadowTables: boolean = false): Promise<void> {
const tableName = (table: BackupTableName): string => (useShadowTables ? shadowTableName(table) : table);
await runInsertBatch(
db,
tableName('config'),
buildInsertStatements(db, tableName('config'), ['key', 'value'], payload.config || [], true)
);
await runInsertBatch(
db,
tableName('users'),
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'],
tableName('users'),
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'],
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(
)
);
await runInsertBatch(
db,
tableName('user_revisions'),
buildInsertStatements(db, tableName('user_revisions'), ['user_id', 'revision_date'], payload.user_revisions || [], true)
);
await runInsertBatch(
db,
tableName('folders'),
buildInsertStatements(db, tableName('folders'), ['id', 'user_id', 'name', 'created_at', 'updated_at'], payload.folders || [])
);
await runInsertBatch(
db,
tableName('ciphers'),
buildInsertStatements(
db,
'ciphers',
['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'deleted_at'],
tableName('ciphers'),
['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'archived_at', 'deleted_at'],
payload.ciphers || []
),
...buildInsertStatements(db, 'attachments', ['id', 'cipher_id', 'file_name', 'size', 'size_name', 'key'], payload.attachments || []),
];
await db.batch(statements);
)
);
await runInsertBatch(
db,
tableName('attachments'),
buildInsertStatements(db, tableName('attachments'), ['id', 'cipher_id', 'file_name', 'size', 'size_name', 'key'], payload.attachments || [])
);
}
export async function importBackupArchiveBytes(
archiveBytes: Uint8Array,
env: Env,
actorUserId: string,
replaceExisting: boolean
replaceExisting: boolean,
progress?: BackupRestoreProgressReporter,
fileName: string = 'nodewarden_backup.zip'
): 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);
@@ -448,40 +645,118 @@ export async function importBackupArchiveBytes(
}
}
await resetRestoreArtifacts(env.DB);
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
const { db } = prepared.payload;
await importBackupRows(env.DB, db);
await normalizeImportedBackupSettings(storage, env, 'UTC');
try {
await progress?.({
source: 'local',
step: 'local_create_shadow',
fileName,
stageTitle: 'txt_backup_restore_progress_local_shadow_title',
stageDetail: 'txt_backup_restore_progress_local_shadow_detail',
replaceExisting,
});
await createShadowTables(env.DB);
await progress?.({
source: 'local',
step: 'local_import_data',
fileName,
stageTitle: 'txt_backup_restore_progress_local_data_title',
stageDetail: 'txt_backup_restore_progress_local_data_detail',
replaceExisting,
});
const db = await importPreparedBackupRows(env.DB, prepared.payload.db, env);
await validateShadowTableCounts(env.DB, {
config: (db.config || []).length,
users: (db.users || []).length,
user_revisions: (db.user_revisions || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: (db.attachments || []).length,
});
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 progress?.({
source: 'local',
step: 'local_restore_files',
fileName,
stageTitle: 'txt_backup_restore_progress_local_files_title',
stageDetail: 'txt_backup_restore_progress_local_files_detail',
replaceExisting,
});
const restored = await restoreBlobFiles(env, db, parsed.files);
const restoredAttachmentKeys = new Set((restored.restoredAttachments || []).map(attachmentRowKey));
const failedRestoreRows = (db.attachments || []).filter((row) => !restoredAttachmentKeys.has(attachmentRowKey(row)));
await removeAttachmentRows(env.DB, failedRestoreRows, true).catch(() => undefined);
await validateShadowTableCounts(env.DB, {
config: (db.config || []).length,
users: (db.users || []).length,
user_revisions: (db.user_revisions || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: restored.restoredAttachments.length,
});
await progress?.({
source: 'local',
step: 'local_finalize',
fileName,
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
replaceExisting,
});
await swapShadowTablesIntoPlace(env.DB);
await resetRestoreArtifacts(env.DB).catch(() => undefined);
if (replaceExisting && previousBlobKeys.size) {
const nextBlobKeys = await collectCurrentBlobKeys(env.DB).catch(() => null);
if (nextBlobKeys) {
await cleanupOrphanedBlobFiles(env, previousBlobKeys, nextBlobKeys).catch(() => undefined);
}
}
await progress?.({
source: 'local',
step: 'local_complete',
fileName,
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
replaceExisting,
done: true,
ok: true,
});
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],
},
},
};
} catch (error) {
await progress?.({
source: 'local',
step: 'local_failed',
fileName,
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
replaceExisting,
done: true,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
await resetRestoreArtifacts(env.DB).catch(() => undefined);
throw error;
}
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(
@@ -489,9 +764,10 @@ export async function importRemoteBackupArchiveBytes(
env: Env,
actorUserId: string,
replaceExisting: boolean,
source: RemoteAttachmentSource
source: RemoteAttachmentSource,
progress?: BackupRestoreProgressReporter,
fileName: string = 'nodewarden_backup.zip'
): 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 });
@@ -504,44 +780,122 @@ export async function importRemoteBackupArchiveBytes(
}
}
await resetRestoreArtifacts(env.DB);
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
const { db } = preparedRemote.payload;
await importBackupRows(env.DB, db);
await normalizeImportedBackupSettings(storage, env, 'UTC');
try {
await progress?.({
source: 'remote',
step: 'remote_create_shadow',
fileName,
stageTitle: 'txt_backup_restore_progress_remote_shadow_title',
stageDetail: 'txt_backup_restore_progress_remote_shadow_detail',
replaceExisting,
});
await createShadowTables(env.DB);
await progress?.({
source: 'remote',
step: 'remote_import_data',
fileName,
stageTitle: 'txt_backup_restore_progress_remote_data_title',
stageDetail: 'txt_backup_restore_progress_remote_data_detail',
replaceExisting,
});
const db = await importPreparedBackupRows(env.DB, preparedRemote.payload.db, env);
await validateShadowTableCounts(env.DB, {
config: (db.config || []).length,
users: (db.users || []).length,
user_revisions: (db.user_revisions || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: (db.attachments || []).length,
});
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);
await progress?.({
source: 'remote',
step: 'remote_restore_files',
fileName,
stageTitle: 'txt_backup_restore_progress_remote_files_title',
stageDetail: 'txt_backup_restore_progress_remote_files_detail',
replaceExisting,
});
const restored = await restoreRemoteAttachmentFiles(env, preparedRemote.payload, parsed.files, source);
const restoredAttachmentKeys = new Set((restored.restoredAttachments || []).map(attachmentRowKey));
const failedRestoreRows = (db.attachments || []).filter((row) => !restoredAttachmentKeys.has(attachmentRowKey(row)));
await removeAttachmentRows(env.DB, failedRestoreRows, true).catch(() => undefined);
await validateShadowTableCounts(env.DB, {
config: (db.config || []).length,
users: (db.users || []).length,
user_revisions: (db.user_revisions || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: restored.restoredAttachments.length,
});
await progress?.({
source: 'remote',
step: 'remote_finalize',
fileName,
stageTitle: 'txt_backup_restore_progress_remote_finalize_title',
stageDetail: 'txt_backup_restore_progress_remote_finalize_detail',
replaceExisting,
});
await swapShadowTablesIntoPlace(env.DB);
await resetRestoreArtifacts(env.DB).catch(() => undefined);
if (replaceExisting && previousBlobKeys.size) {
await cleanupOrphanedBlobFiles(env, previousBlobKeys, await collectCurrentBlobKeys(env.DB));
if (replaceExisting && previousBlobKeys.size) {
const nextBlobKeys = await collectCurrentBlobKeys(env.DB).catch(() => null);
if (nextBlobKeys) {
await cleanupOrphanedBlobFiles(env, previousBlobKeys, nextBlobKeys).catch(() => undefined);
}
}
await progress?.({
source: 'remote',
step: 'remote_complete',
fileName,
stageTitle: 'txt_backup_restore_progress_remote_finalize_title',
stageDetail: 'txt_backup_restore_progress_remote_finalize_detail',
replaceExisting,
done: true,
ok: true,
});
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,
},
},
};
} catch (error) {
await progress?.({
source: 'remote',
step: 'remote_failed',
fileName,
stageTitle: 'txt_backup_restore_progress_remote_finalize_title',
stageDetail: 'txt_backup_restore_progress_remote_finalize_detail',
replaceExisting,
done: true,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
await resetRestoreArtifacts(env.DB).catch(() => undefined);
throw error;
}
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,
},
},
};
}
+81 -14
View File
@@ -250,18 +250,49 @@ async function ensureWebDavDirectory(baseUrl: string, directoryPath: string, aut
}
}
async function ensureWebDavDirectoryCached(
baseUrl: string,
directoryPath: string,
authHeader: string,
ensuredDirectories: Set<string>
): Promise<void> {
const segments = trimSlashes(directoryPath).split('/').filter(Boolean);
let current = '';
for (const segment of segments) {
current = buildJoinedPath(current, segment);
if (ensuredDirectories.has(current)) continue;
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)) {
ensuredDirectories.add(current);
continue;
}
throw new Error(`WebDAV directory creation failed: ${response.status}`);
}
}
async function putToWebDav(
config: WebDavBackupDestination,
relativePath: string,
bytes: Uint8Array,
options: RemoteBackupFilePutOptions = {}
options: RemoteBackupFilePutOptions = {},
ensuredDirectories?: Set<string>
): 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);
if (ensuredDirectories) {
await ensureWebDavDirectoryCached(config.baseUrl, remoteDir, authHeader, ensuredDirectories);
} else {
await ensureWebDavDirectory(config.baseUrl, remoteDir, authHeader);
}
}
const response = await fetch(buildWebDavUrl(config.baseUrl, remoteFilePath), {
@@ -608,6 +639,16 @@ interface ConfiguredDestinationAdapter {
exists: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<boolean>;
}
export interface RemoteBackupTransferSession {
provider: BackupDestinationType;
uploadArchive(archive: Uint8Array, fileName: string): Promise<BackupUploadResult>;
putFile(relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions): Promise<void>;
list(relativePath: string): Promise<RemoteBackupListResult>;
download(relativePath: string): Promise<RemoteBackupFile>;
deleteFile(relativePath: string): Promise<void>;
exists(relativePath: string): Promise<boolean>;
}
function resolveConfiguredDestinationAdapter(
destination: BackupDestinationRecord
): ConfiguredDestinationAdapter {
@@ -641,35 +682,62 @@ function resolveConfiguredDestinationAdapter(
throw new Error('Unsupported backup destination type');
}
export function createRemoteBackupTransferSession(destination: BackupDestinationRecord): RemoteBackupTransferSession {
const adapter = resolveConfiguredDestinationAdapter(destination);
const ensuredDirectories = adapter.provider === 'webdav' ? new Set<string>() : null;
const putFile = async (relativePath: string, bytes: Uint8Array, options: RemoteBackupFilePutOptions = {}): Promise<void> => {
const normalized = normalizeRelativePath(relativePath);
if (adapter.provider === 'webdav' && ensuredDirectories) {
await putToWebDav(adapter.config as WebDavBackupDestination, normalized, bytes, options, ensuredDirectories);
return;
}
await adapter.putFile(adapter.config, normalized, bytes, options);
};
return {
provider: adapter.provider,
uploadArchive: async (archive: Uint8Array, fileName: string) => {
await putFile(fileName, archive, { contentType: 'application/zip' });
return {
provider: adapter.provider,
remotePath: adapter.provider === 'webdav'
? buildJoinedPath((adapter.config as WebDavBackupDestination).remotePath, fileName)
: normalizeE3ObjectKey(adapter.config as E3BackupDestination, fileName),
};
},
putFile,
list: async (relativePath: string) => adapter.list(adapter.config, relativePath),
download: async (relativePath: string) => adapter.download(adapter.config, relativePath),
deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)),
exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)),
};
}
export async function uploadBackupArchive(
destination: BackupDestinationRecord,
archive: Uint8Array,
fileName: string
): Promise<BackupUploadResult> {
const adapter = resolveConfiguredDestinationAdapter(destination);
return adapter.upload(adapter.config, archive, fileName);
return createRemoteBackupTransferSession(destination).uploadArchive(archive, fileName);
}
export async function listRemoteBackupEntries(destination: BackupDestinationRecord, relativePath: string): Promise<RemoteBackupListResult> {
const adapter = resolveConfiguredDestinationAdapter(destination);
return adapter.list(adapter.config, relativePath);
return createRemoteBackupTransferSession(destination).list(relativePath);
}
export async function downloadRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise<RemoteBackupFile> {
const adapter = resolveConfiguredDestinationAdapter(destination);
return adapter.download(adapter.config, relativePath);
return createRemoteBackupTransferSession(destination).download(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);
await createRemoteBackupTransferSession(destination).deleteFile(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);
return createRemoteBackupTransferSession(destination).exists(normalized);
}
export async function uploadRemoteBackupFile(
@@ -679,8 +747,7 @@ export async function uploadRemoteBackupFile(
options: RemoteBackupFilePutOptions = {}
): Promise<void> {
const normalized = normalizeRelativePath(relativePath);
const adapter = resolveConfiguredDestinationAdapter(destination);
await adapter.putFile(adapter.config, normalized, bytes, options);
await createRemoteBackupTransferSession(destination).putFile(normalized, bytes, options);
}
function compareBackupItemsByRecency(a: RemoteBackupItem, b: RemoteBackupItem, preferredFileName?: string): number {
+95 -14
View File
@@ -1,5 +1,11 @@
import type { Cipher } from '../types';
function normalizeOptionalId(value: unknown): string | null {
if (value == null) return null;
const normalized = String(value).trim();
return normalized ? normalized : null;
}
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
type SqlChunkSize = (fixedBindCount: number) => number;
type UpdateRevisionDate = (userId: string) => Promise<string>;
@@ -17,6 +23,7 @@ interface CipherRow {
key: string | null;
created_at: string;
updated_at: string;
archived_at: string | null;
deleted_at: string | null;
}
@@ -24,12 +31,13 @@ function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
if (!row?.data) return null;
try {
const parsed = JSON.parse(row.data) as Cipher;
const folderId = normalizeOptionalId(row.folder_id ?? parsed.folderId ?? null);
return {
...parsed,
id: row.id,
userId: row.user_id,
type: Number(row.type) || Number(parsed.type) || 1,
folderId: row.folder_id ?? parsed.folderId ?? null,
folderId,
name: row.name ?? parsed.name ?? null,
notes: row.notes ?? parsed.notes ?? null,
favorite: row.favorite != null ? !!row.favorite : !!parsed.favorite,
@@ -37,6 +45,7 @@ function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
key: row.key ?? parsed.key ?? null,
createdAt: row.created_at,
updatedAt: row.updated_at,
archivedAt: row.archived_at ?? parsed.archivedAt ?? parsed.archivedDate ?? null,
deletedAt: row.deleted_at ?? null,
};
} catch {
@@ -46,7 +55,7 @@ function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
}
function selectCipherColumns(): string {
return 'id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at';
return 'id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at';
}
export async function getCipher(db: D1Database, id: string): Promise<Cipher | null> {
@@ -58,19 +67,23 @@ export async function getCipher(db: D1Database, id: string): Promise<Cipher | nu
}
export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> {
const data = JSON.stringify(cipher);
const folderId = normalizeOptionalId(cipher.folderId);
const data = JSON.stringify({
...cipher,
folderId,
});
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' +
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at'
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at'
);
await safeBind(
stmt,
cipher.id,
cipher.userId,
Number(cipher.type) || 1,
cipher.folderId,
folderId,
cipher.name,
cipher.notes,
cipher.favorite ? 1 : 0,
@@ -79,10 +92,15 @@ export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cip
cipher.key,
cipher.createdAt,
cipher.updatedAt,
cipher.archivedAt ?? null,
cipher.deletedAt
).run();
}
function sanitizeIds(ids: string[]): string[] {
return Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
}
export async function deleteCipher(db: D1Database, id: string, userId: string): Promise<void> {
await db.prepare('DELETE FROM ciphers WHERE id = ? AND user_id = ?').bind(id, userId).run();
}
@@ -95,7 +113,7 @@ export async function bulkSoftDeleteCiphers(
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)));
const uniqueIds = sanitizeIds(ids);
if (!uniqueIds.length) return null;
const now = new Date().toISOString();
@@ -126,7 +144,7 @@ export async function bulkRestoreCiphers(
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)));
const uniqueIds = sanitizeIds(ids);
if (!uniqueIds.length) return null;
const now = new Date().toISOString();
@@ -157,7 +175,7 @@ export async function bulkDeleteCiphers(
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)));
const uniqueIds = sanitizeIds(ids);
if (!uniqueIds.length) return null;
const chunkSize = sqlChunkSize(1);
@@ -212,7 +230,7 @@ export async function getCiphersByIds(
userId: string
): Promise<Cipher[]> {
if (ids.length === 0) return [];
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
const uniqueIds = sanitizeIds(ids);
if (!uniqueIds.length) return [];
const chunkSize = sqlChunkSize(1);
@@ -242,8 +260,9 @@ export async function bulkMoveCiphers(
): 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 normalizedFolderId = normalizeOptionalId(folderId);
const uniqueIds = sanitizeIds(ids);
const patch = JSON.stringify({ folderId: normalizedFolderId, updatedAt: now });
const chunkSize = sqlChunkSize(4);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
@@ -255,7 +274,69 @@ export async function bulkMoveCiphers(
SET folder_id = ?, updated_at = ?, data = json_patch(data, ?)
WHERE user_id = ? AND id IN (${placeholders})`
)
.bind(folderId, now, patch, userId, ...chunk)
.bind(normalizedFolderId, now, patch, userId, ...chunk)
.run();
}
return updateRevisionDate(userId);
}
export async function bulkArchiveCiphers(
db: D1Database,
sqlChunkSize: SqlChunkSize,
updateRevisionDate: UpdateRevisionDate,
ids: string[],
userId: string
): Promise<string | null> {
if (ids.length === 0) return null;
const uniqueIds = sanitizeIds(ids);
if (!uniqueIds.length) return null;
const now = new Date().toISOString();
const patch = JSON.stringify({ archivedAt: now, archivedDate: now, updatedAt: now });
const chunkSize = sqlChunkSize(4);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
await db
.prepare(
`UPDATE ciphers
SET archived_at = ?, updated_at = ?, data = json_patch(data, ?)
WHERE user_id = ? AND id IN (${placeholders}) AND deleted_at IS NULL`
)
.bind(now, now, patch, userId, ...chunk)
.run();
}
return updateRevisionDate(userId);
}
export async function bulkUnarchiveCiphers(
db: D1Database,
sqlChunkSize: SqlChunkSize,
updateRevisionDate: UpdateRevisionDate,
ids: string[],
userId: string
): Promise<string | null> {
if (ids.length === 0) return null;
const uniqueIds = sanitizeIds(ids);
if (!uniqueIds.length) return null;
const now = new Date().toISOString();
const patch = JSON.stringify({ archivedAt: null, archivedDate: null, updatedAt: now });
const chunkSize = sqlChunkSize(3);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
await db
.prepare(
`UPDATE ciphers
SET archived_at = NULL, updated_at = ?, data = json_patch(data, ?)
WHERE user_id = ? AND id IN (${placeholders})`
)
.bind(now, patch, userId, ...chunk)
.run();
}
+82 -6
View File
@@ -10,6 +10,9 @@ function mapDeviceRow(row: any): Device {
name: row.name,
type: row.type,
sessionStamp: row.session_stamp || '',
encryptedUserKey: row.encrypted_user_key ?? null,
encryptedPublicKey: row.encrypted_public_key ?? null,
encryptedPrivateKey: row.encrypted_private_key ?? null,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
@@ -22,19 +25,92 @@ export async function upsertDevice(
deviceIdentifier: string,
name: string,
type: number,
sessionStamp?: string
sessionStamp?: string,
keys?: {
encryptedUserKey?: string | null;
encryptedPublicKey?: string | null;
encryptedPrivateKey?: string | null;
}
): Promise<void> {
const now = new Date().toISOString();
const effectiveSessionStamp = String(sessionStamp || '').trim() || (await getDeviceById(userId, deviceIdentifier))?.sessionStamp || '';
await db
.prepare(
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, 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'
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?) ' +
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' +
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
'updated_at=excluded.updated_at'
)
.bind(
userId,
deviceIdentifier,
name,
type,
effectiveSessionStamp,
keys?.encryptedUserKey ?? null,
keys?.encryptedPublicKey ?? null,
keys?.encryptedPrivateKey ?? null,
now,
now
)
.bind(userId, deviceIdentifier, name, type, effectiveSessionStamp, now, now)
.run();
}
export async function updateDeviceKeys(
db: D1Database,
userId: string,
deviceIdentifier: string,
keys: {
encryptedUserKey?: string | null;
encryptedPublicKey?: string | null;
encryptedPrivateKey?: string | null;
}
): Promise<boolean> {
const now = new Date().toISOString();
const result = await db
.prepare(
'UPDATE devices SET encrypted_user_key = ?, encrypted_public_key = ?, encrypted_private_key = ?, updated_at = ? ' +
'WHERE user_id = ? AND device_identifier = ?'
)
.bind(
keys.encryptedUserKey ?? null,
keys.encryptedPublicKey ?? null,
keys.encryptedPrivateKey ?? null,
now,
userId,
deviceIdentifier
)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function clearDeviceKeys(
db: D1Database,
userId: string,
deviceIdentifiers: string[]
): Promise<number> {
const uniqueIds = Array.from(
new Set(deviceIdentifiers.map((id) => String(id || '').trim()).filter(Boolean))
);
if (!uniqueIds.length) return 0;
const placeholders = uniqueIds.map(() => '?').join(',');
const result = await db
.prepare(
`UPDATE devices
SET encrypted_user_key = NULL,
encrypted_public_key = NULL,
encrypted_private_key = NULL,
updated_at = ?
WHERE user_id = ? AND device_identifier IN (${placeholders})`
)
.bind(new Date().toISOString(), userId, ...uniqueIds)
.run();
return Number(result.meta.changes ?? 0);
}
export async function isKnownDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> {
const row = await db
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
@@ -57,7 +133,7 @@ export async function isKnownDeviceByEmail(
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 ' +
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' +
'FROM devices WHERE user_id = ? ORDER BY updated_at DESC'
)
.bind(userId)
@@ -68,7 +144,7 @@ export async function getDevicesByUserId(db: D1Database, userId: string): Promis
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 ' +
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' +
'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
)
.bind(userId, deviceIdentifier)
+9 -3
View File
@@ -6,10 +6,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'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)',
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1',
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
@@ -20,9 +21,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'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, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, archived_at TEXT, deleted_at TEXT, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'ALTER TABLE ciphers ADD COLUMN archived_at TEXT',
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)',
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at)',
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
'CREATE TABLE IF NOT EXISTS folders (' +
@@ -68,12 +71,15 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'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, ' +
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'PRIMARY KEY (user_id, device_identifier), ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at)',
'ALTER TABLE devices ADD COLUMN session_stamp TEXT',
'ALTER TABLE devices ADD COLUMN encrypted_user_key TEXT',
'ALTER TABLE devices ADD COLUMN encrypted_public_key TEXT',
'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT',
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE devices ADD COLUMN banned_at TEXT',
+15 -14
View File
@@ -1,6 +1,10 @@
import type { User } from '../types';
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
const USER_SELECT_COLUMNS =
'id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, ' +
'kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, ' +
'totp_secret, totp_recovery_code, created_at, updated_at';
function mapUserRow(row: any): User {
return {
@@ -19,6 +23,7 @@ function mapUserRow(row: any): User {
securityStamp: row.security_stamp,
role: row.role === 'admin' ? 'admin' : 'user',
status: row.status === 'banned' ? 'banned' : 'active',
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
totpSecret: row.totp_secret ?? null,
totpRecoveryCode: row.totp_recovery_code ?? null,
createdAt: row.created_at,
@@ -28,9 +33,7 @@ function mapUserRow(row: any): User {
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 = ?'
)
.prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users WHERE email = ?`)
.bind(email.toLowerCase())
.first<any>();
if (!row) return null;
@@ -39,9 +42,7 @@ export async function getUser(db: D1Database, email: string): Promise<User | nul
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 = ?'
)
.prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users WHERE id = ?`)
.bind(id)
.first<any>();
if (!row) return null;
@@ -55,9 +56,7 @@ export async function getUserCount(db: D1Database): Promise<number> {
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'
)
.prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users ORDER BY created_at ASC`)
.all<any>();
return (res.results || []).map((row) => mapUserRow(row));
}
@@ -65,11 +64,11 @@ export async function getAllUsers(db: D1Database): Promise<User[]> {
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' +
'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' +
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at'
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at'
);
await safeBind(
stmt,
@@ -88,6 +87,7 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User):
user.securityStamp,
user.role,
user.status,
user.verifyDevices ? 1 : 0,
user.totpSecret,
user.totpRecoveryCode,
user.createdAt,
@@ -102,8 +102,8 @@ export async function createUser(db: D1Database, safeBind: SafeBind, user: 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 ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at) ' +
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
);
const result = await safeBind(
@@ -123,6 +123,7 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user:
user.securityStamp,
user.role,
user.status,
user.verifyDevices ? 1 : 0,
user.totpSecret,
user.totpRecoveryCode,
user.createdAt,
+42 -3
View File
@@ -36,10 +36,12 @@ import {
saveFolder as saveStoredFolder,
} from './storage-folder-repo';
import {
bulkArchiveCiphers as archiveStoredCiphers,
bulkDeleteCiphers as deleteStoredCiphers,
bulkMoveCiphers as moveStoredCiphers,
bulkRestoreCiphers as restoreStoredCiphers,
bulkSoftDeleteCiphers as softDeleteStoredCiphers,
bulkUnarchiveCiphers as unarchiveStoredCiphers,
getAllCiphers as listStoredCiphers,
getCipher as findStoredCipher,
getCiphersByIds as listStoredCiphersByIds,
@@ -80,6 +82,7 @@ import {
import {
deleteDevice as deleteStoredDevice,
deleteDevicesByUserId as deleteStoredDevicesByUserId,
clearDeviceKeys as clearStoredDeviceKeys,
deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice,
deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId,
getDevice as findStoredDevice,
@@ -90,6 +93,7 @@ import {
isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
upsertDevice as saveStoredDevice,
updateDeviceKeys as updateStoredDeviceKeys,
} from './storage-device-repo';
import {
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
@@ -102,7 +106,7 @@ import {
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
const STORAGE_SCHEMA_VERSION = '2026-03-19.1';
const STORAGE_SCHEMA_VERSION = '2026-03-23.1';
// D1-backed storage.
// Contract:
@@ -286,6 +290,14 @@ export class StorageService {
return restoreStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
}
async bulkArchiveCiphers(ids: string[], userId: string): Promise<string | null> {
return archiveStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
}
async bulkUnarchiveCiphers(ids: string[], userId: string): Promise<string | null> {
return unarchiveStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
}
async bulkDeleteCiphers(ids: string[], userId: string): Promise<string | null> {
return deleteStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
}
@@ -495,8 +507,19 @@ export class StorageService {
// --- Devices ---
async upsertDevice(userId: string, deviceIdentifier: string, name: string, type: number, sessionStamp?: string): Promise<void> {
await saveStoredDevice(this.db, this.getDevice.bind(this), userId, deviceIdentifier, name, type, sessionStamp);
async upsertDevice(
userId: string,
deviceIdentifier: string,
name: string,
type: number,
sessionStamp?: string,
keys?: {
encryptedUserKey?: string | null;
encryptedPublicKey?: string | null;
encryptedPrivateKey?: string | null;
}
): Promise<void> {
await saveStoredDevice(this.db, this.getDevice.bind(this), userId, deviceIdentifier, name, type, sessionStamp, keys);
}
async isKnownDevice(userId: string, deviceIdentifier: string): Promise<boolean> {
@@ -515,6 +538,22 @@ export class StorageService {
return findStoredDevice(this.db, userId, deviceIdentifier);
}
async updateDeviceKeys(
userId: string,
deviceIdentifier: string,
keys: {
encryptedUserKey?: string | null;
encryptedPublicKey?: string | null;
encryptedPrivateKey?: string | null;
}
): Promise<boolean> {
return updateStoredDeviceKeys(this.db, userId, deviceIdentifier, keys);
}
async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> {
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
}
async deleteDevice(userId: string, deviceIdentifier: string): Promise<boolean> {
return deleteStoredDevice(this.db, userId, deviceIdentifier);
}
+47
View File
@@ -47,6 +47,7 @@ export interface User {
securityStamp: string;
role: UserRole;
status: UserStatus;
verifyDevices?: boolean;
totpSecret: string | null;
totpRecoveryCode: string | null;
createdAt: string;
@@ -169,6 +170,7 @@ export interface Cipher {
key: string | null;
createdAt: string;
updatedAt: string;
archivedAt: string | null;
deletedAt: string | null;
/** Allow unknown fields from Bitwarden clients to be stored and passed through transparently. */
[key: string]: any;
@@ -189,10 +191,47 @@ export interface Device {
name: string;
type: number;
sessionStamp: string;
encryptedUserKey: string | null;
encryptedPublicKey: string | null;
encryptedPrivateKey: string | null;
devicePendingAuthRequest?: DevicePendingAuthRequest | null;
createdAt: string;
updatedAt: string;
}
export interface DevicePendingAuthRequest {
id: string;
creationDate: string;
}
export interface DeviceResponse {
id: string;
userId?: string | null;
name: string;
identifier: string;
type: number;
creationDate: string;
revisionDate: string;
isTrusted: boolean;
encryptedUserKey: string | null;
encryptedPublicKey: string | null;
devicePendingAuthRequest: DevicePendingAuthRequest | null;
object: string;
[key: string]: any;
}
export interface ProtectedDeviceResponse {
id: string;
name: string;
identifier: string;
type: number;
creationDate: string;
encryptedUserKey: string | null;
encryptedPublicKey: string | null;
object: string;
[key: string]: any;
}
export interface RefreshTokenRecord {
userId: string;
expiresAt: number;
@@ -351,6 +390,7 @@ export interface ProfileResponse {
forcePasswordReset: boolean;
avatarColor: string | null;
creationDate: string;
verifyDevices?: boolean;
role?: UserRole;
status?: UserStatus;
object: string;
@@ -409,6 +449,13 @@ export interface SyncResponse {
domains: any;
policies: any[];
sends: SendResponse[];
UserDecryption?: {
MasterPasswordUnlock: MasterPasswordUnlock | null;
TrustedDeviceOption?: null;
KeyConnectorOption?: null;
WebAuthnPrfOption?: null;
Object?: string;
} | null;
// PascalCase for desktop/browser clients
UserDecryptionOptions: UserDecryptionOptions | null;
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
+9 -2
View File
@@ -1,14 +1,21 @@
import { User, UserDecryptionOptions } from '../types';
function normalizeOptionalPublicKey(value: unknown): string {
if (value == null) return '';
return String(value);
}
export function buildAccountKeys(user: Pick<User, 'privateKey' | 'publicKey'>): Record<string, unknown> | null {
if (!user.privateKey || !user.publicKey) {
if (!user.privateKey) {
return null;
}
const publicKey = normalizeOptionalPublicKey(user.publicKey);
return {
publicKeyEncryptionKeyPair: {
wrappedPrivateKey: user.privateKey,
publicKey: user.publicKey,
publicKey,
Object: 'publicKeyEncryptionKeyPair',
},
Object: 'privateKeys',
+121
View File
@@ -50,6 +50,7 @@ import useVaultSendActions from '@/hooks/useVaultSendActions';
import { useToastManager } from '@/hooks/useToastManager';
import { t } from '@/lib/i18n';
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
import type { AppPhase, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
const IMPORT_ROUTE = '/backup/import-export';
@@ -57,10 +58,68 @@ const IMPORT_ROUTE_PATHS = [IMPORT_ROUTE, '/tools/import', '/tools/import-export
const IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE));
const SETTINGS_HOME_ROUTE = '/settings';
const SETTINGS_ACCOUNT_ROUTE = '/settings/account';
const THEME_STORAGE_KEY = 'nodewarden.theme.preference.v1';
const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e);
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
type ThemePreference = 'system' | 'light' | 'dark';
const MAGNETIC_SELECTOR = '.topbar .btn, .topbar .user-chip, .side-link, .mobile-tab';
function installMagneticUiFeedback() {
if (typeof window === 'undefined' || typeof document === 'undefined') return () => {};
if (typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return () => {};
if (typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches) return () => {};
const resetNode = (node: HTMLElement) => {
node.style.setProperty('--mag-x', '0px');
node.style.setProperty('--mag-y', '0px');
node.style.removeProperty('--mx');
node.style.removeProperty('--my');
};
const onPointerMove = (event: PointerEvent) => {
const node = event.target instanceof Element ? event.target.closest<HTMLElement>(MAGNETIC_SELECTOR) : null;
if (!node) return;
const rect = node.getBoundingClientRect();
const localX = event.clientX - rect.left;
const localY = event.clientY - rect.top;
const dx = (localX - rect.width / 2) / Math.max(rect.width / 2, 1);
const dy = (localY - rect.height / 2) / Math.max(rect.height / 2, 1);
node.style.setProperty('--mx', `${localX}px`);
node.style.setProperty('--my', `${localY}px`);
node.style.setProperty('--mag-x', `${dx * 6}px`);
node.style.setProperty('--mag-y', `${dy * 4}px`);
};
const onPointerLeave = (event: Event) => {
const node = event.target instanceof Element ? event.target.closest<HTMLElement>(MAGNETIC_SELECTOR) : null;
if (!node) return;
resetNode(node);
};
document.addEventListener('pointermove', onPointerMove, { passive: true });
document.addEventListener('pointerleave', onPointerLeave, true);
return () => {
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerleave', onPointerLeave, true);
};
}
function readThemePreference(): ThemePreference {
if (typeof window === 'undefined') return 'system';
const stored = String(window.localStorage.getItem(THEME_STORAGE_KEY) || '').trim();
if (stored === 'light' || stored === 'dark' || stored === 'system') return stored;
return 'system';
}
function resolveSystemTheme(): 'light' | 'dark' {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export default function App() {
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
@@ -100,6 +159,8 @@ export default function App() {
const [disableTotpOpen, setDisableTotpOpen] = useState(false);
const [disableTotpPassword, setDisableTotpPassword] = useState('');
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme());
const [confirm, setConfirm] = useState<AppConfirmState | null>(null);
const [mobileLayout, setMobileLayout] = useState(false);
@@ -175,6 +236,41 @@ export default function App() {
return () => media.removeListener(sync);
}, []);
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
const media = window.matchMedia('(prefers-color-scheme: dark)');
const sync = () => setSystemTheme(media.matches ? 'dark' : 'light');
sync();
if (typeof media.addEventListener === 'function') {
media.addEventListener('change', sync);
return () => media.removeEventListener('change', sync);
}
media.addListener(sync);
return () => media.removeListener(sync);
}, []);
const resolvedTheme = themePreference === 'system' ? systemTheme : themePreference;
useEffect(() => {
if (typeof document === 'undefined') return;
document.documentElement.dataset.theme = resolvedTheme;
document.documentElement.style.colorScheme = resolvedTheme;
}, [resolvedTheme]);
useEffect(() => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(THEME_STORAGE_KEY, themePreference);
}, [themePreference]);
useEffect(() => installMagneticUiFeedback(), []);
function handleToggleTheme() {
setThemePreference((prev) => {
const current = prev === 'system' ? systemTheme : prev;
return current === 'dark' ? 'light' : 'dark';
});
}
function setSession(next: SessionState | null) {
sessionRef.current = next;
setSessionState(next);
@@ -814,6 +910,21 @@ export default function App() {
void refreshAuthorizedDevicesRef.current();
continue;
}
if (updateType === SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS) {
const payload = frame.arguments?.[0]?.Payload;
if (
payload
&& typeof payload === 'object'
&& (
payload.operation === 'backup-restore'
|| payload.operation === 'backup-export'
|| payload.operation === 'backup-remote-run'
)
) {
dispatchBackupProgress(payload as BackupProgressDetail);
}
continue;
}
if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue;
const contextId = String(frame.arguments?.[0]?.ContextId || '').trim();
if (contextId && contextId === getCurrentDeviceIdentifier()) continue;
@@ -974,9 +1085,13 @@ export default function App() {
onCreateVaultItem: vaultSendActions.createVaultItem,
onUpdateVaultItem: vaultSendActions.updateVaultItem,
onDeleteVaultItem: vaultSendActions.deleteVaultItem,
onArchiveVaultItem: vaultSendActions.archiveVaultItem,
onUnarchiveVaultItem: vaultSendActions.unarchiveVaultItem,
onBulkDeleteVaultItems: vaultSendActions.bulkDeleteVaultItems,
onBulkPermanentDeleteVaultItems: vaultSendActions.bulkPermanentDeleteVaultItems,
onBulkRestoreVaultItems: vaultSendActions.bulkRestoreVaultItems,
onBulkArchiveVaultItems: vaultSendActions.bulkArchiveVaultItems,
onBulkUnarchiveVaultItems: vaultSendActions.bulkUnarchiveVaultItems,
onBulkMoveVaultItems: vaultSendActions.bulkMoveVaultItems,
onVerifyMasterPassword: vaultSendActions.verifyMasterPassword,
onCreateFolder: vaultSendActions.createFolder,
@@ -1015,13 +1130,16 @@ export default function App() {
onRevokeInvite: adminActions.revokeInvite,
onExportBackup: backupActions.exportBackup,
onImportBackup: backupActions.importBackup,
onImportBackupAllowingChecksumMismatch: backupActions.importBackupAllowingChecksumMismatch,
onLoadBackupSettings: backupActions.loadSettings,
onSaveBackupSettings: backupActions.saveSettings,
onRunRemoteBackup: backupActions.runRemoteBackup,
onListRemoteBackups: backupActions.listRemoteBackups,
onDownloadRemoteBackup: backupActions.downloadRemoteBackup,
onInspectRemoteBackup: backupActions.inspectRemoteBackup,
onDeleteRemoteBackup: backupActions.deleteRemoteBackup,
onRestoreRemoteBackup: backupActions.restoreRemoteBackup,
onRestoreRemoteBackupAllowingChecksumMismatch: backupActions.restoreRemoteBackupAllowingChecksumMismatch,
};
if (jwtWarning) {
@@ -1131,8 +1249,11 @@ export default function App() {
settingsAccountRoute={SETTINGS_ACCOUNT_ROUTE}
importRoute={IMPORT_ROUTE}
isImportRoute={isImportRoute}
darkMode={resolvedTheme === 'dark'}
themeToggleTitle={resolvedTheme === 'dark' ? t('txt_switch_to_light_mode') : t('txt_switch_to_dark_mode')}
onLock={handleLock}
onLogout={handleLogout}
onToggleTheme={handleToggleTheme}
mainRoutesProps={mainRoutesProps}
/>
@@ -1,6 +1,7 @@
import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
import { Link } from 'wouter';
import AppMainRoutes from '@/components/AppMainRoutes';
import ThemeSwitch from '@/components/ThemeSwitch';
import type { AppMainRoutesProps } from '@/components/AppMainRoutes';
import { t } from '@/lib/i18n';
import type { Profile } from '@/lib/types';
@@ -15,12 +16,17 @@ interface AppAuthenticatedShellProps {
settingsAccountRoute: string;
importRoute: string;
isImportRoute: boolean;
darkMode: boolean;
themeToggleTitle: string;
onLock: () => void;
onLogout: () => void;
onToggleTheme: () => void;
mainRoutesProps: AppMainRoutesProps;
}
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location;
return (
<div className="app-page">
<div className="app-shell">
@@ -35,6 +41,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<ShieldUser size={16} />
<span>{props.profile?.email}</span>
</div>
<ThemeSwitch checked={props.darkMode} title={props.themeToggleTitle} onToggle={props.onToggleTheme} />
<button type="button" className="btn btn-secondary small" onClick={props.onLock}>
<Lock size={14} className="btn-icon" /> {t('txt_lock')}
</button>
@@ -49,6 +56,9 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<FolderIcon size={16} className="btn-icon" />
</button>
)}
<div className="mobile-theme-btn">
<ThemeSwitch checked={props.darkMode} title={props.themeToggleTitle} onToggle={props.onToggleTheme} />
</div>
<button type="button" className="btn btn-secondary small mobile-lock-btn" aria-label={t('txt_lock')} title={t('txt_lock')} onClick={props.onLock}>
<Lock size={14} className="btn-icon" />
</button>
@@ -98,7 +108,9 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
</Link>
</aside>
<main className="content">
<AppMainRoutes {...props.mainRoutesProps} />
<div key={routeAnimationKey} className="route-stage">
<AppMainRoutes {...props.mainRoutesProps} />
</div>
</main>
</div>
+14
View File
@@ -64,9 +64,13 @@ export interface AppMainRoutesProps {
onCreateVaultItem: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
onUpdateVaultItem: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>;
onDeleteVaultItem: (cipher: Cipher) => Promise<void>;
onArchiveVaultItem: (cipher: Cipher) => Promise<void>;
onUnarchiveVaultItem: (cipher: Cipher) => Promise<void>;
onBulkDeleteVaultItems: (ids: string[]) => Promise<void>;
onBulkPermanentDeleteVaultItems: (ids: string[]) => Promise<void>;
onBulkRestoreVaultItems: (ids: string[]) => Promise<void>;
onBulkArchiveVaultItems: (ids: string[]) => Promise<void>;
onBulkUnarchiveVaultItems: (ids: string[]) => Promise<void>;
onBulkMoveVaultItems: (ids: string[], folderId: string | null) => Promise<void>;
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
onCreateFolder: (name: string) => Promise<void>;
@@ -102,13 +106,16 @@ export interface AppMainRoutesProps {
onRevokeInvite: (code: string) => Promise<void>;
onExportBackup: (includeAttachments?: boolean) => Promise<void>;
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onImportBackupAllowingChecksumMismatch: (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>;
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>;
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
}
export default function AppMainRoutes(props: AppMainRoutesProps) {
@@ -174,9 +181,13 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onCreate={props.onCreateVaultItem}
onUpdate={props.onUpdateVaultItem}
onDelete={props.onDeleteVaultItem}
onArchive={props.onArchiveVaultItem}
onUnarchive={props.onUnarchiveVaultItem}
onBulkDelete={props.onBulkDeleteVaultItems}
onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems}
onBulkRestore={props.onBulkRestoreVaultItems}
onBulkArchive={props.onBulkArchiveVaultItems}
onBulkUnarchive={props.onBulkUnarchiveVaultItems}
onBulkMove={props.onBulkMoveVaultItems}
onVerifyMasterPassword={props.onVerifyMasterPassword}
onNotify={props.onNotify}
@@ -325,11 +336,14 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
currentUserId={props.profile?.id || null}
onExport={props.onExportBackup}
onImport={props.onImportBackup}
onImportAllowingChecksumMismatch={props.onImportBackupAllowingChecksumMismatch}
onLoadSettings={props.onLoadBackupSettings}
onListRemoteBackups={props.onListRemoteBackups}
onDownloadRemoteBackup={props.onDownloadRemoteBackup}
onInspectRemoteBackup={props.onInspectRemoteBackup}
onDeleteRemoteBackup={props.onDeleteRemoteBackup}
onRestoreRemoteBackup={props.onRestoreRemoteBackup}
onRestoreRemoteBackupAllowingChecksumMismatch={props.onRestoreRemoteBackupAllowingChecksumMismatch}
onSaveSettings={props.onSaveBackupSettings}
onRunRemoteBackup={props.onRunRemoteBackup}
onNotify={props.onNotify}
+414 -19
View File
@@ -1,12 +1,15 @@
import { createPortal } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks';
import ConfirmDialog from '@/components/ConfirmDialog';
import {
type AdminBackupImportResponse,
type AdminBackupRunResponse,
type AdminBackupSettings,
type BackupFileIntegrityCheckResult,
type BackupDestinationRecord,
type BackupDestinationType,
type RemoteBackupBrowserResponse,
verifyBackupFileIntegrity,
} from '@/lib/api/backup';
import {
REMOTE_BROWSER_ITEMS_PER_PAGE,
@@ -22,6 +25,7 @@ import {
loadPersistedRemoteBrowserState,
persistRemoteBrowserState,
} from '@/lib/backup-center';
import { BACKUP_PROGRESS_EVENT, type BackupProgressDetail, type BackupProgressOperation } from '@/lib/backup-restore-progress';
import { RECOMMENDED_PROVIDERS, type RecommendedProvider } from '@/lib/backup-recommendations';
import { t } from '@/lib/i18n';
import { BackupDestinationDetail } from './backup-center/BackupDestinationDetail';
@@ -32,16 +36,82 @@ interface BackupCenterPageProps {
currentUserId: string | null;
onExport: (includeAttachments?: boolean) => Promise<void>;
onImport: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onImportAllowingChecksumMismatch: (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>;
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>;
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
}
type PendingRestoreIntegrity =
| { source: 'local'; fileName: string; result: BackupFileIntegrityCheckResult }
| { source: 'remote'; fileName: string; path: string; result: BackupFileIntegrityCheckResult };
interface BackupProgressPhase {
titleKey: string;
detailKey: string;
}
interface BackupProgressState {
operation: BackupProgressOperation;
source: 'local' | 'remote' | null;
includeAttachments: boolean;
fileLabel: string;
startedAt: number;
phaseIndex: number;
phases: BackupProgressPhase[];
currentTitleKey: string;
currentDetailKey: string;
}
const LOCAL_RESTORE_PHASES: BackupProgressPhase[] = [
{ titleKey: 'txt_backup_restore_progress_local_upload_title', detailKey: 'txt_backup_restore_progress_local_upload_detail' },
{ titleKey: 'txt_backup_restore_progress_local_shadow_title', detailKey: 'txt_backup_restore_progress_local_shadow_detail' },
{ titleKey: 'txt_backup_restore_progress_local_data_title', detailKey: 'txt_backup_restore_progress_local_data_detail' },
{ titleKey: 'txt_backup_restore_progress_local_files_title', detailKey: 'txt_backup_restore_progress_local_files_detail' },
{ titleKey: 'txt_backup_restore_progress_local_finalize_title', detailKey: 'txt_backup_restore_progress_local_finalize_detail' },
];
const REMOTE_RESTORE_PHASES: BackupProgressPhase[] = [
{ titleKey: 'txt_backup_restore_progress_remote_fetch_title', detailKey: 'txt_backup_restore_progress_remote_fetch_detail' },
{ titleKey: 'txt_backup_restore_progress_remote_shadow_title', detailKey: 'txt_backup_restore_progress_remote_shadow_detail' },
{ titleKey: 'txt_backup_restore_progress_remote_data_title', detailKey: 'txt_backup_restore_progress_remote_data_detail' },
{ titleKey: 'txt_backup_restore_progress_remote_files_title', detailKey: 'txt_backup_restore_progress_remote_files_detail' },
{ titleKey: 'txt_backup_restore_progress_remote_finalize_title', detailKey: 'txt_backup_restore_progress_remote_finalize_detail' },
];
const EXPORT_PROGRESS_PHASES: BackupProgressPhase[] = [
{ titleKey: 'txt_backup_archive_progress_collect_title', detailKey: 'txt_backup_archive_progress_collect_detail' },
{ titleKey: 'txt_backup_archive_progress_package_title', detailKey: 'txt_backup_archive_progress_package_detail' },
{ titleKey: 'txt_backup_archive_progress_ready_title', detailKey: 'txt_backup_archive_progress_ready_detail' },
{ titleKey: 'txt_backup_export_progress_save_title', detailKey: 'txt_backup_export_progress_save_detail' },
];
const EXPORT_WITH_ATTACHMENTS_PROGRESS_PHASES: BackupProgressPhase[] = [
{ titleKey: 'txt_backup_archive_progress_collect_title', detailKey: 'txt_backup_archive_progress_collect_with_attachments_detail' },
{ titleKey: 'txt_backup_archive_progress_package_title', detailKey: 'txt_backup_archive_progress_package_with_attachments_detail' },
{ titleKey: 'txt_backup_archive_progress_ready_title', detailKey: 'txt_backup_archive_progress_ready_detail' },
{ titleKey: 'txt_backup_export_progress_fetch_attachments_title', detailKey: 'txt_backup_export_progress_fetch_attachments_detail' },
{ titleKey: 'txt_backup_export_progress_rebuild_title', detailKey: 'txt_backup_export_progress_rebuild_detail' },
{ titleKey: 'txt_backup_export_progress_save_title', detailKey: 'txt_backup_export_progress_save_detail' },
];
const REMOTE_RUN_PROGRESS_PHASES: BackupProgressPhase[] = [
{ titleKey: 'txt_backup_remote_run_progress_prepare_title', detailKey: 'txt_backup_remote_run_progress_prepare_detail' },
{ titleKey: 'txt_backup_archive_progress_collect_title', detailKey: 'txt_backup_archive_progress_collect_with_attachments_detail' },
{ titleKey: 'txt_backup_archive_progress_package_title', detailKey: 'txt_backup_archive_progress_package_with_attachments_detail' },
{ titleKey: 'txt_backup_remote_run_progress_sync_attachments_title', detailKey: 'txt_backup_remote_run_progress_sync_attachments_detail' },
{ titleKey: 'txt_backup_remote_run_progress_upload_title', detailKey: 'txt_backup_remote_run_progress_upload_detail' },
{ titleKey: 'txt_backup_remote_run_progress_verify_title', detailKey: 'txt_backup_remote_run_progress_verify_detail' },
{ titleKey: 'txt_backup_remote_run_progress_cleanup_title', detailKey: 'txt_backup_remote_run_progress_cleanup_detail' },
];
function buildSkippedImportMessage(result: AdminBackupImportResponse): string | null {
const skipped = result.skipped;
if (!skipped || !skipped.attachments) return null;
@@ -51,10 +121,56 @@ function buildSkippedImportMessage(result: AdminBackupImportResponse): string |
});
}
function buildIntegrityStatusMessage(result: BackupFileIntegrityCheckResult, options?: { remote?: boolean }): string {
if (!result.hasChecksumPrefix) {
return t(options?.remote ? 'txt_backup_remote_restore_completed_without_checksum' : 'txt_backup_restore_completed_without_checksum');
}
return t(options?.remote ? 'txt_backup_remote_restore_completed_verified' : 'txt_backup_restore_completed_verified');
}
function buildIntegrityWarningMessage(entry: PendingRestoreIntegrity): string {
if (entry.source === 'remote') {
return t('txt_backup_remote_restore_checksum_warning_message', {
name: entry.fileName,
expected: entry.result.expectedPrefix || '-----',
actual: entry.result.actualPrefix,
});
}
return t('txt_backup_restore_checksum_warning_message', {
name: entry.fileName,
expected: entry.result.expectedPrefix || '-----',
actual: entry.result.actualPrefix,
});
}
function getBackupProgressPhases(
operation: BackupProgressOperation,
source: 'local' | 'remote' | null,
includeAttachments: boolean
): BackupProgressPhase[] {
if (operation === 'backup-restore') {
return source === 'remote' ? REMOTE_RESTORE_PHASES : LOCAL_RESTORE_PHASES;
}
if (operation === 'backup-export') {
return includeAttachments ? EXPORT_WITH_ATTACHMENTS_PROGRESS_PHASES : EXPORT_PROGRESS_PHASES;
}
return REMOTE_RUN_PROGRESS_PHASES;
}
function getBackupProgressTitleKey(state: BackupProgressState): string {
if (state.operation === 'backup-export') return 'txt_backup_export_progress_title';
if (state.operation === 'backup-remote-run') return 'txt_backup_remote_run_progress_title';
return state.source === 'remote'
? 'txt_backup_restore_progress_remote_title'
: 'txt_backup_restore_progress_local_title';
}
export default function BackupCenterPage(props: BackupCenterPageProps) {
const persistedRemoteStateRef = useRef(loadPersistedRemoteBrowserState(props.currentUserId));
const persistedRemoteState = persistedRemoteStateRef.current;
const fileInputRef = useRef<HTMLInputElement | null>(null);
const restoreProgressTimerRef = useRef<number | null>(null);
const restoreProgressPendingRef = useRef<BackupProgressState | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [exporting, setExporting] = useState(false);
@@ -67,14 +183,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
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 [restoreProgress, setRestoreProgress] = useState<BackupProgressState | null>(null);
const [restoreElapsedSeconds, setRestoreElapsedSeconds] = useState(0);
const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false);
const [confirmReplaceOpen, setConfirmReplaceOpen] = useState(false);
const [confirmRemoteReplaceOpen, setConfirmRemoteReplaceOpen] = useState(false);
const [confirmIntegrityWarningOpen, setConfirmIntegrityWarningOpen] = useState(false);
const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false);
const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false);
const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null);
const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState('');
const [savedSettings, setSavedSettings] = useState<AdminBackupSettings | null>(null);
@@ -148,6 +267,59 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
});
}, [props.currentUserId, remoteBrowserCache, remoteBrowserPageByKey, remoteBrowserPathByDestination, selectedDestinationId]);
useEffect(() => {
if (!restoreProgress) {
setRestoreElapsedSeconds(0);
return;
}
setRestoreElapsedSeconds(Math.max(0, Math.floor((Date.now() - restoreProgress.startedAt) / 1000)));
const tickTimer = window.setInterval(() => {
setRestoreElapsedSeconds(Math.max(0, Math.floor((Date.now() - restoreProgress.startedAt) / 1000)));
}, 1000);
return () => window.clearInterval(tickTimer);
}, [restoreProgress]);
useEffect(() => {
const handleProgress = (event: Event) => {
const detail = (event as CustomEvent<BackupProgressDetail>).detail;
if (!detail) return;
const pending = restoreProgressPendingRef.current;
const operation = detail.operation || pending?.operation || 'backup-restore';
const source = (detail.source || pending?.source || null) as 'local' | 'remote' | null;
const includeAttachments = pending?.includeAttachments || false;
const phases = getBackupProgressPhases(operation, source, includeAttachments);
const matchedPhaseIndex = phases.findIndex((phase) => phase.titleKey === detail.stageTitle);
const phaseIndex = matchedPhaseIndex >= 0 ? matchedPhaseIndex : 0;
const nextState: BackupProgressState = {
operation,
source,
includeAttachments,
fileLabel: detail.fileName || pending?.fileLabel || '',
startedAt: pending?.operation === operation
? pending.startedAt
: Date.now(),
phaseIndex,
phases,
currentTitleKey: detail.stageTitle || phases[Math.max(0, phaseIndex)].titleKey,
currentDetailKey: detail.stageDetail || phases[Math.max(0, phaseIndex)].detailKey,
};
restoreProgressPendingRef.current = nextState;
if (restoreProgressTimerRef.current === null) {
setRestoreProgress(nextState);
}
if (detail.done) {
window.setTimeout(() => {
setRestoreProgress((current) => (
current && current.fileLabel === (detail.fileName || current.fileLabel) ? null : current
));
setRestoreElapsedSeconds(0);
}, detail.ok === false ? 1200 : 900);
}
};
window.addEventListener(BACKUP_PROGRESS_EVENT, handleProgress as EventListener);
return () => window.removeEventListener(BACKUP_PROGRESS_EVENT, handleProgress as EventListener);
}, []);
function updateSettings(mutator: (current: AdminBackupSettings) => AdminBackupSettings) {
setSettings((current) => {
const next = mutator(current);
@@ -225,6 +397,67 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
if (fileInputRef.current) fileInputRef.current.value = '';
}
function resetPendingIntegrityWarning() {
setPendingRestoreIntegrity(null);
setConfirmIntegrityWarningOpen(false);
}
function startRestoreProgress(
operation: BackupProgressOperation,
fileLabel: string,
options?: { source?: 'local' | 'remote' | null; includeAttachments?: boolean; delayMs?: number }
) {
if (restoreProgressTimerRef.current !== null) {
window.clearTimeout(restoreProgressTimerRef.current);
restoreProgressTimerRef.current = null;
}
setRestoreElapsedSeconds(0);
const source = options?.source || null;
const includeAttachments = !!options?.includeAttachments;
const phases = getBackupProgressPhases(operation, source, includeAttachments);
restoreProgressPendingRef.current = {
operation,
source,
includeAttachments,
fileLabel,
startedAt: Date.now(),
phaseIndex: 0,
phases,
currentTitleKey: phases[0].titleKey,
currentDetailKey: phases[0].detailKey,
};
restoreProgressTimerRef.current = window.setTimeout(() => {
restoreProgressTimerRef.current = null;
if (!restoreProgressPendingRef.current) return;
setRestoreProgress(restoreProgressPendingRef.current);
}, options?.delayMs ?? 480);
}
function clearRestoreProgress() {
if (restoreProgressTimerRef.current !== null) {
window.clearTimeout(restoreProgressTimerRef.current);
restoreProgressTimerRef.current = null;
}
restoreProgressPendingRef.current = null;
setRestoreProgress(null);
setRestoreElapsedSeconds(0);
}
async function inspectLocalBackupFile(file: File): Promise<BackupFileIntegrityCheckResult> {
const bytes = new Uint8Array(await file.arrayBuffer());
return verifyBackupFileIntegrity(bytes, file.name || '');
}
async function inspectRemoteBackupFile(destinationId: string, path: string): Promise<PendingRestoreIntegrity> {
const payload = await props.onInspectRemoteBackup(destinationId, path);
return {
source: 'remote',
path,
fileName: payload.fileName || path.split('/').pop() || path,
result: payload.integrity,
};
}
function handleAddDestination(type: BackupDestinationType) {
updateSettings((current) => {
const nextDestination = createDraftDestinationRecord(type, current.destinations.filter((destination) => destination.type === type).length + 1);
@@ -277,18 +510,24 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
setLocalError('');
setExporting(true);
try {
startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments });
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);
window.setTimeout(() => clearRestoreProgress(), 1200);
} finally {
setExporting(false);
}
}
async function runLocalRestore(replaceExisting: boolean) {
async function runLocalRestore(
replaceExisting: boolean,
allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult
) {
if (!selectedFile) {
const message = t('txt_backup_file_required');
setLocalError(message);
@@ -296,17 +535,29 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
return;
}
setLocalError('');
setConfirmLocalRestoreOpen(false);
setConfirmReplaceOpen(false);
setConfirmIntegrityWarningOpen(false);
setImporting(true);
try {
const result = await props.onImport(selectedFile, replaceExisting);
props.onNotify('success', t('txt_backup_restore_success_relogin'));
const integrity = knownIntegrity || await inspectLocalBackupFile(selectedFile);
startRestoreProgress('backup-restore', selectedFile.name || t('txt_backup_import'), {
source: 'local',
delayMs: replaceExisting ? 480 : 1400,
});
const result = allowChecksumMismatch
? await props.onImportAllowingChecksumMismatch(selectedFile, replaceExisting)
: await props.onImport(selectedFile, replaceExisting);
props.onNotify('success', `${buildIntegrityStatusMessage(integrity)} ${t('txt_backup_restore_success_relogin')}`);
const skippedMessage = buildSkippedImportMessage(result);
if (skippedMessage) props.onNotify('warning', skippedMessage);
resetSelectedFile();
setConfirmLocalRestoreOpen(false);
setConfirmReplaceOpen(false);
resetPendingIntegrityWarning();
} catch (error) {
if (!replaceExisting && isReplaceRequiredError(error)) {
clearRestoreProgress();
setConfirmLocalRestoreOpen(false);
setConfirmReplaceOpen(true);
return;
@@ -314,6 +565,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const message = error instanceof Error ? error.message : t('txt_backup_restore_failed');
setLocalError(message);
props.onNotify('error', message);
window.setTimeout(() => clearRestoreProgress(), 1200);
} finally {
setImporting(false);
}
@@ -364,16 +616,21 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
setRunningRemoteBackup(true);
setLocalError('');
try {
startRestoreProgress('backup-remote-run', selectedDestination.name || t('txt_backup_run_now'), {
source: 'remote',
includeAttachments: !!selectedDestination.includeAttachments,
});
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'));
props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.fileName }));
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed');
setLocalError(message);
props.onNotify('error', message);
window.setTimeout(() => clearRestoreProgress(), 1200);
} finally {
setRunningRemoteBackup(false);
}
@@ -415,30 +672,88 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
}
}
async function runRemoteRestore(path: string, replaceExisting: boolean) {
async function handleSelectedLocalFile(nextFile: File | null) {
setSelectedFile(nextFile);
setLocalError('');
resetPendingIntegrityWarning();
setConfirmLocalRestoreOpen(false);
if (!nextFile) return;
try {
const integrity = await inspectLocalBackupFile(nextFile);
if (!integrity.matches) {
setPendingRestoreIntegrity({
source: 'local',
fileName: nextFile.name,
result: integrity,
});
setConfirmIntegrityWarningOpen(true);
return;
}
setConfirmLocalRestoreOpen(true);
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_integrity_check_failed');
setLocalError(message);
props.onNotify('error', message);
}
}
async function handlePromptRemoteRestore(path: string) {
if (!savedSelectedDestination) return;
setLocalError('');
resetPendingIntegrityWarning();
try {
const integrity = await inspectRemoteBackupFile(savedSelectedDestination.id, path);
if (!integrity.result.matches) {
setPendingRestoreIntegrity(integrity);
setConfirmIntegrityWarningOpen(true);
return;
}
await runRemoteRestore(path, false, false, integrity.result);
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_integrity_check_failed');
setLocalError(message);
props.onNotify('error', message);
}
}
async function runRemoteRestore(
path: string,
replaceExisting: boolean,
allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult
) {
if (!savedSelectedDestination) return;
setConfirmRemoteReplaceOpen(false);
setConfirmIntegrityWarningOpen(false);
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);
const integrity = knownIntegrity ? { result: knownIntegrity } : await inspectRemoteBackupFile(savedSelectedDestination.id, path);
startRestoreProgress('backup-restore', path.split('/').pop() || path, {
source: 'remote',
delayMs: replaceExisting ? 480 : 1400,
});
const result = allowChecksumMismatch
? await props.onRestoreRemoteBackupAllowingChecksumMismatch(savedSelectedDestination.id, path, replaceExisting)
: await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting);
setConfirmRemoteReplaceOpen(false);
setPendingRemoteRestorePath('');
setRemoteRestoreStatusText('');
props.onNotify('success', t('txt_backup_restore_success_relogin'));
props.onNotify('success', `${buildIntegrityStatusMessage(integrity.result, { remote: true })} ${t('txt_backup_restore_success_relogin')}`);
const skippedMessage = buildSkippedImportMessage(result);
if (skippedMessage) props.onNotify('warning', skippedMessage);
resetPendingIntegrityWarning();
} catch (error) {
if (!replaceExisting && isReplaceRequiredError(error)) {
setPendingRemoteRestorePath(path);
setConfirmRemoteReplaceOpen(true);
setRemoteRestoreStatusText('');
clearRestoreProgress();
return;
}
const message = error instanceof Error ? error.message : t('txt_backup_remote_restore_failed');
setRemoteRestoreStatusText('');
setLocalError(message);
props.onNotify('error', message);
window.setTimeout(() => clearRestoreProgress(), 1200);
} finally {
setRestoringRemotePath('');
}
@@ -454,9 +769,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
disabled={disableWhileBusy}
onChange={(event) => {
const nextFile = (event.currentTarget as HTMLInputElement).files?.[0] || null;
setSelectedFile(nextFile);
setLocalError('');
if (nextFile) setConfirmLocalRestoreOpen(true);
void handleSelectedLocalFile(nextFile);
}}
/>
@@ -521,7 +834,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
if (savedSelectedDestination) showRemoteBrowserPath(savedSelectedDestination.id, path);
}}
onDownloadRemoteBackup={(path) => void handleDownloadRemote(path)}
onRestoreRemoteBackup={(path) => void runRemoteRestore(path, false)}
onRestoreRemoteBackup={(path) => void handlePromptRemoteRestore(path)}
onPromptDeleteRemoteBackup={(path) => {
setPendingRemoteDeletePath(path);
setConfirmRemoteDeleteOpen(true);
@@ -533,7 +846,49 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
/>
{localError ? <div className="local-error">{localError}</div> : null}
{!localError && remoteRestoreStatusText ? <div className="status-ok">{remoteRestoreStatusText}</div> : null}
{restoreProgress && typeof document !== 'undefined' ? createPortal((
<div className="restore-progress-overlay" aria-live="polite">
<section className="restore-progress-card restore-progress-modal">
<div className="restore-progress-head">
<div>
<div className="restore-progress-kicker">{t('txt_backup_progress_kicker')}</div>
<h3 className="restore-progress-title">
{t(getBackupProgressTitleKey(restoreProgress))}
</h3>
<p className="restore-progress-subtitle">
{t('txt_backup_progress_subject', { name: restoreProgress.fileLabel })}
</p>
</div>
<div className="restore-progress-elapsed">
{t('txt_backup_restore_progress_elapsed', { seconds: String(restoreElapsedSeconds) })}
</div>
</div>
<div className="restore-progress-meter">
<span
className="restore-progress-meter-bar"
style={{
width: `${((restoreProgress.phaseIndex + 1) / restoreProgress.phases.length) * 100}%`,
}}
/>
</div>
<div className="restore-progress-current">
<strong>{t(restoreProgress.currentTitleKey)}</strong>
<p>{t(restoreProgress.currentDetailKey)}</p>
</div>
<ol className="restore-progress-list">
{restoreProgress.phases.map((phase, index) => {
const status = index < restoreProgress.phaseIndex ? 'done' : index === restoreProgress.phaseIndex ? 'active' : 'pending';
return (
<li key={phase.titleKey} className={`restore-progress-item ${status}`}>
<span className="restore-progress-dot" />
<span className="restore-progress-item-text">{t(phase.titleKey)}</span>
</li>
);
})}
</ol>
</section>
</div>
), document.body) : null}
<ConfirmDialog
open={confirmLocalRestoreOpen}
@@ -546,6 +901,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
onCancel={() => {
setConfirmLocalRestoreOpen(false);
resetSelectedFile();
resetPendingIntegrityWarning();
}}
/>
@@ -558,11 +914,16 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
confirmDisabled={importing}
cancelDisabled={importing}
danger
onConfirm={() => void runLocalRestore(true)}
onConfirm={() => void runLocalRestore(
true,
pendingRestoreIntegrity?.source === 'local',
pendingRestoreIntegrity?.source === 'local' ? pendingRestoreIntegrity.result : undefined
)}
onCancel={() => {
if (importing) return;
setConfirmReplaceOpen(false);
resetSelectedFile();
resetPendingIntegrityWarning();
}}
/>
@@ -575,11 +936,45 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
confirmDisabled={!!restoringRemotePath}
cancelDisabled={!!restoringRemotePath}
danger
onConfirm={() => void runRemoteRestore(pendingRemoteRestorePath, true)}
onConfirm={() => void runRemoteRestore(
pendingRemoteRestorePath,
true,
pendingRestoreIntegrity?.source === 'remote' && pendingRestoreIntegrity.path === pendingRemoteRestorePath,
pendingRestoreIntegrity?.source === 'remote' && pendingRestoreIntegrity.path === pendingRemoteRestorePath
? pendingRestoreIntegrity.result
: undefined
)}
onCancel={() => {
if (restoringRemotePath) return;
setConfirmRemoteReplaceOpen(false);
setPendingRemoteRestorePath('');
resetPendingIntegrityWarning();
}}
/>
<ConfirmDialog
open={confirmIntegrityWarningOpen}
title={t('txt_backup_restore_checksum_warning_title')}
message={pendingRestoreIntegrity ? buildIntegrityWarningMessage(pendingRestoreIntegrity) : t('txt_backup_restore_checksum_warning_message_fallback')}
variant="warning"
confirmText={t('txt_backup_restore_checksum_warning_confirm')}
cancelText={t('txt_cancel')}
danger
onConfirm={() => {
if (!pendingRestoreIntegrity) return;
setConfirmIntegrityWarningOpen(false);
if (pendingRestoreIntegrity.source === 'local') {
void runLocalRestore(false, true, pendingRestoreIntegrity.result);
return;
}
void runRemoteRestore(pendingRestoreIntegrity.path, false, true, pendingRestoreIntegrity.result);
}}
onCancel={() => {
if (importing || restoringRemotePath) return;
resetPendingIntegrityWarning();
setPendingRemoteRestorePath('');
setConfirmLocalRestoreOpen(false);
resetSelectedFile();
}}
/>
+91 -10
View File
@@ -1,11 +1,14 @@
import { createPortal } from 'preact/compat';
import { useEffect, useState } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
import { Check, X } from 'lucide-preact';
import { TriangleAlert } from 'lucide-preact';
import { t } from '@/lib/i18n';
interface ConfirmDialogProps {
open: boolean;
title: string;
message: string;
variant?: 'default' | 'warning';
showIcon?: boolean;
confirmText?: string;
cancelText?: string;
@@ -19,27 +22,106 @@ interface ConfirmDialogProps {
afterActions?: ComponentChildren;
}
function incrementDialogBodyLock() {
if (typeof document === 'undefined') return;
const body = document.body;
const nextCount = Number(body.dataset.dialogCount || '0') + 1;
body.dataset.dialogCount = String(nextCount);
body.classList.add('dialog-open');
}
function decrementDialogBodyLock() {
if (typeof document === 'undefined') return;
const body = document.body;
const nextCount = Math.max(0, Number(body.dataset.dialogCount || '0') - 1);
if (nextCount === 0) {
delete body.dataset.dialogCount;
body.classList.remove('dialog-open');
return;
}
body.dataset.dialogCount = String(nextCount);
}
export function useDialogLifecycle(active: boolean, onCancel?: (() => void) | null) {
useEffect(() => {
if (!active) return;
incrementDialogBodyLock();
return () => decrementDialogBodyLock();
}, [active]);
useEffect(() => {
if (!active || !onCancel || typeof window === 'undefined') return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.preventDefault();
onCancel();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [active, onCancel]);
}
export default function ConfirmDialog(props: ConfirmDialogProps) {
if (!props.open) return null;
return (
<div className="dialog-mask">
const [present, setPresent] = useState(props.open);
const [closing, setClosing] = useState(false);
const canDismiss = !props.cancelDisabled && !closing && !props.hideCancel;
useEffect(() => {
if (props.open) {
setPresent(true);
setClosing(false);
return;
}
if (!present) return;
setClosing(true);
const timer = window.setTimeout(() => {
setPresent(false);
setClosing(false);
}, 240);
return () => window.clearTimeout(timer);
}, [props.open, present]);
useDialogLifecycle(present, canDismiss ? props.onCancel : null);
if (!present || typeof document === 'undefined') return null;
return createPortal((
<div
className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
onClick={(event) => {
if (event.target !== event.currentTarget || !canDismiss) return;
props.onCancel();
}}
>
<form
className="dialog-card"
className={`dialog-card ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
role="dialog"
aria-modal="true"
aria-label={props.title}
onSubmit={(e) => {
e.preventDefault();
if (props.confirmDisabled) return;
if (props.confirmDisabled || closing) return;
props.onConfirm();
}}
>
{props.variant === 'warning' ? (
<>
<div className="dialog-warning-strip" aria-hidden="true" />
<div className="dialog-warning-head">
<div className="dialog-warning-badge" aria-hidden="true">
<TriangleAlert size={24} />
</div>
<div className="dialog-warning-kicker">{t('txt_warning')}</div>
</div>
</>
) : null}
<h3 className="dialog-title">{props.title}</h3>
<div className="dialog-message">{props.message}</div>
<div className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{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 && (
@@ -52,12 +134,11 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
props.onCancel();
}}
>
<X size={14} className="btn-icon" />
{props.cancelText || t('txt_no')}
</button>
)}
{props.afterActions}
</form>
</div>
);
), document.body);
}
+14 -5
View File
@@ -1,9 +1,10 @@
import { useState } from 'preact/hooks';
import { argon2idAsync } from '@noble/hashes/argon2.js';
import { createPortal } from 'preact/compat';
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 ConfirmDialog, { useDialogLifecycle } from '@/components/ConfirmDialog';
import type { CiphersImportPayload } from '@/lib/api/vault';
import {
type EncryptedJsonMode,
@@ -311,6 +312,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
const [exportAuthDialogOpen, setExportAuthDialogOpen] = useState(false);
const [exportAuthPassword, setExportAuthPassword] = useState('');
const [importSummary, setImportSummary] = useState<ImportResultSummary | null>(null);
useDialogLifecycle(!!importSummary, importSummary ? () => setImportSummary(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));
@@ -803,9 +806,15 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
</label>
</ConfirmDialog>
{importSummary && (
<div className="dialog-mask">
<section className="dialog-card import-summary-dialog">
{importSummary && typeof document !== 'undefined' ? createPortal((
<div
className="dialog-mask"
onClick={(event) => {
if (event.target !== event.currentTarget) return;
setImportSummary(null);
}}
>
<section className="dialog-card import-summary-dialog" role="dialog" aria-modal="true" aria-label={t('txt_import_success')}>
<button
type="button"
className="import-summary-close"
@@ -866,7 +875,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
</button>
</section>
</div>
)}
), document.body) : null}
</div>
);
}
+44 -15
View File
@@ -62,6 +62,10 @@ function draftFromSend(send: Send): SendDraft {
}
export default function SendsPage(props: SendsPageProps) {
const getInitialIsMobileLayout = () =>
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia(MOBILE_LAYOUT_QUERY).matches
: false;
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<SendTypeFilter>('all');
const [selectedId, setSelectedId] = useState<string | null>(null);
@@ -71,7 +75,7 @@ export default function SendsPage(props: SendsPageProps) {
const [draft, setDraft] = useState<SendDraft | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
const [isMobileLayout, setIsMobileLayout] = useState(false);
const [isMobileLayout, setIsMobileLayout] = useState(getInitialIsMobileLayout);
const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list');
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [autoCopyLink, setAutoCopyLink] = useState<boolean>(() => {
@@ -226,7 +230,15 @@ export default function SendsPage(props: SendsPageProps) {
return (
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
{isMobileLayout && mobileSidebarOpen && <div className="mobile-sidebar-mask" onClick={() => setMobileSidebarOpen(false)} />}
{isMobileLayout && (
<div
className={`mobile-sidebar-mask ${mobileSidebarOpen ? 'open' : ''}`}
onClick={() => {
if (!mobileSidebarOpen) return;
setMobileSidebarOpen(false);
}}
/>
)}
<aside className={`sidebar ${isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${isMobileLayout && mobileSidebarOpen ? 'open' : ''}`}>
{isMobileLayout && (
<div className="mobile-sidebar-head">
@@ -310,12 +322,27 @@ export default function SendsPage(props: SendsPageProps) {
</button>
</div>
<div className="list-panel">
{filteredSends.map((send) => (
<div key={send.id} className={`list-item ${selectedId === send.id ? 'active' : ''}`}>
{filteredSends.map((send, index) => (
<div
key={send.id}
className={`list-item stagger-item ${selectedId === send.id ? 'active' : ''}`}
style={{ animationDelay: `${Math.min(index, 10) * 26}ms` }}
onClick={(event) => {
const target = event.target as HTMLElement;
if (target.closest('.row-check')) return;
setSelectedId(send.id);
setIsEditing(false);
setIsCreating(false);
setDraft(null);
if (isMobileLayout) setMobilePanel('detail');
setMobileSidebarOpen(false);
}}
>
<input
type="checkbox"
className="row-check"
checked={!!selectedMap[send.id]}
onClick={(event) => event.stopPropagation()}
onInput={(e) =>
setSelectedMap((prev) => ({
...prev,
@@ -377,10 +404,11 @@ export default function SendsPage(props: SendsPageProps) {
</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">
<div key={`send-editor-${draft.id || selectedSend?.id || 'new'}-${draft.type}`} className="detail-switch-stage">
<div className="card stagger-item" style={{ animationDelay: '0ms' }}>
<h3 className="detail-title">{isCreating ? t('txt_new_send') : t('txt_edit_send')}</h3>
{!!props.uploadingSendFileName && <div className="detail-sub">{sendUploadLabel}</div>}
<div className="field-grid">
<label className="field field-span-2">
<span>{t('txt_name')}</span>
<input className="input" value={draft.name} onInput={(e) => setDraft({ ...draft, name: (e.currentTarget as HTMLInputElement).value })} />
@@ -451,8 +479,8 @@ export default function SendsPage(props: SendsPageProps) {
<label><input type="checkbox" checked={autoCopyLink} onInput={(e) => setAutoCopyLink((e.currentTarget as HTMLInputElement).checked)} /> {t('txt_auto_copy_link_after_save')}</label>
</div>
</label>
</div>
<div className="detail-actions">
</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>
@@ -470,18 +498,19 @@ export default function SendsPage(props: SendsPageProps) {
>
<X size={14} className="btn-icon" /> {t('txt_cancel')}
</button>
</div>
</div>
</div>
)}
{!isEditing && selectedSend && (
<>
<div className="card">
<div key={`send-detail-${selectedSend.id}`} className="detail-switch-stage">
<div className="card stagger-item" style={{ animationDelay: '36ms' }}>
<h3 className="detail-title">{selectedSend.decName || t('txt_no_name')}</h3>
<div className="detail-sub">{Number(selectedSend.type) === 1 ? t('txt_file_send') : t('txt_text_send')}</div>
</div>
<div className="card">
<div className="card stagger-item" style={{ animationDelay: '72ms' }}>
<h4>{t('txt_send_details')}</h4>
<div className="kv-line"><span>{t('txt_access_count')}</span><strong>{selectedSend.accessCount || 0}</strong></div>
<div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{selectedSend.deletionDate || t('txt_dash')}</strong></div>
@@ -504,7 +533,7 @@ export default function SendsPage(props: SendsPageProps) {
</div>
{!!(selectedSend.decNotes || '').trim() && (
<div className="card">
<div className="card stagger-item" style={{ animationDelay: '108ms' }}>
<h4>{t('txt_notes')}</h4>
<div className="notes">{selectedSend.decNotes || ''}</div>
</div>
@@ -523,7 +552,7 @@ export default function SendsPage(props: SendsPageProps) {
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
</button>
</div>
</>
</div>
)}
</section>
</div>
+29
View File
@@ -0,0 +1,29 @@
interface ThemeSwitchProps {
checked: boolean;
title: string;
onToggle: () => void;
}
export default function ThemeSwitch(props: ThemeSwitchProps) {
return (
<div className="theme-switch-wrap" title={props.title}>
<label className="theme-switch" aria-label={props.title}>
<span className="sun" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g fill="#ffd43b">
<circle r={5} cy={12} cx={12} />
<path d="m21 13h-1a1 1 0 0 1 0-2h1a1 1 0 0 1 0 2zm-17 0h-1a1 1 0 0 1 0-2h1a1 1 0 0 1 0 2zm13.66-5.66a1 1 0 0 1 -.66-.29 1 1 0 0 1 0-1.41l.71-.71a1 1 0 1 1 1.41 1.41l-.71.71a1 1 0 0 1 -.75.29zm-12.02 12.02a1 1 0 0 1 -.71-.29 1 1 0 0 1 0-1.41l.71-.66a1 1 0 0 1 1.41 1.41l-.71.71a1 1 0 0 1 -.7.24zm6.36-14.36a1 1 0 0 1 -1-1v-1a1 1 0 0 1 2 0v1a1 1 0 0 1 -1 1zm0 17a1 1 0 0 1 -1-1v-1a1 1 0 0 1 2 0v1a1 1 0 0 1 -1 1zm-5.66-14.66a1 1 0 0 1 -.7-.29l-.71-.71a1 1 0 0 1 1.41-1.41l.71.71a1 1 0 0 1 0 1.41 1 1 0 0 1 -.71.29zm12.02 12.02a1 1 0 0 1 -.7-.29l-.66-.71a1 1 0 0 1 1.36-1.36l.71.71a1 1 0 0 1 0 1.41 1 1 0 0 1 -.71.24z" />
</g>
</svg>
</span>
<span className="moon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512">
<path d="m223.5 32c-123.5 0-223.5 100.3-223.5 224s100 224 223.5 224c60.6 0 115.5-24.2 155.8-63.4 5-4.9 6.3-12.5 3.1-18.7s-10.1-9.7-17-8.5c-9.8 1.7-19.8 2.6-30.1 2.6-96.9 0-175.5-78.8-175.5-176 0-65.8 36-123.1 89.3-153.3 6.1-3.5 9.2-10.5 7.7-17.3s-7.3-11.9-14.3-12.5c-6.3-.5-12.6-.8-19-.8z" />
</svg>
</span>
<input type="checkbox" className="theme-switch-input" checked={props.checked} onInput={props.onToggle} />
<span className="theme-switch-slider" />
</label>
</div>
);
}
+179 -55
View File
@@ -1,10 +1,26 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { Clipboard, Globe } from 'lucide-preact';
import { Clipboard, Globe, GripVertical } from 'lucide-preact';
import {
closestCenter,
DndContext,
type DragEndEvent,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
rectSortingStrategy,
SortableContext,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
import { calcTotpNow } from '@/lib/crypto';
import { t } from '@/lib/i18n';
import type { Cipher } from '@/lib/types';
import { websiteIconUrl } from '@/components/vault/vault-page-helpers';
import { isCipherVisibleInNormalVault, websiteIconUrl } from '@/components/vault/vault-page-helpers';
interface TotpCodesPageProps {
ciphers: Cipher[];
@@ -15,6 +31,7 @@ interface TotpCodesPageProps {
const TOTP_PERIOD_SECONDS = 30;
const TOTP_RING_RADIUS = 14;
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order';
const failedIconHosts = new Set<string>();
function formatTotp(code: string): string {
@@ -69,22 +86,117 @@ function TotpListIcon({ cipher }: { cipher: Cipher }) {
);
}
interface SortableTotpRowProps {
cipher: Cipher;
live: { code: string; remain: number } | null;
onCopy: (value: string) => void;
}
function SortableTotpRow(props: SortableTotpRowProps) {
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.cipher.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const name = props.cipher.decName || props.cipher.name || t('txt_no_name');
const username = props.cipher.login?.decUsername || '';
return (
<div ref={setNodeRef} style={style} className={`totp-code-row${isDragging ? ' is-dragging' : ''}`}>
<button
type="button"
ref={setActivatorNodeRef}
className="btn btn-secondary small totp-drag-btn"
title={t('txt_drag_to_reorder')}
aria-label={t('txt_drag_to_reorder')}
{...attributes}
{...listeners}
>
<GripVertical size={14} className="btn-icon" />
</button>
<div className="totp-code-info">
<div className="list-icon-wrap">
<TotpListIcon cipher={props.cipher} />
</div>
<div className="totp-code-meta">
<div className="totp-code-name" title={name}>{name}</div>
<div className="totp-code-username" title={username}>{username || t('txt_no_username')}</div>
</div>
</div>
<div className="totp-code-main">
<strong>{props.live ? formatTotp(props.live.code) : t('txt_text_3')}</strong>
<div
className="totp-timer"
title={t('txt_refresh_in_seconds_s', { seconds: props.live ? props.live.remain : 0 })}
aria-label={t('txt_refresh_in_seconds_s', { seconds: props.live ? props.live.remain : 0 })}
>
<svg viewBox="0 0 36 36" className="totp-ring" role="presentation" aria-hidden="true">
<circle className="totp-ring-track" cx="18" cy="18" r={TOTP_RING_RADIUS} />
<circle
className="totp-ring-progress"
cx="18"
cy="18"
r={TOTP_RING_RADIUS}
style={{
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
strokeDashoffset: String(
TOTP_RING_CIRCUMFERENCE -
TOTP_RING_CIRCUMFERENCE *
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.live?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
),
}}
/>
</svg>
<span className="totp-timer-value">{props.live ? props.live.remain : 0}</span>
</div>
<button type="button" className="btn btn-secondary small totp-copy-btn" onClick={() => props.onCopy(props.live?.code || '')} aria-label={t('txt_copy')}>
<Clipboard size={14} className="btn-icon" />
</button>
</div>
</div>
);
}
export default function TotpCodesPage(props: TotpCodesPageProps) {
const [totpMap, setTotpMap] = useState<Record<string, { code: string; remain: number } | null>>({});
const [columnCount, setColumnCount] = useState(1);
const [orderedIds, setOrderedIds] = useState<string[]>(() => {
if (typeof window === 'undefined') return [];
try {
const parsed = JSON.parse(String(window.localStorage.getItem(TOTP_ORDER_STORAGE_KEY) || '[]'));
return Array.isArray(parsed) ? parsed.map((id) => String(id || '').trim()).filter(Boolean) : [];
} catch {
return [];
}
});
const listRef = useRef<HTMLDivElement | null>(null);
const hasLoadedTotpItemsRef = useRef(false);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 6,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 120,
tolerance: 8,
},
}),
);
async function copyToClipboard(value: string): Promise<void> {
await copyTextWithFeedback(value, { successMessage: t('txt_code_copied') });
}
const totpItems = useMemo(
const baseTotpItems = useMemo(
() =>
props.ciphers
.filter((cipher) => {
const isDeleted = !!(cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt);
return !isDeleted && !!cipher.login?.decTotp;
})
.filter((cipher) => isCipherVisibleInNormalVault(cipher) && !!cipher.login?.decTotp)
.sort((a, b) => {
const nameA = (a.decName || a.name || '').trim().toLowerCase();
const nameB = (b.decName || b.name || '').trim().toLowerCase();
@@ -93,6 +205,44 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
[props.ciphers]
);
const totpItems = useMemo(() => {
if (!baseTotpItems.length) return [];
const orderMap = new Map(orderedIds.map((id, index) => [id, index]));
return [...baseTotpItems].sort((a, b) => {
const orderA = orderMap.get(a.id);
const orderB = orderMap.get(b.id);
if (orderA != null && orderB != null) return orderA - orderB;
if (orderA != null) return -1;
if (orderB != null) return 1;
const nameA = (a.decName || a.name || '').trim().toLowerCase();
const nameB = (b.decName || b.name || '').trim().toLowerCase();
return nameA.localeCompare(nameB);
});
}, [baseTotpItems, orderedIds]);
useEffect(() => {
if (!baseTotpItems.length) return;
hasLoadedTotpItemsRef.current = true;
const validIds = new Set(baseTotpItems.map((cipher) => cipher.id));
setOrderedIds((prev) => {
const filtered = prev.filter((id) => validIds.has(id));
const missing = baseTotpItems.map((cipher) => cipher.id).filter((id) => !filtered.includes(id));
const next = [...filtered, ...missing];
if (next.length === prev.length && next.every((id, index) => id === prev[index])) return prev;
return next;
});
}, [baseTotpItems]);
useEffect(() => {
if (typeof window === 'undefined') return;
if (!hasLoadedTotpItemsRef.current) return;
try {
window.localStorage.setItem(TOTP_ORDER_STORAGE_KEY, JSON.stringify(orderedIds));
} catch {
// ignore storage write failures
}
}, [orderedIds]);
useEffect(() => {
if (!totpItems.length) {
setTotpMap({});
@@ -142,6 +292,16 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
return () => observer.disconnect();
}, []);
const handleDragEnd = (event: DragEndEvent) => {
const activeId = String(event.active.id);
const overId = event.over ? String(event.over.id) : null;
if (!overId || activeId === overId) return;
const fromIndex = orderedIds.indexOf(activeId);
const toIndex = orderedIds.indexOf(overId);
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return;
setOrderedIds((prev) => arrayMove(prev, fromIndex, toIndex));
};
return (
<div className="totp-codes-page">
<div className="card">
@@ -154,54 +314,18 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
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>
);
})}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={totpItems.map((cipher) => cipher.id)} strategy={rectSortingStrategy}>
{totpItems.map((cipher) => (
<SortableTotpRow
key={cipher.id}
cipher={cipher}
live={totpMap[cipher.id] || null}
onCopy={(value) => void copyToClipboard(value)}
/>
))}
</SortableContext>
</DndContext>
</div>
</div>
</div>
+174 -56
View File
@@ -17,6 +17,9 @@ import {
buildCipherDuplicateSignature,
firstCipherUri,
firstPasskeyCreationTime,
isCipherVisibleInArchive,
isCipherVisibleInNormalVault,
isCipherVisibleInTrash,
sortTimeValue,
type SidebarFilter,
type VaultSortMode,
@@ -36,9 +39,13 @@ interface VaultPageProps {
onCreate: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
onUpdate: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>;
onDelete: (cipher: Cipher) => Promise<void>;
onArchive: (cipher: Cipher) => Promise<void>;
onUnarchive: (cipher: Cipher) => Promise<void>;
onBulkDelete: (ids: string[]) => Promise<void>;
onBulkPermanentDelete: (ids: string[]) => Promise<void>;
onBulkRestore: (ids: string[]) => Promise<void>;
onBulkArchive: (ids: string[]) => Promise<void>;
onBulkUnarchive: (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;
@@ -54,6 +61,10 @@ interface VaultPageProps {
export default function VaultPage(props: VaultPageProps) {
const getInitialIsMobileLayout = () =>
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia(MOBILE_LAYOUT_QUERY).matches
: false;
const [searchInput, setSearchInput] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [searchComposing, setSearchComposing] = useState(false);
@@ -72,7 +83,9 @@ export default function VaultPage(props: VaultPageProps) {
const [fieldLabel, setFieldLabel] = useState('');
const [fieldValue, setFieldValue] = useState('');
const [localError, setLocalError] = useState('');
const [pendingArchive, setPendingArchive] = useState<Cipher | null>(null);
const [pendingDelete, setPendingDelete] = useState<Cipher | null>(null);
const [bulkArchiveOpen, setBulkArchiveOpen] = useState(false);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [moveOpen, setMoveOpen] = useState(false);
const [moveFolderId, setMoveFolderId] = useState('__none__');
@@ -88,7 +101,7 @@ export default function VaultPage(props: VaultPageProps) {
const [repromptOpen, setRepromptOpen] = useState(false);
const [repromptPassword, setRepromptPassword] = useState('');
const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null);
const [isMobileLayout, setIsMobileLayout] = useState(false);
const [isMobileLayout, setIsMobileLayout] = useState(getInitialIsMobileLayout);
const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list');
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const createMenuRef = useRef<HTMLDivElement | null>(null);
@@ -229,8 +242,7 @@ export default function VaultPage(props: VaultPageProps) {
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;
if (!isCipherVisibleInNormalVault(cipher)) continue;
const signature = buildCipherDuplicateSignature(cipher);
counts.set(signature, (counts.get(signature) || 0) + 1);
}
@@ -239,11 +251,12 @@ export default function VaultPage(props: VaultPageProps) {
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;
if (!isCipherVisibleInTrash(cipher)) return false;
} else if (sidebarFilter.kind === 'archive') {
if (!isCipherVisibleInArchive(cipher)) return false;
} else {
if (isDeleted) return false;
if (!isCipherVisibleInNormalVault(cipher)) return false;
if (sidebarFilter.kind === 'duplicates' && (duplicateSignatureCounts.get(buildCipherDuplicateSignature(cipher)) || 0) < 2) {
return false;
}
@@ -494,7 +507,30 @@ function folderName(id: string | null | undefined): string {
setDraft((prev) => {
if (!prev) return prev;
const next = [...prev.loginUris];
next[index] = value;
next[index] = { ...(next[index] || { uri: '', match: null }), uri: value };
return { ...prev, loginUris: next };
});
}
function updateDraftLoginUriMatch(index: number, value: number | null): void {
setDraft((prev) => {
if (!prev) return prev;
const next = [...prev.loginUris];
next[index] = { ...(next[index] || { uri: '', match: null }), match: value };
return { ...prev, loginUris: next };
});
}
function reorderDraftLoginUri(fromIndex: number, toIndex: number): void {
setDraft((prev) => {
if (!prev) return prev;
if (fromIndex < 0 || toIndex < 0 || fromIndex >= prev.loginUris.length || toIndex >= prev.loginUris.length || fromIndex === toIndex) {
return prev;
}
const next = [...prev.loginUris];
const [moved] = next.splice(fromIndex, 1);
if (!moved) return prev;
next.splice(toIndex, 0, moved);
return { ...prev, loginUris: next };
});
}
@@ -677,6 +713,63 @@ function folderName(id: string | null | undefined): string {
}
}
async function confirmArchiveSelected(): Promise<void> {
if (!pendingArchive) return;
setBusy(true);
try {
await props.onArchive(pendingArchive);
setPendingArchive(null);
if (isMobileLayout && selectedCipherId === pendingArchive.id) {
setMobilePanel('list');
}
} finally {
setBusy(false);
}
}
async function handleUnarchiveSelected(cipher: Cipher): Promise<void> {
setBusy(true);
try {
await props.onBulkUnarchive([cipher.id]);
setSelectedMap((prev) => {
const next = { ...prev };
delete next[cipher.id];
return next;
});
} finally {
setBusy(false);
}
}
async function confirmBulkArchive(): Promise<void> {
const ids = Object.entries(selectedMap)
.filter(([, selected]) => selected)
.map(([id]) => id);
if (!ids.length) return;
setBusy(true);
try {
await props.onBulkArchive(ids);
setSelectedMap({});
setBulkArchiveOpen(false);
} finally {
setBusy(false);
}
}
async function confirmBulkUnarchive(): Promise<void> {
const ids = Object.entries(selectedMap)
.filter(([, selected]) => selected)
.map(([id]) => id);
if (!ids.length) return;
setBusy(true);
try {
await props.onBulkUnarchive(ids);
setSelectedMap({});
} finally {
setBusy(false);
}
}
async function confirmDeleteAllFolders(): Promise<void> {
if (!props.folders.length) return;
setBusy(true);
@@ -694,7 +787,15 @@ function folderName(id: string | null | undefined): string {
return (
<>
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
{isMobileLayout && mobileSidebarOpen && <div className="mobile-sidebar-mask" onClick={() => setMobileSidebarOpen(false)} />}
{isMobileLayout && (
<div
className={`mobile-sidebar-mask ${mobileSidebarOpen ? 'open' : ''}`}
onClick={() => {
if (!mobileSidebarOpen) return;
setMobileSidebarOpen(false);
}}
/>
)}
<VaultSidebar
folders={props.folders}
sidebarFilter={sidebarFilter}
@@ -727,6 +828,7 @@ function folderName(id: string | null | undefined): string {
sortMenuRef={sortMenuRef}
listPanelRef={listPanelRef}
onSearchInput={setSearchInput}
onClearSearch={() => setSearchInput('')}
onSearchCompositionStart={() => setSearchComposing(true)}
onSearchCompositionEnd={(value) => {
setSearchComposing(false);
@@ -760,6 +862,8 @@ function folderName(id: string | null | undefined): string {
onToggleCreateMenu={() => setCreateMenuOpen((open) => !open)}
onStartCreate={startCreate}
onBulkRestore={() => void confirmBulkRestore()}
onBulkArchive={() => setBulkArchiveOpen(true)}
onBulkUnarchive={() => void confirmBulkUnarchive()}
onOpenMove={() => {
setMoveFolderId('__none__');
setMoveOpen(true);
@@ -801,57 +905,65 @@ function folderName(id: string | null | undefined): string {
</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)}
/>
<div key={`editor-${draft.id || selectedCipher?.id || 'new'}-${draft.type}`} className="detail-switch-stage">
<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}
onUpdateDraftLoginUriMatch={updateDraftLoginUriMatch}
onReorderDraftLoginUri={reorderDraftLoginUri}
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)}
/>
</div>
)}
{!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}
/>
<div key={`detail-${selectedCipher.id}`} className="detail-switch-stage">
<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}
onArchive={(cipher) => setPendingArchive(cipher)}
onUnarchive={(cipher) => void handleUnarchiveSelected(cipher)}
/>
</div>
)}
{!isEditing && !selectedCipher && <div className="empty card">{t('txt_select_an_item')}</div>}
@@ -863,6 +975,8 @@ function folderName(id: string | null | undefined): string {
fieldType={fieldType}
fieldLabel={fieldLabel}
fieldValue={fieldValue}
archiveConfirmOpen={!!pendingArchive}
bulkArchiveOpen={bulkArchiveOpen}
pendingDeleteOpen={!!pendingDelete}
bulkDeleteOpen={bulkDeleteOpen}
sidebarTrashMode={sidebarFilter.kind === 'trash'}
@@ -905,6 +1019,10 @@ function folderName(id: string | null | undefined): string {
onFieldTypeChange={setFieldType}
onFieldLabelChange={setFieldLabel}
onFieldValueChange={setFieldValue}
onConfirmArchive={() => void confirmArchiveSelected()}
onCancelArchive={() => setPendingArchive(null)}
onConfirmBulkArchive={() => void confirmBulkArchive()}
onCancelBulkArchive={() => setBulkArchiveOpen(false)}
onConfirmDelete={() => void deleteSelected()}
onCancelDelete={() => setPendingDelete(null)}
onConfirmBulkDelete={() => void confirmBulkDelete()}
@@ -256,6 +256,23 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
</div>
</div>
</label>
<label className="field">
<span>{t('txt_backup_start_time')}</span>
<input
className="input"
type="time"
step={300}
value={props.selectedDestination.schedule.startTime || '03:00'}
disabled={props.loadingSettings || props.disableWhileBusy}
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
schedule: {
...destination.schedule,
startTime: (event.currentTarget as HTMLInputElement).value || '03:00',
},
}))}
/>
</label>
<label className="field">
<span>{t('txt_backup_timezone')}</span>
<select
+40 -19
View File
@@ -1,5 +1,5 @@
import { useState } from 'preact/hooks';
import { Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, Trash2 } from 'lucide-preact';
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, RotateCcw, Trash2 } from 'lucide-preact';
import type { Cipher } from '@/lib/types';
import { t } from '@/lib/i18n';
import {
@@ -31,11 +31,14 @@ interface VaultDetailViewProps {
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => void;
onStartEdit: () => void;
onDelete: (cipher: Cipher) => void;
onArchive: (cipher: Cipher) => void | Promise<void>;
onUnarchive: (cipher: Cipher) => void | Promise<void>;
}
export default function VaultDetailView(props: VaultDetailViewProps) {
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt);
const formatDownloadLabel = (attachmentId: string) => {
const downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
@@ -62,6 +65,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<div className="card">
<h3 className="detail-title">{props.selectedCipher.decName || t('txt_no_name')}</h3>
<div className="detail-sub">{props.folderName(props.selectedCipher.folderId)}</div>
{isArchived && <div className="list-badge" style={{ marginTop: '8px', width: 'fit-content' }}>{t('txt_archived')}</div>}
</div>
{props.selectedCipher.login && (
@@ -265,29 +269,36 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
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 key={`view-field-${index}`} className="custom-field-card">
<div className="custom-field-label">{fieldName}</div>
<div className="custom-field-body">
<div className="custom-field-value">
<label className="check-line cf-check view custom-field-check">
<input type="checkbox" checked={checked} disabled />
<span className="boolean-text value-ellipsis" title={checked ? t('txt_checked') : t('txt_unchecked')}>
{checked ? t('txt_checked') : t('txt_unchecked')}
</span>
</label>
</div>
<div className="kv-actions">
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(rawValue)}>
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
</button>
</div>
</div>
<div 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">
<div key={`view-field-${index}`} className="custom-field-card">
<div className="custom-field-label" title={fieldName}>{fieldName}</div>
<div className="custom-field-body">
<div className="custom-field-value">
<strong className="value-ellipsis" title={fieldType === 1 && !isHiddenVisible ? '' : rawValue}>
{fieldType === 1 && !isHiddenVisible ? maskSecret(rawValue) : rawValue}
</strong>
</div>
<div className="kv-actions">
{fieldType === 1 && (
<button type="button" className="btn btn-secondary small" onClick={() => props.onToggleHiddenField(index)}>
{isHiddenVisible ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
@@ -297,6 +308,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(rawValue)}>
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
</button>
</div>
</div>
</div>
);
@@ -351,6 +363,15 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<button type="button" className="btn btn-secondary" onClick={props.onStartEdit}>
<Pencil size={14} className="btn-icon" /> {t('txt_edit')}
</button>
{isArchived ? (
<button type="button" className="btn btn-secondary" onClick={() => void props.onUnarchive(props.selectedCipher)}>
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
</button>
) : (
<button type="button" className="btn btn-secondary" onClick={() => void props.onArchive(props.selectedCipher)}>
<Archive size={14} className="btn-icon" /> {t('txt_archive')}
</button>
)}
</div>
<button type="button" className="btn btn-danger" onClick={() => props.onDelete(props.selectedCipher)}>
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
@@ -8,6 +8,8 @@ interface VaultDialogsProps {
fieldType: CustomFieldType;
fieldLabel: string;
fieldValue: string;
archiveConfirmOpen: boolean;
bulkArchiveOpen: boolean;
pendingDeleteOpen: boolean;
bulkDeleteOpen: boolean;
sidebarTrashMode: boolean;
@@ -26,6 +28,10 @@ interface VaultDialogsProps {
onFieldTypeChange: (value: CustomFieldType) => void;
onFieldLabelChange: (value: string) => void;
onFieldValueChange: (value: string) => void;
onConfirmArchive: () => void;
onCancelArchive: () => void;
onConfirmBulkArchive: () => void;
onCancelBulkArchive: () => void;
onConfirmDelete: () => void;
onCancelDelete: () => void;
onConfirmBulkDelete: () => void;
@@ -88,6 +94,26 @@ export default function VaultDialogs(props: VaultDialogsProps) {
)}
</ConfirmDialog>
<ConfirmDialog
open={props.archiveConfirmOpen}
title={t('txt_archive_item')}
message={t('txt_archive_item_message')}
confirmText={t('txt_archive')}
cancelText={t('txt_cancel')}
onConfirm={props.onConfirmArchive}
onCancel={props.onCancelArchive}
/>
<ConfirmDialog
open={props.bulkArchiveOpen}
title={t('txt_archive_selected_items')}
message={t('txt_archive_selected_items_message', { count: props.selectedCount })}
confirmText={t('txt_archive')}
cancelText={t('txt_cancel')}
onConfirm={props.onConfirmBulkArchive}
onCancel={props.onCancelBulkArchive}
/>
<ConfirmDialog open={props.pendingDeleteOpen} title={t('txt_delete_item')} message={t('txt_are_you_sure_you_want_to_delete_this_item')} danger onConfirm={props.onConfirmDelete} onCancel={props.onCancelDelete} />
<ConfirmDialog
+192 -31
View File
@@ -1,8 +1,26 @@
import type { RefObject } from 'preact';
import { CheckCheck, Download, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
import { CheckCheck, Download, GripVertical, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import {
closestCenter,
DndContext,
type DragEndEvent,
type DragStartEvent,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
import { t } from '@/lib/i18n';
import { CREATE_TYPE_OPTIONS, cipherTypeLabel, formatAttachmentSize, toBooleanFieldValue } from '@/components/vault/vault-page-helpers';
import { CREATE_TYPE_OPTIONS, cipherTypeLabel, createEmptyLoginUri, formatAttachmentSize, toBooleanFieldValue, WEBSITE_MATCH_OPTIONS } from '@/components/vault/vault-page-helpers';
interface VaultEditorProps {
draft: VaultDraft;
@@ -24,6 +42,8 @@ interface VaultEditorProps {
onSeedSshDefaults: (force?: boolean) => void;
onUpdateSshPublicKey: (value: string) => void;
onUpdateDraftLoginUri: (index: number, value: string) => void;
onUpdateDraftLoginUriMatch: (index: number, value: number | null) => void;
onReorderDraftLoginUri: (fromIndex: number, toIndex: number) => void;
onQueueAttachmentFiles: (list: FileList | null) => void;
onToggleExistingAttachmentRemoval: (attachmentId: string) => void;
onRemoveQueuedAttachment: (index: number) => void;
@@ -36,7 +56,108 @@ interface VaultEditorProps {
onDeleteSelected: () => void;
}
interface SortableWebsiteRowProps {
id: string;
uriEntry: VaultDraft['loginUris'][number];
index: number;
canRemove: boolean;
isDragging: boolean;
onUpdateUri: (index: number, value: string) => void;
onUpdateMatch: (index: number, value: number | null) => void;
onRemove: (index: number) => void;
}
function SortableWebsiteRow(props: SortableWebsiteRowProps) {
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={`website-row${isDragging || props.isDragging ? ' is-dragging' : ''}`}
>
<button
type="button"
ref={setActivatorNodeRef}
className="btn btn-secondary small website-drag-btn"
title={t('txt_drag_to_reorder')}
aria-label={t('txt_drag_to_reorder')}
{...attributes}
{...listeners}
>
<GripVertical size={14} className="btn-icon" />
</button>
<input
className="input"
value={props.uriEntry.uri}
onInput={(e) => props.onUpdateUri(props.index, (e.currentTarget as HTMLInputElement).value)}
/>
<select
className="input website-match-select"
value={props.uriEntry.match == null ? '' : String(props.uriEntry.match)}
onInput={(e) => {
const raw = (e.currentTarget as HTMLSelectElement).value;
props.onUpdateMatch(props.index, raw === '' ? null : Number(raw));
}}
>
{WEBSITE_MATCH_OPTIONS.map((option) => (
<option key={`website-match-${String(option.value)}`} value={option.value == null ? '' : String(option.value)}>
{option.label}
</option>
))}
</select>
{props.canRemove && (
<button type="button" className="btn btn-secondary small" onClick={() => props.onRemove(props.index)}>
<X size={14} className="btn-icon" />
{t('txt_remove')}
</button>
)}
</div>
);
}
export default function VaultEditor(props: VaultEditorProps) {
const uriIdSeedRef = useRef(0);
const [uriItemIds, setUriItemIds] = useState<string[]>([]);
const [activeUriId, setActiveUriId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 6,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 120,
tolerance: 8,
},
}),
);
const createUriId = () => `login-uri-${uriIdSeedRef.current++}`;
useEffect(() => {
setUriItemIds((prev) => {
if (prev.length === props.draft.loginUris.length) return prev;
if (prev.length < props.draft.loginUris.length) {
return [...prev, ...Array.from({ length: props.draft.loginUris.length - prev.length }, () => createUriId())];
}
return prev.slice(0, props.draft.loginUris.length);
});
}, [props.draft.loginUris.length]);
useEffect(() => {
setUriItemIds(props.draft.loginUris.map(() => createUriId()));
setActiveUriId(null);
}, [props.draft.id, props.isCreating]);
const formatDownloadLabel = (attachmentId: string) => {
const downloadKey = `${props.selectedCipher?.id || ''}:${attachmentId}`;
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
@@ -52,6 +173,32 @@ export default function VaultEditor(props: VaultEditorProps) {
percent: props.attachmentUploadPercent,
});
const addLoginUri = () => {
setUriItemIds((prev) => [...prev, createUriId()]);
props.onUpdateDraft({ loginUris: [...props.draft.loginUris, createEmptyLoginUri()] });
};
const removeLoginUri = (index: number) => {
setUriItemIds((prev) => prev.filter((_, itemIndex) => itemIndex !== index));
props.onUpdateDraft({ loginUris: props.draft.loginUris.filter((_, itemIndex) => itemIndex !== index) });
};
const handleWebsiteDragStart = (event: DragStartEvent) => {
setActiveUriId(String(event.active.id));
};
const handleWebsiteDragEnd = (event: DragEndEvent) => {
const activeId = String(event.active.id);
const overId = event.over ? String(event.over.id) : null;
setActiveUriId(null);
if (!overId || activeId === overId) return;
const fromIndex = uriItemIds.indexOf(activeId);
const toIndex = uriItemIds.indexOf(overId);
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return;
setUriItemIds((prev) => arrayMove(prev, fromIndex, toIndex));
props.onReorderDraftLoginUri(fromIndex, toIndex);
};
return (
<>
<div className="card">
@@ -119,21 +266,27 @@ export default function VaultEditor(props: VaultEditorProps) {
</label>
<div className="section-head">
<h4>{t('txt_websites')}</h4>
<button type="button" className="btn btn-secondary small" onClick={() => props.onUpdateDraft({ loginUris: [...props.draft.loginUris, ''] })}>
<button type="button" className="btn btn-secondary small" onClick={addLoginUri}>
<Plus size={14} className="btn-icon" /> {t('txt_add_website')}
</button>
</div>
{props.draft.loginUris.map((uri, index) => (
<div key={`uri-${index}`} className="website-row">
<input className="input" value={uri} onInput={(e) => props.onUpdateDraftLoginUri(index, (e.currentTarget as HTMLInputElement).value)} />
{props.draft.loginUris.length > 1 && (
<button type="button" className="btn btn-secondary small" onClick={() => props.onUpdateDraft({ loginUris: props.draft.loginUris.filter((_, i) => i !== index) })}>
<X size={14} className="btn-icon" />
{t('txt_remove')}
</button>
)}
</div>
))}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragStart={handleWebsiteDragStart} onDragEnd={handleWebsiteDragEnd}>
<SortableContext items={uriItemIds} strategy={verticalListSortingStrategy}>
{props.draft.loginUris.map((uriEntry, index) => (
<SortableWebsiteRow
key={uriItemIds[index] ?? `uri-${index}`}
id={uriItemIds[index] ?? `uri-fallback-${index}`}
uriEntry={uriEntry}
index={index}
canRemove={props.draft.loginUris.length > 1}
isDragging={activeUriId === uriItemIds[index]}
onUpdateUri={props.onUpdateDraftLoginUri}
onUpdateMatch={props.onUpdateDraftLoginUriMatch}
onRemove={removeLoginUri}
/>
))}
</SortableContext>
</DndContext>
</div>
)}
@@ -322,23 +475,31 @@ export default function VaultEditor(props: VaultEditorProps) {
.map((field, originalIndex) => ({ field, originalIndex }))
.filter((entry) => entry.field.type !== 3)
.map(({ field, originalIndex }) => (
<div key={`field-${originalIndex}`} className="uri-row">
<input className="input" value={field.label} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { label: (e.currentTarget as HTMLInputElement).value })} />
{field.type === 2 ? (
<label className="check-line cf-check">
<input
type="checkbox"
checked={toBooleanFieldValue(field.value)}
onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).checked ? 'true' : 'false' })}
/>
</label>
) : (
<input className="input" value={field.value} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).value })} />
)}
<button type="button" className="btn btn-secondary small" onClick={() => props.onUpdateDraftCustomFields(props.draft.customFields.filter((_, i) => i !== originalIndex))}>
<X size={14} className="btn-icon" />
{t('txt_remove')}
</button>
<div key={`field-${originalIndex}`} className="custom-field-card">
<label className="field custom-field-label">
<span>{t('txt_field_label')}</span>
<input className="input" value={field.label} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { label: (e.currentTarget as HTMLInputElement).value })} />
</label>
<div className="custom-field-body">
<div className="custom-field-value">
{field.type === 2 ? (
<label className="check-line cf-check custom-field-check">
<input
type="checkbox"
checked={toBooleanFieldValue(field.value)}
onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).checked ? 'true' : 'false' })}
/>
<span>{toBooleanFieldValue(field.value) ? t('txt_checked') : t('txt_unchecked')}</span>
</label>
) : (
<input className="input" value={field.value} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).value })} />
)}
</div>
<button type="button" className="btn btn-secondary small custom-field-remove" onClick={() => props.onUpdateDraftCustomFields(props.draft.customFields.filter((_, i) => i !== originalIndex))}>
<X size={14} className="btn-icon" />
{t('txt_remove')}
</button>
</div>
</div>
))}
</div>
+70 -29
View File
@@ -1,5 +1,5 @@
import type { RefObject } from 'preact';
import { ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, Trash2, X } from 'lucide-preact';
import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact';
import type { Cipher } from '@/lib/types';
import { t } from '@/lib/i18n';
import {
@@ -37,6 +37,7 @@ interface VaultListPanelProps {
sortMenuRef: RefObject<HTMLDivElement>;
listPanelRef: RefObject<HTMLDivElement>;
onSearchInput: (value: string) => void;
onClearSearch: () => void;
onSearchCompositionStart: () => void;
onSearchCompositionEnd: (value: string) => void;
onToggleSortMenu: () => void;
@@ -48,6 +49,8 @@ interface VaultListPanelProps {
onToggleCreateMenu: () => void;
onStartCreate: (type: number) => void;
onBulkRestore: () => void;
onBulkArchive: () => void;
onBulkUnarchive: () => void;
onOpenMove: () => void;
onClearSelection: () => void;
onScroll: (top: number) => void;
@@ -60,14 +63,32 @@ export default function VaultListPanel(props: VaultListPanelProps) {
return (
<section className="list-col">
<div className="list-head">
<input
className="search-input"
placeholder={t('txt_search_your_secure_vault')}
value={props.searchInput}
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
onCompositionStart={props.onSearchCompositionStart}
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
/>
<div className="search-input-wrap">
<input
className="search-input"
placeholder={t('txt_search_your_secure_vault')}
value={props.searchInput}
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
onCompositionStart={props.onSearchCompositionStart}
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
onKeyDown={(e) => {
if (e.key !== 'Escape' || !props.searchInput) return;
e.preventDefault();
props.onClearSearch();
}}
/>
{!!props.searchInput && (
<button
type="button"
className="search-clear-btn"
aria-label={t('txt_clear_search')}
title={t('txt_clear_search_esc')}
onClick={props.onClearSearch}
>
<X size={14} />
</button>
)}
</div>
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
<button
type="button"
@@ -102,14 +123,39 @@ export default function VaultListPanel(props: VaultListPanelProps) {
</button>
</div>
<div className="toolbar actions">
<button type="button" className="btn btn-danger small" disabled={!props.selectedCount || props.busy} onClick={props.onOpenBulkDelete}>
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
</button>
{props.sidebarFilter.kind === 'duplicates' && (
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length || props.busy} onClick={props.onSelectDuplicates}>
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
</button>
)}
{props.selectedCount > 0 && props.sidebarFilter.kind === 'trash' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkRestore}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
</button>
)}
{props.selectedCount > 0 && props.sidebarFilter.kind === 'archive' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkUnarchive}>
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
</button>
)}
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkArchive}>
<Archive size={14} className="btn-icon" /> {t('txt_archive_selected')}
</button>
)}
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}>
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
</button>
)}
{props.selectedCount > 0 && (
<button type="button" className="btn btn-secondary small" onClick={props.onClearSelection}>
<X size={14} className="btn-icon" /> {t('txt_cancel')}
</button>
)}
<button type="button" className="btn btn-danger small" disabled={!props.selectedCount || props.busy} onClick={props.onOpenBulkDelete}>
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
</button>
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
</button>
@@ -134,32 +180,27 @@ export default function VaultListPanel(props: VaultListPanelProps) {
</div>
)}
</div>
{props.selectedCount > 0 && props.sidebarFilter.kind === 'trash' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkRestore}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
</button>
)}
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'duplicates' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}>
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
</button>
)}
{props.selectedCount > 0 && (
<button type="button" className="btn btn-secondary small" onClick={props.onClearSelection}>
<X size={14} className="btn-icon" /> {t('txt_cancel')}
</button>
)}
</div>
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
{!!props.filteredCiphers.length && (
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
{props.visibleCiphers.map((cipher) => (
<div key={cipher.id} className={`list-item ${props.selectedCipherId === cipher.id ? 'active' : ''}`}>
{props.visibleCiphers.map((cipher, index) => (
<div
key={cipher.id}
className={`list-item stagger-item ${props.selectedCipherId === cipher.id ? 'active' : ''}`}
style={{ animationDelay: `${Math.min(index, 10) * 26}ms` }}
onClick={(event) => {
const target = event.target as HTMLElement;
if (target.closest('.row-check')) return;
props.onSelectCipher(cipher.id);
}}
>
<input
type="checkbox"
className="row-check"
checked={!!props.selectedMap[cipher.id]}
onClick={(event) => event.stopPropagation()}
onInput={(e) => props.onToggleSelected(cipher.id, (e.currentTarget as HTMLInputElement).checked)}
/>
<button type="button" className="row-main" onClick={() => props.onSelectCipher(cipher.id)}>
@@ -1,4 +1,5 @@
import {
Archive,
Copy,
CreditCard,
Folder as FolderIcon,
@@ -48,6 +49,9 @@ export default function VaultSidebar(props: VaultSidebarProps) {
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'favorite' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'favorite' })}>
<Star size={14} className="tree-icon" /> <span className="tree-label">{t('txt_favorites')}</span>
</button>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'archive' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'archive' })}>
<Archive size={14} className="tree-icon" /> <span className="tree-label">{t('txt_archive')}</span>
</button>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'trash' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'trash' })}>
<Trash2 size={14} className="tree-icon" /> <span className="tree-label">{t('txt_trash')}</span>
</button>
@@ -9,13 +9,14 @@ import {
} from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard';
import { t } from '@/lib/i18n';
import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField } from '@/lib/types';
import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField, VaultDraftLoginUri } from '@/lib/types';
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
export type VaultSortMode = 'edited' | 'created' | 'name';
export type SidebarFilter =
| { kind: 'all' }
| { kind: 'favorite' }
| { kind: 'archive' }
| { kind: 'trash' }
| { kind: 'duplicates' }
| { kind: 'type'; value: TypeFilter }
@@ -50,6 +51,16 @@ export const FIELD_TYPE_OPTIONS: Array<{ value: CustomFieldType; label: string }
{ value: 2, label: t('txt_boolean') },
];
export const WEBSITE_MATCH_OPTIONS: Array<{ value: number | null; label: string }> = [
{ value: null, label: t('txt_uri_match_default_base_domain') },
{ value: 0, label: t('txt_uri_match_base_domain') },
{ value: 1, label: t('txt_uri_match_host') },
{ value: 3, label: t('txt_uri_match_exact') },
{ value: 5, label: t('txt_uri_match_never') },
{ value: 2, label: t('txt_uri_match_starts_with') },
{ value: 4, label: t('txt_uri_match_regular_expression') },
];
export const TOTP_PERIOD_SECONDS = 30;
export const TOTP_RING_RADIUS = 14;
export const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
@@ -71,6 +82,34 @@ export function cipherTypeKey(type: number): TypeFilter {
return 'ssh';
}
function cipherDeletedValue(cipher: Cipher): boolean {
return !!(cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt);
}
function cipherArchivedValue(cipher: Cipher): boolean {
return !!(cipher.archivedDate || (cipher as { archivedAt?: string | null }).archivedAt);
}
export function isCipherDeleted(cipher: Cipher): boolean {
return cipherDeletedValue(cipher);
}
export function isCipherArchived(cipher: Cipher): boolean {
return cipherArchivedValue(cipher) && !cipherDeletedValue(cipher);
}
export function isCipherVisibleInNormalVault(cipher: Cipher): boolean {
return !cipherDeletedValue(cipher) && !cipherArchivedValue(cipher);
}
export function isCipherVisibleInArchive(cipher: Cipher): boolean {
return !cipherDeletedValue(cipher) && cipherArchivedValue(cipher);
}
export function isCipherVisibleInTrash(cipher: Cipher): boolean {
return cipherDeletedValue(cipher);
}
export function cipherTypeLabel(type: number): string {
if (type === 1) return t('txt_login');
if (type === 3) return t('txt_card');
@@ -125,6 +164,15 @@ export function websiteIconUrl(host: string): string {
return `/icons/${encodeURIComponent(host)}/icon.png`;
}
export function createEmptyLoginUri(): VaultDraftLoginUri {
return { uri: '', match: null };
}
export function websiteMatchLabel(value: number | null | undefined): string {
const normalized = typeof value === 'number' && Number.isFinite(value) ? value : null;
return WEBSITE_MATCH_OPTIONS.find((option) => option.value === normalized)?.label || t('txt_uri_match_default_base_domain');
}
function valueOrFallback(value: string | null | undefined): string {
return String(value || '');
}
@@ -216,7 +264,7 @@ export function createEmptyDraft(type: number): VaultDraft {
loginUsername: '',
loginPassword: '',
loginTotp: '',
loginUris: [''],
loginUris: [createEmptyLoginUri()],
loginFido2Credentials: [],
cardholderName: '',
cardNumber: '',
@@ -262,11 +310,14 @@ export function draftFromCipher(cipher: Cipher): VaultDraft {
draft.loginUsername = cipher.login.decUsername || '';
draft.loginPassword = cipher.login.decPassword || '';
draft.loginTotp = cipher.login.decTotp || '';
draft.loginUris = (cipher.login.uris || []).map((x) => x.decUri || x.uri || '');
draft.loginUris = (cipher.login.uris || []).map((x) => ({
uri: x.decUri || x.uri || '',
match: x.match ?? null,
}));
draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials)
? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
: [];
if (!draft.loginUris.length) draft.loginUris = [''];
if (!draft.loginUris.length) draft.loginUris = [createEmptyLoginUri()];
}
if (cipher.card) {
draft.cardholderName = cipher.card.decCardholderName || '';
+40 -5
View File
@@ -1,16 +1,19 @@
import { useMemo } from 'preact/hooks';
import {
type BackupExportClientProgressEvent,
buildCompleteAdminBackupExport,
deleteRemoteBackup,
downloadRemoteBackup,
downloadRemoteBackup as fetchRemoteBackupPayload,
getAdminBackupSettings,
importAdminBackup,
inspectRemoteBackupIntegrity,
listRemoteBackups,
restoreRemoteBackup,
restoreRemoteBackup as restoreRemoteBackupRequest,
runAdminBackupNow,
saveAdminBackupSettings,
} from '@/lib/api/backup';
import { downloadBytesAsFile } from '@/lib/download';
import { dispatchBackupProgress } from '@/lib/backup-restore-progress';
import type { AuthedFetch } from '@/lib/api/shared';
interface UseBackupActionsOptions {
@@ -25,8 +28,24 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
return useMemo(
() => ({
async exportBackup(includeAttachments: boolean = false) {
const payload = await buildCompleteAdminBackupExport(authedFetch, includeAttachments);
const payload = await buildCompleteAdminBackupExport(
authedFetch,
includeAttachments,
async (event: BackupExportClientProgressEvent) => {
dispatchBackupProgress(event);
}
);
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
dispatchBackupProgress({
operation: 'backup-export',
source: 'local',
step: 'export_complete',
fileName: payload.fileName,
stageTitle: 'txt_backup_export_progress_complete_title',
stageDetail: 'txt_backup_export_progress_complete_detail',
done: true,
ok: true,
});
},
async importBackup(file: File, replaceExisting: boolean = false) {
@@ -35,6 +54,12 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
return result;
},
async importBackupAllowingChecksumMismatch(file: File, replaceExisting: boolean = false) {
const result = await importAdminBackup(authedFetch, file, replaceExisting, true);
onImported?.();
return result;
},
async loadSettings() {
return getAdminBackupSettings(authedFetch);
},
@@ -52,16 +77,26 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
},
async downloadRemoteBackup(destinationId: string, path: string, onProgress?: (percent: number | null) => void) {
const payload = await downloadRemoteBackup(authedFetch, destinationId, path, onProgress);
const payload = await fetchRemoteBackupPayload(authedFetch, destinationId, path, onProgress);
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
},
async inspectRemoteBackup(destinationId: string, path: string) {
return inspectRemoteBackupIntegrity(authedFetch, destinationId, path);
},
async deleteRemoteBackup(destinationId: string, path: string) {
await deleteRemoteBackup(authedFetch, destinationId, path);
},
async restoreRemoteBackup(destinationId: string, path: string, replaceExisting: boolean = false) {
const result = await restoreRemoteBackup(authedFetch, destinationId, path, replaceExisting);
const result = await restoreRemoteBackupRequest(authedFetch, destinationId, path, replaceExisting);
onRestored?.();
return result;
},
async restoreRemoteBackupAllowingChecksumMismatch(destinationId: string, path: string, replaceExisting: boolean = false) {
const result = await restoreRemoteBackupRequest(authedFetch, destinationId, path, replaceExisting, true);
onRestored?.();
return result;
},
+48
View File
@@ -22,12 +22,15 @@ import {
} from '@/lib/app-support';
import { buildSendShareKey, bulkDeleteSends, createSend, deleteSend, updateSend } from '@/lib/api/send';
import {
archiveCipher,
buildCipherImportPayload,
bulkArchiveCiphers,
bulkDeleteCiphers,
bulkDeleteFolders,
bulkMoveCiphers,
bulkPermanentDeleteCiphers,
bulkRestoreCiphers,
bulkUnarchiveCiphers,
createCipher,
createFolder,
deleteCipher,
@@ -40,6 +43,7 @@ import {
type CiphersImportPayload,
type ImportedCipherMapEntry,
updateCipher,
unarchiveCipher,
uploadCipherAttachment,
} from '@/lib/api/vault';
import { deriveLoginHash, getPreloginKdfConfig, verifyMasterPassword } from '@/lib/api/auth';
@@ -237,6 +241,28 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
}
},
async archiveVaultItem(cipher: Cipher) {
try {
await archiveCipher(authedFetch, cipher.id);
await Promise.all([refetchCiphers(), refetchFolders()]);
onNotify('success', t('txt_item_archived'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_archive_item_failed'));
throw error;
}
},
async unarchiveVaultItem(cipher: Cipher) {
try {
await unarchiveCipher(authedFetch, cipher.id);
await Promise.all([refetchCiphers(), refetchFolders()]);
onNotify('success', t('txt_item_unarchived'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_unarchive_item_failed'));
throw error;
}
},
async bulkDeleteVaultItems(ids: string[]) {
try {
await bulkDeleteCiphers(authedFetch, ids);
@@ -248,6 +274,28 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
}
},
async bulkArchiveVaultItems(ids: string[]) {
try {
await bulkArchiveCiphers(authedFetch, ids);
await Promise.all([refetchCiphers(), refetchFolders()]);
onNotify('success', t('txt_archived_selected_items'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_archive_failed'));
throw error;
}
},
async bulkUnarchiveVaultItems(ids: string[]) {
try {
await bulkUnarchiveCiphers(authedFetch, ids);
await Promise.all([refetchCiphers(), refetchFolders()]);
onNotify('success', t('txt_unarchived_selected_items'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_unarchive_failed'));
throw error;
}
},
async bulkMoveVaultItems(ids: string[], folderId: string | null) {
try {
await bulkMoveCiphers(authedFetch, ids, folderId);
+144 -6
View File
@@ -57,6 +57,21 @@ export interface AdminBackupRunResponse {
settings: AdminBackupSettings;
}
export interface BackupFileIntegrityCheckResult {
hasChecksumPrefix: boolean;
expectedPrefix: string | null;
actualPrefix: string;
matches: boolean;
}
export interface RemoteBackupIntegrityResponse {
object: 'backup-remote-integrity';
destinationId: string;
path: string;
fileName: string;
integrity: BackupFileIntegrityCheckResult;
}
export interface RemoteBackupItem {
path: string;
name: string;
@@ -109,6 +124,18 @@ export interface AdminBackupExportPayload {
bytes: Uint8Array;
}
export interface BackupExportClientProgressEvent {
operation: 'backup-export';
source: 'local';
step: string;
fileName: string;
stageTitle: string;
stageDetail: string;
done?: boolean;
ok?: boolean;
error?: string | null;
}
interface BackupExportManifestAttachmentBlob {
cipherId: string;
attachmentId: string;
@@ -119,6 +146,36 @@ interface BackupExportManifest {
attachmentBlobs?: BackupExportManifestAttachmentBlob[];
}
const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
function parseBackupTimestampFromFileName(fileName: string): Date | null {
const match = String(fileName || '').match(/nodewarden_backup_(\d{8})_(\d{6})(?:_[0-9a-f]{5})?\.zip$/i);
if (!match) return null;
const datePart = match[1];
const timePart = match[2];
const iso = `${datePart.slice(0, 4)}-${datePart.slice(4, 6)}-${datePart.slice(6, 8)}T${timePart.slice(0, 2)}:${timePart.slice(2, 4)}:${timePart.slice(4, 6)}.000Z`;
const parsed = new Date(iso);
return Number.isFinite(parsed.getTime()) ? parsed : null;
}
function buildBackupFileName(date: Date, checksumPrefix: string): string {
const parts = [
date.getUTCFullYear().toString().padStart(4, '0'),
(date.getUTCMonth() + 1).toString().padStart(2, '0'),
date.getUTCDate().toString().padStart(2, '0'),
date.getUTCHours().toString().padStart(2, '0'),
date.getUTCMinutes().toString().padStart(2, '0'),
date.getUTCSeconds().toString().padStart(2, '0'),
];
return `nodewarden_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}_${checksumPrefix}.zip`;
}
async function applyBackupFileIntegrityName(fileName: string, bytes: Uint8Array): Promise<string> {
const integrity = await verifyBackupFileIntegrity(bytes, fileName);
const effectiveDate = parseBackupTimestampFromFileName(fileName) || new Date();
return buildBackupFileName(effectiveDate, integrity.actualPrefix);
}
export async function exportAdminBackup(
authedFetch: AuthedFetch,
includeAttachments: boolean = false
@@ -149,10 +206,21 @@ export async function downloadAdminBackupAttachmentBlob(
export async function buildCompleteAdminBackupExport(
authedFetch: AuthedFetch,
includeAttachments: boolean = false
includeAttachments: boolean = false,
onProgress?: (event: BackupExportClientProgressEvent) => void | Promise<void>
): Promise<AdminBackupExportPayload> {
const payload = await exportAdminBackup(authedFetch, includeAttachments);
if (!includeAttachments) return payload;
if (!includeAttachments) {
await onProgress?.({
operation: 'backup-export',
source: 'local',
step: 'export_client_save',
fileName: payload.fileName,
stageTitle: 'txt_backup_export_progress_save_title',
stageDetail: 'txt_backup_export_progress_save_detail',
});
return payload;
}
const zipped = unzipSync(payload.bytes);
const manifestBytes = zipped['manifest.json'];
@@ -167,14 +235,41 @@ export async function buildCompleteAdminBackupExport(
throw new Error(t('txt_backup_export_failed'));
}
await onProgress?.({
operation: 'backup-export',
source: 'local',
step: 'export_client_fetch_attachments',
fileName: payload.fileName,
stageTitle: 'txt_backup_export_progress_fetch_attachments_title',
stageDetail: 'txt_backup_export_progress_fetch_attachments_detail',
});
for (const attachment of manifest.attachmentBlobs || []) {
const bytes = await downloadAdminBackupAttachmentBlob(authedFetch, attachment.blobName);
zipped[`attachments/${attachment.cipherId}/${attachment.attachmentId}.bin`] = bytes;
}
await onProgress?.({
operation: 'backup-export',
source: 'local',
step: 'export_client_rebuild',
fileName: payload.fileName,
stageTitle: 'txt_backup_export_progress_rebuild_title',
stageDetail: 'txt_backup_export_progress_rebuild_detail',
});
const rebuiltBytes = zipSync(zipped, { level: 0 });
const rebuiltFileName = await applyBackupFileIntegrityName(payload.fileName, rebuiltBytes);
await onProgress?.({
operation: 'backup-export',
source: 'local',
step: 'export_client_save',
fileName: rebuiltFileName,
stageTitle: 'txt_backup_export_progress_save_title',
stageDetail: 'txt_backup_export_progress_save_detail',
});
return {
...payload,
bytes: zipSync(zipped, { level: 0 }),
bytes: rebuiltBytes,
fileName: rebuiltFileName,
};
}
@@ -276,6 +371,29 @@ export async function downloadRemoteBackup(
return { fileName, mimeType, bytes };
}
export function extractBackupFileChecksumPrefix(fileName: string): string | null {
const normalized = String(fileName || '').trim();
const match = normalized.match(/_([0-9a-f]{5})\.zip$/i);
return match ? match[1].toLowerCase() : null;
}
async function sha256Hex(bytes: Uint8Array): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
}
export async function verifyBackupFileIntegrity(bytes: Uint8Array, fileName: string): Promise<BackupFileIntegrityCheckResult> {
const expectedPrefix = extractBackupFileChecksumPrefix(fileName);
const actualHash = await sha256Hex(bytes);
const actualPrefix = actualHash.slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
return {
hasChecksumPrefix: !!expectedPrefix,
expectedPrefix,
actualPrefix,
matches: !expectedPrefix || expectedPrefix === actualPrefix,
};
}
export async function deleteRemoteBackup(
authedFetch: AuthedFetch,
destinationId: string,
@@ -288,16 +406,32 @@ export async function deleteRemoteBackup(
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_delete_failed')));
}
export async function inspectRemoteBackupIntegrity(
authedFetch: AuthedFetch,
destinationId: string,
path: string
): Promise<RemoteBackupIntegrityResponse> {
const params = new URLSearchParams();
params.set('destinationId', destinationId);
params.set('path', path);
const resp = await authedFetch(`/api/admin/backup/remote/integrity?${params.toString()}`, { method: 'GET' });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed')));
const body = await parseJson<RemoteBackupIntegrityResponse>(resp);
if (!body?.integrity || !body?.fileName) throw new Error(t('txt_backup_remote_invalid_response'));
return body;
}
export async function restoreRemoteBackup(
authedFetch: AuthedFetch,
destinationId: string,
path: string,
replaceExisting: boolean = false
replaceExisting: boolean = false,
allowChecksumMismatch: boolean = false
): Promise<AdminBackupImportResponse> {
const resp = await authedFetch('/api/admin/backup/remote/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ destinationId, path, replaceExisting }),
body: JSON.stringify({ destinationId, path, replaceExisting, allowChecksumMismatch }),
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_restore_failed')));
const body = await parseJson<AdminBackupImportResponse>(resp);
@@ -308,13 +442,17 @@ export async function restoreRemoteBackup(
export async function importAdminBackup(
authedFetch: AuthedFetch,
file: File,
replaceExisting: boolean = false
replaceExisting: boolean = false,
allowChecksumMismatch: boolean = false
): Promise<AdminBackupImportResponse> {
const formData = new FormData();
formData.set('file', file, file.name || 'nodewarden_backup.zip');
if (replaceExisting) {
formData.set('replaceExisting', '1');
}
if (allowChecksumMismatch) {
formData.set('allowChecksumMismatch', '1');
}
const resp = await authedFetch('/api/admin/backup/import', {
method: 'POST',
+50 -5
View File
@@ -367,12 +367,19 @@ async function encryptCustomFields(
return out;
}
async function encryptUris(uris: string[], enc: Uint8Array, mac: Uint8Array): Promise<Array<{ uri: string | null; match: null }>> {
const out: Array<{ uri: string | null; match: null }> = [];
for (const uri of uris || []) {
const trimmed = String(uri || '').trim();
async function encryptUris(
uris: VaultDraft['loginUris'],
enc: Uint8Array,
mac: Uint8Array
): Promise<Array<{ uri: string | null; match: number | null }>> {
const out: Array<{ uri: string | null; match: number | null }> = [];
for (const entry of uris || []) {
const trimmed = String(entry?.uri || '').trim();
if (!trimmed) continue;
out.push({ uri: await encryptTextValue(trimmed, enc, mac), match: null });
out.push({
uri: await encryptTextValue(trimmed, enc, mac),
match: typeof entry?.match === 'number' && Number.isFinite(entry.match) ? entry.match : null,
});
}
return out;
}
@@ -582,6 +589,20 @@ export async function deleteCipher(authedFetch: AuthedFetch, cipherId: string):
if (!resp.ok) throw new Error('Delete item failed');
}
export async function archiveCipher(authedFetch: AuthedFetch, cipherId: string): Promise<void> {
const id = String(cipherId || '').trim();
if (!id) throw new Error('Cipher id is required');
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/archive`, { method: 'PUT' });
if (!resp.ok) throw new Error('Archive item failed');
}
export async function unarchiveCipher(authedFetch: AuthedFetch, cipherId: string): Promise<void> {
const id = String(cipherId || '').trim();
if (!id) throw new Error('Cipher id is required');
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/unarchive`, { method: 'PUT' });
if (!resp.ok) throw new Error('Unarchive item failed');
}
export async function bulkDeleteCiphers(authedFetch: AuthedFetch, ids: string[]): Promise<void> {
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) {
@@ -594,6 +615,18 @@ export async function bulkDeleteCiphers(authedFetch: AuthedFetch, ids: string[])
}
}
export async function bulkArchiveCiphers(authedFetch: AuthedFetch, ids: string[]): Promise<void> {
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) {
const resp = await authedFetch('/api/ciphers/archive', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: chunk }),
});
if (!resp.ok) throw new Error('Bulk archive failed');
}
}
export async function bulkPermanentDeleteCiphers(authedFetch: AuthedFetch, ids: string[]): Promise<void> {
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) {
@@ -618,6 +651,18 @@ export async function bulkRestoreCiphers(authedFetch: AuthedFetch, ids: string[]
}
}
export async function bulkUnarchiveCiphers(authedFetch: AuthedFetch, ids: string[]): Promise<void> {
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) {
const resp = await authedFetch('/api/ciphers/unarchive', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: chunk }),
});
if (!resp.ok) throw new Error('Bulk unarchive failed');
}
}
export async function bulkMoveCiphers(
authedFetch: AuthedFetch,
ids: string[],
+13 -4
View File
@@ -17,6 +17,7 @@ export interface WebVaultSignalRInvocation {
UserId?: string;
Date?: string;
RevisionDate?: string;
[key: string]: unknown;
};
}>;
}
@@ -97,7 +98,7 @@ export function buildEmptyImportDraft(type: number): VaultDraft {
loginUsername: '',
loginPassword: '',
loginTotp: '',
loginUris: [''],
loginUris: [{ uri: '', match: null }],
loginFido2Credentials: [],
cardholderName: '',
cardNumber: '',
@@ -167,9 +168,17 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
: [];
const urisRaw = Array.isArray(login.uris) ? login.uris : [];
const uris = urisRaw
.map((u) => asText((u as Record<string, unknown>)?.uri).trim())
.filter((u) => !!u);
draft.loginUris = uris.length ? uris : [''];
.map((u) => {
const row = (u || {}) as Record<string, unknown>;
const uri = asText(row.uri).trim();
const matchRaw = row.match;
return {
uri,
match: typeof matchRaw === 'number' && Number.isFinite(matchRaw) ? matchRaw : null,
};
})
.filter((u) => !!u.uri);
draft.loginUris = uris.length ? uris : [{ uri: '', match: null }];
} else if (type === 3) {
const card = (cipher.card || {}) as Record<string, unknown>;
draft.cardholderName = asText(card.cardholderName);
+27
View File
@@ -0,0 +1,27 @@
export type BackupProgressOperation = 'backup-restore' | 'backup-export' | 'backup-remote-run';
export interface BackupProgressDetail {
operation: BackupProgressOperation;
source?: 'local' | 'remote';
step: string;
fileName: string;
stageTitle?: string;
stageDetail?: string;
replaceExisting?: boolean;
done?: boolean;
ok?: boolean;
error?: string | null;
Date?: string;
}
export type BackupRestoreProgressDetail = BackupProgressDetail;
export const BACKUP_PROGRESS_EVENT = 'nodewarden:backup-progress';
export const BACKUP_RESTORE_PROGRESS_EVENT = BACKUP_PROGRESS_EVENT;
export function dispatchBackupProgress(detail: BackupProgressDetail): void {
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent<BackupProgressDetail>(BACKUP_PROGRESS_EVENT, { detail }));
}
export const dispatchBackupRestoreProgress = dispatchBackupProgress;
+218 -12
View File
@@ -26,11 +26,16 @@ const messages: Record<Locale, Record<string, string>> = {
txt_backup_export_success: "Backup exported",
txt_backup_import_success_relogin: "Backup restored. Please sign in again.",
txt_backup_restore_success_relogin: "Backup restored. Please sign in again.",
txt_backup_restore_completed_verified: "Backup file integrity verification passed.",
txt_backup_restore_completed_without_checksum: "Backup restored. No filename integrity marker was available for verification.",
txt_backup_remote_restore_completed_verified: "Remote backup integrity verification passed.",
txt_backup_remote_restore_completed_without_checksum: "Remote backup restored. No filename integrity marker was available for verification.",
txt_backup_restore_skipped_summary: "{reason}. Skipped {attachments} attachment(s).",
txt_backup_restore_skipped_reason_default: "Some files could not be restored",
txt_backup_export_failed: "Backup export failed",
txt_backup_import_failed: "Backup restore failed",
txt_backup_restore_failed: "Backup restore failed",
txt_backup_integrity_check_failed: "Backup integrity verification failed",
txt_backup_center_title: "Instance Backup",
txt_backup_center_description: "Keep local exports for manual restore, and configure one daily remote backup target for unattended protection.",
txt_backup_restore_note: "Restoring will overwrite the current instance if you choose the replace flow.",
@@ -99,6 +104,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_backup_run_manual: "Run Manually",
txt_backup_running_now: "Running...",
txt_backup_remote_run_success: "Remote backup completed",
txt_backup_remote_run_success_verified: "Remote backup completed and integrity verification passed.",
txt_backup_remote_run_failed: "Remote backup failed",
txt_backup_remote_title: "Remote Backups",
txt_backup_remote_note: "Browse the saved destination and choose a backup ZIP to download or restore.",
@@ -112,6 +118,68 @@ const messages: Record<Locale, Record<string, string>> = {
txt_backup_remote_restore: "Restore",
txt_backup_remote_restore_stage_prepare: "Preparing remote backup restore...",
txt_backup_remote_restore_stage_replace: "Clearing current data and restoring remote backup...",
txt_backup_progress_kicker: "Backup Task",
txt_backup_progress_subject: "Current item: {name}",
txt_backup_restore_progress_kicker: "Restore Progress",
txt_backup_restore_progress_local_title: "Restoring local backup",
txt_backup_restore_progress_remote_title: "Restoring remote backup",
txt_backup_export_progress_title: "Exporting backup",
txt_backup_remote_run_progress_title: "Running remote backup",
txt_backup_restore_progress_file: "Current file: {name}",
txt_backup_restore_progress_elapsed: "{seconds}s elapsed",
txt_backup_archive_progress_collect_title: "Collecting vault data",
txt_backup_archive_progress_collect_detail: "The server is reading database tables and assembling the backup payload.",
txt_backup_archive_progress_collect_with_attachments_detail: "The server is reading database tables and collecting attachment metadata for the backup payload.",
txt_backup_archive_progress_package_title: "Packaging backup archive",
txt_backup_archive_progress_package_detail: "The server is generating the backup ZIP and computing its checksum prefix.",
txt_backup_archive_progress_package_with_attachments_detail: "The server is generating the backup ZIP metadata and computing its checksum prefix for the attachment-aware export.",
txt_backup_archive_progress_ready_title: "Preparing download",
txt_backup_archive_progress_ready_detail: "The backup archive is ready and is being returned to the browser.",
txt_backup_export_progress_fetch_attachments_title: "Downloading attachment files",
txt_backup_export_progress_fetch_attachments_detail: "The browser is fetching attachment objects and adding them into the export package.",
txt_backup_export_progress_rebuild_title: "Rebuilding export archive",
txt_backup_export_progress_rebuild_detail: "The browser is rebuilding the final ZIP and refreshing its checksum suffix.",
txt_backup_export_progress_save_title: "Saving export file",
txt_backup_export_progress_save_detail: "The browser is preparing the final backup file for download.",
txt_backup_export_progress_complete_title: "Export completed",
txt_backup_export_progress_complete_detail: "The backup export is ready.",
txt_backup_export_progress_failed_title: "Export failed",
txt_backup_export_progress_failed_detail: "The backup export could not be completed.",
txt_backup_remote_run_progress_prepare_title: "Preparing remote backup",
txt_backup_remote_run_progress_prepare_detail: "The server is loading the selected destination and preparing this backup run.",
txt_backup_remote_run_progress_sync_attachments_title: "Checking attachment index",
txt_backup_remote_run_progress_sync_attachments_detail: "The server is comparing attachment metadata so only missing attachment objects are uploaded.",
txt_backup_remote_run_progress_sync_attachments_skipped_detail: "This backup does not include attachments, so attachment synchronization is skipped.",
txt_backup_remote_run_progress_upload_title: "Uploading backup archive",
txt_backup_remote_run_progress_upload_detail: "The server is uploading the backup ZIP to the remote destination.",
txt_backup_remote_run_progress_verify_title: "Verifying uploaded archive",
txt_backup_remote_run_progress_verify_detail: "The server is downloading the uploaded ZIP back and verifying its checksum and size.",
txt_backup_remote_run_progress_cleanup_title: "Cleaning older backups",
txt_backup_remote_run_progress_cleanup_detail: "The server is pruning older backup files according to the retention policy.",
txt_backup_remote_run_progress_complete_title: "Remote backup completed",
txt_backup_remote_run_progress_complete_detail: "The remote backup has been uploaded and verified successfully.",
txt_backup_remote_run_progress_failed_title: "Remote backup failed",
txt_backup_remote_run_progress_failed_detail: "The remote backup could not be completed.",
txt_backup_restore_progress_local_upload_title: "Uploading backup archive",
txt_backup_restore_progress_local_upload_detail: "The selected ZIP is being sent to the server for processing.",
txt_backup_restore_progress_local_shadow_title: "Creating shadow workspace",
txt_backup_restore_progress_local_shadow_detail: "The server is preparing an isolated restore area so the current data remains untouched until validation passes.",
txt_backup_restore_progress_local_data_title: "Writing vault data",
txt_backup_restore_progress_local_data_detail: "The server is importing users, folders, vault items, and related metadata into shadow tables.",
txt_backup_restore_progress_local_files_title: "Restoring attachment files",
txt_backup_restore_progress_local_files_detail: "The server is writing attachment objects back to storage and removing any attachment rows that cannot be restored.",
txt_backup_restore_progress_local_finalize_title: "Validating and switching data",
txt_backup_restore_progress_local_finalize_detail: "The server is performing final validation and then swapping the verified restore data into the live tables.",
txt_backup_restore_progress_remote_fetch_title: "Reading remote backup",
txt_backup_restore_progress_remote_fetch_detail: "The server is downloading the selected backup package from the remote destination.",
txt_backup_restore_progress_remote_shadow_title: "Creating shadow workspace",
txt_backup_restore_progress_remote_shadow_detail: "The server is preparing an isolated restore area so the current data remains untouched until validation passes.",
txt_backup_restore_progress_remote_data_title: "Writing vault data",
txt_backup_restore_progress_remote_data_detail: "The server is importing users, folders, vault items, and related metadata into shadow tables.",
txt_backup_restore_progress_remote_files_title: "Restoring remote attachments",
txt_backup_restore_progress_remote_files_detail: "The server is fetching required attachment objects from remote storage and writing them back into local storage.",
txt_backup_restore_progress_remote_finalize_title: "Validating and switching data",
txt_backup_restore_progress_remote_finalize_detail: "The server is performing final validation and then switching the verified restore data into the live tables.",
txt_backup_remote_loading: "Loading remote backups...",
txt_backup_remote_cached_empty: "Click Refresh to load this destination.",
txt_backup_remote_empty: "No backup files found in this folder.",
@@ -126,6 +194,11 @@ const messages: Record<Locale, Record<string, string>> = {
txt_backup_remote_delete_confirm_message: "Delete backup file \"{name}\"? This cannot be undone.",
txt_backup_remote_deleting: "Deleting...",
txt_backup_remote_restore_failed: "Restoring remote backup failed",
txt_backup_restore_checksum_warning_title: "Backup Integrity Warning",
txt_backup_restore_checksum_warning_message: "The selected backup file \"{name}\" failed filename integrity verification. Expected prefix {expected}, actual prefix {actual}. The file may be incomplete or corrupted. Continuing may restore damaged data.",
txt_backup_remote_restore_checksum_warning_message: "The remote backup file \"{name}\" failed filename integrity verification. Expected prefix {expected}, actual prefix {actual}. The file may be corrupted during upload or storage. Continuing may restore damaged data and may cause serious data loss.",
txt_backup_restore_checksum_warning_message_fallback: "The selected backup file failed integrity verification. Continuing may restore damaged data.",
txt_backup_restore_checksum_warning_confirm: "Continue Restore",
txt_backup_remote_restore_invalid_response: "Invalid remote backup restore response",
txt_backup_remote_run_invalid_response: "Invalid remote backup run response",
txt_backup_settings_invalid_response: "Invalid backup settings response",
@@ -140,6 +213,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_backup_type: "Backup Type",
txt_backup_destination_reserved: "Reserved Slot",
txt_backup_time: "Backup Time",
txt_backup_start_time: "Start Time",
txt_backup_timezone: "Timezone",
txt_backup_interval_hours: "Every",
txt_backup_interval_hours_suffix: "hours",
@@ -164,10 +238,10 @@ const messages: Record<Locale, Record<string, string>> = {
txt_backup_include_attachments_help_button: "Attachment backup help",
txt_backup_include_attachments_help: "Attachments are stored incrementally in the remote attachments folder, so later backups usually only upload new files. Deleting an attachment locally does not remove earlier remote copies. During restore, NodeWarden reads the required files from the attachments folder and skips any attachment that is no longer available.",
txt_backup_enable_schedule: "Enable automatic daily backup",
txt_backup_schedule_note: "The worker checks the schedule every 5 minutes and runs the backup as soon as the selected time window is reached.",
txt_backup_schedule_note: "The worker checks the schedule every 5 minutes. It starts at the selected time in the selected timezone, then repeats by the chosen hour interval, and resets from that start time each day.",
txt_backup_schedule_disabled: "Disabled",
txt_backup_schedule_status: "Schedule",
txt_backup_schedule_summary: "Daily at {time} ({timezone})",
txt_backup_schedule_summary: "Start at {time}, every {interval} hours ({timezone})",
txt_backup_schedule_empty: "No automatic backup plans are enabled yet.",
txt_backup_last_success: "Last Success",
txt_backup_last_target: "Last Target",
@@ -196,9 +270,9 @@ const messages: Record<Locale, Record<string, string>> = {
txt_backup_no_file_selected: "No backup file selected",
txt_backup_selected_file_name: "Selected file: {name}",
txt_backup_replace_confirm_title: "Replace Current Instance Data",
txt_backup_replace_confirm_message: "The current instance already contains data. Clear it and restore the selected backup?",
txt_backup_clear_and_import: "Clear and Import",
txt_backup_clear_and_restore: "Clear and Restore",
txt_backup_replace_confirm_message: "The current instance already contains data. Continue restoring and replace the current instance data with the selected backup after verification succeeds?",
txt_backup_clear_and_import: "Replace and Import",
txt_backup_clear_and_restore: "Replace and Restore",
txt_access_count: "Access Count",
txt_accessed_count_times: "Accessed {count} times",
txt_actions: "Actions",
@@ -280,7 +354,23 @@ const messages: Record<Locale, Record<string, string>> = {
txt_delete_item: "Delete Item",
txt_delete_item_failed: "Delete item failed",
txt_delete_permanently: "Delete Permanently",
txt_delete_selected: "Delete Selected",
txt_archive: "Archive",
txt_archive_item: "Archive Item",
txt_archive_item_message: "After archiving, this item will be excluded from general search results and autofill suggestions.",
txt_archive_selected_items: "Archive Items",
txt_archive_selected_items_message: "After archiving, {count} selected items will be excluded from general search results and autofill suggestions.",
txt_archived: "Archived",
txt_archive_selected: "Archive",
txt_item_archived: "Item archived",
txt_item_unarchived: "Item unarchived",
txt_archived_selected_items: "Archived selected items",
txt_unarchived_selected_items: "Unarchived selected items",
txt_archive_item_failed: "Archive item failed",
txt_unarchive_item_failed: "Unarchive item failed",
txt_bulk_archive_failed: "Bulk archive failed",
txt_bulk_unarchive_failed: "Bulk unarchive failed",
txt_unarchive: "Unarchive",
txt_delete_selected: "Delete",
txt_delete_selected_items: "Delete Selected Items",
txt_delete_selected_items_permanently: "Delete Selected Items Permanently",
txt_delete_send_failed: "Delete send failed",
@@ -434,6 +524,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_master_password_reprompt_2: "Master Password Reprompt",
txt_max_access_count: "Max Access Count",
txt_middle_name: "Middle Name",
txt_drag_to_reorder: "Drag to reorder",
txt_move: "Move",
txt_move_selected_items: "Move Selected Items",
txt_moved_selected_items: "Moved selected items",
@@ -528,6 +619,8 @@ const messages: Record<Locale, Record<string, string>> = {
txt_save_profile_failed: "Save profile failed",
txt_search_sends: "Search sends...",
txt_search_your_secure_vault: "Search your secure vault...",
txt_clear_search: "Clear search",
txt_clear_search_esc: "Clear search (Esc)",
txt_sort: "Sort",
txt_sort_last_edited: "Modified",
txt_sort_created: "Created",
@@ -556,6 +649,8 @@ const messages: Record<Locale, Record<string, string>> = {
txt_submit: "Submit",
txt_sync: "Sync",
txt_sync_vault: "Sync Vault",
txt_switch_to_dark_mode: "Switch to dark mode",
txt_switch_to_light_mode: "Switch to light mode",
txt_dash: "-",
txt_text: "Text",
txt_text_2fa_recovered: "2FA recovered",
@@ -610,10 +705,18 @@ const messages: Record<Locale, Record<string, string>> = {
txt_user_deleted: "User deleted",
txt_user_status_updated: "User status updated",
txt_username: "Username",
txt_uri_match_default_base_domain: "Default (Base Domain)",
txt_uri_match_base_domain: "Base Domain",
txt_uri_match_host: "Host",
txt_uri_match_exact: "Exact",
txt_uri_match_never: "Never",
txt_uri_match_starts_with: "Starts With",
txt_uri_match_regular_expression: "Regular Expression",
txt_users: "Users",
txt_vault_synced: "Vault synced",
txt_verification_code: "Verification Code",
txt_verify: "Verify",
txt_warning: "Warning",
txt_view_recovery_code: "View Recovery Code",
txt_web: "Web",
txt_website: "Website",
@@ -647,11 +750,16 @@ const zhCNOverrides: Record<string, string> = {
txt_backup_export_success: '备份已导出',
txt_backup_import_success_relogin: '备份已还原,请重新登录',
txt_backup_restore_success_relogin: '备份已还原,请重新登录',
txt_backup_restore_completed_verified: '备份文件完整性校验已通过。',
txt_backup_restore_completed_without_checksum: '备份已还原,但文件名中未提供可校验的完整性标记。',
txt_backup_remote_restore_completed_verified: '远程备份完整性校验已通过。',
txt_backup_remote_restore_completed_without_checksum: '远程备份已还原,但文件名中未提供可校验的完整性标记。',
txt_backup_restore_skipped_summary: '{reason},已跳过 {attachments} 个附件',
txt_backup_restore_skipped_reason_default: '部分文件无法还原',
txt_backup_export_failed: '备份导出失败',
txt_backup_import_failed: '备份还原失败',
txt_backup_restore_failed: '备份还原失败',
txt_backup_integrity_check_failed: '备份完整性校验失败',
txt_backup_center_title: '实例备份',
txt_backup_center_description: '把本地导出和远程自动备份放在一起管理,既方便手动恢复,也能每天自动留一份。',
txt_backup_restore_note: '还原会覆盖当前实例;如果当前已有数据,系统会要求你确认“清空后还原”。',
@@ -720,6 +828,7 @@ const zhCNOverrides: Record<string, string> = {
txt_backup_run_manual: '手动执行',
txt_backup_running_now: '执行中...',
txt_backup_remote_run_success: '远程备份已完成',
txt_backup_remote_run_success_verified: '远程备份已完成,且完整性校验已通过。',
txt_backup_remote_run_failed: '远程备份失败',
txt_backup_remote_title: '远端备份',
txt_backup_remote_note: '浏览已保存的备份地点,选择某个备份 ZIP 后可以下载,也可以直接还原。',
@@ -733,6 +842,68 @@ const zhCNOverrides: Record<string, string> = {
txt_backup_remote_restore: '还原',
txt_backup_remote_restore_stage_prepare: '正在读取远端备份并检查可恢复内容...',
txt_backup_remote_restore_stage_replace: '正在清空当前数据并还原远端备份,请稍候...',
txt_backup_progress_kicker: '备份任务',
txt_backup_progress_subject: '当前对象:{name}',
txt_backup_restore_progress_kicker: '还原进度',
txt_backup_restore_progress_local_title: '正在还原本地备份',
txt_backup_restore_progress_remote_title: '正在还原远端备份',
txt_backup_export_progress_title: '正在导出备份',
txt_backup_remote_run_progress_title: '正在执行远程备份',
txt_backup_restore_progress_file: '当前文件:{name}',
txt_backup_restore_progress_elapsed: '已耗时 {seconds} 秒',
txt_backup_archive_progress_collect_title: '正在收集密码库数据',
txt_backup_archive_progress_collect_detail: '服务器正在读取数据库表,并整理备份所需的数据内容。',
txt_backup_archive_progress_collect_with_attachments_detail: '服务器正在读取数据库表,并整理附件元数据与备份内容。',
txt_backup_archive_progress_package_title: '正在打包备份压缩包',
txt_backup_archive_progress_package_detail: '服务器正在生成备份 ZIP,并计算文件名校验前缀。',
txt_backup_archive_progress_package_with_attachments_detail: '服务器正在生成带附件信息的备份 ZIP 元数据,并计算文件名校验前缀。',
txt_backup_archive_progress_ready_title: '正在准备下载',
txt_backup_archive_progress_ready_detail: '备份压缩包已经生成,服务器正在把它返回给浏览器。',
txt_backup_export_progress_fetch_attachments_title: '正在下载附件文件',
txt_backup_export_progress_fetch_attachments_detail: '浏览器正在读取附件对象,并把它们补入导出备份包。',
txt_backup_export_progress_rebuild_title: '正在重建导出压缩包',
txt_backup_export_progress_rebuild_detail: '浏览器正在重建最终 ZIP,并刷新文件名里的校验后缀。',
txt_backup_export_progress_save_title: '正在保存导出文件',
txt_backup_export_progress_save_detail: '浏览器正在准备最终的备份文件下载。',
txt_backup_export_progress_complete_title: '备份导出已完成',
txt_backup_export_progress_complete_detail: '导出备份已经准备完成。',
txt_backup_export_progress_failed_title: '备份导出失败',
txt_backup_export_progress_failed_detail: '导出备份未能完成。',
txt_backup_remote_run_progress_prepare_title: '正在准备远程备份',
txt_backup_remote_run_progress_prepare_detail: '服务器正在读取当前备份目标,并准备执行这次远程备份。',
txt_backup_remote_run_progress_sync_attachments_title: '正在检查附件索引',
txt_backup_remote_run_progress_sync_attachments_detail: '服务器正在比对附件索引,只会上传缺失或不一致的附件对象。',
txt_backup_remote_run_progress_sync_attachments_skipped_detail: '当前备份未包含附件,因此跳过附件同步。',
txt_backup_remote_run_progress_upload_title: '正在上传备份压缩包',
txt_backup_remote_run_progress_upload_detail: '服务器正在把备份 ZIP 上传到远程备份目标。',
txt_backup_remote_run_progress_verify_title: '正在校验已上传压缩包',
txt_backup_remote_run_progress_verify_detail: '服务器正在回读刚上传的 ZIP,并校验它的哈希和大小。',
txt_backup_remote_run_progress_cleanup_title: '正在清理旧备份',
txt_backup_remote_run_progress_cleanup_detail: '服务器正在按保留策略清理旧备份文件。',
txt_backup_remote_run_progress_complete_title: '远程备份已完成',
txt_backup_remote_run_progress_complete_detail: '远程备份已上传完成,并通过完整性校验。',
txt_backup_remote_run_progress_failed_title: '远程备份失败',
txt_backup_remote_run_progress_failed_detail: '远程备份未能完成。',
txt_backup_restore_progress_local_upload_title: '正在上传备份包',
txt_backup_restore_progress_local_upload_detail: '已选 ZIP 正在发送到服务器,服务器收到后会开始执行还原。',
txt_backup_restore_progress_local_shadow_title: '正在创建影子恢复区',
txt_backup_restore_progress_local_shadow_detail: '服务器正在准备独立的影子数据区,只有校验通过后才会替换正式数据。',
txt_backup_restore_progress_local_data_title: '正在写入密码库数据',
txt_backup_restore_progress_local_data_detail: '服务器正在把用户、文件夹、密码条目和相关元数据写入影子表。',
txt_backup_restore_progress_local_files_title: '正在恢复附件文件',
txt_backup_restore_progress_local_files_detail: '服务器正在把附件对象写回存储,并剔除无法恢复的附件记录。',
txt_backup_restore_progress_local_finalize_title: '正在校验并完成切换',
txt_backup_restore_progress_local_finalize_detail: '服务器正在执行最终校验,校验通过后会把已验证的数据切换为正式数据。',
txt_backup_restore_progress_remote_fetch_title: '正在读取远端备份包',
txt_backup_restore_progress_remote_fetch_detail: '服务器正在从远端备份目标下载你选中的备份包。',
txt_backup_restore_progress_remote_shadow_title: '正在创建影子恢复区',
txt_backup_restore_progress_remote_shadow_detail: '服务器正在准备独立的影子数据区,只有校验通过后才会替换正式数据。',
txt_backup_restore_progress_remote_data_title: '正在写入密码库数据',
txt_backup_restore_progress_remote_data_detail: '服务器正在把用户、文件夹、密码条目和相关元数据写入影子表。',
txt_backup_restore_progress_remote_files_title: '正在恢复远端附件',
txt_backup_restore_progress_remote_files_detail: '服务器正在从远端存储读取所需附件,并写回到当前实例的附件存储。',
txt_backup_restore_progress_remote_finalize_title: '正在校验并完成切换',
txt_backup_restore_progress_remote_finalize_detail: '服务器正在执行最终校验,校验通过后会把已验证的数据切换为正式数据。',
txt_backup_remote_loading: '正在读取远端备份...',
txt_backup_remote_cached_empty: '点击“刷新”后读取',
txt_backup_remote_empty: '这个目录下还没有备份文件',
@@ -747,6 +918,11 @@ const zhCNOverrides: Record<string, string> = {
txt_backup_remote_delete_confirm_message: '删除备份文件“{name}”?此操作不可撤销。',
txt_backup_remote_deleting: '删除中...',
txt_backup_remote_restore_failed: '还原远端备份失败',
txt_backup_restore_checksum_warning_title: '备份完整性警告',
txt_backup_restore_checksum_warning_message: '所选备份文件“{name}”未通过文件名完整性校验。期望前缀为 {expected},实际计算结果为 {actual}。该文件可能不完整或已经损坏。继续还原可能会导入受损数据。',
txt_backup_remote_restore_checksum_warning_message: '远程备份文件“{name}”未通过文件名完整性校验。期望前缀为 {expected},实际计算结果为 {actual}。该文件可能在上传或存储过程中损坏。继续还原可能会导入受损数据,并可能造成严重后果。',
txt_backup_restore_checksum_warning_message_fallback: '所选备份文件未通过完整性校验。继续还原可能会导入受损数据。',
txt_backup_restore_checksum_warning_confirm: '继续还原',
txt_backup_remote_restore_invalid_response: '远端备份还原响应无效',
txt_backup_remote_run_invalid_response: '远端备份执行响应无效',
txt_backup_settings_invalid_response: '备份设置响应无效',
@@ -761,6 +937,7 @@ const zhCNOverrides: Record<string, string> = {
txt_backup_type: '备份类型',
txt_backup_destination_reserved: '预留位置',
txt_backup_time: '备份时间',
txt_backup_start_time: '开始时间',
txt_backup_timezone: '时区',
txt_backup_interval_hours: '每隔',
txt_backup_interval_hours_suffix: '小时',
@@ -785,10 +962,10 @@ const zhCNOverrides: Record<string, string> = {
txt_backup_include_attachments_help_button: '附件备份说明',
txt_backup_include_attachments_help: '附件会以增量方式保存在远端的 attachments 文件夹中,后续备份通常只上传新增文件。你在本地删除附件时,已经备份到远端的旧文件不会自动删除。恢复时会按需从 attachments 文件夹读取对应附件,找不到的附件会自动跳过。',
txt_backup_enable_schedule: '启用每日自动备份',
txt_backup_schedule_note: 'Worker 每 5 分钟检查一次计划,到达你设定的时间窗口后会尽快执行备份。',
txt_backup_schedule_note: 'Worker 每 5 分钟检查一次计划。会先按你选择的时区和开始时间起跑,再按小时间隔继续执行;到了下一天,会重新从开始时间开始。',
txt_backup_schedule_disabled: '未启用',
txt_backup_schedule_status: '计划状态',
txt_backup_schedule_summary: '每天 {time}{timezone}',
txt_backup_schedule_summary: ' {time} 开始,每隔 {interval} 小时{timezone}',
txt_backup_schedule_empty: '还没有启用任何自动备份计划',
txt_backup_last_success: '上次成功时间',
txt_backup_last_target: '上次备份位置',
@@ -817,9 +994,9 @@ const zhCNOverrides: Record<string, string> = {
txt_backup_no_file_selected: '尚未选择备份文件',
txt_backup_selected_file_name: '已选择文件:{name}',
txt_backup_replace_confirm_title: '替换当前实例数据',
txt_backup_replace_confirm_message: '当前实例里已经有数据。要先清空当前数据库和文件,再还原所选备份吗',
txt_backup_clear_and_import: '清空后导入',
txt_backup_clear_and_restore: '清空后还原',
txt_backup_replace_confirm_message: '当前实例里已经有数据。确认后,系统会先完成校验与恢复准备,只有在恢复成功后才会用所选备份替换当前实例数据。是否继续',
txt_backup_clear_and_import: '替换并导入',
txt_backup_clear_and_restore: '替换并还原',
txt_sign_out: '退出登录',
txt_log_in: '登录',
txt_logging_in: '正在登录...',
@@ -844,6 +1021,8 @@ const zhCNOverrides: Record<string, string> = {
txt_loading_nodewarden: '正在加载 NodeWarden...',
txt_search_sends: '搜索发送...',
txt_search_your_secure_vault: '搜索你的密码库...',
txt_clear_search: '清空搜索',
txt_clear_search_esc: '清空搜索(Esc',
txt_refresh: '刷新',
txt_sync: '同步',
txt_sync_vault: '同步',
@@ -852,13 +1031,14 @@ const zhCNOverrides: Record<string, string> = {
txt_delete: '删除',
txt_save: '保存',
txt_confirm: '确认',
txt_drag_to_reorder: '拖动调整顺序',
txt_move: '移动',
txt_copy: '复制',
txt_code_copied: '验证码已复制',
txt_copy_link: '复制链接',
txt_select_all: '全选',
txt_select_duplicate_items: '选择重复项',
txt_delete_selected: '删除所选',
txt_delete_selected: '删除',
txt_all_items: '所有项目',
txt_favorites: '收藏',
txt_duplicates: '重复项',
@@ -888,6 +1068,13 @@ const zhCNOverrides: Record<string, string> = {
txt_last_edited_value: '最后编辑:{value}',
txt_created_value: '创建于:{value}',
txt_username: '用户名',
txt_uri_match_default_base_domain: '默认(基础域名)',
txt_uri_match_base_domain: '基础域名',
txt_uri_match_host: '主机',
txt_uri_match_exact: '精确',
txt_uri_match_never: '从不',
txt_uri_match_starts_with: '开始于',
txt_uri_match_regular_expression: '正则表达式',
txt_website: '网站',
txt_websites: '网站',
txt_open: '打开',
@@ -1185,6 +1372,8 @@ const zhCNOverrides: Record<string, string> = {
txt_totp_is_enabled_for_this_account: '此账户已启用 TOTP。',
txt_total_items_count: '共 {count} 项',
txt_totp_verify_failed: 'TOTP 验证失败',
txt_switch_to_dark_mode: '切换到暗黑模式',
txt_switch_to_light_mode: '切换到明亮模式',
txt_trust_this_device_for_30_days: '信任此设备 30 天',
txt_type_type: '类型 {type}',
txt_unlock_details: '解锁详情',
@@ -1201,6 +1390,7 @@ const zhCNOverrides: Record<string, string> = {
txt_user_status_updated: '用户状态已更新',
txt_vault_synced: '密码库已同步',
txt_verify: '验证',
txt_warning: '警告',
txt_web: '网页',
txt_windows_desktop: 'Windows 桌面端',
txt_jwt_warning_title: 'JWT_SECRET 配置警告',
@@ -1363,6 +1553,22 @@ zhCNOverrides.txt_import_encrypted_zip_message = '该 ZIP 压缩包已加密,
zhCNOverrides.txt_import_export_title = '导入导出';
zhCNOverrides.txt_new_type_header = '新建{type}';
zhCNOverrides.txt_edit_type_header = '编辑{type}';
zhCNOverrides.txt_archive = '归档';
zhCNOverrides.txt_archive_item = '归档项目';
zhCNOverrides.txt_archive_item_message = '归档后,此项目将被排除在一般搜索结果和自动填充建议之外。';
zhCNOverrides.txt_archive_selected_items = '归档项目';
zhCNOverrides.txt_archive_selected_items_message = '归档后,所选的 {count} 个项目将被排除在一般搜索结果和自动填充建议之外。';
zhCNOverrides.txt_archived = '已归档';
zhCNOverrides.txt_archive_selected = '归档';
zhCNOverrides.txt_item_archived = '项目已归档';
zhCNOverrides.txt_item_unarchived = '项目已取消归档';
zhCNOverrides.txt_archived_selected_items = '已归档所选项目';
zhCNOverrides.txt_unarchived_selected_items = '已取消归档所选项目';
zhCNOverrides.txt_archive_item_failed = '归档项目失败';
zhCNOverrides.txt_unarchive_item_failed = '取消归档项目失败';
zhCNOverrides.txt_bulk_archive_failed = '批量归档失败';
zhCNOverrides.txt_bulk_unarchive_failed = '批量取消归档失败';
zhCNOverrides.txt_unarchive = '取消归档';
zhCNOverrides.txt_delete_folder = '删除文件夹';
zhCNOverrides.txt_delete_folder_message = '删除文件夹「{name}」?其中的项目将移至无文件夹。';
zhCNOverrides.txt_delete_all_folders = '删除全部文件夹';
+8 -1
View File
@@ -28,9 +28,15 @@ export interface Folder {
export interface CipherLoginUri {
uri?: string | null;
match?: number | null;
decUri?: string;
}
export interface VaultDraftLoginUri {
uri: string;
match: number | null;
}
export interface CipherAttachment {
id?: string;
url?: string | null;
@@ -143,6 +149,7 @@ export interface Cipher {
creationDate?: string;
revisionDate?: string;
deletedDate?: string | null;
archivedDate?: string | null;
attachments?: CipherAttachment[] | null;
login?: CipherLogin | null;
card?: CipherCard | null;
@@ -220,7 +227,7 @@ export interface VaultDraft {
loginUsername: string;
loginPassword: string;
loginTotp: string;
loginUris: string[];
loginUris: VaultDraftLoginUri[];
loginFido2Credentials: Array<Record<string, unknown>>;
cardholderName: string;
cardNumber: string;
+1854 -139
View File
File diff suppressed because it is too large Load Diff