mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 14:50:11 +00:00
Compare commits
15
Commits
beta
..
58a86ae8fd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58a86ae8fd | ||
|
|
b986af86dc | ||
|
|
8e33f92b33 | ||
|
|
a0f832e8a5 | ||
|
|
8a5b210a1d | ||
|
|
ebc8e8e340 | ||
|
|
a870142b7b | ||
|
|
a366acbac0 | ||
|
|
57c5ef9da6 | ||
|
|
f532d3ace3 | ||
|
|
cc4a830be8 | ||
|
|
c6438747e3 | ||
|
|
5c8f01be59 | ||
|
|
7ac6ae50bb | ||
|
|
ace00e8e74 |
@@ -3,95 +3,100 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
|
Bitwarden-compatible server running on Cloudflare Workers
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
|
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
|
||||||
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
|
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
|
||||||
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
|
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
|
||||||
<a href="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml"><img src="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg" alt="Sync Upstream" /></a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
|
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
|
||||||
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a>
|
<a href="https://t.me/NodeWarden_Official">Telegram Group</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="./README_EN.md">English</a> |
|
<a href="./README_ZH.md">中文</a> |
|
||||||
<a href="./CONTRIBUTING.md">贡献指南</a>
|
<a href="./CONTRIBUTING.md">Contributing</a> |
|
||||||
|
<a href="https://nodewarden.app">Official wiki</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> **免责声明**
|
> **Disclaimer**
|
||||||
> 本项目仅供学习与交流使用,请定期备份你的密码库。
|
> This project is for learning and discussion purposes only. Please back up your vault regularly.
|
||||||
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
|
> This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 与 Bitwarden 官方服务端能力对比
|
## Feature comparison with the official Bitwarden server
|
||||||
|
|
||||||
| 能力 | Bitwarden | NodeWarden | 说明 |
|
| Feature | Bitwarden Free | NodeWarden | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
|
| Web vault | ✅ | ✅ | **Original Web Vault UI** |
|
||||||
| **PWA 支持** | ⚠️ 基础 | ✅ | **可安装、离线使用、App快捷方式** |
|
| TOTP | ❌ | ✅ | Includes `steam://` support |
|
||||||
| **Web Vault 离线查看** | ❌ | ✅ | **网页端支持离线查看保险库** |
|
| **PWA / offline** | ❌ | ✅ | **Installable, offline** |
|
||||||
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
|
| **Passkey login** | ✅ | ✅ | **passwordless auth** |
|
||||||
| 实时同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 |
|
| API keys | ✅ | ✅ | CLI keys; create and rotate |
|
||||||
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
|
| Login 2FA | ✅ | ✅ | TOTP, YubiKey, Passkey |
|
||||||
| Send | ✅ | ✅ | 支持文本与文件 Send |
|
| 2FA recovery codes | ✅ | ✅ | One-time 2FA disable codes |
|
||||||
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
|
| Real-time push sync | ✅ | ✅ | All device sync |
|
||||||
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时备份(OneDrive/Google Drive等)** |
|
| Attachments / Send | ✅ | ✅ | Cloudflare R2 or KV |
|
||||||
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
|
| Import / export | ✅ | ✅ | Bitwarden JSON / CSV / **ZIP** |
|
||||||
| TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 |
|
| **Cloud backup center** | ❌ | ✅ | **Scheduled WebDAV / S3 incrementals** |
|
||||||
| 多用户 | ✅ | ✅ | 支持邀请码注册 |
|
| Device management | ✅ | ✅ | **Remove devices; trust controls** |
|
||||||
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
|
| Login requests | ✅ | ✅ | **Cross-device login approval/unlock** |
|
||||||
| 登录 2FA | ✅ | ⚠️ 部分支持 | 支持TOTP和Passkey(作为第二因素) |
|
| **Multi-user** | ✅ | ✅ | Invite-code registration |
|
||||||
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
|
| Domain rules | ✅ | ✅ | Equivalent domains, global exclusions |
|
||||||
|
| Fill-assist | ✅ | ✅ | `POST /fill-assist`|
|
||||||
|
| Organizations / collections / roles | ✅ | ❌ | Not implemented |
|
||||||
|
| SSO / SCIM / directory | ✅ | ❌ | Not implemented |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 已测试客户端
|
## Tested clients
|
||||||
|
|
||||||
- ✅ Windows 桌面端
|
- ✅ Windows desktop
|
||||||
- ✅ 手机 App
|
- ✅ Mobile app
|
||||||
- ✅ 浏览器扩展
|
- ✅ Browser extension
|
||||||
- ✅ Linux 桌面端
|
- ✅ Linux desktop
|
||||||
- ⚠️ macOS 桌面端尚未完整验证
|
- ⚠️ macOS desktop not fully verified yet
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 可视化快速部署
|
## Visual quick deploy
|
||||||
|
|
||||||
1. Fork NodeWarden 仓库到自己的 GitHub 账号
|
1. Fork the NodeWarden repository to your GitHub account
|
||||||
2. 进入 [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
2. Open [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
||||||
3. 选择 Continue with GitHub 并选择你的仓库
|
3. Choose **Continue with GitHub** and select your fork
|
||||||
4. 构建命令填 `npm run build`,部署命令填 `npm run deploy`
|
4. Set **build command** to `npm run build` and **deploy command** to `npm run deploy`
|
||||||
- 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
|
- For KV mode, change the deploy command to `npm run deploy:kv`
|
||||||
5. 等部署完成后,打开生成的 Workers 域名
|
5. After deployment finishes, open the generated Workers URL
|
||||||
|
|
||||||
- Workers 默认域名在部分网络环境不可直连。如需自定义域名,到 [Workers 设置](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings)里添加。
|
- The default Workers hostname may be unreachable on some networks. To use a custom domain, add it in [Workers settings](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings).
|
||||||
|
|
||||||
- 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。
|
- If the site reports a missing `JWT_SECRET`, add it as a **Secret** in Workers settings. In production use a random string of at least 32 characters; do not use temporary or example values.
|
||||||
|
|
||||||
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml` 或 `wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。
|
- In this flow you hand code to Cloudflare to build and deploy. `wrangler.toml` or `wrangler.kv.toml` in the repo defines binding names; the Worker initializes the D1 schema on first request—no manual SQL upload.
|
||||||
|
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> 默认R2与可选KV的区别:
|
> Default R2 vs optional KV:
|
||||||
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|
> | Storage | Card required | Max single attachment / Send file | Free tier |
|
||||||
> |---|---|---|---|
|
> |---|---|---|---|
|
||||||
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
|
> | R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
|
||||||
> | KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
> | KV | No | 25 MiB (Cloudflare limit) | 1 GB |
|
||||||
|
|
||||||
|
|
||||||
## 更新方法:
|
## How to update
|
||||||
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork` ➜ `Update branch`
|
|
||||||
- 自动:进入你的 Fork 仓库 ➜ `Actions` ➜ `Sync upstream` ➜ `Enable workflow`,会在每天凌晨 3 点自动同步上游。
|
- Manual: open your fork on GitHub; when the sync banner appears, click **Sync fork** → **Update branch**
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## CLI 部署
|
|
||||||
|
## CLI deploy
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||||
@@ -100,82 +105,31 @@ cd NodeWarden
|
|||||||
npm install
|
npm install
|
||||||
npx wrangler login
|
npx wrangler login
|
||||||
|
|
||||||
# 默认:R2 模式
|
# Default: R2 mode
|
||||||
npm run deploy
|
npm run deploy
|
||||||
|
|
||||||
# 可选:KV 模式
|
# Optional: KV mode
|
||||||
npm run deploy:kv
|
npm run deploy:kv
|
||||||
|
|
||||||
# 本地开发
|
# Local development
|
||||||
npm run dev
|
npm run dev
|
||||||
npm run dev:kv
|
npm run dev:kv
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 主要特性
|
|
||||||
|
|
||||||
### PWA 渐进式 Web 应用
|
## License
|
||||||
|
|
||||||
- ✅ **可安装到桌面** - 像原生应用一样运行
|
|
||||||
- ✅ **离线使用** - Service Worker 缓存,离线也能查看密码
|
|
||||||
- ✅ **App 快捷方式** - 快速启动保险库、TOTP代码
|
|
||||||
- ✅ **后台解密** - Web Worker 处理解密,不阻塞UI
|
|
||||||
|
|
||||||
### Passkey 无密码登录
|
|
||||||
|
|
||||||
- ✅ **WebAuthn/FIDO2 支持** - 使用指纹、Face ID等登录
|
|
||||||
- ✅ **PRF 密钥解锁** - Passkey 可直接解锁保险库
|
|
||||||
- ✅ **官方客户端兼容** - Chromium系浏览器扩展可用Passkey登录
|
|
||||||
- ✅ **多设备同步** - 支持iCloud、Google Password Manager等
|
|
||||||
|
|
||||||
### 云端备份说明
|
|
||||||
|
|
||||||
- 远程备份支持 **WebDAV** 与 **S3**
|
|
||||||
- 支持 **OneDrive**(通过Koofr)、**Google Drive**(通过Koofr)、**Cloudflare R2**、**Backblaze B2** 等
|
|
||||||
- 勾选”包含附件”后:
|
|
||||||
- ZIP 内仍只包含 `db.json` 与 `manifest.json`
|
|
||||||
- 真实附件单独存放在 `attachments/`
|
|
||||||
- 后续备份会按稳定 blob 名复用已有附件,不会每次全量重传
|
|
||||||
- 远程还原时:
|
|
||||||
- 会从 `attachments/` 目录按需读取附件
|
|
||||||
- 缺失的附件会被安全跳过
|
|
||||||
- 被跳过的附件不会在恢复后的数据库中留下脏记录
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 导入 / 导出
|
|
||||||
|
|
||||||
当前支持的导入来源包括:
|
|
||||||
|
|
||||||
- Bitwarden JSON
|
|
||||||
- Bitwarden CSV
|
|
||||||
- Bitwarden 密码库 + 附件 ZIP
|
|
||||||
- NodeWarden JSON
|
|
||||||
- 网页导入器里可见的多种浏览器 / 密码管理器格式
|
|
||||||
|
|
||||||
当前支持的导出方式包括:
|
|
||||||
|
|
||||||
- Bitwarden JSON
|
|
||||||
- Bitwarden 加密 JSON
|
|
||||||
- 带附件的 ZIP 导出
|
|
||||||
- NodeWarden JSON 系列
|
|
||||||
- 备份中心中的实例级完整手动导出
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
## 开源协议
|
|
||||||
|
|
||||||
LGPL-3.0 License
|
LGPL-3.0 License
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 致谢
|
## Credits
|
||||||
|
|
||||||
- [Bitwarden](https://bitwarden.com/) - 原始设计与客户端
|
- [Bitwarden](https://bitwarden.com/) - Original design and clients
|
||||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务端实现参考
|
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - Server implementation reference
|
||||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
|
- [Cloudflare Workers](https://workers.cloudflare.com/) - Serverless platform
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
-172
@@ -1,172 +0,0 @@
|
|||||||
<p align="center">
|
|
||||||
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
Bitwarden-compatible server running on Cloudflare Workers
|
|
||||||
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
|
|
||||||
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
|
|
||||||
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
|
|
||||||
<a href="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml"><img src="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg" alt="Sync Upstream" /></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
|
|
||||||
<a href="https://t.me/NodeWarden_Official">Telegram Group</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="./README.md">中文说明</a> |
|
|
||||||
<a href="./CONTRIBUTING.md">Contributing</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
> **Disclaimer**
|
|
||||||
>
|
|
||||||
> This project is for learning and discussion 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 with the Official Bitwarden Server
|
|
||||||
|
|
||||||
| Capability | Bitwarden | NodeWarden | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Web Vault | ✅ | ✅ | **Original Web Vault interface** |
|
|
||||||
| **PWA Support** | ⚠️ Basic | ✅ | **Installable, offline-capable, app shortcuts** |
|
|
||||||
| **Web Vault Offline Access** | ❌ | ✅ | **Web client supports offline vault viewing** |
|
|
||||||
| **Passkey Login** | ✅ | ✅ | **WebAuthn/FIDO2 passwordless login** |
|
|
||||||
| Real-time sync | ✅ | ✅ | Web, browser extension, desktop, and mobile clients stay in sync in real time |
|
|
||||||
| 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** | ❌ | ✅ | **WebDAV / S3 scheduled backup (OneDrive/Google Drive etc.)** |
|
|
||||||
| 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 | TOTP and Passkey (as second factor) |
|
|
||||||
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not implemented |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tested Clients
|
|
||||||
|
|
||||||
- ✅ Windows desktop client
|
|
||||||
- ✅ Mobile app
|
|
||||||
- ✅ Browser extension
|
|
||||||
- ✅ Linux desktop client
|
|
||||||
- ⚠️ macOS desktop client has not been fully verified yet
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Web Deploy
|
|
||||||
|
|
||||||
1. Fork this repository. If this project helps you, 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`) -> continue.
|
|
||||||
3. R2 is used by default. If R2 is not enabled on your account, you can use KV instead by changing the **deploy command** to `npm run deploy:kv`.
|
|
||||||
4. Deploy and open the generated URL.
|
|
||||||
|
|
||||||
| Storage | Card required | Single attachment / Send file limit | Free tier |
|
|
||||||
|---|---|---|---|
|
|
||||||
| R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
|
|
||||||
| KV | No | 25 MiB (Cloudflare limit) | 1 GB |
|
|
||||||
|
|
||||||
> [!TIP]
|
|
||||||
> How to keep your fork updated:
|
|
||||||
> - Manual: open your fork on GitHub, click `Sync fork`, then `Update branch`
|
|
||||||
> - Automatic: go to your fork -> `Actions` -> `Sync upstream` -> `Enable workflow`; it will sync upstream automatically every day at 3 AM
|
|
||||||
|
|
||||||
## CLI Deploy
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
|
||||||
cd NodeWarden
|
|
||||||
npm install
|
|
||||||
npx wrangler login
|
|
||||||
|
|
||||||
# Default: R2 mode
|
|
||||||
npm run deploy
|
|
||||||
|
|
||||||
# Optional: KV mode
|
|
||||||
npm run deploy:kv
|
|
||||||
|
|
||||||
# Local development
|
|
||||||
npm run dev
|
|
||||||
npm run dev:kv
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Features
|
|
||||||
|
|
||||||
### PWA Progressive Web App
|
|
||||||
|
|
||||||
- ✅ **Install to desktop** - Runs like a native app
|
|
||||||
- ✅ **Offline usage** - Service Worker caching, view passwords offline
|
|
||||||
- ✅ **App shortcuts** - Quick launch vault, TOTP codes
|
|
||||||
- ✅ **Background decryption** - Web Worker handles decryption without blocking UI
|
|
||||||
|
|
||||||
### Passkey Passwordless Login
|
|
||||||
|
|
||||||
- ✅ **WebAuthn/FIDO2 support** - Login with fingerprint, Face ID, etc.
|
|
||||||
- ✅ **PRF key unlock** - Passkey can unlock vault directly
|
|
||||||
- ✅ **Official client compatibility** - Chromium browser extension supports Passkey login
|
|
||||||
- ✅ **Multi-device sync** - Supports iCloud, Google Password Manager, etc.
|
|
||||||
|
|
||||||
### Cloud Backup Notes
|
|
||||||
|
|
||||||
- Remote backup supports **WebDAV** and **S3**
|
|
||||||
- Supports **OneDrive** (via Koofr), **Google Drive** (via Koofr), **Cloudflare R2**, **Backblaze B2**, etc.
|
|
||||||
- When `Include attachments` is enabled:
|
|
||||||
- the ZIP still contains only `db.json` and `manifest.json`
|
|
||||||
- actual attachment files are stored separately under `attachments/`
|
|
||||||
- later backups reuse existing attachments by stable blob name instead of re-uploading everything every time
|
|
||||||
- During remote restore:
|
|
||||||
- required attachment files are loaded from `attachments/` on demand
|
|
||||||
- missing attachments are skipped safely
|
|
||||||
- skipped attachments do not leave broken rows in the restored database
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Import / Export
|
|
||||||
|
|
||||||
Current supported import sources include:
|
|
||||||
|
|
||||||
- Bitwarden JSON
|
|
||||||
- Bitwarden CSV
|
|
||||||
- Bitwarden vault + attachments ZIP
|
|
||||||
- NodeWarden JSON
|
|
||||||
- Multiple browser / password-manager formats available 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
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
LGPL-3.0 License
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Credits
|
|
||||||
|
|
||||||
- [Bitwarden](https://bitwarden.com/) - Original design and clients
|
|
||||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - Server implementation reference
|
|
||||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - Serverless platform
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Star History
|
|
||||||
|
|
||||||
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
|
||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
|
||||||
|
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
|
||||||
|
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
|
||||||
|
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="./README.md">English</a> |
|
||||||
|
<a href="./CONTRIBUTING.md">贡献指南</a> |
|
||||||
|
<a href="https://nodewarden.app">官方wiki</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
> **免责声明**
|
||||||
|
> 本项目仅供学习与交流使用,请定期备份你的密码库。
|
||||||
|
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 与 Bitwarden 官方服务端能力对比
|
||||||
|
|
||||||
|
| 能力 | Bitwarden免费版 | NodeWarden | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
|
||||||
|
| TOTP | ❌ | ✅ | 包括 `steam://` 支持 |
|
||||||
|
| **PWA / 离线使用** | ❌ | ✅ | **可安装、离线使用、App快捷方式** |
|
||||||
|
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
|
||||||
|
| API 密钥 | ✅ | ✅ | 供bitwarden cli使用,支持获取和轮换 |
|
||||||
|
| 登录 2FA | ✅ | ✅ | 支持 TOTP、YubiKey、Passkey |
|
||||||
|
| 2FA 恢复码 | ✅ | ✅ | 一次性恢复码用于禁用 2FA |
|
||||||
|
| 实时推送同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 |
|
||||||
|
| 附件 / Send| ✅ | ✅ | Cloudflare R2 或 KV |
|
||||||
|
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
|
||||||
|
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时增量备份** |
|
||||||
|
| 设备管理 | ✅ | ✅ | **删除设备、撤销信任、永久信任** |
|
||||||
|
| 登录请求 | ✅ | ✅ | **多端免密登录审批、跨设备解锁请求** |
|
||||||
|
| **多用户使用** | ✅ | ✅ | 支持邀请码注册 |
|
||||||
|
| 域名规则 | ✅ | ✅ | 自定义等效域名、全局域名排除 |
|
||||||
|
| Fill-assist | ✅ | ✅ | `POST /fill-assist` 辅助客户端自动填充;不能绕过保险库解锁 |
|
||||||
|
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
|
||||||
|
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已测试客户端
|
||||||
|
|
||||||
|
- ✅ Windows 桌面端
|
||||||
|
- ✅ 手机 App
|
||||||
|
- ✅ 浏览器扩展
|
||||||
|
- ✅ Linux 桌面端
|
||||||
|
- ⚠️ macOS 桌面端尚未完整验证
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 可视化快速部署
|
||||||
|
|
||||||
|
1. Fork NodeWarden 仓库到自己的 GitHub 账号
|
||||||
|
2. 进入 [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
||||||
|
3. 选择 Continue with GitHub 并选择你的仓库
|
||||||
|
4. 构建命令填 `npm run build`,部署命令填 `npm run deploy`
|
||||||
|
- 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
|
||||||
|
5. 等部署完成后,打开生成的 Workers 域名
|
||||||
|
|
||||||
|
- Workers 默认域名在部分网络环境不可直连。如需自定义域名,到 [Workers 设置](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings)里添加。
|
||||||
|
|
||||||
|
- 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。
|
||||||
|
|
||||||
|
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml` 或 `wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。
|
||||||
|
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 默认R2与可选KV的区别:
|
||||||
|
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|
||||||
|
> |---|---|---|---|
|
||||||
|
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
|
||||||
|
> | KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
||||||
|
|
||||||
|
|
||||||
|
## 更新方法:
|
||||||
|
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork` ➜ `Update branch`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## CLI 部署
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||||
|
cd NodeWarden
|
||||||
|
|
||||||
|
npm install
|
||||||
|
npx wrangler login
|
||||||
|
|
||||||
|
# 默认:R2 模式
|
||||||
|
npm run deploy
|
||||||
|
|
||||||
|
# 可选:KV 模式
|
||||||
|
npm run deploy:kv
|
||||||
|
|
||||||
|
# 本地开发
|
||||||
|
npm run dev
|
||||||
|
npm run dev:kv
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## 开源协议
|
||||||
|
|
||||||
|
LGPL-3.0 License
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
- [Bitwarden](https://bitwarden.com/) - 原始设计与客户端
|
||||||
|
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务端实现参考
|
||||||
|
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
||||||
Submodule
+1
Submodule nodewarden-wiki added at 70f838b044
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.7.2",
|
"version": "1.7.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.7.2",
|
"version": "1.7.3",
|
||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^2.2.0",
|
"@noble/hashes": "^2.2.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.7.2",
|
"version": "1.7.3",
|
||||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||||
"author": "shuaiplus",
|
"author": "shuaiplus",
|
||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = '1.7.2';
|
export const APP_VERSION = '1.7.3';
|
||||||
|
|||||||
@@ -1161,16 +1161,8 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
|
|||||||
return errorResponse('Two-factor provider is not supported by this server.', 400);
|
return errorResponse('Two-factor provider is not supported by this server.', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = normalizeTotpSecret(readBodyString(body, ['key', 'Key']));
|
|
||||||
const userVerificationToken = readBodyString(body, ['userVerificationToken', 'UserVerificationToken']);
|
|
||||||
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
|
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
|
||||||
let verified = false;
|
const verified = await verifyUserSecret(auth, user, secret);
|
||||||
if (key && userVerificationToken) {
|
|
||||||
verified = await verifyTotpUserVerificationToken(env, user, key, userVerificationToken);
|
|
||||||
}
|
|
||||||
if (!verified) {
|
|
||||||
verified = await verifyUserSecret(auth, user, secret);
|
|
||||||
}
|
|
||||||
if (!verified) return errorResponse('User verification failed.', 400);
|
if (!verified) return errorResponse('User verification failed.', 400);
|
||||||
|
|
||||||
if (type === TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
|
if (type === TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
|
||||||
|
|||||||
@@ -439,17 +439,16 @@ export async function handlePublicDownloadAttachment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const path = getAttachmentObjectKey(cipherId, attachmentId);
|
const path = getAttachmentObjectKey(cipherId, attachmentId);
|
||||||
const object = await getBlobObject(env, path);
|
|
||||||
|
|
||||||
if (!object) {
|
|
||||||
return errorResponse('Attachment file not found', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstUse = await storage.consumeAttachmentDownloadToken(claims.jti, claims.exp);
|
const firstUse = await storage.consumeAttachmentDownloadToken(claims.jti, claims.exp);
|
||||||
if (!firstUse) {
|
if (!firstUse) {
|
||||||
return errorResponse('Invalid or expired token', 401);
|
return errorResponse('Invalid or expired token', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const object = await getBlobObject(env, path);
|
||||||
|
if (!object) {
|
||||||
|
return errorResponse('Attachment file not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(object.body, {
|
return new Response(object.body, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': sanitizeDownloadContentType(object.contentType),
|
'Content-Type': sanitizeDownloadContentType(object.contentType),
|
||||||
|
|||||||
@@ -96,8 +96,8 @@ function toAuthRequestResponse(request: Request, authRequest: AuthRequestRecord,
|
|||||||
RequestCountryName: authRequest.requestCountryName,
|
RequestCountryName: authRequest.requestCountryName,
|
||||||
key: authRequest.key,
|
key: authRequest.key,
|
||||||
Key: authRequest.key,
|
Key: authRequest.key,
|
||||||
masterPasswordHash: authRequest.masterPasswordHash,
|
masterPasswordHash: null,
|
||||||
MasterPasswordHash: authRequest.masterPasswordHash,
|
MasterPasswordHash: null,
|
||||||
creationDate: authRequest.creationDate,
|
creationDate: authRequest.creationDate,
|
||||||
CreationDate: authRequest.creationDate,
|
CreationDate: authRequest.creationDate,
|
||||||
responseDate: authRequest.responseDate,
|
responseDate: authRequest.responseDate,
|
||||||
@@ -349,7 +349,6 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
|
|||||||
|
|
||||||
const approved = Boolean(readBodyValue(body, ['requestApproved', 'RequestApproved']));
|
const approved = Boolean(readBodyValue(body, ['requestApproved', 'RequestApproved']));
|
||||||
const key = normalizeText(readBodyValue(body, ['key', 'Key']), 20000);
|
const key = normalizeText(readBodyValue(body, ['key', 'Key']), 20000);
|
||||||
const masterPasswordHash = normalizeText(readBodyValue(body, ['masterPasswordHash', 'MasterPasswordHash']), 20000) || null;
|
|
||||||
const responseDeviceIdentifier =
|
const responseDeviceIdentifier =
|
||||||
normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128) ||
|
normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128) ||
|
||||||
readActingDeviceIdentifier(request) ||
|
readActingDeviceIdentifier(request) ||
|
||||||
@@ -366,7 +365,7 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
|
|||||||
approved,
|
approved,
|
||||||
responseDeviceIdentifier,
|
responseDeviceIdentifier,
|
||||||
key,
|
key,
|
||||||
masterPasswordHash,
|
masterPasswordHash: null,
|
||||||
});
|
});
|
||||||
if (!updated) return errorResponse('Auth request has already been answered.', 409);
|
if (!updated) return errorResponse('Auth request has already been answered.', 409);
|
||||||
const updatedRequest = await storage.getAuthRequestByIdForUser(id, userId);
|
const updatedRequest = await storage.getAuthRequestByIdForUser(id, userId);
|
||||||
|
|||||||
+34
-4
@@ -1062,12 +1062,21 @@ export async function handleDownloadAdminRemoteBackup(request: Request, env: Env
|
|||||||
export async function handleInspectAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
export async function handleInspectAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
let body: { destinationId?: string; path?: string; masterPasswordHash?: string };
|
||||||
|
try {
|
||||||
|
body = await request.json<{ destinationId?: string; path?: string; masterPasswordHash?: string }>();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Remote backup integrity payload is invalid', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
|
||||||
|
if (verificationError) return verificationError;
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
try {
|
try {
|
||||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
const url = new URL(request.url);
|
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
||||||
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
|
const destination = requireBackupDestination(settings, body.destinationId || null);
|
||||||
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
|
|
||||||
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
||||||
const integrity = await inspectBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
|
const integrity = await inspectBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
@@ -1239,7 +1248,28 @@ export async function handleDownloadAdminBackupAttachment(request: Request, env:
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const blobName = ensureBackupBlobName(url.searchParams.get('blobName') || '');
|
let input: { blobName?: unknown; masterPasswordHash?: unknown } = {};
|
||||||
|
if (request.method === 'POST') {
|
||||||
|
try {
|
||||||
|
input = await request.json<{ blobName?: unknown; masterPasswordHash?: unknown }>();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Backup attachment download payload is invalid', 400);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
input = {
|
||||||
|
blobName: url.searchParams.get('blobName') || '',
|
||||||
|
masterPasswordHash: url.searchParams.get('masterPasswordHash') || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const verificationError = await requireBackupUserVerification(
|
||||||
|
actorUser,
|
||||||
|
String(input.masterPasswordHash || ''),
|
||||||
|
env
|
||||||
|
);
|
||||||
|
if (verificationError) return verificationError;
|
||||||
|
|
||||||
|
const blobName = ensureBackupBlobName(String(input.blobName || ''));
|
||||||
const object = await getBlobObject(env, blobName);
|
const object = await getBlobObject(env, blobName);
|
||||||
if (!object) {
|
if (!object) {
|
||||||
return errorResponse('Backup attachment blob not found', 404);
|
return errorResponse('Backup attachment blob not found', 404);
|
||||||
|
|||||||
@@ -300,17 +300,17 @@ export async function handleDownloadSendFile(
|
|||||||
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp);
|
||||||
|
if (!firstUse) {
|
||||||
|
return errorResponse('Invalid or expired token', 401);
|
||||||
|
}
|
||||||
|
|
||||||
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId));
|
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId));
|
||||||
if (!object) {
|
if (!object) {
|
||||||
return errorResponse('Send file not found', 404);
|
return errorResponse('Send file not found', 404);
|
||||||
}
|
}
|
||||||
const fileName = typeof data.fileName === 'string' ? data.fileName : fileId;
|
const fileName = typeof data.fileName === 'string' ? data.fileName : fileId;
|
||||||
|
|
||||||
const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp);
|
|
||||||
if (!firstUse) {
|
|
||||||
return errorResponse('Invalid or expired token', 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(object.body, {
|
return new Response(object.body, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': sanitizeDownloadContentType(object.contentType),
|
'Content-Type': sanitizeDownloadContentType(object.contentType),
|
||||||
|
|||||||
+3
-3
@@ -89,7 +89,7 @@ export default {
|
|||||||
const normalizedRequest = normalizeRequestUrl(request);
|
const normalizedRequest = normalizeRequestUrl(request);
|
||||||
const assetResponse = await maybeServeAsset(normalizedRequest, env);
|
const assetResponse = await maybeServeAsset(normalizedRequest, env);
|
||||||
if (assetResponse) {
|
if (assetResponse) {
|
||||||
return applyCors(normalizedRequest, assetResponse);
|
return applyCors(normalizedRequest, assetResponse, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureDatabaseInitialized(env);
|
await ensureDatabaseInitialized(env);
|
||||||
@@ -107,11 +107,11 @@ export default {
|
|||||||
},
|
},
|
||||||
500
|
500
|
||||||
);
|
);
|
||||||
return applyCors(normalizedRequest, resp);
|
return applyCors(normalizedRequest, resp, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resp = await handleRequest(normalizedRequest, env);
|
const resp = await handleRequest(normalizedRequest, env);
|
||||||
return applyCors(normalizedRequest, resp);
|
return applyCors(normalizedRequest, resp, env);
|
||||||
},
|
},
|
||||||
|
|
||||||
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export async function handleAdminBackupRoute(
|
|||||||
return handleAdminExportBackup(request, env, actorUser);
|
return handleAdminExportBackup(request, env, actorUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/admin/backup/blob' && method === 'GET') {
|
if (path === '/api/admin/backup/blob' && (method === 'GET' || method === 'POST')) {
|
||||||
return handleDownloadAdminBackupAttachment(request, env, actorUser);
|
return handleDownloadAdminBackupAttachment(request, env, actorUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ export async function handleAdminBackupRoute(
|
|||||||
return handleDownloadAdminRemoteBackup(request, env, actorUser);
|
return handleDownloadAdminRemoteBackup(request, env, actorUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/admin/backup/remote/integrity' && method === 'GET') {
|
if (path === '/api/admin/backup/remote/integrity' && method === 'POST') {
|
||||||
return handleInspectAdminRemoteBackup(request, env, actorUser);
|
return handleInspectAdminRemoteBackup(request, env, actorUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-2
@@ -35,6 +35,7 @@ import { isSafeWebsiteIconContentType } from './utils/content-type';
|
|||||||
import { jsonResponse, unsupportedResponse } from './utils/response';
|
import { jsonResponse, unsupportedResponse } from './utils/response';
|
||||||
import { StorageService } from './services/storage';
|
import { StorageService } from './services/storage';
|
||||||
import type { Env } from './types';
|
import type { Env } from './types';
|
||||||
|
import { getConfiguredWebAuthnAllowedOrigins } from './utils/origins';
|
||||||
|
|
||||||
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
|
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
|
||||||
type JwtUnsafeReason = 'missing' | 'too_short' | null;
|
type JwtUnsafeReason = 'missing' | 'too_short' | null;
|
||||||
@@ -44,6 +45,12 @@ export interface WebBootstrapResponse {
|
|||||||
jwtUnsafeReason: JwtUnsafeReason;
|
jwtUnsafeReason: JwtUnsafeReason;
|
||||||
jwtSecretMinLength: number;
|
jwtSecretMinLength: number;
|
||||||
registrationInviteRequired: boolean;
|
registrationInviteRequired: boolean;
|
||||||
|
webAuthnAllowedOrigins: string[];
|
||||||
|
websiteIconsEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWebsiteIconProxyEnabled(env: Env): boolean {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSameOriginWriteRequest(request: Request): boolean {
|
function isSameOriginWriteRequest(request: Request): boolean {
|
||||||
@@ -255,7 +262,11 @@ function iconResponse(body: BodyInit | null, contentType: string | null): Respon
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
|
async function handleWebsiteIcon(env: Env, host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
|
||||||
|
if (!isWebsiteIconProxyEnabled(env)) {
|
||||||
|
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedHost = normalizeIconHost(host);
|
const normalizedHost = normalizeIconHost(host);
|
||||||
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||||
|
|
||||||
@@ -322,6 +333,8 @@ export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapR
|
|||||||
jwtUnsafeReason,
|
jwtUnsafeReason,
|
||||||
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
||||||
registrationInviteRequired: userCount > 0,
|
registrationInviteRequired: userCount > 0,
|
||||||
|
webAuthnAllowedOrigins: getConfiguredWebAuthnAllowedOrigins(env),
|
||||||
|
websiteIconsEnabled: isWebsiteIconProxyEnabled(env),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,7 +385,7 @@ export async function handlePublicRoute(
|
|||||||
const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute);
|
const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute);
|
||||||
if (blocked) return blocked;
|
if (blocked) return blocked;
|
||||||
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
|
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
|
||||||
return handleWebsiteIcon(iconMatch[1], fallbackMode);
|
return handleWebsiteIcon(env, iconMatch[1], fallbackMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
||||||
|
|||||||
+1
-1
@@ -148,7 +148,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === 'OPTIONS') {
|
if (method === 'OPTIONS') {
|
||||||
return handleCors(request);
|
return handleCors(request, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -442,9 +442,10 @@ export function validateBackupPayloadContents(
|
|||||||
for (const row of accountPasskeyRows) {
|
for (const row of accountPasskeyRows) {
|
||||||
const id = String(row.id || '').trim();
|
const id = String(row.id || '').trim();
|
||||||
const userId = String(row.user_id || '').trim();
|
const userId = String(row.user_id || '').trim();
|
||||||
|
const purpose = row.purpose == null ? 'login' : String(row.purpose || '').trim();
|
||||||
const credentialId = String(row.credential_id || '').trim();
|
const credentialId = String(row.credential_id || '').trim();
|
||||||
const publicKey = String(row.public_key || '').trim();
|
const publicKey = String(row.public_key || '').trim();
|
||||||
if (!id || !userIds.has(userId) || !credentialId || !publicKey) {
|
if (!id || !userIds.has(userId) || !credentialId || !publicKey || (purpose !== 'login' && purpose !== 'twoFactor')) {
|
||||||
throw new Error('Backup archive contains an invalid account passkey row');
|
throw new Error('Backup archive contains an invalid account passkey row');
|
||||||
}
|
}
|
||||||
if (accountPasskeyIds.has(id)) throw new Error(`Backup archive contains duplicate account passkey id: ${id}`);
|
if (accountPasskeyIds.has(id)) throw new Error(`Backup archive contains duplicate account passkey id: ${id}`);
|
||||||
@@ -493,7 +494,7 @@ export async function buildBackupArchive(
|
|||||||
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, 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, archived_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'),
|
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'),
|
queryRows(env.DB, 'SELECT id, user_id, purpose, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'),
|
||||||
queryRows(env.DB, 'SELECT token, user_id, device_identifier, expires_at FROM trusted_two_factor_device_tokens WHERE expires_at >= ? ORDER BY user_id ASC, device_identifier ASC, expires_at DESC', date.getTime()),
|
queryRows(env.DB, 'SELECT token, user_id, device_identifier, expires_at FROM trusted_two_factor_device_tokens WHERE expires_at >= ? ORDER BY user_id ASC, device_identifier ASC, expires_at DESC', date.getTime()),
|
||||||
]);
|
]);
|
||||||
const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
|
const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
|
||||||
|
|||||||
@@ -68,6 +68,114 @@ function normalizePath(value: unknown): string {
|
|||||||
return asTrimmedString(value).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
return asTrimmedString(value).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeHostnameForPolicy(hostname: string): string {
|
||||||
|
return hostname.trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIpv4Address(hostname: string): number[] | null {
|
||||||
|
const parts = hostname.split('.');
|
||||||
|
if (parts.length !== 4) return null;
|
||||||
|
const octets = parts.map((part) => {
|
||||||
|
if (!/^\d{1,3}$/.test(part)) return -1;
|
||||||
|
const value = Number(part);
|
||||||
|
return Number.isInteger(value) && value >= 0 && value <= 255 ? value : -1;
|
||||||
|
});
|
||||||
|
return octets.every((value) => value >= 0) ? octets : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedIpv4Address(octets: number[]): boolean {
|
||||||
|
const [a, b, c] = octets;
|
||||||
|
return (
|
||||||
|
a === 0 ||
|
||||||
|
a === 10 ||
|
||||||
|
a === 127 ||
|
||||||
|
(a === 100 && b >= 64 && b <= 127) ||
|
||||||
|
(a === 169 && b === 254) ||
|
||||||
|
(a === 172 && b >= 16 && b <= 31) ||
|
||||||
|
(a === 192 && (b === 0 || b === 168)) ||
|
||||||
|
(a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
|
||||||
|
(a === 203 && b === 0 && c === 113) ||
|
||||||
|
a >= 224
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedIpv6Address(hostname: string): boolean {
|
||||||
|
if (!hostname.includes(':')) return false;
|
||||||
|
const normalized = hostname.toLowerCase();
|
||||||
|
const mappedIpv4 = normalized.match(/::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
|
||||||
|
if (mappedIpv4) {
|
||||||
|
const octets = parseIpv4Address(mappedIpv4[1]);
|
||||||
|
return !octets || isBlockedIpv4Address(octets);
|
||||||
|
}
|
||||||
|
const firstHextetText = normalized.split(':').find((part) => part.length > 0) || '0';
|
||||||
|
const firstHextet = Number.parseInt(firstHextetText, 16);
|
||||||
|
if (!Number.isFinite(firstHextet)) return true;
|
||||||
|
return (
|
||||||
|
firstHextet === 0 ||
|
||||||
|
(firstHextet & 0xfe00) === 0xfc00 ||
|
||||||
|
(firstHextet & 0xffc0) === 0xfe80 ||
|
||||||
|
(firstHextet & 0xff00) === 0xff00 ||
|
||||||
|
normalized.startsWith('2001:db8:')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertBackupEndpointHostAllowed(hostname: string, label: string): void {
|
||||||
|
const normalized = normalizeHostnameForPolicy(hostname);
|
||||||
|
if (!normalized) throw new Error(`${label} host is required`);
|
||||||
|
if (
|
||||||
|
normalized === 'localhost' ||
|
||||||
|
normalized === 'localhost.localdomain' ||
|
||||||
|
normalized.endsWith('.localhost.localdomain') ||
|
||||||
|
normalized.endsWith('.localhost') ||
|
||||||
|
normalized.endsWith('.local') ||
|
||||||
|
normalized.endsWith('.home.arpa') ||
|
||||||
|
normalized.endsWith('.internal') ||
|
||||||
|
normalized.endsWith('.lan') ||
|
||||||
|
normalized === 'metadata.google.internal' ||
|
||||||
|
normalized === 'localtest.me' ||
|
||||||
|
normalized.endsWith('.localtest.me') ||
|
||||||
|
normalized === 'lvh.me' ||
|
||||||
|
normalized.endsWith('.lvh.me') ||
|
||||||
|
normalized === 'vcap.me' ||
|
||||||
|
normalized.endsWith('.vcap.me') ||
|
||||||
|
normalized === 'nip.io' ||
|
||||||
|
normalized.endsWith('.nip.io') ||
|
||||||
|
normalized === 'sslip.io' ||
|
||||||
|
normalized.endsWith('.sslip.io') ||
|
||||||
|
normalized === 'xip.io' ||
|
||||||
|
normalized.endsWith('.xip.io')
|
||||||
|
) {
|
||||||
|
throw new Error(`${label} host is not allowed`);
|
||||||
|
}
|
||||||
|
const ipv4 = parseIpv4Address(normalized);
|
||||||
|
if (ipv4 && isBlockedIpv4Address(ipv4)) {
|
||||||
|
throw new Error(`${label} host is not allowed`);
|
||||||
|
}
|
||||||
|
if (isBlockedIpv6Address(normalized)) {
|
||||||
|
throw new Error(`${label} host is not allowed`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBackupEndpointUrl(value: string, label: string): string {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(value);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${label} must be a valid URL`);
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
throw new Error(`${label} must start with http:// or https://`);
|
||||||
|
}
|
||||||
|
if (parsed.username || parsed.password) {
|
||||||
|
throw new Error(`${label} must not include credentials`);
|
||||||
|
}
|
||||||
|
if (parsed.search || parsed.hash) {
|
||||||
|
throw new Error(`${label} must not include query or fragment`);
|
||||||
|
}
|
||||||
|
assertBackupEndpointHostAllowed(parsed.hostname, label);
|
||||||
|
return parsed.toString().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
function assertValidTimeZone(timezone: string): string {
|
function assertValidTimeZone(timezone: string): string {
|
||||||
try {
|
try {
|
||||||
new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(new Date());
|
new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(new Date());
|
||||||
@@ -123,7 +231,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
|
|||||||
|
|
||||||
if (!allowIncomplete || endpoint) {
|
if (!allowIncomplete || endpoint) {
|
||||||
if (!endpoint) throw new Error('S3 endpoint is required');
|
if (!endpoint) throw new Error('S3 endpoint is required');
|
||||||
if (!/^https?:\/\//i.test(endpoint)) throw new Error('S3 endpoint must start with http:// or https://');
|
normalizeBackupEndpointUrl(endpoint, 'S3 endpoint');
|
||||||
}
|
}
|
||||||
if (!allowIncomplete || bucket) {
|
if (!allowIncomplete || bucket) {
|
||||||
if (!bucket) throw new Error('S3 bucket is required');
|
if (!bucket) throw new Error('S3 bucket is required');
|
||||||
@@ -136,7 +244,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
|
endpoint: endpoint ? normalizeBackupEndpointUrl(endpoint, 'S3 endpoint') : '',
|
||||||
bucket,
|
bucket,
|
||||||
addressingStyle,
|
addressingStyle,
|
||||||
region,
|
region,
|
||||||
@@ -155,7 +263,7 @@ function normalizeWebDavDestination(value: unknown, allowIncomplete = false): We
|
|||||||
|
|
||||||
if (!allowIncomplete || baseUrl) {
|
if (!allowIncomplete || baseUrl) {
|
||||||
if (!baseUrl) throw new Error('WebDAV server URL is required');
|
if (!baseUrl) throw new Error('WebDAV server URL is required');
|
||||||
if (!/^https?:\/\//i.test(baseUrl)) throw new Error('WebDAV server URL must start with http:// or https://');
|
normalizeBackupEndpointUrl(baseUrl, 'WebDAV server URL');
|
||||||
}
|
}
|
||||||
if (!allowIncomplete || username) {
|
if (!allowIncomplete || username) {
|
||||||
if (!username) throw new Error('WebDAV username is required');
|
if (!username) throw new Error('WebDAV username is required');
|
||||||
@@ -165,7 +273,7 @@ function normalizeWebDavDestination(value: unknown, allowIncomplete = false): We
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
baseUrl: baseUrl ? baseUrl.replace(/\/+$/, '') : '',
|
baseUrl: baseUrl ? normalizeBackupEndpointUrl(baseUrl, 'WebDAV server URL') : '',
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
remotePath,
|
remotePath,
|
||||||
|
|||||||
@@ -254,6 +254,10 @@ function cloneRows(rows: SqlRow[]): SqlRow[] {
|
|||||||
return rows.map((row) => ({ ...row }));
|
return rows.map((row) => ({ ...row }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAccountPasskeyPurpose(value: unknown): 'login' | 'twoFactor' {
|
||||||
|
return value == null ? 'login' : String(value).trim() === 'twoFactor' ? 'twoFactor' : 'login';
|
||||||
|
}
|
||||||
|
|
||||||
function upsertConfigRow(rows: SqlRow[], key: string, value: string): SqlRow[] {
|
function upsertConfigRow(rows: SqlRow[], key: string, value: string): SqlRow[] {
|
||||||
let replaced = false;
|
let replaced = false;
|
||||||
const nextRows = rows.map((row) => {
|
const nextRows = rows.map((row) => {
|
||||||
@@ -303,7 +307,10 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
|
|||||||
domain_settings: cloneRows(payload.domain_settings || []),
|
domain_settings: cloneRows(payload.domain_settings || []),
|
||||||
user_revisions: cloneRows(payload.user_revisions || []),
|
user_revisions: cloneRows(payload.user_revisions || []),
|
||||||
trusted_two_factor_device_tokens: cloneRows(payload.trusted_two_factor_device_tokens || []),
|
trusted_two_factor_device_tokens: cloneRows(payload.trusted_two_factor_device_tokens || []),
|
||||||
webauthn_credentials: cloneRows(payload.webauthn_credentials || []),
|
webauthn_credentials: cloneRows(payload.webauthn_credentials || []).map((row) => ({
|
||||||
|
...row,
|
||||||
|
purpose: normalizeAccountPasskeyPurpose(row.purpose),
|
||||||
|
})),
|
||||||
folders: cloneRows(payload.folders || []),
|
folders: cloneRows(payload.folders || []),
|
||||||
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
@@ -668,7 +675,7 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
|
|||||||
buildInsertStatements(
|
buildInsertStatements(
|
||||||
db,
|
db,
|
||||||
tableName('webauthn_credentials'),
|
tableName('webauthn_credentials'),
|
||||||
['id', 'user_id', 'name', 'public_key', 'credential_id', 'counter', 'type', 'aa_guid', 'transports', 'encrypted_user_key', 'encrypted_public_key', 'encrypted_private_key', 'supports_prf', 'created_at', 'updated_at'],
|
['id', 'user_id', 'purpose', 'name', 'public_key', 'credential_id', 'counter', 'type', 'aa_guid', 'transports', 'encrypted_user_key', 'encrypted_public_key', 'encrypted_private_key', 'supports_prf', 'created_at', 'updated_at'],
|
||||||
payload.webauthn_credentials || []
|
payload.webauthn_credentials || []
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
BackupDestinationType,
|
BackupDestinationType,
|
||||||
S3BackupDestination,
|
S3BackupDestination,
|
||||||
WebDavBackupDestination,
|
WebDavBackupDestination,
|
||||||
|
normalizeBackupEndpointUrl,
|
||||||
} from './backup-config';
|
} from './backup-config';
|
||||||
|
|
||||||
export interface BackupUploadResult {
|
export interface BackupUploadResult {
|
||||||
@@ -215,7 +216,7 @@ function ensureDestinationConfigReady(destination: BackupDestinationRecord): voi
|
|||||||
if (destination.type === 'webdav') {
|
if (destination.type === 'webdav') {
|
||||||
const config = destination.destination as WebDavBackupDestination;
|
const config = destination.destination as WebDavBackupDestination;
|
||||||
if (!String(config.baseUrl || '').trim()) throw new Error('WebDAV server URL is required');
|
if (!String(config.baseUrl || '').trim()) throw new Error('WebDAV server URL is required');
|
||||||
if (!/^https?:\/\//i.test(String(config.baseUrl || '').trim())) throw new Error('WebDAV server URL must start with http:// or https://');
|
normalizeBackupEndpointUrl(String(config.baseUrl || '').trim(), 'WebDAV server URL');
|
||||||
if (!String(config.username || '').trim()) throw new Error('WebDAV username is required');
|
if (!String(config.username || '').trim()) throw new Error('WebDAV username is required');
|
||||||
if (!String(config.password || '')) throw new Error('WebDAV password is required');
|
if (!String(config.password || '')) throw new Error('WebDAV password is required');
|
||||||
return;
|
return;
|
||||||
@@ -223,7 +224,7 @@ function ensureDestinationConfigReady(destination: BackupDestinationRecord): voi
|
|||||||
if (destination.type === 's3') {
|
if (destination.type === 's3') {
|
||||||
const config = destination.destination as S3BackupDestination;
|
const config = destination.destination as S3BackupDestination;
|
||||||
if (!String(config.endpoint || '').trim()) throw new Error('S3 endpoint is required');
|
if (!String(config.endpoint || '').trim()) throw new Error('S3 endpoint is required');
|
||||||
if (!/^https?:\/\//i.test(String(config.endpoint || '').trim())) throw new Error('S3 endpoint must start with http:// or https://');
|
normalizeBackupEndpointUrl(String(config.endpoint || '').trim(), 'S3 endpoint');
|
||||||
if (!String(config.bucket || '').trim()) throw new Error('S3 bucket is required');
|
if (!String(config.bucket || '').trim()) throw new Error('S3 bucket is required');
|
||||||
if (!String(config.accessKeyId || '').trim()) throw new Error('S3 access key is required');
|
if (!String(config.accessKeyId || '').trim()) throw new Error('S3 access key is required');
|
||||||
if (!String(config.secretAccessKey || '')) throw new Error('S3 secret key is required');
|
if (!String(config.secretAccessKey || '')) throw new Error('S3 secret key is required');
|
||||||
@@ -252,7 +253,7 @@ async function ensureWebDavDirectory(baseUrl: string, directoryPath: string, aut
|
|||||||
Authorization: authHeader,
|
Authorization: authHeader,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if ([200, 201, 204, 301, 302, 405].includes(response.status)) continue;
|
if ([200, 201, 204, 405].includes(response.status)) continue;
|
||||||
throw new Error(`WebDAV directory creation failed: ${response.status}`);
|
throw new Error(`WebDAV directory creation failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,7 +276,7 @@ async function ensureWebDavDirectoryCached(
|
|||||||
Authorization: authHeader,
|
Authorization: authHeader,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if ([200, 201, 204, 301, 302, 405].includes(response.status)) {
|
if ([200, 201, 204, 405].includes(response.status)) {
|
||||||
ensuredDirectories.add(current);
|
ensuredDirectories.add(current);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -518,7 +519,7 @@ async function signedS3Request(
|
|||||||
config.region || 'auto'
|
config.region || 'auto'
|
||||||
);
|
);
|
||||||
|
|
||||||
return fetch(url.toString(), {
|
return fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: authorization,
|
Authorization: authorization,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
WebAuthnPrfDecryptionOption,
|
WebAuthnPrfDecryptionOption,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { base64UrlToBytes, bytesToBase64Url } from './passkey';
|
import { base64UrlToBytes, bytesToBase64Url } from './passkey';
|
||||||
|
import { getConfiguredWebAuthnAllowedOrigins } from './origins';
|
||||||
|
|
||||||
const ACCOUNT_PASSKEY_TOKEN_TYPE = 'nodewarden.account-passkey.challenge.v1';
|
const ACCOUNT_PASSKEY_TOKEN_TYPE = 'nodewarden.account-passkey.challenge.v1';
|
||||||
const ACCOUNT_PASSKEY_TOKEN_TTL_MS = 17 * 60 * 1000;
|
const ACCOUNT_PASSKEY_TOKEN_TTL_MS = 17 * 60 * 1000;
|
||||||
@@ -159,22 +160,8 @@ export function getAccountPasskeyRpConfig(request: Request, env: Env): { rpId: s
|
|||||||
const configuredRpId = String(env.WEBAUTHN_RP_ID || '').trim();
|
const configuredRpId = String(env.WEBAUTHN_RP_ID || '').trim();
|
||||||
const rpId = configuredRpId || url.hostname;
|
const rpId = configuredRpId || url.hostname;
|
||||||
const rpName = String(env.WEBAUTHN_RP_NAME || '').trim() || DEFAULT_RP_NAME;
|
const rpName = String(env.WEBAUTHN_RP_NAME || '').trim() || DEFAULT_RP_NAME;
|
||||||
const configuredOrigins = String(env.WEBAUTHN_ALLOWED_ORIGINS || '')
|
const configuredOrigins = getConfiguredWebAuthnAllowedOrigins(env);
|
||||||
.split(',')
|
|
||||||
.map((origin) => origin.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const origins = new Set<string>([url.origin, ...configuredOrigins]);
|
const origins = new Set<string>([url.origin, ...configuredOrigins]);
|
||||||
const requestOrigin = request.headers.get('Origin');
|
|
||||||
if (
|
|
||||||
requestOrigin
|
|
||||||
&& (
|
|
||||||
requestOrigin.startsWith('chrome-extension://')
|
|
||||||
|| requestOrigin.startsWith('moz-extension://')
|
|
||||||
|| requestOrigin.startsWith('safari-web-extension://')
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
origins.add(requestOrigin);
|
|
||||||
}
|
|
||||||
return { rpId, rpName, origins: Array.from(origins) };
|
return { rpId, rpName, origins: Array.from(origins) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Env } from '../types';
|
||||||
|
|
||||||
|
export function normalizeOrigin(value: unknown): string | null {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(raw);
|
||||||
|
if (!url.protocol || !url.host) return null;
|
||||||
|
return `${url.protocol}//${url.host}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBrowserExtensionOrigin(origin: unknown): boolean {
|
||||||
|
const normalized = normalizeOrigin(origin);
|
||||||
|
return !!normalized && (
|
||||||
|
normalized.startsWith('chrome-extension://')
|
||||||
|
|| normalized.startsWith('moz-extension://')
|
||||||
|
|| normalized.startsWith('safari-web-extension://')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfiguredWebAuthnAllowedOrigins(
|
||||||
|
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>
|
||||||
|
): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) {
|
||||||
|
const origin = normalizeOrigin(item);
|
||||||
|
if (origin) seen.add(origin);
|
||||||
|
}
|
||||||
|
return Array.from(seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isConfiguredWebAuthnAllowedOrigin(
|
||||||
|
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>,
|
||||||
|
origin: unknown
|
||||||
|
): boolean {
|
||||||
|
const normalized = normalizeOrigin(origin);
|
||||||
|
return !!normalized && getConfiguredWebAuthnAllowedOrigins(env).includes(normalized);
|
||||||
|
}
|
||||||
+18
-18
@@ -1,4 +1,10 @@
|
|||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
|
import type { Env } from '../types';
|
||||||
|
import {
|
||||||
|
isBrowserExtensionOrigin,
|
||||||
|
isConfiguredWebAuthnAllowedOrigin,
|
||||||
|
normalizeOrigin,
|
||||||
|
} from './origins';
|
||||||
|
|
||||||
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
|
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
|
||||||
const DEFAULT_CORS_HEADERS = [
|
const DEFAULT_CORS_HEADERS = [
|
||||||
@@ -18,14 +24,6 @@ const DEFAULT_CORS_HEADERS = [
|
|||||||
'X-NodeWarden-Web-Session',
|
'X-NodeWarden-Web-Session',
|
||||||
];
|
];
|
||||||
|
|
||||||
function isExtensionOrigin(origin: string): boolean {
|
|
||||||
return (
|
|
||||||
origin.startsWith('chrome-extension://')
|
|
||||||
|| origin.startsWith('moz-extension://')
|
|
||||||
|| origin.startsWith('safari-web-extension://')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWildcardCorsPath(path: string): boolean {
|
function isWildcardCorsPath(path: string): boolean {
|
||||||
return (
|
return (
|
||||||
path.startsWith('/icons/')
|
path.startsWith('/icons/')
|
||||||
@@ -38,18 +36,19 @@ function isWildcardCorsPath(path: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCredentials: boolean } {
|
function getCorsPolicy(request: Request, env: Env): { allowOrigin: string | null; allowCredentials: boolean } {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const origin = request.headers.get('Origin');
|
const originHeader = request.headers.get('Origin');
|
||||||
if (!origin) {
|
if (!originHeader) {
|
||||||
return isWildcardCorsPath(url.pathname)
|
return isWildcardCorsPath(url.pathname)
|
||||||
? { allowOrigin: '*', allowCredentials: false }
|
? { allowOrigin: '*', allowCredentials: false }
|
||||||
: { allowOrigin: null, allowCredentials: false };
|
: { allowOrigin: null, allowCredentials: false };
|
||||||
}
|
}
|
||||||
|
const origin = normalizeOrigin(originHeader);
|
||||||
if (origin === url.origin) {
|
if (origin === url.origin) {
|
||||||
return { allowOrigin: origin, allowCredentials: true };
|
return { allowOrigin: origin, allowCredentials: true };
|
||||||
}
|
}
|
||||||
if (isExtensionOrigin(origin)) {
|
if (isBrowserExtensionOrigin(origin) && isConfiguredWebAuthnAllowedOrigin(env, origin)) {
|
||||||
return { allowOrigin: origin, allowCredentials: true };
|
return { allowOrigin: origin, allowCredentials: true };
|
||||||
}
|
}
|
||||||
if (isWildcardCorsPath(url.pathname)) {
|
if (isWildcardCorsPath(url.pathname)) {
|
||||||
@@ -58,7 +57,7 @@ function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCre
|
|||||||
return { allowOrigin: null, allowCredentials: false };
|
return { allowOrigin: null, allowCredentials: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCorsHeaders(request: Request): Record<string, string> {
|
function buildCorsHeaders(request: Request, env: Env): Record<string, string> {
|
||||||
const requestedHeaders = String(request.headers.get('Access-Control-Request-Headers') || '')
|
const requestedHeaders = String(request.headers.get('Access-Control-Request-Headers') || '')
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((value) => value.trim())
|
.map((value) => value.trim())
|
||||||
@@ -72,7 +71,7 @@ function buildCorsHeaders(request: Request): Record<string, string> {
|
|||||||
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
|
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
|
||||||
};
|
};
|
||||||
|
|
||||||
const corsPolicy = getCorsPolicy(request);
|
const corsPolicy = getCorsPolicy(request, env);
|
||||||
if (corsPolicy.allowOrigin) {
|
if (corsPolicy.allowOrigin) {
|
||||||
headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin;
|
headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin;
|
||||||
if (corsPolicy.allowCredentials) {
|
if (corsPolicy.allowCredentials) {
|
||||||
@@ -86,7 +85,8 @@ function buildCorsHeaders(request: Request): Record<string, string> {
|
|||||||
|
|
||||||
export function applyCors(
|
export function applyCors(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response
|
response: Response,
|
||||||
|
env: Env
|
||||||
): Response {
|
): Response {
|
||||||
// WebSocket upgrade responses must be returned untouched.
|
// WebSocket upgrade responses must be returned untouched.
|
||||||
const webSocket = (response as Response & { webSocket?: unknown }).webSocket;
|
const webSocket = (response as Response & { webSocket?: unknown }).webSocket;
|
||||||
@@ -95,7 +95,7 @@ export function applyCors(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const headers = new Headers(response.headers);
|
const headers = new Headers(response.headers);
|
||||||
const corsHeaders = buildCorsHeaders(request);
|
const corsHeaders = buildCorsHeaders(request, env);
|
||||||
for (const [k, v] of Object.entries(corsHeaders)) {
|
for (const [k, v] of Object.entries(corsHeaders)) {
|
||||||
headers.set(k, v);
|
headers.set(k, v);
|
||||||
}
|
}
|
||||||
@@ -159,10 +159,10 @@ export function identityErrorResponse(message: string, error: string = 'invalid_
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle CORS preflight
|
// Handle CORS preflight
|
||||||
export function handleCors(request: Request): Response {
|
export function handleCors(request: Request, env: Env): Response {
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 204,
|
status: 204,
|
||||||
headers: buildCorsHeaders(request),
|
headers: buildCorsHeaders(request, env),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -170,6 +170,7 @@
|
|||||||
(function () {
|
(function () {
|
||||||
var params = new URLSearchParams(window.location.search);
|
var params = new URLSearchParams(window.location.search);
|
||||||
var sentSuccess = false;
|
var sentSuccess = false;
|
||||||
|
var allowedParentOriginsPromise = null;
|
||||||
|
|
||||||
var text = pickText(params.get("locale") || navigator.language || "en");
|
var text = pickText(params.get("locale") || navigator.language || "en");
|
||||||
document.documentElement.lang = params.get("locale") || navigator.language || "en";
|
document.documentElement.lang = params.get("locale") || navigator.language || "en";
|
||||||
@@ -227,24 +228,55 @@
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function trustedParentOrigin() {
|
function normalizeOrigin(value) {
|
||||||
var parent = decodeRepeated(params.get("parent"));
|
if (!value) return "";
|
||||||
if (!parent) return "";
|
|
||||||
try {
|
try {
|
||||||
var parentUrl = new URL(parent);
|
var url = new URL(value);
|
||||||
if (
|
if (!url.protocol || !url.host) return "";
|
||||||
parentUrl.protocol === "chrome-extension:" ||
|
return url.protocol + "//" + url.host;
|
||||||
parentUrl.protocol === "moz-extension:" ||
|
|
||||||
parentUrl.protocol === "safari-web-extension:"
|
|
||||||
) {
|
|
||||||
return parentUrl.protocol + "//" + parentUrl.host;
|
|
||||||
}
|
|
||||||
if (parentUrl.origin === window.location.origin) {
|
|
||||||
return parentUrl.origin;
|
|
||||||
}
|
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExtensionOrigin(origin) {
|
||||||
|
return (
|
||||||
|
origin.indexOf("chrome-extension://") === 0 ||
|
||||||
|
origin.indexOf("moz-extension://") === 0 ||
|
||||||
|
origin.indexOf("safari-web-extension://") === 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function allowedParentOrigins() {
|
||||||
|
if (allowedParentOriginsPromise) return allowedParentOriginsPromise;
|
||||||
|
allowedParentOriginsPromise = fetch("/api/web-bootstrap", {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
credentials: "omit",
|
||||||
|
}).then(function (response) {
|
||||||
|
if (!response.ok) return [];
|
||||||
|
return response.json();
|
||||||
|
}).then(function (body) {
|
||||||
|
var origins = Array.isArray(body && body.webAuthnAllowedOrigins)
|
||||||
|
? body.webAuthnAllowedOrigins
|
||||||
|
: [];
|
||||||
|
return origins.map(normalizeOrigin).filter(Boolean);
|
||||||
|
}).catch(function () {
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
return allowedParentOriginsPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trustedParentOrigin(allowedOrigins) {
|
||||||
|
var parent = decodeRepeated(params.get("parent"));
|
||||||
|
if (!parent) return "";
|
||||||
|
var parentOrigin = normalizeOrigin(parent);
|
||||||
|
if (!parentOrigin) return "";
|
||||||
|
if (parentOrigin === window.location.origin) {
|
||||||
|
return parentOrigin;
|
||||||
|
}
|
||||||
|
if (isExtensionOrigin(parentOrigin) && allowedOrigins.indexOf(parentOrigin) >= 0) {
|
||||||
|
return parentOrigin;
|
||||||
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,8 +290,8 @@
|
|||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
function postResult(message) {
|
async function postResult(message) {
|
||||||
var parentOrigin = trustedParentOrigin();
|
var parentOrigin = trustedParentOrigin(await allowedParentOrigins());
|
||||||
if (parentOrigin) {
|
if (parentOrigin) {
|
||||||
if (window.opener && !window.opener.closed) {
|
if (window.opener && !window.opener.closed) {
|
||||||
window.opener.postMessage(message, parentOrigin);
|
window.opener.postMessage(message, parentOrigin);
|
||||||
@@ -371,7 +403,7 @@
|
|||||||
if (!(credential instanceof PublicKeyCredential)) {
|
if (!(credential instanceof PublicKeyCredential)) {
|
||||||
throw new Error("No security key was selected.");
|
throw new Error("No security key was selected.");
|
||||||
}
|
}
|
||||||
postResult({
|
await postResult({
|
||||||
command: "webAuthnResult",
|
command: "webAuthnResult",
|
||||||
data: credentialToDataString(credential),
|
data: credentialToDataString(credential),
|
||||||
remember: rememberEl.checked,
|
remember: rememberEl.checked,
|
||||||
|
|||||||
+4
-2
@@ -1167,7 +1167,6 @@ export default function App() {
|
|||||||
const key = await encryptSessionUserKeyForAuthRequest(session, authRequest);
|
const key = await encryptSessionUserKeyForAuthRequest(session, authRequest);
|
||||||
await respondToAuthRequest(authedFetch, authRequest.id, {
|
await respondToAuthRequest(authedFetch, authRequest.id, {
|
||||||
key,
|
key,
|
||||||
masterPasswordHash: null,
|
|
||||||
deviceIdentifier: getCurrentDeviceIdentifier(),
|
deviceIdentifier: getCurrentDeviceIdentifier(),
|
||||||
requestApproved: true,
|
requestApproved: true,
|
||||||
});
|
});
|
||||||
@@ -2121,7 +2120,10 @@ export default function App() {
|
|||||||
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||||
return backupActions.downloadRemoteBackup(hash, destinationId, path, onProgress);
|
return backupActions.downloadRemoteBackup(hash, destinationId, path, onProgress);
|
||||||
},
|
},
|
||||||
onInspectRemoteBackup: backupActions.inspectRemoteBackup,
|
onInspectRemoteBackup: async (masterPassword: string, destinationId: string, path: string) => {
|
||||||
|
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||||
|
return backupActions.inspectRemoteBackup(hash, destinationId, path);
|
||||||
|
},
|
||||||
onDeleteRemoteBackup: async (masterPassword: string, destinationId: string, path: string) => {
|
onDeleteRemoteBackup: async (masterPassword: string, destinationId: string, path: string) => {
|
||||||
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||||
return backupActions.deleteRemoteBackup(hash, destinationId, path);
|
return backupActions.deleteRemoteBackup(hash, destinationId, path);
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ export interface AppMainRoutesProps {
|
|||||||
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||||
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||||
onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
onDownloadRemoteBackup: (masterPassword: string, 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 } }>;
|
onInspectRemoteBackup: (masterPassword: string, 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: (masterPassword: string, destinationId: string, path: string) => Promise<void>;
|
onDeleteRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<void>;
|
||||||
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ interface BackupCenterPageProps {
|
|||||||
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||||
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||||
onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
onDownloadRemoteBackup: (masterPassword: string, 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 }>;
|
onInspectRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>;
|
||||||
onDeleteRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<void>;
|
onDeleteRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<void>;
|
||||||
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
@@ -492,8 +492,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
return verifyBackupFileIntegrity(bytes, file.name || '');
|
return verifyBackupFileIntegrity(bytes, file.name || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function inspectRemoteBackupFile(destinationId: string, path: string): Promise<PendingRestoreIntegrity> {
|
async function inspectRemoteBackupFile(masterPassword: string, destinationId: string, path: string): Promise<PendingRestoreIntegrity> {
|
||||||
const payload = await props.onInspectRemoteBackup(destinationId, path);
|
const payload = await props.onInspectRemoteBackup(masterPassword, destinationId, path);
|
||||||
return {
|
return {
|
||||||
source: 'remote',
|
source: 'remote',
|
||||||
path,
|
path,
|
||||||
@@ -800,19 +800,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
if (!savedSelectedDestination) return;
|
if (!savedSelectedDestination) return;
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
resetPendingIntegrityWarning();
|
resetPendingIntegrityWarning();
|
||||||
try {
|
await runRemoteRestore(path, false);
|
||||||
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(
|
async function runRemoteRestore(
|
||||||
@@ -846,7 +834,23 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
setRestoringRemotePath(path);
|
setRestoringRemotePath(path);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
try {
|
try {
|
||||||
const integrity = knownIntegrity ? { result: knownIntegrity } : await inspectRemoteBackupFile(savedSelectedDestination.id, path);
|
const integrity = knownIntegrity
|
||||||
|
? { result: knownIntegrity }
|
||||||
|
: await inspectRemoteBackupFile(masterPassword, savedSelectedDestination.id, path);
|
||||||
|
if (!allowChecksumMismatch && !integrity.result.matches) {
|
||||||
|
setPendingRestoreIntegrity(
|
||||||
|
'source' in integrity
|
||||||
|
? integrity
|
||||||
|
: {
|
||||||
|
source: 'remote',
|
||||||
|
path,
|
||||||
|
fileName: path.split('/').pop() || path,
|
||||||
|
result: integrity.result,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setConfirmIntegrityWarningOpen(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
startRestoreProgress('backup-restore', path.split('/').pop() || path, {
|
startRestoreProgress('backup-restore', path.split('/').pop() || path, {
|
||||||
source: 'remote',
|
source: 'remote',
|
||||||
delayMs: replaceExisting ? 480 : 1400,
|
delayMs: replaceExisting ? 480 : 1400,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { CheckCheck, ChevronLeft, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact';
|
import { CheckCheck, ChevronLeft, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Lock, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import LoadingState from '@/components/LoadingState';
|
import LoadingState from '@/components/LoadingState';
|
||||||
import type { Send, SendDraft } from '@/lib/types';
|
import type { Send, SendDraft } from '@/lib/types';
|
||||||
@@ -43,6 +43,7 @@ function buildDefaultDraft(): SendDraft {
|
|||||||
expirationDays: '0',
|
expirationDays: '0',
|
||||||
maxAccessCount: '',
|
maxAccessCount: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
hasPassword: false,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -59,6 +60,7 @@ function draftFromSend(send: Send): SendDraft {
|
|||||||
expirationDays: daysFromNow(send.expirationDate, 0),
|
expirationDays: daysFromNow(send.expirationDate, 0),
|
||||||
maxAccessCount: send.maxAccessCount !== null && send.maxAccessCount !== undefined ? String(send.maxAccessCount) : '',
|
maxAccessCount: send.maxAccessCount !== null && send.maxAccessCount !== undefined ? String(send.maxAccessCount) : '',
|
||||||
password: '',
|
password: '',
|
||||||
|
hasPassword: !!send.password,
|
||||||
disabled: !!send.disabled,
|
disabled: !!send.disabled,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -380,6 +382,7 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
<div className="list-text">
|
<div className="list-text">
|
||||||
<span className="list-title" title={send.decName || t('txt_no_name')}>{send.decName || t('txt_no_name')}</span>
|
<span className="list-title" title={send.decName || t('txt_no_name')}>{send.decName || t('txt_no_name')}</span>
|
||||||
<span className="list-sub">
|
<span className="list-sub">
|
||||||
|
{!!send.password && <><Lock size={12} className="inline-icon" /> </>}
|
||||||
{Number(send.type) === 1 ? t('txt_file') : t('txt_text')} - {t('txt_accessed_count_times', { count: send.accessCount || 0 })}
|
{Number(send.type) === 1 ? t('txt_file') : t('txt_text')} - {t('txt_accessed_count_times', { count: send.accessCount || 0 })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -471,12 +474,23 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_password')}</span>
|
<span>{t('txt_password')}</span>
|
||||||
<div className="password-wrap">
|
{draft.hasPassword ? (
|
||||||
<input className="input" type={showPassword ? 'text' : 'password'} value={draft.password} onInput={(e) => setDraft({ ...draft, password: (e.currentTarget as HTMLInputElement).value })} />
|
<div className="password-wrap">
|
||||||
<button type="button" className="password-toggle" onClick={() => setShowPassword((v) => !v)}>
|
<input className="input" type="password" value="••••••••" disabled />
|
||||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
{!isCreating && (
|
||||||
</button>
|
<button type="button" className="password-toggle text-red-600 hover:text-red-700" onClick={() => setDraft({ ...draft, hasPassword: false, password: '' })} title={t('txt_remove')}>
|
||||||
</div>
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="password-wrap">
|
||||||
|
<input className="input" type={showPassword ? 'text' : 'password'} value={draft.password} onInput={(e) => setDraft({ ...draft, password: (e.currentTarget as HTMLInputElement).value })} />
|
||||||
|
<button type="button" className="password-toggle" onClick={() => setShowPassword((v) => !v)}>
|
||||||
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<label className="field field-span-2">
|
<label className="field field-span-2">
|
||||||
<span>{t('txt_notes')}</span>
|
<span>{t('txt_notes')}</span>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { Clipboard, Globe } from 'lucide-preact';
|
import { Clipboard, Globe } from 'lucide-preact';
|
||||||
import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
|
import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
|
||||||
import { calcTotpNow } from '@/lib/crypto';
|
import { calcTotpNow, type TotpCodeResult } from '@/lib/crypto';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import type { Cipher } from '@/lib/types';
|
import type { Cipher } from '@/lib/types';
|
||||||
import LoadingState from '@/components/LoadingState';
|
import LoadingState from '@/components/LoadingState';
|
||||||
@@ -14,17 +14,9 @@ interface TotpCodesPageProps {
|
|||||||
onNotify: (type: 'success' | 'error', text: string) => void;
|
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOTP_PERIOD_SECONDS = 30;
|
|
||||||
const TOTP_RING_RADIUS = 14;
|
const TOTP_RING_RADIUS = 14;
|
||||||
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
||||||
const TOTP_REFRESH_BATCH_SIZE = 16;
|
const TOTP_REFRESH_BATCH_SIZE = 16;
|
||||||
function getTotpTimeState(): { windowId: number; remain: number } {
|
|
||||||
const epoch = Math.floor(Date.now() / 1000);
|
|
||||||
return {
|
|
||||||
windowId: Math.floor(epoch / TOTP_PERIOD_SECONDS),
|
|
||||||
remain: TOTP_PERIOD_SECONDS - (epoch % TOTP_PERIOD_SECONDS),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
||||||
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
|
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
|
||||||
@@ -32,13 +24,15 @@ function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
|||||||
|
|
||||||
interface TotpRowProps {
|
interface TotpRowProps {
|
||||||
cipher: Cipher;
|
cipher: Cipher;
|
||||||
live: { code: string; remain: number } | null;
|
live: TotpCodeResult | null;
|
||||||
onCopy: (value: string) => void;
|
onCopy: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TotpRow(props: TotpRowProps) {
|
function TotpRow(props: TotpRowProps) {
|
||||||
const name = props.cipher.decName || props.cipher.name || t('txt_no_name');
|
const name = props.cipher.decName || props.cipher.name || t('txt_no_name');
|
||||||
const username = props.cipher.login?.decUsername || '';
|
const username = props.cipher.login?.decUsername || '';
|
||||||
|
const period = Math.max(1, props.live?.period || 30);
|
||||||
|
const progress = props.live ? Math.max(0, Math.min(period, props.live.remain)) / period : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="totp-code-row">
|
<div className="totp-code-row">
|
||||||
@@ -69,8 +63,7 @@ function TotpRow(props: TotpRowProps) {
|
|||||||
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
|
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
|
||||||
strokeDashoffset: String(
|
strokeDashoffset: String(
|
||||||
TOTP_RING_CIRCUMFERENCE -
|
TOTP_RING_CIRCUMFERENCE -
|
||||||
TOTP_RING_CIRCUMFERENCE *
|
TOTP_RING_CIRCUMFERENCE * progress
|
||||||
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.live?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
|
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -86,8 +79,7 @@ function TotpRow(props: TotpRowProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TotpCodesPage(props: TotpCodesPageProps) {
|
export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||||
const [totpCodes, setTotpCodes] = useState<Record<string, string | null>>({});
|
const [totpCodes, setTotpCodes] = useState<Record<string, TotpCodeResult | null>>({});
|
||||||
const [remainingSeconds, setRemainingSeconds] = useState(() => getTotpTimeState().remain);
|
|
||||||
const [columnCount, setColumnCount] = useState(1);
|
const [columnCount, setColumnCount] = useState(1);
|
||||||
const listRef = useRef<HTMLDivElement | null>(null);
|
const listRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -120,11 +112,10 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
let stopped = false;
|
let stopped = false;
|
||||||
let activeRun = 0;
|
let activeRun = 0;
|
||||||
let timer = 0;
|
let timer = 0;
|
||||||
let currentWindowId = -1;
|
|
||||||
|
|
||||||
const refreshCodes = async () => {
|
const refreshCodes = async () => {
|
||||||
const runId = ++activeRun;
|
const runId = ++activeRun;
|
||||||
const nextCodes: Record<string, string | null> = {};
|
const nextCodes: Record<string, TotpCodeResult | null> = {};
|
||||||
for (let start = 0; start < totpItems.length; start += TOTP_REFRESH_BATCH_SIZE) {
|
for (let start = 0; start < totpItems.length; start += TOTP_REFRESH_BATCH_SIZE) {
|
||||||
if (stopped || runId !== activeRun) return;
|
if (stopped || runId !== activeRun) return;
|
||||||
const batch = totpItems.slice(start, start + TOTP_REFRESH_BATCH_SIZE);
|
const batch = totpItems.slice(start, start + TOTP_REFRESH_BATCH_SIZE);
|
||||||
@@ -132,7 +123,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
batch.map(async (cipher) => {
|
batch.map(async (cipher) => {
|
||||||
try {
|
try {
|
||||||
const next = await calcTotpNow(cipher.login?.decTotp || '');
|
const next = await calcTotpNow(cipher.login?.decTotp || '');
|
||||||
return [cipher.id, next?.code || null] as const;
|
return [cipher.id, next] as const;
|
||||||
} catch {
|
} catch {
|
||||||
return [cipher.id, null] as const;
|
return [cipher.id, null] as const;
|
||||||
}
|
}
|
||||||
@@ -146,15 +137,20 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
if (stopped || runId !== activeRun) return;
|
if (stopped || runId !== activeRun) return;
|
||||||
setTotpCodes((prev) => {
|
setTotpCodes((prev) => {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const next: Record<string, string | null> = { ...prev };
|
const next: Record<string, TotpCodeResult | null> = { ...prev };
|
||||||
for (const id of Object.keys(next)) {
|
for (const id of Object.keys(next)) {
|
||||||
if (id in nextCodes) continue;
|
if (id in nextCodes) continue;
|
||||||
delete next[id];
|
delete next[id];
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
for (const [id, code] of Object.entries(nextCodes)) {
|
for (const [id, live] of Object.entries(nextCodes)) {
|
||||||
if (next[id] === code) continue;
|
const prevLive = next[id];
|
||||||
next[id] = code;
|
if (
|
||||||
|
prevLive?.code === live?.code &&
|
||||||
|
prevLive?.remain === live?.remain &&
|
||||||
|
prevLive?.period === live?.period
|
||||||
|
) continue;
|
||||||
|
next[id] = live;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
return changed ? next : prev;
|
return changed ? next : prev;
|
||||||
@@ -162,10 +158,6 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
const next = getTotpTimeState();
|
|
||||||
setRemainingSeconds((prev) => (prev === next.remain ? prev : next.remain));
|
|
||||||
if (next.windowId === currentWindowId) return;
|
|
||||||
currentWindowId = next.windowId;
|
|
||||||
void refreshCodes();
|
void refreshCodes();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -215,7 +207,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
<TotpRow
|
<TotpRow
|
||||||
key={cipher.id}
|
key={cipher.id}
|
||||||
cipher={cipher}
|
cipher={cipher}
|
||||||
live={totpCodes[cipher.id] ? { code: totpCodes[cipher.id] || '', remain: remainingSeconds } : null}
|
live={totpCodes[cipher.id] || null}
|
||||||
onCopy={(value) => void copyToClipboard(value)}
|
onCopy={(value) => void copyToClipboard(value)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
type SidebarFilter,
|
type SidebarFilter,
|
||||||
type VaultSortMode,
|
type VaultSortMode,
|
||||||
} from '@/components/vault/vault-page-helpers';
|
} from '@/components/vault/vault-page-helpers';
|
||||||
import { calcTotpNow } from '@/lib/crypto';
|
import { calcTotpNow, type TotpCodeResult } from '@/lib/crypto';
|
||||||
import { computeSshFingerprint, generateDefaultSshKeyMaterial } from '@/lib/ssh';
|
import { computeSshFingerprint, generateDefaultSshKeyMaterial } from '@/lib/ssh';
|
||||||
import { ChevronLeft } from 'lucide-preact';
|
import { ChevronLeft } from 'lucide-preact';
|
||||||
import type { Cipher, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
import type { Cipher, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
||||||
@@ -109,7 +109,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
const [renameFolderName, setRenameFolderName] = useState('');
|
const [renameFolderName, setRenameFolderName] = useState('');
|
||||||
const [pendingDeleteFolder, setPendingDeleteFolder] = useState<Folder | null>(null);
|
const [pendingDeleteFolder, setPendingDeleteFolder] = useState<Folder | null>(null);
|
||||||
const [deleteAllFoldersOpen, setDeleteAllFoldersOpen] = useState(false);
|
const [deleteAllFoldersOpen, setDeleteAllFoldersOpen] = useState(false);
|
||||||
const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null);
|
const [totpLive, setTotpLive] = useState<TotpCodeResult | null>(null);
|
||||||
const [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState<Record<number, boolean>>({});
|
const [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState<Record<number, boolean>>({});
|
||||||
const [attachmentQueue, setAttachmentQueue] = useState<File[]>([]);
|
const [attachmentQueue, setAttachmentQueue] = useState<File[]>([]);
|
||||||
const [removedAttachmentIds, setRemovedAttachmentIds] = useState<Record<string, boolean>>({});
|
const [removedAttachmentIds, setRemovedAttachmentIds] = useState<Record<string, boolean>>({});
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { createPortal } from 'preact/compat';
|
|||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact';
|
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact';
|
||||||
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||||
|
import type { TotpCodeResult } from '@/lib/crypto';
|
||||||
import type { Cipher } from '@/lib/types';
|
import type { Cipher } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import {
|
import {
|
||||||
CardBrandIcon,
|
CardBrandIcon,
|
||||||
TOTP_PERIOD_SECONDS,
|
|
||||||
TOTP_RING_CIRCUMFERENCE,
|
TOTP_RING_CIRCUMFERENCE,
|
||||||
VaultListIcon,
|
VaultListIcon,
|
||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
@@ -25,7 +25,7 @@ interface VaultDetailViewProps {
|
|||||||
selectedCipher: Cipher;
|
selectedCipher: Cipher;
|
||||||
repromptApprovedCipherId: string | null;
|
repromptApprovedCipherId: string | null;
|
||||||
showPassword: boolean;
|
showPassword: boolean;
|
||||||
totpLive: { code: string; remain: number } | null;
|
totpLive: TotpCodeResult | null;
|
||||||
passkeyCreatedAt: string | null;
|
passkeyCreatedAt: string | null;
|
||||||
hiddenFieldVisibleMap: Record<number, boolean>;
|
hiddenFieldVisibleMap: Record<number, boolean>;
|
||||||
folderName: (id: string | null | undefined) => string;
|
folderName: (id: string | null | undefined) => string;
|
||||||
@@ -42,6 +42,11 @@ interface VaultDetailViewProps {
|
|||||||
onUnarchive: (cipher: Cipher) => void | Promise<void>;
|
onUnarchive: (cipher: Cipher) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function totpProgress(live: TotpCodeResult | null): number {
|
||||||
|
const period = Math.max(1, live?.period || 30);
|
||||||
|
return live ? Math.max(0, Math.min(period, live.remain)) / period : 0;
|
||||||
|
}
|
||||||
|
|
||||||
function PasswordHistoryDialog(props: {
|
function PasswordHistoryDialog(props: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
entries: Array<{ password: string; lastUsedDate: string | null }>;
|
entries: Array<{ password: string; lastUsedDate: string | null }>;
|
||||||
@@ -191,8 +196,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
|
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
|
||||||
strokeDashoffset: String(
|
strokeDashoffset: String(
|
||||||
TOTP_RING_CIRCUMFERENCE -
|
TOTP_RING_CIRCUMFERENCE -
|
||||||
TOTP_RING_CIRCUMFERENCE *
|
TOTP_RING_CIRCUMFERENCE * totpProgress(props.totpLive)
|
||||||
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.totpLive?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
|
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ArrowDown, ArrowUp, CheckCheck, Download, Paperclip, Plus, QrCode, Refr
|
|||||||
import jsQR from 'jsqr';
|
import jsQR from 'jsqr';
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||||
|
import { normalizeTotpInput } from '@/lib/crypto';
|
||||||
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import { cardBrand } from '@/lib/import-format-shared';
|
import { cardBrand } from '@/lib/import-format-shared';
|
||||||
@@ -161,9 +162,9 @@ export default function VaultEditor(props: VaultEditorProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const applyTotpQrValue = (value: string) => {
|
const applyTotpQrValue = (value: string) => {
|
||||||
const trimmed = value.trim();
|
const normalized = normalizeTotpInput(value);
|
||||||
if (!trimmed) return false;
|
if (!normalized) return false;
|
||||||
props.onUpdateDraft({ loginTotp: trimmed });
|
props.onUpdateDraft({ loginTotp: normalized });
|
||||||
setTotpQrStatus(t('txt_totp_qr_scanned'));
|
setTotpQrStatus(t('txt_totp_qr_scanned'));
|
||||||
setTotpQrOpen(false);
|
setTotpQrOpen(false);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '@/lib/website-icon-cache';
|
} from '@/lib/website-icon-cache';
|
||||||
import { demoBrandIconUrl } from '@/lib/demo-brand-icons';
|
import { demoBrandIconUrl } from '@/lib/demo-brand-icons';
|
||||||
import { getCurrentNetworkStatus, subscribeNetworkStatus } from '@/lib/network-status';
|
import { getCurrentNetworkStatus, subscribeNetworkStatus } from '@/lib/network-status';
|
||||||
|
import { areWebsiteIconsEnabled } from '@/lib/website-icon-settings';
|
||||||
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
||||||
|
|
||||||
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
|
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
|
||||||
@@ -22,7 +23,8 @@ interface WebsiteIconProps {
|
|||||||
|
|
||||||
export default function WebsiteIcon(props: WebsiteIconProps) {
|
export default function WebsiteIcon(props: WebsiteIconProps) {
|
||||||
const host = useMemo(() => hostFromUri(firstCipherUri(props.cipher)), [props.cipher]);
|
const host = useMemo(() => hostFromUri(firstCipherUri(props.cipher)), [props.cipher]);
|
||||||
const src = host ? websiteIconUrl(host) : '';
|
const iconsEnabled = areWebsiteIconsEnabled();
|
||||||
|
const src = iconsEnabled && host ? websiteIconUrl(host) : '';
|
||||||
const nodeRef = useRef<HTMLSpanElement | null>(null);
|
const nodeRef = useRef<HTMLSpanElement | null>(null);
|
||||||
const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
|
const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
|
||||||
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
|
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
|
||||||
@@ -33,7 +35,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
|||||||
useEffect(() => subscribeNetworkStatus(setNetworkStatus), []);
|
useEffect(() => subscribeNetworkStatus(setNetworkStatus), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!host) {
|
if (!host || !iconsEnabled) {
|
||||||
setShouldLoad(true);
|
setShouldLoad(true);
|
||||||
setStatus('idle');
|
setStatus('idle');
|
||||||
setImageUrl('');
|
setImageUrl('');
|
||||||
@@ -47,7 +49,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
|||||||
setStatus(next);
|
setStatus(next);
|
||||||
setImageUrl(getWebsiteIconImageUrl(host));
|
setImageUrl(getWebsiteIconImageUrl(host));
|
||||||
});
|
});
|
||||||
}, [host]);
|
}, [host, iconsEnabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!host || shouldLoad || status === 'loaded' || status === 'error') return;
|
if (!host || shouldLoad || status === 'loaded' || status === 'error') return;
|
||||||
@@ -81,10 +83,11 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (SHOULD_LOAD_DEMO_BRAND_ICONS) return;
|
if (SHOULD_LOAD_DEMO_BRAND_ICONS) return;
|
||||||
if (demoIconUrl) return;
|
if (demoIconUrl) return;
|
||||||
|
if (!iconsEnabled) return;
|
||||||
if (networkStatus !== 'online') return;
|
if (networkStatus !== 'online') return;
|
||||||
if (!host || !src || !shouldLoad || status !== 'idle') return;
|
if (!host || !src || !shouldLoad || status !== 'idle') return;
|
||||||
beginWebsiteIconLoad(host, src);
|
beginWebsiteIconLoad(host, src);
|
||||||
}, [demoIconUrl, host, networkStatus, src, shouldLoad, status]);
|
}, [demoIconUrl, host, iconsEnabled, networkStatus, src, shouldLoad, status]);
|
||||||
|
|
||||||
if (demoIconUrl) {
|
if (demoIconUrl) {
|
||||||
return (
|
return (
|
||||||
@@ -100,7 +103,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!host || status === 'error') {
|
if (!host || !iconsEnabled || status === 'error') {
|
||||||
return <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>;
|
return <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -207,8 +207,7 @@ export function getWebsiteMatchOptions(): Array<{ value: number | null; label: s
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TOTP_PERIOD_SECONDS = 30;
|
const TOTP_RING_RADIUS = 14;
|
||||||
export const TOTP_RING_RADIUS = 14;
|
|
||||||
export const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
export const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
||||||
|
|
||||||
export function CreateTypeIcon({ type }: { type: number }) {
|
export function CreateTypeIcon({ type }: { type: number }) {
|
||||||
|
|||||||
@@ -82,8 +82,8 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
|
|||||||
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
|
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
|
||||||
},
|
},
|
||||||
|
|
||||||
async inspectRemoteBackup(destinationId: string, path: string) {
|
async inspectRemoteBackup(masterPasswordHash: string, destinationId: string, path: string) {
|
||||||
return inspectRemoteBackupIntegrity(authedFetch, destinationId, path);
|
return inspectRemoteBackupIntegrity(authedFetch, masterPasswordHash, destinationId, path);
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteRemoteBackup(masterPasswordHash: string, destinationId: string, path: string) {
|
async deleteRemoteBackup(masterPasswordHash: string, destinationId: string, path: string) {
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ export async function respondToAuthRequest(
|
|||||||
requestId: string,
|
requestId: string,
|
||||||
payload: {
|
payload: {
|
||||||
key?: string | null;
|
key?: string | null;
|
||||||
masterPasswordHash?: string | null;
|
|
||||||
deviceIdentifier: string;
|
deviceIdentifier: string;
|
||||||
requestApproved: boolean;
|
requestApproved: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,11 +196,14 @@ export async function exportAdminBackup(
|
|||||||
|
|
||||||
export async function downloadAdminBackupAttachmentBlob(
|
export async function downloadAdminBackupAttachmentBlob(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
blobName: string
|
blobName: string,
|
||||||
|
masterPasswordHash: string
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
const params = new URLSearchParams();
|
const resp = await authedFetch('/api/admin/backup/blob', {
|
||||||
params.set('blobName', blobName);
|
method: 'POST',
|
||||||
const resp = await authedFetch(`/api/admin/backup/blob?${params.toString()}`, { method: 'GET' });
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ blobName, masterPasswordHash }),
|
||||||
|
});
|
||||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed')));
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed')));
|
||||||
return new Uint8Array(await resp.arrayBuffer());
|
return new Uint8Array(await resp.arrayBuffer());
|
||||||
}
|
}
|
||||||
@@ -246,7 +249,7 @@ export async function buildCompleteAdminBackupExport(
|
|||||||
stageDetail: 'txt_backup_export_progress_fetch_attachments_detail',
|
stageDetail: 'txt_backup_export_progress_fetch_attachments_detail',
|
||||||
});
|
});
|
||||||
for (const attachment of manifest.attachmentBlobs || []) {
|
for (const attachment of manifest.attachmentBlobs || []) {
|
||||||
const bytes = await downloadAdminBackupAttachmentBlob(authedFetch, attachment.blobName);
|
const bytes = await downloadAdminBackupAttachmentBlob(authedFetch, attachment.blobName, masterPasswordHash);
|
||||||
zipped[`attachments/${attachment.cipherId}/${attachment.attachmentId}.bin`] = bytes;
|
zipped[`attachments/${attachment.cipherId}/${attachment.attachmentId}.bin`] = bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,13 +420,15 @@ export async function deleteRemoteBackup(
|
|||||||
|
|
||||||
export async function inspectRemoteBackupIntegrity(
|
export async function inspectRemoteBackupIntegrity(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
|
masterPasswordHash: string,
|
||||||
destinationId: string,
|
destinationId: string,
|
||||||
path: string
|
path: string
|
||||||
): Promise<RemoteBackupIntegrityResponse> {
|
): Promise<RemoteBackupIntegrityResponse> {
|
||||||
const params = new URLSearchParams();
|
const resp = await authedFetch('/api/admin/backup/remote/integrity', {
|
||||||
params.set('destinationId', destinationId);
|
method: 'POST',
|
||||||
params.set('path', path);
|
headers: { 'Content-Type': 'application/json' },
|
||||||
const resp = await authedFetch(`/api/admin/backup/remote/integrity?${params.toString()}`, { method: 'GET' });
|
body: JSON.stringify({ destinationId, path, masterPasswordHash }),
|
||||||
|
});
|
||||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed')));
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed')));
|
||||||
const body = await parseJson<RemoteBackupIntegrityResponse>(resp);
|
const body = await parseJson<RemoteBackupIntegrityResponse>(resp);
|
||||||
if (!body?.integrity || !body?.fileName) throw new Error(t('txt_backup_remote_invalid_response'));
|
if (!body?.integrity || !body?.fileName) throw new Error(t('txt_backup_remote_invalid_response'));
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
unlockOfflineVaultWithMasterKey,
|
unlockOfflineVaultWithMasterKey,
|
||||||
} from '@/lib/offline-auth';
|
} from '@/lib/offline-auth';
|
||||||
import { probeNodeWardenService } from '@/lib/network-status';
|
import { probeNodeWardenService } from '@/lib/network-status';
|
||||||
|
import { setWebsiteIconsEnabled } from '@/lib/website-icon-settings';
|
||||||
import type { AccountPasskeyPrfOption, AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
|
import type { AccountPasskeyPrfOption, AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
|
||||||
|
|
||||||
export interface PendingTotp {
|
export interface PendingTotp {
|
||||||
@@ -51,6 +52,7 @@ export type JwtUnsafeReason = 'missing' | 'too_short';
|
|||||||
export interface BootstrapAppResult {
|
export interface BootstrapAppResult {
|
||||||
defaultKdfIterations: number;
|
defaultKdfIterations: number;
|
||||||
registrationInviteRequired?: boolean;
|
registrationInviteRequired?: boolean;
|
||||||
|
websiteIconsEnabled: boolean;
|
||||||
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
|
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
|
||||||
session: SessionState | null;
|
session: SessionState | null;
|
||||||
profile: Profile | null;
|
profile: Profile | null;
|
||||||
@@ -61,6 +63,7 @@ export interface BootstrapAppResult {
|
|||||||
export interface InitialAppBootstrapState {
|
export interface InitialAppBootstrapState {
|
||||||
defaultKdfIterations: number;
|
defaultKdfIterations: number;
|
||||||
registrationInviteRequired?: boolean;
|
registrationInviteRequired?: boolean;
|
||||||
|
websiteIconsEnabled: boolean;
|
||||||
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
|
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
|
||||||
session: SessionState | null;
|
session: SessionState | null;
|
||||||
phase: AppPhase;
|
phase: AppPhase;
|
||||||
@@ -229,10 +232,11 @@ function readWindowBootstrap(): WebBootstrapResponse {
|
|||||||
return raw && typeof raw === 'object' ? raw : {};
|
return raw && typeof raw === 'object' ? raw : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeBootstrapResponse(boot: WebBootstrapResponse): Pick<InitialAppBootstrapState, 'defaultKdfIterations' | 'registrationInviteRequired' | 'jwtWarning'> {
|
function normalizeBootstrapResponse(boot: WebBootstrapResponse): Pick<InitialAppBootstrapState, 'defaultKdfIterations' | 'registrationInviteRequired' | 'websiteIconsEnabled' | 'jwtWarning'> {
|
||||||
const defaultKdfIterations = Number(boot.defaultKdfIterations || 600000);
|
const defaultKdfIterations = Number(boot.defaultKdfIterations || 600000);
|
||||||
const registrationInviteRequired =
|
const registrationInviteRequired =
|
||||||
typeof boot.registrationInviteRequired === 'boolean' ? boot.registrationInviteRequired : undefined;
|
typeof boot.registrationInviteRequired === 'boolean' ? boot.registrationInviteRequired : undefined;
|
||||||
|
const websiteIconsEnabled = boot.websiteIconsEnabled !== false;
|
||||||
const jwtUnsafeReason = boot.jwtUnsafeReason || null;
|
const jwtUnsafeReason = boot.jwtUnsafeReason || null;
|
||||||
const jwtWarning = jwtUnsafeReason
|
const jwtWarning = jwtUnsafeReason
|
||||||
? {
|
? {
|
||||||
@@ -244,6 +248,7 @@ function normalizeBootstrapResponse(boot: WebBootstrapResponse): Pick<InitialApp
|
|||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
registrationInviteRequired,
|
registrationInviteRequired,
|
||||||
|
websiteIconsEnabled,
|
||||||
jwtWarning,
|
jwtWarning,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -304,7 +309,8 @@ function resolveUnauthenticatedPhase(registrationInviteRequired: boolean | undef
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function readInitialAppBootstrapState(): InitialAppBootstrapState {
|
export function readInitialAppBootstrapState(): InitialAppBootstrapState {
|
||||||
const { defaultKdfIterations, registrationInviteRequired, jwtWarning } = normalizeBootstrapResponse(readWindowBootstrap());
|
const { defaultKdfIterations, registrationInviteRequired, websiteIconsEnabled, jwtWarning } = normalizeBootstrapResponse(readWindowBootstrap());
|
||||||
|
setWebsiteIconsEnabled(websiteIconsEnabled);
|
||||||
const session = loadSession();
|
const session = loadSession();
|
||||||
const hasInviteCode = !!readInviteCodeFromUrl();
|
const hasInviteCode = !!readInviteCodeFromUrl();
|
||||||
const unauthenticatedPhase = hasInviteCode ? 'register' : 'login';
|
const unauthenticatedPhase = hasInviteCode ? 'register' : 'login';
|
||||||
@@ -312,6 +318,7 @@ export function readInitialAppBootstrapState(): InitialAppBootstrapState {
|
|||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
registrationInviteRequired,
|
registrationInviteRequired,
|
||||||
|
websiteIconsEnabled,
|
||||||
jwtWarning,
|
jwtWarning,
|
||||||
session,
|
session,
|
||||||
phase: jwtWarning ? 'login' : session ? 'locked' : resolveUnauthenticatedPhase(registrationInviteRequired, unauthenticatedPhase),
|
phase: jwtWarning ? 'login' : session ? 'locked' : resolveUnauthenticatedPhase(registrationInviteRequired, unauthenticatedPhase),
|
||||||
@@ -323,12 +330,15 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
|||||||
const normalizedBoot = normalizeBootstrapResponse(remoteBoot);
|
const normalizedBoot = normalizeBootstrapResponse(remoteBoot);
|
||||||
const defaultKdfIterations = normalizedBoot.defaultKdfIterations || initial.defaultKdfIterations;
|
const defaultKdfIterations = normalizedBoot.defaultKdfIterations || initial.defaultKdfIterations;
|
||||||
const registrationInviteRequired = normalizedBoot.registrationInviteRequired ?? initial.registrationInviteRequired;
|
const registrationInviteRequired = normalizedBoot.registrationInviteRequired ?? initial.registrationInviteRequired;
|
||||||
|
const websiteIconsEnabled = normalizedBoot.websiteIconsEnabled !== false;
|
||||||
|
setWebsiteIconsEnabled(websiteIconsEnabled);
|
||||||
const jwtWarning = normalizedBoot.jwtWarning ?? initial.jwtWarning;
|
const jwtWarning = normalizedBoot.jwtWarning ?? initial.jwtWarning;
|
||||||
|
|
||||||
if (jwtWarning) {
|
if (jwtWarning) {
|
||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
registrationInviteRequired,
|
registrationInviteRequired,
|
||||||
|
websiteIconsEnabled,
|
||||||
jwtWarning,
|
jwtWarning,
|
||||||
session: null,
|
session: null,
|
||||||
profile: null,
|
profile: null,
|
||||||
@@ -341,6 +351,7 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
|||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
registrationInviteRequired,
|
registrationInviteRequired,
|
||||||
|
websiteIconsEnabled,
|
||||||
jwtWarning: null,
|
jwtWarning: null,
|
||||||
session: null,
|
session: null,
|
||||||
profile: null,
|
profile: null,
|
||||||
@@ -353,6 +364,7 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
|||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
registrationInviteRequired,
|
registrationInviteRequired,
|
||||||
|
websiteIconsEnabled,
|
||||||
jwtWarning: null,
|
jwtWarning: null,
|
||||||
session: loaded,
|
session: loaded,
|
||||||
profile: cachedProfile,
|
profile: cachedProfile,
|
||||||
@@ -364,6 +376,7 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
|||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
registrationInviteRequired,
|
registrationInviteRequired,
|
||||||
|
websiteIconsEnabled,
|
||||||
jwtWarning: null,
|
jwtWarning: null,
|
||||||
session: loaded,
|
session: loaded,
|
||||||
profile: null,
|
profile: null,
|
||||||
|
|||||||
+219
-27
@@ -259,17 +259,33 @@ interface TotpConfig {
|
|||||||
period: number;
|
period: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GoogleAuthenticatorMigrationTotp {
|
||||||
|
secret: string;
|
||||||
|
name: string;
|
||||||
|
issuer: string;
|
||||||
|
algorithm: TotpHashAlgorithm;
|
||||||
|
digits: number;
|
||||||
|
period: number;
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_TOTP_CONFIG: Omit<TotpConfig, 'secret' | 'steam'> = {
|
const DEFAULT_TOTP_CONFIG: Omit<TotpConfig, 'secret' | 'steam'> = {
|
||||||
algorithm: 'SHA-1',
|
algorithm: 'SHA-1',
|
||||||
digits: 6,
|
digits: 6,
|
||||||
period: 30,
|
period: 30,
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseTotpPositiveInt(value: string | null, fallback: number, min: number, max: number): number {
|
function parseTotpDigits(value: string | null): number {
|
||||||
if (!value) return fallback;
|
if (!value) return DEFAULT_TOTP_CONFIG.digits;
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
if (!Number.isInteger(parsed) || parsed < min || parsed > max) return fallback;
|
if (!Number.isInteger(parsed)) return DEFAULT_TOTP_CONFIG.digits;
|
||||||
return parsed;
|
return Math.max(0, Math.min(10, parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTotpPeriod(value: string | null): number {
|
||||||
|
if (!value) return DEFAULT_TOTP_CONFIG.period;
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isSafeInteger(parsed)) return DEFAULT_TOTP_CONFIG.period;
|
||||||
|
return Math.max(1, parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTotpHashAlgorithm(value: string | null): TotpHashAlgorithm {
|
function parseTotpHashAlgorithm(value: string | null): TotpHashAlgorithm {
|
||||||
@@ -279,9 +295,190 @@ function parseTotpHashAlgorithm(value: string | null): TotpHashAlgorithm {
|
|||||||
return 'SHA-1';
|
return 'SHA-1';
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTotpConfig(raw: string): TotpConfig {
|
function base64ToBytesLoose(value: string): Uint8Array {
|
||||||
if (!raw) return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
|
const normalized = value.trim().replace(/\s/g, '+').replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
if (!normalized) return new Uint8Array();
|
||||||
|
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
|
||||||
|
try {
|
||||||
|
const binary = atob(padded);
|
||||||
|
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||||
|
} catch {
|
||||||
|
return new Uint8Array();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToBase32(bytes: Uint8Array): string {
|
||||||
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||||
|
let bits = 0;
|
||||||
|
let value = 0;
|
||||||
|
let out = '';
|
||||||
|
for (const byte of bytes) {
|
||||||
|
value = (value << 8) | byte;
|
||||||
|
bits += 8;
|
||||||
|
while (bits >= 5) {
|
||||||
|
out += alphabet[(value >>> (bits - 5)) & 31];
|
||||||
|
bits -= 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bits > 0) {
|
||||||
|
out += alphabet[(value << (5 - bits)) & 31];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readProtoVarint(bytes: Uint8Array, state: { offset: number }): number | null {
|
||||||
|
let result = 0;
|
||||||
|
let factor = 1;
|
||||||
|
for (let i = 0; i < 10 && state.offset < bytes.length; i += 1) {
|
||||||
|
const byte = bytes[state.offset++];
|
||||||
|
result += (byte & 0x7f) * factor;
|
||||||
|
if ((byte & 0x80) === 0) return Number.isSafeInteger(result) ? result : null;
|
||||||
|
factor *= 128;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readProtoBytes(bytes: Uint8Array, state: { offset: number }): Uint8Array | null {
|
||||||
|
const length = readProtoVarint(bytes, state);
|
||||||
|
if (length == null || length < 0 || state.offset + length > bytes.length) return null;
|
||||||
|
const out = bytes.slice(state.offset, state.offset + length);
|
||||||
|
state.offset += length;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipProtoField(bytes: Uint8Array, state: { offset: number }, wireType: number): boolean {
|
||||||
|
if (wireType === 0) return readProtoVarint(bytes, state) != null;
|
||||||
|
if (wireType === 1 && state.offset + 8 <= bytes.length) {
|
||||||
|
state.offset += 8;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (wireType === 2) return readProtoBytes(bytes, state) != null;
|
||||||
|
if (wireType === 5 && state.offset + 4 <= bytes.length) {
|
||||||
|
state.offset += 4;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function googleMigrationAlgorithm(value: number): TotpHashAlgorithm | null {
|
||||||
|
if (value === 0 || value === 1) return 'SHA-1';
|
||||||
|
if (value === 2) return 'SHA-256';
|
||||||
|
if (value === 3) return 'SHA-512';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function googleMigrationDigits(value: number): number {
|
||||||
|
if (value === 2) return 8;
|
||||||
|
return 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGoogleMigrationOtpParameter(bytes: Uint8Array): GoogleAuthenticatorMigrationTotp | null {
|
||||||
|
const state = { offset: 0 };
|
||||||
|
let secretBytes: Uint8Array | null = null;
|
||||||
|
let name = '';
|
||||||
|
let issuer = '';
|
||||||
|
let algorithm: TotpHashAlgorithm | null = 'SHA-1';
|
||||||
|
let digits = 6;
|
||||||
|
let otpType = 0;
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
while (state.offset < bytes.length) {
|
||||||
|
const key = readProtoVarint(bytes, state);
|
||||||
|
if (key == null) return null;
|
||||||
|
const fieldNumber = Math.floor(key / 8);
|
||||||
|
const wireType = key % 8;
|
||||||
|
|
||||||
|
if (fieldNumber === 1 && wireType === 2) {
|
||||||
|
secretBytes = readProtoBytes(bytes, state);
|
||||||
|
} else if (fieldNumber === 2 && wireType === 2) {
|
||||||
|
const value = readProtoBytes(bytes, state);
|
||||||
|
name = value ? decoder.decode(value) : '';
|
||||||
|
} else if (fieldNumber === 3 && wireType === 2) {
|
||||||
|
const value = readProtoBytes(bytes, state);
|
||||||
|
issuer = value ? decoder.decode(value) : '';
|
||||||
|
} else if (fieldNumber === 4 && wireType === 0) {
|
||||||
|
const value = readProtoVarint(bytes, state);
|
||||||
|
algorithm = value == null ? null : googleMigrationAlgorithm(value);
|
||||||
|
} else if (fieldNumber === 5 && wireType === 0) {
|
||||||
|
const value = readProtoVarint(bytes, state);
|
||||||
|
digits = googleMigrationDigits(value ?? 0);
|
||||||
|
} else if (fieldNumber === 6 && wireType === 0) {
|
||||||
|
otpType = readProtoVarint(bytes, state) ?? 0;
|
||||||
|
} else if (!skipProtoField(bytes, state, wireType)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!secretBytes?.length || !algorithm || otpType === 1) return null;
|
||||||
|
return {
|
||||||
|
secret: bytesToBase32(secretBytes),
|
||||||
|
name,
|
||||||
|
issuer,
|
||||||
|
algorithm,
|
||||||
|
digits,
|
||||||
|
period: DEFAULT_TOTP_CONFIG.period,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGoogleAuthenticatorMigration(raw: string): GoogleAuthenticatorMigrationTotp[] {
|
||||||
|
let data = '';
|
||||||
|
try {
|
||||||
|
data = new URL(raw).searchParams.get('data') || '';
|
||||||
|
} catch {
|
||||||
|
data = readOtpAuthParam(raw, 'data');
|
||||||
|
}
|
||||||
|
const bytes = base64ToBytesLoose(data);
|
||||||
|
if (!bytes.length) return [];
|
||||||
|
|
||||||
|
const state = { offset: 0 };
|
||||||
|
const out: GoogleAuthenticatorMigrationTotp[] = [];
|
||||||
|
while (state.offset < bytes.length) {
|
||||||
|
const key = readProtoVarint(bytes, state);
|
||||||
|
if (key == null) return [];
|
||||||
|
const fieldNumber = Math.floor(key / 8);
|
||||||
|
const wireType = key % 8;
|
||||||
|
if (fieldNumber === 1 && wireType === 2) {
|
||||||
|
const parameterBytes = readProtoBytes(bytes, state);
|
||||||
|
const parameter = parameterBytes ? parseGoogleMigrationOtpParameter(parameterBytes) : null;
|
||||||
|
if (parameter) out.push(parameter);
|
||||||
|
} else if (!skipProtoField(bytes, state, wireType)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOtpAuthUri(account: GoogleAuthenticatorMigrationTotp): string {
|
||||||
|
const issuer = account.issuer.trim();
|
||||||
|
const name = account.name.trim();
|
||||||
|
const label = issuer && name && !name.toLowerCase().startsWith(`${issuer.toLowerCase()}:`)
|
||||||
|
? `${issuer}:${name}`
|
||||||
|
: name || issuer || 'TOTP';
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
secret: account.secret,
|
||||||
|
algorithm: account.algorithm.replace('-', ''),
|
||||||
|
digits: String(account.digits),
|
||||||
|
period: String(account.period),
|
||||||
|
});
|
||||||
|
if (issuer) params.set('issuer', issuer);
|
||||||
|
return `otpauth://totp/${encodeURIComponent(label)}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTotpInput(raw: string): string {
|
||||||
const s = raw.trim();
|
const s = raw.trim();
|
||||||
|
if (!s) return '';
|
||||||
|
if (/^otpauth-migration:\/\//i.test(s)) {
|
||||||
|
const accounts = parseGoogleAuthenticatorMigration(s);
|
||||||
|
return accounts.length === 1 ? buildOtpAuthUri(accounts[0]) : '';
|
||||||
|
}
|
||||||
|
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s) && !/^otpauth:\/\//i.test(s) && !/^steam:\/\//i.test(s)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTotpConfig(raw: string): TotpConfig {
|
||||||
|
const s = normalizeTotpInput(raw);
|
||||||
if (!s) return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
|
if (!s) return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
|
||||||
if (/^steam:\/\//i.test(s)) {
|
if (/^steam:\/\//i.test(s)) {
|
||||||
return {
|
return {
|
||||||
@@ -295,31 +492,20 @@ function parseTotpConfig(raw: string): TotpConfig {
|
|||||||
if (/^otpauth:\/\//i.test(s)) {
|
if (/^otpauth:\/\//i.test(s)) {
|
||||||
try {
|
try {
|
||||||
const u = new URL(s);
|
const u = new URL(s);
|
||||||
const otpType = u.hostname.toLowerCase();
|
|
||||||
if (otpType !== 'totp') {
|
|
||||||
return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
|
|
||||||
}
|
|
||||||
const label = decodeURIComponent((u.pathname || '').replace(/^\/+/, '')).toLowerCase();
|
|
||||||
const issuer = (u.searchParams.get('issuer') || '').trim().toLowerCase();
|
|
||||||
const algorithm = (u.searchParams.get('algorithm') || '').trim().toLowerCase();
|
|
||||||
const steam = issuer === 'steam' || label.startsWith('steam:') || algorithm === 'steam';
|
|
||||||
return {
|
return {
|
||||||
secret: normalizeTotpSecret(u.searchParams.get('secret') || ''),
|
secret: normalizeTotpSecret(u.searchParams.get('secret') || ''),
|
||||||
steam,
|
steam: false,
|
||||||
algorithm: steam ? 'SHA-1' : parseTotpHashAlgorithm(u.searchParams.get('algorithm')),
|
algorithm: parseTotpHashAlgorithm(u.searchParams.get('algorithm')),
|
||||||
digits: steam ? 5 : parseTotpPositiveInt(u.searchParams.get('digits'), DEFAULT_TOTP_CONFIG.digits, 1, 10),
|
digits: parseTotpDigits(u.searchParams.get('digits')),
|
||||||
period: parseTotpPositiveInt(u.searchParams.get('period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
|
period: parseTotpPeriod(u.searchParams.get('period')),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
const issuer = readOtpAuthParam(s, 'issuer').trim().toLowerCase();
|
|
||||||
const algorithm = readOtpAuthParam(s, 'algorithm').trim().toLowerCase();
|
|
||||||
const steam = issuer === 'steam' || algorithm === 'steam';
|
|
||||||
return {
|
return {
|
||||||
secret: normalizeTotpSecret(readOtpAuthParam(s, 'secret')),
|
secret: normalizeTotpSecret(readOtpAuthParam(s, 'secret')),
|
||||||
steam,
|
steam: false,
|
||||||
algorithm: steam ? 'SHA-1' : parseTotpHashAlgorithm(algorithm),
|
algorithm: parseTotpHashAlgorithm(readOtpAuthParam(s, 'algorithm')),
|
||||||
digits: steam ? 5 : parseTotpPositiveInt(readOtpAuthParam(s, 'digits'), DEFAULT_TOTP_CONFIG.digits, 1, 10),
|
digits: parseTotpDigits(readOtpAuthParam(s, 'digits')),
|
||||||
period: parseTotpPositiveInt(readOtpAuthParam(s, 'period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
|
period: parseTotpPeriod(readOtpAuthParam(s, 'period')),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,7 +535,13 @@ function base32ToBytes(input: string): Uint8Array {
|
|||||||
return new Uint8Array(out);
|
return new Uint8Array(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now()): Promise<{ code: string; remain: number } | null> {
|
export interface TotpCodeResult {
|
||||||
|
code: string;
|
||||||
|
remain: number;
|
||||||
|
period: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now()): Promise<TotpCodeResult | null> {
|
||||||
const { secret, steam, algorithm, digits, period } = parseTotpConfig(rawSecret);
|
const { secret, steam, algorithm, digits, period } = parseTotpConfig(rawSecret);
|
||||||
if (!secret) return null;
|
if (!secret) return null;
|
||||||
const keyBytes = base32ToBytes(secret);
|
const keyBytes = base32ToBytes(secret);
|
||||||
@@ -378,5 +570,5 @@ export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now())
|
|||||||
value = Math.floor(value / chars.length);
|
value = Math.floor(value / chars.length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { code, remain };
|
return { code, remain, period };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function createDemoInitialBootstrapState(): InitialAppBootstrapState {
|
|||||||
return {
|
return {
|
||||||
defaultKdfIterations: 600000,
|
defaultKdfIterations: 600000,
|
||||||
registrationInviteRequired: true,
|
registrationInviteRequired: true,
|
||||||
|
websiteIconsEnabled: true,
|
||||||
jwtWarning: null,
|
jwtWarning: null,
|
||||||
session: null,
|
session: null,
|
||||||
phase: 'login',
|
phase: 'login',
|
||||||
|
|||||||
@@ -790,6 +790,7 @@ export function createDemoInitialBootstrapState(): InitialAppBootstrapState {
|
|||||||
return {
|
return {
|
||||||
defaultKdfIterations: 600000,
|
defaultKdfIterations: 600000,
|
||||||
registrationInviteRequired: true,
|
registrationInviteRequired: true,
|
||||||
|
websiteIconsEnabled: true,
|
||||||
jwtWarning: null,
|
jwtWarning: null,
|
||||||
session: null,
|
session: null,
|
||||||
phase: 'login',
|
phase: 'login',
|
||||||
@@ -1200,7 +1201,7 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
|
|||||||
onDownloadRemoteBackup: async (_masterPassword: string, _destinationId: string, _path: string, _onProgress?: (percent: number | null) => void) => {
|
onDownloadRemoteBackup: async (_masterPassword: string, _destinationId: string, _path: string, _onProgress?: (percent: number | null) => void) => {
|
||||||
notify('success', t('txt_demo_download_prepared'));
|
notify('success', t('txt_demo_download_prepared'));
|
||||||
},
|
},
|
||||||
onInspectRemoteBackup: async (_destinationId: string, path: string) => ({
|
onInspectRemoteBackup: async (_masterPassword: string, _destinationId: string, path: string) => ({
|
||||||
object: 'backup-remote-integrity',
|
object: 'backup-remote-integrity',
|
||||||
destinationId: _destinationId,
|
destinationId: _destinationId,
|
||||||
path,
|
path,
|
||||||
|
|||||||
@@ -280,6 +280,8 @@ export interface Send {
|
|||||||
key?: string | null;
|
key?: string | null;
|
||||||
maxAccessCount?: number | null;
|
maxAccessCount?: number | null;
|
||||||
accessCount?: number;
|
accessCount?: number;
|
||||||
|
password?: string | null;
|
||||||
|
authType?: number | null;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
revisionDate?: string;
|
revisionDate?: string;
|
||||||
expirationDate?: string | null;
|
expirationDate?: string | null;
|
||||||
@@ -308,6 +310,7 @@ export interface SendDraft {
|
|||||||
expirationDays: string;
|
expirationDays: string;
|
||||||
maxAccessCount: string;
|
maxAccessCount: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
hasPassword?: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,6 +414,8 @@ export interface WebBootstrapResponse {
|
|||||||
jwtUnsafeReason?: 'missing' | 'too_short' | null;
|
jwtUnsafeReason?: 'missing' | 'too_short' | null;
|
||||||
jwtSecretMinLength?: number;
|
jwtSecretMinLength?: number;
|
||||||
registrationInviteRequired?: boolean;
|
registrationInviteRequired?: boolean;
|
||||||
|
webAuthnAllowedOrigins?: string[];
|
||||||
|
websiteIconsEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface YubiKeyOtpSettings {
|
export interface YubiKeyOtpSettings {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
let websiteIconsEnabled = true;
|
||||||
|
|
||||||
|
export function setWebsiteIconsEnabled(enabled: boolean): void {
|
||||||
|
websiteIconsEnabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areWebsiteIconsEnabled(): boolean {
|
||||||
|
return websiteIconsEnabled;
|
||||||
|
}
|
||||||
@@ -1269,3 +1269,8 @@ select.input.duplicate-mode-toolbar-select {
|
|||||||
@apply text-sm;
|
@apply text-sm;
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-icon {
|
||||||
|
display: inline;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user