mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cae5cb218 | |||
| d96ad9bb1c | |||
| 92d1f07998 | |||
| a8432ab94b | |||
| 2230f75d8a | |||
| a982a5a57b | |||
| 4d7ee2164a | |||
| 34d4851981 | |||
| 4827a4958e | |||
| 70463d3fc7 | |||
| 681705ee13 | |||
| 5bf7c79ada | |||
| c516194d54 | |||
| 53231a4878 | |||
| c9e7417825 | |||
| 76623d7201 | |||
| 90a7731351 | |||
| f4adeb8ec9 | |||
| bb0b82f838 | |||
| be82c953d6 | |||
| edd2ba2e44 | |||
| 0f6da7d147 | |||
| 1184cb8d9a | |||
| 882fa2e8c8 | |||
| b6b7e46f79 | |||
| 144d3d9406 | |||
| 10707cf902 | |||
| 3bd4f6a9fe | |||
| 3d4e95ef66 | |||
| 2a7879efaa | |||
| bd8e26d2ab | |||
| 783fcbbe4b |
@@ -0,0 +1,70 @@
|
||||
name: "Bug Report"
|
||||
description: "Report a reproducible bug / 反馈可复现问题"
|
||||
title: "[Bug] "
|
||||
labels: ["bug", "needs-triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for reporting. Please provide enough detail so maintainers can reproduce quickly.
|
||||
感谢反馈,请尽量提供可复现信息,方便快速定位。
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Pre-check / 提交前确认
|
||||
options:
|
||||
- label: I have searched existing issues and did not find a duplicate. / 我已搜索现有 issue,确认不是重复问题。
|
||||
required: true
|
||||
- label: I have read README and Project Wiki / 我已阅读 README 与 项目 Wiki。
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version / 版本
|
||||
description: "Which version of NodeWarden are you using? Please provide the exact version or commit hash."
|
||||
placeholder: "1.0.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduce_steps
|
||||
attributes:
|
||||
label: Steps to Reproduce / 复现步骤
|
||||
placeholder: |
|
||||
1. Start service with ...
|
||||
2. Open ...
|
||||
3. Click ...
|
||||
4. Observe ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior / 预期行为
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Behavior / 实际行为
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs and Screenshots / 日志与截图
|
||||
description: "Please paste key logs (docker logs / browser console / network errors)."
|
||||
render: shell
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Additional Context / 补充信息
|
||||
description: "Any workaround, frequency, impact scope, etc."
|
||||
@@ -0,0 +1,12 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Project Wiki/ 项目文档
|
||||
url: https://github.com/shuaiplus/nodewarden/wiki
|
||||
about: |
|
||||
Please check the documentation for common questions and troubleshooting steps.
|
||||
请先查看文档,常见问题和排查步骤可能已经覆盖了你的问题。
|
||||
- name: Project Discussions / 讨论区
|
||||
url: https://github.com/shuaiplus/nodewarden/discussions
|
||||
about: |
|
||||
For general questions, feature discussions, or if you're not sure which template to use, please post in the Discussions section.
|
||||
如果你有一般性问题、功能讨论,或者不确定使用哪个模板,请在讨论区发帖。
|
||||
@@ -0,0 +1,62 @@
|
||||
name: "Feature Request"
|
||||
description: "Suggest an improvement / 功能建议"
|
||||
title: "[Feature] "
|
||||
labels: ["enhancement", "needs-triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Proposals with clear use-case and expected value are easier to evaluate.
|
||||
说明清晰的使用场景和价值,有助于快速评估。
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Pre-check / 提交前确认
|
||||
options:
|
||||
- label: I have searched existing issues and this request is not duplicated. / 我已搜索现有 issue,确认不是重复建议。
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem Statement / 现存问题
|
||||
description: "What is difficult or missing today?"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed Solution / 建议方案
|
||||
description: "Describe your expected behavior, UI flow, API changes, etc."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered / 备选方案
|
||||
description: "Any alternatives or workarounds you've considered."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: impact
|
||||
attributes:
|
||||
label: Expected Impact / 预期价值
|
||||
description: "Who benefits? Any performance/security/maintenance concerns?"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: scope
|
||||
attributes:
|
||||
label: Scope (Optional) / 影响范围(可选)
|
||||
placeholder: "frontend / backend / docs / deployment"
|
||||
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Additional Context / 补充信息
|
||||
description: "Mockups, references, related links, etc."
|
||||
@@ -4,6 +4,11 @@ on:
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_commit:
|
||||
description: 'Commit hash (leave blank to use latest commit)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -11,9 +16,8 @@ permissions:
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -22,13 +26,118 @@ jobs:
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Sync main from upstream
|
||||
- name: Add upstream
|
||||
run: |
|
||||
git remote add upstream https://github.com/shuaiplus/NodeWarden.git || true
|
||||
git fetch upstream
|
||||
git checkout main
|
||||
git merge upstream/main
|
||||
git fetch upstream --tags
|
||||
|
||||
- name: Push synced main
|
||||
- name: Resolve target commit
|
||||
id: resolve
|
||||
run: |
|
||||
git push origin main
|
||||
TRIGGER="${{ github.event_name }}"
|
||||
MANUAL_INPUT="${{ github.event.inputs.target_commit }}"
|
||||
|
||||
if [ "$TRIGGER" = "schedule" ]; then
|
||||
# Auto mode: resolve latest upstream release tag
|
||||
LATEST_TAG=$(curl -s https://api.github.com/repos/shuaiplus/NodeWarden/releases/latest | jq -r .tag_name)
|
||||
if [ "$LATEST_TAG" = "null" ] || [ -z "$LATEST_TAG" ]; then
|
||||
echo "No release found in upstream."
|
||||
exit 1
|
||||
fi
|
||||
TARGET_SHA=$(git rev-list -n 1 "$LATEST_TAG" 2>/dev/null)
|
||||
if [ -z "$TARGET_SHA" ]; then
|
||||
echo "Tag '$LATEST_TAG' not found after fetch."
|
||||
exit 1
|
||||
fi
|
||||
echo "mode=auto" >> $GITHUB_OUTPUT
|
||||
echo "latest_tag=$LATEST_TAG" >> $GITHUB_OUTPUT
|
||||
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
|
||||
echo "Auto mode — latest release: $LATEST_TAG ($TARGET_SHA)"
|
||||
|
||||
elif [ -n "$MANUAL_INPUT" ]; then
|
||||
# Manual mode: use provided commit hash or tag
|
||||
TARGET_SHA=$(git rev-parse "$MANUAL_INPUT" 2>/dev/null)
|
||||
if [ -z "$TARGET_SHA" ]; then
|
||||
echo "Cannot resolve '$MANUAL_INPUT' to a commit."
|
||||
exit 1
|
||||
fi
|
||||
echo "mode=manual" >> $GITHUB_OUTPUT
|
||||
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
|
||||
echo "Manual mode — target: $MANUAL_INPUT ($TARGET_SHA)"
|
||||
|
||||
else
|
||||
# Manual mode, blank input: use latest commit on upstream/main
|
||||
TARGET_SHA=$(git rev-parse upstream/main)
|
||||
echo "mode=manual" >> $GITHUB_OUTPUT
|
||||
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
|
||||
echo "Manual mode — latest commit: $TARGET_SHA"
|
||||
fi
|
||||
|
||||
- name: Check if update is needed
|
||||
id: check
|
||||
run: |
|
||||
TARGET_SHA="${{ steps.resolve.outputs.target_sha }}"
|
||||
MODE="${{ steps.resolve.outputs.mode }}"
|
||||
|
||||
if [ "$MODE" = "manual" ]; then
|
||||
# Manual: skip only if HEAD is exactly this commit
|
||||
CURRENT_SHA=$(git rev-parse HEAD)
|
||||
if [ "$CURRENT_SHA" = "$TARGET_SHA" ]; then
|
||||
echo "Already at $TARGET_SHA — skipping."
|
||||
echo "needs_update=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Switching to $TARGET_SHA"
|
||||
echo "needs_update=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
else
|
||||
# Auto: skip if target is already in ancestry
|
||||
if git merge-base --is-ancestor "$TARGET_SHA" HEAD 2>/dev/null; then
|
||||
echo "Already up to date with $TARGET_SHA — skipping."
|
||||
echo "needs_update=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Update needed — target: $TARGET_SHA"
|
||||
echo "needs_update=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Apply update
|
||||
if: steps.check.outputs.needs_update == 'true'
|
||||
run: |
|
||||
TARGET_SHA="${{ steps.resolve.outputs.target_sha }}"
|
||||
MODE="${{ steps.resolve.outputs.mode }}"
|
||||
git checkout main
|
||||
if [ "$MODE" = "manual" ]; then
|
||||
# Hard reset allows both upgrade and rollback
|
||||
git reset --hard "$TARGET_SHA"
|
||||
else
|
||||
git merge "$TARGET_SHA" --no-edit
|
||||
fi
|
||||
|
||||
- name: Restore workflow file
|
||||
if: steps.check.outputs.needs_update == 'true'
|
||||
run: |
|
||||
# Always keep our own workflow file, never let upstream overwrite it
|
||||
git checkout HEAD@{1} -- .github/workflows/sync-upstream.yml 2>/dev/null || true
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "chore: restore sync-upstream workflow after sync"
|
||||
fi
|
||||
|
||||
- name: Push
|
||||
if: steps.check.outputs.needs_update == 'true'
|
||||
run: |
|
||||
if [ "${{ steps.resolve.outputs.mode }}" = "manual" ]; then
|
||||
git push origin main --force
|
||||
else
|
||||
git push origin main
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
if [ "${{ steps.check.outputs.needs_update }}" = "true" ]; then
|
||||
echo "### Synced successfully" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Mode:** ${{ steps.resolve.outputs.mode }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Tag:** ${{ steps.resolve.outputs.latest_tag || 'N/A (manual)' }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Commit:** \`${{ steps.resolve.outputs.target_sha }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "### Nothing to update" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
@@ -40,3 +40,5 @@ npm-debug.log*
|
||||
|
||||
tmp/
|
||||
.tmp/
|
||||
|
||||
nodewarden.wiki/
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
|
||||
[更新日志](./RELEASE_NOTES.md) | [提交问题](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [最新发布](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||
|
||||
[文档首页](./nodewarden.wiki/Home.md) | [快速开始](./nodewarden.wiki/快速开始.md)
|
||||
|
||||
[Telegram 频道](https://t.me/NodeWarden_News) | [Telegram 群组](https://t.me/NodeWarden_Official)
|
||||
|
||||
English: [`README_EN.md`](./README_EN.md)
|
||||
|
||||
> **免责声明**
|
||||
@@ -52,19 +56,26 @@ English: [`README_EN.md`](./README_EN.md)
|
||||
|
||||
## 网页部署
|
||||
|
||||
|
||||
1. Fork 本仓库。若本项目对你有帮助,欢迎点个 Star。
|
||||
2. 打开 [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) ➜ `Continue with GitHub` ➜ 选择你 Fork 后的仓库(`NodeWarden`)➜ 下一步 ➜ (默认使用 R2 存储;若未开通,可用 KV 来代替,将**部署命令**改为 `npm run deploy:kv`)➜ 部署 ➜ 打开生成的链接
|
||||
|
||||
| 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|
||||
|---|---|---|---|
|
||||
| R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
|
||||
| KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
||||
1. Fork `NodeWarden` 仓库到自己的 GitHub 账号
|
||||
2. 进入 [Cloudflare Workers 创建页面](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
||||
3. 选择 `Continue with GitHub`
|
||||
4. 选择你刚刚 Fork 的仓库
|
||||
5. 保持默认配置继续部署
|
||||
6. 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
|
||||
7. 等部署完成后,打开生成的 Workers 域名
|
||||
8. 根据页面提示设置`JWT_SECRET` ,不建议临时乱填。这个值直接关系到令牌签发安全,正式环境至少使用 32 个字符以上的随机字符串。
|
||||
|
||||
> [!TIP]
|
||||
> 同步方法(更新仓库):
|
||||
>- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork` ➜ `Update branch`
|
||||
>- 自动:进入你的 Fork 仓库 ➜ `Actions` ➜ `Sync upstream` ➜ `Enable workflow`,会在每天凌晨 3 点自动同步上游。
|
||||
> 默认R2与可选KV的区别:
|
||||
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|
||||
> |---|---|---|---|
|
||||
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
|
||||
> | KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
||||
|
||||
|
||||
## 更新方法:
|
||||
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork` ➜ `Update branch`
|
||||
- 自动:进入你的 Fork 仓库 ➜ `Actions` ➜ `Sync upstream` ➜ `Enable workflow`,会在每天凌晨 3 点自动同步上游。
|
||||
|
||||
|
||||
|
||||
|
||||
+25
-25
@@ -5,32 +5,32 @@
|
||||
<p align="center">
|
||||
A third-party Bitwarden-compatible server running on Cloudflare Workers.
|
||||
</p>
|
||||
|
||||
[](https://workers.cloudflare.com/)
|
||||
[](./LICENSE)
|
||||
[](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||
[](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
|
||||
|
||||
[Release Notes](./RELEASE_NOTES.md) | [Report an Issue](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [Latest Release](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||
[Telegram Channel](https://t.me/NodeWarden_News) | [Telegram Group](https://t.me/NodeWarden_Official)
|
||||
中文说明:[`README.md`](./README.md)
|
||||
|
||||
English: [`README.md`](./README.md)
|
||||
|
||||
> **Disclaimer**
|
||||
> This project is for learning and communication purposes only. Please back up your vault regularly.
|
||||
> **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 Official Bitwarden Server
|
||||
## Feature Comparison with the Official Bitwarden Server
|
||||
|
||||
| Capability | Bitwarden | NodeWarden | Notes |
|
||||
|---|---|---|---|
|
||||
| Web Vault | ✅ | ✅ | **Original Web Vault interface** |
|
||||
| Full sync `/api/sync` | ✅ | ✅ | Optimized for official clients |
|
||||
| Full sync `/api/sync` | ✅ | ✅ | Compatibility optimized for official clients |
|
||||
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
|
||||
| Send | ✅ | ✅ | Supports both text and file Sends |
|
||||
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
|
||||
| **Cloud Backup Center** | ❌ | ✅ | **Supports scheduled backups with WebDAV / E3** |
|
||||
| **Cloud Backup Center** | ❌ | ✅ | **Scheduled backup to WebDAV / E3** |
|
||||
| Password hint (web) | ⚠️ Limited | ✅ | **No email required** |
|
||||
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
|
||||
| Multi-user | ✅ | ✅ | Invite-based registration |
|
||||
@@ -46,19 +46,20 @@ English: [`README.md`](./README.md)
|
||||
- ✅ Mobile app
|
||||
- ✅ Browser extension
|
||||
- ✅ Linux desktop client
|
||||
- ⚠️ macOS desktop client not fully verified
|
||||
- ⚠️ macOS desktop client has not been fully verified yet
|
||||
|
||||
---
|
||||
|
||||
## Web Deploy
|
||||
|
||||
1. Fork this repository. If this project helps you, please consider giving it a Star.
|
||||
2. Open [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) -> `Continue with GitHub` -> select your forked repository (`NodeWarden`) -> `Next` -> deploy.
|
||||
R2 is used by default. If R2 is unavailable for your account, you can use KV instead by changing the **deploy command** to `npm run deploy:kv`.
|
||||
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, can be adjusted) | 10 GB |
|
||||
| R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
|
||||
| KV | No | 25 MiB (Cloudflare limit) | 1 GB |
|
||||
|
||||
> [!TIP]
|
||||
@@ -71,7 +72,6 @@ English: [`README.md`](./README.md)
|
||||
```powershell
|
||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||
cd NodeWarden
|
||||
|
||||
npm install
|
||||
npx wrangler login
|
||||
|
||||
@@ -92,13 +92,13 @@ npm run dev:kv
|
||||
|
||||
- Remote backup supports **WebDAV** and **E3**
|
||||
- When `Include attachments` is enabled:
|
||||
- the ZIP still contains only `db.json` and `manifest.json`
|
||||
- real attachment files are stored separately under `attachments/`
|
||||
- later backups reuse existing attachments by stable blob name instead of uploading everything again
|
||||
- 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/`
|
||||
- missing attachments are skipped safely
|
||||
- skipped attachments do not leave broken rows in the restored database
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
@@ -110,7 +110,7 @@ Current supported import sources include:
|
||||
- Bitwarden CSV
|
||||
- Bitwarden vault + attachments ZIP
|
||||
- NodeWarden JSON
|
||||
- Multiple browser / password-manager formats visible in the web import selector
|
||||
- Multiple browser / password-manager formats available in the web import selector
|
||||
|
||||
Current supported export formats include:
|
||||
|
||||
@@ -130,9 +130,9 @@ 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
|
||||
- [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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ CREATE TABLE IF NOT EXISTS ciphers (
|
||||
CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted_updated ON ciphers(user_id, deleted_at, updated_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -106,6 +107,7 @@ CREATE TABLE IF NOT EXISTS sends (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_sends_user_updated_id ON sends(user_id, updated_at, id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "nodewarden",
|
||||
"version": "1.4.1",
|
||||
"version": "1.4.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nodewarden",
|
||||
"version": "1.4.1",
|
||||
"version": "1.4.3",
|
||||
"license": "LGPL-3.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nodewarden",
|
||||
"version": "1.4.1",
|
||||
"version": "1.4.3",
|
||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||
"author": "shuaiplus",
|
||||
"license": "LGPL-3.0",
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = '1.4.1';
|
||||
export const APP_VERSION = '1.4.3';
|
||||
|
||||
@@ -130,6 +130,9 @@
|
||||
// Max total items (folders + ciphers) allowed in a single import.
|
||||
// 单次导入允许的最大条目数(文件夹 + 密码项合计)。
|
||||
importItemLimit: 5000,
|
||||
// Small fixed concurrency for blob/attachment batch cleanup work.
|
||||
// 附件 / blob 批量清理时的保守并发数。
|
||||
attachmentDeleteConcurrency: 4,
|
||||
},
|
||||
request: {
|
||||
// Hard body size limit for JSON API endpoints (bytes). File upload paths are exempt.
|
||||
|
||||
+174
-135
@@ -1,3 +1,4 @@
|
||||
import { DurableObject } from 'cloudflare:workers';
|
||||
import type { Env } from '../types';
|
||||
|
||||
const SIGNALR_RECORD_SEPARATOR = 0x1e;
|
||||
@@ -5,11 +6,12 @@ const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARAT
|
||||
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
||||
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
||||
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
|
||||
const SIGNALR_PING_INTERVAL_MS = 15_000;
|
||||
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
|
||||
|
||||
type HubProtocol = 'json' | 'messagepack';
|
||||
|
||||
interface ConnectionState {
|
||||
interface WsAttachment {
|
||||
userId: string;
|
||||
handshakeComplete: boolean;
|
||||
protocol: HubProtocol;
|
||||
deviceIdentifier: string | null;
|
||||
@@ -30,6 +32,12 @@ function encodeUtf8(value: string): Uint8Array {
|
||||
return new TextEncoder().encode(value);
|
||||
}
|
||||
|
||||
function decodeIncomingMessage(data: string | ArrayBuffer | ArrayBufferView): string {
|
||||
if (typeof data === 'string') return data;
|
||||
if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data));
|
||||
return new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
||||
}
|
||||
|
||||
function encodeMsgPackInteger(value: number): Uint8Array {
|
||||
const normalized = Math.trunc(value);
|
||||
if (normalized >= 0 && normalized <= 0x7f) {
|
||||
@@ -127,40 +135,31 @@ function frameSignalRBinary(payload: Uint8Array): Uint8Array {
|
||||
}
|
||||
|
||||
function buildSignalRJsonInvocation(
|
||||
userId: string,
|
||||
updateType: number,
|
||||
revisionDate: string,
|
||||
payload: Record<string, unknown>,
|
||||
contextId: string | null
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
type: 1,
|
||||
target: 'ReceiveMessage',
|
||||
arguments: [
|
||||
{
|
||||
ContextId: contextId,
|
||||
Type: updateType,
|
||||
Payload: {
|
||||
UserId: userId,
|
||||
Date: revisionDate,
|
||||
{
|
||||
ContextId: contextId,
|
||||
Type: updateType,
|
||||
Payload: payload,
|
||||
},
|
||||
},
|
||||
],
|
||||
}) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
|
||||
}
|
||||
|
||||
function buildSignalRJsonPing(): string {
|
||||
return JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
|
||||
],
|
||||
}) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
|
||||
}
|
||||
|
||||
function buildSignalRMessagePackInvocation(
|
||||
userId: string,
|
||||
updateType: number,
|
||||
revisionDate: string,
|
||||
messagePayload: Record<string, unknown>,
|
||||
contextId: string | null
|
||||
): Uint8Array {
|
||||
// SignalR MessagePack hub protocol uses an array-based invocation shape:
|
||||
// [type, headers, invocationId, target, arguments]
|
||||
const payload = encodeMsgPack([
|
||||
const encodedPayload = encodeMsgPack([
|
||||
1,
|
||||
{},
|
||||
null,
|
||||
@@ -169,34 +168,22 @@ function buildSignalRMessagePackInvocation(
|
||||
{
|
||||
ContextId: contextId,
|
||||
Type: updateType,
|
||||
Payload: {
|
||||
UserId: userId,
|
||||
Date: new Date(revisionDate),
|
||||
},
|
||||
Payload: messagePayload,
|
||||
},
|
||||
],
|
||||
]);
|
||||
return frameSignalRBinary(payload);
|
||||
return frameSignalRBinary(encodedPayload);
|
||||
}
|
||||
|
||||
function buildSignalRMessagePackPing(): Uint8Array {
|
||||
return frameSignalRBinary(encodeMsgPack([6]));
|
||||
}
|
||||
|
||||
function decodeIncomingMessage(data: string | ArrayBuffer | ArrayBufferView): string {
|
||||
if (typeof data === 'string') return data;
|
||||
if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data));
|
||||
return new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
||||
}
|
||||
|
||||
export class NotificationsHub {
|
||||
private readonly connections = new Map<WebSocket, ConnectionState>();
|
||||
private userId = '';
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(private readonly state: DurableObjectState, private readonly env: Env) {
|
||||
void this.state;
|
||||
void this.env;
|
||||
export class NotificationsHub extends DurableObject<Env> {
|
||||
constructor(ctx: DurableObjectState, env: Env) {
|
||||
super(ctx, env);
|
||||
this.ctx.setWebSocketAutoResponse(
|
||||
new WebSocketRequestResponsePair(
|
||||
JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR),
|
||||
JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
@@ -209,13 +196,20 @@ export class NotificationsHub {
|
||||
contextId?: string | null;
|
||||
updateType?: number;
|
||||
targetDeviceIdentifier?: string | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
} | null;
|
||||
const revisionDate = String(body?.revisionDate || '').trim() || new Date().toISOString();
|
||||
this.userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || this.userId).trim();
|
||||
const userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || '').trim();
|
||||
const contextId = String(body?.contextId || '').trim() || null;
|
||||
const updateType = Number(body?.updateType || SIGNALR_UPDATE_TYPE_SYNC_VAULT) || SIGNALR_UPDATE_TYPE_SYNC_VAULT;
|
||||
const targetDeviceIdentifier = String(body?.targetDeviceIdentifier || '').trim() || null;
|
||||
this.broadcastMessage(updateType, revisionDate, contextId, targetDeviceIdentifier);
|
||||
const payload = body?.payload && typeof body.payload === 'object'
|
||||
? body.payload
|
||||
: {
|
||||
UserId: userId,
|
||||
Date: revisionDate,
|
||||
};
|
||||
this.broadcastMessage(updateType, payload, contextId, targetDeviceIdentifier);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
@@ -238,46 +232,27 @@ export class NotificationsHub {
|
||||
|
||||
const requestUserId = String(url.searchParams.get('nw_uid') || '').trim();
|
||||
const requestDeviceIdentifier = String(url.searchParams.get('nw_did') || '').trim() || null;
|
||||
if (requestUserId) {
|
||||
this.userId = requestUserId;
|
||||
}
|
||||
|
||||
if (!this.userId) {
|
||||
if (!requestUserId) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const pair = new WebSocketPair();
|
||||
const client = pair[0];
|
||||
const server = pair[1];
|
||||
server.accept();
|
||||
|
||||
this.connections.set(server, {
|
||||
const tags: string[] = [];
|
||||
if (requestDeviceIdentifier) {
|
||||
tags.push(`device:${requestDeviceIdentifier}`);
|
||||
}
|
||||
this.ctx.acceptWebSocket(server, tags);
|
||||
|
||||
server.serializeAttachment({
|
||||
userId: requestUserId,
|
||||
handshakeComplete: false,
|
||||
protocol: 'messagepack',
|
||||
deviceIdentifier: requestDeviceIdentifier,
|
||||
});
|
||||
this.ensurePingLoop();
|
||||
|
||||
server.addEventListener('message', (event) => {
|
||||
void this.handleSocketMessage(server, event.data);
|
||||
});
|
||||
server.addEventListener('close', () => {
|
||||
const shouldBroadcast = !!this.connections.get(server)?.handshakeComplete;
|
||||
this.connections.delete(server);
|
||||
this.stopPingLoopIfIdle();
|
||||
if (shouldBroadcast) this.broadcastDeviceStatus();
|
||||
});
|
||||
server.addEventListener('error', () => {
|
||||
const shouldBroadcast = !!this.connections.get(server)?.handshakeComplete;
|
||||
this.connections.delete(server);
|
||||
this.stopPingLoopIfIdle();
|
||||
if (shouldBroadcast) this.broadcastDeviceStatus();
|
||||
try {
|
||||
server.close(1011, 'Socket error');
|
||||
} catch {
|
||||
// ignore close races
|
||||
}
|
||||
});
|
||||
} satisfies WsAttachment);
|
||||
|
||||
return new Response(null, {
|
||||
status: 101,
|
||||
@@ -285,21 +260,21 @@ export class NotificationsHub {
|
||||
});
|
||||
}
|
||||
|
||||
private async handleSocketMessage(socket: WebSocket, rawData: string | ArrayBuffer | ArrayBufferView): Promise<void> {
|
||||
const connection = this.connections.get(socket);
|
||||
if (!connection) return;
|
||||
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer | ArrayBufferView): Promise<void> {
|
||||
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||
if (!attachment) return;
|
||||
|
||||
if (!connection.handshakeComplete) {
|
||||
const text = decodeIncomingMessage(rawData);
|
||||
if (!attachment.handshakeComplete) {
|
||||
const text = decodeIncomingMessage(message);
|
||||
const frames = text.split(String.fromCharCode(SIGNALR_RECORD_SEPARATOR)).filter(Boolean);
|
||||
for (const frame of frames) {
|
||||
try {
|
||||
const handshake = JSON.parse(frame) as { protocol?: string };
|
||||
const protocol = handshake.protocol === 'json' ? 'json' : 'messagepack';
|
||||
connection.protocol = protocol;
|
||||
connection.handshakeComplete = true;
|
||||
socket.send(SIGNALR_HANDSHAKE_ACK);
|
||||
this.broadcastDeviceStatus();
|
||||
attachment.protocol = handshake.protocol === 'json' ? 'json' : 'messagepack';
|
||||
attachment.handshakeComplete = true;
|
||||
ws.serializeAttachment(attachment);
|
||||
ws.send(SIGNALR_HANDSHAKE_ACK);
|
||||
this.broadcastDeviceStatus(attachment.userId);
|
||||
return;
|
||||
} catch {
|
||||
// Ignore malformed pre-handshake payloads.
|
||||
@@ -307,89 +282,83 @@ export class NotificationsHub {
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private ensurePingLoop(): void {
|
||||
if (this.pingTimer !== null) return;
|
||||
this.pingTimer = setInterval(() => {
|
||||
this.broadcastPing();
|
||||
}, SIGNALR_PING_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private stopPingLoopIfIdle(): void {
|
||||
if (this.connections.size > 0 || this.pingTimer === null) return;
|
||||
clearInterval(this.pingTimer);
|
||||
this.pingTimer = null;
|
||||
}
|
||||
|
||||
private broadcastPing(): void {
|
||||
if (this.connections.size === 0) {
|
||||
this.stopPingLoopIfIdle();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [socket, connection] of this.connections) {
|
||||
if (!connection.handshakeComplete) continue;
|
||||
if (typeof message !== 'string') {
|
||||
try {
|
||||
if (connection.protocol === 'json') {
|
||||
socket.send(buildSignalRJsonPing());
|
||||
} else {
|
||||
socket.send(buildSignalRMessagePackPing());
|
||||
}
|
||||
ws.send(message);
|
||||
} catch {
|
||||
this.connections.delete(socket);
|
||||
try {
|
||||
socket.close(1011, 'Ping send failed');
|
||||
} catch {
|
||||
// ignore close races
|
||||
}
|
||||
// ignore send errors on echo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.stopPingLoopIfIdle();
|
||||
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
|
||||
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||
const shouldBroadcast = !!attachment?.handshakeComplete;
|
||||
if (shouldBroadcast && attachment?.userId) {
|
||||
this.broadcastDeviceStatus(attachment.userId);
|
||||
}
|
||||
}
|
||||
|
||||
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
|
||||
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||
const shouldBroadcast = !!attachment?.handshakeComplete;
|
||||
if (shouldBroadcast && attachment?.userId) {
|
||||
this.broadcastDeviceStatus(attachment.userId);
|
||||
}
|
||||
}
|
||||
|
||||
private getOnlineDeviceIdentifiers(): string[] {
|
||||
const out = new Set<string>();
|
||||
for (const connection of this.connections.values()) {
|
||||
if (!connection.handshakeComplete || !connection.deviceIdentifier) continue;
|
||||
out.add(connection.deviceIdentifier);
|
||||
for (const ws of this.ctx.getWebSockets()) {
|
||||
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||
if (!attachment?.handshakeComplete || !attachment.deviceIdentifier) continue;
|
||||
out.add(attachment.deviceIdentifier);
|
||||
}
|
||||
return Array.from(out);
|
||||
}
|
||||
|
||||
private broadcastMessage(
|
||||
updateType: number,
|
||||
revisionDate: string,
|
||||
payload: Record<string, unknown>,
|
||||
contextId: string | null,
|
||||
targetDeviceIdentifier: string | null
|
||||
): void {
|
||||
if (!this.userId || this.connections.size === 0) return;
|
||||
const sockets = targetDeviceIdentifier
|
||||
? this.ctx.getWebSockets(`device:${targetDeviceIdentifier}`)
|
||||
: this.ctx.getWebSockets();
|
||||
|
||||
for (const [socket, connection] of this.connections) {
|
||||
if (!connection.handshakeComplete) continue;
|
||||
if (targetDeviceIdentifier && connection.deviceIdentifier !== targetDeviceIdentifier) continue;
|
||||
if (sockets.length === 0) return;
|
||||
|
||||
for (const ws of sockets) {
|
||||
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||
if (!attachment?.handshakeComplete) continue;
|
||||
try {
|
||||
if (connection.protocol === 'json') {
|
||||
socket.send(buildSignalRJsonInvocation(this.userId, updateType, revisionDate, contextId));
|
||||
if (attachment.protocol === 'json') {
|
||||
ws.send(buildSignalRJsonInvocation(updateType, payload, contextId));
|
||||
} else {
|
||||
socket.send(buildSignalRMessagePackInvocation(this.userId, updateType, revisionDate, contextId));
|
||||
ws.send(buildSignalRMessagePackInvocation(updateType, payload, contextId));
|
||||
}
|
||||
} catch {
|
||||
this.connections.delete(socket);
|
||||
try {
|
||||
socket.close(1011, 'Notification send failed');
|
||||
ws.close(1011, 'Notification send failed');
|
||||
} catch {
|
||||
// ignore close races
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.stopPingLoopIfIdle();
|
||||
}
|
||||
|
||||
private broadcastDeviceStatus(): void {
|
||||
this.broadcastMessage(SIGNALR_UPDATE_TYPE_DEVICE_STATUS, new Date().toISOString(), null, null);
|
||||
private broadcastDeviceStatus(userId: string): void {
|
||||
this.broadcastMessage(
|
||||
SIGNALR_UPDATE_TYPE_DEVICE_STATUS,
|
||||
{
|
||||
UserId: userId,
|
||||
Date: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,9 +414,79 @@ async function notifyUserUpdate(
|
||||
contextId: contextId || null,
|
||||
updateType,
|
||||
targetDeviceIdentifier: targetDeviceIdentifier || null,
|
||||
payload: {
|
||||
UserId: userId,
|
||||
Date: revisionDate,
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to broadcast realtime notification:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyUserBackupProgress(
|
||||
env: Env,
|
||||
userId: string,
|
||||
progress: {
|
||||
operation: 'backup-restore' | 'backup-export' | 'backup-remote-run';
|
||||
source?: 'local' | 'remote';
|
||||
step: string;
|
||||
fileName: string;
|
||||
stageTitle?: string;
|
||||
stageDetail?: string;
|
||||
replaceExisting?: boolean;
|
||||
done?: boolean;
|
||||
ok?: boolean;
|
||||
error?: string | null;
|
||||
timestamp?: string;
|
||||
},
|
||||
targetDeviceIdentifier?: string | null
|
||||
): Promise<void> {
|
||||
const revisionDate = progress.timestamp || new Date().toISOString();
|
||||
try {
|
||||
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
|
||||
const stub = env.NOTIFICATIONS_HUB.get(id);
|
||||
await stub.fetch('https://notifications/internal/notify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-NodeWarden-UserId': userId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
revisionDate,
|
||||
contextId: null,
|
||||
updateType: SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS,
|
||||
targetDeviceIdentifier: targetDeviceIdentifier || null,
|
||||
payload: {
|
||||
UserId: userId,
|
||||
Date: revisionDate,
|
||||
...progress,
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to broadcast backup progress:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyUserBackupRestoreProgress(
|
||||
env: Env,
|
||||
userId: string,
|
||||
progress: {
|
||||
operation: 'backup-restore';
|
||||
source: 'local' | 'remote';
|
||||
step: string;
|
||||
fileName: string;
|
||||
stageTitle?: string;
|
||||
stageDetail?: string;
|
||||
replaceExisting?: boolean;
|
||||
done?: boolean;
|
||||
ok?: boolean;
|
||||
error?: string | null;
|
||||
timestamp?: string;
|
||||
},
|
||||
targetDeviceIdentifier?: string | null
|
||||
): Promise<void> {
|
||||
return notifyUserBackupProgress(env, userId, progress, targetDeviceIdentifier);
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ async function verifyUserSecret(
|
||||
|
||||
function toProfile(user: User, env: Env): ProfileResponse {
|
||||
void env;
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
@@ -100,7 +101,7 @@ function toProfile(user: User, env: Env): ProfileResponse {
|
||||
twoFactorEnabled: !!user.totpSecret,
|
||||
key: user.key,
|
||||
privateKey: user.privateKey,
|
||||
accountKeys: buildAccountKeys(user),
|
||||
accountKeys,
|
||||
securityStamp: user.securityStamp || user.id,
|
||||
organizations: [],
|
||||
providers: [],
|
||||
|
||||
+17
-10
@@ -10,7 +10,7 @@ import {
|
||||
verifyAttachmentUploadToken,
|
||||
verifyFileDownloadToken,
|
||||
} from '../utils/jwt';
|
||||
import { cipherToResponse, shouldOmitPasskeysForResponse } from './ciphers';
|
||||
import { cipherToResponse } from './ciphers';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { readActingDeviceIdentifier } from '../utils/device';
|
||||
import {
|
||||
@@ -38,6 +38,18 @@ function formatSize(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
async function runWithConcurrency<T>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
worker: (item: T) => Promise<void>
|
||||
): Promise<void> {
|
||||
if (items.length === 0) return;
|
||||
const limit = Math.max(1, concurrency);
|
||||
for (let index = 0; index < items.length; index += limit) {
|
||||
await Promise.all(items.slice(index, index + limit).map(worker));
|
||||
}
|
||||
}
|
||||
|
||||
async function processAttachmentUpload(
|
||||
request: Request,
|
||||
env: Env,
|
||||
@@ -158,9 +170,7 @@ export async function handleCreateAttachment(
|
||||
attachmentId: attachmentId,
|
||||
url: buildDirectUploadUrl(request, `/api/ciphers/${cipherId}/attachment/${attachmentId}`, uploadToken),
|
||||
fileUploadType: 1,
|
||||
cipherResponse: cipherToResponse(updatedCipher!, attachments, {
|
||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||
}),
|
||||
cipherResponse: cipherToResponse(updatedCipher!, attachments),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -372,9 +382,7 @@ export async function handleDeleteAttachment(
|
||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||
|
||||
return jsonResponse({
|
||||
cipher: cipherToResponse(updatedCipher!, attachments, {
|
||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||
}),
|
||||
cipher: cipherToResponse(updatedCipher!, attachments),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -385,10 +393,9 @@ export async function deleteAllAttachmentsForCipher(
|
||||
): Promise<void> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||
|
||||
for (const attachment of attachments) {
|
||||
await runWithConcurrency(attachments, LIMITS.performance.attachmentDeleteConcurrency, async (attachment) => {
|
||||
const path = getAttachmentObjectKey(cipherId, attachment.id);
|
||||
await deleteBlobObject(env, path);
|
||||
await storage.deleteAttachment(attachment.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+346
-18
@@ -1,7 +1,12 @@
|
||||
import type { Env, User } from '../types';
|
||||
import { errorResponse, jsonResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { type BackupArchiveBundle, buildBackupArchive } from '../services/backup-archive';
|
||||
import {
|
||||
type BackupArchiveBundle,
|
||||
buildBackupArchive,
|
||||
inspectBackupArchiveFileNameChecksum,
|
||||
verifyBackupArchiveFileNameChecksum,
|
||||
} from '../services/backup-archive';
|
||||
import {
|
||||
type BackupDestinationRecord,
|
||||
type BackupSettingsInput,
|
||||
@@ -17,19 +22,25 @@ import {
|
||||
requireBackupDestination,
|
||||
saveBackupSettings,
|
||||
} from '../services/backup-config';
|
||||
import { type BackupImportExecutionResult, importBackupArchiveBytes, importRemoteBackupArchiveBytes } from '../services/backup-import';
|
||||
import {
|
||||
type BackupImportExecutionResult,
|
||||
type BackupRestoreProgressReporter,
|
||||
importBackupArchiveBytes,
|
||||
importRemoteBackupArchiveBytes,
|
||||
} from '../services/backup-import';
|
||||
import {
|
||||
type RemoteBackupTransferSession,
|
||||
createRemoteBackupTransferSession,
|
||||
deleteRemoteBackupFile,
|
||||
downloadRemoteBackupFile,
|
||||
ensureRemoteRestoreCandidate,
|
||||
listRemoteBackupEntries,
|
||||
pruneRemoteBackupArchives,
|
||||
remoteBackupFileExists,
|
||||
uploadRemoteBackupFile,
|
||||
uploadBackupArchive,
|
||||
} from '../services/backup-uploader';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { getBlobObject } from '../services/blob-store';
|
||||
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
|
||||
|
||||
function isAdmin(user: User): boolean {
|
||||
return user.role === 'admin' && user.status === 'active';
|
||||
@@ -81,13 +92,86 @@ function ensureBackupBlobName(value: string): string {
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
const REMOTE_ATTACHMENT_INDEX_PATH = 'attachments/.nodewarden-attachment-index.v1.json';
|
||||
|
||||
interface RemoteAttachmentIndexPayload {
|
||||
version: 1;
|
||||
blobs: Record<string, { sizeBytes: number; updatedAt: string }>;
|
||||
}
|
||||
|
||||
async function loadRemoteAttachmentIndex(session: RemoteBackupTransferSession): Promise<Map<string, number>> {
|
||||
try {
|
||||
const file = await session.download(REMOTE_ATTACHMENT_INDEX_PATH);
|
||||
const payload = JSON.parse(new TextDecoder().decode(file.bytes)) as RemoteAttachmentIndexPayload;
|
||||
if (payload?.version !== 1 || !payload.blobs || typeof payload.blobs !== 'object') {
|
||||
return new Map<string, number>();
|
||||
}
|
||||
return new Map(
|
||||
Object.entries(payload.blobs)
|
||||
.filter(([key, value]) => !!String(key || '').trim() && Number.isFinite(Number(value?.sizeBytes || 0)))
|
||||
.map(([key, value]) => [key, Number(value.sizeBytes || 0)])
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const normalized = message.toLowerCase();
|
||||
// Some WebDAV providers return non-standard codes such as 530 when the
|
||||
// attachment index does not exist yet. Treat these "missing file" style
|
||||
// responses as an empty index so first-time incremental backups can proceed.
|
||||
if (
|
||||
normalized.includes('404')
|
||||
|| normalized.includes('403')
|
||||
|| normalized.includes('530')
|
||||
|| normalized.includes('not found')
|
||||
|| normalized.includes('file not found')
|
||||
|| normalized.includes('does not exist')
|
||||
|| normalized.includes('please select a backup file')
|
||||
) {
|
||||
return new Map<string, number>();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRemoteAttachmentIndex(
|
||||
session: RemoteBackupTransferSession,
|
||||
index: Map<string, number>
|
||||
): Promise<void> {
|
||||
const payload: RemoteAttachmentIndexPayload = {
|
||||
version: 1,
|
||||
blobs: Object.fromEntries(
|
||||
Array.from(index.entries()).map(([blobName, sizeBytes]) => [
|
||||
blobName,
|
||||
{
|
||||
sizeBytes,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
),
|
||||
};
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(payload));
|
||||
await session.putFile(REMOTE_ATTACHMENT_INDEX_PATH, bytes, {
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
});
|
||||
}
|
||||
|
||||
async function executeConfiguredBackup(
|
||||
env: Env,
|
||||
storage: StorageService,
|
||||
actorUserId: string | null,
|
||||
trigger: 'manual' | 'scheduled',
|
||||
destinationId?: string | null
|
||||
destinationId?: string | null,
|
||||
progress?: ((event: {
|
||||
operation: 'backup-remote-run';
|
||||
step: string;
|
||||
fileName: string;
|
||||
stageTitle: string;
|
||||
stageDetail: string;
|
||||
done?: boolean;
|
||||
ok?: boolean;
|
||||
error?: string | null;
|
||||
}) => Promise<void>) | null
|
||||
): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> {
|
||||
const maxArchiveUploadAttempts = 3;
|
||||
const currentSettings = await loadBackupSettings(storage, env, 'UTC');
|
||||
const destination = requireBackupDestination(currentSettings, destinationId);
|
||||
|
||||
@@ -99,25 +183,110 @@ async function executeConfiguredBackup(
|
||||
await saveBackupSettings(storage, env, currentSettings);
|
||||
|
||||
try {
|
||||
await progress?.({
|
||||
operation: 'backup-remote-run',
|
||||
step: 'remote_run_prepare',
|
||||
fileName: '',
|
||||
stageTitle: 'txt_backup_remote_run_progress_prepare_title',
|
||||
stageDetail: 'txt_backup_remote_run_progress_prepare_detail',
|
||||
});
|
||||
const archive = await buildBackupArchive(env, now, {
|
||||
includeAttachments: destination.includeAttachments,
|
||||
timeZone: destination.schedule.timezone,
|
||||
progress: progress
|
||||
? async (event) => {
|
||||
if (event.step === 'archive_ready') {
|
||||
return;
|
||||
}
|
||||
await progress({
|
||||
operation: 'backup-remote-run',
|
||||
step: `remote_run_${event.step}`,
|
||||
fileName: event.fileName || '',
|
||||
stageTitle: event.stageTitle,
|
||||
stageDetail: event.stageDetail,
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
await progress?.({
|
||||
operation: 'backup-remote-run',
|
||||
step: 'remote_run_sync_attachments',
|
||||
fileName: archive.fileName,
|
||||
stageTitle: 'txt_backup_remote_run_progress_sync_attachments_title',
|
||||
stageDetail: destination.includeAttachments
|
||||
? 'txt_backup_remote_run_progress_sync_attachments_detail'
|
||||
: 'txt_backup_remote_run_progress_sync_attachments_skipped_detail',
|
||||
});
|
||||
const remoteSession = createRemoteBackupTransferSession(destination);
|
||||
const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession);
|
||||
let attachmentIndexChanged = false;
|
||||
for (const attachment of archive.manifest.attachmentBlobs || []) {
|
||||
if (remoteAttachmentIndex.get(attachment.blobName) === attachment.sizeBytes) {
|
||||
continue;
|
||||
}
|
||||
const remotePath = `attachments/${attachment.blobName}`;
|
||||
if (await remoteBackupFileExists(destination, remotePath)) continue;
|
||||
const object = await getBlobObject(env, attachment.blobName);
|
||||
if (!object) {
|
||||
throw new Error(`Attachment blob missing for ${attachment.blobName}`);
|
||||
}
|
||||
const bytes = new Uint8Array(await new Response(object.body).arrayBuffer());
|
||||
await uploadRemoteBackupFile(destination, remotePath, bytes, {
|
||||
await remoteSession.putFile(remotePath, bytes, {
|
||||
contentType: object.contentType,
|
||||
});
|
||||
remoteAttachmentIndex.set(attachment.blobName, attachment.sizeBytes);
|
||||
attachmentIndexChanged = true;
|
||||
}
|
||||
if (attachmentIndexChanged) {
|
||||
await saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex);
|
||||
}
|
||||
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
|
||||
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
|
||||
await progress?.({
|
||||
operation: 'backup-remote-run',
|
||||
step: 'remote_run_upload_archive',
|
||||
fileName: archive.fileName,
|
||||
stageTitle: 'txt_backup_remote_run_progress_upload_title',
|
||||
stageDetail: 'txt_backup_remote_run_progress_upload_detail',
|
||||
});
|
||||
upload = await remoteSession.uploadArchive(archive.bytes, archive.fileName);
|
||||
try {
|
||||
await progress?.({
|
||||
operation: 'backup-remote-run',
|
||||
step: 'remote_run_verify_archive',
|
||||
fileName: archive.fileName,
|
||||
stageTitle: 'txt_backup_remote_run_progress_verify_title',
|
||||
stageDetail: 'txt_backup_remote_run_progress_verify_detail',
|
||||
});
|
||||
const remoteFile = await remoteSession.download(archive.fileName);
|
||||
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, archive.fileName);
|
||||
if (!checksumOk) {
|
||||
throw new Error('Remote backup ZIP checksum verification failed');
|
||||
}
|
||||
if (remoteFile.bytes.byteLength !== archive.bytes.byteLength) {
|
||||
throw new Error('Remote backup ZIP size verification failed');
|
||||
}
|
||||
break;
|
||||
} catch (error) {
|
||||
await remoteSession.deleteFile(archive.fileName).catch(() => undefined);
|
||||
if (attempt === maxArchiveUploadAttempts) {
|
||||
const message = error instanceof Error ? error.message : 'Remote backup ZIP verification failed';
|
||||
throw new Error(`Backup archive upload verification failed after ${maxArchiveUploadAttempts} attempts: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!upload) {
|
||||
throw new Error('Backup archive upload failed');
|
||||
}
|
||||
const upload = await uploadBackupArchive(destination, archive.bytes, archive.fileName);
|
||||
let prunedFileCount = 0;
|
||||
let pruneErrorMessage: string | null = null;
|
||||
try {
|
||||
await progress?.({
|
||||
operation: 'backup-remote-run',
|
||||
step: 'remote_run_cleanup',
|
||||
fileName: archive.fileName,
|
||||
stageTitle: 'txt_backup_remote_run_progress_cleanup_title',
|
||||
stageDetail: 'txt_backup_remote_run_progress_cleanup_detail',
|
||||
});
|
||||
prunedFileCount = await pruneRemoteBackupArchives(destination, destination.schedule.retentionCount, archive.fileName);
|
||||
} catch (error) {
|
||||
pruneErrorMessage = error instanceof Error ? error.message : 'Old backup cleanup failed';
|
||||
@@ -137,10 +306,21 @@ async function executeConfiguredBackup(
|
||||
remotePath: upload.remotePath,
|
||||
fileName: archive.fileName,
|
||||
fileBytes: archive.bytes.byteLength,
|
||||
uploadVerificationAttempts: maxArchiveUploadAttempts,
|
||||
prunedFileCount,
|
||||
pruneError: pruneErrorMessage,
|
||||
});
|
||||
|
||||
await progress?.({
|
||||
operation: 'backup-remote-run',
|
||||
step: 'remote_run_complete',
|
||||
fileName: archive.fileName,
|
||||
stageTitle: 'txt_backup_remote_run_progress_complete_title',
|
||||
stageDetail: 'txt_backup_remote_run_progress_complete_detail',
|
||||
done: true,
|
||||
ok: true,
|
||||
});
|
||||
|
||||
return {
|
||||
fileName: archive.fileName,
|
||||
fileSize: archive.bytes.byteLength,
|
||||
@@ -156,6 +336,16 @@ async function executeConfiguredBackup(
|
||||
...getBackupDestinationSummary(destination),
|
||||
error: destination.runtime.lastErrorMessage,
|
||||
});
|
||||
await progress?.({
|
||||
operation: 'backup-remote-run',
|
||||
step: 'remote_run_failed',
|
||||
fileName: '',
|
||||
stageTitle: 'txt_backup_remote_run_progress_failed_title',
|
||||
stageDetail: 'txt_backup_remote_run_progress_failed_detail',
|
||||
done: true,
|
||||
ok: false,
|
||||
error: destination.runtime.lastErrorMessage,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -170,13 +360,35 @@ function toImportStatusCode(message: string): number {
|
||||
|
||||
async function runImportAndAudit(
|
||||
env: Env,
|
||||
request: Request,
|
||||
actorUser: User,
|
||||
archiveBytes: Uint8Array,
|
||||
fileName: string,
|
||||
replaceExisting: boolean,
|
||||
metadata: Record<string, unknown>
|
||||
): Promise<BackupImportExecutionResult> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const imported = await importBackupArchiveBytes(archiveBytes, env, actorUser.id, replaceExisting);
|
||||
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
||||
const progress: BackupRestoreProgressReporter = async (event) => {
|
||||
await notifyUserBackupRestoreProgress(
|
||||
env,
|
||||
actorUser.id,
|
||||
{
|
||||
operation: 'backup-restore',
|
||||
...event,
|
||||
},
|
||||
targetDeviceIdentifier
|
||||
);
|
||||
};
|
||||
await progress({
|
||||
source: 'local',
|
||||
step: 'local_upload_received',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_local_upload_title',
|
||||
stageDetail: 'txt_backup_restore_progress_local_upload_detail',
|
||||
replaceExisting,
|
||||
});
|
||||
const imported = await importBackupArchiveBytes(archiveBytes, env, actorUser.id, replaceExisting, progress, fileName);
|
||||
await writeAuditLog(storage, imported.auditActorUserId, 'admin.backup.import', 'backup', null, {
|
||||
users: imported.result.imported.users,
|
||||
ciphers: imported.result.imported.ciphers,
|
||||
@@ -309,7 +521,20 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env,
|
||||
return errorResponse('Backup run payload is invalid', 400);
|
||||
}
|
||||
|
||||
const result = await executeConfiguredBackup(env, storage, actorUser.id, 'manual', body?.destinationId || null);
|
||||
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
||||
const progress = async (event: {
|
||||
operation: 'backup-remote-run';
|
||||
step: string;
|
||||
fileName: string;
|
||||
stageTitle: string;
|
||||
stageDetail: string;
|
||||
done?: boolean;
|
||||
ok?: boolean;
|
||||
error?: string | null;
|
||||
}) => {
|
||||
await notifyUserBackupProgress(env, actorUser.id, event, targetDeviceIdentifier);
|
||||
};
|
||||
const result = await executeConfiguredBackup(env, storage, actorUser.id, 'manual', body?.destinationId || null, progress);
|
||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||
return jsonResponse({
|
||||
object: 'backup-run',
|
||||
@@ -369,6 +594,29 @@ export async function handleDownloadAdminRemoteBackup(request: Request, env: Env
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleInspectAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
try {
|
||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||
const url = new URL(request.url);
|
||||
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
|
||||
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
|
||||
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
||||
const integrity = await inspectBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
|
||||
return jsonResponse({
|
||||
object: 'backup-remote-integrity',
|
||||
destinationId: destination.id,
|
||||
path,
|
||||
fileName: remoteFile.fileName || path.split('/').pop() || path,
|
||||
integrity,
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error instanceof Error ? error.message : 'Remote backup integrity inspection failed', 409);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleDeleteAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||
|
||||
@@ -392,7 +640,7 @@ export async function handleDeleteAdminRemoteBackup(request: Request, env: Env,
|
||||
export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||
|
||||
let body: { destinationId?: string; path?: string; replaceExisting?: boolean };
|
||||
let body: { destinationId?: string; path?: string; replaceExisting?: boolean; allowChecksumMismatch?: boolean };
|
||||
try {
|
||||
body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>();
|
||||
} catch {
|
||||
@@ -404,7 +652,39 @@ export async function handleRestoreAdminRemoteBackup(request: Request, env: Env,
|
||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||
const destination = requireBackupDestination(settings, body.destinationId || null);
|
||||
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
||||
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
||||
const restoreFileNameFromPath = path.split('/').pop() || path;
|
||||
await notifyUserBackupRestoreProgress(
|
||||
env,
|
||||
actorUser.id,
|
||||
{
|
||||
operation: 'backup-restore',
|
||||
source: 'remote',
|
||||
step: 'remote_fetch_archive',
|
||||
fileName: restoreFileNameFromPath,
|
||||
stageTitle: 'txt_backup_restore_progress_remote_fetch_title',
|
||||
stageDetail: 'txt_backup_restore_progress_remote_fetch_detail',
|
||||
replaceExisting: !!body.replaceExisting,
|
||||
},
|
||||
targetDeviceIdentifier
|
||||
);
|
||||
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
||||
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
|
||||
if (!checksumOk && !body.allowChecksumMismatch) {
|
||||
return errorResponse('Remote backup file checksum does not match its filename', 400);
|
||||
}
|
||||
const restoreFileName = remoteFile.fileName || path.split('/').pop() || path;
|
||||
const progress: BackupRestoreProgressReporter = async (event) => {
|
||||
await notifyUserBackupRestoreProgress(
|
||||
env,
|
||||
actorUser.id,
|
||||
{
|
||||
operation: 'backup-restore',
|
||||
...event,
|
||||
},
|
||||
targetDeviceIdentifier
|
||||
);
|
||||
};
|
||||
const imported = await (async () => {
|
||||
const storage = new StorageService(env.DB);
|
||||
const result = await importRemoteBackupArchiveBytes(
|
||||
@@ -413,12 +693,13 @@ export async function handleRestoreAdminRemoteBackup(request: Request, env: Env,
|
||||
actorUser.id,
|
||||
!!body.replaceExisting,
|
||||
{
|
||||
hasAttachment: async (blobName) => remoteBackupFileExists(destination, `attachments/${blobName}`),
|
||||
loadAttachment: async (blobName) => {
|
||||
const file = await downloadRemoteBackupFile(destination, `attachments/${blobName}`).catch(() => null);
|
||||
return file?.bytes || null;
|
||||
},
|
||||
}
|
||||
loadAttachment: async (blobName) => {
|
||||
const file = await downloadRemoteBackupFile(destination, `attachments/${blobName}`).catch(() => null);
|
||||
return file?.bytes || null;
|
||||
},
|
||||
},
|
||||
progress,
|
||||
restoreFileName
|
||||
);
|
||||
await writeAuditLog(storage, result.auditActorUserId, 'admin.backup.import', 'backup', null, {
|
||||
users: result.result.imported.users,
|
||||
@@ -431,6 +712,7 @@ export async function handleRestoreAdminRemoteBackup(request: Request, env: Env,
|
||||
remotePath: path,
|
||||
bytes: remoteFile.bytes.byteLength,
|
||||
trigger: 'remote',
|
||||
checksumMismatchAccepted: !checksumOk,
|
||||
});
|
||||
return result;
|
||||
})();
|
||||
@@ -445,6 +727,7 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
|
||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
||||
let body: { includeAttachments?: boolean } | null = null;
|
||||
try {
|
||||
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
|
||||
@@ -455,11 +738,49 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
|
||||
}
|
||||
let archive: BackupArchiveBundle;
|
||||
try {
|
||||
const progress = async (event: {
|
||||
step: string;
|
||||
fileName?: string;
|
||||
stageTitle: string;
|
||||
stageDetail: string;
|
||||
includeAttachments: boolean;
|
||||
}) => {
|
||||
await notifyUserBackupProgress(
|
||||
env,
|
||||
actorUser.id,
|
||||
{
|
||||
operation: 'backup-export',
|
||||
source: 'local',
|
||||
step: `export_${event.step}`,
|
||||
fileName: event.fileName || '',
|
||||
stageTitle: event.stageTitle,
|
||||
stageDetail: event.stageDetail,
|
||||
},
|
||||
targetDeviceIdentifier
|
||||
);
|
||||
};
|
||||
archive = await buildBackupArchive(env, new Date(), {
|
||||
includeAttachments: !!body?.includeAttachments,
|
||||
progress,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Backup export failed';
|
||||
await notifyUserBackupProgress(
|
||||
env,
|
||||
actorUser.id,
|
||||
{
|
||||
operation: 'backup-export',
|
||||
source: 'local',
|
||||
step: 'export_failed',
|
||||
fileName: '',
|
||||
stageTitle: 'txt_backup_export_progress_failed_title',
|
||||
stageDetail: 'txt_backup_export_progress_failed_detail',
|
||||
done: true,
|
||||
ok: false,
|
||||
error: message,
|
||||
},
|
||||
targetDeviceIdentifier
|
||||
);
|
||||
return errorResponse(message, message.includes('blob missing') ? 409 : 500);
|
||||
}
|
||||
|
||||
@@ -520,6 +841,7 @@ export async function handleAdminImportBackup(request: Request, env: Env, actorU
|
||||
}
|
||||
|
||||
const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1';
|
||||
const allowChecksumMismatch = String(formData.get('allowChecksumMismatch') || '').trim() === '1';
|
||||
let archiveBytes: Uint8Array;
|
||||
try {
|
||||
archiveBytes = new Uint8Array(await (file as { arrayBuffer(): Promise<ArrayBuffer> }).arrayBuffer());
|
||||
@@ -528,9 +850,15 @@ export async function handleAdminImportBackup(request: Request, env: Env, actorU
|
||||
}
|
||||
|
||||
try {
|
||||
const imported = await runImportAndAudit(env, actorUser, archiveBytes, replaceExisting, {
|
||||
const fileName = 'name' in file ? String((file as File).name || '') : '';
|
||||
const checksumOk = await verifyBackupArchiveFileNameChecksum(archiveBytes, fileName);
|
||||
if (!checksumOk && !allowChecksumMismatch) {
|
||||
return errorResponse('Backup file checksum does not match its filename', 400);
|
||||
}
|
||||
const imported = await runImportAndAudit(env, request, actorUser, archiveBytes, fileName || 'nodewarden_backup.zip', replaceExisting, {
|
||||
trigger: 'local',
|
||||
bytes: archiveBytes.byteLength,
|
||||
checksumMismatchAccepted: !checksumOk,
|
||||
});
|
||||
return jsonResponse(imported.result);
|
||||
} catch (error) {
|
||||
|
||||
+60
-106
@@ -7,6 +7,32 @@ import { deleteAllAttachmentsForCipher } from './attachments';
|
||||
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
||||
import { readActingDeviceIdentifier } from '../utils/device';
|
||||
|
||||
function normalizeOptionalId(value: unknown): string | null {
|
||||
if (value == null) return null;
|
||||
const normalized = String(value).trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function mergeCipherNestedObject<T>(
|
||||
existingValue: T | null | undefined,
|
||||
incomingValue: unknown
|
||||
): T | null {
|
||||
if (incomingValue === undefined) {
|
||||
return (existingValue ?? null) as T | null;
|
||||
}
|
||||
if (incomingValue === null || typeof incomingValue !== 'object' || Array.isArray(incomingValue)) {
|
||||
return incomingValue as T | null;
|
||||
}
|
||||
const existingObject =
|
||||
existingValue && typeof existingValue === 'object' && !Array.isArray(existingValue)
|
||||
? (existingValue as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
...existingObject,
|
||||
...(incomingValue as Record<string, unknown>),
|
||||
} as T;
|
||||
}
|
||||
|
||||
async function notifyVaultSyncForRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
@@ -47,6 +73,7 @@ function syncCipherComputedAliases(cipher: Cipher): Cipher {
|
||||
function normalizeCipherForStorage(cipher: Cipher): Cipher {
|
||||
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
|
||||
cipher.folderId = normalizeOptionalId(cipher.folderId);
|
||||
const hasArchivedAt = Object.prototype.hasOwnProperty.call(cipher as object, 'archivedAt');
|
||||
cipher.archivedAt = hasArchivedAt
|
||||
? normalizeCipherTimestamp(cipher.archivedAt) ?? null
|
||||
@@ -54,80 +81,18 @@ function normalizeCipherForStorage(cipher: Cipher): Cipher {
|
||||
return syncCipherComputedAliases(cipher);
|
||||
}
|
||||
|
||||
function looksLikeCipherString(value: unknown): boolean {
|
||||
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
|
||||
}
|
||||
|
||||
export function shouldOmitPasskeysForResponse(request: Request | null | undefined): boolean {
|
||||
const userAgent = String(request?.headers.get('user-agent') || '').toLowerCase();
|
||||
if (!userAgent) return false;
|
||||
|
||||
// Temporary compatibility fallback:
|
||||
// mobile clients expect official EncString payloads for most FIDO2 fields.
|
||||
// Keep passkeys available everywhere, but suppress only legacy malformed data
|
||||
// for mobile clients so newly-saved credentials can flow through unchanged.
|
||||
return (
|
||||
userAgent.includes('android') ||
|
||||
userAgent.includes('iphone') ||
|
||||
userAgent.includes('ipad') ||
|
||||
userAgent.includes('ios')
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeCipherLoginForStorage(login: any): any {
|
||||
if (!login || typeof login !== 'object') return login ?? null;
|
||||
|
||||
return {
|
||||
...login,
|
||||
fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCipherLoginForCompatibility(
|
||||
login: any,
|
||||
options?: { omitFido2Credentials?: boolean }
|
||||
): any {
|
||||
export function normalizeCipherLoginForCompatibility(login: any): any {
|
||||
const normalized = normalizeCipherLoginForStorage(login);
|
||||
if (!normalized || typeof normalized !== 'object') return normalized ?? null;
|
||||
if (!options?.omitFido2Credentials) return normalized;
|
||||
|
||||
const credentials = Array.isArray(normalized.fido2Credentials) ? normalized.fido2Credentials : null;
|
||||
if (!credentials?.length) return normalized;
|
||||
|
||||
const hasMalformedCredential = credentials.some((credential: any) => {
|
||||
if (!credential || typeof credential !== 'object') return true;
|
||||
const requiredEncryptedFields = [
|
||||
credential.credentialId,
|
||||
credential.keyType,
|
||||
credential.keyAlgorithm,
|
||||
credential.keyCurve,
|
||||
credential.keyValue,
|
||||
credential.rpId,
|
||||
credential.counter,
|
||||
credential.discoverable,
|
||||
];
|
||||
const optionalEncryptedFields = [
|
||||
credential.userHandle,
|
||||
credential.userName,
|
||||
credential.rpName,
|
||||
credential.userDisplayName,
|
||||
];
|
||||
|
||||
if (requiredEncryptedFields.some((value) => !looksLikeCipherString(value))) {
|
||||
return true;
|
||||
}
|
||||
if (optionalEncryptedFields.some((value) => value != null && !looksLikeCipherString(value))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return hasMalformedCredential
|
||||
? {
|
||||
...normalized,
|
||||
fido2Credentials: null,
|
||||
}
|
||||
: normalized;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// Android 2026.2.0 requires sshKey.keyFingerprint in sync payloads.
|
||||
@@ -173,18 +138,18 @@ export function formatAttachments(attachments: Attachment[]): any[] | null {
|
||||
// survive a round-trip without code changes.
|
||||
export function cipherToResponse(
|
||||
cipher: Cipher,
|
||||
attachments: Attachment[] = [],
|
||||
options?: { omitFido2Credentials?: boolean }
|
||||
attachments: Attachment[] = []
|
||||
): CipherResponse {
|
||||
// Strip internal-only fields that must not appear in the API response
|
||||
const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher;
|
||||
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null, options);
|
||||
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null);
|
||||
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
|
||||
|
||||
return {
|
||||
// Pass through ALL stored cipher fields (known + unknown)
|
||||
...passthrough,
|
||||
// Server-computed / enforced fields (always override)
|
||||
folderId: normalizeOptionalId(cipher.folderId),
|
||||
type: Number(cipher.type) || 1,
|
||||
organizationId: null,
|
||||
organizationUseTotp: false,
|
||||
@@ -213,7 +178,6 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
||||
const url = new URL(request.url);
|
||||
const includeDeleted = url.searchParams.get('deleted') === 'true';
|
||||
const pagination = parsePagination(url);
|
||||
const omitFido2Credentials = shouldOmitPasskeysForResponse(request);
|
||||
|
||||
let filteredCiphers: Cipher[];
|
||||
let continuationToken: string | null = null;
|
||||
@@ -234,13 +198,15 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
||||
: ciphers.filter(c => !c.deletedAt);
|
||||
}
|
||||
|
||||
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId);
|
||||
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(
|
||||
filteredCiphers.map((cipher) => cipher.id)
|
||||
);
|
||||
|
||||
// Get attachments for all ciphers
|
||||
const cipherResponses = [];
|
||||
// Build responses only for the current page to keep pagination cheap.
|
||||
const cipherResponses: CipherResponse[] = [];
|
||||
for (const cipher of filteredCiphers) {
|
||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||
cipherResponses.push(cipherToResponse(cipher, attachments, { omitFido2Credentials }));
|
||||
cipherResponses.push(cipherToResponse(cipher, attachments));
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
@@ -261,9 +227,7 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
|
||||
|
||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, attachments, {
|
||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||
})
|
||||
cipherToResponse(cipher, attachments)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -319,9 +283,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, [], {
|
||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||
}),
|
||||
cipherToResponse(cipher, []),
|
||||
200
|
||||
);
|
||||
}
|
||||
@@ -362,6 +324,11 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
|
||||
deletedAt: existingCipher.deletedAt,
|
||||
};
|
||||
cipher.login = mergeCipherNestedObject(existingCipher.login, cipherData.login);
|
||||
cipher.card = mergeCipherNestedObject(existingCipher.card, cipherData.card);
|
||||
cipher.identity = mergeCipherNestedObject(existingCipher.identity, cipherData.identity);
|
||||
cipher.secureNote = mergeCipherNestedObject(existingCipher.secureNote, cipherData.secureNote);
|
||||
cipher.sshKey = mergeCipherNestedObject(existingCipher.sshKey, cipherData.sshKey);
|
||||
|
||||
// Custom fields deletion compatibility:
|
||||
// - Accept both camelCase "fields" and PascalCase "Fields".
|
||||
@@ -386,9 +353,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, [], {
|
||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||
})
|
||||
cipherToResponse(cipher, [])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -410,9 +375,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, [], {
|
||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||
})
|
||||
cipherToResponse(cipher, [])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -476,9 +439,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, [], {
|
||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||
})
|
||||
cipherToResponse(cipher, [])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -499,11 +460,12 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
|
||||
}
|
||||
|
||||
if (body.folderId !== undefined) {
|
||||
if (body.folderId) {
|
||||
const folderOk = await verifyFolderOwnership(storage, body.folderId, userId);
|
||||
const folderId = normalizeOptionalId(body.folderId);
|
||||
if (folderId) {
|
||||
const folderOk = await verifyFolderOwnership(storage, folderId, userId);
|
||||
if (!folderOk) return errorResponse('Folder not found', 404);
|
||||
}
|
||||
cipher.folderId = body.folderId;
|
||||
cipher.folderId = folderId;
|
||||
}
|
||||
if (body.favorite !== undefined) {
|
||||
cipher.favorite = body.favorite;
|
||||
@@ -516,9 +478,7 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, [], {
|
||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||
})
|
||||
cipherToResponse(cipher, [])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -537,12 +497,13 @@ export async function handleBulkMoveCiphers(request: Request, env: Env, userId:
|
||||
return errorResponse('ids array is required', 400);
|
||||
}
|
||||
|
||||
if (body.folderId) {
|
||||
const folderOk = await verifyFolderOwnership(storage, body.folderId, userId);
|
||||
const folderId = normalizeOptionalId(body.folderId);
|
||||
if (folderId) {
|
||||
const folderOk = await verifyFolderOwnership(storage, folderId, userId);
|
||||
if (!folderOk) return errorResponse('Folder not found', 404);
|
||||
}
|
||||
|
||||
const revisionDate = await storage.bulkMoveCiphers(body.ids, body.folderId || null, userId);
|
||||
const revisionDate = await storage.bulkMoveCiphers(body.ids, folderId, userId);
|
||||
if (revisionDate) {
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
}
|
||||
@@ -558,13 +519,10 @@ async function buildCipherListResponse(
|
||||
): Promise<Response> {
|
||||
const ciphers = await storage.getCiphersByIds(ids, userId);
|
||||
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map((cipher) => cipher.id));
|
||||
const omitFido2Credentials = shouldOmitPasskeysForResponse(request);
|
||||
|
||||
return jsonResponse({
|
||||
data: ciphers.map((cipher) =>
|
||||
cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], {
|
||||
omitFido2Credentials,
|
||||
})
|
||||
cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [])
|
||||
),
|
||||
object: 'list',
|
||||
continuationToken: null,
|
||||
@@ -597,9 +555,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st
|
||||
|
||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, attachments, {
|
||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||
})
|
||||
cipherToResponse(cipher, attachments)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -621,9 +577,7 @@ export async function handleUnarchiveCipher(request: Request, env: Env, userId:
|
||||
|
||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, attachments, {
|
||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||
})
|
||||
cipherToResponse(cipher, attachments)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+89
-16
@@ -18,6 +18,7 @@ import {
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||
const TWO_FACTOR_PROVIDER_REMEMBER = 5;
|
||||
const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh';
|
||||
// Android client (2026.2.x) deserializes TwoFactorProviders2 keys with -1 for recovery code.
|
||||
// Keep request parsing backward-compatible with historical provider values (8 / 100).
|
||||
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
|
||||
@@ -31,6 +32,54 @@ function resolveTotpSecret(userSecret: string | null): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function shouldUseWebSession(request: Request): boolean {
|
||||
return String(request.headers.get('X-NodeWarden-Web-Session') || '').trim() === '1';
|
||||
}
|
||||
|
||||
function parseCookieValue(request: Request, name: string): string | null {
|
||||
const rawCookie = String(request.headers.get('Cookie') || '').trim();
|
||||
if (!rawCookie) return null;
|
||||
for (const part of rawCookie.split(';')) {
|
||||
const [key, ...rest] = part.trim().split('=');
|
||||
if (key !== name) continue;
|
||||
const value = rest.join('=').trim();
|
||||
return value ? decodeURIComponent(value) : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string {
|
||||
const isHttps = new URL(request.url).protocol === 'https:';
|
||||
const parts = [
|
||||
`${WEB_REFRESH_COOKIE}=${encodeURIComponent(refreshToken)}`,
|
||||
'Path=/identity/connect',
|
||||
'HttpOnly',
|
||||
'SameSite=Strict',
|
||||
`Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
|
||||
];
|
||||
if (isHttps) parts.push('Secure');
|
||||
return parts.join('; ');
|
||||
}
|
||||
|
||||
function buildClearedRefreshCookie(request: Request): string {
|
||||
return buildRefreshCookie(request, '', 0);
|
||||
}
|
||||
|
||||
function withWebRefreshCookie(request: Request, response: Response, refreshToken: string | null): Response {
|
||||
const headers = new Headers(response.headers);
|
||||
headers.append(
|
||||
'Set-Cookie',
|
||||
refreshToken
|
||||
? buildRefreshCookie(request, refreshToken, Math.floor(LIMITS.auth.refreshTokenTtlMs / 1000))
|
||||
: buildClearedRefreshCookie(request)
|
||||
);
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
function buildPreloginResponse(
|
||||
email: string,
|
||||
kdfType: number,
|
||||
@@ -278,17 +327,19 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
|
||||
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
||||
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||
|
||||
const response: TokenResponse = {
|
||||
access_token: accessToken,
|
||||
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
||||
token_type: 'Bearer',
|
||||
refresh_token: refreshToken,
|
||||
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
|
||||
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
||||
Key: user.key,
|
||||
PrivateKey: user.privateKey,
|
||||
AccountKeys: buildAccountKeys(user),
|
||||
accountKeys: buildAccountKeys(user),
|
||||
AccountKeys: accountKeys,
|
||||
accountKeys: accountKeys,
|
||||
Kdf: user.kdfType,
|
||||
KdfIterations: user.kdfIterations,
|
||||
KdfMemory: user.kdfMemory,
|
||||
@@ -301,11 +352,14 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
ApiUseKeyConnector: false,
|
||||
scope: 'api offline_access',
|
||||
unofficialServer: true,
|
||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
||||
userDecryptionOptions: buildUserDecryptionOptions(user),
|
||||
UserDecryptionOptions: userDecryptionOptions,
|
||||
userDecryptionOptions: userDecryptionOptions,
|
||||
};
|
||||
|
||||
return jsonResponse(response);
|
||||
const baseResponse = jsonResponse(response);
|
||||
return shouldUseWebSession(request)
|
||||
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
||||
: baseResponse;
|
||||
|
||||
} else if (grantType === 'send_access') {
|
||||
const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
|
||||
@@ -371,14 +425,21 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
}
|
||||
|
||||
// Refresh token
|
||||
const refreshToken = body.refresh_token;
|
||||
const refreshToken = String(body.refresh_token || '').trim() || (
|
||||
shouldUseWebSession(request)
|
||||
? parseCookieValue(request, WEB_REFRESH_COOKIE)
|
||||
: null
|
||||
);
|
||||
if (!refreshToken) {
|
||||
return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
|
||||
}
|
||||
|
||||
const result = await auth.refreshAccessToken(refreshToken);
|
||||
if (!result) {
|
||||
return identityErrorResponse('Invalid refresh token', 'invalid_grant', 400);
|
||||
const invalidResponse = identityErrorResponse('Invalid refresh token', 'invalid_grant', 400);
|
||||
return shouldUseWebSession(request)
|
||||
? withWebRefreshCookie(request, invalidResponse, null)
|
||||
: invalidResponse;
|
||||
}
|
||||
|
||||
// Keep a short overlap window for old refresh token to absorb
|
||||
@@ -390,16 +451,18 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
|
||||
const { accessToken, user, device } = result;
|
||||
const newRefreshToken = await auth.generateRefreshToken(user.id, device);
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||
|
||||
const response: TokenResponse = {
|
||||
access_token: accessToken,
|
||||
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
||||
token_type: 'Bearer',
|
||||
refresh_token: newRefreshToken,
|
||||
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: newRefreshToken }),
|
||||
Key: user.key,
|
||||
PrivateKey: user.privateKey,
|
||||
AccountKeys: buildAccountKeys(user),
|
||||
accountKeys: buildAccountKeys(user),
|
||||
AccountKeys: accountKeys,
|
||||
accountKeys: accountKeys,
|
||||
Kdf: user.kdfType,
|
||||
KdfIterations: user.kdfIterations,
|
||||
KdfMemory: user.kdfMemory,
|
||||
@@ -412,11 +475,14 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
ApiUseKeyConnector: false,
|
||||
scope: 'api offline_access',
|
||||
unofficialServer: true,
|
||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
||||
userDecryptionOptions: buildUserDecryptionOptions(user),
|
||||
UserDecryptionOptions: userDecryptionOptions,
|
||||
userDecryptionOptions: userDecryptionOptions,
|
||||
};
|
||||
|
||||
return jsonResponse(response);
|
||||
const baseResponse = jsonResponse(response);
|
||||
return shouldUseWebSession(request)
|
||||
? withWebRefreshCookie(request, baseResponse, newRefreshToken)
|
||||
: baseResponse;
|
||||
}
|
||||
|
||||
return identityErrorResponse('Unsupported grant type', 'unsupported_grant_type', 400);
|
||||
@@ -470,10 +536,17 @@ export async function handleRevocation(request: Request, env: Env): Promise<Resp
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
const token = String(body.token || '').trim();
|
||||
const token = String(body.token || '').trim() || (
|
||||
shouldUseWebSession(request)
|
||||
? (parseCookieValue(request, WEB_REFRESH_COOKIE) || '')
|
||||
: ''
|
||||
);
|
||||
if (token) {
|
||||
await storage.deleteRefreshToken(token);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
const baseResponse = new Response(null, { status: 200 });
|
||||
return shouldUseWebSession(request)
|
||||
? withWebRefreshCookie(request, baseResponse, null)
|
||||
: baseResponse;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ interface CiphersImportRequest {
|
||||
password?: string | null;
|
||||
totp?: string | null;
|
||||
autofillOnPageLoad?: boolean | null;
|
||||
fido2Credentials?: any[] | null;
|
||||
uri?: string | null;
|
||||
passwordRevisionDate?: string | null;
|
||||
[key: string]: any;
|
||||
@@ -159,7 +158,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = [];
|
||||
for (let i = 0; i < ciphers.length; i++) {
|
||||
const c = ciphers[i];
|
||||
const folderId = cipherFolderMap.get(i) || null;
|
||||
const folderId = cipherFolderMap.get(i) || c.folderId || null;
|
||||
const sourceIdRaw = String(c?.id ?? '').trim();
|
||||
const sourceId = sourceIdRaw || null;
|
||||
|
||||
@@ -184,7 +183,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
})) || null,
|
||||
totp: c.login.totp ?? null,
|
||||
autofillOnPageLoad: c.login.autofillOnPageLoad ?? null,
|
||||
fido2Credentials: c.login.fido2Credentials ?? null,
|
||||
fido2Credentials: Array.isArray(c.login.fido2Credentials) ? c.login.fido2Credentials : null,
|
||||
uri: c.login.uri ?? null,
|
||||
passwordRevisionDate: c.login.passwordRevisionDate ?? null,
|
||||
} : null,
|
||||
|
||||
@@ -97,8 +97,9 @@ export async function handleGetSends(request: Request, env: Env, userId: string)
|
||||
sends = await storage.getAllSends(userId);
|
||||
}
|
||||
|
||||
const sendResponses = sends.map(sendToResponse);
|
||||
return jsonResponse({
|
||||
data: sends.map(sendToResponse),
|
||||
data: sendResponses,
|
||||
object: 'list',
|
||||
continuationToken,
|
||||
});
|
||||
|
||||
+48
-117
@@ -10,87 +10,23 @@ import {
|
||||
buildUserDecryptionOptions,
|
||||
} from '../utils/user-decryption';
|
||||
|
||||
interface SyncCacheEntry {
|
||||
userId: string;
|
||||
revisionDate: string;
|
||||
body: string;
|
||||
expiresAt: number;
|
||||
bytes: number;
|
||||
function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean): Request {
|
||||
const url = new URL(request.url);
|
||||
const cacheUrl = new URL(
|
||||
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${excludeDomains ? '1' : '0'}`,
|
||||
url.origin
|
||||
);
|
||||
return new Request(cacheUrl.toString(), { method: 'GET' });
|
||||
}
|
||||
|
||||
const syncResponseCache = new Map<string, SyncCacheEntry>();
|
||||
let syncResponseCacheTotalBytes = 0;
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
function buildSyncCacheKey(userId: string, revisionDate: string, excludeDomains: boolean): string {
|
||||
return `${userId}:${revisionDate}:${excludeDomains ? '1' : '0'}`;
|
||||
}
|
||||
|
||||
function readSyncCache(key: string): string | null {
|
||||
const hit = syncResponseCache.get(key);
|
||||
async function readSyncCache(cacheRequest: Request): Promise<Response | null> {
|
||||
const hit = await caches.default.match(cacheRequest);
|
||||
if (!hit) return null;
|
||||
if (hit.expiresAt <= Date.now()) {
|
||||
deleteSyncCacheEntry(key, hit);
|
||||
return null;
|
||||
}
|
||||
return hit.body;
|
||||
return new Response(hit.body, hit);
|
||||
}
|
||||
|
||||
function deleteSyncCacheEntry(key: string, entry?: SyncCacheEntry): void {
|
||||
const existing = entry ?? syncResponseCache.get(key);
|
||||
if (!existing) return;
|
||||
syncResponseCache.delete(key);
|
||||
syncResponseCacheTotalBytes = Math.max(0, syncResponseCacheTotalBytes - existing.bytes);
|
||||
}
|
||||
|
||||
function pruneExpiredSyncCache(nowMs: number = Date.now()): void {
|
||||
for (const [key, entry] of syncResponseCache.entries()) {
|
||||
if (entry.expiresAt <= nowMs) {
|
||||
deleteSyncCacheEntry(key, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pruneStaleUserSyncCache(userId: string, revisionDate: string): void {
|
||||
for (const [key, entry] of syncResponseCache.entries()) {
|
||||
if (entry.userId === userId && entry.revisionDate !== revisionDate) {
|
||||
deleteSyncCacheEntry(key, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeSyncCache(userId: string, revisionDate: string, key: string, body: string): void {
|
||||
const nowMs = Date.now();
|
||||
pruneExpiredSyncCache(nowMs);
|
||||
pruneStaleUserSyncCache(userId, revisionDate);
|
||||
|
||||
const bodyBytes = textEncoder.encode(body).byteLength;
|
||||
if (bodyBytes > LIMITS.cache.syncResponseMaxBodyBytes) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = syncResponseCache.get(key);
|
||||
if (existing) {
|
||||
deleteSyncCacheEntry(key, existing);
|
||||
}
|
||||
|
||||
while (
|
||||
syncResponseCache.size >= LIMITS.cache.syncResponseMaxEntries ||
|
||||
syncResponseCacheTotalBytes + bodyBytes > LIMITS.cache.syncResponseMaxTotalBytes
|
||||
) {
|
||||
const oldestKey = syncResponseCache.keys().next().value as string | undefined;
|
||||
if (!oldestKey) break;
|
||||
deleteSyncCacheEntry(oldestKey);
|
||||
}
|
||||
|
||||
syncResponseCache.set(key, {
|
||||
userId,
|
||||
revisionDate,
|
||||
body,
|
||||
expiresAt: nowMs + LIMITS.cache.syncResponseTtlMs,
|
||||
bytes: bodyBytes,
|
||||
});
|
||||
syncResponseCacheTotalBytes += bodyBytes;
|
||||
async function writeSyncCache(cacheRequest: Request, response: Response): Promise<void> {
|
||||
await caches.default.put(cacheRequest, response.clone());
|
||||
}
|
||||
|
||||
// GET /api/sync
|
||||
@@ -99,34 +35,28 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
const url = new URL(request.url);
|
||||
const excludeDomainsParam = url.searchParams.get('excludeDomains');
|
||||
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
||||
const userAgent = String(request.headers.get('user-agent') || '').toLowerCase();
|
||||
const omitFido2Credentials =
|
||||
userAgent.includes('android') ||
|
||||
userAgent.includes('iphone') ||
|
||||
userAgent.includes('ipad') ||
|
||||
userAgent.includes('ios');
|
||||
|
||||
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) {
|
||||
return errorResponse('User not found', 404);
|
||||
}
|
||||
|
||||
const revisionDate = await storage.getRevisionDate(userId);
|
||||
const cacheKey = buildSyncCacheKey(userId, revisionDate, excludeDomains);
|
||||
const cachedBody = readSyncCache(cacheKey);
|
||||
if (cachedBody) {
|
||||
return new Response(cachedBody, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains);
|
||||
const cachedResponse = await readSyncCache(cacheRequest);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
const ciphers = await storage.getAllCiphers(userId);
|
||||
const folders = await storage.getAllFolders(userId);
|
||||
const sends = await storage.getAllSends(userId);
|
||||
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId);
|
||||
const [ciphers, folders, sends, attachmentsByCipher] = await Promise.all([
|
||||
storage.getAllCiphers(userId),
|
||||
storage.getAllFolders(userId),
|
||||
storage.getAllSends(userId),
|
||||
storage.getAttachmentsByUserId(userId),
|
||||
]);
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||
|
||||
// Build profile response
|
||||
const profile: ProfileResponse = {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
@@ -140,7 +70,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
twoFactorEnabled: !!user.totpSecret,
|
||||
key: user.key,
|
||||
privateKey: user.privateKey,
|
||||
accountKeys: buildAccountKeys(user),
|
||||
accountKeys,
|
||||
securityStamp: user.securityStamp || user.id,
|
||||
organizations: [],
|
||||
providers: [],
|
||||
@@ -152,23 +82,24 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
object: 'profile',
|
||||
};
|
||||
|
||||
// Build cipher responses with attachments
|
||||
const cipherResponses: CipherResponse[] = [];
|
||||
for (const cipher of ciphers) {
|
||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||
cipherResponses.push(cipherToResponse(cipher, attachments, { omitFido2Credentials }));
|
||||
cipherResponses.push(cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []));
|
||||
}
|
||||
|
||||
// Build folder responses
|
||||
const folderResponses: FolderResponse[] = folders.map(folder => ({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
revisionDate: folder.updatedAt,
|
||||
object: 'folder',
|
||||
}));
|
||||
const folderResponses: FolderResponse[] = [];
|
||||
for (const folder of folders) {
|
||||
folderResponses.push({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
revisionDate: folder.updatedAt,
|
||||
object: 'folder',
|
||||
});
|
||||
}
|
||||
|
||||
const sendResponses = sends.map(sendToResponse);
|
||||
const syncResponse: SyncResponse = {
|
||||
profile: profile,
|
||||
profile,
|
||||
folders: folderResponses,
|
||||
collections: [],
|
||||
ciphers: cipherResponses,
|
||||
@@ -180,25 +111,25 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
object: 'domains',
|
||||
},
|
||||
policies: [],
|
||||
sends: sends.map(sendToResponse),
|
||||
sends: sendResponses,
|
||||
UserDecryption: {
|
||||
MasterPasswordUnlock: buildUserDecryptionOptions(user).MasterPasswordUnlock,
|
||||
MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock,
|
||||
TrustedDeviceOption: null,
|
||||
KeyConnectorOption: null,
|
||||
Object: 'userDecryption',
|
||||
},
|
||||
// PascalCase for desktop/browser clients
|
||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
||||
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
||||
UserDecryptionOptions: userDecryptionOptions,
|
||||
userDecryption: buildUserDecryptionCompat(user) as SyncResponse['userDecryption'],
|
||||
object: 'sync',
|
||||
};
|
||||
|
||||
const body = JSON.stringify(syncResponse);
|
||||
writeSyncCache(userId, revisionDate, cacheKey, body);
|
||||
|
||||
return new Response(body, {
|
||||
const response = new Response(JSON.stringify(syncResponse), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': `private, max-age=${Math.max(1, Math.floor(LIMITS.cache.syncResponseTtlMs / 1000))}`,
|
||||
},
|
||||
});
|
||||
await writeSyncCache(cacheRequest, response);
|
||||
return response;
|
||||
}
|
||||
|
||||
+15
-5
@@ -9,6 +9,15 @@ let dbInitialized = false;
|
||||
let dbInitError: string | null = null;
|
||||
let dbInitPromise: Promise<void> | null = null;
|
||||
|
||||
function normalizeRequestUrl(request: Request): Request {
|
||||
const url = new URL(request.url);
|
||||
const normalizedPathname = url.pathname.length <= 1 ? url.pathname : url.pathname.replace(/\/+$/, '');
|
||||
if (normalizedPathname === url.pathname) return request;
|
||||
|
||||
url.pathname = normalizedPathname;
|
||||
return new Request(url.toString(), request);
|
||||
}
|
||||
|
||||
function isWorkerHandledPath(path: string): boolean {
|
||||
return (
|
||||
path.startsWith('/api/') ||
|
||||
@@ -56,9 +65,10 @@ async function ensureDatabaseInitialized(env: Env): Promise<void> {
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
void ctx;
|
||||
const assetResponse = await maybeServeAsset(request, env);
|
||||
const normalizedRequest = normalizeRequestUrl(request);
|
||||
const assetResponse = await maybeServeAsset(normalizedRequest, env);
|
||||
if (assetResponse) {
|
||||
return applyCors(request, assetResponse);
|
||||
return applyCors(normalizedRequest, assetResponse);
|
||||
}
|
||||
|
||||
await ensureDatabaseInitialized(env);
|
||||
@@ -76,11 +86,11 @@ export default {
|
||||
},
|
||||
500
|
||||
);
|
||||
return applyCors(request, resp);
|
||||
return applyCors(normalizedRequest, resp);
|
||||
}
|
||||
|
||||
const resp = await handleRequest(request, env);
|
||||
return applyCors(request, resp);
|
||||
const resp = await handleRequest(normalizedRequest, env);
|
||||
return applyCors(normalizedRequest, resp);
|
||||
},
|
||||
|
||||
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
handleDownloadAdminBackupAttachment,
|
||||
handleGetAdminBackupSettings,
|
||||
handleGetAdminBackupSettingsRepairState,
|
||||
handleInspectAdminRemoteBackup,
|
||||
handleAdminImportBackup,
|
||||
handleListAdminRemoteBackups,
|
||||
handleRepairAdminBackupSettings,
|
||||
@@ -53,6 +54,10 @@ export async function handleAdminBackupRoute(
|
||||
return handleDownloadAdminRemoteBackup(request, env, actorUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/remote/integrity' && method === 'GET') {
|
||||
return handleInspectAdminRemoteBackup(request, env, actorUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/remote/file' && method === 'DELETE') {
|
||||
return handleDeleteAdminRemoteBackup(request, env, actorUser);
|
||||
}
|
||||
|
||||
+110
-15
@@ -9,6 +9,7 @@ import {
|
||||
type SqlRow = Record<string, string | number | null>;
|
||||
|
||||
const BACKUP_FORMAT_VERSION = 1;
|
||||
const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
|
||||
// Worker-side backup export must stay well below Cloudflare CPU limits.
|
||||
// Prefer store-only ZIP entries over heavier compression to keep exports reliable.
|
||||
const BACKUP_TEXT_COMPRESSION_LEVEL = 0;
|
||||
@@ -60,25 +61,89 @@ export interface BackupArchiveBundle {
|
||||
manifest: BackupManifest;
|
||||
}
|
||||
|
||||
export interface BackupFileIntegrityCheckResult {
|
||||
hasChecksumPrefix: boolean;
|
||||
expectedPrefix: string | null;
|
||||
actualPrefix: string;
|
||||
matches: boolean;
|
||||
}
|
||||
|
||||
export interface BuildBackupArchiveOptions {
|
||||
includeAttachments?: boolean;
|
||||
progress?: BackupArchiveBuildProgressReporter;
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
export interface BackupArchiveBuildProgressEvent {
|
||||
step: string;
|
||||
fileName?: string;
|
||||
stageTitle: string;
|
||||
stageDetail: string;
|
||||
includeAttachments: boolean;
|
||||
}
|
||||
|
||||
export type BackupArchiveBuildProgressReporter = (event: BackupArchiveBuildProgressEvent) => Promise<void>;
|
||||
|
||||
async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Promise<SqlRow[]> {
|
||||
const result = await db.prepare(sql).bind(...values).all<SqlRow>();
|
||||
return (result.results || []).map((row) => ({ ...row }));
|
||||
}
|
||||
|
||||
function buildBackupFileName(date: Date = new Date()): string {
|
||||
const parts = [
|
||||
date.getUTCFullYear().toString().padStart(4, '0'),
|
||||
(date.getUTCMonth() + 1).toString().padStart(2, '0'),
|
||||
date.getUTCDate().toString().padStart(2, '0'),
|
||||
date.getUTCHours().toString().padStart(2, '0'),
|
||||
date.getUTCMinutes().toString().padStart(2, '0'),
|
||||
date.getUTCSeconds().toString().padStart(2, '0'),
|
||||
];
|
||||
return `nodewarden_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}.zip`;
|
||||
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function getDateParts(date: Date, timeZone: string): string {
|
||||
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
});
|
||||
const parts = formatter.formatToParts(date);
|
||||
const pick = (type: string): string => parts.find((part) => part.type === type)?.value || '';
|
||||
return `${pick('year')}${pick('month')}${pick('day')}_${pick('hour')}${pick('minute')}${pick('second')}`;
|
||||
}
|
||||
|
||||
function buildBackupFileNameInTimeZone(
|
||||
date: Date = new Date(),
|
||||
checksumPrefix: string | null = null,
|
||||
timeZone: string = 'UTC'
|
||||
): string {
|
||||
const parts = getDateParts(date, timeZone);
|
||||
const suffix = checksumPrefix ? `_${checksumPrefix}` : '';
|
||||
return `nodewarden_backup_${parts}${suffix}.zip`;
|
||||
}
|
||||
|
||||
export function extractBackupFileChecksumPrefix(fileName: string): string | null {
|
||||
const normalized = String(fileName || '').trim();
|
||||
const match = normalized.match(/_([0-9a-f]{5})\.zip$/i);
|
||||
return match ? match[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
export async function inspectBackupArchiveFileNameChecksum(
|
||||
bytes: Uint8Array,
|
||||
fileName: string
|
||||
): Promise<BackupFileIntegrityCheckResult> {
|
||||
const expectedPrefix = extractBackupFileChecksumPrefix(fileName);
|
||||
const actualHash = await sha256Hex(bytes);
|
||||
const actualPrefix = actualHash.slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
|
||||
return {
|
||||
hasChecksumPrefix: !!expectedPrefix,
|
||||
expectedPrefix,
|
||||
actualPrefix,
|
||||
matches: !expectedPrefix || actualPrefix === expectedPrefix,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyBackupArchiveFileNameChecksum(bytes: Uint8Array, fileName: string): Promise<boolean> {
|
||||
const result = await inspectBackupArchiveFileNameChecksum(bytes, fileName);
|
||||
return result.matches;
|
||||
}
|
||||
|
||||
function validateArchiveSize(bytes: Uint8Array): void {
|
||||
@@ -269,16 +334,25 @@ export async function buildBackupArchive(
|
||||
date: Date = new Date(),
|
||||
options: BuildBackupArchiveOptions = {}
|
||||
): Promise<BackupArchiveBundle> {
|
||||
const includeAttachments = options.includeAttachments !== false;
|
||||
await options.progress?.({
|
||||
step: 'collect_data',
|
||||
fileName: '',
|
||||
stageTitle: 'txt_backup_archive_progress_collect_title',
|
||||
stageDetail: includeAttachments
|
||||
? 'txt_backup_archive_progress_collect_with_attachments_detail'
|
||||
: 'txt_backup_archive_progress_collect_detail',
|
||||
includeAttachments,
|
||||
});
|
||||
const encoder = new TextEncoder();
|
||||
const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows] = await Promise.all([
|
||||
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
|
||||
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
|
||||
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
|
||||
]);
|
||||
const includeAttachments = options.includeAttachments !== false;
|
||||
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
|
||||
const attachmentBlobs: BackupManifestAttachmentBlob[] = exportedAttachmentRows.map((row) => {
|
||||
const cipherId = String(row.cipher_id || '').trim();
|
||||
@@ -327,9 +401,30 @@ export async function buildBackupArchive(
|
||||
}, null, BACKUP_JSON_INDENT)),
|
||||
};
|
||||
|
||||
await options.progress?.({
|
||||
step: 'package_archive',
|
||||
fileName: '',
|
||||
stageTitle: 'txt_backup_archive_progress_package_title',
|
||||
stageDetail: includeAttachments
|
||||
? 'txt_backup_archive_progress_package_with_attachments_detail'
|
||||
: 'txt_backup_archive_progress_package_detail',
|
||||
includeAttachments,
|
||||
});
|
||||
const bytes = zipSync(createZipEntries(files));
|
||||
const fileHashPrefix = (await sha256Hex(bytes)).slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
|
||||
const backupTimeZone = options.timeZone || 'UTC';
|
||||
const fileName = buildBackupFileNameInTimeZone(date, fileHashPrefix, backupTimeZone);
|
||||
await options.progress?.({
|
||||
step: 'archive_ready',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_archive_progress_ready_title',
|
||||
stageDetail: 'txt_backup_archive_progress_ready_detail',
|
||||
includeAttachments,
|
||||
});
|
||||
|
||||
return {
|
||||
bytes: zipSync(createZipEntries(files)),
|
||||
fileName: buildBackupFileName(date),
|
||||
bytes,
|
||||
fileName,
|
||||
manifest: manifestBase,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Env } from '../types';
|
||||
import type { Env, User } from '../types';
|
||||
import { StorageService } from './storage';
|
||||
import {
|
||||
type BackupSettingsPortableEnvelope,
|
||||
@@ -422,20 +422,45 @@ export async function saveBackupSettings(storage: StorageService, env: Env, sett
|
||||
export async function normalizeImportedBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<void> {
|
||||
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
|
||||
if (!raw) return;
|
||||
const users = await storage.getAllUsers();
|
||||
const normalized = await normalizeImportedBackupSettingsValue(raw, env, users, fallbackTimezone);
|
||||
if (normalized !== null) {
|
||||
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
export async function normalizeImportedBackupSettingsValue(
|
||||
raw: string | null,
|
||||
env: Env,
|
||||
users: Pick<User, 'id' | 'publicKey' | 'role' | 'status'>[],
|
||||
fallbackTimezone: string = 'UTC'
|
||||
): Promise<string | null> {
|
||||
if (!raw) return null;
|
||||
const envelope = parseBackupSettingsEnvelope(raw);
|
||||
if (envelope) {
|
||||
try {
|
||||
const decrypted = await decryptBackupSettingsRuntime(raw, env);
|
||||
const settings = parseBackupSettings(decrypted, fallbackTimezone);
|
||||
await saveBackupSettings(storage, env, settings);
|
||||
return;
|
||||
const hasPortableAdmins = users.some(
|
||||
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
|
||||
);
|
||||
if (!hasPortableAdmins) {
|
||||
return serializeBackupSettings(settings);
|
||||
}
|
||||
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
||||
} catch {
|
||||
// Keep imported portable recovery data intact until an admin signs in and repairs it.
|
||||
return;
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
const settings = parseBackupSettings(raw, fallbackTimezone);
|
||||
await saveBackupSettings(storage, env, settings);
|
||||
const hasPortableAdmins = users.some(
|
||||
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
|
||||
);
|
||||
if (!hasPortableAdmins) {
|
||||
return serializeBackupSettings(settings);
|
||||
}
|
||||
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
||||
}
|
||||
|
||||
export async function getBackupSettingsRepairState(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettingsRepairState> {
|
||||
|
||||
+464
-110
@@ -1,7 +1,6 @@
|
||||
import type { Env } from '../types';
|
||||
import { StorageService } from './storage';
|
||||
import type { Env, User } from '../types';
|
||||
import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobStorageKind, putBlobObject } from './blob-store';
|
||||
import { normalizeImportedBackupSettings } from './backup-config';
|
||||
import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from './backup-config';
|
||||
import {
|
||||
type BackupManifestAttachmentBlob,
|
||||
type BackupPayload,
|
||||
@@ -10,6 +9,26 @@ import {
|
||||
} from './backup-archive';
|
||||
|
||||
type SqlRow = Record<string, string | number | null>;
|
||||
type BackupTableName =
|
||||
| 'config'
|
||||
| 'users'
|
||||
| 'user_revisions'
|
||||
| 'folders'
|
||||
| 'ciphers'
|
||||
| 'attachments';
|
||||
|
||||
const BACKUP_TABLES: BackupTableName[] = [
|
||||
'config',
|
||||
'users',
|
||||
'user_revisions',
|
||||
'folders',
|
||||
'ciphers',
|
||||
'attachments',
|
||||
];
|
||||
|
||||
function shadowTableName(table: BackupTableName): string {
|
||||
return `${table}__restore`;
|
||||
}
|
||||
|
||||
export interface BackupImportResultBody {
|
||||
object: 'instance-backup-import';
|
||||
@@ -43,6 +62,81 @@ async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Pro
|
||||
return (response.results || []).map((row) => ({ ...row }));
|
||||
}
|
||||
|
||||
async function getTableCreateSql(db: D1Database, table: BackupTableName): Promise<string> {
|
||||
const row = await db
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.bind(table)
|
||||
.first<{ sql: string | null }>();
|
||||
const sql = String(row?.sql || '').trim();
|
||||
if (!sql) {
|
||||
throw new Error(`Restore shadow schema is missing table definition for ${table}`);
|
||||
}
|
||||
return sql;
|
||||
}
|
||||
|
||||
function buildShadowTableCreateSql(createSql: string, table: BackupTableName): string {
|
||||
const tablePattern = new RegExp(`^CREATE TABLE(?:\\s+IF NOT EXISTS)?\\s+(?:\"${table}\"|${table})(?=\\s*\\()`, 'i');
|
||||
let next = createSql.replace(tablePattern, `CREATE TABLE "${shadowTableName(table)}"`);
|
||||
if (next === createSql) {
|
||||
throw new Error(`Restore shadow schema could not rewrite CREATE TABLE statement for ${table}`);
|
||||
}
|
||||
for (const currentTable of BACKUP_TABLES) {
|
||||
const referencePattern = new RegExp(`\\bREFERENCES\\s+(?:\"${currentTable}\"|${currentTable})(?=\\s*\\()`, 'gi');
|
||||
next = next.replace(
|
||||
referencePattern,
|
||||
`REFERENCES "${shadowTableName(currentTable)}"`
|
||||
);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
async function resetRestoreArtifacts(db: D1Database): Promise<void> {
|
||||
const dropStatements = BACKUP_TABLES
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((table) => db.prepare(`DROP TABLE IF EXISTS ${shadowTableName(table)}`));
|
||||
if (dropStatements.length) {
|
||||
await db.batch(dropStatements);
|
||||
}
|
||||
}
|
||||
|
||||
async function createShadowTables(db: D1Database): Promise<void> {
|
||||
const createStatements: D1PreparedStatement[] = [];
|
||||
for (const table of BACKUP_TABLES) {
|
||||
const createSql = await getTableCreateSql(db, table);
|
||||
createStatements.push(db.prepare(buildShadowTableCreateSql(createSql, table)));
|
||||
}
|
||||
await db.batch(createStatements);
|
||||
}
|
||||
|
||||
async function validateShadowTableCounts(
|
||||
db: D1Database,
|
||||
expectedCounts: Partial<Record<BackupTableName, number>>
|
||||
): Promise<void> {
|
||||
await Promise.all(BACKUP_TABLES.map(async (table) => {
|
||||
const expected = expectedCounts[table] ?? 0;
|
||||
const row = await db.prepare(`SELECT COUNT(*) AS count FROM ${shadowTableName(table)}`).first<{ count: number }>();
|
||||
const actual = Number(row?.count || 0);
|
||||
if (actual !== expected) {
|
||||
throw new Error(`Restore shadow validation failed for ${table}: expected ${expected}, received ${actual}`);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async function swapShadowTablesIntoPlace(db: D1Database): Promise<void> {
|
||||
const statements: D1PreparedStatement[] = [];
|
||||
// Commit by replacing live table contents from validated shadow tables.
|
||||
// This avoids D1 schema-rename edge cases while keeping current data intact
|
||||
// until the final batch succeeds.
|
||||
for (const sql of buildResetImportTargetStatements(db)) {
|
||||
statements.push(sql);
|
||||
}
|
||||
for (const table of BACKUP_TABLES) {
|
||||
statements.push(db.prepare(`INSERT INTO ${table} SELECT * FROM ${shadowTableName(table)}`));
|
||||
}
|
||||
await db.batch(statements);
|
||||
}
|
||||
|
||||
async function ensureImportTargetIsFresh(db: D1Database): Promise<void> {
|
||||
const counts = await Promise.all([
|
||||
db.prepare('SELECT COUNT(*) AS count FROM ciphers').first<{ count: number }>(),
|
||||
@@ -61,18 +155,9 @@ function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[]
|
||||
'DELETE FROM attachments',
|
||||
'DELETE FROM ciphers',
|
||||
'DELETE FROM folders',
|
||||
'DELETE FROM sends',
|
||||
'DELETE FROM trusted_two_factor_device_tokens',
|
||||
'DELETE FROM devices',
|
||||
'DELETE FROM refresh_tokens',
|
||||
'DELETE FROM invites',
|
||||
'DELETE FROM audit_logs',
|
||||
'DELETE FROM user_revisions',
|
||||
'DELETE FROM users',
|
||||
'DELETE FROM config',
|
||||
'DELETE FROM login_attempts_ip',
|
||||
'DELETE FROM api_rate_limits',
|
||||
'DELETE FROM used_attachment_download_tokens',
|
||||
].map((sql) => db.prepare(sql));
|
||||
}
|
||||
|
||||
@@ -119,10 +204,90 @@ interface AttachmentRestoreResult {
|
||||
}
|
||||
|
||||
interface RemoteAttachmentSource {
|
||||
hasAttachment(blobName: string): Promise<boolean>;
|
||||
loadAttachment(blobName: string): Promise<Uint8Array | null>;
|
||||
}
|
||||
|
||||
export interface BackupRestoreProgressEvent {
|
||||
source: 'local' | 'remote';
|
||||
step: string;
|
||||
fileName: string;
|
||||
stageTitle: string;
|
||||
stageDetail: string;
|
||||
replaceExisting: boolean;
|
||||
done?: boolean;
|
||||
ok?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export type BackupRestoreProgressReporter = (event: BackupRestoreProgressEvent) => Promise<void> | void;
|
||||
|
||||
function attachmentRowKey(row: SqlRow): string {
|
||||
const attachmentId = String(row.id || '').trim();
|
||||
const cipherId = String(row.cipher_id || '').trim();
|
||||
return `${cipherId}/${attachmentId}`;
|
||||
}
|
||||
|
||||
function cloneRows(rows: SqlRow[]): SqlRow[] {
|
||||
return rows.map((row) => ({ ...row }));
|
||||
}
|
||||
|
||||
function upsertConfigRow(rows: SqlRow[], key: string, value: string): SqlRow[] {
|
||||
let replaced = false;
|
||||
const nextRows = rows.map((row) => {
|
||||
if (String(row.key || '').trim() !== key) return { ...row };
|
||||
replaced = true;
|
||||
return { ...row, key, value };
|
||||
});
|
||||
if (!replaced) {
|
||||
nextRows.push({ key, value });
|
||||
}
|
||||
return nextRows;
|
||||
}
|
||||
|
||||
async function prepareImportedConfigRows(
|
||||
env: Env,
|
||||
configRows: SqlRow[],
|
||||
userRows: SqlRow[]
|
||||
): Promise<SqlRow[]> {
|
||||
let nextConfigRows = cloneRows(configRows || []);
|
||||
const rawBackupSettings = nextConfigRows.find((row) => String(row.key || '').trim() === BACKUP_SETTINGS_CONFIG_KEY);
|
||||
const normalizedBackupSettings = await normalizeImportedBackupSettingsValue(
|
||||
typeof rawBackupSettings?.value === 'string' ? rawBackupSettings.value : null,
|
||||
env,
|
||||
userRows.map((row) => ({
|
||||
id: String(row.id || '').trim(),
|
||||
publicKey: typeof row.public_key === 'string' ? row.public_key : null,
|
||||
role: String(row.role || '').trim() as User['role'],
|
||||
status: String(row.status || '').trim() as User['status'],
|
||||
})),
|
||||
'UTC'
|
||||
);
|
||||
if (normalizedBackupSettings !== null) {
|
||||
nextConfigRows = upsertConfigRow(nextConfigRows, BACKUP_SETTINGS_CONFIG_KEY, normalizedBackupSettings);
|
||||
}
|
||||
nextConfigRows = upsertConfigRow(nextConfigRows, 'registered', 'true');
|
||||
return nextConfigRows;
|
||||
}
|
||||
|
||||
async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['db'], env: Env): Promise<BackupPayload['db']> {
|
||||
const preparedDb: BackupPayload['db'] = {
|
||||
config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []),
|
||||
users: cloneRows(payload.users || []).map((row) => ({
|
||||
...row,
|
||||
verify_devices: row.verify_devices ?? 1,
|
||||
})),
|
||||
user_revisions: cloneRows(payload.user_revisions || []),
|
||||
folders: cloneRows(payload.folders || []),
|
||||
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
||||
...row,
|
||||
archived_at: row.archived_at ?? null,
|
||||
})),
|
||||
attachments: cloneRows(payload.attachments || []),
|
||||
};
|
||||
await importBackupRows(db, preparedDb, true);
|
||||
return preparedDb;
|
||||
}
|
||||
|
||||
function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files: Record<string, Uint8Array>): PreparedBackupImportPayload {
|
||||
const storageKind = getBlobStorageKind(env);
|
||||
if (storageKind === 'r2') {
|
||||
@@ -147,7 +312,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files:
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
const result = {
|
||||
payload: {
|
||||
...payload,
|
||||
db: {
|
||||
@@ -161,6 +326,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files:
|
||||
items: skippedItems,
|
||||
},
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
const oversizedAttachmentPaths = new Set<string>();
|
||||
@@ -197,7 +363,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files:
|
||||
throw new Error('Backup restore requires ATTACHMENTS_KV when using KV blob storage');
|
||||
}
|
||||
|
||||
return {
|
||||
const result = {
|
||||
payload: nextPayload,
|
||||
skipped: {
|
||||
reason: skippedItems.length ? KV_BLOB_SKIP_REASON : null,
|
||||
@@ -205,6 +371,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files:
|
||||
items: skippedItems,
|
||||
},
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildInsertStatements(db: D1Database, table: string, columns: string[], rows: SqlRow[], upsert = false): D1PreparedStatement[] {
|
||||
@@ -214,6 +381,16 @@ function buildInsertStatements(db: D1Database, table: string, columns: string[],
|
||||
return rows.map((row) => db.prepare(sql).bind(...columns.map((column) => row[column] ?? null)));
|
||||
}
|
||||
|
||||
async function runInsertBatch(db: D1Database, table: string, statements: D1PreparedStatement[]): Promise<void> {
|
||||
if (!statements.length) return;
|
||||
try {
|
||||
await db.batch(statements);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Restore insert failed for ${table}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record<string, Uint8Array>): Promise<AttachmentRestoreResult> {
|
||||
const restoredAttachments: SqlRow[] = [];
|
||||
const skippedItems: BackupImportSkipSummary['items'] = [];
|
||||
@@ -300,14 +477,10 @@ async function prepareRemoteAttachmentPayload(
|
||||
skippedItems.push({ kind: 'attachment', path, sizeBytes });
|
||||
continue;
|
||||
}
|
||||
if (!(await source.hasAttachment(ref.blobName))) {
|
||||
skippedItems.push({ kind: 'attachment', path, sizeBytes });
|
||||
continue;
|
||||
}
|
||||
nextAttachments.push(row);
|
||||
}
|
||||
|
||||
return {
|
||||
const result = {
|
||||
payload: {
|
||||
...payload,
|
||||
db: {
|
||||
@@ -321,16 +494,18 @@ async function prepareRemoteAttachmentPayload(
|
||||
items: skippedItems,
|
||||
},
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
async function removeAttachmentRows(db: D1Database, attachmentRows: SqlRow[]): Promise<void> {
|
||||
async function removeAttachmentRows(db: D1Database, attachmentRows: SqlRow[], useShadowTable: boolean = false): Promise<void> {
|
||||
if (!attachmentRows.length) return;
|
||||
const tableName = useShadowTable ? shadowTableName('attachments') : 'attachments';
|
||||
const statements = attachmentRows
|
||||
.map((row) => {
|
||||
const attachmentId = String(row.id || '').trim();
|
||||
const cipherId = String(row.cipher_id || '').trim();
|
||||
if (!attachmentId || !cipherId) return null;
|
||||
return db.prepare('DELETE FROM attachments WHERE id = ? AND cipher_id = ?').bind(attachmentId, cipherId);
|
||||
return db.prepare(`DELETE FROM ${tableName} WHERE id = ? AND cipher_id = ?`).bind(attachmentId, cipherId);
|
||||
})
|
||||
.filter((statement): statement is D1PreparedStatement => !!statement);
|
||||
if (!statements.length) return;
|
||||
@@ -406,36 +581,58 @@ async function cleanupOrphanedBlobFiles(env: Env, beforeKeys: Set<string>, after
|
||||
}
|
||||
}
|
||||
|
||||
async function importBackupRows(db: D1Database, payload: BackupPayload['db']): Promise<void> {
|
||||
const statements: D1PreparedStatement[] = [
|
||||
...buildResetImportTargetStatements(db),
|
||||
...buildInsertStatements(db, 'config', ['key', 'value'], payload.config || [], true),
|
||||
...buildInsertStatements(
|
||||
async function importBackupRows(db: D1Database, payload: BackupPayload['db'], useShadowTables: boolean = false): Promise<void> {
|
||||
const tableName = (table: BackupTableName): string => (useShadowTables ? shadowTableName(table) : table);
|
||||
await runInsertBatch(
|
||||
db,
|
||||
tableName('config'),
|
||||
buildInsertStatements(db, tableName('config'), ['key', 'value'], payload.config || [], true)
|
||||
);
|
||||
await runInsertBatch(
|
||||
db,
|
||||
tableName('users'),
|
||||
buildInsertStatements(
|
||||
db,
|
||||
'users',
|
||||
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'],
|
||||
tableName('users'),
|
||||
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'],
|
||||
payload.users || []
|
||||
),
|
||||
...buildInsertStatements(db, 'user_revisions', ['user_id', 'revision_date'], payload.user_revisions || [], true),
|
||||
...buildInsertStatements(db, 'folders', ['id', 'user_id', 'name', 'created_at', 'updated_at'], payload.folders || []),
|
||||
...buildInsertStatements(
|
||||
)
|
||||
);
|
||||
await runInsertBatch(
|
||||
db,
|
||||
tableName('user_revisions'),
|
||||
buildInsertStatements(db, tableName('user_revisions'), ['user_id', 'revision_date'], payload.user_revisions || [], true)
|
||||
);
|
||||
await runInsertBatch(
|
||||
db,
|
||||
tableName('folders'),
|
||||
buildInsertStatements(db, tableName('folders'), ['id', 'user_id', 'name', 'created_at', 'updated_at'], payload.folders || [])
|
||||
);
|
||||
await runInsertBatch(
|
||||
db,
|
||||
tableName('ciphers'),
|
||||
buildInsertStatements(
|
||||
db,
|
||||
'ciphers',
|
||||
['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'deleted_at'],
|
||||
tableName('ciphers'),
|
||||
['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'archived_at', 'deleted_at'],
|
||||
payload.ciphers || []
|
||||
),
|
||||
...buildInsertStatements(db, 'attachments', ['id', 'cipher_id', 'file_name', 'size', 'size_name', 'key'], payload.attachments || []),
|
||||
];
|
||||
await db.batch(statements);
|
||||
)
|
||||
);
|
||||
await runInsertBatch(
|
||||
db,
|
||||
tableName('attachments'),
|
||||
buildInsertStatements(db, tableName('attachments'), ['id', 'cipher_id', 'file_name', 'size', 'size_name', 'key'], payload.attachments || [])
|
||||
);
|
||||
}
|
||||
|
||||
export async function importBackupArchiveBytes(
|
||||
archiveBytes: Uint8Array,
|
||||
env: Env,
|
||||
actorUserId: string,
|
||||
replaceExisting: boolean
|
||||
replaceExisting: boolean,
|
||||
progress?: BackupRestoreProgressReporter,
|
||||
fileName: string = 'nodewarden_backup.zip'
|
||||
): Promise<BackupImportExecutionResult> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const parsed = parseBackupArchive(archiveBytes);
|
||||
validateBackupPayloadContents(parsed.payload, parsed.files);
|
||||
const prepared = prepareImportPayloadForTarget(env, parsed.payload, parsed.files);
|
||||
@@ -448,40 +645,118 @@ export async function importBackupArchiveBytes(
|
||||
}
|
||||
}
|
||||
|
||||
await resetRestoreArtifacts(env.DB);
|
||||
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
|
||||
const { db } = prepared.payload;
|
||||
await importBackupRows(env.DB, db);
|
||||
await normalizeImportedBackupSettings(storage, env, 'UTC');
|
||||
try {
|
||||
await progress?.({
|
||||
source: 'local',
|
||||
step: 'local_create_shadow',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_local_shadow_title',
|
||||
stageDetail: 'txt_backup_restore_progress_local_shadow_detail',
|
||||
replaceExisting,
|
||||
});
|
||||
await createShadowTables(env.DB);
|
||||
await progress?.({
|
||||
source: 'local',
|
||||
step: 'local_import_data',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_local_data_title',
|
||||
stageDetail: 'txt_backup_restore_progress_local_data_detail',
|
||||
replaceExisting,
|
||||
});
|
||||
const db = await importPreparedBackupRows(env.DB, prepared.payload.db, env);
|
||||
await validateShadowTableCounts(env.DB, {
|
||||
config: (db.config || []).length,
|
||||
users: (db.users || []).length,
|
||||
user_revisions: (db.user_revisions || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: (db.attachments || []).length,
|
||||
});
|
||||
|
||||
const restored = await restoreBlobFiles(env, db, parsed.files);
|
||||
const failedRestoreRows = (db.attachments || []).filter((row) => !restored.restoredAttachments.includes(row));
|
||||
await removeAttachmentRows(env.DB, failedRestoreRows);
|
||||
if (replaceExisting && previousBlobKeys.size) {
|
||||
await cleanupOrphanedBlobFiles(env, previousBlobKeys, await collectCurrentBlobKeys(env.DB));
|
||||
await progress?.({
|
||||
source: 'local',
|
||||
step: 'local_restore_files',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_local_files_title',
|
||||
stageDetail: 'txt_backup_restore_progress_local_files_detail',
|
||||
replaceExisting,
|
||||
});
|
||||
const restored = await restoreBlobFiles(env, db, parsed.files);
|
||||
const restoredAttachmentKeys = new Set((restored.restoredAttachments || []).map(attachmentRowKey));
|
||||
const failedRestoreRows = (db.attachments || []).filter((row) => !restoredAttachmentKeys.has(attachmentRowKey(row)));
|
||||
await removeAttachmentRows(env.DB, failedRestoreRows, true).catch(() => undefined);
|
||||
await validateShadowTableCounts(env.DB, {
|
||||
config: (db.config || []).length,
|
||||
users: (db.users || []).length,
|
||||
user_revisions: (db.user_revisions || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: restored.restoredAttachments.length,
|
||||
});
|
||||
await progress?.({
|
||||
source: 'local',
|
||||
step: 'local_finalize',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
|
||||
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
|
||||
replaceExisting,
|
||||
});
|
||||
await swapShadowTablesIntoPlace(env.DB);
|
||||
await resetRestoreArtifacts(env.DB).catch(() => undefined);
|
||||
if (replaceExisting && previousBlobKeys.size) {
|
||||
const nextBlobKeys = await collectCurrentBlobKeys(env.DB).catch(() => null);
|
||||
if (nextBlobKeys) {
|
||||
await cleanupOrphanedBlobFiles(env, previousBlobKeys, nextBlobKeys).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
await progress?.({
|
||||
source: 'local',
|
||||
step: 'local_complete',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
|
||||
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
|
||||
replaceExisting,
|
||||
done: true,
|
||||
ok: true,
|
||||
});
|
||||
return {
|
||||
auditActorUserId: (db.users || []).some((row) => String(row.id || '').trim() === actorUserId) ? actorUserId : null,
|
||||
result: {
|
||||
object: 'instance-backup-import',
|
||||
imported: {
|
||||
config: (db.config || []).length,
|
||||
users: (db.users || []).length,
|
||||
userRevisions: (db.user_revisions || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: restored.restoredAttachments.length,
|
||||
attachmentFiles: restored.imported,
|
||||
},
|
||||
skipped: {
|
||||
reason: restored.skipped.reason || prepared.skipped.reason,
|
||||
attachments: prepared.skipped.attachments + restored.skipped.attachments,
|
||||
items: [...prepared.skipped.items, ...restored.skipped.items],
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await progress?.({
|
||||
source: 'local',
|
||||
step: 'local_failed',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
|
||||
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
|
||||
replaceExisting,
|
||||
done: true,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
await resetRestoreArtifacts(env.DB).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
|
||||
await storage.setRegistered();
|
||||
|
||||
return {
|
||||
auditActorUserId: (db.users || []).some((row) => String(row.id || '').trim() === actorUserId) ? actorUserId : null,
|
||||
result: {
|
||||
object: 'instance-backup-import',
|
||||
imported: {
|
||||
config: (db.config || []).length,
|
||||
users: (db.users || []).length,
|
||||
userRevisions: (db.user_revisions || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: restored.restoredAttachments.length,
|
||||
attachmentFiles: restored.imported,
|
||||
},
|
||||
skipped: {
|
||||
reason: restored.skipped.reason || prepared.skipped.reason,
|
||||
attachments: prepared.skipped.attachments + restored.skipped.attachments,
|
||||
items: [...prepared.skipped.items, ...restored.skipped.items],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function importRemoteBackupArchiveBytes(
|
||||
@@ -489,9 +764,10 @@ export async function importRemoteBackupArchiveBytes(
|
||||
env: Env,
|
||||
actorUserId: string,
|
||||
replaceExisting: boolean,
|
||||
source: RemoteAttachmentSource
|
||||
source: RemoteAttachmentSource,
|
||||
progress?: BackupRestoreProgressReporter,
|
||||
fileName: string = 'nodewarden_backup.zip'
|
||||
): Promise<BackupImportExecutionResult> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const parsed = parseBackupArchive(archiveBytes, { allowExternalAttachmentBlobs: true });
|
||||
const preparedRemote = await prepareRemoteAttachmentPayload(env, parsed.payload, parsed.files, source);
|
||||
validateBackupPayloadContents(preparedRemote.payload, parsed.files, { allowExternalAttachmentBlobs: true });
|
||||
@@ -504,44 +780,122 @@ export async function importRemoteBackupArchiveBytes(
|
||||
}
|
||||
}
|
||||
|
||||
await resetRestoreArtifacts(env.DB);
|
||||
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
|
||||
const { db } = preparedRemote.payload;
|
||||
await importBackupRows(env.DB, db);
|
||||
await normalizeImportedBackupSettings(storage, env, 'UTC');
|
||||
try {
|
||||
await progress?.({
|
||||
source: 'remote',
|
||||
step: 'remote_create_shadow',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_remote_shadow_title',
|
||||
stageDetail: 'txt_backup_restore_progress_remote_shadow_detail',
|
||||
replaceExisting,
|
||||
});
|
||||
await createShadowTables(env.DB);
|
||||
await progress?.({
|
||||
source: 'remote',
|
||||
step: 'remote_import_data',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_remote_data_title',
|
||||
stageDetail: 'txt_backup_restore_progress_remote_data_detail',
|
||||
replaceExisting,
|
||||
});
|
||||
const db = await importPreparedBackupRows(env.DB, preparedRemote.payload.db, env);
|
||||
await validateShadowTableCounts(env.DB, {
|
||||
config: (db.config || []).length,
|
||||
users: (db.users || []).length,
|
||||
user_revisions: (db.user_revisions || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: (db.attachments || []).length,
|
||||
});
|
||||
|
||||
const restored = await restoreRemoteAttachmentFiles(env, preparedRemote.payload, parsed.files, source);
|
||||
const failedRestoreRows = (db.attachments || []).filter((row) => !restored.restoredAttachments.includes(row));
|
||||
await removeAttachmentRows(env.DB, failedRestoreRows);
|
||||
await progress?.({
|
||||
source: 'remote',
|
||||
step: 'remote_restore_files',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_remote_files_title',
|
||||
stageDetail: 'txt_backup_restore_progress_remote_files_detail',
|
||||
replaceExisting,
|
||||
});
|
||||
const restored = await restoreRemoteAttachmentFiles(env, preparedRemote.payload, parsed.files, source);
|
||||
const restoredAttachmentKeys = new Set((restored.restoredAttachments || []).map(attachmentRowKey));
|
||||
const failedRestoreRows = (db.attachments || []).filter((row) => !restoredAttachmentKeys.has(attachmentRowKey(row)));
|
||||
await removeAttachmentRows(env.DB, failedRestoreRows, true).catch(() => undefined);
|
||||
await validateShadowTableCounts(env.DB, {
|
||||
config: (db.config || []).length,
|
||||
users: (db.users || []).length,
|
||||
user_revisions: (db.user_revisions || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: restored.restoredAttachments.length,
|
||||
});
|
||||
await progress?.({
|
||||
source: 'remote',
|
||||
step: 'remote_finalize',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_remote_finalize_title',
|
||||
stageDetail: 'txt_backup_restore_progress_remote_finalize_detail',
|
||||
replaceExisting,
|
||||
});
|
||||
await swapShadowTablesIntoPlace(env.DB);
|
||||
await resetRestoreArtifacts(env.DB).catch(() => undefined);
|
||||
|
||||
if (replaceExisting && previousBlobKeys.size) {
|
||||
await cleanupOrphanedBlobFiles(env, previousBlobKeys, await collectCurrentBlobKeys(env.DB));
|
||||
if (replaceExisting && previousBlobKeys.size) {
|
||||
const nextBlobKeys = await collectCurrentBlobKeys(env.DB).catch(() => null);
|
||||
if (nextBlobKeys) {
|
||||
await cleanupOrphanedBlobFiles(env, previousBlobKeys, nextBlobKeys).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
await progress?.({
|
||||
source: 'remote',
|
||||
step: 'remote_complete',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_remote_finalize_title',
|
||||
stageDetail: 'txt_backup_restore_progress_remote_finalize_detail',
|
||||
replaceExisting,
|
||||
done: true,
|
||||
ok: true,
|
||||
});
|
||||
const finalSkippedItems = [...preparedRemote.skipped.items, ...restored.skipped.items];
|
||||
const finalSkippedReason = finalSkippedItems.length
|
||||
? restored.skipped.reason || preparedRemote.skipped.reason
|
||||
: null;
|
||||
|
||||
return {
|
||||
auditActorUserId: (db.users || []).some((row) => String(row.id || '').trim() === actorUserId) ? actorUserId : null,
|
||||
result: {
|
||||
object: 'instance-backup-import',
|
||||
imported: {
|
||||
config: (db.config || []).length,
|
||||
users: (db.users || []).length,
|
||||
userRevisions: (db.user_revisions || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: restored.restoredAttachments.length,
|
||||
attachmentFiles: restored.imported,
|
||||
},
|
||||
skipped: {
|
||||
reason: finalSkippedReason,
|
||||
attachments: finalSkippedItems.length,
|
||||
items: finalSkippedItems,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await progress?.({
|
||||
source: 'remote',
|
||||
step: 'remote_failed',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_remote_finalize_title',
|
||||
stageDetail: 'txt_backup_restore_progress_remote_finalize_detail',
|
||||
replaceExisting,
|
||||
done: true,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
await resetRestoreArtifacts(env.DB).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
|
||||
await storage.setRegistered();
|
||||
|
||||
const finalSkippedItems = [...preparedRemote.skipped.items, ...restored.skipped.items];
|
||||
const finalSkippedReason = finalSkippedItems.length
|
||||
? restored.skipped.reason || preparedRemote.skipped.reason
|
||||
: null;
|
||||
|
||||
return {
|
||||
auditActorUserId: (db.users || []).some((row) => String(row.id || '').trim() === actorUserId) ? actorUserId : null,
|
||||
result: {
|
||||
object: 'instance-backup-import',
|
||||
imported: {
|
||||
config: (db.config || []).length,
|
||||
users: (db.users || []).length,
|
||||
userRevisions: (db.user_revisions || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: restored.restoredAttachments.length,
|
||||
attachmentFiles: restored.imported,
|
||||
},
|
||||
skipped: {
|
||||
reason: finalSkippedReason,
|
||||
attachments: finalSkippedItems.length,
|
||||
items: finalSkippedItems,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -250,18 +250,49 @@ async function ensureWebDavDirectory(baseUrl: string, directoryPath: string, aut
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureWebDavDirectoryCached(
|
||||
baseUrl: string,
|
||||
directoryPath: string,
|
||||
authHeader: string,
|
||||
ensuredDirectories: Set<string>
|
||||
): Promise<void> {
|
||||
const segments = trimSlashes(directoryPath).split('/').filter(Boolean);
|
||||
let current = '';
|
||||
for (const segment of segments) {
|
||||
current = buildJoinedPath(current, segment);
|
||||
if (ensuredDirectories.has(current)) continue;
|
||||
const url = buildWebDavUrl(baseUrl, current);
|
||||
const response = await fetch(url, {
|
||||
method: 'MKCOL',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
});
|
||||
if ([200, 201, 204, 301, 302, 405].includes(response.status)) {
|
||||
ensuredDirectories.add(current);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`WebDAV directory creation failed: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function putToWebDav(
|
||||
config: WebDavBackupDestination,
|
||||
relativePath: string,
|
||||
bytes: Uint8Array,
|
||||
options: RemoteBackupFilePutOptions = {}
|
||||
options: RemoteBackupFilePutOptions = {},
|
||||
ensuredDirectories?: Set<string>
|
||||
): Promise<void> {
|
||||
const authHeader = toBasicAuthHeader(config.username, config.password);
|
||||
const remoteFilePath = buildJoinedPath(config.remotePath, relativePath);
|
||||
const remoteDir = parentPath(remoteFilePath);
|
||||
|
||||
if (remoteDir) {
|
||||
await ensureWebDavDirectory(config.baseUrl, remoteDir, authHeader);
|
||||
if (ensuredDirectories) {
|
||||
await ensureWebDavDirectoryCached(config.baseUrl, remoteDir, authHeader, ensuredDirectories);
|
||||
} else {
|
||||
await ensureWebDavDirectory(config.baseUrl, remoteDir, authHeader);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(buildWebDavUrl(config.baseUrl, remoteFilePath), {
|
||||
@@ -608,6 +639,16 @@ interface ConfiguredDestinationAdapter {
|
||||
exists: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface RemoteBackupTransferSession {
|
||||
provider: BackupDestinationType;
|
||||
uploadArchive(archive: Uint8Array, fileName: string): Promise<BackupUploadResult>;
|
||||
putFile(relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions): Promise<void>;
|
||||
list(relativePath: string): Promise<RemoteBackupListResult>;
|
||||
download(relativePath: string): Promise<RemoteBackupFile>;
|
||||
deleteFile(relativePath: string): Promise<void>;
|
||||
exists(relativePath: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
function resolveConfiguredDestinationAdapter(
|
||||
destination: BackupDestinationRecord
|
||||
): ConfiguredDestinationAdapter {
|
||||
@@ -641,35 +682,62 @@ function resolveConfiguredDestinationAdapter(
|
||||
throw new Error('Unsupported backup destination type');
|
||||
}
|
||||
|
||||
export function createRemoteBackupTransferSession(destination: BackupDestinationRecord): RemoteBackupTransferSession {
|
||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
||||
const ensuredDirectories = adapter.provider === 'webdav' ? new Set<string>() : null;
|
||||
|
||||
const putFile = async (relativePath: string, bytes: Uint8Array, options: RemoteBackupFilePutOptions = {}): Promise<void> => {
|
||||
const normalized = normalizeRelativePath(relativePath);
|
||||
if (adapter.provider === 'webdav' && ensuredDirectories) {
|
||||
await putToWebDav(adapter.config as WebDavBackupDestination, normalized, bytes, options, ensuredDirectories);
|
||||
return;
|
||||
}
|
||||
await adapter.putFile(adapter.config, normalized, bytes, options);
|
||||
};
|
||||
|
||||
return {
|
||||
provider: adapter.provider,
|
||||
uploadArchive: async (archive: Uint8Array, fileName: string) => {
|
||||
await putFile(fileName, archive, { contentType: 'application/zip' });
|
||||
return {
|
||||
provider: adapter.provider,
|
||||
remotePath: adapter.provider === 'webdav'
|
||||
? buildJoinedPath((adapter.config as WebDavBackupDestination).remotePath, fileName)
|
||||
: normalizeE3ObjectKey(adapter.config as E3BackupDestination, fileName),
|
||||
};
|
||||
},
|
||||
putFile,
|
||||
list: async (relativePath: string) => adapter.list(adapter.config, relativePath),
|
||||
download: async (relativePath: string) => adapter.download(adapter.config, relativePath),
|
||||
deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)),
|
||||
exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function uploadBackupArchive(
|
||||
destination: BackupDestinationRecord,
|
||||
archive: Uint8Array,
|
||||
fileName: string
|
||||
): Promise<BackupUploadResult> {
|
||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
||||
return adapter.upload(adapter.config, archive, fileName);
|
||||
return createRemoteBackupTransferSession(destination).uploadArchive(archive, fileName);
|
||||
}
|
||||
|
||||
export async function listRemoteBackupEntries(destination: BackupDestinationRecord, relativePath: string): Promise<RemoteBackupListResult> {
|
||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
||||
return adapter.list(adapter.config, relativePath);
|
||||
return createRemoteBackupTransferSession(destination).list(relativePath);
|
||||
}
|
||||
|
||||
export async function downloadRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise<RemoteBackupFile> {
|
||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
||||
return adapter.download(adapter.config, relativePath);
|
||||
return createRemoteBackupTransferSession(destination).download(relativePath);
|
||||
}
|
||||
|
||||
export async function deleteRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise<void> {
|
||||
const normalized = ensureRemoteRestoreCandidate(relativePath);
|
||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
||||
await adapter.deleteFile(adapter.config, normalized);
|
||||
await createRemoteBackupTransferSession(destination).deleteFile(normalized);
|
||||
}
|
||||
|
||||
export async function remoteBackupFileExists(destination: BackupDestinationRecord, relativePath: string): Promise<boolean> {
|
||||
const normalized = normalizeRelativePath(relativePath);
|
||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
||||
return adapter.exists(adapter.config, normalized);
|
||||
return createRemoteBackupTransferSession(destination).exists(normalized);
|
||||
}
|
||||
|
||||
export async function uploadRemoteBackupFile(
|
||||
@@ -679,8 +747,7 @@ export async function uploadRemoteBackupFile(
|
||||
options: RemoteBackupFilePutOptions = {}
|
||||
): Promise<void> {
|
||||
const normalized = normalizeRelativePath(relativePath);
|
||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
||||
await adapter.putFile(adapter.config, normalized, bytes, options);
|
||||
await createRemoteBackupTransferSession(destination).putFile(normalized, bytes, options);
|
||||
}
|
||||
|
||||
function compareBackupItemsByRecency(a: RemoteBackupItem, b: RemoteBackupItem, preferredFileName?: string): number {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { Cipher } from '../types';
|
||||
|
||||
function normalizeOptionalId(value: unknown): string | null {
|
||||
if (value == null) return null;
|
||||
const normalized = String(value).trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
|
||||
type SqlChunkSize = (fixedBindCount: number) => number;
|
||||
type UpdateRevisionDate = (userId: string) => Promise<string>;
|
||||
@@ -25,12 +31,13 @@ function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
|
||||
if (!row?.data) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(row.data) as Cipher;
|
||||
const folderId = normalizeOptionalId(row.folder_id ?? parsed.folderId ?? null);
|
||||
return {
|
||||
...parsed,
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
type: Number(row.type) || Number(parsed.type) || 1,
|
||||
folderId: row.folder_id ?? parsed.folderId ?? null,
|
||||
folderId,
|
||||
name: row.name ?? parsed.name ?? null,
|
||||
notes: row.notes ?? parsed.notes ?? null,
|
||||
favorite: row.favorite != null ? !!row.favorite : !!parsed.favorite,
|
||||
@@ -60,7 +67,11 @@ export async function getCipher(db: D1Database, id: string): Promise<Cipher | nu
|
||||
}
|
||||
|
||||
export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> {
|
||||
const data = JSON.stringify(cipher);
|
||||
const folderId = normalizeOptionalId(cipher.folderId);
|
||||
const data = JSON.stringify({
|
||||
...cipher,
|
||||
folderId,
|
||||
});
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
@@ -72,7 +83,7 @@ export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cip
|
||||
cipher.id,
|
||||
cipher.userId,
|
||||
Number(cipher.type) || 1,
|
||||
cipher.folderId,
|
||||
folderId,
|
||||
cipher.name,
|
||||
cipher.notes,
|
||||
cipher.favorite ? 1 : 0,
|
||||
@@ -249,8 +260,9 @@ export async function bulkMoveCiphers(
|
||||
): Promise<string | null> {
|
||||
if (ids.length === 0) return null;
|
||||
const now = new Date().toISOString();
|
||||
const normalizedFolderId = normalizeOptionalId(folderId);
|
||||
const uniqueIds = sanitizeIds(ids);
|
||||
const patch = JSON.stringify({ folderId, updatedAt: now });
|
||||
const patch = JSON.stringify({ folderId: normalizedFolderId, updatedAt: now });
|
||||
const chunkSize = sqlChunkSize(4);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
@@ -262,7 +274,7 @@ export async function bulkMoveCiphers(
|
||||
SET folder_id = ?, updated_at = ?, data = json_patch(data, ?)
|
||||
WHERE user_id = ? AND id IN (${placeholders})`
|
||||
)
|
||||
.bind(folderId, now, patch, userId, ...chunk)
|
||||
.bind(normalizedFolderId, now, patch, userId, ...chunk)
|
||||
.run();
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted_updated ON ciphers(user_id, deleted_at, updated_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS folders (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
@@ -47,6 +48,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_sends_user_updated_id ON sends(user_id, updated_at, id)',
|
||||
'ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2',
|
||||
'ALTER TABLE sends ADD COLUMN emails TEXT',
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ import {
|
||||
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
||||
const STORAGE_SCHEMA_VERSION = '2026-03-23.1';
|
||||
const STORAGE_SCHEMA_VERSION = '2026-03-30.1';
|
||||
|
||||
// D1-backed storage.
|
||||
// Contract:
|
||||
|
||||
+6
-1
@@ -347,7 +347,8 @@ export interface TokenResponse {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
refresh_token: string;
|
||||
refresh_token?: string;
|
||||
web_session?: boolean;
|
||||
TwoFactorToken?: string;
|
||||
Key: string;
|
||||
PrivateKey: string | null;
|
||||
@@ -367,6 +368,10 @@ export interface TokenResponse {
|
||||
accountKeys?: any | null;
|
||||
UserDecryptionOptions: UserDecryptionOptions;
|
||||
userDecryptionOptions?: UserDecryptionOptions;
|
||||
VaultKeys?: {
|
||||
symEncKey: string;
|
||||
symMacKey: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProfileResponse {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export function bytesToBase64Url(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (const b of bytes) binary += String.fromCharCode(b);
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
export function base64UrlToBytes(input: string): Uint8Array {
|
||||
const normalized = String(input || '').replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = normalized + '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
|
||||
const binary = atob(padded);
|
||||
const out = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function randomChallenge(size: number = 32): string {
|
||||
return bytesToBase64Url(crypto.getRandomValues(new Uint8Array(size)));
|
||||
}
|
||||
|
||||
export function parseClientDataJSON(base64Url: string): { type?: string; challenge?: string; origin?: string } | null {
|
||||
try {
|
||||
const raw = base64UrlToBytes(base64Url);
|
||||
const text = new TextDecoder().decode(raw);
|
||||
const parsed = JSON.parse(text) as { type?: string; challenge?: string; origin?: string };
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+39
-8
@@ -15,12 +15,42 @@ const DEFAULT_CORS_HEADERS = [
|
||||
'X-Request-Email',
|
||||
'X-Device-Identifier',
|
||||
'X-Device-Name',
|
||||
'X-NodeWarden-Web-Session',
|
||||
];
|
||||
|
||||
function getAllowedOrigin(request: Request): string | null {
|
||||
function isExtensionOrigin(origin: string): boolean {
|
||||
return (
|
||||
origin.startsWith('chrome-extension://')
|
||||
|| origin.startsWith('moz-extension://')
|
||||
|| origin.startsWith('safari-web-extension://')
|
||||
);
|
||||
}
|
||||
|
||||
function isWildcardCorsPath(path: string): boolean {
|
||||
return (
|
||||
path.startsWith('/icons/')
|
||||
|| path === '/config'
|
||||
|| path === '/api/config'
|
||||
|| path === '/api/version'
|
||||
);
|
||||
}
|
||||
|
||||
function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCredentials: boolean } {
|
||||
const url = new URL(request.url);
|
||||
const origin = request.headers.get('Origin');
|
||||
if (!origin) return '*';
|
||||
return origin;
|
||||
if (isWildcardCorsPath(url.pathname)) {
|
||||
return { allowOrigin: '*', allowCredentials: false };
|
||||
}
|
||||
if (!origin) {
|
||||
return { allowOrigin: null, allowCredentials: false };
|
||||
}
|
||||
if (origin === url.origin) {
|
||||
return { allowOrigin: origin, allowCredentials: true };
|
||||
}
|
||||
if (isExtensionOrigin(origin)) {
|
||||
return { allowOrigin: origin, allowCredentials: false };
|
||||
}
|
||||
return { allowOrigin: null, allowCredentials: false };
|
||||
}
|
||||
|
||||
function buildCorsHeaders(request: Request): Record<string, string> {
|
||||
@@ -35,13 +65,14 @@ function buildCorsHeaders(request: Request): Record<string, string> {
|
||||
'Access-Control-Allow-Headers': allowHeaders.join(', '),
|
||||
'Access-Control-Expose-Headers': '*',
|
||||
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
|
||||
'Access-Control-Allow-Private-Network': 'true',
|
||||
};
|
||||
|
||||
const allowedOrigin = getAllowedOrigin(request);
|
||||
if (allowedOrigin) {
|
||||
headers['Access-Control-Allow-Origin'] = allowedOrigin;
|
||||
headers['Access-Control-Allow-Credentials'] = 'true';
|
||||
const corsPolicy = getCorsPolicy(request);
|
||||
if (corsPolicy.allowOrigin) {
|
||||
headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin;
|
||||
if (corsPolicy.allowCredentials) {
|
||||
headers['Access-Control-Allow-Credentials'] = 'true';
|
||||
}
|
||||
headers['Vary'] = 'Origin, Access-Control-Request-Headers';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { User, UserDecryptionOptions } from '../types';
|
||||
|
||||
function normalizeOptionalPublicKey(value: unknown): string {
|
||||
if (value == null) return '';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function buildAccountKeys(user: Pick<User, 'privateKey' | 'publicKey'>): Record<string, unknown> | null {
|
||||
if (!user.privateKey || !user.publicKey) {
|
||||
if (!user.privateKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const publicKey = normalizeOptionalPublicKey(user.publicKey);
|
||||
|
||||
return {
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: user.privateKey,
|
||||
publicKey: user.publicKey,
|
||||
publicKey,
|
||||
Object: 'publicKeyEncryptionKeyPair',
|
||||
},
|
||||
Object: 'privateKeys',
|
||||
|
||||
+125
-9
@@ -10,8 +10,12 @@ import JwtWarningPage from '@/components/JwtWarningPage';
|
||||
import {
|
||||
createAuthedFetch,
|
||||
getAuthorizedDevices,
|
||||
clearProfileSnapshot,
|
||||
getCurrentDeviceIdentifier,
|
||||
getPasswordHint,
|
||||
loadProfileSnapshot,
|
||||
saveProfileSnapshot,
|
||||
revokeCurrentSession,
|
||||
getTotpStatus,
|
||||
saveSession,
|
||||
} from '@/lib/api/auth';
|
||||
@@ -39,6 +43,7 @@ import {
|
||||
performRecoverTwoFactorLogin,
|
||||
performRegistration,
|
||||
performTotpLogin,
|
||||
hydrateLockedSession,
|
||||
performUnlock,
|
||||
type JwtUnsafeReason,
|
||||
type PendingTotp,
|
||||
@@ -50,8 +55,20 @@ import useVaultSendActions from '@/hooks/useVaultSendActions';
|
||||
import { useToastManager } from '@/hooks/useToastManager';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
|
||||
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
|
||||
import type { AppPhase, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
|
||||
|
||||
function isBackupProgressDetail(value: unknown): value is BackupProgressDetail {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const detail = value as Record<string, unknown>;
|
||||
const operation = detail.operation;
|
||||
return (
|
||||
(operation === 'backup-restore' || operation === 'backup-export' || operation === 'backup-remote-run')
|
||||
&& typeof detail.step === 'string'
|
||||
&& typeof detail.fileName === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
const IMPORT_ROUTE = '/backup/import-export';
|
||||
const IMPORT_ROUTE_PATHS = [IMPORT_ROUTE, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const;
|
||||
const IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE));
|
||||
@@ -62,6 +79,7 @@ const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e);
|
||||
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
||||
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
||||
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
|
||||
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
|
||||
|
||||
type ThemePreference = 'system' | 'light' | 'dark';
|
||||
const MAGNETIC_SELECTOR = '.topbar .btn, .topbar .user-chip, .side-link, .mobile-tab';
|
||||
@@ -122,11 +140,12 @@ function resolveSystemTheme(): 'light' | 'dark' {
|
||||
export default function App() {
|
||||
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
|
||||
const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []);
|
||||
const initialProfileSnapshot = useMemo(() => loadProfileSnapshot(initialBootstrap.session?.email), [initialBootstrap]);
|
||||
const [pendingAuthAction, setPendingAuthAction] = useState<'login' | 'register' | 'unlock' | null>(null);
|
||||
const [location, navigate] = useLocation();
|
||||
const [phase, setPhase] = useState<AppPhase>(initialBootstrap.phase);
|
||||
const [session, setSessionState] = useState<SessionState | null>(initialBootstrap.session);
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [profile, setProfile] = useState<Profile | null>(initialProfileSnapshot);
|
||||
const [defaultKdfIterations, setDefaultKdfIterations] = useState(initialBootstrap.defaultKdfIterations);
|
||||
const [jwtWarning, setJwtWarning] = useState<{ reason: JwtUnsafeReason; minLength: number } | null>(initialBootstrap.jwtWarning);
|
||||
|
||||
@@ -153,12 +172,15 @@ export default function App() {
|
||||
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [rememberDevice, setRememberDevice] = useState(true);
|
||||
const [totpSubmitting, setTotpSubmitting] = useState(false);
|
||||
|
||||
const [disableTotpOpen, setDisableTotpOpen] = useState(false);
|
||||
const [disableTotpPassword, setDisableTotpPassword] = useState('');
|
||||
const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false);
|
||||
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
|
||||
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
|
||||
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme());
|
||||
const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialProfileSnapshot?.key);
|
||||
|
||||
const [confirm, setConfirm] = useState<AppConfirmState | null>(null);
|
||||
const [mobileLayout, setMobileLayout] = useState(false);
|
||||
@@ -260,6 +282,16 @@ export default function App() {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, themePreference);
|
||||
}, [themePreference]);
|
||||
|
||||
useEffect(() => {
|
||||
saveProfileSnapshot(profile);
|
||||
}, [profile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === 'locked' && profile?.key && session) {
|
||||
setUnlockPreparing(false);
|
||||
}
|
||||
}, [phase, profile, session]);
|
||||
|
||||
useEffect(() => installMagneticUiFeedback(), []);
|
||||
|
||||
function handleToggleTheme() {
|
||||
@@ -321,6 +353,7 @@ export default function App() {
|
||||
setSession(boot.session);
|
||||
setProfile(boot.profile);
|
||||
setPhase(boot.phase);
|
||||
setUnlockPreparing(boot.phase === 'locked' && !boot.profile?.key);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
@@ -328,9 +361,34 @@ export default function App() {
|
||||
};
|
||||
}, [initialBootstrap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== 'locked' || !session) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const result = await hydrateLockedSession(session, profile);
|
||||
if (cancelled) return;
|
||||
if (!result.session) {
|
||||
setSession(null);
|
||||
setProfile(null);
|
||||
setUnlockPreparing(false);
|
||||
setPhase('login');
|
||||
if (location !== '/login') navigate('/login');
|
||||
return;
|
||||
}
|
||||
setSession(result.session);
|
||||
if (result.profile) {
|
||||
setProfile(result.profile);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [phase, session?.email, location, navigate]);
|
||||
|
||||
async function finalizeLogin(login: CompletedLogin) {
|
||||
setSession(login.session);
|
||||
setProfile(login.profile);
|
||||
setUnlockPreparing(false);
|
||||
setPendingTotp(null);
|
||||
setTotpCode('');
|
||||
setPhase('app');
|
||||
@@ -377,16 +435,20 @@ export default function App() {
|
||||
}
|
||||
|
||||
async function handleTotpVerify() {
|
||||
if (totpSubmitting) return;
|
||||
if (!pendingTotp) return;
|
||||
if (!totpCode.trim()) {
|
||||
pushToast('error', t('txt_please_input_totp_code'));
|
||||
return;
|
||||
}
|
||||
setTotpSubmitting(true);
|
||||
try {
|
||||
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
|
||||
await finalizeLogin(login);
|
||||
} catch (error) {
|
||||
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
|
||||
} finally {
|
||||
setTotpSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,6 +577,7 @@ export default function App() {
|
||||
const nextSession = await performUnlock(session, profile, unlockPassword, defaultKdfIterations);
|
||||
setSession(nextSession);
|
||||
setUnlockPassword('');
|
||||
setUnlockPreparing(false);
|
||||
setPhase('app');
|
||||
if (location === '/' || location === '/lock') navigate('/vault');
|
||||
pushToast('success', t('txt_unlocked'));
|
||||
@@ -531,14 +594,18 @@ export default function App() {
|
||||
delete nextSession.symEncKey;
|
||||
delete nextSession.symMacKey;
|
||||
setSession(nextSession);
|
||||
setUnlockPreparing(false);
|
||||
setPhase('locked');
|
||||
navigate('/lock');
|
||||
}
|
||||
|
||||
function logoutNow() {
|
||||
void revokeCurrentSession(sessionRef.current);
|
||||
setConfirm(null);
|
||||
setSession(null);
|
||||
clearProfileSnapshot();
|
||||
setProfile(null);
|
||||
setUnlockPreparing(false);
|
||||
setPendingTotp(null);
|
||||
setPhase('login');
|
||||
navigate('/login');
|
||||
@@ -570,11 +637,13 @@ export default function App() {
|
||||
onConfirmTotp={() => {}}
|
||||
onCancelTotp={() => {}}
|
||||
onUseRecoveryCode={() => {}}
|
||||
totpSubmitting={false}
|
||||
disableTotpOpen={false}
|
||||
disableTotpPassword=""
|
||||
onDisableTotpPasswordChange={() => {}}
|
||||
onConfirmDisableTotp={() => {}}
|
||||
onCancelDisableTotp={() => {}}
|
||||
disableTotpSubmitting={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -689,9 +758,6 @@ export default function App() {
|
||||
decUsername: await decryptField(cipher.login.username || '', itemEnc, itemMac),
|
||||
decPassword: await decryptField(cipher.login.password || '', itemEnc, itemMac),
|
||||
decTotp: await decryptField(cipher.login.totp || '', itemEnc, itemMac),
|
||||
fido2Credentials: Array.isArray(cipher.login.fido2Credentials)
|
||||
? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
|
||||
: null,
|
||||
uris: await Promise.all(
|
||||
(cipher.login.uris || []).map(async (u) => ({
|
||||
...u,
|
||||
@@ -872,9 +938,11 @@ export default function App() {
|
||||
|
||||
const connect = () => {
|
||||
if (disposed) return;
|
||||
const accessToken = session.accessToken;
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
const hubUrl = new URL('/notifications/hub', window.location.origin);
|
||||
hubUrl.searchParams.set('access_token', session.accessToken);
|
||||
hubUrl.searchParams.set('access_token', accessToken);
|
||||
hubUrl.protocol = hubUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
socket = new WebSocket(hubUrl.toString());
|
||||
} catch {
|
||||
@@ -882,6 +950,15 @@ export default function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
let pingTimer: number | null = null;
|
||||
|
||||
const clearPingTimer = () => {
|
||||
if (pingTimer !== null) {
|
||||
window.clearInterval(pingTimer);
|
||||
pingTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
reconnectAttempts = 0;
|
||||
void refreshAuthorizedDevicesRef.current();
|
||||
@@ -889,7 +966,16 @@ export default function App() {
|
||||
socket?.send(`{"protocol":"json","version":1}${SIGNALR_RECORD_SEPARATOR}`);
|
||||
} catch {
|
||||
socket?.close();
|
||||
return;
|
||||
}
|
||||
clearPingTimer();
|
||||
pingTimer = window.setInterval(() => {
|
||||
try {
|
||||
socket?.send(`{"type":6}${SIGNALR_RECORD_SEPARATOR}`);
|
||||
} catch {
|
||||
// send failure will trigger close event
|
||||
}
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
@@ -908,6 +994,11 @@ export default function App() {
|
||||
void refreshAuthorizedDevicesRef.current();
|
||||
continue;
|
||||
}
|
||||
if (updateType === SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS) {
|
||||
const payload = frame.arguments?.[0]?.Payload;
|
||||
if (isBackupProgressDetail(payload)) dispatchBackupProgress(payload);
|
||||
continue;
|
||||
}
|
||||
if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue;
|
||||
const contextId = String(frame.arguments?.[0]?.ContextId || '').trim();
|
||||
if (contextId && contextId === getCurrentDeviceIdentifier()) continue;
|
||||
@@ -917,6 +1008,7 @@ export default function App() {
|
||||
|
||||
socket.addEventListener('close', () => {
|
||||
socket = null;
|
||||
clearPingTimer();
|
||||
void refreshAuthorizedDevicesRef.current();
|
||||
scheduleReconnect();
|
||||
});
|
||||
@@ -935,9 +1027,11 @@ export default function App() {
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearReconnectTimer();
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
if (socket) {
|
||||
const s = socket;
|
||||
socket = null;
|
||||
try {
|
||||
socket.close();
|
||||
s.close();
|
||||
} catch {
|
||||
// ignore close races
|
||||
}
|
||||
@@ -1078,6 +1172,7 @@ export default function App() {
|
||||
onBulkMoveVaultItems: vaultSendActions.bulkMoveVaultItems,
|
||||
onVerifyMasterPassword: vaultSendActions.verifyMasterPassword,
|
||||
onCreateFolder: vaultSendActions.createFolder,
|
||||
onRenameFolder: vaultSendActions.renameFolder,
|
||||
onDeleteFolder: vaultSendActions.deleteFolder,
|
||||
onBulkDeleteFolders: vaultSendActions.bulkDeleteFolders,
|
||||
onDownloadVaultAttachment: vaultSendActions.downloadVaultAttachment,
|
||||
@@ -1113,13 +1208,16 @@ export default function App() {
|
||||
onRevokeInvite: adminActions.revokeInvite,
|
||||
onExportBackup: backupActions.exportBackup,
|
||||
onImportBackup: backupActions.importBackup,
|
||||
onImportBackupAllowingChecksumMismatch: backupActions.importBackupAllowingChecksumMismatch,
|
||||
onLoadBackupSettings: backupActions.loadSettings,
|
||||
onSaveBackupSettings: backupActions.saveSettings,
|
||||
onRunRemoteBackup: backupActions.runRemoteBackup,
|
||||
onListRemoteBackups: backupActions.listRemoteBackups,
|
||||
onDownloadRemoteBackup: backupActions.downloadRemoteBackup,
|
||||
onInspectRemoteBackup: backupActions.inspectRemoteBackup,
|
||||
onDeleteRemoteBackup: backupActions.deleteRemoteBackup,
|
||||
onRestoreRemoteBackup: backupActions.restoreRemoteBackup,
|
||||
onRestoreRemoteBackupAllowingChecksumMismatch: backupActions.restoreRemoteBackupAllowingChecksumMismatch,
|
||||
};
|
||||
|
||||
if (jwtWarning) {
|
||||
@@ -1158,7 +1256,8 @@ export default function App() {
|
||||
<AuthViews
|
||||
mode={phase}
|
||||
pendingAction={pendingAuthAction}
|
||||
unlockReady={!!profile}
|
||||
unlockReady={!!profile?.key && !!session}
|
||||
unlockPreparing={unlockPreparing}
|
||||
loginValues={loginValues}
|
||||
registerValues={registerValues}
|
||||
unlockPassword={unlockPassword}
|
||||
@@ -1197,21 +1296,25 @@ export default function App() {
|
||||
onRememberDeviceChange={setRememberDevice}
|
||||
onConfirmTotp={() => void handleTotpVerify()}
|
||||
onCancelTotp={() => {
|
||||
if (totpSubmitting) return;
|
||||
setPendingTotp(null);
|
||||
setTotpCode('');
|
||||
setRememberDevice(true);
|
||||
}}
|
||||
onUseRecoveryCode={() => {
|
||||
if (totpSubmitting) return;
|
||||
setPendingTotp(null);
|
||||
setTotpCode('');
|
||||
setRememberDevice(true);
|
||||
navigate('/recover-2fa');
|
||||
}}
|
||||
totpSubmitting={totpSubmitting}
|
||||
disableTotpOpen={false}
|
||||
disableTotpPassword=""
|
||||
onDisableTotpPasswordChange={() => {}}
|
||||
onConfirmDisableTotp={() => {}}
|
||||
onCancelDisableTotp={() => {}}
|
||||
disableTotpSubmitting={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -1250,14 +1353,27 @@ export default function App() {
|
||||
onConfirmTotp={() => {}}
|
||||
onCancelTotp={() => {}}
|
||||
onUseRecoveryCode={() => {}}
|
||||
totpSubmitting={false}
|
||||
disableTotpOpen={disableTotpOpen}
|
||||
disableTotpPassword={disableTotpPassword}
|
||||
onDisableTotpPasswordChange={setDisableTotpPassword}
|
||||
onConfirmDisableTotp={() => void accountSecurityActions.disableTotp()}
|
||||
onConfirmDisableTotp={() => {
|
||||
if (disableTotpSubmitting) return;
|
||||
void (async () => {
|
||||
setDisableTotpSubmitting(true);
|
||||
try {
|
||||
await accountSecurityActions.disableTotp();
|
||||
} finally {
|
||||
setDisableTotpSubmitting(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
onCancelDisableTotp={() => {
|
||||
if (disableTotpSubmitting) return;
|
||||
setDisableTotpOpen(false);
|
||||
setDisableTotpPassword('');
|
||||
}}
|
||||
disableTotpSubmitting={disableTotpSubmitting}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -40,6 +40,12 @@ export default function AdminPage(props: AdminPageProps) {
|
||||
return status || '-';
|
||||
};
|
||||
|
||||
const normalizeToggleableStatus = (status: string): 'active' | 'banned' | null => {
|
||||
const normalized = String(status || '').toLowerCase();
|
||||
if (normalized === 'active' || normalized === 'banned') return normalized;
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<section className="card">
|
||||
@@ -55,8 +61,10 @@ export default function AdminPage(props: AdminPageProps) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{props.users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
{props.users.map((user) => {
|
||||
const toggleableStatus = normalizeToggleableStatus(user.status);
|
||||
return (
|
||||
<tr key={user.id}>
|
||||
<td data-label={t('txt_email')}>{user.email}</td>
|
||||
<td data-label={t('txt_name')}>{user.name || t('txt_dash')}</td>
|
||||
<td data-label={t('txt_role')}>{roleText(user.role)}</td>
|
||||
@@ -66,8 +74,11 @@ export default function AdminPage(props: AdminPageProps) {
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={user.id === props.currentUserId}
|
||||
onClick={() => void props.onToggleUserStatus(user.id, user.status)}
|
||||
disabled={user.id === props.currentUserId || !toggleableStatus}
|
||||
onClick={() => {
|
||||
if (!toggleableStatus) return;
|
||||
void props.onToggleUserStatus(user.id, toggleableStatus);
|
||||
}}
|
||||
>
|
||||
{user.status === 'active' ? <UserX size={14} className="btn-icon" /> : <UserCheck size={14} className="btn-icon" />}
|
||||
{user.status === 'active' ? t('txt_ban') : t('txt_unban')}
|
||||
@@ -80,8 +91,9 @@ export default function AdminPage(props: AdminPageProps) {
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
@@ -27,11 +27,13 @@ interface AppGlobalOverlaysProps {
|
||||
onConfirmTotp: () => void;
|
||||
onCancelTotp: () => void;
|
||||
onUseRecoveryCode: () => void;
|
||||
totpSubmitting: boolean;
|
||||
disableTotpOpen: boolean;
|
||||
disableTotpPassword: string;
|
||||
onDisableTotpPasswordChange: (value: string) => void;
|
||||
onConfirmDisableTotp: () => void;
|
||||
onCancelDisableTotp: () => void;
|
||||
disableTotpSubmitting: boolean;
|
||||
}
|
||||
|
||||
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
@@ -57,12 +59,14 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
confirmText={t('txt_verify')}
|
||||
cancelText={t('txt_cancel')}
|
||||
showIcon={false}
|
||||
confirmDisabled={props.totpSubmitting}
|
||||
cancelDisabled={props.totpSubmitting}
|
||||
onConfirm={props.onConfirmTotp}
|
||||
onCancel={props.onCancelTotp}
|
||||
afterActions={(
|
||||
<div className="dialog-extra">
|
||||
<div className="dialog-divider" />
|
||||
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onUseRecoveryCode}>
|
||||
<button type="button" className="btn btn-secondary dialog-btn" disabled={props.totpSubmitting} onClick={props.onUseRecoveryCode}>
|
||||
{t('txt_use_recovery_code')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -86,6 +90,8 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
cancelText={t('txt_cancel')}
|
||||
danger
|
||||
showIcon={false}
|
||||
confirmDisabled={props.disableTotpSubmitting}
|
||||
cancelDisabled={props.disableTotpSubmitting}
|
||||
onConfirm={props.onConfirmDisableTotp}
|
||||
onCancel={props.onCancelDisableTotp}
|
||||
>
|
||||
|
||||
@@ -74,6 +74,7 @@ export interface AppMainRoutesProps {
|
||||
onBulkMoveVaultItems: (ids: string[], folderId: string | null) => Promise<void>;
|
||||
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
|
||||
onCreateFolder: (name: string) => Promise<void>;
|
||||
onRenameFolder: (folderId: string, name: string) => Promise<void>;
|
||||
onDeleteFolder: (folderId: string) => Promise<void>;
|
||||
onBulkDeleteFolders: (folderIds: string[]) => Promise<void>;
|
||||
onDownloadVaultAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
|
||||
@@ -106,13 +107,16 @@ export interface AppMainRoutesProps {
|
||||
onRevokeInvite: (code: string) => Promise<void>;
|
||||
onExportBackup: (includeAttachments?: boolean) => Promise<void>;
|
||||
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onImportBackupAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onLoadBackupSettings: () => Promise<AdminBackupSettings>;
|
||||
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
||||
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>;
|
||||
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
||||
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
}
|
||||
|
||||
export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
@@ -189,6 +193,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
onVerifyMasterPassword={props.onVerifyMasterPassword}
|
||||
onNotify={props.onNotify}
|
||||
onCreateFolder={props.onCreateFolder}
|
||||
onRenameFolder={props.onRenameFolder}
|
||||
onDeleteFolder={props.onDeleteFolder}
|
||||
onBulkDeleteFolders={props.onBulkDeleteFolders}
|
||||
onDownloadAttachment={props.onDownloadVaultAttachment}
|
||||
@@ -333,11 +338,14 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
currentUserId={props.profile?.id || null}
|
||||
onExport={props.onExportBackup}
|
||||
onImport={props.onImportBackup}
|
||||
onImportAllowingChecksumMismatch={props.onImportBackupAllowingChecksumMismatch}
|
||||
onLoadSettings={props.onLoadBackupSettings}
|
||||
onListRemoteBackups={props.onListRemoteBackups}
|
||||
onDownloadRemoteBackup={props.onDownloadRemoteBackup}
|
||||
onInspectRemoteBackup={props.onInspectRemoteBackup}
|
||||
onDeleteRemoteBackup={props.onDeleteRemoteBackup}
|
||||
onRestoreRemoteBackup={props.onRestoreRemoteBackup}
|
||||
onRestoreRemoteBackupAllowingChecksumMismatch={props.onRestoreRemoteBackupAllowingChecksumMismatch}
|
||||
onSaveSettings={props.onSaveBackupSettings}
|
||||
onRunRemoteBackup={props.onRunRemoteBackup}
|
||||
onNotify={props.onNotify}
|
||||
|
||||
@@ -21,6 +21,7 @@ interface AuthViewsProps {
|
||||
mode: 'login' | 'register' | 'locked';
|
||||
pendingAction: 'login' | 'register' | 'unlock' | null;
|
||||
unlockReady: boolean;
|
||||
unlockPreparing: boolean;
|
||||
loginValues: LoginValues;
|
||||
registerValues: RegisterValues;
|
||||
unlockPassword: string;
|
||||
@@ -97,14 +98,17 @@ export default function AuthViews(props: AuthViewsProps) {
|
||||
type="button"
|
||||
className="auth-link-btn"
|
||||
onClick={props.onShowLockedPasswordHint}
|
||||
disabled={unlockBusy}
|
||||
disabled={unlockBusy || props.unlockPreparing}
|
||||
>
|
||||
{t('txt_show_password_hint')}
|
||||
</button>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || !props.unlockReady}>
|
||||
{props.unlockPreparing ? (
|
||||
<p className="muted standalone-muted">{t('txt_loading')}</p>
|
||||
) : null}
|
||||
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || props.unlockPreparing || !props.unlockReady}>
|
||||
<Unlock size={16} className="btn-icon" />
|
||||
{unlockBusy ? t('txt_unlocking') : t('txt_unlock')}
|
||||
{unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')}
|
||||
</button>
|
||||
<div className="or">{t('txt_or')}</div>
|
||||
<button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy}>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import {
|
||||
type AdminBackupImportResponse,
|
||||
type AdminBackupRunResponse,
|
||||
type AdminBackupSettings,
|
||||
type BackupFileIntegrityCheckResult,
|
||||
type BackupDestinationRecord,
|
||||
type BackupDestinationType,
|
||||
type RemoteBackupBrowserResponse,
|
||||
verifyBackupFileIntegrity,
|
||||
} from '@/lib/api/backup';
|
||||
import {
|
||||
REMOTE_BROWSER_ITEMS_PER_PAGE,
|
||||
@@ -22,6 +25,7 @@ import {
|
||||
loadPersistedRemoteBrowserState,
|
||||
persistRemoteBrowserState,
|
||||
} from '@/lib/backup-center';
|
||||
import { BACKUP_PROGRESS_EVENT, type BackupProgressDetail, type BackupProgressOperation } from '@/lib/backup-restore-progress';
|
||||
import { RECOMMENDED_PROVIDERS, type RecommendedProvider } from '@/lib/backup-recommendations';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { BackupDestinationDetail } from './backup-center/BackupDestinationDetail';
|
||||
@@ -32,16 +36,82 @@ interface BackupCenterPageProps {
|
||||
currentUserId: string | null;
|
||||
onExport: (includeAttachments?: boolean) => Promise<void>;
|
||||
onImport: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onImportAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onLoadSettings: () => Promise<AdminBackupSettings>;
|
||||
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
||||
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>;
|
||||
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
||||
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
}
|
||||
|
||||
type PendingRestoreIntegrity =
|
||||
| { source: 'local'; fileName: string; result: BackupFileIntegrityCheckResult }
|
||||
| { source: 'remote'; fileName: string; path: string; result: BackupFileIntegrityCheckResult };
|
||||
|
||||
interface BackupProgressPhase {
|
||||
titleKey: string;
|
||||
detailKey: string;
|
||||
}
|
||||
|
||||
interface BackupProgressState {
|
||||
operation: BackupProgressOperation;
|
||||
source: 'local' | 'remote' | null;
|
||||
includeAttachments: boolean;
|
||||
fileLabel: string;
|
||||
startedAt: number;
|
||||
phaseIndex: number;
|
||||
phases: BackupProgressPhase[];
|
||||
currentTitleKey: string;
|
||||
currentDetailKey: string;
|
||||
}
|
||||
|
||||
const LOCAL_RESTORE_PHASES: BackupProgressPhase[] = [
|
||||
{ titleKey: 'txt_backup_restore_progress_local_upload_title', detailKey: 'txt_backup_restore_progress_local_upload_detail' },
|
||||
{ titleKey: 'txt_backup_restore_progress_local_shadow_title', detailKey: 'txt_backup_restore_progress_local_shadow_detail' },
|
||||
{ titleKey: 'txt_backup_restore_progress_local_data_title', detailKey: 'txt_backup_restore_progress_local_data_detail' },
|
||||
{ titleKey: 'txt_backup_restore_progress_local_files_title', detailKey: 'txt_backup_restore_progress_local_files_detail' },
|
||||
{ titleKey: 'txt_backup_restore_progress_local_finalize_title', detailKey: 'txt_backup_restore_progress_local_finalize_detail' },
|
||||
];
|
||||
|
||||
const REMOTE_RESTORE_PHASES: BackupProgressPhase[] = [
|
||||
{ titleKey: 'txt_backup_restore_progress_remote_fetch_title', detailKey: 'txt_backup_restore_progress_remote_fetch_detail' },
|
||||
{ titleKey: 'txt_backup_restore_progress_remote_shadow_title', detailKey: 'txt_backup_restore_progress_remote_shadow_detail' },
|
||||
{ titleKey: 'txt_backup_restore_progress_remote_data_title', detailKey: 'txt_backup_restore_progress_remote_data_detail' },
|
||||
{ titleKey: 'txt_backup_restore_progress_remote_files_title', detailKey: 'txt_backup_restore_progress_remote_files_detail' },
|
||||
{ titleKey: 'txt_backup_restore_progress_remote_finalize_title', detailKey: 'txt_backup_restore_progress_remote_finalize_detail' },
|
||||
];
|
||||
|
||||
const EXPORT_PROGRESS_PHASES: BackupProgressPhase[] = [
|
||||
{ titleKey: 'txt_backup_archive_progress_collect_title', detailKey: 'txt_backup_archive_progress_collect_detail' },
|
||||
{ titleKey: 'txt_backup_archive_progress_package_title', detailKey: 'txt_backup_archive_progress_package_detail' },
|
||||
{ titleKey: 'txt_backup_archive_progress_ready_title', detailKey: 'txt_backup_archive_progress_ready_detail' },
|
||||
{ titleKey: 'txt_backup_export_progress_save_title', detailKey: 'txt_backup_export_progress_save_detail' },
|
||||
];
|
||||
|
||||
const EXPORT_WITH_ATTACHMENTS_PROGRESS_PHASES: BackupProgressPhase[] = [
|
||||
{ titleKey: 'txt_backup_archive_progress_collect_title', detailKey: 'txt_backup_archive_progress_collect_with_attachments_detail' },
|
||||
{ titleKey: 'txt_backup_archive_progress_package_title', detailKey: 'txt_backup_archive_progress_package_with_attachments_detail' },
|
||||
{ titleKey: 'txt_backup_archive_progress_ready_title', detailKey: 'txt_backup_archive_progress_ready_detail' },
|
||||
{ titleKey: 'txt_backup_export_progress_fetch_attachments_title', detailKey: 'txt_backup_export_progress_fetch_attachments_detail' },
|
||||
{ titleKey: 'txt_backup_export_progress_rebuild_title', detailKey: 'txt_backup_export_progress_rebuild_detail' },
|
||||
{ titleKey: 'txt_backup_export_progress_save_title', detailKey: 'txt_backup_export_progress_save_detail' },
|
||||
];
|
||||
|
||||
const REMOTE_RUN_PROGRESS_PHASES: BackupProgressPhase[] = [
|
||||
{ titleKey: 'txt_backup_remote_run_progress_prepare_title', detailKey: 'txt_backup_remote_run_progress_prepare_detail' },
|
||||
{ titleKey: 'txt_backup_archive_progress_collect_title', detailKey: 'txt_backup_archive_progress_collect_with_attachments_detail' },
|
||||
{ titleKey: 'txt_backup_archive_progress_package_title', detailKey: 'txt_backup_archive_progress_package_with_attachments_detail' },
|
||||
{ titleKey: 'txt_backup_remote_run_progress_sync_attachments_title', detailKey: 'txt_backup_remote_run_progress_sync_attachments_detail' },
|
||||
{ titleKey: 'txt_backup_remote_run_progress_upload_title', detailKey: 'txt_backup_remote_run_progress_upload_detail' },
|
||||
{ titleKey: 'txt_backup_remote_run_progress_verify_title', detailKey: 'txt_backup_remote_run_progress_verify_detail' },
|
||||
{ titleKey: 'txt_backup_remote_run_progress_cleanup_title', detailKey: 'txt_backup_remote_run_progress_cleanup_detail' },
|
||||
];
|
||||
|
||||
function buildSkippedImportMessage(result: AdminBackupImportResponse): string | null {
|
||||
const skipped = result.skipped;
|
||||
if (!skipped || !skipped.attachments) return null;
|
||||
@@ -51,10 +121,56 @@ function buildSkippedImportMessage(result: AdminBackupImportResponse): string |
|
||||
});
|
||||
}
|
||||
|
||||
function buildIntegrityStatusMessage(result: BackupFileIntegrityCheckResult, options?: { remote?: boolean }): string {
|
||||
if (!result.hasChecksumPrefix) {
|
||||
return t(options?.remote ? 'txt_backup_remote_restore_completed_without_checksum' : 'txt_backup_restore_completed_without_checksum');
|
||||
}
|
||||
return t(options?.remote ? 'txt_backup_remote_restore_completed_verified' : 'txt_backup_restore_completed_verified');
|
||||
}
|
||||
|
||||
function buildIntegrityWarningMessage(entry: PendingRestoreIntegrity): string {
|
||||
if (entry.source === 'remote') {
|
||||
return t('txt_backup_remote_restore_checksum_warning_message', {
|
||||
name: entry.fileName,
|
||||
expected: entry.result.expectedPrefix || '-----',
|
||||
actual: entry.result.actualPrefix,
|
||||
});
|
||||
}
|
||||
return t('txt_backup_restore_checksum_warning_message', {
|
||||
name: entry.fileName,
|
||||
expected: entry.result.expectedPrefix || '-----',
|
||||
actual: entry.result.actualPrefix,
|
||||
});
|
||||
}
|
||||
|
||||
function getBackupProgressPhases(
|
||||
operation: BackupProgressOperation,
|
||||
source: 'local' | 'remote' | null,
|
||||
includeAttachments: boolean
|
||||
): BackupProgressPhase[] {
|
||||
if (operation === 'backup-restore') {
|
||||
return source === 'remote' ? REMOTE_RESTORE_PHASES : LOCAL_RESTORE_PHASES;
|
||||
}
|
||||
if (operation === 'backup-export') {
|
||||
return includeAttachments ? EXPORT_WITH_ATTACHMENTS_PROGRESS_PHASES : EXPORT_PROGRESS_PHASES;
|
||||
}
|
||||
return REMOTE_RUN_PROGRESS_PHASES;
|
||||
}
|
||||
|
||||
function getBackupProgressTitleKey(state: BackupProgressState): string {
|
||||
if (state.operation === 'backup-export') return 'txt_backup_export_progress_title';
|
||||
if (state.operation === 'backup-remote-run') return 'txt_backup_remote_run_progress_title';
|
||||
return state.source === 'remote'
|
||||
? 'txt_backup_restore_progress_remote_title'
|
||||
: 'txt_backup_restore_progress_local_title';
|
||||
}
|
||||
|
||||
export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
const persistedRemoteStateRef = useRef(loadPersistedRemoteBrowserState(props.currentUserId));
|
||||
const persistedRemoteState = persistedRemoteStateRef.current;
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const restoreProgressTimerRef = useRef<number | null>(null);
|
||||
const restoreProgressPendingRef = useRef<BackupProgressState | null>(null);
|
||||
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
@@ -67,14 +183,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
const [downloadingRemotePath, setDownloadingRemotePath] = useState('');
|
||||
const [downloadingRemotePercent, setDownloadingRemotePercent] = useState<number | null>(null);
|
||||
const [restoringRemotePath, setRestoringRemotePath] = useState('');
|
||||
const [remoteRestoreStatusText, setRemoteRestoreStatusText] = useState('');
|
||||
const [deletingRemotePath, setDeletingRemotePath] = useState('');
|
||||
const [localError, setLocalError] = useState('');
|
||||
const [restoreProgress, setRestoreProgress] = useState<BackupProgressState | null>(null);
|
||||
const [restoreElapsedSeconds, setRestoreElapsedSeconds] = useState(0);
|
||||
const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false);
|
||||
const [confirmReplaceOpen, setConfirmReplaceOpen] = useState(false);
|
||||
const [confirmRemoteReplaceOpen, setConfirmRemoteReplaceOpen] = useState(false);
|
||||
const [confirmIntegrityWarningOpen, setConfirmIntegrityWarningOpen] = useState(false);
|
||||
const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false);
|
||||
const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false);
|
||||
const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null);
|
||||
const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
|
||||
const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState('');
|
||||
const [savedSettings, setSavedSettings] = useState<AdminBackupSettings | null>(null);
|
||||
@@ -148,6 +267,59 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
});
|
||||
}, [props.currentUserId, remoteBrowserCache, remoteBrowserPageByKey, remoteBrowserPathByDestination, selectedDestinationId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!restoreProgress) {
|
||||
setRestoreElapsedSeconds(0);
|
||||
return;
|
||||
}
|
||||
setRestoreElapsedSeconds(Math.max(0, Math.floor((Date.now() - restoreProgress.startedAt) / 1000)));
|
||||
const tickTimer = window.setInterval(() => {
|
||||
setRestoreElapsedSeconds(Math.max(0, Math.floor((Date.now() - restoreProgress.startedAt) / 1000)));
|
||||
}, 1000);
|
||||
return () => window.clearInterval(tickTimer);
|
||||
}, [restoreProgress]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleProgress = (event: Event) => {
|
||||
const detail = (event as CustomEvent<BackupProgressDetail>).detail;
|
||||
if (!detail) return;
|
||||
const pending = restoreProgressPendingRef.current;
|
||||
const operation = detail.operation || pending?.operation || 'backup-restore';
|
||||
const source = (detail.source || pending?.source || null) as 'local' | 'remote' | null;
|
||||
const includeAttachments = pending?.includeAttachments || false;
|
||||
const phases = getBackupProgressPhases(operation, source, includeAttachments);
|
||||
const matchedPhaseIndex = phases.findIndex((phase) => phase.titleKey === detail.stageTitle);
|
||||
const phaseIndex = matchedPhaseIndex >= 0 ? matchedPhaseIndex : 0;
|
||||
const nextState: BackupProgressState = {
|
||||
operation,
|
||||
source,
|
||||
includeAttachments,
|
||||
fileLabel: detail.fileName || pending?.fileLabel || '',
|
||||
startedAt: pending?.operation === operation
|
||||
? pending.startedAt
|
||||
: Date.now(),
|
||||
phaseIndex,
|
||||
phases,
|
||||
currentTitleKey: detail.stageTitle || phases[Math.max(0, phaseIndex)].titleKey,
|
||||
currentDetailKey: detail.stageDetail || phases[Math.max(0, phaseIndex)].detailKey,
|
||||
};
|
||||
restoreProgressPendingRef.current = nextState;
|
||||
if (restoreProgressTimerRef.current === null) {
|
||||
setRestoreProgress(nextState);
|
||||
}
|
||||
if (detail.done) {
|
||||
window.setTimeout(() => {
|
||||
setRestoreProgress((current) => (
|
||||
current && current.fileLabel === (detail.fileName || current.fileLabel) ? null : current
|
||||
));
|
||||
setRestoreElapsedSeconds(0);
|
||||
}, detail.ok === false ? 1200 : 900);
|
||||
}
|
||||
};
|
||||
window.addEventListener(BACKUP_PROGRESS_EVENT, handleProgress as EventListener);
|
||||
return () => window.removeEventListener(BACKUP_PROGRESS_EVENT, handleProgress as EventListener);
|
||||
}, []);
|
||||
|
||||
function updateSettings(mutator: (current: AdminBackupSettings) => AdminBackupSettings) {
|
||||
setSettings((current) => {
|
||||
const next = mutator(current);
|
||||
@@ -225,6 +397,67 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
|
||||
function resetPendingIntegrityWarning() {
|
||||
setPendingRestoreIntegrity(null);
|
||||
setConfirmIntegrityWarningOpen(false);
|
||||
}
|
||||
|
||||
function startRestoreProgress(
|
||||
operation: BackupProgressOperation,
|
||||
fileLabel: string,
|
||||
options?: { source?: 'local' | 'remote' | null; includeAttachments?: boolean; delayMs?: number }
|
||||
) {
|
||||
if (restoreProgressTimerRef.current !== null) {
|
||||
window.clearTimeout(restoreProgressTimerRef.current);
|
||||
restoreProgressTimerRef.current = null;
|
||||
}
|
||||
setRestoreElapsedSeconds(0);
|
||||
const source = options?.source || null;
|
||||
const includeAttachments = !!options?.includeAttachments;
|
||||
const phases = getBackupProgressPhases(operation, source, includeAttachments);
|
||||
restoreProgressPendingRef.current = {
|
||||
operation,
|
||||
source,
|
||||
includeAttachments,
|
||||
fileLabel,
|
||||
startedAt: Date.now(),
|
||||
phaseIndex: 0,
|
||||
phases,
|
||||
currentTitleKey: phases[0].titleKey,
|
||||
currentDetailKey: phases[0].detailKey,
|
||||
};
|
||||
restoreProgressTimerRef.current = window.setTimeout(() => {
|
||||
restoreProgressTimerRef.current = null;
|
||||
if (!restoreProgressPendingRef.current) return;
|
||||
setRestoreProgress(restoreProgressPendingRef.current);
|
||||
}, options?.delayMs ?? 480);
|
||||
}
|
||||
|
||||
function clearRestoreProgress() {
|
||||
if (restoreProgressTimerRef.current !== null) {
|
||||
window.clearTimeout(restoreProgressTimerRef.current);
|
||||
restoreProgressTimerRef.current = null;
|
||||
}
|
||||
restoreProgressPendingRef.current = null;
|
||||
setRestoreProgress(null);
|
||||
setRestoreElapsedSeconds(0);
|
||||
}
|
||||
|
||||
async function inspectLocalBackupFile(file: File): Promise<BackupFileIntegrityCheckResult> {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
return verifyBackupFileIntegrity(bytes, file.name || '');
|
||||
}
|
||||
|
||||
async function inspectRemoteBackupFile(destinationId: string, path: string): Promise<PendingRestoreIntegrity> {
|
||||
const payload = await props.onInspectRemoteBackup(destinationId, path);
|
||||
return {
|
||||
source: 'remote',
|
||||
path,
|
||||
fileName: payload.fileName || path.split('/').pop() || path,
|
||||
result: payload.integrity,
|
||||
};
|
||||
}
|
||||
|
||||
function handleAddDestination(type: BackupDestinationType) {
|
||||
updateSettings((current) => {
|
||||
const nextDestination = createDraftDestinationRecord(type, current.destinations.filter((destination) => destination.type === type).length + 1);
|
||||
@@ -277,18 +510,25 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
setLocalError('');
|
||||
setExporting(true);
|
||||
try {
|
||||
startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments });
|
||||
await props.onExport(exportIncludeAttachments);
|
||||
props.onNotify('success', t('txt_backup_export_success'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_export_failed');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
window.setTimeout(() => clearRestoreProgress(), 1200);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runLocalRestore(replaceExisting: boolean) {
|
||||
async function runLocalRestore(
|
||||
replaceExisting: boolean,
|
||||
allowChecksumMismatch: boolean = false,
|
||||
knownIntegrity?: BackupFileIntegrityCheckResult
|
||||
) {
|
||||
if (importing) return;
|
||||
if (!selectedFile) {
|
||||
const message = t('txt_backup_file_required');
|
||||
setLocalError(message);
|
||||
@@ -296,17 +536,29 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
return;
|
||||
}
|
||||
setLocalError('');
|
||||
setConfirmLocalRestoreOpen(false);
|
||||
setConfirmReplaceOpen(false);
|
||||
setConfirmIntegrityWarningOpen(false);
|
||||
setImporting(true);
|
||||
try {
|
||||
const result = await props.onImport(selectedFile, replaceExisting);
|
||||
props.onNotify('success', t('txt_backup_restore_success_relogin'));
|
||||
const integrity = knownIntegrity || await inspectLocalBackupFile(selectedFile);
|
||||
startRestoreProgress('backup-restore', selectedFile.name || t('txt_backup_import'), {
|
||||
source: 'local',
|
||||
delayMs: replaceExisting ? 480 : 1400,
|
||||
});
|
||||
const result = allowChecksumMismatch
|
||||
? await props.onImportAllowingChecksumMismatch(selectedFile, replaceExisting)
|
||||
: await props.onImport(selectedFile, replaceExisting);
|
||||
props.onNotify('success', `${buildIntegrityStatusMessage(integrity)} ${t('txt_backup_restore_success_relogin')}`);
|
||||
const skippedMessage = buildSkippedImportMessage(result);
|
||||
if (skippedMessage) props.onNotify('warning', skippedMessage);
|
||||
resetSelectedFile();
|
||||
setConfirmLocalRestoreOpen(false);
|
||||
setConfirmReplaceOpen(false);
|
||||
resetPendingIntegrityWarning();
|
||||
} catch (error) {
|
||||
if (!replaceExisting && isReplaceRequiredError(error)) {
|
||||
clearRestoreProgress();
|
||||
setConfirmLocalRestoreOpen(false);
|
||||
setConfirmReplaceOpen(true);
|
||||
return;
|
||||
@@ -314,6 +566,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_restore_failed');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
window.setTimeout(() => clearRestoreProgress(), 1200);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
@@ -364,16 +617,21 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
setRunningRemoteBackup(true);
|
||||
setLocalError('');
|
||||
try {
|
||||
startRestoreProgress('backup-remote-run', selectedDestination.name || t('txt_backup_run_now'), {
|
||||
source: 'remote',
|
||||
includeAttachments: !!selectedDestination.includeAttachments,
|
||||
});
|
||||
const result = await props.onRunRemoteBackup(selectedDestination.id);
|
||||
setSavedSettings(result.settings);
|
||||
setSettings(result.settings);
|
||||
setSelectedDestinationId(selectedDestination.id);
|
||||
await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true });
|
||||
props.onNotify('success', t('txt_backup_remote_run_success'));
|
||||
props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.result.fileName }));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
window.setTimeout(() => clearRestoreProgress(), 1200);
|
||||
} finally {
|
||||
setRunningRemoteBackup(false);
|
||||
}
|
||||
@@ -397,6 +655,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
}
|
||||
|
||||
async function handleDeleteRemote(path: string) {
|
||||
if (deletingRemotePath) return;
|
||||
if (!savedSelectedDestination) return;
|
||||
setDeletingRemotePath(path);
|
||||
setLocalError('');
|
||||
@@ -415,30 +674,89 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function runRemoteRestore(path: string, replaceExisting: boolean) {
|
||||
async function handleSelectedLocalFile(nextFile: File | null) {
|
||||
setSelectedFile(nextFile);
|
||||
setLocalError('');
|
||||
resetPendingIntegrityWarning();
|
||||
setConfirmLocalRestoreOpen(false);
|
||||
if (!nextFile) return;
|
||||
|
||||
try {
|
||||
const integrity = await inspectLocalBackupFile(nextFile);
|
||||
if (!integrity.matches) {
|
||||
setPendingRestoreIntegrity({
|
||||
source: 'local',
|
||||
fileName: nextFile.name,
|
||||
result: integrity,
|
||||
});
|
||||
setConfirmIntegrityWarningOpen(true);
|
||||
return;
|
||||
}
|
||||
setConfirmLocalRestoreOpen(true);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_integrity_check_failed');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePromptRemoteRestore(path: string) {
|
||||
if (!savedSelectedDestination) return;
|
||||
setLocalError('');
|
||||
resetPendingIntegrityWarning();
|
||||
try {
|
||||
const integrity = await inspectRemoteBackupFile(savedSelectedDestination.id, path);
|
||||
if (!integrity.result.matches) {
|
||||
setPendingRestoreIntegrity(integrity);
|
||||
setConfirmIntegrityWarningOpen(true);
|
||||
return;
|
||||
}
|
||||
await runRemoteRestore(path, false, false, integrity.result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_integrity_check_failed');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
}
|
||||
}
|
||||
|
||||
async function runRemoteRestore(
|
||||
path: string,
|
||||
replaceExisting: boolean,
|
||||
allowChecksumMismatch: boolean = false,
|
||||
knownIntegrity?: BackupFileIntegrityCheckResult
|
||||
) {
|
||||
if (restoringRemotePath) return;
|
||||
if (!savedSelectedDestination) return;
|
||||
setConfirmRemoteReplaceOpen(false);
|
||||
setConfirmIntegrityWarningOpen(false);
|
||||
setRestoringRemotePath(path);
|
||||
setRemoteRestoreStatusText(replaceExisting ? t('txt_backup_remote_restore_stage_replace') : t('txt_backup_remote_restore_stage_prepare'));
|
||||
setLocalError('');
|
||||
try {
|
||||
const result = await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting);
|
||||
const integrity = knownIntegrity ? { result: knownIntegrity } : await inspectRemoteBackupFile(savedSelectedDestination.id, path);
|
||||
startRestoreProgress('backup-restore', path.split('/').pop() || path, {
|
||||
source: 'remote',
|
||||
delayMs: replaceExisting ? 480 : 1400,
|
||||
});
|
||||
const result = allowChecksumMismatch
|
||||
? await props.onRestoreRemoteBackupAllowingChecksumMismatch(savedSelectedDestination.id, path, replaceExisting)
|
||||
: await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting);
|
||||
setConfirmRemoteReplaceOpen(false);
|
||||
setPendingRemoteRestorePath('');
|
||||
setRemoteRestoreStatusText('');
|
||||
props.onNotify('success', t('txt_backup_restore_success_relogin'));
|
||||
props.onNotify('success', `${buildIntegrityStatusMessage(integrity.result, { remote: true })} ${t('txt_backup_restore_success_relogin')}`);
|
||||
const skippedMessage = buildSkippedImportMessage(result);
|
||||
if (skippedMessage) props.onNotify('warning', skippedMessage);
|
||||
resetPendingIntegrityWarning();
|
||||
} catch (error) {
|
||||
if (!replaceExisting && isReplaceRequiredError(error)) {
|
||||
setPendingRemoteRestorePath(path);
|
||||
setConfirmRemoteReplaceOpen(true);
|
||||
setRemoteRestoreStatusText('');
|
||||
clearRestoreProgress();
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_restore_failed');
|
||||
setRemoteRestoreStatusText('');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
window.setTimeout(() => clearRestoreProgress(), 1200);
|
||||
} finally {
|
||||
setRestoringRemotePath('');
|
||||
}
|
||||
@@ -454,9 +772,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
disabled={disableWhileBusy}
|
||||
onChange={(event) => {
|
||||
const nextFile = (event.currentTarget as HTMLInputElement).files?.[0] || null;
|
||||
setSelectedFile(nextFile);
|
||||
setLocalError('');
|
||||
if (nextFile) setConfirmLocalRestoreOpen(true);
|
||||
void handleSelectedLocalFile(nextFile);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -521,7 +837,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
if (savedSelectedDestination) showRemoteBrowserPath(savedSelectedDestination.id, path);
|
||||
}}
|
||||
onDownloadRemoteBackup={(path) => void handleDownloadRemote(path)}
|
||||
onRestoreRemoteBackup={(path) => void runRemoteRestore(path, false)}
|
||||
onRestoreRemoteBackup={(path) => void handlePromptRemoteRestore(path)}
|
||||
onPromptDeleteRemoteBackup={(path) => {
|
||||
setPendingRemoteDeletePath(path);
|
||||
setConfirmRemoteDeleteOpen(true);
|
||||
@@ -533,7 +849,49 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
/>
|
||||
|
||||
{localError ? <div className="local-error">{localError}</div> : null}
|
||||
{!localError && remoteRestoreStatusText ? <div className="status-ok">{remoteRestoreStatusText}</div> : null}
|
||||
{restoreProgress && typeof document !== 'undefined' ? createPortal((
|
||||
<div className="restore-progress-overlay" aria-live="polite">
|
||||
<section className="restore-progress-card restore-progress-modal">
|
||||
<div className="restore-progress-head">
|
||||
<div>
|
||||
<div className="restore-progress-kicker">{t('txt_backup_progress_kicker')}</div>
|
||||
<h3 className="restore-progress-title">
|
||||
{t(getBackupProgressTitleKey(restoreProgress))}
|
||||
</h3>
|
||||
<p className="restore-progress-subtitle">
|
||||
{t('txt_backup_progress_subject', { name: restoreProgress.fileLabel })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="restore-progress-elapsed">
|
||||
{t('txt_backup_restore_progress_elapsed', { seconds: String(restoreElapsedSeconds) })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="restore-progress-meter">
|
||||
<span
|
||||
className="restore-progress-meter-bar"
|
||||
style={{
|
||||
width: `${((restoreProgress.phaseIndex + 1) / restoreProgress.phases.length) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="restore-progress-current">
|
||||
<strong>{t(restoreProgress.currentTitleKey)}</strong>
|
||||
<p>{t(restoreProgress.currentDetailKey)}</p>
|
||||
</div>
|
||||
<ol className="restore-progress-list">
|
||||
{restoreProgress.phases.map((phase, index) => {
|
||||
const status = index < restoreProgress.phaseIndex ? 'done' : index === restoreProgress.phaseIndex ? 'active' : 'pending';
|
||||
return (
|
||||
<li key={phase.titleKey} className={`restore-progress-item ${status}`}>
|
||||
<span className="restore-progress-dot" />
|
||||
<span className="restore-progress-item-text">{t(phase.titleKey)}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
), document.body) : null}
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmLocalRestoreOpen}
|
||||
@@ -541,11 +899,15 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
message={selectedFile ? t('txt_backup_selected_file_name', { name: selectedFile.name }) : t('txt_backup_restore_note')}
|
||||
confirmText={t('txt_backup_import')}
|
||||
cancelText={t('txt_cancel')}
|
||||
confirmDisabled={importing}
|
||||
cancelDisabled={importing}
|
||||
danger
|
||||
onConfirm={() => void runLocalRestore(false)}
|
||||
onCancel={() => {
|
||||
if (importing) return;
|
||||
setConfirmLocalRestoreOpen(false);
|
||||
resetSelectedFile();
|
||||
resetPendingIntegrityWarning();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -558,11 +920,16 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
confirmDisabled={importing}
|
||||
cancelDisabled={importing}
|
||||
danger
|
||||
onConfirm={() => void runLocalRestore(true)}
|
||||
onConfirm={() => void runLocalRestore(
|
||||
true,
|
||||
pendingRestoreIntegrity?.source === 'local',
|
||||
pendingRestoreIntegrity?.source === 'local' ? pendingRestoreIntegrity.result : undefined
|
||||
)}
|
||||
onCancel={() => {
|
||||
if (importing) return;
|
||||
setConfirmReplaceOpen(false);
|
||||
resetSelectedFile();
|
||||
resetPendingIntegrityWarning();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -575,11 +942,47 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
confirmDisabled={!!restoringRemotePath}
|
||||
cancelDisabled={!!restoringRemotePath}
|
||||
danger
|
||||
onConfirm={() => void runRemoteRestore(pendingRemoteRestorePath, true)}
|
||||
onConfirm={() => void runRemoteRestore(
|
||||
pendingRemoteRestorePath,
|
||||
true,
|
||||
pendingRestoreIntegrity?.source === 'remote' && pendingRestoreIntegrity.path === pendingRemoteRestorePath,
|
||||
pendingRestoreIntegrity?.source === 'remote' && pendingRestoreIntegrity.path === pendingRemoteRestorePath
|
||||
? pendingRestoreIntegrity.result
|
||||
: undefined
|
||||
)}
|
||||
onCancel={() => {
|
||||
if (restoringRemotePath) return;
|
||||
setConfirmRemoteReplaceOpen(false);
|
||||
setPendingRemoteRestorePath('');
|
||||
resetPendingIntegrityWarning();
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmIntegrityWarningOpen}
|
||||
title={t('txt_backup_restore_checksum_warning_title')}
|
||||
message={pendingRestoreIntegrity ? buildIntegrityWarningMessage(pendingRestoreIntegrity) : t('txt_backup_restore_checksum_warning_message_fallback')}
|
||||
variant="warning"
|
||||
confirmText={t('txt_backup_restore_checksum_warning_confirm')}
|
||||
cancelText={t('txt_cancel')}
|
||||
confirmDisabled={importing || !!restoringRemotePath}
|
||||
cancelDisabled={importing || !!restoringRemotePath}
|
||||
danger
|
||||
onConfirm={() => {
|
||||
if (!pendingRestoreIntegrity) return;
|
||||
setConfirmIntegrityWarningOpen(false);
|
||||
if (pendingRestoreIntegrity.source === 'local') {
|
||||
void runLocalRestore(false, true, pendingRestoreIntegrity.result);
|
||||
return;
|
||||
}
|
||||
void runRemoteRestore(pendingRestoreIntegrity.path, false, true, pendingRestoreIntegrity.result);
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (importing || restoringRemotePath) return;
|
||||
resetPendingIntegrityWarning();
|
||||
setPendingRemoteRestorePath('');
|
||||
setConfirmLocalRestoreOpen(false);
|
||||
resetSelectedFile();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -589,6 +992,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
message={t('txt_backup_remote_delete_confirm_message', { name: pendingRemoteDeletePath.split('/').pop() || pendingRemoteDeletePath })}
|
||||
confirmText={t('txt_delete')}
|
||||
cancelText={t('txt_cancel')}
|
||||
confirmDisabled={!!deletingRemotePath}
|
||||
cancelDisabled={!!deletingRemotePath}
|
||||
danger
|
||||
onConfirm={() => void handleDeleteRemote(pendingRemoteDeletePath)}
|
||||
onCancel={() => {
|
||||
@@ -606,6 +1011,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
})}
|
||||
confirmText={t('txt_delete')}
|
||||
cancelText={t('txt_cancel')}
|
||||
confirmDisabled={savingSettings}
|
||||
cancelDisabled={savingSettings}
|
||||
danger
|
||||
onConfirm={() => void handleDeleteDestination()}
|
||||
onCancel={() => {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import { TriangleAlert } from 'lucide-preact';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
variant?: 'default' | 'warning';
|
||||
showIcon?: boolean;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
@@ -19,9 +22,49 @@ interface ConfirmDialogProps {
|
||||
afterActions?: ComponentChildren;
|
||||
}
|
||||
|
||||
function incrementDialogBodyLock() {
|
||||
if (typeof document === 'undefined') return;
|
||||
const body = document.body;
|
||||
const nextCount = Number(body.dataset.dialogCount || '0') + 1;
|
||||
body.dataset.dialogCount = String(nextCount);
|
||||
body.classList.add('dialog-open');
|
||||
}
|
||||
|
||||
function decrementDialogBodyLock() {
|
||||
if (typeof document === 'undefined') return;
|
||||
const body = document.body;
|
||||
const nextCount = Math.max(0, Number(body.dataset.dialogCount || '0') - 1);
|
||||
if (nextCount === 0) {
|
||||
delete body.dataset.dialogCount;
|
||||
body.classList.remove('dialog-open');
|
||||
return;
|
||||
}
|
||||
body.dataset.dialogCount = String(nextCount);
|
||||
}
|
||||
|
||||
export function useDialogLifecycle(active: boolean, onCancel?: (() => void) | null) {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
incrementDialogBodyLock();
|
||||
return () => decrementDialogBodyLock();
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !onCancel || typeof window === 'undefined') return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
event.preventDefault();
|
||||
onCancel();
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [active, onCancel]);
|
||||
}
|
||||
|
||||
export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
const [present, setPresent] = useState(props.open);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const canDismiss = !props.cancelDisabled && !closing && !props.hideCancel;
|
||||
|
||||
useEffect(() => {
|
||||
if (props.open) {
|
||||
@@ -38,19 +81,41 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [props.open, present]);
|
||||
|
||||
if (!present) return null;
|
||||
return (
|
||||
<div className={`dialog-mask ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}>
|
||||
useDialogLifecycle(present, canDismiss ? props.onCancel : null);
|
||||
|
||||
if (!present || typeof document === 'undefined') return null;
|
||||
return createPortal((
|
||||
<div
|
||||
className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
||||
onClick={(event) => {
|
||||
if (event.target !== event.currentTarget || !canDismiss) return;
|
||||
props.onCancel();
|
||||
}}
|
||||
>
|
||||
<form
|
||||
className={`dialog-card ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
||||
className={`dialog-card ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={props.title}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (props.confirmDisabled || closing) return;
|
||||
props.onConfirm();
|
||||
}}
|
||||
>
|
||||
{props.variant === 'warning' ? (
|
||||
<>
|
||||
<div className="dialog-warning-strip" aria-hidden="true" />
|
||||
<div className="dialog-warning-head">
|
||||
<div className="dialog-warning-badge" aria-hidden="true">
|
||||
<TriangleAlert size={24} />
|
||||
</div>
|
||||
<div className="dialog-warning-kicker">{t('txt_warning')}</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<h3 className="dialog-title">{props.title}</h3>
|
||||
<div className="dialog-message">{props.message}</div>
|
||||
<div className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>
|
||||
{props.children}
|
||||
<button
|
||||
type="submit"
|
||||
@@ -75,5 +140,5 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
{props.afterActions}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
), document.body);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { argon2idAsync } from '@noble/hashes/argon2.js';
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { strFromU8, unzipSync } from 'fflate';
|
||||
import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js';
|
||||
import { Download, FileUp } from 'lucide-preact';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import ConfirmDialog, { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||
import {
|
||||
type EncryptedJsonMode,
|
||||
@@ -311,6 +312,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
const [exportAuthDialogOpen, setExportAuthDialogOpen] = useState(false);
|
||||
const [exportAuthPassword, setExportAuthPassword] = useState('');
|
||||
const [importSummary, setImportSummary] = useState<ImportResultSummary | null>(null);
|
||||
|
||||
useDialogLifecycle(!!importSummary, importSummary ? () => setImportSummary(null) : null);
|
||||
const commonSourceSet = new Set<ImportSourceId>(COMMON_IMPORT_SOURCE_IDS);
|
||||
const commonSources = IMPORT_SOURCES.filter((item) => commonSourceSet.has(item.id as ImportSourceId));
|
||||
const otherSources = IMPORT_SOURCES.filter((item) => !commonSourceSet.has(item.id as ImportSourceId));
|
||||
@@ -465,6 +468,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
}
|
||||
|
||||
async function handlePasswordImportConfirm() {
|
||||
if (isPasswordSubmitting) return;
|
||||
if (!pendingPasswordImport) return;
|
||||
setIsPasswordSubmitting(true);
|
||||
try {
|
||||
@@ -483,6 +487,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
}
|
||||
|
||||
async function handleZipPasswordImportConfirm() {
|
||||
if (isZipPasswordSubmitting) return;
|
||||
if (!pendingZipFile) return;
|
||||
setIsZipPasswordSubmitting(true);
|
||||
try {
|
||||
@@ -555,6 +560,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
}
|
||||
|
||||
async function handleExportConfirmPassword() {
|
||||
if (isExporting) return;
|
||||
const masterPassword = String(exportAuthPassword || '').trim();
|
||||
if (!masterPassword) {
|
||||
onNotify('error', t('txt_master_password_is_required'));
|
||||
@@ -733,6 +739,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
confirmText={isExporting ? t('txt_loading') : t('txt_verify')}
|
||||
cancelText={t('txt_cancel')}
|
||||
showIcon={false}
|
||||
confirmDisabled={isExporting}
|
||||
cancelDisabled={isExporting}
|
||||
onConfirm={() => void handleExportConfirmPassword()}
|
||||
onCancel={() => {
|
||||
if (isExporting) return;
|
||||
@@ -758,6 +766,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
confirmText={isPasswordSubmitting ? t('txt_loading') : t('txt_import')}
|
||||
cancelText={t('txt_cancel')}
|
||||
showIcon={false}
|
||||
confirmDisabled={isPasswordSubmitting}
|
||||
cancelDisabled={isPasswordSubmitting}
|
||||
onConfirm={() => void handlePasswordImportConfirm()}
|
||||
onCancel={() => {
|
||||
if (isPasswordSubmitting) return;
|
||||
@@ -784,6 +794,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
confirmText={isZipPasswordSubmitting ? t('txt_loading') : t('txt_import')}
|
||||
cancelText={t('txt_cancel')}
|
||||
showIcon={false}
|
||||
confirmDisabled={isZipPasswordSubmitting}
|
||||
cancelDisabled={isZipPasswordSubmitting}
|
||||
onConfirm={() => void handleZipPasswordImportConfirm()}
|
||||
onCancel={() => {
|
||||
if (isZipPasswordSubmitting) return;
|
||||
@@ -803,9 +815,15 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
|
||||
{importSummary && (
|
||||
<div className="dialog-mask">
|
||||
<section className="dialog-card import-summary-dialog">
|
||||
{importSummary && typeof document !== 'undefined' ? createPortal((
|
||||
<div
|
||||
className="dialog-mask"
|
||||
onClick={(event) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
setImportSummary(null);
|
||||
}}
|
||||
>
|
||||
<section className="dialog-card import-summary-dialog" role="dialog" aria-modal="true" aria-label={t('txt_import_success')}>
|
||||
<button
|
||||
type="button"
|
||||
className="import-summary-close"
|
||||
@@ -866,7 +884,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
), document.body) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { Download, Eye, Lock } from 'lucide-preact';
|
||||
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send';
|
||||
import { toBufferSource } from '@/lib/crypto';
|
||||
import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download';
|
||||
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||
import { t } from '@/lib/i18n';
|
||||
@@ -61,13 +62,13 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
||||
if (props.keyPart) {
|
||||
try {
|
||||
const decryptedBytes = await decryptPublicSendFileBytes(encryptedBytes, props.keyPart);
|
||||
blob = new Blob([decryptedBytes as unknown as BlobPart], { type: 'application/octet-stream' });
|
||||
blob = new Blob([toBufferSource(decryptedBytes)], { type: 'application/octet-stream' });
|
||||
} catch {
|
||||
// Legacy compatibility: early web-created file sends uploaded plaintext bytes.
|
||||
blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
|
||||
blob = new Blob([toBufferSource(encryptedBytes)], { type: 'application/octet-stream' });
|
||||
}
|
||||
} else {
|
||||
blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
|
||||
blob = new Blob([toBufferSource(encryptedBytes)], { type: 'application/octet-stream' });
|
||||
}
|
||||
downloadBytesAsFile(
|
||||
new Uint8Array(await blob.arrayBuffer()),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import qrcode from 'qrcode-generator';
|
||||
import type { Profile } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
|
||||
interface SettingsPageProps {
|
||||
profile: Profile;
|
||||
@@ -64,7 +65,8 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
const qr = qrcode(0, 'M');
|
||||
qr.addData(buildOtpUri(props.profile.email, secret));
|
||||
qr.make();
|
||||
const svg = qr.createSvgTag({ scalable: true, margin: 0 });
|
||||
// Keep a visible quiet zone so authenticator apps can scan reliably in both themes.
|
||||
const svg = qr.createSvgTag({ scalable: true, margin: 4 });
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
}, [props.profile.email, secret]);
|
||||
|
||||
@@ -85,6 +87,13 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
props.onNotify?.('success', t('txt_recovery_code_loaded'));
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) return t('txt_dash');
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<section className="card">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { JSX } from 'preact';
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { Clipboard, Globe, GripVertical } from 'lucide-preact';
|
||||
import {
|
||||
@@ -96,6 +97,7 @@ function SortableTotpRow(props: SortableTotpRowProps) {
|
||||
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: props.cipher.id,
|
||||
});
|
||||
const dragButtonAttributes = attributes as JSX.HTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -113,7 +115,7 @@ function SortableTotpRow(props: SortableTotpRowProps) {
|
||||
className="btn btn-secondary small totp-drag-btn"
|
||||
title={t('txt_drag_to_reorder')}
|
||||
aria-label={t('txt_drag_to_reorder')}
|
||||
{...attributes}
|
||||
{...dragButtonAttributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical size={14} className="btn-icon" />
|
||||
|
||||
@@ -50,6 +50,7 @@ interface VaultPageProps {
|
||||
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
|
||||
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
onCreateFolder: (name: string) => Promise<void>;
|
||||
onRenameFolder: (folderId: string, name: string) => Promise<void>;
|
||||
onDeleteFolder: (folderId: string) => Promise<void>;
|
||||
onBulkDeleteFolders: (folderIds: string[]) => Promise<void>;
|
||||
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
|
||||
@@ -91,6 +92,8 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
const [moveFolderId, setMoveFolderId] = useState('__none__');
|
||||
const [createFolderOpen, setCreateFolderOpen] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
const [pendingRenameFolder, setPendingRenameFolder] = useState<Folder | null>(null);
|
||||
const [renameFolderName, setRenameFolderName] = useState('');
|
||||
const [pendingDeleteFolder, setPendingDeleteFolder] = useState<Folder | null>(null);
|
||||
const [deleteAllFoldersOpen, setDeleteAllFoldersOpen] = useState(false);
|
||||
const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null);
|
||||
@@ -101,6 +104,7 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
const [repromptOpen, setRepromptOpen] = useState(false);
|
||||
const [repromptPassword, setRepromptPassword] = useState('');
|
||||
const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null);
|
||||
const [pendingDeletePasskeyIndex, setPendingDeletePasskeyIndex] = useState<number | null>(null);
|
||||
const [isMobileLayout, setIsMobileLayout] = useState(getInitialIsMobileLayout);
|
||||
const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list');
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
@@ -349,7 +353,6 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
() => filteredCiphers.slice(virtualRange.start, virtualRange.end),
|
||||
[filteredCiphers, virtualRange.start, virtualRange.end]
|
||||
);
|
||||
const passkeyCreatedAt = firstPasskeyCreationTime(selectedCipher);
|
||||
const selectedAttachments = useMemo(
|
||||
() => (Array.isArray(selectedCipher?.attachments) ? selectedCipher.attachments : []),
|
||||
[selectedCipher]
|
||||
@@ -443,6 +446,7 @@ function folderName(id: string | null | undefined): string {
|
||||
setLocalError('');
|
||||
setAttachmentQueue([]);
|
||||
setRemovedAttachmentIds({});
|
||||
setPendingDeletePasskeyIndex(null);
|
||||
if (isMobileLayout) setMobilePanel(returnToDetail ? 'detail' : 'list');
|
||||
}
|
||||
|
||||
@@ -450,6 +454,18 @@ function folderName(id: string | null | undefined): string {
|
||||
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
|
||||
}
|
||||
|
||||
function confirmDeleteLoginPasskey(): void {
|
||||
if (pendingDeletePasskeyIndex == null) return;
|
||||
setDraft((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
loginFido2Credentials: prev.loginFido2Credentials.filter((_, index) => index !== pendingDeletePasskeyIndex),
|
||||
};
|
||||
});
|
||||
setPendingDeletePasskeyIndex(null);
|
||||
}
|
||||
|
||||
async function seedSshDefaults(force = false): Promise<void> {
|
||||
const ticket = ++sshSeedTicketRef.current;
|
||||
try {
|
||||
@@ -699,6 +715,23 @@ function folderName(id: string | null | undefined): string {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRenameFolder(): Promise<void> {
|
||||
if (!pendingRenameFolder) return;
|
||||
const nextName = renameFolderName.trim();
|
||||
if (!nextName) {
|
||||
props.onNotify('error', t('txt_folder_name_is_required'));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await props.onRenameFolder(pendingRenameFolder.id, nextName);
|
||||
setPendingRenameFolder(null);
|
||||
setRenameFolderName('');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmBulkRestore(): Promise<void> {
|
||||
const ids = Object.entries(selectedMap)
|
||||
.filter(([, selected]) => selected)
|
||||
@@ -806,6 +839,10 @@ function folderName(id: string | null | undefined): string {
|
||||
onChangeFilter={setSidebarFilter}
|
||||
onOpenDeleteAllFolders={() => setDeleteAllFoldersOpen(true)}
|
||||
onOpenCreateFolder={() => setCreateFolderOpen(true)}
|
||||
onOpenRenameFolder={(folder) => {
|
||||
setPendingRenameFolder(folder);
|
||||
setRenameFolderName(folder.decName || folder.name || '');
|
||||
}}
|
||||
onOpenDeleteFolder={setPendingDeleteFolder}
|
||||
/>
|
||||
|
||||
@@ -828,6 +865,7 @@ function folderName(id: string | null | undefined): string {
|
||||
sortMenuRef={sortMenuRef}
|
||||
listPanelRef={listPanelRef}
|
||||
onSearchInput={setSearchInput}
|
||||
onClearSearch={() => setSearchInput('')}
|
||||
onSearchCompositionStart={() => setSearchComposing(true)}
|
||||
onSearchCompositionEnd={(value) => {
|
||||
setSearchComposing(false);
|
||||
@@ -923,6 +961,7 @@ function folderName(id: string | null | undefined): string {
|
||||
onUpdateDraftLoginUri={updateDraftLoginUri}
|
||||
onUpdateDraftLoginUriMatch={updateDraftLoginUriMatch}
|
||||
onReorderDraftLoginUri={reorderDraftLoginUri}
|
||||
onRequestDeleteLoginPasskey={setPendingDeletePasskeyIndex}
|
||||
onQueueAttachmentFiles={queueAttachmentFiles}
|
||||
onToggleExistingAttachmentRemoval={toggleExistingAttachmentRemoval}
|
||||
onRemoveQueuedAttachment={removeQueuedAttachment}
|
||||
@@ -948,7 +987,7 @@ function folderName(id: string | null | undefined): string {
|
||||
repromptApprovedCipherId={repromptApprovedCipherId}
|
||||
showPassword={showPassword}
|
||||
totpLive={totpLive}
|
||||
passkeyCreatedAt={passkeyCreatedAt}
|
||||
passkeyCreatedAt={firstPasskeyCreationTime(selectedCipher)}
|
||||
hiddenFieldVisibleMap={hiddenFieldVisibleMap}
|
||||
folderName={folderName}
|
||||
onOpenReprompt={() => setRepromptOpen(true)}
|
||||
@@ -970,6 +1009,7 @@ function folderName(id: string | null | undefined): string {
|
||||
</div>
|
||||
|
||||
<VaultDialogs
|
||||
busy={busy}
|
||||
fieldModalOpen={fieldModalOpen}
|
||||
fieldType={fieldType}
|
||||
fieldLabel={fieldLabel}
|
||||
@@ -985,10 +1025,13 @@ function folderName(id: string | null | undefined): string {
|
||||
folders={props.folders}
|
||||
createFolderOpen={createFolderOpen}
|
||||
newFolderName={newFolderName}
|
||||
renameFolderOpen={!!pendingRenameFolder}
|
||||
renameFolderName={renameFolderName}
|
||||
pendingDeleteFolder={pendingDeleteFolder}
|
||||
deleteAllFoldersOpen={deleteAllFoldersOpen}
|
||||
repromptOpen={repromptOpen}
|
||||
repromptPassword={repromptPassword}
|
||||
deletePasskeyOpen={pendingDeletePasskeyIndex != null}
|
||||
onConfirmAddField={() => {
|
||||
if (!draft) return;
|
||||
if (!fieldLabel.trim()) {
|
||||
@@ -1035,6 +1078,12 @@ function folderName(id: string | null | undefined): string {
|
||||
setNewFolderName('');
|
||||
}}
|
||||
onNewFolderNameChange={setNewFolderName}
|
||||
onConfirmRenameFolder={() => void confirmRenameFolder()}
|
||||
onCancelRenameFolder={() => {
|
||||
setPendingRenameFolder(null);
|
||||
setRenameFolderName('');
|
||||
}}
|
||||
onRenameFolderNameChange={setRenameFolderName}
|
||||
onConfirmDeleteFolder={() => void confirmDeleteFolder()}
|
||||
onCancelDeleteFolder={() => setPendingDeleteFolder(null)}
|
||||
onConfirmDeleteAllFolders={() => void confirmDeleteAllFolders()}
|
||||
@@ -1045,12 +1094,10 @@ function folderName(id: string | null | undefined): string {
|
||||
setRepromptPassword('');
|
||||
}}
|
||||
onRepromptPasswordChange={setRepromptPassword}
|
||||
onConfirmDeletePasskey={confirmDeleteLoginPasskey}
|
||||
onCancelDeletePasskey={() => setPendingDeletePasskeyIndex(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
||||
...COMMON_TIME_ZONES,
|
||||
...props.availableTimeZones,
|
||||
]));
|
||||
const selectedIntervalHours = props.selectedDestination?.schedule.intervalHours ?? 24;
|
||||
|
||||
if (props.selectedRecommendedProvider) {
|
||||
return (
|
||||
@@ -216,7 +217,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={String(props.selectedDestination.schedule.intervalHours || 24)}
|
||||
value={String(selectedIntervalHours)}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
onInput={(event) => {
|
||||
const raw = (event.currentTarget as HTMLInputElement).value.replace(/[^\d]/g, '');
|
||||
@@ -234,7 +235,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
||||
</div>
|
||||
<div className="backup-interval-presets" aria-label={t('txt_backup_interval_hours_presets')}>
|
||||
{INTERVAL_HOUR_PRESETS.map((preset) => {
|
||||
const active = preset === props.selectedDestination.schedule.intervalHours;
|
||||
const active = preset === selectedIntervalHours;
|
||||
return (
|
||||
<button
|
||||
key={preset}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FIELD_TYPE_OPTIONS, toBooleanFieldValue } from '@/components/vault/vaul
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
interface VaultDialogsProps {
|
||||
busy: boolean;
|
||||
fieldModalOpen: boolean;
|
||||
fieldType: CustomFieldType;
|
||||
fieldLabel: string;
|
||||
@@ -19,10 +20,13 @@ interface VaultDialogsProps {
|
||||
folders: Folder[];
|
||||
createFolderOpen: boolean;
|
||||
newFolderName: string;
|
||||
renameFolderOpen: boolean;
|
||||
renameFolderName: string;
|
||||
pendingDeleteFolder: Folder | null;
|
||||
deleteAllFoldersOpen: boolean;
|
||||
repromptOpen: boolean;
|
||||
repromptPassword: string;
|
||||
deletePasskeyOpen: boolean;
|
||||
onConfirmAddField: () => void;
|
||||
onCancelFieldModal: () => void;
|
||||
onFieldTypeChange: (value: CustomFieldType) => void;
|
||||
@@ -42,6 +46,9 @@ interface VaultDialogsProps {
|
||||
onConfirmCreateFolder: () => void;
|
||||
onCancelCreateFolder: () => void;
|
||||
onNewFolderNameChange: (value: string) => void;
|
||||
onConfirmRenameFolder: () => void;
|
||||
onCancelRenameFolder: () => void;
|
||||
onRenameFolderNameChange: (value: string) => void;
|
||||
onConfirmDeleteFolder: () => void;
|
||||
onCancelDeleteFolder: () => void;
|
||||
onConfirmDeleteAllFolders: () => void;
|
||||
@@ -49,6 +56,8 @@ interface VaultDialogsProps {
|
||||
onConfirmReprompt: () => void;
|
||||
onCancelReprompt: () => void;
|
||||
onRepromptPasswordChange: (value: string) => void;
|
||||
onConfirmDeletePasskey: () => void;
|
||||
onCancelDeletePasskey: () => void;
|
||||
}
|
||||
|
||||
export default function VaultDialogs(props: VaultDialogsProps) {
|
||||
@@ -100,6 +109,8 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
||||
message={t('txt_archive_item_message')}
|
||||
confirmText={t('txt_archive')}
|
||||
cancelText={t('txt_cancel')}
|
||||
confirmDisabled={props.busy}
|
||||
cancelDisabled={props.busy}
|
||||
onConfirm={props.onConfirmArchive}
|
||||
onCancel={props.onCancelArchive}
|
||||
/>
|
||||
@@ -110,11 +121,22 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
||||
message={t('txt_archive_selected_items_message', { count: props.selectedCount })}
|
||||
confirmText={t('txt_archive')}
|
||||
cancelText={t('txt_cancel')}
|
||||
confirmDisabled={props.busy}
|
||||
cancelDisabled={props.busy}
|
||||
onConfirm={props.onConfirmBulkArchive}
|
||||
onCancel={props.onCancelBulkArchive}
|
||||
/>
|
||||
|
||||
<ConfirmDialog open={props.pendingDeleteOpen} title={t('txt_delete_item')} message={t('txt_are_you_sure_you_want_to_delete_this_item')} danger onConfirm={props.onConfirmDelete} onCancel={props.onCancelDelete} />
|
||||
<ConfirmDialog
|
||||
open={props.pendingDeleteOpen}
|
||||
title={t('txt_delete_item')}
|
||||
message={t('txt_are_you_sure_you_want_to_delete_this_item')}
|
||||
danger
|
||||
confirmDisabled={props.busy}
|
||||
cancelDisabled={props.busy}
|
||||
onConfirm={props.onConfirmDelete}
|
||||
onCancel={props.onCancelDelete}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={props.bulkDeleteOpen}
|
||||
@@ -125,11 +147,23 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
||||
: t('txt_are_you_sure_you_want_to_delete_count_selected_items', { count: props.selectedCount })
|
||||
}
|
||||
danger
|
||||
confirmDisabled={props.busy}
|
||||
cancelDisabled={props.busy}
|
||||
onConfirm={props.onConfirmBulkDelete}
|
||||
onCancel={props.onCancelBulkDelete}
|
||||
/>
|
||||
|
||||
<ConfirmDialog open={props.moveOpen} title={t('txt_move_selected_items')} message={t('txt_choose_destination_folder')} confirmText={t('txt_move')} cancelText={t('txt_cancel')} onConfirm={props.onConfirmMove} onCancel={props.onCancelMove}>
|
||||
<ConfirmDialog
|
||||
open={props.moveOpen}
|
||||
title={t('txt_move_selected_items')}
|
||||
message={t('txt_choose_destination_folder')}
|
||||
confirmText={t('txt_move')}
|
||||
cancelText={t('txt_cancel')}
|
||||
confirmDisabled={props.busy}
|
||||
cancelDisabled={props.busy}
|
||||
onConfirm={props.onConfirmMove}
|
||||
onCancel={props.onCancelMove}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_folder')}</span>
|
||||
<select className="input" value={props.moveFolderId} onInput={(e) => props.onMoveFolderIdChange((e.currentTarget as HTMLSelectElement).value)}>
|
||||
@@ -143,13 +177,40 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog open={props.createFolderOpen} title={t('txt_create_folder')} message={t('txt_enter_a_folder_name')} confirmText={t('txt_create')} cancelText={t('txt_cancel')} onConfirm={props.onConfirmCreateFolder} onCancel={props.onCancelCreateFolder}>
|
||||
<ConfirmDialog
|
||||
open={props.createFolderOpen}
|
||||
title={t('txt_create_folder')}
|
||||
message={t('txt_enter_a_folder_name')}
|
||||
confirmText={t('txt_create')}
|
||||
cancelText={t('txt_cancel')}
|
||||
confirmDisabled={props.busy}
|
||||
cancelDisabled={props.busy}
|
||||
onConfirm={props.onConfirmCreateFolder}
|
||||
onCancel={props.onCancelCreateFolder}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_folder_name')}</span>
|
||||
<input className="input" value={props.newFolderName} onInput={(e) => props.onNewFolderNameChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={props.renameFolderOpen}
|
||||
title={t('txt_edit')}
|
||||
message={t('txt_enter_a_folder_name')}
|
||||
confirmText={t('txt_save')}
|
||||
cancelText={t('txt_cancel')}
|
||||
confirmDisabled={props.busy}
|
||||
cancelDisabled={props.busy}
|
||||
onConfirm={props.onConfirmRenameFolder}
|
||||
onCancel={props.onCancelRenameFolder}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_folder_name')}</span>
|
||||
<input className="input" value={props.renameFolderName} onInput={(e) => props.onRenameFolderNameChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!props.pendingDeleteFolder}
|
||||
title={t('txt_delete_folder')}
|
||||
@@ -157,18 +218,53 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
||||
confirmText={t('txt_delete')}
|
||||
cancelText={t('txt_cancel')}
|
||||
danger
|
||||
confirmDisabled={props.busy}
|
||||
cancelDisabled={props.busy}
|
||||
onConfirm={props.onConfirmDeleteFolder}
|
||||
onCancel={props.onCancelDeleteFolder}
|
||||
/>
|
||||
|
||||
<ConfirmDialog open={props.deleteAllFoldersOpen} title={t('txt_delete_all_folders')} message={t('txt_delete_all_folders_message')} confirmText={t('txt_delete')} cancelText={t('txt_cancel')} danger onConfirm={props.onConfirmDeleteAllFolders} onCancel={props.onCancelDeleteAllFolders} />
|
||||
<ConfirmDialog
|
||||
open={props.deleteAllFoldersOpen}
|
||||
title={t('txt_delete_all_folders')}
|
||||
message={t('txt_delete_all_folders_message')}
|
||||
confirmText={t('txt_delete')}
|
||||
cancelText={t('txt_cancel')}
|
||||
danger
|
||||
confirmDisabled={props.busy}
|
||||
cancelDisabled={props.busy}
|
||||
onConfirm={props.onConfirmDeleteAllFolders}
|
||||
onCancel={props.onCancelDeleteAllFolders}
|
||||
/>
|
||||
|
||||
<ConfirmDialog open={props.repromptOpen} title={t('txt_unlock_item')} message={t('txt_enter_master_password_to_view_this_item')} confirmText={t('txt_unlock')} cancelText={t('txt_cancel')} showIcon={false} onConfirm={props.onConfirmReprompt} onCancel={props.onCancelReprompt}>
|
||||
<ConfirmDialog
|
||||
open={props.repromptOpen}
|
||||
title={t('txt_unlock_item')}
|
||||
message={t('txt_enter_master_password_to_view_this_item')}
|
||||
confirmText={t('txt_unlock')}
|
||||
cancelText={t('txt_cancel')}
|
||||
showIcon={false}
|
||||
confirmDisabled={props.busy}
|
||||
cancelDisabled={props.busy}
|
||||
onConfirm={props.onConfirmReprompt}
|
||||
onCancel={props.onCancelReprompt}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_master_password')}</span>
|
||||
<input className="input" type="password" value={props.repromptPassword} onInput={(e) => props.onRepromptPasswordChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={props.deletePasskeyOpen}
|
||||
title={t('txt_delete_passkey')}
|
||||
message={t('txt_are_you_sure_you_want_to_delete_this_passkey')}
|
||||
confirmText={t('txt_delete')}
|
||||
cancelText={t('txt_cancel')}
|
||||
danger
|
||||
onConfirm={props.onConfirmDeletePasskey}
|
||||
onCancel={props.onCancelDeletePasskey}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RefObject } from 'preact';
|
||||
import type { JSX, RefObject } from 'preact';
|
||||
import { CheckCheck, Download, GripVertical, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import {
|
||||
@@ -20,7 +20,15 @@ import {
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { CREATE_TYPE_OPTIONS, cipherTypeLabel, createEmptyLoginUri, formatAttachmentSize, toBooleanFieldValue, WEBSITE_MATCH_OPTIONS } from '@/components/vault/vault-page-helpers';
|
||||
import {
|
||||
CREATE_TYPE_OPTIONS,
|
||||
cipherTypeLabel,
|
||||
createEmptyLoginUri,
|
||||
formatAttachmentSize,
|
||||
formatHistoryTime,
|
||||
toBooleanFieldValue,
|
||||
WEBSITE_MATCH_OPTIONS,
|
||||
} from '@/components/vault/vault-page-helpers';
|
||||
|
||||
interface VaultEditorProps {
|
||||
draft: VaultDraft;
|
||||
@@ -44,6 +52,7 @@ interface VaultEditorProps {
|
||||
onUpdateDraftLoginUri: (index: number, value: string) => void;
|
||||
onUpdateDraftLoginUriMatch: (index: number, value: number | null) => void;
|
||||
onReorderDraftLoginUri: (fromIndex: number, toIndex: number) => void;
|
||||
onRequestDeleteLoginPasskey: (index: number) => void;
|
||||
onQueueAttachmentFiles: (list: FileList | null) => void;
|
||||
onToggleExistingAttachmentRemoval: (attachmentId: string) => void;
|
||||
onRemoveQueuedAttachment: (index: number) => void;
|
||||
@@ -71,6 +80,7 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
|
||||
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: props.id,
|
||||
});
|
||||
const dragButtonAttributes = attributes as JSX.HTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -89,7 +99,7 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
|
||||
className="btn btn-secondary small website-drag-btn"
|
||||
title={t('txt_drag_to_reorder')}
|
||||
aria-label={t('txt_drag_to_reorder')}
|
||||
{...attributes}
|
||||
{...dragButtonAttributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical size={14} className="btn-icon" />
|
||||
@@ -287,6 +297,42 @@ export default function VaultEditor(props: VaultEditorProps) {
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{props.draft.loginFido2Credentials.length > 0 && (
|
||||
<>
|
||||
<div className="section-head" style={{ marginTop: '18px' }}>
|
||||
<h4>{t('txt_passkeys')}</h4>
|
||||
</div>
|
||||
<div className="attachment-list">
|
||||
{props.draft.loginFido2Credentials.map((credential, index) => {
|
||||
const createdAt = String(credential?.creationDate || '').trim();
|
||||
const label = createdAt
|
||||
? t('txt_passkey_created_at_value', { value: formatHistoryTime(createdAt) })
|
||||
: t('txt_passkey');
|
||||
return (
|
||||
<div key={`login-passkey-${index}`} className="attachment-row">
|
||||
<div className="attachment-main">
|
||||
<div className="attachment-text">
|
||||
<strong>{t('txt_passkey')}</strong>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kv-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small"
|
||||
disabled={props.busy}
|
||||
onClick={() => props.onRequestDeleteLoginPasskey(index)}
|
||||
>
|
||||
<X size={14} className="btn-icon" />
|
||||
{t('txt_remove')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ interface VaultListPanelProps {
|
||||
sortMenuRef: RefObject<HTMLDivElement>;
|
||||
listPanelRef: RefObject<HTMLDivElement>;
|
||||
onSearchInput: (value: string) => void;
|
||||
onClearSearch: () => void;
|
||||
onSearchCompositionStart: () => void;
|
||||
onSearchCompositionEnd: (value: string) => void;
|
||||
onToggleSortMenu: () => void;
|
||||
@@ -62,14 +63,32 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
||||
return (
|
||||
<section className="list-col">
|
||||
<div className="list-head">
|
||||
<input
|
||||
className="search-input"
|
||||
placeholder={t('txt_search_your_secure_vault')}
|
||||
value={props.searchInput}
|
||||
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
|
||||
onCompositionStart={props.onSearchCompositionStart}
|
||||
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
<div className="search-input-wrap">
|
||||
<input
|
||||
className="search-input"
|
||||
placeholder={t('txt_search_your_secure_vault')}
|
||||
value={props.searchInput}
|
||||
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
|
||||
onCompositionStart={props.onSearchCompositionStart}
|
||||
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Escape' || !props.searchInput) return;
|
||||
e.preventDefault();
|
||||
props.onClearSearch();
|
||||
}}
|
||||
/>
|
||||
{!!props.searchInput && (
|
||||
<button
|
||||
type="button"
|
||||
className="search-clear-btn"
|
||||
aria-label={t('txt_clear_search')}
|
||||
title={t('txt_clear_search_esc')}
|
||||
onClick={props.onClearSearch}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Globe,
|
||||
KeyRound,
|
||||
LayoutGrid,
|
||||
Pencil,
|
||||
ShieldUser,
|
||||
Star,
|
||||
StickyNote,
|
||||
@@ -28,6 +29,7 @@ interface VaultSidebarProps {
|
||||
onChangeFilter: (filter: SidebarFilter) => void;
|
||||
onOpenDeleteAllFolders: () => void;
|
||||
onOpenCreateFolder: () => void;
|
||||
onOpenRenameFolder: (folder: Folder) => void;
|
||||
onOpenDeleteFolder: (folder: Folder) => void;
|
||||
}
|
||||
|
||||
@@ -113,6 +115,20 @@ export default function VaultSidebar(props: VaultSidebarProps) {
|
||||
{folder.decName || folder.name || folder.id}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="folder-delete-btn folder-edit-btn"
|
||||
title={t('txt_edit')}
|
||||
aria-label={t('txt_edit')}
|
||||
disabled={props.busy}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
props.onOpenRenameFolder(folder);
|
||||
}}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="folder-delete-btn"
|
||||
|
||||
@@ -165,7 +165,7 @@ export function websiteIconUrl(host: string): string {
|
||||
}
|
||||
|
||||
export function createEmptyLoginUri(): VaultDraftLoginUri {
|
||||
return { uri: '', match: null };
|
||||
return { uri: '', match: null, originalUri: '', extra: {} };
|
||||
}
|
||||
|
||||
export function websiteMatchLabel(value: number | null | undefined): string {
|
||||
@@ -313,6 +313,10 @@ export function draftFromCipher(cipher: Cipher): VaultDraft {
|
||||
draft.loginUris = (cipher.login.uris || []).map((x) => ({
|
||||
uri: x.decUri || x.uri || '',
|
||||
match: x.match ?? null,
|
||||
originalUri: x.decUri || x.uri || '',
|
||||
extra: Object.fromEntries(
|
||||
Object.entries(x as Record<string, unknown>).filter(([key]) => !['uri', 'match', 'decUri'].includes(key))
|
||||
),
|
||||
}));
|
||||
draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials)
|
||||
? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import {
|
||||
type BackupExportClientProgressEvent,
|
||||
buildCompleteAdminBackupExport,
|
||||
deleteRemoteBackup,
|
||||
downloadRemoteBackup,
|
||||
downloadRemoteBackup as fetchRemoteBackupPayload,
|
||||
getAdminBackupSettings,
|
||||
importAdminBackup,
|
||||
inspectRemoteBackupIntegrity,
|
||||
listRemoteBackups,
|
||||
restoreRemoteBackup,
|
||||
restoreRemoteBackup as restoreRemoteBackupRequest,
|
||||
runAdminBackupNow,
|
||||
saveAdminBackupSettings,
|
||||
} from '@/lib/api/backup';
|
||||
import { downloadBytesAsFile } from '@/lib/download';
|
||||
import { dispatchBackupProgress } from '@/lib/backup-restore-progress';
|
||||
import type { AuthedFetch } from '@/lib/api/shared';
|
||||
|
||||
interface UseBackupActionsOptions {
|
||||
@@ -25,8 +28,24 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
|
||||
return useMemo(
|
||||
() => ({
|
||||
async exportBackup(includeAttachments: boolean = false) {
|
||||
const payload = await buildCompleteAdminBackupExport(authedFetch, includeAttachments);
|
||||
const payload = await buildCompleteAdminBackupExport(
|
||||
authedFetch,
|
||||
includeAttachments,
|
||||
async (event: BackupExportClientProgressEvent) => {
|
||||
dispatchBackupProgress(event);
|
||||
}
|
||||
);
|
||||
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
|
||||
dispatchBackupProgress({
|
||||
operation: 'backup-export',
|
||||
source: 'local',
|
||||
step: 'export_complete',
|
||||
fileName: payload.fileName,
|
||||
stageTitle: 'txt_backup_export_progress_complete_title',
|
||||
stageDetail: 'txt_backup_export_progress_complete_detail',
|
||||
done: true,
|
||||
ok: true,
|
||||
});
|
||||
},
|
||||
|
||||
async importBackup(file: File, replaceExisting: boolean = false) {
|
||||
@@ -35,6 +54,12 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
|
||||
return result;
|
||||
},
|
||||
|
||||
async importBackupAllowingChecksumMismatch(file: File, replaceExisting: boolean = false) {
|
||||
const result = await importAdminBackup(authedFetch, file, replaceExisting, true);
|
||||
onImported?.();
|
||||
return result;
|
||||
},
|
||||
|
||||
async loadSettings() {
|
||||
return getAdminBackupSettings(authedFetch);
|
||||
},
|
||||
@@ -52,16 +77,26 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
|
||||
},
|
||||
|
||||
async downloadRemoteBackup(destinationId: string, path: string, onProgress?: (percent: number | null) => void) {
|
||||
const payload = await downloadRemoteBackup(authedFetch, destinationId, path, onProgress);
|
||||
const payload = await fetchRemoteBackupPayload(authedFetch, destinationId, path, onProgress);
|
||||
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
|
||||
},
|
||||
|
||||
async inspectRemoteBackup(destinationId: string, path: string) {
|
||||
return inspectRemoteBackupIntegrity(authedFetch, destinationId, path);
|
||||
},
|
||||
|
||||
async deleteRemoteBackup(destinationId: string, path: string) {
|
||||
await deleteRemoteBackup(authedFetch, destinationId, path);
|
||||
},
|
||||
|
||||
async restoreRemoteBackup(destinationId: string, path: string, replaceExisting: boolean = false) {
|
||||
const result = await restoreRemoteBackup(authedFetch, destinationId, path, replaceExisting);
|
||||
const result = await restoreRemoteBackupRequest(authedFetch, destinationId, path, replaceExisting);
|
||||
onRestored?.();
|
||||
return result;
|
||||
},
|
||||
|
||||
async restoreRemoteBackupAllowingChecksumMismatch(destinationId: string, path: string, replaceExisting: boolean = false) {
|
||||
const result = await restoreRemoteBackupRequest(authedFetch, destinationId, path, replaceExisting, true);
|
||||
onRestored?.();
|
||||
return result;
|
||||
},
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
type CiphersImportPayload,
|
||||
type ImportedCipherMapEntry,
|
||||
updateCipher,
|
||||
updateFolder,
|
||||
unarchiveCipher,
|
||||
uploadCipherAttachment,
|
||||
} from '@/lib/api/vault';
|
||||
@@ -340,6 +341,28 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
}
|
||||
},
|
||||
|
||||
async renameFolder(folderId: string, name: string) {
|
||||
const id = String(folderId || '').trim();
|
||||
const nextName = String(name || '').trim();
|
||||
if (!id) {
|
||||
onNotify('error', t('txt_folder_not_found'));
|
||||
return;
|
||||
}
|
||||
if (!nextName) {
|
||||
onNotify('error', t('txt_folder_name_is_required'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
||||
await updateFolder(authedFetch, session, id, nextName);
|
||||
await refetchFolders();
|
||||
onNotify('success', t('txt_folder_updated'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_update_folder_failed'));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async bulkRestoreVaultItems(ids: string[]) {
|
||||
try {
|
||||
await bulkRestoreCiphers(authedFetch, ids);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64ToBytes, decryptBw } from './crypto';
|
||||
import { base64ToBytes, decryptBw, toBufferSource } from './crypto';
|
||||
import type { AdminBackupSettings, BackupSettingsPortablePayload } from './api/backup';
|
||||
import type { Profile, SessionState } from './types';
|
||||
|
||||
@@ -9,7 +9,7 @@ const AES_GCM_ALGORITHM = 'AES-GCM';
|
||||
async function importPortablePrivateKey(pkcs8: Uint8Array): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
pkcs8,
|
||||
toBufferSource(pkcs8),
|
||||
{ name: PORTABLE_ALGORITHM, hash: PORTABLE_HASH },
|
||||
false,
|
||||
['decrypt']
|
||||
@@ -17,7 +17,7 @@ async function importPortablePrivateKey(pkcs8: Uint8Array): Promise<CryptoKey> {
|
||||
}
|
||||
|
||||
async function importPortableAesKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey('raw', keyBytes, { name: AES_GCM_ALGORITHM }, false, ['decrypt']);
|
||||
return crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: AES_GCM_ALGORITHM }, false, ['decrypt']);
|
||||
}
|
||||
|
||||
export async function decryptPortableBackupSettings(
|
||||
@@ -50,15 +50,15 @@ export async function decryptPortableBackupSettings(
|
||||
await crypto.subtle.decrypt(
|
||||
{ name: PORTABLE_ALGORITHM },
|
||||
privateKey,
|
||||
base64ToBytes(wrap.wrappedKey)
|
||||
toBufferSource(base64ToBytes(wrap.wrappedKey))
|
||||
)
|
||||
);
|
||||
const aesKey = await importPortableAesKey(portableDek);
|
||||
const plaintext = new Uint8Array(
|
||||
await crypto.subtle.decrypt(
|
||||
{ name: AES_GCM_ALGORITHM, iv: base64ToBytes(portable.iv) },
|
||||
{ name: AES_GCM_ALGORITHM, iv: toBufferSource(base64ToBytes(portable.iv)) },
|
||||
aesKey,
|
||||
base64ToBytes(portable.ciphertext)
|
||||
toBufferSource(base64ToBytes(portable.ciphertext))
|
||||
)
|
||||
);
|
||||
return JSON.parse(new TextDecoder().decode(plaintext)) as AdminBackupSettings;
|
||||
|
||||
+116
-19
@@ -10,8 +10,10 @@ import type {
|
||||
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
|
||||
|
||||
const SESSION_KEY = 'nodewarden.web.session.v4';
|
||||
const PROFILE_SNAPSHOT_KEY = 'nodewarden.web.profile-snapshot.v1';
|
||||
const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1';
|
||||
const TOTP_REMEMBER_TOKEN_KEY = 'nodewarden.web.totp.remember-token.v1';
|
||||
const WEB_SESSION_HEADER = 'X-NodeWarden-Web-Session';
|
||||
|
||||
export interface PreloginResult {
|
||||
hash: string;
|
||||
@@ -26,6 +28,24 @@ export interface PreloginKdfConfig {
|
||||
kdfParallelism: number | null;
|
||||
}
|
||||
|
||||
interface PersistedSessionState {
|
||||
email: string;
|
||||
authMode: 'token' | 'web-cookie';
|
||||
}
|
||||
|
||||
interface RefreshFailure {
|
||||
ok: false;
|
||||
transient: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface RefreshSuccess {
|
||||
ok: true;
|
||||
token: TokenSuccess;
|
||||
}
|
||||
|
||||
type RefreshResult = RefreshFailure | RefreshSuccess;
|
||||
|
||||
function randomHex(length: number): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
|
||||
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
|
||||
@@ -66,12 +86,19 @@ export function loadSession(): SessionState | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as SessionState;
|
||||
if (!parsed.accessToken || !parsed.refreshToken) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<SessionState> & Partial<PersistedSessionState>;
|
||||
if (parsed.authMode === 'web-cookie' && parsed.email) {
|
||||
return {
|
||||
email: parsed.email,
|
||||
authMode: 'web-cookie',
|
||||
};
|
||||
}
|
||||
if (!parsed.accessToken || !parsed.refreshToken || !parsed.email) return null;
|
||||
return {
|
||||
accessToken: parsed.accessToken,
|
||||
refreshToken: parsed.refreshToken,
|
||||
email: parsed.email,
|
||||
authMode: 'token',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -83,14 +110,35 @@ export function saveSession(session: SessionState | null): void {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
return;
|
||||
}
|
||||
const persisted: SessionState = {
|
||||
accessToken: session.accessToken,
|
||||
refreshToken: session.refreshToken,
|
||||
const persisted: PersistedSessionState = {
|
||||
email: session.email,
|
||||
authMode: session.authMode === 'token' ? 'token' : 'web-cookie',
|
||||
};
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(persisted));
|
||||
}
|
||||
|
||||
export function loadProfileSnapshot(email?: string | null): Profile | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(PROFILE_SNAPSHOT_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Profile;
|
||||
if (!parsed?.email || !parsed?.key) return null;
|
||||
if (email && parsed.email !== email) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveProfileSnapshot(profile: Profile | null): void {
|
||||
if (!profile) return;
|
||||
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(profile));
|
||||
}
|
||||
|
||||
export function clearProfileSnapshot(): void {
|
||||
localStorage.removeItem(PROFILE_SNAPSHOT_KEY);
|
||||
}
|
||||
|
||||
export function getCurrentDeviceIdentifier(): string {
|
||||
return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim();
|
||||
}
|
||||
@@ -170,7 +218,10 @@ export async function loginWithPassword(
|
||||
}
|
||||
const resp = await fetch('/identity/connect/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
[WEB_SESSION_HEADER]: '1',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
|
||||
@@ -183,18 +234,60 @@ export async function loginWithPassword(
|
||||
return json;
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(refreshToken: string): Promise<TokenSuccess | null> {
|
||||
function isTransientRefreshStatus(status: number): boolean {
|
||||
return status === 0 || status === 429 || status >= 500;
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(session: SessionState): Promise<RefreshResult> {
|
||||
const body = new URLSearchParams();
|
||||
body.set('grant_type', 'refresh_token');
|
||||
body.set('refresh_token', refreshToken);
|
||||
const resp = await fetch('/identity/connect/token', {
|
||||
if (session.authMode !== 'web-cookie' && session.refreshToken) {
|
||||
body.set('refresh_token', session.refreshToken);
|
||||
}
|
||||
try {
|
||||
const resp = await fetch('/identity/connect/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(session.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const json = await parseJson<TokenError>(resp);
|
||||
return {
|
||||
ok: false,
|
||||
transient: isTransientRefreshStatus(resp.status),
|
||||
error: json?.error_description || json?.error || 'Session refresh failed',
|
||||
};
|
||||
}
|
||||
const json = await parseJson<TokenSuccess>(resp);
|
||||
if (!json?.access_token) {
|
||||
return { ok: false, transient: false, error: 'Session refresh failed' };
|
||||
}
|
||||
return { ok: true, token: json };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
transient: true,
|
||||
error: error instanceof Error ? error.message : 'Network error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function revokeCurrentSession(session: SessionState | null): Promise<void> {
|
||||
const body = new URLSearchParams();
|
||||
if (session?.authMode !== 'web-cookie' && session?.refreshToken) {
|
||||
body.set('token', session.refreshToken);
|
||||
}
|
||||
await fetch('/identity/connect/revocation', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(session?.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const json = await parseJson<TokenSuccess>(resp);
|
||||
return json || null;
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function registerAccount(args: {
|
||||
@@ -279,18 +372,22 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
||||
headers.set('Authorization', `Bearer ${session.accessToken}`);
|
||||
|
||||
let resp = await fetch(input, { ...init, headers });
|
||||
if (resp.status !== 401 || !session.refreshToken) return resp;
|
||||
if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp;
|
||||
|
||||
const refreshed = await refreshAccessToken(session.refreshToken);
|
||||
if (!refreshed?.access_token) {
|
||||
const refreshed = await refreshAccessToken(session);
|
||||
if (!refreshed.ok) {
|
||||
if (refreshed.transient) {
|
||||
throw new Error(refreshed.error || 'Session refresh temporarily unavailable');
|
||||
}
|
||||
setSession(null);
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
|
||||
const nextSession: SessionState = {
|
||||
...session,
|
||||
accessToken: refreshed.access_token,
|
||||
refreshToken: refreshed.refresh_token || session.refreshToken,
|
||||
accessToken: refreshed.token.access_token,
|
||||
refreshToken: refreshed.token.refresh_token || session.refreshToken,
|
||||
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
|
||||
};
|
||||
setSession(nextSession);
|
||||
saveSession(nextSession);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type AuthedFetch,
|
||||
} from './shared';
|
||||
import { readResponseBytesWithProgress } from '../download';
|
||||
import { toBufferSource } from '../crypto';
|
||||
import { unzipSync, zipSync } from 'fflate';
|
||||
|
||||
export type {
|
||||
@@ -57,6 +58,21 @@ export interface AdminBackupRunResponse {
|
||||
settings: AdminBackupSettings;
|
||||
}
|
||||
|
||||
export interface BackupFileIntegrityCheckResult {
|
||||
hasChecksumPrefix: boolean;
|
||||
expectedPrefix: string | null;
|
||||
actualPrefix: string;
|
||||
matches: boolean;
|
||||
}
|
||||
|
||||
export interface RemoteBackupIntegrityResponse {
|
||||
object: 'backup-remote-integrity';
|
||||
destinationId: string;
|
||||
path: string;
|
||||
fileName: string;
|
||||
integrity: BackupFileIntegrityCheckResult;
|
||||
}
|
||||
|
||||
export interface RemoteBackupItem {
|
||||
path: string;
|
||||
name: string;
|
||||
@@ -109,6 +125,18 @@ export interface AdminBackupExportPayload {
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
export interface BackupExportClientProgressEvent {
|
||||
operation: 'backup-export';
|
||||
source: 'local';
|
||||
step: string;
|
||||
fileName: string;
|
||||
stageTitle: string;
|
||||
stageDetail: string;
|
||||
done?: boolean;
|
||||
ok?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
interface BackupExportManifestAttachmentBlob {
|
||||
cipherId: string;
|
||||
attachmentId: string;
|
||||
@@ -119,6 +147,25 @@ interface BackupExportManifest {
|
||||
attachmentBlobs?: BackupExportManifestAttachmentBlob[];
|
||||
}
|
||||
|
||||
const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
|
||||
|
||||
function extractBackupTimestampFromFileName(fileName: string): string | null {
|
||||
const match = String(fileName || '').match(/nodewarden_backup_(\d{8})_(\d{6})(?:_[0-9a-f]{5})?\.zip$/i);
|
||||
if (!match) return null;
|
||||
return `${match[1]}_${match[2]}`;
|
||||
}
|
||||
|
||||
function buildBackupFileName(timestamp: string, checksumPrefix: string): string {
|
||||
return `nodewarden_backup_${timestamp}_${checksumPrefix}.zip`;
|
||||
}
|
||||
|
||||
async function applyBackupFileIntegrityName(fileName: string, bytes: Uint8Array): Promise<string> {
|
||||
const integrity = await verifyBackupFileIntegrity(bytes, fileName);
|
||||
const timestamp = extractBackupTimestampFromFileName(fileName);
|
||||
if (!timestamp) return fileName;
|
||||
return buildBackupFileName(timestamp, integrity.actualPrefix);
|
||||
}
|
||||
|
||||
export async function exportAdminBackup(
|
||||
authedFetch: AuthedFetch,
|
||||
includeAttachments: boolean = false
|
||||
@@ -149,10 +196,21 @@ export async function downloadAdminBackupAttachmentBlob(
|
||||
|
||||
export async function buildCompleteAdminBackupExport(
|
||||
authedFetch: AuthedFetch,
|
||||
includeAttachments: boolean = false
|
||||
includeAttachments: boolean = false,
|
||||
onProgress?: (event: BackupExportClientProgressEvent) => void | Promise<void>
|
||||
): Promise<AdminBackupExportPayload> {
|
||||
const payload = await exportAdminBackup(authedFetch, includeAttachments);
|
||||
if (!includeAttachments) return payload;
|
||||
if (!includeAttachments) {
|
||||
await onProgress?.({
|
||||
operation: 'backup-export',
|
||||
source: 'local',
|
||||
step: 'export_client_save',
|
||||
fileName: payload.fileName,
|
||||
stageTitle: 'txt_backup_export_progress_save_title',
|
||||
stageDetail: 'txt_backup_export_progress_save_detail',
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
|
||||
const zipped = unzipSync(payload.bytes);
|
||||
const manifestBytes = zipped['manifest.json'];
|
||||
@@ -167,14 +225,41 @@ export async function buildCompleteAdminBackupExport(
|
||||
throw new Error(t('txt_backup_export_failed'));
|
||||
}
|
||||
|
||||
await onProgress?.({
|
||||
operation: 'backup-export',
|
||||
source: 'local',
|
||||
step: 'export_client_fetch_attachments',
|
||||
fileName: payload.fileName,
|
||||
stageTitle: 'txt_backup_export_progress_fetch_attachments_title',
|
||||
stageDetail: 'txt_backup_export_progress_fetch_attachments_detail',
|
||||
});
|
||||
for (const attachment of manifest.attachmentBlobs || []) {
|
||||
const bytes = await downloadAdminBackupAttachmentBlob(authedFetch, attachment.blobName);
|
||||
zipped[`attachments/${attachment.cipherId}/${attachment.attachmentId}.bin`] = bytes;
|
||||
}
|
||||
|
||||
await onProgress?.({
|
||||
operation: 'backup-export',
|
||||
source: 'local',
|
||||
step: 'export_client_rebuild',
|
||||
fileName: payload.fileName,
|
||||
stageTitle: 'txt_backup_export_progress_rebuild_title',
|
||||
stageDetail: 'txt_backup_export_progress_rebuild_detail',
|
||||
});
|
||||
const rebuiltBytes = zipSync(zipped, { level: 0 });
|
||||
const rebuiltFileName = await applyBackupFileIntegrityName(payload.fileName, rebuiltBytes);
|
||||
await onProgress?.({
|
||||
operation: 'backup-export',
|
||||
source: 'local',
|
||||
step: 'export_client_save',
|
||||
fileName: rebuiltFileName,
|
||||
stageTitle: 'txt_backup_export_progress_save_title',
|
||||
stageDetail: 'txt_backup_export_progress_save_detail',
|
||||
});
|
||||
return {
|
||||
...payload,
|
||||
bytes: zipSync(zipped, { level: 0 }),
|
||||
bytes: rebuiltBytes,
|
||||
fileName: rebuiltFileName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -276,6 +361,29 @@ export async function downloadRemoteBackup(
|
||||
return { fileName, mimeType, bytes };
|
||||
}
|
||||
|
||||
export function extractBackupFileChecksumPrefix(fileName: string): string | null {
|
||||
const normalized = String(fileName || '').trim();
|
||||
const match = normalized.match(/_([0-9a-f]{5})\.zip$/i);
|
||||
return match ? match[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', toBufferSource(bytes));
|
||||
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export async function verifyBackupFileIntegrity(bytes: Uint8Array, fileName: string): Promise<BackupFileIntegrityCheckResult> {
|
||||
const expectedPrefix = extractBackupFileChecksumPrefix(fileName);
|
||||
const actualHash = await sha256Hex(bytes);
|
||||
const actualPrefix = actualHash.slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
|
||||
return {
|
||||
hasChecksumPrefix: !!expectedPrefix,
|
||||
expectedPrefix,
|
||||
actualPrefix,
|
||||
matches: !expectedPrefix || expectedPrefix === actualPrefix,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteRemoteBackup(
|
||||
authedFetch: AuthedFetch,
|
||||
destinationId: string,
|
||||
@@ -288,16 +396,32 @@ export async function deleteRemoteBackup(
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_delete_failed')));
|
||||
}
|
||||
|
||||
export async function inspectRemoteBackupIntegrity(
|
||||
authedFetch: AuthedFetch,
|
||||
destinationId: string,
|
||||
path: string
|
||||
): Promise<RemoteBackupIntegrityResponse> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('destinationId', destinationId);
|
||||
params.set('path', path);
|
||||
const resp = await authedFetch(`/api/admin/backup/remote/integrity?${params.toString()}`, { method: 'GET' });
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed')));
|
||||
const body = await parseJson<RemoteBackupIntegrityResponse>(resp);
|
||||
if (!body?.integrity || !body?.fileName) throw new Error(t('txt_backup_remote_invalid_response'));
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function restoreRemoteBackup(
|
||||
authedFetch: AuthedFetch,
|
||||
destinationId: string,
|
||||
path: string,
|
||||
replaceExisting: boolean = false
|
||||
replaceExisting: boolean = false,
|
||||
allowChecksumMismatch: boolean = false
|
||||
): Promise<AdminBackupImportResponse> {
|
||||
const resp = await authedFetch('/api/admin/backup/remote/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ destinationId, path, replaceExisting }),
|
||||
body: JSON.stringify({ destinationId, path, replaceExisting, allowChecksumMismatch }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_restore_failed')));
|
||||
const body = await parseJson<AdminBackupImportResponse>(resp);
|
||||
@@ -308,13 +432,17 @@ export async function restoreRemoteBackup(
|
||||
export async function importAdminBackup(
|
||||
authedFetch: AuthedFetch,
|
||||
file: File,
|
||||
replaceExisting: boolean = false
|
||||
replaceExisting: boolean = false,
|
||||
allowChecksumMismatch: boolean = false
|
||||
): Promise<AdminBackupImportResponse> {
|
||||
const formData = new FormData();
|
||||
formData.set('file', file, file.name || 'nodewarden_backup.zip');
|
||||
if (replaceExisting) {
|
||||
formData.set('replaceExisting', '1');
|
||||
}
|
||||
if (allowChecksumMismatch) {
|
||||
formData.set('allowChecksumMismatch', '1');
|
||||
}
|
||||
|
||||
const resp = await authedFetch('/api/admin/backup/import', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { base64ToBytes, bytesToBase64, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData, hkdf, pbkdf2 } from '../crypto';
|
||||
import type { Send, SendDraft, SessionState } from '../types';
|
||||
import { chunkArray, createApiError, parseErrorMessage, parseJson, uploadDirectEncryptedPayload, type AuthedFetch } from './shared';
|
||||
import { loadVaultSyncSnapshot } from './vault-sync';
|
||||
|
||||
function toIsoDateFromDays(value: string, required: boolean): string | null {
|
||||
const raw = String(value || '').trim();
|
||||
@@ -61,10 +62,8 @@ function parseMaxAccessCountRaw(value: string): number | null {
|
||||
}
|
||||
|
||||
export async function getSends(authedFetch: AuthedFetch): Promise<Send[]> {
|
||||
const resp = await authedFetch('/api/sends');
|
||||
if (!resp.ok) throw new Error('Failed to load sends');
|
||||
const body = await parseJson<{ object: 'list'; data: Send[] }>(resp);
|
||||
return body?.data || [];
|
||||
const body = await loadVaultSyncSnapshot(authedFetch);
|
||||
return body.sends || [];
|
||||
}
|
||||
|
||||
export async function createSend(
|
||||
@@ -152,10 +151,13 @@ export async function createSend(
|
||||
const uploadInfo = await parseJson<{ url?: string; sendResponse?: Send; fileUploadType?: number }>(fileResp);
|
||||
const uploadUrl = uploadInfo?.url;
|
||||
if (!uploadUrl) throw new Error('Create file send failed: missing upload URL');
|
||||
if (!session.accessToken) throw new Error('Unauthorized');
|
||||
const payload = new ArrayBuffer(encryptedFileBytes.byteLength);
|
||||
new Uint8Array(payload).set(encryptedFileBytes);
|
||||
const uploadResp = await uploadDirectEncryptedPayload({
|
||||
accessToken: session.accessToken,
|
||||
uploadUrl,
|
||||
payload: encryptedFileBytes,
|
||||
payload,
|
||||
fileUploadType: uploadInfo?.fileUploadType,
|
||||
unsupportedMessage: 'Unsupported send upload type',
|
||||
onProgress,
|
||||
|
||||
@@ -63,14 +63,14 @@ interface UploadWithProgressOptions {
|
||||
accessToken?: string;
|
||||
method?: string;
|
||||
headers?: HeadersInit;
|
||||
body?: Document | XMLHttpRequestBodyInit | null;
|
||||
body?: XMLHttpRequestBodyInit | null;
|
||||
onProgress?: (percent: number | null) => void;
|
||||
}
|
||||
|
||||
interface DirectEncryptedUploadOptions {
|
||||
accessToken: string;
|
||||
uploadUrl: string;
|
||||
payload: ArrayBuffer | Uint8Array;
|
||||
payload: XMLHttpRequestBodyInit;
|
||||
fileUploadType: number | null | undefined;
|
||||
unsupportedMessage: string;
|
||||
onProgress?: (percent: number | null) => void;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Cipher, Folder, Send } from '../types';
|
||||
import { parseJson, type AuthedFetch } from './shared';
|
||||
|
||||
interface VaultSyncResponse {
|
||||
ciphers?: Cipher[];
|
||||
folders?: Folder[];
|
||||
sends?: Send[];
|
||||
}
|
||||
|
||||
const pendingSyncRequests = new WeakMap<AuthedFetch, Promise<VaultSyncResponse>>();
|
||||
|
||||
export async function loadVaultSyncSnapshot(authedFetch: AuthedFetch): Promise<VaultSyncResponse> {
|
||||
const existing = pendingSyncRequests.get(authedFetch);
|
||||
if (existing) return existing;
|
||||
|
||||
const request = (async () => {
|
||||
const resp = await authedFetch('/api/sync');
|
||||
if (!resp.ok) throw new Error('Failed to load vault');
|
||||
const body = await parseJson<VaultSyncResponse>(resp);
|
||||
return body || {};
|
||||
})();
|
||||
|
||||
pendingSyncRequests.set(authedFetch, request);
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
if (pendingSyncRequests.get(authedFetch) === request) {
|
||||
pendingSyncRequests.delete(authedFetch);
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-11
@@ -2,7 +2,6 @@ import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, encryptBw, enc
|
||||
import type {
|
||||
Cipher,
|
||||
Folder,
|
||||
ListResponse,
|
||||
SessionState,
|
||||
VaultDraft,
|
||||
VaultDraftField,
|
||||
@@ -16,12 +15,11 @@ import {
|
||||
type AuthedFetch,
|
||||
} from './shared';
|
||||
import { readResponseBytesWithProgress } from '../download';
|
||||
import { loadVaultSyncSnapshot } from './vault-sync';
|
||||
|
||||
export async function getFolders(authedFetch: AuthedFetch): Promise<Folder[]> {
|
||||
const resp = await authedFetch('/api/folders');
|
||||
if (!resp.ok) throw new Error('Failed to load folders');
|
||||
const body = await parseJson<ListResponse<Folder>>(resp);
|
||||
return body?.data || [];
|
||||
const body = await loadVaultSyncSnapshot(authedFetch);
|
||||
return body.folders || [];
|
||||
}
|
||||
|
||||
export async function createFolder(
|
||||
@@ -93,10 +91,8 @@ export async function updateFolder(
|
||||
}
|
||||
|
||||
export async function getCiphers(authedFetch: AuthedFetch): Promise<Cipher[]> {
|
||||
const resp = await authedFetch('/api/ciphers?deleted=true');
|
||||
if (!resp.ok) throw new Error('Failed to load ciphers');
|
||||
const body = await parseJson<ListResponse<Cipher>>(resp);
|
||||
return body?.data || [];
|
||||
const body = await loadVaultSyncSnapshot(authedFetch);
|
||||
return body.ciphers || [];
|
||||
}
|
||||
|
||||
export interface CiphersImportPayload {
|
||||
@@ -240,6 +236,7 @@ export async function uploadCipherAttachment(
|
||||
const attachmentId = String(meta.attachmentId || '').trim();
|
||||
const uploadUrl = String(meta.url || '').trim();
|
||||
if (!attachmentId || !uploadUrl) throw new Error('Create attachment failed');
|
||||
if (!session.accessToken) throw new Error('Unauthorized');
|
||||
|
||||
const payload = new ArrayBuffer(encryptedBytes.byteLength);
|
||||
new Uint8Array(payload).set(encryptedBytes);
|
||||
@@ -371,12 +368,20 @@ async function encryptUris(
|
||||
uris: VaultDraft['loginUris'],
|
||||
enc: Uint8Array,
|
||||
mac: Uint8Array
|
||||
): Promise<Array<{ uri: string | null; match: number | null }>> {
|
||||
const out: Array<{ uri: string | null; match: number | null }> = [];
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
const out: Array<Record<string, unknown>> = [];
|
||||
for (const entry of uris || []) {
|
||||
const trimmed = String(entry?.uri || '').trim();
|
||||
if (!trimmed) continue;
|
||||
const preservedExtra =
|
||||
entry?.extra && typeof entry.extra === 'object'
|
||||
? { ...entry.extra }
|
||||
: {};
|
||||
if (String(entry?.originalUri || '').trim() !== trimmed) {
|
||||
delete preservedExtra.uriChecksum;
|
||||
}
|
||||
out.push({
|
||||
...preservedExtra,
|
||||
uri: await encryptTextValue(trimmed, enc, mac),
|
||||
match: typeof entry?.match === 'number' && Number.isFinite(entry.match) ? entry.match : null,
|
||||
});
|
||||
@@ -494,7 +499,12 @@ async function buildCipherPayload(
|
||||
cipher?.login && Array.isArray((cipher.login as any).fido2Credentials)
|
||||
? (cipher.login as any).fido2Credentials
|
||||
: draft.loginFido2Credentials;
|
||||
const existingLogin =
|
||||
cipher?.login && typeof cipher.login === 'object'
|
||||
? { ...(cipher.login as Record<string, unknown>) }
|
||||
: {};
|
||||
payload.login = {
|
||||
...existingLogin,
|
||||
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
|
||||
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
|
||||
totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac),
|
||||
|
||||
+48
-22
@@ -2,6 +2,7 @@ import {
|
||||
createAuthedFetch,
|
||||
deriveLoginHashLocally,
|
||||
getProfile,
|
||||
loadProfileSnapshot,
|
||||
loadSession,
|
||||
loginWithPassword,
|
||||
refreshAccessToken,
|
||||
@@ -26,6 +27,7 @@ export interface BootstrapAppResult {
|
||||
session: SessionState | null;
|
||||
profile: Profile | null;
|
||||
phase: AppPhase;
|
||||
needsBackgroundHydration?: boolean;
|
||||
}
|
||||
|
||||
export interface InitialAppBootstrapState {
|
||||
@@ -51,8 +53,9 @@ export interface RecoverTwoFactorResult {
|
||||
newRecoveryCode: string | null;
|
||||
}
|
||||
|
||||
function decodeJwtExp(accessToken: string): number | null {
|
||||
function decodeJwtExp(accessToken: string | undefined): number | null {
|
||||
try {
|
||||
if (!accessToken) return null;
|
||||
const parts = accessToken.split('.');
|
||||
if (parts.length < 2) return null;
|
||||
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
@@ -66,23 +69,24 @@ function decodeJwtExp(accessToken: string): number | null {
|
||||
}
|
||||
|
||||
async function maybeRefreshSession(session: SessionState): Promise<SessionState | null> {
|
||||
if (!session.refreshToken) return session;
|
||||
if (!session.refreshToken && session.authMode !== 'web-cookie') return session.accessToken ? session : null;
|
||||
const exp = decodeJwtExp(session.accessToken);
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (exp !== null && exp - nowSeconds > 60) {
|
||||
if (session.accessToken && exp !== null && exp - nowSeconds > 60) {
|
||||
return session;
|
||||
}
|
||||
|
||||
const refreshed = await refreshAccessToken(session.refreshToken);
|
||||
if (!refreshed?.access_token) {
|
||||
return exp !== null && exp > nowSeconds ? session : null;
|
||||
const refreshed = await refreshAccessToken(session);
|
||||
if (!refreshed.ok) {
|
||||
return session.accessToken && exp !== null && exp > nowSeconds ? session : null;
|
||||
}
|
||||
|
||||
return {
|
||||
...session,
|
||||
accessToken: refreshed.access_token,
|
||||
refreshToken: refreshed.refresh_token || session.refreshToken,
|
||||
accessToken: refreshed.token.access_token,
|
||||
refreshToken: refreshed.token.refresh_token || session.refreshToken,
|
||||
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -197,31 +201,51 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
||||
};
|
||||
}
|
||||
|
||||
const cachedProfile = loadProfileSnapshot(loaded.email);
|
||||
if (cachedProfile) {
|
||||
return {
|
||||
defaultKdfIterations,
|
||||
jwtWarning: null,
|
||||
session: loaded,
|
||||
profile: cachedProfile,
|
||||
phase: 'locked',
|
||||
needsBackgroundHydration: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
defaultKdfIterations,
|
||||
jwtWarning: null,
|
||||
session: loaded,
|
||||
profile: null,
|
||||
phase: 'locked',
|
||||
needsBackgroundHydration: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function hydrateLockedSession(
|
||||
session: SessionState,
|
||||
fallbackProfile: Profile | null = null
|
||||
): Promise<{ session: SessionState | null; profile: Profile | null }> {
|
||||
const refreshedSession = await maybeRefreshSession(session);
|
||||
if (!refreshedSession?.accessToken) {
|
||||
return { session: null, profile: null };
|
||||
}
|
||||
try {
|
||||
const session = await maybeRefreshSession(loaded);
|
||||
if (!session) {
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
const profile = await getProfile(
|
||||
createAuthedFetch(
|
||||
() => session,
|
||||
() => refreshedSession,
|
||||
() => {}
|
||||
)
|
||||
);
|
||||
return {
|
||||
defaultKdfIterations,
|
||||
jwtWarning: null,
|
||||
session,
|
||||
session: refreshedSession,
|
||||
profile,
|
||||
phase: 'locked',
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
defaultKdfIterations,
|
||||
jwtWarning: null,
|
||||
session: null,
|
||||
profile: null,
|
||||
phase: initial.phase === 'register' ? 'register' : 'login',
|
||||
session: refreshedSession,
|
||||
profile: fallbackProfile,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -236,6 +260,7 @@ export async function completeLogin(
|
||||
accessToken: token.access_token,
|
||||
refreshToken: token.refresh_token,
|
||||
email: normalizedEmail,
|
||||
authMode: token.web_session ? 'web-cookie' : 'token',
|
||||
};
|
||||
const tempFetch = createAuthedFetch(
|
||||
() => baseSession,
|
||||
@@ -359,3 +384,4 @@ export async function performUnlock(
|
||||
}
|
||||
return { ...refreshedSession, ...keys };
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface WebVaultSignalRInvocation {
|
||||
UserId?: string;
|
||||
Date?: string;
|
||||
RevisionDate?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
@@ -160,11 +161,6 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
|
||||
draft.loginUsername = asText(login.username);
|
||||
draft.loginPassword = asText(login.password);
|
||||
draft.loginTotp = asText(login.totp);
|
||||
draft.loginFido2Credentials = Array.isArray(login.fido2Credentials)
|
||||
? login.fido2Credentials
|
||||
.filter((credential): credential is Record<string, unknown> => !!credential && typeof credential === 'object')
|
||||
.map((credential) => ({ ...credential }))
|
||||
: [];
|
||||
const urisRaw = Array.isArray(login.uris) ? login.uris : [];
|
||||
const uris = urisRaw
|
||||
.map((u) => {
|
||||
@@ -174,10 +170,17 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
|
||||
return {
|
||||
uri,
|
||||
match: typeof matchRaw === 'number' && Number.isFinite(matchRaw) ? matchRaw : null,
|
||||
originalUri: uri,
|
||||
extra: Object.fromEntries(
|
||||
Object.entries(row).filter(([key]) => !['uri', 'match'].includes(key))
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((u) => !!u.uri);
|
||||
draft.loginUris = uris.length ? uris : [{ uri: '', match: null }];
|
||||
draft.loginUris = uris.length ? uris : [{ uri: '', match: null, originalUri: '', extra: {} }];
|
||||
draft.loginFido2Credentials = Array.isArray(login.fido2Credentials)
|
||||
? login.fido2Credentials.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
|
||||
: [];
|
||||
} else if (type === 3) {
|
||||
const card = (cipher.card || {}) as Record<string, unknown>;
|
||||
draft.cardholderName = asText(card.cardholderName);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export type BackupProgressOperation = 'backup-restore' | 'backup-export' | 'backup-remote-run';
|
||||
|
||||
export interface BackupProgressDetail {
|
||||
operation: BackupProgressOperation;
|
||||
source?: 'local' | 'remote';
|
||||
step: string;
|
||||
fileName: string;
|
||||
stageTitle?: string;
|
||||
stageDetail?: string;
|
||||
replaceExisting?: boolean;
|
||||
done?: boolean;
|
||||
ok?: boolean;
|
||||
error?: string | null;
|
||||
Date?: string;
|
||||
}
|
||||
|
||||
export type BackupRestoreProgressDetail = BackupProgressDetail;
|
||||
|
||||
export const BACKUP_PROGRESS_EVENT = 'nodewarden:backup-progress';
|
||||
export const BACKUP_RESTORE_PROGRESS_EVENT = BACKUP_PROGRESS_EVENT;
|
||||
|
||||
export function dispatchBackupProgress(detail: BackupProgressDetail): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(new CustomEvent<BackupProgressDetail>(BACKUP_PROGRESS_EVENT, { detail }));
|
||||
}
|
||||
|
||||
export const dispatchBackupRestoreProgress = dispatchBackupProgress;
|
||||
@@ -18,7 +18,7 @@ export function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
|
||||
return out;
|
||||
}
|
||||
|
||||
function toBufferSource(bytes: Uint8Array): ArrayBuffer {
|
||||
export function toBufferSource(bytes: Uint8Array): ArrayBuffer {
|
||||
return new Uint8Array(bytes).buffer;
|
||||
}
|
||||
|
||||
|
||||
@@ -293,7 +293,9 @@ async function mapCipherPlain(cipher: Cipher, userEnc: Uint8Array, userMac: Uint
|
||||
)
|
||||
: [],
|
||||
fido2Credentials: Array.isArray(cipher.login.fido2Credentials)
|
||||
? await Promise.all(cipher.login.fido2Credentials.map((credential) => deepDecryptUnknown(credential, keyParts.enc, keyParts.mac)))
|
||||
? await Promise.all(
|
||||
cipher.login.fido2Credentials.map((credential) => deepDecryptUnknown(credential, keyParts.enc, keyParts.mac))
|
||||
)
|
||||
: [],
|
||||
};
|
||||
} else {
|
||||
|
||||
+172
-11
@@ -26,11 +26,16 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_backup_export_success: "Backup exported",
|
||||
txt_backup_import_success_relogin: "Backup restored. Please sign in again.",
|
||||
txt_backup_restore_success_relogin: "Backup restored. Please sign in again.",
|
||||
txt_backup_restore_completed_verified: "Backup file integrity verification passed.",
|
||||
txt_backup_restore_completed_without_checksum: "Backup restored. No filename integrity marker was available for verification.",
|
||||
txt_backup_remote_restore_completed_verified: "Remote backup integrity verification passed.",
|
||||
txt_backup_remote_restore_completed_without_checksum: "Remote backup restored. No filename integrity marker was available for verification.",
|
||||
txt_backup_restore_skipped_summary: "{reason}. Skipped {attachments} attachment(s).",
|
||||
txt_backup_restore_skipped_reason_default: "Some files could not be restored",
|
||||
txt_backup_export_failed: "Backup export failed",
|
||||
txt_backup_import_failed: "Backup restore failed",
|
||||
txt_backup_restore_failed: "Backup restore failed",
|
||||
txt_backup_integrity_check_failed: "Backup integrity verification failed",
|
||||
txt_backup_center_title: "Instance Backup",
|
||||
txt_backup_center_description: "Keep local exports for manual restore, and configure one daily remote backup target for unattended protection.",
|
||||
txt_backup_restore_note: "Restoring will overwrite the current instance if you choose the replace flow.",
|
||||
@@ -99,6 +104,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_backup_run_manual: "Run Manually",
|
||||
txt_backup_running_now: "Running...",
|
||||
txt_backup_remote_run_success: "Remote backup completed",
|
||||
txt_backup_remote_run_success_verified: "Remote backup completed and integrity verification passed.",
|
||||
txt_backup_remote_run_failed: "Remote backup failed",
|
||||
txt_backup_remote_title: "Remote Backups",
|
||||
txt_backup_remote_note: "Browse the saved destination and choose a backup ZIP to download or restore.",
|
||||
@@ -112,6 +118,68 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_backup_remote_restore: "Restore",
|
||||
txt_backup_remote_restore_stage_prepare: "Preparing remote backup restore...",
|
||||
txt_backup_remote_restore_stage_replace: "Clearing current data and restoring remote backup...",
|
||||
txt_backup_progress_kicker: "Backup Task",
|
||||
txt_backup_progress_subject: "Current item: {name}",
|
||||
txt_backup_restore_progress_kicker: "Restore Progress",
|
||||
txt_backup_restore_progress_local_title: "Restoring local backup",
|
||||
txt_backup_restore_progress_remote_title: "Restoring remote backup",
|
||||
txt_backup_export_progress_title: "Exporting backup",
|
||||
txt_backup_remote_run_progress_title: "Running remote backup",
|
||||
txt_backup_restore_progress_file: "Current file: {name}",
|
||||
txt_backup_restore_progress_elapsed: "{seconds}s elapsed",
|
||||
txt_backup_archive_progress_collect_title: "Collecting vault data",
|
||||
txt_backup_archive_progress_collect_detail: "The server is reading database tables and assembling the backup payload.",
|
||||
txt_backup_archive_progress_collect_with_attachments_detail: "The server is reading database tables and collecting attachment metadata for the backup payload.",
|
||||
txt_backup_archive_progress_package_title: "Packaging backup archive",
|
||||
txt_backup_archive_progress_package_detail: "The server is generating the backup ZIP and computing its checksum prefix.",
|
||||
txt_backup_archive_progress_package_with_attachments_detail: "The server is generating the backup ZIP metadata and computing its checksum prefix for the attachment-aware export.",
|
||||
txt_backup_archive_progress_ready_title: "Preparing download",
|
||||
txt_backup_archive_progress_ready_detail: "The backup archive is ready and is being returned to the browser.",
|
||||
txt_backup_export_progress_fetch_attachments_title: "Downloading attachment files",
|
||||
txt_backup_export_progress_fetch_attachments_detail: "The browser is fetching attachment objects and adding them into the export package.",
|
||||
txt_backup_export_progress_rebuild_title: "Rebuilding export archive",
|
||||
txt_backup_export_progress_rebuild_detail: "The browser is rebuilding the final ZIP and refreshing its checksum suffix.",
|
||||
txt_backup_export_progress_save_title: "Saving export file",
|
||||
txt_backup_export_progress_save_detail: "The browser is preparing the final backup file for download.",
|
||||
txt_backup_export_progress_complete_title: "Export completed",
|
||||
txt_backup_export_progress_complete_detail: "The backup export is ready.",
|
||||
txt_backup_export_progress_failed_title: "Export failed",
|
||||
txt_backup_export_progress_failed_detail: "The backup export could not be completed.",
|
||||
txt_backup_remote_run_progress_prepare_title: "Preparing remote backup",
|
||||
txt_backup_remote_run_progress_prepare_detail: "The server is loading the selected destination and preparing this backup run.",
|
||||
txt_backup_remote_run_progress_sync_attachments_title: "Checking attachment index",
|
||||
txt_backup_remote_run_progress_sync_attachments_detail: "The server is comparing attachment metadata so only missing attachment objects are uploaded.",
|
||||
txt_backup_remote_run_progress_sync_attachments_skipped_detail: "This backup does not include attachments, so attachment synchronization is skipped.",
|
||||
txt_backup_remote_run_progress_upload_title: "Uploading backup archive",
|
||||
txt_backup_remote_run_progress_upload_detail: "The server is uploading the backup ZIP to the remote destination.",
|
||||
txt_backup_remote_run_progress_verify_title: "Verifying uploaded archive",
|
||||
txt_backup_remote_run_progress_verify_detail: "The server is downloading the uploaded ZIP back and verifying its checksum and size.",
|
||||
txt_backup_remote_run_progress_cleanup_title: "Cleaning older backups",
|
||||
txt_backup_remote_run_progress_cleanup_detail: "The server is pruning older backup files according to the retention policy.",
|
||||
txt_backup_remote_run_progress_complete_title: "Remote backup completed",
|
||||
txt_backup_remote_run_progress_complete_detail: "The remote backup has been uploaded and verified successfully.",
|
||||
txt_backup_remote_run_progress_failed_title: "Remote backup failed",
|
||||
txt_backup_remote_run_progress_failed_detail: "The remote backup could not be completed.",
|
||||
txt_backup_restore_progress_local_upload_title: "Uploading backup archive",
|
||||
txt_backup_restore_progress_local_upload_detail: "The selected ZIP is being sent to the server for processing.",
|
||||
txt_backup_restore_progress_local_shadow_title: "Creating shadow workspace",
|
||||
txt_backup_restore_progress_local_shadow_detail: "The server is preparing an isolated restore area so the current data remains untouched until validation passes.",
|
||||
txt_backup_restore_progress_local_data_title: "Writing vault data",
|
||||
txt_backup_restore_progress_local_data_detail: "The server is importing users, folders, vault items, and related metadata into shadow tables.",
|
||||
txt_backup_restore_progress_local_files_title: "Restoring attachment files",
|
||||
txt_backup_restore_progress_local_files_detail: "The server is writing attachment objects back to storage and removing any attachment rows that cannot be restored.",
|
||||
txt_backup_restore_progress_local_finalize_title: "Validating and switching data",
|
||||
txt_backup_restore_progress_local_finalize_detail: "The server is performing final validation and then swapping the verified restore data into the live tables.",
|
||||
txt_backup_restore_progress_remote_fetch_title: "Reading remote backup",
|
||||
txt_backup_restore_progress_remote_fetch_detail: "The server is downloading the selected backup package from the remote destination.",
|
||||
txt_backup_restore_progress_remote_shadow_title: "Creating shadow workspace",
|
||||
txt_backup_restore_progress_remote_shadow_detail: "The server is preparing an isolated restore area so the current data remains untouched until validation passes.",
|
||||
txt_backup_restore_progress_remote_data_title: "Writing vault data",
|
||||
txt_backup_restore_progress_remote_data_detail: "The server is importing users, folders, vault items, and related metadata into shadow tables.",
|
||||
txt_backup_restore_progress_remote_files_title: "Restoring remote attachments",
|
||||
txt_backup_restore_progress_remote_files_detail: "The server is fetching required attachment objects from remote storage and writing them back into local storage.",
|
||||
txt_backup_restore_progress_remote_finalize_title: "Validating and switching data",
|
||||
txt_backup_restore_progress_remote_finalize_detail: "The server is performing final validation and then switching the verified restore data into the live tables.",
|
||||
txt_backup_remote_loading: "Loading remote backups...",
|
||||
txt_backup_remote_cached_empty: "Click Refresh to load this destination.",
|
||||
txt_backup_remote_empty: "No backup files found in this folder.",
|
||||
@@ -126,6 +194,11 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_backup_remote_delete_confirm_message: "Delete backup file \"{name}\"? This cannot be undone.",
|
||||
txt_backup_remote_deleting: "Deleting...",
|
||||
txt_backup_remote_restore_failed: "Restoring remote backup failed",
|
||||
txt_backup_restore_checksum_warning_title: "Backup Integrity Warning",
|
||||
txt_backup_restore_checksum_warning_message: "The selected backup file \"{name}\" failed filename integrity verification. Expected prefix {expected}, actual prefix {actual}. The file may be incomplete or corrupted. Continuing may restore damaged data.",
|
||||
txt_backup_remote_restore_checksum_warning_message: "The remote backup file \"{name}\" failed filename integrity verification. Expected prefix {expected}, actual prefix {actual}. The file may be corrupted during upload or storage. Continuing may restore damaged data and may cause serious data loss.",
|
||||
txt_backup_restore_checksum_warning_message_fallback: "The selected backup file failed integrity verification. Continuing may restore damaged data.",
|
||||
txt_backup_restore_checksum_warning_confirm: "Continue Restore",
|
||||
txt_backup_remote_restore_invalid_response: "Invalid remote backup restore response",
|
||||
txt_backup_remote_run_invalid_response: "Invalid remote backup run response",
|
||||
txt_backup_settings_invalid_response: "Invalid backup settings response",
|
||||
@@ -197,9 +270,9 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_backup_no_file_selected: "No backup file selected",
|
||||
txt_backup_selected_file_name: "Selected file: {name}",
|
||||
txt_backup_replace_confirm_title: "Replace Current Instance Data",
|
||||
txt_backup_replace_confirm_message: "The current instance already contains data. Clear it and restore the selected backup?",
|
||||
txt_backup_clear_and_import: "Clear and Import",
|
||||
txt_backup_clear_and_restore: "Clear and Restore",
|
||||
txt_backup_replace_confirm_message: "The current instance already contains data. Continue restoring and replace the current instance data with the selected backup after verification succeeds?",
|
||||
txt_backup_clear_and_import: "Replace and Import",
|
||||
txt_backup_clear_and_restore: "Replace and Restore",
|
||||
txt_access_count: "Access Count",
|
||||
txt_accessed_count_times: "Accessed {count} times",
|
||||
txt_actions: "Actions",
|
||||
@@ -220,6 +293,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_are_you_sure_you_want_to_delete_count_selected_items: "Are you sure you want to delete {count} selected items?",
|
||||
txt_are_you_sure_you_want_to_delete_count_selected_items_permanently: "Are you sure you want to permanently delete {count} selected items?",
|
||||
txt_are_you_sure_you_want_to_delete_this_item: "Are you sure you want to delete this item?",
|
||||
txt_are_you_sure_you_want_to_delete_this_passkey: "Are you sure you want to delete this passkey?",
|
||||
txt_are_you_sure_you_want_to_log_out: "Are you sure you want to log out?",
|
||||
txt_authenticator_key: "Authenticator Key",
|
||||
txt_authorized_devices: "Authorized Devices",
|
||||
@@ -279,6 +353,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_delete_all_invite_codes_active_inactive: "Delete all invite codes (active/inactive)?",
|
||||
txt_delete_all_invites: "Delete all invites",
|
||||
txt_delete_item: "Delete Item",
|
||||
txt_delete_passkey: "Delete Passkey",
|
||||
txt_delete_item_failed: "Delete item failed",
|
||||
txt_delete_permanently: "Delete Permanently",
|
||||
txt_archive: "Archive",
|
||||
@@ -498,6 +573,9 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_password_hint_not_set: "No password hint is available for this email.",
|
||||
txt_password_hint_load_failed: "Failed to load password hint",
|
||||
txt_password_hint_too_long: "Password hint must be 120 characters or fewer",
|
||||
txt_passkey: "Passkey",
|
||||
txt_passkeys: "Passkeys",
|
||||
txt_passkey_created_at_value: "Created on {value}",
|
||||
txt_phone: "Phone",
|
||||
txt_please_input_email_and_password: "Please input email and password",
|
||||
txt_please_input_master_password: "Please input master password",
|
||||
@@ -546,6 +624,8 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_save_profile_failed: "Save profile failed",
|
||||
txt_search_sends: "Search sends...",
|
||||
txt_search_your_secure_vault: "Search your secure vault...",
|
||||
txt_clear_search: "Clear search",
|
||||
txt_clear_search_esc: "Clear search (Esc)",
|
||||
txt_sort: "Sort",
|
||||
txt_sort_last_edited: "Modified",
|
||||
txt_sort_created: "Created",
|
||||
@@ -596,8 +676,6 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_total_items_count: "{count} items",
|
||||
txt_totp_secret: "TOTP Secret",
|
||||
txt_totp_verify_failed: "TOTP verify failed",
|
||||
txt_passkey: "Passkey",
|
||||
txt_passkey_created_at_value: "Created at {value}",
|
||||
txt_attachments: "Attachments",
|
||||
txt_upload_attachments: "Upload attachments",
|
||||
txt_new_attachments: "New attachments",
|
||||
@@ -641,6 +719,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_vault_synced: "Vault synced",
|
||||
txt_verification_code: "Verification Code",
|
||||
txt_verify: "Verify",
|
||||
txt_warning: "Warning",
|
||||
txt_view_recovery_code: "View Recovery Code",
|
||||
txt_web: "Web",
|
||||
txt_website: "Website",
|
||||
@@ -674,11 +753,16 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_backup_export_success: '备份已导出',
|
||||
txt_backup_import_success_relogin: '备份已还原,请重新登录',
|
||||
txt_backup_restore_success_relogin: '备份已还原,请重新登录',
|
||||
txt_backup_restore_completed_verified: '备份文件完整性校验已通过。',
|
||||
txt_backup_restore_completed_without_checksum: '备份已还原,但文件名中未提供可校验的完整性标记。',
|
||||
txt_backup_remote_restore_completed_verified: '远程备份完整性校验已通过。',
|
||||
txt_backup_remote_restore_completed_without_checksum: '远程备份已还原,但文件名中未提供可校验的完整性标记。',
|
||||
txt_backup_restore_skipped_summary: '{reason},已跳过 {attachments} 个附件',
|
||||
txt_backup_restore_skipped_reason_default: '部分文件无法还原',
|
||||
txt_backup_export_failed: '备份导出失败',
|
||||
txt_backup_import_failed: '备份还原失败',
|
||||
txt_backup_restore_failed: '备份还原失败',
|
||||
txt_backup_integrity_check_failed: '备份完整性校验失败',
|
||||
txt_backup_center_title: '实例备份',
|
||||
txt_backup_center_description: '把本地导出和远程自动备份放在一起管理,既方便手动恢复,也能每天自动留一份。',
|
||||
txt_backup_restore_note: '还原会覆盖当前实例;如果当前已有数据,系统会要求你确认“清空后还原”。',
|
||||
@@ -747,6 +831,7 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_backup_run_manual: '手动执行',
|
||||
txt_backup_running_now: '执行中...',
|
||||
txt_backup_remote_run_success: '远程备份已完成',
|
||||
txt_backup_remote_run_success_verified: '远程备份已完成,且完整性校验已通过。',
|
||||
txt_backup_remote_run_failed: '远程备份失败',
|
||||
txt_backup_remote_title: '远端备份',
|
||||
txt_backup_remote_note: '浏览已保存的备份地点,选择某个备份 ZIP 后可以下载,也可以直接还原。',
|
||||
@@ -760,6 +845,68 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_backup_remote_restore: '还原',
|
||||
txt_backup_remote_restore_stage_prepare: '正在读取远端备份并检查可恢复内容...',
|
||||
txt_backup_remote_restore_stage_replace: '正在清空当前数据并还原远端备份,请稍候...',
|
||||
txt_backup_progress_kicker: '备份任务',
|
||||
txt_backup_progress_subject: '当前对象:{name}',
|
||||
txt_backup_restore_progress_kicker: '还原进度',
|
||||
txt_backup_restore_progress_local_title: '正在还原本地备份',
|
||||
txt_backup_restore_progress_remote_title: '正在还原远端备份',
|
||||
txt_backup_export_progress_title: '正在导出备份',
|
||||
txt_backup_remote_run_progress_title: '正在执行远程备份',
|
||||
txt_backup_restore_progress_file: '当前文件:{name}',
|
||||
txt_backup_restore_progress_elapsed: '已耗时 {seconds} 秒',
|
||||
txt_backup_archive_progress_collect_title: '正在收集密码库数据',
|
||||
txt_backup_archive_progress_collect_detail: '服务器正在读取数据库表,并整理备份所需的数据内容。',
|
||||
txt_backup_archive_progress_collect_with_attachments_detail: '服务器正在读取数据库表,并整理附件元数据与备份内容。',
|
||||
txt_backup_archive_progress_package_title: '正在打包备份压缩包',
|
||||
txt_backup_archive_progress_package_detail: '服务器正在生成备份 ZIP,并计算文件名校验前缀。',
|
||||
txt_backup_archive_progress_package_with_attachments_detail: '服务器正在生成带附件信息的备份 ZIP 元数据,并计算文件名校验前缀。',
|
||||
txt_backup_archive_progress_ready_title: '正在准备下载',
|
||||
txt_backup_archive_progress_ready_detail: '备份压缩包已经生成,服务器正在把它返回给浏览器。',
|
||||
txt_backup_export_progress_fetch_attachments_title: '正在下载附件文件',
|
||||
txt_backup_export_progress_fetch_attachments_detail: '浏览器正在读取附件对象,并把它们补入导出备份包。',
|
||||
txt_backup_export_progress_rebuild_title: '正在重建导出压缩包',
|
||||
txt_backup_export_progress_rebuild_detail: '浏览器正在重建最终 ZIP,并刷新文件名里的校验后缀。',
|
||||
txt_backup_export_progress_save_title: '正在保存导出文件',
|
||||
txt_backup_export_progress_save_detail: '浏览器正在准备最终的备份文件下载。',
|
||||
txt_backup_export_progress_complete_title: '备份导出已完成',
|
||||
txt_backup_export_progress_complete_detail: '导出备份已经准备完成。',
|
||||
txt_backup_export_progress_failed_title: '备份导出失败',
|
||||
txt_backup_export_progress_failed_detail: '导出备份未能完成。',
|
||||
txt_backup_remote_run_progress_prepare_title: '正在准备远程备份',
|
||||
txt_backup_remote_run_progress_prepare_detail: '服务器正在读取当前备份目标,并准备执行这次远程备份。',
|
||||
txt_backup_remote_run_progress_sync_attachments_title: '正在检查附件索引',
|
||||
txt_backup_remote_run_progress_sync_attachments_detail: '服务器正在比对附件索引,只会上传缺失或不一致的附件对象。',
|
||||
txt_backup_remote_run_progress_sync_attachments_skipped_detail: '当前备份未包含附件,因此跳过附件同步。',
|
||||
txt_backup_remote_run_progress_upload_title: '正在上传备份压缩包',
|
||||
txt_backup_remote_run_progress_upload_detail: '服务器正在把备份 ZIP 上传到远程备份目标。',
|
||||
txt_backup_remote_run_progress_verify_title: '正在校验已上传压缩包',
|
||||
txt_backup_remote_run_progress_verify_detail: '服务器正在回读刚上传的 ZIP,并校验它的哈希和大小。',
|
||||
txt_backup_remote_run_progress_cleanup_title: '正在清理旧备份',
|
||||
txt_backup_remote_run_progress_cleanup_detail: '服务器正在按保留策略清理旧备份文件。',
|
||||
txt_backup_remote_run_progress_complete_title: '远程备份已完成',
|
||||
txt_backup_remote_run_progress_complete_detail: '远程备份已上传完成,并通过完整性校验。',
|
||||
txt_backup_remote_run_progress_failed_title: '远程备份失败',
|
||||
txt_backup_remote_run_progress_failed_detail: '远程备份未能完成。',
|
||||
txt_backup_restore_progress_local_upload_title: '正在上传备份包',
|
||||
txt_backup_restore_progress_local_upload_detail: '已选 ZIP 正在发送到服务器,服务器收到后会开始执行还原。',
|
||||
txt_backup_restore_progress_local_shadow_title: '正在创建影子恢复区',
|
||||
txt_backup_restore_progress_local_shadow_detail: '服务器正在准备独立的影子数据区,只有校验通过后才会替换正式数据。',
|
||||
txt_backup_restore_progress_local_data_title: '正在写入密码库数据',
|
||||
txt_backup_restore_progress_local_data_detail: '服务器正在把用户、文件夹、密码条目和相关元数据写入影子表。',
|
||||
txt_backup_restore_progress_local_files_title: '正在恢复附件文件',
|
||||
txt_backup_restore_progress_local_files_detail: '服务器正在把附件对象写回存储,并剔除无法恢复的附件记录。',
|
||||
txt_backup_restore_progress_local_finalize_title: '正在校验并完成切换',
|
||||
txt_backup_restore_progress_local_finalize_detail: '服务器正在执行最终校验,校验通过后会把已验证的数据切换为正式数据。',
|
||||
txt_backup_restore_progress_remote_fetch_title: '正在读取远端备份包',
|
||||
txt_backup_restore_progress_remote_fetch_detail: '服务器正在从远端备份目标下载你选中的备份包。',
|
||||
txt_backup_restore_progress_remote_shadow_title: '正在创建影子恢复区',
|
||||
txt_backup_restore_progress_remote_shadow_detail: '服务器正在准备独立的影子数据区,只有校验通过后才会替换正式数据。',
|
||||
txt_backup_restore_progress_remote_data_title: '正在写入密码库数据',
|
||||
txt_backup_restore_progress_remote_data_detail: '服务器正在把用户、文件夹、密码条目和相关元数据写入影子表。',
|
||||
txt_backup_restore_progress_remote_files_title: '正在恢复远端附件',
|
||||
txt_backup_restore_progress_remote_files_detail: '服务器正在从远端存储读取所需附件,并写回到当前实例的附件存储。',
|
||||
txt_backup_restore_progress_remote_finalize_title: '正在校验并完成切换',
|
||||
txt_backup_restore_progress_remote_finalize_detail: '服务器正在执行最终校验,校验通过后会把已验证的数据切换为正式数据。',
|
||||
txt_backup_remote_loading: '正在读取远端备份...',
|
||||
txt_backup_remote_cached_empty: '点击“刷新”后读取',
|
||||
txt_backup_remote_empty: '这个目录下还没有备份文件',
|
||||
@@ -774,6 +921,11 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_backup_remote_delete_confirm_message: '删除备份文件“{name}”?此操作不可撤销。',
|
||||
txt_backup_remote_deleting: '删除中...',
|
||||
txt_backup_remote_restore_failed: '还原远端备份失败',
|
||||
txt_backup_restore_checksum_warning_title: '备份完整性警告',
|
||||
txt_backup_restore_checksum_warning_message: '所选备份文件“{name}”未通过文件名完整性校验。期望前缀为 {expected},实际计算结果为 {actual}。该文件可能不完整或已经损坏。继续还原可能会导入受损数据。',
|
||||
txt_backup_remote_restore_checksum_warning_message: '远程备份文件“{name}”未通过文件名完整性校验。期望前缀为 {expected},实际计算结果为 {actual}。该文件可能在上传或存储过程中损坏。继续还原可能会导入受损数据,并可能造成严重后果。',
|
||||
txt_backup_restore_checksum_warning_message_fallback: '所选备份文件未通过完整性校验。继续还原可能会导入受损数据。',
|
||||
txt_backup_restore_checksum_warning_confirm: '继续还原',
|
||||
txt_backup_remote_restore_invalid_response: '远端备份还原响应无效',
|
||||
txt_backup_remote_run_invalid_response: '远端备份执行响应无效',
|
||||
txt_backup_settings_invalid_response: '备份设置响应无效',
|
||||
@@ -845,9 +997,9 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_backup_no_file_selected: '尚未选择备份文件',
|
||||
txt_backup_selected_file_name: '已选择文件:{name}',
|
||||
txt_backup_replace_confirm_title: '替换当前实例数据',
|
||||
txt_backup_replace_confirm_message: '当前实例里已经有数据。要先清空当前数据库和文件,再还原所选备份吗?',
|
||||
txt_backup_clear_and_import: '清空后导入',
|
||||
txt_backup_clear_and_restore: '清空后还原',
|
||||
txt_backup_replace_confirm_message: '当前实例里已经有数据。确认后,系统会先完成校验与恢复准备,只有在恢复成功后才会用所选备份替换当前实例数据。是否继续?',
|
||||
txt_backup_clear_and_import: '替换并导入',
|
||||
txt_backup_clear_and_restore: '替换并还原',
|
||||
txt_sign_out: '退出登录',
|
||||
txt_log_in: '登录',
|
||||
txt_logging_in: '正在登录...',
|
||||
@@ -872,6 +1024,8 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_loading_nodewarden: '正在加载 NodeWarden...',
|
||||
txt_search_sends: '搜索发送...',
|
||||
txt_search_your_secure_vault: '搜索你的密码库...',
|
||||
txt_clear_search: '清空搜索',
|
||||
txt_clear_search_esc: '清空搜索(Esc)',
|
||||
txt_refresh: '刷新',
|
||||
txt_sync: '同步',
|
||||
txt_sync_vault: '同步',
|
||||
@@ -1012,6 +1166,7 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_no_name: '(无名称)',
|
||||
txt_are_you_sure_you_want_to_log_out: '确认要退出登录吗?',
|
||||
txt_delete_item: '删除项目',
|
||||
txt_delete_passkey: '删除通行密钥',
|
||||
txt_delete_selected_items: '删除所选项目',
|
||||
txt_move_selected_items: '移动所选项目',
|
||||
txt_create_folder: '创建文件夹',
|
||||
@@ -1075,6 +1230,7 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_are_you_sure_you_want_to_delete_count_selected_items: '确认删除所选的 {count} 个项目?',
|
||||
txt_are_you_sure_you_want_to_delete_count_selected_items_permanently: '确认永久删除所选的 {count} 个项目?',
|
||||
txt_are_you_sure_you_want_to_delete_this_item: '确认删除此项目?',
|
||||
txt_are_you_sure_you_want_to_delete_this_passkey: '确认删除这个通行密钥?',
|
||||
txt_authenticator_key: '验证器密钥',
|
||||
txt_brand: '品牌',
|
||||
txt_bulk_delete_failed: '批量删除失败',
|
||||
@@ -1175,6 +1331,9 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_password_hint_not_set: '这个邮箱没有可显示的密码提示。',
|
||||
txt_password_hint_load_failed: '加载密码提示失败',
|
||||
txt_password_hint_too_long: '密码提示最多只能输入 120 个字符',
|
||||
txt_passkey: '通行密钥',
|
||||
txt_passkeys: '通行密钥',
|
||||
txt_passkey_created_at_value: '创建于 {value}',
|
||||
txt_phone: '电话',
|
||||
txt_please_input_email_and_password: '请输入邮箱和密码',
|
||||
txt_please_input_master_password: '请输入主密码',
|
||||
@@ -1239,6 +1398,7 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_user_status_updated: '用户状态已更新',
|
||||
txt_vault_synced: '密码库已同步',
|
||||
txt_verify: '验证',
|
||||
txt_warning: '警告',
|
||||
txt_web: '网页',
|
||||
txt_windows_desktop: 'Windows 桌面端',
|
||||
txt_jwt_warning_title: 'JWT_SECRET 配置警告',
|
||||
@@ -1279,8 +1439,6 @@ zhCNOverrides.txt_lock = '锁定';
|
||||
zhCNOverrides.txt_menu = '菜单';
|
||||
zhCNOverrides.txt_settings = '设置';
|
||||
zhCNOverrides.txt_back = '返回';
|
||||
zhCNOverrides.txt_passkey = 'Passkey';
|
||||
zhCNOverrides.txt_passkey_created_at_value = '创建于 {value}';
|
||||
zhCNOverrides.txt_attachments = '附件';
|
||||
zhCNOverrides.txt_upload_attachments = '上传附件';
|
||||
zhCNOverrides.txt_new_attachments = '待上传附件';
|
||||
@@ -1341,7 +1499,9 @@ messages.en.txt_delete_all_folders = 'Delete All Folders';
|
||||
messages.en.txt_delete_all_folders_message = 'Delete all folders? Items inside will move to No Folder.';
|
||||
messages.en.txt_folder_not_found = 'Folder not found';
|
||||
messages.en.txt_folder_deleted = 'Folder deleted';
|
||||
messages.en.txt_folder_updated = 'Folder updated';
|
||||
messages.en.txt_folders_deleted = 'Folders deleted';
|
||||
messages.en.txt_update_folder_failed = 'Update folder failed';
|
||||
messages.en.txt_delete_folder_failed = 'Delete folder failed';
|
||||
messages.en.txt_delete_all_folders_failed = 'Delete all folders failed';
|
||||
messages.en.txt_other = 'Other';
|
||||
@@ -1423,7 +1583,9 @@ zhCNOverrides.txt_delete_all_folders = '删除全部文件夹';
|
||||
zhCNOverrides.txt_delete_all_folders_message = '确认删除全部文件夹吗?其中的项目将移至无文件夹。';
|
||||
zhCNOverrides.txt_folder_not_found = '文件夹不存在';
|
||||
zhCNOverrides.txt_folder_deleted = '文件夹已删除';
|
||||
zhCNOverrides.txt_folder_updated = '文件夹已重命名';
|
||||
zhCNOverrides.txt_folders_deleted = '文件夹已删除';
|
||||
zhCNOverrides.txt_update_folder_failed = '重命名文件夹失败';
|
||||
zhCNOverrides.txt_delete_folder_failed = '删除文件夹失败';
|
||||
zhCNOverrides.txt_delete_all_folders_failed = '删除全部文件夹失败';
|
||||
zhCNOverrides.txt_other = '其他';
|
||||
@@ -1477,4 +1639,3 @@ export function setLocale(next: Locale): void {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@ export function makeLoginCipher(): Record<string, unknown> {
|
||||
favorite: false,
|
||||
reprompt: 0,
|
||||
key: null,
|
||||
login: { username: null, password: null, totp: null, fido2Credentials: null, uris: null },
|
||||
login: { username: null, password: null, totp: null, uris: null },
|
||||
card: null,
|
||||
identity: null,
|
||||
secureNote: null,
|
||||
|
||||
+19
-3
@@ -1,9 +1,10 @@
|
||||
export type AppPhase = 'register' | 'login' | 'locked' | 'app';
|
||||
|
||||
export interface SessionState {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
email: string;
|
||||
authMode?: 'token' | 'web-cookie';
|
||||
symEncKey?: string;
|
||||
symMacKey?: string;
|
||||
}
|
||||
@@ -28,13 +29,18 @@ export interface Folder {
|
||||
|
||||
export interface CipherLoginUri {
|
||||
uri?: string | null;
|
||||
uriChecksum?: string | null;
|
||||
match?: number | null;
|
||||
response?: unknown | null;
|
||||
decUri?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface VaultDraftLoginUri {
|
||||
uri: string;
|
||||
match: number | null;
|
||||
originalUri?: string;
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CipherAttachment {
|
||||
@@ -59,9 +65,14 @@ export interface CipherLogin {
|
||||
totp?: string | null;
|
||||
uris?: CipherLoginUri[] | null;
|
||||
fido2Credentials?: CipherLoginPasskey[] | null;
|
||||
autofillOnPageLoad?: boolean | null;
|
||||
uri?: string | null;
|
||||
passwordRevisionDate?: string | null;
|
||||
response?: unknown | null;
|
||||
decUsername?: string;
|
||||
decPassword?: string;
|
||||
decTotp?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CipherCard {
|
||||
@@ -272,7 +283,8 @@ export interface WebBootstrapResponse {
|
||||
|
||||
export interface TokenSuccess {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
refresh_token?: string;
|
||||
web_session?: boolean;
|
||||
expires_in?: number;
|
||||
token_type?: string;
|
||||
TwoFactorToken?: string;
|
||||
@@ -290,6 +302,10 @@ export interface TokenSuccess {
|
||||
unofficialServer?: boolean;
|
||||
UserDecryptionOptions?: unknown;
|
||||
userDecryptionOptions?: unknown;
|
||||
VaultKeys?: {
|
||||
symEncKey?: string;
|
||||
symMacKey?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TokenError {
|
||||
|
||||
+328
-10
@@ -80,6 +80,11 @@ body {
|
||||
color var(--dur-medium) var(--ease-smooth);
|
||||
}
|
||||
|
||||
body.dialog-open {
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: none;
|
||||
}
|
||||
@@ -1021,12 +1026,15 @@ input[type='file'].input::file-selector-button:hover {
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 0 12px;
|
||||
height: 48px;
|
||||
border: 1px solid rgba(74, 103, 150, 0.42);
|
||||
border-radius: 14px;
|
||||
padding: 10px 14px;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
background: var(--panel);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
transition:
|
||||
border-color var(--dur-fast) var(--ease-smooth),
|
||||
box-shadow var(--dur-fast) var(--ease-out-soft),
|
||||
@@ -1034,13 +1042,52 @@ input[type='file'].input::file-selector-button:hover {
|
||||
transform var(--dur-fast) var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.search-input-wrap {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: rgba(43, 102, 217, 0.28);
|
||||
background: #fbfdff;
|
||||
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.08), 0 8px 18px rgba(37, 99, 235, 0.06);
|
||||
border-color: rgba(43, 102, 217, 0.6);
|
||||
background-color: #fbfdff;
|
||||
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.11), 0 10px 20px rgba(37, 99, 235, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.95);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.search-input-wrap .search-input {
|
||||
padding-right: 42px;
|
||||
}
|
||||
|
||||
.search-clear-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 9px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.18);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transform: translateY(-50%);
|
||||
transition: background-color var(--dur-fast) var(--ease-out-soft), color var(--dur-fast) var(--ease-out-soft), transform var(--dur-fast) var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.search-clear-btn:hover {
|
||||
background: rgba(59, 130, 246, 0.18);
|
||||
color: var(--brand);
|
||||
transform: translateY(-50%) scale(1.04);
|
||||
}
|
||||
|
||||
.search-clear-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.16);
|
||||
}
|
||||
|
||||
.tree-btn {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
@@ -1117,6 +1164,11 @@ input[type='file'].input::file-selector-button:hover {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.folder-edit-btn:hover {
|
||||
color: #1d4ed8;
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.list-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1151,10 +1203,13 @@ input[type='file'].input::file-selector-button:hover {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.list-head .search-input {
|
||||
.list-head .search-input-wrap {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.list-head .search-input {
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.list-head .btn {
|
||||
@@ -2619,6 +2674,8 @@ input[type='file'].input::file-selector-button:hover {
|
||||
.totp-qr img {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
@@ -2884,6 +2941,148 @@ input[type='file'].input::file-selector-button:hover {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.restore-progress-card {
|
||||
margin: 8px 0 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #d7e2f1;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.10);
|
||||
}
|
||||
|
||||
.restore-progress-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1250;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
background: rgba(15, 23, 42, 0.30);
|
||||
}
|
||||
|
||||
.restore-progress-modal {
|
||||
width: min(520px, 100%);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.restore-progress-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.restore-progress-kicker {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.restore-progress-title {
|
||||
margin: 4px 0 2px;
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.restore-progress-subtitle {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.restore-progress-elapsed {
|
||||
flex: 0 0 auto;
|
||||
min-width: 88px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 10px;
|
||||
background: #f8fbff;
|
||||
border: 1px solid #d7e2f1;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.restore-progress-meter {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #e7eef8;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.restore-progress-meter-bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: #3a71d8;
|
||||
transition: width 280ms ease;
|
||||
}
|
||||
|
||||
.restore-progress-current {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: #f8fbff;
|
||||
border: 1px solid #d7e2f1;
|
||||
}
|
||||
|
||||
.restore-progress-current strong {
|
||||
display: block;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.restore-progress-current p {
|
||||
margin: 4px 0 0;
|
||||
color: #64748b;
|
||||
line-height: 1.45;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.restore-progress-list {
|
||||
list-style: none;
|
||||
margin: 12px 0 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.restore-progress-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 30px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.restore-progress-item.active {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.restore-progress-item.done {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.restore-progress-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #cbd5e1;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.restore-progress-item.active .restore-progress-dot {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.restore-progress-item.done .restore-progress-dot {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
.kv-line strong {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -2977,6 +3176,8 @@ input[type='file'].input::file-selector-button:hover {
|
||||
.dialog-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100dvh;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -2984,6 +3185,8 @@ input[type='file'].input::file-selector-button:hover {
|
||||
padding: 20px;
|
||||
opacity: 0;
|
||||
animation: fade-in var(--dur-medium) var(--ease-smooth) both;
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.dialog-card {
|
||||
@@ -2998,6 +3201,54 @@ input[type='file'].input::file-selector-button:hover {
|
||||
animation: dialog-in 240ms var(--ease-out-strong) both;
|
||||
}
|
||||
|
||||
.dialog-mask.warning {
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(255, 237, 213, 0.32), transparent 34%),
|
||||
linear-gradient(180deg, rgba(127, 29, 29, 0.36), rgba(15, 23, 42, 0.72));
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.dialog-card.warning {
|
||||
width: min(520px, 100%);
|
||||
border: 1px solid rgba(220, 38, 38, 0.22);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 246, 246, 0.98), rgba(255, 255, 255, 0.99));
|
||||
box-shadow:
|
||||
0 36px 90px rgba(69, 10, 10, 0.28),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.7) inset;
|
||||
}
|
||||
|
||||
.dialog-warning-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dialog-warning-badge {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(180deg, #fff1f2, #ffe4e6);
|
||||
color: #dc2626;
|
||||
box-shadow:
|
||||
0 12px 30px rgba(220, 38, 38, 0.18),
|
||||
0 0 0 1px rgba(220, 38, 38, 0.08) inset;
|
||||
}
|
||||
|
||||
.dialog-warning-kicker {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.dialog-mask.closing {
|
||||
animation: fade-out 220ms var(--ease-smooth) both;
|
||||
}
|
||||
@@ -3025,6 +3276,22 @@ input[type='file'].input::file-selector-button:hover {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.dialog-card.warning .dialog-title {
|
||||
color: #7f1d1d;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.dialog-message.warning {
|
||||
margin-bottom: 16px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(220, 38, 38, 0.16);
|
||||
background: linear-gradient(180deg, rgba(255, 241, 242, 0.94), rgba(255, 247, 237, 0.9));
|
||||
color: #7a2832;
|
||||
line-height: 1.65;
|
||||
box-shadow: 0 10px 28px rgba(248, 113, 113, 0.08) inset;
|
||||
}
|
||||
|
||||
.dialog-btn {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
@@ -3733,6 +4000,11 @@ input[type='file'].input::file-selector-button:hover {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.list-head .search-input-wrap {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list-head .search-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
@@ -4082,6 +4354,14 @@ input[type='file'].input::file-selector-button:hover {
|
||||
padding: 18px 16px calc(18px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.dialog-card.warning {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.dialog-warning-strip {
|
||||
margin: -18px -16px 16px;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
@@ -4202,6 +4482,40 @@ input[type='file'].input::file-selector-button:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .dialog-card.warning {
|
||||
border-color: rgba(248, 113, 113, 0.36);
|
||||
background: linear-gradient(180deg, rgba(39, 16, 16, 0.98), rgba(27, 12, 12, 0.98));
|
||||
box-shadow:
|
||||
0 36px 90px rgba(5, 5, 5, 0.56),
|
||||
0 0 0 1px rgba(248, 113, 113, 0.12) inset;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .dialog-mask.warning {
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(127, 29, 29, 0.28), transparent 34%),
|
||||
linear-gradient(180deg, rgba(20, 12, 12, 0.64), rgba(2, 6, 23, 0.82));
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .dialog-warning-badge {
|
||||
background: linear-gradient(180deg, rgba(127, 29, 29, 0.8), rgba(69, 10, 10, 0.86));
|
||||
color: #fda4af;
|
||||
box-shadow:
|
||||
0 12px 30px rgba(0, 0, 0, 0.32),
|
||||
0 0 0 1px rgba(248, 113, 113, 0.14) inset;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .dialog-warning-kicker,
|
||||
:root[data-theme='dark'] .dialog-card.warning .dialog-title {
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .dialog-message.warning {
|
||||
border-color: rgba(248, 113, 113, 0.18);
|
||||
background: linear-gradient(180deg, rgba(69, 10, 10, 0.54), rgba(67, 20, 7, 0.46));
|
||||
color: #fecdd3;
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18) inset;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .app-side,
|
||||
:root[data-theme='dark'] .sidebar,
|
||||
:root[data-theme='dark'] .mobile-sidebar-sheet .sidebar-block {
|
||||
@@ -4461,7 +4775,6 @@ input[type='file'].input::file-selector-button:hover {
|
||||
:root[data-theme='dark'] .backup-browser-list,
|
||||
:root[data-theme='dark'] .backup-schedule-current,
|
||||
:root[data-theme='dark'] .backup-status-card,
|
||||
:root[data-theme='dark'] .totp-qr,
|
||||
:root[data-theme='dark'] .create-menu,
|
||||
:root[data-theme='dark'] .create-menu-item,
|
||||
:root[data-theme='dark'] .sort-menu,
|
||||
@@ -4619,6 +4932,11 @@ input[type='file'].input::file-selector-button:hover {
|
||||
color: #9be2bd;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .totp-qr {
|
||||
background: #ffffff;
|
||||
border-color: rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .totp-qr svg,
|
||||
:root[data-theme='dark'] .totp-qr img {
|
||||
background: #ffffff;
|
||||
|
||||
@@ -6,9 +6,8 @@
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@/*": ["./src/*"],
|
||||
"@shared/*": ["../shared/*"]
|
||||
},
|
||||
"strict": true,
|
||||
|
||||
Reference in New Issue
Block a user