Compare commits
150 Commits
v1.4.1
...
04ebfc7021
| Author | SHA1 | Date | |
|---|---|---|---|
| 04ebfc7021 | |||
| c50247b8fe | |||
| 776408e9d0 | |||
| e641da517d | |||
| b7878ffe01 | |||
| bbad9d60a7 | |||
| ed58467766 | |||
| 2f911e66a6 | |||
| d06e050162 | |||
| d0dc31ce86 | |||
| f64abaa75d | |||
| 7312086f92 | |||
| 3e4c104e1d | |||
| 17ceec45b1 | |||
| 2685741386 | |||
| 83a1fc2376 | |||
| 06431c4145 | |||
| 700910099b | |||
| 6b671450a8 | |||
| c0df6d1c16 | |||
| 35f9512d94 | |||
| 9e39161fc7 | |||
| 7c58282e42 | |||
| e0d81f2733 | |||
| 1d23b3fe5e | |||
| a0d4d7a1ff | |||
| 2f1b61e883 | |||
| 4e62c90700 | |||
| 7afb496eb0 | |||
| 5809e3eebc | |||
| 2e9bbe6801 | |||
| dc0eec7c54 | |||
| a0605299f0 | |||
| db68437a0b | |||
| 77d8411ea9 | |||
| 0c1ab3db48 | |||
| 6cc6e94b91 | |||
| 37ae493fa7 | |||
| 33f7c5d88a | |||
| c6c8979772 | |||
| a00279f47d | |||
| 669d7ef242 | |||
| 97d2117e15 | |||
| 429b747710 | |||
| a06853835d | |||
| c4ff063865 | |||
| 70b0a3a394 | |||
| e7c07fda4e | |||
| 0a001bebcc | |||
| 246c73a3d3 | |||
| 3d95c959f7 | |||
| e0737006c2 | |||
| 70dc9a76a9 | |||
| ba38b77387 | |||
| 1b4d263d6e | |||
| 97a3aa691d | |||
| 0ab7c44981 | |||
| 75a6a593dc | |||
| 45f0387526 | |||
| 851c9c4080 | |||
| a73f9a6d87 | |||
| 77a9faac88 | |||
| 0c00114cc8 | |||
| 9c5fbda374 | |||
| 85147e1569 | |||
| 29a846c562 | |||
| 3c5f43ecc2 | |||
| 68ded534a4 | |||
| 69b98f9e67 | |||
| 1b0386bf78 | |||
| aa6f9210b4 | |||
| 3be6a16d90 | |||
| fdb4cb91bf | |||
| 4b69f71ddb | |||
| 44020541e8 | |||
| 5869755c74 | |||
| 5b62d2142e | |||
| 575cf7ca79 | |||
| bfd347a52c | |||
| 7ab836d0f3 | |||
| d589b15123 | |||
| f48f3d0c8e | |||
| 2f7e66ee69 | |||
| 0cffbcd1f8 | |||
| 64b4da4035 | |||
| 3d2285e7af | |||
| 62f0aedc27 | |||
| 193e0ca189 | |||
| 4a63c077f5 | |||
| 15ee922777 | |||
| 2ea0b2c14c | |||
| 4ec1926888 | |||
| 3995e01336 | |||
| 481536ba24 | |||
| db8b9263a1 | |||
| a1f7250e90 | |||
| e4bc1b9bbe | |||
| 514889adfc | |||
| fccc85c4bb | |||
| acd59a7387 | |||
| d40b0514fd | |||
| 033d44808f | |||
| 4246e179f1 | |||
| fe8d9e0b7d | |||
| 1147c1e013 | |||
| 31ffd98166 | |||
| 7d7562d191 | |||
| d6e5a1c40b | |||
| 77794e43ce | |||
| b990f17a3e | |||
| 31b8ec6f7d | |||
| ef47597be5 | |||
| 408874ac05 | |||
| dabd2c923e | |||
| 08414d7cf2 | |||
| 38b33df719 | |||
| 7ebd12fa07 | |||
| f7cbdaf730 | |||
| 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."
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
## Summary
|
||||||
|
|
||||||
|
<!-- What changed and why? -->
|
||||||
|
|
||||||
|
## Change Type
|
||||||
|
|
||||||
|
- [ ] Bug fix
|
||||||
|
- [ ] Feature
|
||||||
|
- [ ] Compatibility update
|
||||||
|
- [ ] Documentation
|
||||||
|
- [ ] Refactor
|
||||||
|
|
||||||
|
## Cross-File Checklist
|
||||||
|
|
||||||
|
- [ ] I read `CONTRIBUTING.md`.
|
||||||
|
- [ ] Schema changes, if any, updated both runtime schema and `migrations/0001_init.sql`.
|
||||||
|
- [ ] Persistent data changes, if any, updated backup export/import or documented why backup is not needed.
|
||||||
|
- [ ] User-facing text changes, if any, updated all locale files.
|
||||||
|
- [ ] Bitwarden client compatibility was considered for sync/API shape changes.
|
||||||
|
- [ ] No secrets, tokens, private deployment values, or real vault data are included.
|
||||||
|
|
||||||
|
## Checks
|
||||||
|
|
||||||
|
- [ ] `npx tsc -p tsconfig.json --noEmit`
|
||||||
|
- [ ] `npx tsc -p webapp/tsconfig.json --noEmit`
|
||||||
|
- [ ] `npm run i18n:validate`
|
||||||
|
- [ ] `npm run build`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
<!-- Anything reviewers should pay special attention to? -->
|
||||||
@@ -67,7 +67,7 @@ class SecurityReport {
|
|||||||
guideStep1: '1. **开发人员**:使用上方表格中的 **位置** 列找到确切的文件和行号。',
|
guideStep1: '1. **开发人员**:使用上方表格中的 **位置** 列找到确切的文件和行号。',
|
||||||
guideStep2: '2. **纠正**:遵循为每个规则提供的文档链接以提交修复。',
|
guideStep2: '2. **纠正**:遵循为每个规则提供的文档链接以提交修复。',
|
||||||
guideStep3: '3. **可追溯性**:完整的原始 `.sarif` 数据已附加到此分支。下载并将其导入您的 IDE(例如 VS Code SARIF 查看器)进行本地分析。',
|
guideStep3: '3. **可追溯性**:完整的原始 `.sarif` 数据已附加到此分支。下载并将其导入您的 IDE(例如 VS Code SARIF 查看器)进行本地分析。',
|
||||||
footer: '💡 *由 Antigravity AI 安全引擎生成。透明度是我们的承诺。*',
|
footer: '💡 *由 NodeWarden 安全工作流生成。透明度是我们的承诺。*',
|
||||||
auditedIcon: '✅ **已审计**',
|
auditedIcon: '✅ **已审计**',
|
||||||
noFiles: '未检索到文件。',
|
noFiles: '未检索到文件。',
|
||||||
trivyTitle: '🛡️ 容器配置安全 (Trivy)',
|
trivyTitle: '🛡️ 容器配置安全 (Trivy)',
|
||||||
@@ -119,7 +119,7 @@ class SecurityReport {
|
|||||||
guideStep1: '1. **Developers**: Use the **Location** column in the tables above to find the exact file and line number.',
|
guideStep1: '1. **Developers**: Use the **Location** column in the tables above to find the exact file and line number.',
|
||||||
guideStep2: '2. **Remediate**: Follow the documentation links provided for each rule to submit a fix.',
|
guideStep2: '2. **Remediate**: Follow the documentation links provided for each rule to submit a fix.',
|
||||||
guideStep3: '3. **Traceability**: Full raw `.sarif` data is attached to this branch. Download and import it into your IDE (e.g., VS Code SARIF Viewer) for local analysis.',
|
guideStep3: '3. **Traceability**: Full raw `.sarif` data is attached to this branch. Download and import it into your IDE (e.g., VS Code SARIF Viewer) for local analysis.',
|
||||||
footer: '💡 *Generated by Antigravity AI Security Engine. Transparency is our commitment.*',
|
footer: '💡 *Generated by the NodeWarden security workflow. Transparency is our commitment.*',
|
||||||
auditedIcon: '✅ **Audited**',
|
auditedIcon: '✅ **Audited**',
|
||||||
noFiles: 'No files found.',
|
noFiles: 'No files found.',
|
||||||
trivyTitle: '🛡️ Container Config Security (Trivy)',
|
trivyTitle: '🛡️ Container Config Security (Trivy)',
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
name: Sync Bitwarden global domains
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "17 4 * * 1"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
bitwarden_ref:
|
||||||
|
description: "bitwarden/server ref to sync"
|
||||||
|
required: false
|
||||||
|
default: "main"
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync-global-domains:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Sync generated Bitwarden domains
|
||||||
|
run: npm run domains:sync -- --ref "${{ inputs.bitwarden_ref || 'main' }}"
|
||||||
|
|
||||||
|
- name: Verify custom domains were not touched
|
||||||
|
run: git diff --exit-code -- src/static/global_domains.custom.json
|
||||||
|
|
||||||
|
- name: Create pull request
|
||||||
|
uses: peter-evans/create-pull-request@v6
|
||||||
|
with:
|
||||||
|
branch: chore/sync-bitwarden-global-domains
|
||||||
|
delete-branch: true
|
||||||
|
title: "chore: sync Bitwarden global domain rules"
|
||||||
|
commit-message: "chore: sync Bitwarden global domain rules"
|
||||||
|
body: |
|
||||||
|
Automated sync from bitwarden/server.
|
||||||
|
|
||||||
|
This PR only updates:
|
||||||
|
- `src/static/global_domains.bitwarden.json`
|
||||||
|
- `src/static/global_domains.bitwarden.meta.json`
|
||||||
|
|
||||||
|
`src/static/global_domains.custom.json` is intentionally left untouched.
|
||||||
|
add-paths: |
|
||||||
|
src/static/global_domains.bitwarden.json
|
||||||
|
src/static/global_domains.bitwarden.meta.json
|
||||||
@@ -4,6 +4,11 @@ on:
|
|||||||
schedule:
|
schedule:
|
||||||
- cron: "0 3 * * *"
|
- cron: "0 3 * * *"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
target_commit:
|
||||||
|
description: 'Commit hash (leave blank to use latest commit)'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -11,9 +16,8 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
sync:
|
sync:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -22,13 +26,118 @@ jobs:
|
|||||||
git config user.name "github-actions[bot]"
|
git config user.name "github-actions[bot]"
|
||||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
- name: Sync main from upstream
|
- name: Add upstream
|
||||||
run: |
|
run: |
|
||||||
git remote add upstream https://github.com/shuaiplus/NodeWarden.git || true
|
git remote add upstream https://github.com/shuaiplus/NodeWarden.git || true
|
||||||
git fetch upstream
|
git fetch upstream --tags
|
||||||
git checkout main
|
|
||||||
git merge upstream/main
|
|
||||||
|
|
||||||
- name: Push synced main
|
- name: Resolve target commit
|
||||||
|
id: resolve
|
||||||
run: |
|
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
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ Thumbs.db
|
|||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
.vite-tailwind.err
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
@@ -40,3 +40,10 @@ npm-debug.log*
|
|||||||
|
|
||||||
tmp/
|
tmp/
|
||||||
.tmp/
|
.tmp/
|
||||||
|
|
||||||
|
nodewarden.wiki/
|
||||||
|
wiki/
|
||||||
|
AGENTS.md
|
||||||
|
settings.json
|
||||||
|
.claude/
|
||||||
|
NodeWarden-compat/
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# Contributing to NodeWarden
|
||||||
|
|
||||||
|
Thanks for taking the time to improve NodeWarden.
|
||||||
|
|
||||||
|
NodeWarden is a Bitwarden-compatible server with a custom web vault, Cloudflare
|
||||||
|
Workers/D1 storage, attachment storage, imports/exports, and scheduled backups.
|
||||||
|
Small changes can affect official clients, backups, migrations, or locale files,
|
||||||
|
so please keep changes focused and check the related parts of the project.
|
||||||
|
|
||||||
|
## Before Opening an Issue
|
||||||
|
|
||||||
|
For bug reports, include enough detail for someone else to reproduce the problem:
|
||||||
|
|
||||||
|
- The client or browser you used.
|
||||||
|
- The page, API route, or action that failed.
|
||||||
|
- Screenshots, logs, or the exact error message.
|
||||||
|
- Whether the problem happened after sync, import, export, restore, upgrade, or
|
||||||
|
a fresh deployment.
|
||||||
|
|
||||||
|
Please do not report NodeWarden-specific problems to the official Bitwarden
|
||||||
|
team. This project is independent from Bitwarden.
|
||||||
|
|
||||||
|
## Pull Request Guidelines
|
||||||
|
|
||||||
|
Keep pull requests small enough to review. A good PR should explain:
|
||||||
|
|
||||||
|
- What changed and why.
|
||||||
|
- What user-facing behavior changed.
|
||||||
|
- Which related areas were checked.
|
||||||
|
- Which commands were run before submitting.
|
||||||
|
|
||||||
|
Avoid mixing unrelated refactors with feature or bug-fix work. If a cleanup is
|
||||||
|
needed before the real fix, mention that clearly in the PR.
|
||||||
|
|
||||||
|
## Areas That Need Extra Care
|
||||||
|
|
||||||
|
Some parts of the codebase are deliberately connected. When changing one of
|
||||||
|
these areas, check the related files before calling the work complete.
|
||||||
|
|
||||||
|
### Database Changes
|
||||||
|
|
||||||
|
Runtime schema lives in `src/services/storage-schema.ts`. The initial D1 schema
|
||||||
|
lives in `migrations/0001_init.sql`.
|
||||||
|
|
||||||
|
If you add or change a table, column, or index:
|
||||||
|
|
||||||
|
- Update both schema files.
|
||||||
|
- Bump `STORAGE_SCHEMA_VERSION` in `src/services/storage.ts`.
|
||||||
|
- Decide whether the data should be included in instance backup.
|
||||||
|
|
||||||
|
### Backup And Restore
|
||||||
|
|
||||||
|
Backup export and restore are whitelist-based. This protects old backups from
|
||||||
|
breaking when fields are removed and prevents transient or secret runtime data
|
||||||
|
from being exported by accident.
|
||||||
|
|
||||||
|
When adding persistent data, check:
|
||||||
|
|
||||||
|
- `src/services/backup-archive.ts`
|
||||||
|
- `src/services/backup-import.ts`
|
||||||
|
- `webapp/src/lib/api/backup.ts`
|
||||||
|
|
||||||
|
Do not export runtime lock rows such as `backup.runner.lock.v1`. Do not import
|
||||||
|
retired sensitive fields such as `users.api_key`.
|
||||||
|
|
||||||
|
### Secrets And Provider Settings
|
||||||
|
|
||||||
|
Provider credentials must not be stored or exported as plain config JSON. Follow
|
||||||
|
the encrypted settings pattern in `src/services/backup-settings-crypto.ts`, or
|
||||||
|
document a replacement design before changing it.
|
||||||
|
|
||||||
|
### Bitwarden Client Compatibility
|
||||||
|
|
||||||
|
Official Bitwarden clients may send or expect fields that are not used directly
|
||||||
|
by the web vault. Cipher and sync changes should preserve unknown client fields
|
||||||
|
unless they are known-invalid or server-owned.
|
||||||
|
|
||||||
|
Check these files when changing vault item shape or sync behavior:
|
||||||
|
|
||||||
|
- `src/handlers/ciphers.ts`
|
||||||
|
- `src/handlers/sync.ts`
|
||||||
|
- `src/services/storage-cipher-repo.ts`
|
||||||
|
|
||||||
|
### Domain Rules
|
||||||
|
|
||||||
|
Equivalent-domain settings store both client/UI rule state and derived active
|
||||||
|
groups. Do not remove `equivalent_domains`, `custom_equivalent_domains`, or
|
||||||
|
`excluded_global_equivalent_domains` as duplicates without a migration and
|
||||||
|
compatibility plan.
|
||||||
|
|
||||||
|
### Accounts And Passwords
|
||||||
|
|
||||||
|
`users.master_password_hash` is for server-side login verification. It is not the
|
||||||
|
vault decryption key. Password changes, key material, `securityStamp`, and
|
||||||
|
refresh-token revocation must stay aligned.
|
||||||
|
|
||||||
|
Password hints are reminders, not recovery secrets. They must never contain the
|
||||||
|
master password, recovery codes, API keys, or anything that directly unlocks the
|
||||||
|
vault.
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
|
||||||
|
Locale files are complete standalone bundles. When adding or changing user-facing
|
||||||
|
text, keep every locale in sync and run the validation script.
|
||||||
|
|
||||||
|
For new locales, update:
|
||||||
|
|
||||||
|
- `webapp/src/lib/i18n.ts`
|
||||||
|
- `webapp/src/lib/i18n/locales/*`
|
||||||
|
- `scripts/i18n-utils.cjs`
|
||||||
|
|
||||||
|
## Recommended Checks
|
||||||
|
|
||||||
|
For most backend or shared changes:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx tsc -p tsconfig.json --noEmit
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
For webapp text or locale changes:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run i18n:validate
|
||||||
|
npx tsc -p webapp/tsconfig.json --noEmit
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
For documentation-only changes:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,19 @@
|
|||||||
|
<svg width="960" height="180" viewBox="0 0 1240 220" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(0 30) scale(0.276)">
|
||||||
|
<path d="M370.5 93C481.785 93 572 181.2 572 290C572 329.877 559.879 366.986 539.046 398H1.68164C0.576599 391.834 0 385.484 0 379C0 323.617 42.0774 278.061 96.0078 272.558C92.7712 263.989 91 254.701 91 245C91 201.922 125.922 167 169 167C182.365 167 194.945 170.362 205.94 176.286C242.437 125.895 302.539 93 370.5 93Z" fill="#F6821F"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M76.6568 1.00686C72.7796 172.923 85.5495 291.119 127.869 379.459C170.188 467.799 242.092 526.353 356.665 578.892C469.877 526.354 540.929 467.802 582.746 379.461C624.564 291.12 637.181 172.923 633.35 1.00686H76.6568ZM523.796 342.933C554.479 275.533 565.347 188.379 566.419 63.9394L566.422 63.432H361.661V503.786L362.405 503.364C442.602 457.962 493.101 410.36 523.796 342.933Z" fill="#116FF9"/>
|
||||||
|
<path d="M588.465 215C664.976 215 727 277.233 727 354C727 369.378 724.509 384.172 719.913 398H363V333.553C375.721 307.751 402.287 290 433 290C443.483 290 453.482 292.068 462.613 295.818C484.559 248.11 532.658 215 588.465 215Z" fill="#FD9C33"/>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(225 50) scale(0.112)" fill="#116FF9">
|
||||||
|
<path d="M238.439 995.188H0V209.944C0 111.788 76.3004 53.1675 156.688 53.1675C220.726 53.1675 276.589 74.9799 309.289 126.784L633.566 640.737V74.9799H872.005V860.224C872.005 958.379 795.704 1015.64 715.317 1015.64C652.641 1015.64 595.416 993.824 562.716 942.02L238.439 428.067V995.188Z"/>
|
||||||
|
<path d="M1389.81 1015.64C1177.26 1015.64 1015.12 852.044 1015.12 653.007C1015.12 455.332 1177.26 291.74 1389.81 291.74C1602.36 291.74 1764.5 455.332 1764.5 653.007C1764.5 852.044 1602.36 1015.64 1389.81 1015.64ZM1389.81 785.244C1467.47 785.244 1519.25 725.26 1519.25 654.37C1519.25 582.117 1467.47 522.133 1389.81 522.133C1312.15 522.133 1260.37 582.117 1260.37 654.37C1260.37 725.26 1312.15 785.244 1389.81 785.244Z"/>
|
||||||
|
<path d="M2221.42 1015.64C2008.87 1015.64 1846.73 853.407 1846.73 655.733C1846.73 437.61 1991.16 293.103 2207.79 293.103C2258.21 293.103 2308.62 308.099 2350.86 331.275V0H2596.11V655.733C2596.11 864.314 2439.42 1015.64 2221.42 1015.64ZM2221.42 785.244C2299.08 785.244 2350.86 726.623 2350.86 654.37C2350.86 583.48 2299.08 523.496 2221.42 523.496C2143.76 523.496 2091.98 583.48 2091.98 654.37C2091.98 726.623 2143.76 785.244 2221.42 785.244Z"/>
|
||||||
|
<path d="M3086.45 1014.27C2868.45 1014.27 2704.95 869.767 2704.95 646.19C2704.95 449.879 2852.1 286.287 3067.38 286.287C3290.83 286.287 3414.82 452.606 3414.82 635.284V696.631H2940.66C2957.01 764.795 3008.79 805.693 3083.73 805.693C3149.13 805.693 3200.9 770.248 3225.43 717.08L3413.45 811.146C3354.87 937.93 3239.05 1014.27 3086.45 1014.27ZM2951.56 569.847H3170.93C3160.03 531.676 3121.88 496.231 3064.65 496.231C3006.06 496.231 2966.55 530.312 2951.56 569.847Z"/>
|
||||||
|
<path d="M3604.95 845.228L3441.45 74.9799H3693.51L3812.05 704.811L3915.6 246.752C3945.58 111.788 4009.62 54.5308 4107.72 54.5308C4205.82 54.5308 4269.85 111.788 4299.83 246.752L4403.38 704.811L4521.92 74.9799H4773.98L4610.48 845.228C4587.32 955.653 4513.74 1017 4414.28 1017C4324.35 1017 4243.97 957.016 4220.8 856.134L4107.72 358.54L3994.63 856.134C3971.46 957.016 3891.08 1017 3801.15 1017C3701.69 1017 3628.11 955.653 3604.95 845.228Z"/>
|
||||||
|
<path d="M5121.11 1015.64C4922.19 1015.64 4787.3 852.044 4787.3 653.007C4787.3 455.332 4949.44 291.74 5161.99 291.74C5379.99 291.74 5536.68 444.426 5536.68 653.007V995.188H5305.05V944.747C5261.45 989.735 5200.14 1015.64 5121.11 1015.64ZM5161.99 785.244C5239.65 785.244 5291.43 725.26 5291.43 654.37C5291.43 582.117 5239.65 522.133 5161.99 522.133C5084.33 522.133 5032.55 582.117 5032.55 654.37C5032.55 725.26 5084.33 785.244 5161.99 785.244Z"/>
|
||||||
|
<path d="M5918.02 995.188H5672.77V617.562C5672.77 436.247 5776.32 291.74 5998.41 291.74C6044.73 291.74 6095.15 299.92 6129.21 314.916V550.761C6096.51 533.039 6055.63 523.496 6021.57 523.496C5957.53 523.496 5918.02 560.304 5918.02 625.741V995.188Z"/>
|
||||||
|
<path d="M6565.74 1015.64C6353.19 1015.64 6191.05 853.407 6191.05 655.733C6191.05 437.61 6335.48 293.103 6552.12 293.103C6602.53 293.103 6652.94 308.099 6695.18 331.275V0H6940.43V655.733C6940.43 864.314 6783.74 1015.64 6565.74 1015.64ZM6565.74 785.244C6643.41 785.244 6695.18 726.623 6695.18 654.37C6695.18 583.48 6643.41 523.496 6565.74 523.496C6488.08 523.496 6436.31 583.48 6436.31 654.37C6436.31 726.623 6488.08 785.244 6565.74 785.244Z"/>
|
||||||
|
<path d="M7430.78 1014.27C7212.77 1014.27 7049.27 869.767 7049.27 646.19C7049.27 449.879 7196.42 286.287 7411.7 286.287C7635.15 286.287 7759.14 452.606 7759.14 635.284V696.631H7284.99C7301.34 764.795 7353.11 805.693 7428.05 805.693C7493.45 805.693 7545.23 770.248 7569.75 717.08L7757.78 811.146C7699.19 937.93 7583.38 1014.27 7430.78 1014.27ZM7295.89 569.847H7515.25C7504.35 531.676 7466.2 496.231 7408.98 496.231C7350.39 496.231 7310.88 530.312 7295.89 569.847Z"/>
|
||||||
|
<path d="M8250.76 531.676C8160.84 531.676 8126.77 603.929 8126.77 689.815V995.188H7881.52V659.823C7881.52 459.422 7998.7 293.103 8250.76 293.103C8502.82 293.103 8620 459.422 8620 659.823V995.188H8374.75V689.815C8374.75 603.929 8340.69 531.676 8250.76 531.676Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.1 KiB |
@@ -1,19 +1,27 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="./NodeWarden.png" alt="NodeWarden Logo" />
|
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
运行在 Cloudflare Workers 上的第三方 Bitwarden 兼容服务端。
|
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://workers.cloudflare.com/)
|
<p align="center">
|
||||||
[](./LICENSE)
|
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
|
||||||
[](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
|
||||||
[](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
|
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
|
||||||
|
<a href="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml"><img src="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg" alt="Sync Upstream" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
[更新日志](./RELEASE_NOTES.md) | [提交问题](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [最新发布](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
<p align="center">
|
||||||
|
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
|
||||||
|
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
English: [`README_EN.md`](./README_EN.md)
|
<p align="center">
|
||||||
|
<a href="./README_EN.md">English</a> |
|
||||||
|
<a href="./CONTRIBUTING.md">贡献指南</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
> **免责声明**
|
> **免责声明**
|
||||||
> 本项目仅供学习与交流使用,请定期备份你的密码库。
|
> 本项目仅供学习与交流使用,请定期备份你的密码库。
|
||||||
@@ -30,7 +38,7 @@ English: [`README_EN.md`](./README_EN.md)
|
|||||||
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
|
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
|
||||||
| Send | ✅ | ✅ | 支持文本与文件 Send |
|
| Send | ✅ | ✅ | 支持文本与文件 Send |
|
||||||
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
|
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
|
||||||
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / E3 定时备份** |
|
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时备份** |
|
||||||
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
|
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
|
||||||
| TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 |
|
| TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 |
|
||||||
| 多用户 | ✅ | ✅ | 支持邀请码注册 |
|
| 多用户 | ✅ | ✅ | 支持邀请码注册 |
|
||||||
@@ -50,21 +58,33 @@ English: [`README_EN.md`](./README_EN.md)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 网页部署
|
## 可视化快速部署
|
||||||
|
|
||||||
|
1. Fork NodeWarden 仓库到自己的 GitHub 账号
|
||||||
|
2. 进入 [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
||||||
|
3. 选择 Continue with GitHub 并选择你的仓库
|
||||||
|
4. 构建命令填 `npm run build`,部署命令填 `npm run deploy`
|
||||||
|
- 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
|
||||||
|
5. 等部署完成后,打开生成的 Workers 域名
|
||||||
|
|
||||||
1. Fork 本仓库。若本项目对你有帮助,欢迎点个 Star。
|
- Workers 默认域名在部分网络环境不可直连。如需自定义域名,到 [Workers 设置](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings)里添加。
|
||||||
2. 打开 [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) ➜ `Continue with GitHub` ➜ 选择你 Fork 后的仓库(`NodeWarden`)➜ 下一步 ➜ (默认使用 R2 存储;若未开通,可用 KV 来代替,将**部署命令**改为 `npm run deploy:kv`)➜ 部署 ➜ 打开生成的链接
|
|
||||||
|
- 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。
|
||||||
|
|
||||||
|
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml` 或 `wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。
|
||||||
|
|
||||||
| 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
|
|
||||||
| KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> 同步方法(更新仓库):
|
> 默认R2与可选KV的区别:
|
||||||
>- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork` ➜ `Update branch`
|
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|
||||||
>- 自动:进入你的 Fork 仓库 ➜ `Actions` ➜ `Sync upstream` ➜ `Enable workflow`,会在每天凌晨 3 点自动同步上游。
|
> |---|---|---|---|
|
||||||
|
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
|
||||||
|
> | KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
||||||
|
|
||||||
|
|
||||||
|
## 更新方法:
|
||||||
|
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork` ➜ `Update branch`
|
||||||
|
- 自动:进入你的 Fork 仓库 ➜ `Actions` ➜ `Sync upstream` ➜ `Enable workflow`,会在每天凌晨 3 点自动同步上游。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +1,47 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="./NodeWarden.png" alt="NodeWarden Logo" />
|
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
A third-party Bitwarden-compatible server running on Cloudflare Workers.
|
Bitwarden-compatible server running on Cloudflare Workers
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://workers.cloudflare.com/)
|
<p align="center">
|
||||||
[](./LICENSE)
|
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
|
||||||
[](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
|
||||||
[](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
|
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
|
||||||
|
<a href="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml"><img src="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg" alt="Sync Upstream" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
[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)
|
<p align="center">
|
||||||
|
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
|
||||||
|
<a href="https://t.me/NodeWarden_Official">Telegram Group</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
English: [`README.md`](./README.md)
|
<p align="center">
|
||||||
|
<a href="./README.md">中文说明</a> |
|
||||||
|
<a href="./CONTRIBUTING.md">Contributing</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
> **Disclaimer**
|
> **Disclaimer**
|
||||||
> This project is for learning and communication purposes only. Please back up your vault regularly.
|
>
|
||||||
|
> 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.
|
> 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 |
|
| Capability | Bitwarden | NodeWarden | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Web Vault | ✅ | ✅ | **Original Web Vault interface** |
|
| 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 |
|
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
|
||||||
| Send | ✅ | ✅ | Supports both text and file Sends |
|
| Send | ✅ | ✅ | Supports both text and file Sends |
|
||||||
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
|
| 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** |
|
| Password hint (web) | ⚠️ Limited | ✅ | **No email required** |
|
||||||
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
|
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
|
||||||
| Multi-user | ✅ | ✅ | Invite-based registration |
|
| Multi-user | ✅ | ✅ | Invite-based registration |
|
||||||
@@ -46,19 +57,20 @@ English: [`README.md`](./README.md)
|
|||||||
- ✅ Mobile app
|
- ✅ Mobile app
|
||||||
- ✅ Browser extension
|
- ✅ Browser extension
|
||||||
- ✅ Linux desktop client
|
- ✅ Linux desktop client
|
||||||
- ⚠️ macOS desktop client not fully verified
|
- ⚠️ macOS desktop client has not been fully verified yet
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Web Deploy
|
## Web Deploy
|
||||||
|
|
||||||
1. Fork this repository. If this project helps you, please consider giving it a Star.
|
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`) -> `Next` -> deploy.
|
2. Open [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) -> `Continue with GitHub` -> select your forked repository (`NodeWarden`) -> continue.
|
||||||
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`.
|
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 |
|
| 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 |
|
| KV | No | 25 MiB (Cloudflare limit) | 1 GB |
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
@@ -71,7 +83,6 @@ English: [`README.md`](./README.md)
|
|||||||
```powershell
|
```powershell
|
||||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||||
cd NodeWarden
|
cd NodeWarden
|
||||||
|
|
||||||
npm install
|
npm install
|
||||||
npx wrangler login
|
npx wrangler login
|
||||||
|
|
||||||
@@ -92,13 +103,13 @@ npm run dev:kv
|
|||||||
|
|
||||||
- Remote backup supports **WebDAV** and **E3**
|
- Remote backup supports **WebDAV** and **E3**
|
||||||
- When `Include attachments` is enabled:
|
- When `Include attachments` is enabled:
|
||||||
- the ZIP still contains only `db.json` and `manifest.json`
|
- the ZIP still contains only `db.json` and `manifest.json`
|
||||||
- real attachment files are stored separately under `attachments/`
|
- actual attachment files are stored separately under `attachments/`
|
||||||
- later backups reuse existing attachments by stable blob name instead of uploading everything again
|
- later backups reuse existing attachments by stable blob name instead of re-uploading everything every time
|
||||||
- During remote restore:
|
- During remote restore:
|
||||||
- required attachment files are loaded from `attachments/`
|
- required attachment files are loaded from `attachments/` on demand
|
||||||
- missing attachments are skipped safely
|
- missing attachments are skipped safely
|
||||||
- skipped attachments do not leave broken rows in the restored database
|
- skipped attachments do not leave broken rows in the restored database
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -110,7 +121,7 @@ Current supported import sources include:
|
|||||||
- Bitwarden CSV
|
- Bitwarden CSV
|
||||||
- Bitwarden vault + attachments ZIP
|
- Bitwarden vault + attachments ZIP
|
||||||
- NodeWarden JSON
|
- 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:
|
Current supported export formats include:
|
||||||
|
|
||||||
@@ -130,9 +141,9 @@ LGPL-3.0 License
|
|||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
- [Bitwarden](https://bitwarden.com/) - original design and clients
|
- [Bitwarden](https://bitwarden.com/) - Original design and clients
|
||||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - server implementation reference
|
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - Server implementation reference
|
||||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - serverless platform
|
- [Cloudflare Workers](https://workers.cloudflare.com/) - Serverless platform
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
PRAGMA foreign_keys = ON;
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
-- IMPORTANT:
|
-- IMPORTANT:
|
||||||
-- Keep this file in sync with src/services/storage.ts (SCHEMA_STATEMENTS).
|
-- This is the initial D1 schema. Keep it in sync with
|
||||||
|
-- src/services/storage-schema.ts (SCHEMA_STATEMENTS).
|
||||||
-- Any new table/column/index must be added to both places together.
|
-- Any new table/column/index must be added to both places together.
|
||||||
|
--
|
||||||
|
-- WHEN CHANGING THIS:
|
||||||
|
-- - Also bump STORAGE_SCHEMA_VERSION in src/services/storage.ts.
|
||||||
|
-- - If the new table stores persistent data, update backup export/import.
|
||||||
|
-- - Keep src/services/storage-schema.ts idempotent for existing installs.
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS config (
|
CREATE TABLE IF NOT EXISTS config (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
@@ -28,10 +34,20 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
verify_devices INTEGER NOT NULL DEFAULT 1,
|
verify_devices INTEGER NOT NULL DEFAULT 1,
|
||||||
totp_secret TEXT,
|
totp_secret TEXT,
|
||||||
totp_recovery_code TEXT,
|
totp_recovery_code TEXT,
|
||||||
|
api_key TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS domain_settings (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
equivalent_domains TEXT NOT NULL DEFAULT '[]',
|
||||||
|
custom_equivalent_domains TEXT NOT NULL DEFAULT '[]',
|
||||||
|
excluded_global_equivalent_domains TEXT NOT NULL DEFAULT '[]',
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
-- Per-user sync revision date
|
-- Per-user sync revision date
|
||||||
CREATE TABLE IF NOT EXISTS user_revisions (
|
CREATE TABLE IF NOT EXISTS user_revisions (
|
||||||
user_id TEXT PRIMARY KEY,
|
user_id TEXT PRIMARY KEY,
|
||||||
@@ -59,6 +75,8 @@ 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_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_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 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 INDEX IF NOT EXISTS idx_ciphers_user_folder ON ciphers(user_id, folder_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS folders (
|
CREATE TABLE IF NOT EXISTS folders (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -106,11 +124,14 @@ 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_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_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 (
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
token TEXT PRIMARY KEY,
|
token TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
expires_at INTEGER NOT NULL,
|
expires_at INTEGER NOT NULL,
|
||||||
|
device_identifier TEXT,
|
||||||
|
device_session_stamp TEXT,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||||
@@ -133,6 +154,8 @@ CREATE TABLE IF NOT EXISTS audit_logs (
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
actor_user_id TEXT,
|
actor_user_id TEXT,
|
||||||
action TEXT NOT NULL,
|
action TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL DEFAULT 'system',
|
||||||
|
level TEXT NOT NULL DEFAULT 'info',
|
||||||
target_type TEXT,
|
target_type TEXT,
|
||||||
target_id TEXT,
|
target_id TEXT,
|
||||||
metadata TEXT,
|
metadata TEXT,
|
||||||
@@ -141,6 +164,8 @@ CREATE TABLE IF NOT EXISTS audit_logs (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at);
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_category_created ON audit_logs(category, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS devices (
|
CREATE TABLE IF NOT EXISTS devices (
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
@@ -151,12 +176,17 @@ CREATE TABLE IF NOT EXISTS devices (
|
|||||||
encrypted_user_key TEXT,
|
encrypted_user_key TEXT,
|
||||||
encrypted_public_key TEXT,
|
encrypted_public_key TEXT,
|
||||||
encrypted_private_key TEXT,
|
encrypted_private_key TEXT,
|
||||||
|
banned INTEGER NOT NULL DEFAULT 0,
|
||||||
|
banned_at TEXT,
|
||||||
|
device_note TEXT,
|
||||||
|
last_seen_at TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
PRIMARY KEY (user_id, device_identifier),
|
PRIMARY KEY (user_id, device_identifier),
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at);
|
CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
|
CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
|
||||||
token TEXT PRIMARY KEY,
|
token TEXT PRIMARY KEY,
|
||||||
@@ -176,14 +206,6 @@ CREATE TABLE IF NOT EXISTS login_attempts_ip (
|
|||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS api_rate_limits (
|
|
||||||
identifier TEXT NOT NULL,
|
|
||||||
window_start INTEGER NOT NULL,
|
|
||||||
count INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (identifier, window_start)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (
|
CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (
|
||||||
jti TEXT PRIMARY KEY,
|
jti TEXT PRIMARY KEY,
|
||||||
expires_at INTEGER NOT NULL
|
expires_at INTEGER NOT NULL
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.4.1",
|
"version": "1.5.2",
|
||||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||||
"author": "shuaiplus",
|
"author": "shuaiplus",
|
||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "wrangler dev -c wrangler.toml",
|
"dev": "npm run build && wrangler dev -c wrangler.toml",
|
||||||
"dev:kv": "wrangler dev -c wrangler.kv.toml",
|
"dev:kv": "npm run build && wrangler dev -c wrangler.kv.toml",
|
||||||
|
"dev:demo": "vite --config webapp/vite.config.ts --mode demo --host 127.0.0.1 --port 5174",
|
||||||
"build": "vite build --config webapp/vite.config.ts",
|
"build": "vite build --config webapp/vite.config.ts",
|
||||||
"deploy": "wrangler deploy",
|
"build:demo": "vite build --config webapp/vite.config.ts --mode demo && node scripts/pages-spa-redirects.cjs",
|
||||||
"deploy:kv": "wrangler deploy -c wrangler.kv.toml"
|
"domains:sync": "node scripts/sync-global-domains.mjs",
|
||||||
|
"i18n": "node scripts/i18n-validate.cjs",
|
||||||
|
"i18n:validate": "node scripts/i18n-validate.cjs",
|
||||||
|
"deploy": "npm run build && wrangler deploy",
|
||||||
|
"deploy:kv": "npm run build && wrangler deploy -c wrangler.kv.toml",
|
||||||
|
"deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"bitwarden",
|
"bitwarden",
|
||||||
@@ -40,15 +46,16 @@
|
|||||||
"@cloudflare/workers-types": "^4.20260131.0",
|
"@cloudflare/workers-types": "^4.20260131.0",
|
||||||
"@preact/preset-vite": "^2.10.3",
|
"@preact/preset-vite": "^2.10.3",
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"opencc-js": "^1.0.5",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"wrangler": "^4.71.0"
|
"wrangler": "^4.71.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@zip.js/zip.js": "^2.8.22",
|
"@zip.js/zip.js": "^2.8.22",
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
// CONTRACT:
|
||||||
|
// This list is the script-side locale source of truth. Keep it in sync with
|
||||||
|
// webapp/src/lib/i18n.ts whenever adding/removing a locale.
|
||||||
|
const localeDir = path.join(__dirname, '..', 'webapp', 'src', 'lib', 'i18n', 'locales');
|
||||||
|
|
||||||
|
const localeFiles = [
|
||||||
|
['en', 'en.ts', 'en', 'English'],
|
||||||
|
['zh-CN', 'zh-CN.ts', 'zhCN', 'Simplified Chinese'],
|
||||||
|
['zh-TW', 'zh-TW.ts', 'zhTW', 'Traditional Chinese'],
|
||||||
|
['ru', 'ru.ts', 'ru', 'Russian'],
|
||||||
|
['es', 'es.ts', 'es', 'Spanish'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function readLocale(fileName, variableName) {
|
||||||
|
let code = fs.readFileSync(path.join(localeDir, fileName), 'utf8');
|
||||||
|
code = code
|
||||||
|
.replace(/const (\w+): Record<string, string> =/g, 'const $1 =')
|
||||||
|
.replace(/export default \w+;\s*$/m, '');
|
||||||
|
code += `\nresult = ${variableName};`;
|
||||||
|
const sandbox = { result: null };
|
||||||
|
vm.createContext(sandbox);
|
||||||
|
vm.runInContext(code, sandbox, { filename: fileName });
|
||||||
|
return sandbox.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeLocale(fileName, variableName, table, header) {
|
||||||
|
const body = JSON.stringify(table, null, 2);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(localeDir, fileName),
|
||||||
|
`${header}\nconst ${variableName}: Record<string, string> = ${body};\n\nexport default ${variableName};\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
localeFiles,
|
||||||
|
localeDir,
|
||||||
|
readLocale,
|
||||||
|
writeLocale,
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
const { localeFiles, readLocale } = require('./i18n-utils.cjs');
|
||||||
|
|
||||||
|
// CONTRACT:
|
||||||
|
// This is the authoritative locale consistency gate. It checks key parity,
|
||||||
|
// placeholder parity, and accidental mostly-English locale files. Run after any
|
||||||
|
// user-facing text or locale-file change.
|
||||||
|
const locales = Object.fromEntries(
|
||||||
|
localeFiles.map(([locale, fileName, variableName]) => [locale, readLocale(fileName, variableName)])
|
||||||
|
);
|
||||||
|
const base = locales.en;
|
||||||
|
const baseKeys = Object.keys(base).sort();
|
||||||
|
const placeholderRe = /\{\w+\}/g;
|
||||||
|
const errors = [];
|
||||||
|
const intentionallyEnglishKeys = new Set([
|
||||||
|
'txt_backup_destination_detail_note',
|
||||||
|
'txt_backup_protocol_webdav',
|
||||||
|
'txt_backup_protocol_s3',
|
||||||
|
'txt_backup_recommend_group_webdav',
|
||||||
|
'txt_backup_recommend_group_s3',
|
||||||
|
'txt_backup_destination_name_default_webdav',
|
||||||
|
'txt_backup_destination_name_default_s3',
|
||||||
|
'txt_dash',
|
||||||
|
'txt_text_3',
|
||||||
|
]);
|
||||||
|
const intentionallyEnglishPrefixes = [
|
||||||
|
'txt_log_action_',
|
||||||
|
'txt_log_meta_',
|
||||||
|
'txt_log_reason_',
|
||||||
|
'txt_log_target_type_',
|
||||||
|
'txt_log_trigger_',
|
||||||
|
];
|
||||||
|
|
||||||
|
function isIntentionallyEnglishKey(key) {
|
||||||
|
return intentionallyEnglishKeys.has(key) || intentionallyEnglishPrefixes.some((prefix) => key.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [locale, table] of Object.entries(locales)) {
|
||||||
|
const keys = Object.keys(table).sort();
|
||||||
|
const missing = baseKeys.filter((key) => !(key in table));
|
||||||
|
const extra = keys.filter((key) => !baseKeys.includes(key));
|
||||||
|
if (missing.length || extra.length) {
|
||||||
|
errors.push({ locale, missing, extra });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of baseKeys) {
|
||||||
|
const basePlaceholders = Array.from(String(base[key]).matchAll(placeholderRe), (match) => match[0]).sort().join('|');
|
||||||
|
const localePlaceholders = Array.from(String(table[key]).matchAll(placeholderRe), (match) => match[0]).sort().join('|');
|
||||||
|
if (basePlaceholders !== localePlaceholders) {
|
||||||
|
errors.push({ locale, key, basePlaceholders, localePlaceholders });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locale !== 'en') {
|
||||||
|
const sameAsEnglish = baseKeys.filter((key) => table[key] === base[key] && !isIntentionallyEnglishKey(key));
|
||||||
|
if (sameAsEnglish.length > 40) {
|
||||||
|
errors.push({
|
||||||
|
locale,
|
||||||
|
sameAsEnglishCount: sameAsEnglish.length,
|
||||||
|
sameAsEnglishSample: sameAsEnglish.slice(0, 25),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
counts: Object.fromEntries(Object.entries(locales).map(([locale, table]) => [locale, Object.keys(table).length])),
|
||||||
|
errors,
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
if (errors.length) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const distDir = path.resolve(__dirname, '..', 'dist');
|
||||||
|
|
||||||
|
fs.mkdirSync(distDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(distDir, '_redirects'), '/* /index.html 200\n');
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const DEFAULT_REF = 'main';
|
||||||
|
const OUTPUT_DIR = path.join(process.cwd(), 'src', 'static');
|
||||||
|
const OUT_FILE = path.join(OUTPUT_DIR, 'global_domains.bitwarden.json');
|
||||||
|
const META_FILE = path.join(OUTPUT_DIR, 'global_domains.bitwarden.meta.json');
|
||||||
|
const ENUM_PATH = 'src/Core/Enums/GlobalEquivalentDomainsType.cs';
|
||||||
|
const STATIC_STORE_PATH = 'src/Core/Utilities/StaticStore.cs';
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = { ref: process.env.BITWARDEN_SERVER_REF || DEFAULT_REF };
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (arg === '--ref' && argv[i + 1]) {
|
||||||
|
args.ref = argv[i + 1];
|
||||||
|
i += 1;
|
||||||
|
} else if (arg.startsWith('--ref=')) {
|
||||||
|
args.ref = arg.slice('--ref='.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawUrl(ref, filePath) {
|
||||||
|
return `https://raw.githubusercontent.com/bitwarden/server/${encodeURIComponent(ref)}/${filePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchText(url) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'NodeWarden global domains sync',
|
||||||
|
Accept: 'text/plain',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnumTypes(source) {
|
||||||
|
const map = new Map();
|
||||||
|
const enumMatch = source.match(/enum\s+GlobalEquivalentDomainsType\b[\s\S]*?\{([\s\S]*?)\}/);
|
||||||
|
if (!enumMatch) {
|
||||||
|
throw new Error('GlobalEquivalentDomainsType enum was not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = enumMatch[1].replace(/\/\/.*$/gm, '');
|
||||||
|
const entryRe = /\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(\d+)\b/g;
|
||||||
|
let match;
|
||||||
|
while ((match = entryRe.exec(body)) !== null) {
|
||||||
|
map.set(match[1], Number(match[2]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.size) {
|
||||||
|
throw new Error('No enum values were parsed from GlobalEquivalentDomainsType');
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStringList(source) {
|
||||||
|
const domains = [];
|
||||||
|
const stringRe = /"((?:\\.|[^"\\])*)"/g;
|
||||||
|
let match;
|
||||||
|
while ((match = stringRe.exec(source)) !== null) {
|
||||||
|
domains.push(match[1].replace(/\\"/g, '"').trim().toLowerCase());
|
||||||
|
}
|
||||||
|
return Array.from(new Set(domains.filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGlobalDomains(source, enumTypes) {
|
||||||
|
const out = [];
|
||||||
|
const addRe = /GlobalDomains\.Add\s*\(\s*GlobalEquivalentDomainsType\.([A-Za-z_][A-Za-z0-9_]*)\s*,\s*new\s+List(?:<\s*string\s*>)?\s*\{([\s\S]*?)\}\s*\)\s*;/g;
|
||||||
|
let match;
|
||||||
|
while ((match = addRe.exec(source)) !== null) {
|
||||||
|
const name = match[1];
|
||||||
|
const type = enumTypes.get(name);
|
||||||
|
if (!Number.isInteger(type)) {
|
||||||
|
throw new Error(`GlobalDomains references unknown enum value ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domains = parseStringList(match[2]);
|
||||||
|
if (domains.length < 2) {
|
||||||
|
throw new Error(`GlobalDomains.${name} has fewer than two domains`);
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
type,
|
||||||
|
domains,
|
||||||
|
excluded: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!out.length) {
|
||||||
|
throw new Error('No GlobalDomains.Add(...) rules were parsed from StaticStore.cs');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRulesJson(rules) {
|
||||||
|
return `[\n${rules.map((rule) => ` ${JSON.stringify(rule)}`).join(',\n')}\n]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMetaJson(meta) {
|
||||||
|
return JSON.stringify(meta, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ref } = parseArgs(process.argv.slice(2));
|
||||||
|
const enumUrl = rawUrl(ref, ENUM_PATH);
|
||||||
|
const staticStoreUrl = rawUrl(ref, STATIC_STORE_PATH);
|
||||||
|
|
||||||
|
const [enumSource, staticStoreSource] = await Promise.all([
|
||||||
|
fetchText(enumUrl),
|
||||||
|
fetchText(staticStoreUrl),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const enumTypes = parseEnumTypes(enumSource);
|
||||||
|
const rules = parseGlobalDomains(staticStoreSource, enumTypes);
|
||||||
|
const domainsCount = rules.reduce((sum, rule) => sum + rule.domains.length, 0);
|
||||||
|
const rulesJson = formatRulesJson(rules);
|
||||||
|
|
||||||
|
async function readJsonFile(filePath) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(await readFile(filePath, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRules = await readJsonFile(OUT_FILE);
|
||||||
|
const existingMeta = await readJsonFile(META_FILE);
|
||||||
|
const unchangedRules = JSON.stringify(existingRules) === JSON.stringify(rules);
|
||||||
|
const unchangedRef = existingMeta?.ref === ref;
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
source: 'https://github.com/bitwarden/server',
|
||||||
|
ref,
|
||||||
|
generatedAt: unchangedRules && unchangedRef && existingMeta?.generatedAt
|
||||||
|
? existingMeta.generatedAt
|
||||||
|
: new Date().toISOString(),
|
||||||
|
rulesCount: rules.length,
|
||||||
|
domainsCount,
|
||||||
|
sourceFiles: [
|
||||||
|
ENUM_PATH,
|
||||||
|
STATIC_STORE_PATH,
|
||||||
|
],
|
||||||
|
sourceUrls: [
|
||||||
|
enumUrl,
|
||||||
|
staticStoreUrl,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await mkdir(OUTPUT_DIR, { recursive: true });
|
||||||
|
await writeFile(OUT_FILE, `${rulesJson}\n`, 'utf8');
|
||||||
|
await writeFile(META_FILE, `${formatMetaJson(meta)}\n`, 'utf8');
|
||||||
|
|
||||||
|
console.log(`Wrote ${rules.length} global domain rules (${domainsCount} domains) from bitwarden/server@${ref}.`);
|
||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = '1.4.1';
|
export const APP_VERSION = '1.5.2';
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
|
// Shared backup settings types used by both Worker and webapp code.
|
||||||
|
//
|
||||||
|
// CONTRACT:
|
||||||
|
// Keep this file serializable and provider-neutral. Runtime state is operational
|
||||||
|
// metadata; destination fields can contain provider credentials and must be
|
||||||
|
// encrypted by src/services/backup-settings-crypto.ts before storage/export.
|
||||||
|
// User-facing provider names should use canonical values here. Legacy aliases
|
||||||
|
// belong in backend normalization, not in this shared type.
|
||||||
export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
|
export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
|
||||||
export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
|
export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
|
||||||
export const BACKUP_DEFAULT_E3_REGION = 'auto';
|
export const BACKUP_DEFAULT_S3_REGION = 'auto';
|
||||||
export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden';
|
export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden';
|
||||||
export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
|
export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
|
||||||
export const BACKUP_DEFAULT_START_TIME = '03:00';
|
export const BACKUP_DEFAULT_START_TIME = '03:00';
|
||||||
|
|
||||||
export type BackupDestinationType = 'e3' | 'webdav';
|
export type BackupDestinationType = 's3' | 'webdav';
|
||||||
|
|
||||||
export interface E3BackupDestination {
|
export interface S3BackupDestination {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
bucket: string;
|
bucket: string;
|
||||||
region: string;
|
region: string;
|
||||||
@@ -24,7 +32,7 @@ export interface WebDavBackupDestination {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type BackupDestinationConfig =
|
export type BackupDestinationConfig =
|
||||||
| E3BackupDestination
|
| S3BackupDestination
|
||||||
| WebDavBackupDestination;
|
| WebDavBackupDestination;
|
||||||
|
|
||||||
export interface BackupRuntimeState {
|
export interface BackupRuntimeState {
|
||||||
@@ -91,11 +99,11 @@ export function createDefaultBackupScheduleConfig(timezone: string = BACKUP_DEFA
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createDefaultBackupDestinationConfig(type: BackupDestinationType): BackupDestinationConfig {
|
export function createDefaultBackupDestinationConfig(type: BackupDestinationType): BackupDestinationConfig {
|
||||||
if (type === 'e3') {
|
if (type === 's3') {
|
||||||
return {
|
return {
|
||||||
endpoint: '',
|
endpoint: '',
|
||||||
bucket: '',
|
bucket: '',
|
||||||
region: BACKUP_DEFAULT_E3_REGION,
|
region: BACKUP_DEFAULT_S3_REGION,
|
||||||
accessKeyId: '',
|
accessKeyId: '',
|
||||||
secretAccessKey: '',
|
secretAccessKey: '',
|
||||||
rootPath: BACKUP_DEFAULT_REMOTE_PATH,
|
rootPath: BACKUP_DEFAULT_REMOTE_PATH,
|
||||||
@@ -110,7 +118,7 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createDefaultBackupDestinationName(type: BackupDestinationType, index: number): string {
|
export function createDefaultBackupDestinationName(type: BackupDestinationType, index: number): string {
|
||||||
if (type === 'e3') return `E3 ${index}`;
|
if (type === 's3') return `S3 ${index}`;
|
||||||
return `WebDAV ${index}`;
|
return `WebDAV ${index}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
const MULTI_LABEL_PUBLIC_SUFFIXES = new Set([
|
||||||
|
'ac.cn',
|
||||||
|
'com.cn',
|
||||||
|
'edu.cn',
|
||||||
|
'gov.cn',
|
||||||
|
'net.cn',
|
||||||
|
'org.cn',
|
||||||
|
'ah.cn',
|
||||||
|
'bj.cn',
|
||||||
|
'cq.cn',
|
||||||
|
'fj.cn',
|
||||||
|
'gd.cn',
|
||||||
|
'gs.cn',
|
||||||
|
'gx.cn',
|
||||||
|
'gz.cn',
|
||||||
|
'ha.cn',
|
||||||
|
'hb.cn',
|
||||||
|
'he.cn',
|
||||||
|
'hi.cn',
|
||||||
|
'hk.cn',
|
||||||
|
'hl.cn',
|
||||||
|
'hn.cn',
|
||||||
|
'jl.cn',
|
||||||
|
'js.cn',
|
||||||
|
'jx.cn',
|
||||||
|
'ln.cn',
|
||||||
|
'mo.cn',
|
||||||
|
'nm.cn',
|
||||||
|
'nx.cn',
|
||||||
|
'qh.cn',
|
||||||
|
'sc.cn',
|
||||||
|
'sd.cn',
|
||||||
|
'sh.cn',
|
||||||
|
'sn.cn',
|
||||||
|
'sx.cn',
|
||||||
|
'tj.cn',
|
||||||
|
'tw.cn',
|
||||||
|
'xj.cn',
|
||||||
|
'xz.cn',
|
||||||
|
'yn.cn',
|
||||||
|
'zj.cn',
|
||||||
|
'co.uk',
|
||||||
|
'org.uk',
|
||||||
|
'net.uk',
|
||||||
|
'ac.uk',
|
||||||
|
'gov.uk',
|
||||||
|
'com.au',
|
||||||
|
'net.au',
|
||||||
|
'org.au',
|
||||||
|
'edu.au',
|
||||||
|
'gov.au',
|
||||||
|
'co.nz',
|
||||||
|
'org.nz',
|
||||||
|
'net.nz',
|
||||||
|
'com.br',
|
||||||
|
'com.mx',
|
||||||
|
'com.ar',
|
||||||
|
'com.tr',
|
||||||
|
'com.sg',
|
||||||
|
'com.my',
|
||||||
|
'com.hk',
|
||||||
|
'com.tw',
|
||||||
|
'co.jp',
|
||||||
|
'ne.jp',
|
||||||
|
'or.jp',
|
||||||
|
'co.kr',
|
||||||
|
'or.kr',
|
||||||
|
'co.in',
|
||||||
|
'firm.in',
|
||||||
|
'net.in',
|
||||||
|
'org.in',
|
||||||
|
'co.id',
|
||||||
|
'or.id',
|
||||||
|
'web.id',
|
||||||
|
'co.il',
|
||||||
|
'org.il',
|
||||||
|
'co.za',
|
||||||
|
'com.sa',
|
||||||
|
'com.ph',
|
||||||
|
'com.vn',
|
||||||
|
'com.pk',
|
||||||
|
'com.bd',
|
||||||
|
'com.ng',
|
||||||
|
'github.io',
|
||||||
|
'pages.dev',
|
||||||
|
'workers.dev',
|
||||||
|
'cloudflareaccess.com',
|
||||||
|
'vercel.app',
|
||||||
|
'netlify.app',
|
||||||
|
'web.app',
|
||||||
|
'firebaseapp.com',
|
||||||
|
'herokuapp.com',
|
||||||
|
'fly.dev',
|
||||||
|
'railway.app',
|
||||||
|
'render.com',
|
||||||
|
'onrender.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function extractHost(input: string): string {
|
||||||
|
let raw = input.trim().toLowerCase();
|
||||||
|
if (!raw) return '';
|
||||||
|
raw = raw.replace(/\\/g, '/');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
|
||||||
|
const parsed = new URL(candidate);
|
||||||
|
raw = parsed.hostname;
|
||||||
|
} catch {
|
||||||
|
raw = raw.split(/[/?#]/, 1)[0] || '';
|
||||||
|
const atIndex = raw.lastIndexOf('@');
|
||||||
|
if (atIndex >= 0) raw = raw.slice(atIndex + 1);
|
||||||
|
if (raw.startsWith('[')) return '';
|
||||||
|
const colonIndex = raw.lastIndexOf(':');
|
||||||
|
if (colonIndex > -1 && raw.indexOf(':') === colonIndex) raw = raw.slice(0, colonIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
.replace(/^\*+\./, '')
|
||||||
|
.replace(/^\.+/, '')
|
||||||
|
.replace(/\.+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidHost(host: string): boolean {
|
||||||
|
if (!host || host.length > 253 || !host.includes('.')) return false;
|
||||||
|
if (host.includes('..') || /[:/\s]/.test(host)) return false;
|
||||||
|
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) return false;
|
||||||
|
return host.split('.').every((label) => (
|
||||||
|
label.length > 0
|
||||||
|
&& label.length <= 63
|
||||||
|
&& /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEquivalentDomain(value: unknown): string {
|
||||||
|
const host = extractHost(String(value || ''));
|
||||||
|
if (!isValidHost(host)) return '';
|
||||||
|
|
||||||
|
const labels = host.split('.');
|
||||||
|
for (let index = 0; index < labels.length; index += 1) {
|
||||||
|
const suffix = labels.slice(index).join('.');
|
||||||
|
if (!MULTI_LABEL_PUBLIC_SUFFIXES.has(suffix)) continue;
|
||||||
|
if (index === 0) return '';
|
||||||
|
return labels.slice(index - 1).join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels.length >= 2 ? labels.slice(-2).join('.') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidEquivalentDomain(value: unknown): boolean {
|
||||||
|
return !!normalizeEquivalentDomain(value);
|
||||||
|
}
|
||||||
@@ -5,10 +5,10 @@
|
|||||||
accessTokenTtlSeconds: 7200,
|
accessTokenTtlSeconds: 7200,
|
||||||
// Refresh token lifetime in milliseconds.
|
// Refresh token lifetime in milliseconds.
|
||||||
// 刷新令牌有效期(毫秒)。
|
// 刷新令牌有效期(毫秒)。
|
||||||
refreshTokenTtlMs: 30 * 24 * 60 * 60 * 1000,
|
refreshTokenTtlMs: 365 * 24 * 60 * 60 * 1000,
|
||||||
// Grace window for previous refresh token after rotation (ms).
|
// Grace window for previous refresh token after rotation (ms).
|
||||||
// 刷新令牌轮换后的旧令牌宽限窗口(毫秒)。
|
// 刷新令牌轮换后的旧令牌宽限窗口(毫秒)。
|
||||||
refreshTokenOverlapGraceMs: 60 * 1000,
|
refreshTokenOverlapGraceMs: 30 * 60 * 1000,
|
||||||
// Refresh token random byte length.
|
// Refresh token random byte length.
|
||||||
// 刷新令牌随机字节长度。
|
// 刷新令牌随机字节长度。
|
||||||
refreshTokenRandomBytes: 32,
|
refreshTokenRandomBytes: 32,
|
||||||
@@ -24,6 +24,9 @@
|
|||||||
// Default PBKDF2 iterations for account creation/prelogin fallback.
|
// Default PBKDF2 iterations for account creation/prelogin fallback.
|
||||||
// 账户创建与预登录回退使用的默认 PBKDF2 迭代次数。
|
// 账户创建与预登录回退使用的默认 PBKDF2 迭代次数。
|
||||||
defaultKdfIterations: 600000,
|
defaultKdfIterations: 600000,
|
||||||
|
// clientSecret length
|
||||||
|
// clientSecret 长度
|
||||||
|
clientSecretLength: 30,
|
||||||
},
|
},
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
// Max failed login attempts before temporary lock.
|
// Max failed login attempts before temporary lock.
|
||||||
@@ -130,6 +133,9 @@
|
|||||||
// Max total items (folders + ciphers) allowed in a single import.
|
// Max total items (folders + ciphers) allowed in a single import.
|
||||||
// 单次导入允许的最大条目数(文件夹 + 密码项合计)。
|
// 单次导入允许的最大条目数(文件夹 + 密码项合计)。
|
||||||
importItemLimit: 5000,
|
importItemLimit: 5000,
|
||||||
|
// Small fixed concurrency for blob/attachment batch cleanup work.
|
||||||
|
// 附件 / blob 批量清理时的保守并发数。
|
||||||
|
attachmentDeleteConcurrency: 4,
|
||||||
},
|
},
|
||||||
request: {
|
request: {
|
||||||
// Hard body size limit for JSON API endpoints (bytes). File upload paths are exempt.
|
// Hard body size limit for JSON API endpoints (bytes). File upload paths are exempt.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DurableObject, waitUntil } from 'cloudflare:workers';
|
||||||
import type { Env } from '../types';
|
import type { Env } from '../types';
|
||||||
|
|
||||||
const SIGNALR_RECORD_SEPARATOR = 0x1e;
|
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_SYNC_VAULT = 5;
|
||||||
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
||||||
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
|
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';
|
type HubProtocol = 'json' | 'messagepack';
|
||||||
|
|
||||||
interface ConnectionState {
|
interface WsAttachment {
|
||||||
|
userId: string;
|
||||||
handshakeComplete: boolean;
|
handshakeComplete: boolean;
|
||||||
protocol: HubProtocol;
|
protocol: HubProtocol;
|
||||||
deviceIdentifier: string | null;
|
deviceIdentifier: string | null;
|
||||||
@@ -30,6 +32,12 @@ function encodeUtf8(value: string): Uint8Array {
|
|||||||
return new TextEncoder().encode(value);
|
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 {
|
function encodeMsgPackInteger(value: number): Uint8Array {
|
||||||
const normalized = Math.trunc(value);
|
const normalized = Math.trunc(value);
|
||||||
if (normalized >= 0 && normalized <= 0x7f) {
|
if (normalized >= 0 && normalized <= 0x7f) {
|
||||||
@@ -127,40 +135,31 @@ function frameSignalRBinary(payload: Uint8Array): Uint8Array {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSignalRJsonInvocation(
|
function buildSignalRJsonInvocation(
|
||||||
userId: string,
|
|
||||||
updateType: number,
|
updateType: number,
|
||||||
revisionDate: string,
|
payload: Record<string, unknown>,
|
||||||
contextId: string | null
|
contextId: string | null
|
||||||
): string {
|
): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
type: 1,
|
type: 1,
|
||||||
target: 'ReceiveMessage',
|
target: 'ReceiveMessage',
|
||||||
arguments: [
|
arguments: [
|
||||||
{
|
{
|
||||||
ContextId: contextId,
|
ContextId: contextId,
|
||||||
Type: updateType,
|
Type: updateType,
|
||||||
Payload: {
|
Payload: payload,
|
||||||
UserId: userId,
|
|
||||||
Date: revisionDate,
|
|
||||||
},
|
},
|
||||||
},
|
],
|
||||||
],
|
}) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
|
||||||
}) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSignalRJsonPing(): string {
|
|
||||||
return JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSignalRMessagePackInvocation(
|
function buildSignalRMessagePackInvocation(
|
||||||
userId: string,
|
|
||||||
updateType: number,
|
updateType: number,
|
||||||
revisionDate: string,
|
messagePayload: Record<string, unknown>,
|
||||||
contextId: string | null
|
contextId: string | null
|
||||||
): Uint8Array {
|
): Uint8Array {
|
||||||
// SignalR MessagePack hub protocol uses an array-based invocation shape:
|
// SignalR MessagePack hub protocol uses an array-based invocation shape:
|
||||||
// [type, headers, invocationId, target, arguments]
|
// [type, headers, invocationId, target, arguments]
|
||||||
const payload = encodeMsgPack([
|
const encodedPayload = encodeMsgPack([
|
||||||
1,
|
1,
|
||||||
{},
|
{},
|
||||||
null,
|
null,
|
||||||
@@ -169,34 +168,22 @@ function buildSignalRMessagePackInvocation(
|
|||||||
{
|
{
|
||||||
ContextId: contextId,
|
ContextId: contextId,
|
||||||
Type: updateType,
|
Type: updateType,
|
||||||
Payload: {
|
Payload: messagePayload,
|
||||||
UserId: userId,
|
|
||||||
Date: new Date(revisionDate),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
return frameSignalRBinary(payload);
|
return frameSignalRBinary(encodedPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSignalRMessagePackPing(): Uint8Array {
|
export class NotificationsHub extends DurableObject<Env> {
|
||||||
return frameSignalRBinary(encodeMsgPack([6]));
|
constructor(ctx: DurableObjectState, env: Env) {
|
||||||
}
|
super(ctx, env);
|
||||||
|
this.ctx.setWebSocketAutoResponse(
|
||||||
function decodeIncomingMessage(data: string | ArrayBuffer | ArrayBufferView): string {
|
new WebSocketRequestResponsePair(
|
||||||
if (typeof data === 'string') return data;
|
JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR),
|
||||||
if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data));
|
JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR)
|
||||||
return new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
)
|
||||||
}
|
);
|
||||||
|
|
||||||
export class NotificationsHub {
|
|
||||||
private readonly connections = new Map<WebSocket, ConnectionState>();
|
|
||||||
private userId = '';
|
|
||||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
constructor(private readonly state: DurableObjectState, private readonly env: Env) {
|
|
||||||
void this.state;
|
|
||||||
void this.env;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetch(request: Request): Promise<Response> {
|
async fetch(request: Request): Promise<Response> {
|
||||||
@@ -209,13 +196,20 @@ export class NotificationsHub {
|
|||||||
contextId?: string | null;
|
contextId?: string | null;
|
||||||
updateType?: number;
|
updateType?: number;
|
||||||
targetDeviceIdentifier?: string | null;
|
targetDeviceIdentifier?: string | null;
|
||||||
|
payload?: Record<string, unknown> | null;
|
||||||
} | null;
|
} | null;
|
||||||
const revisionDate = String(body?.revisionDate || '').trim() || new Date().toISOString();
|
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 contextId = String(body?.contextId || '').trim() || null;
|
||||||
const updateType = Number(body?.updateType || SIGNALR_UPDATE_TYPE_SYNC_VAULT) || SIGNALR_UPDATE_TYPE_SYNC_VAULT;
|
const updateType = Number(body?.updateType || SIGNALR_UPDATE_TYPE_SYNC_VAULT) || SIGNALR_UPDATE_TYPE_SYNC_VAULT;
|
||||||
const targetDeviceIdentifier = String(body?.targetDeviceIdentifier || '').trim() || null;
|
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 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,46 +232,27 @@ export class NotificationsHub {
|
|||||||
|
|
||||||
const requestUserId = String(url.searchParams.get('nw_uid') || '').trim();
|
const requestUserId = String(url.searchParams.get('nw_uid') || '').trim();
|
||||||
const requestDeviceIdentifier = String(url.searchParams.get('nw_did') || '').trim() || null;
|
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 });
|
return new Response('Unauthorized', { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const pair = new WebSocketPair();
|
const pair = new WebSocketPair();
|
||||||
const client = pair[0];
|
const client = pair[0];
|
||||||
const server = pair[1];
|
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,
|
handshakeComplete: false,
|
||||||
protocol: 'messagepack',
|
protocol: 'messagepack',
|
||||||
deviceIdentifier: requestDeviceIdentifier,
|
deviceIdentifier: requestDeviceIdentifier,
|
||||||
});
|
} satisfies WsAttachment);
|
||||||
this.ensurePingLoop();
|
|
||||||
|
|
||||||
server.addEventListener('message', (event) => {
|
|
||||||
void this.handleSocketMessage(server, event.data);
|
|
||||||
});
|
|
||||||
server.addEventListener('close', () => {
|
|
||||||
const shouldBroadcast = !!this.connections.get(server)?.handshakeComplete;
|
|
||||||
this.connections.delete(server);
|
|
||||||
this.stopPingLoopIfIdle();
|
|
||||||
if (shouldBroadcast) this.broadcastDeviceStatus();
|
|
||||||
});
|
|
||||||
server.addEventListener('error', () => {
|
|
||||||
const shouldBroadcast = !!this.connections.get(server)?.handshakeComplete;
|
|
||||||
this.connections.delete(server);
|
|
||||||
this.stopPingLoopIfIdle();
|
|
||||||
if (shouldBroadcast) this.broadcastDeviceStatus();
|
|
||||||
try {
|
|
||||||
server.close(1011, 'Socket error');
|
|
||||||
} catch {
|
|
||||||
// ignore close races
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 101,
|
status: 101,
|
||||||
@@ -285,21 +260,21 @@ export class NotificationsHub {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleSocketMessage(socket: WebSocket, rawData: string | ArrayBuffer | ArrayBufferView): Promise<void> {
|
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer | ArrayBufferView): Promise<void> {
|
||||||
const connection = this.connections.get(socket);
|
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||||
if (!connection) return;
|
if (!attachment) return;
|
||||||
|
|
||||||
if (!connection.handshakeComplete) {
|
if (!attachment.handshakeComplete) {
|
||||||
const text = decodeIncomingMessage(rawData);
|
const text = decodeIncomingMessage(message);
|
||||||
const frames = text.split(String.fromCharCode(SIGNALR_RECORD_SEPARATOR)).filter(Boolean);
|
const frames = text.split(String.fromCharCode(SIGNALR_RECORD_SEPARATOR)).filter(Boolean);
|
||||||
for (const frame of frames) {
|
for (const frame of frames) {
|
||||||
try {
|
try {
|
||||||
const handshake = JSON.parse(frame) as { protocol?: string };
|
const handshake = JSON.parse(frame) as { protocol?: string };
|
||||||
const protocol = handshake.protocol === 'json' ? 'json' : 'messagepack';
|
attachment.protocol = handshake.protocol === 'json' ? 'json' : 'messagepack';
|
||||||
connection.protocol = protocol;
|
attachment.handshakeComplete = true;
|
||||||
connection.handshakeComplete = true;
|
ws.serializeAttachment(attachment);
|
||||||
socket.send(SIGNALR_HANDSHAKE_ACK);
|
ws.send(SIGNALR_HANDSHAKE_ACK);
|
||||||
this.broadcastDeviceStatus();
|
this.broadcastDeviceStatus(attachment.userId);
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore malformed pre-handshake payloads.
|
// Ignore malformed pre-handshake payloads.
|
||||||
@@ -307,107 +282,101 @@ export class NotificationsHub {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private ensurePingLoop(): void {
|
if (typeof message !== 'string') {
|
||||||
if (this.pingTimer !== null) return;
|
|
||||||
this.pingTimer = setInterval(() => {
|
|
||||||
this.broadcastPing();
|
|
||||||
}, SIGNALR_PING_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
private stopPingLoopIfIdle(): void {
|
|
||||||
if (this.connections.size > 0 || this.pingTimer === null) return;
|
|
||||||
clearInterval(this.pingTimer);
|
|
||||||
this.pingTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private broadcastPing(): void {
|
|
||||||
if (this.connections.size === 0) {
|
|
||||||
this.stopPingLoopIfIdle();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [socket, connection] of this.connections) {
|
|
||||||
if (!connection.handshakeComplete) continue;
|
|
||||||
try {
|
try {
|
||||||
if (connection.protocol === 'json') {
|
ws.send(message);
|
||||||
socket.send(buildSignalRJsonPing());
|
|
||||||
} else {
|
|
||||||
socket.send(buildSignalRMessagePackPing());
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
this.connections.delete(socket);
|
// ignore send errors on echo
|
||||||
try {
|
|
||||||
socket.close(1011, 'Ping send failed');
|
|
||||||
} catch {
|
|
||||||
// ignore close races
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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[] {
|
private getOnlineDeviceIdentifiers(): string[] {
|
||||||
const out = new Set<string>();
|
const out = new Set<string>();
|
||||||
for (const connection of this.connections.values()) {
|
for (const ws of this.ctx.getWebSockets()) {
|
||||||
if (!connection.handshakeComplete || !connection.deviceIdentifier) continue;
|
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||||
out.add(connection.deviceIdentifier);
|
if (!attachment?.handshakeComplete || !attachment.deviceIdentifier) continue;
|
||||||
|
out.add(attachment.deviceIdentifier);
|
||||||
}
|
}
|
||||||
return Array.from(out);
|
return Array.from(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
private broadcastMessage(
|
private broadcastMessage(
|
||||||
updateType: number,
|
updateType: number,
|
||||||
revisionDate: string,
|
payload: Record<string, unknown>,
|
||||||
contextId: string | null,
|
contextId: string | null,
|
||||||
targetDeviceIdentifier: string | null
|
targetDeviceIdentifier: string | null
|
||||||
): void {
|
): 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 (sockets.length === 0) return;
|
||||||
if (!connection.handshakeComplete) continue;
|
|
||||||
if (targetDeviceIdentifier && connection.deviceIdentifier !== targetDeviceIdentifier) continue;
|
for (const ws of sockets) {
|
||||||
|
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||||
|
if (!attachment?.handshakeComplete) continue;
|
||||||
try {
|
try {
|
||||||
if (connection.protocol === 'json') {
|
if (attachment.protocol === 'json') {
|
||||||
socket.send(buildSignalRJsonInvocation(this.userId, updateType, revisionDate, contextId));
|
ws.send(buildSignalRJsonInvocation(updateType, payload, contextId));
|
||||||
} else {
|
} else {
|
||||||
socket.send(buildSignalRMessagePackInvocation(this.userId, updateType, revisionDate, contextId));
|
ws.send(buildSignalRMessagePackInvocation(updateType, payload, contextId));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
this.connections.delete(socket);
|
|
||||||
try {
|
try {
|
||||||
socket.close(1011, 'Notification send failed');
|
ws.close(1011, 'Notification send failed');
|
||||||
} catch {
|
} catch {
|
||||||
// ignore close races
|
// ignore close races
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.stopPingLoopIfIdle();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private broadcastDeviceStatus(): void {
|
private broadcastDeviceStatus(userId: string): void {
|
||||||
this.broadcastMessage(SIGNALR_UPDATE_TYPE_DEVICE_STATUS, new Date().toISOString(), null, null);
|
this.broadcastMessage(
|
||||||
|
SIGNALR_UPDATE_TYPE_DEVICE_STATUS,
|
||||||
|
{
|
||||||
|
UserId: userId,
|
||||||
|
Date: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function notifyUserVaultSync(
|
export function notifyUserVaultSync(
|
||||||
env: Env,
|
env: Env,
|
||||||
userId: string,
|
userId: string,
|
||||||
revisionDate: string,
|
revisionDate: string,
|
||||||
contextId?: string | null
|
contextId?: string | null
|
||||||
): Promise<void> {
|
): void {
|
||||||
return notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_VAULT, revisionDate, contextId ?? null, null);
|
waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_VAULT, revisionDate, contextId ?? null, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function notifyUserLogout(
|
export function notifyUserLogout(
|
||||||
env: Env,
|
env: Env,
|
||||||
userId: string,
|
userId: string,
|
||||||
targetDeviceIdentifier?: string | null
|
targetDeviceIdentifier?: string | null
|
||||||
): Promise<void> {
|
): void {
|
||||||
return notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_LOG_OUT, new Date().toISOString(), null, targetDeviceIdentifier ?? null);
|
waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_LOG_OUT, new Date().toISOString(), null, targetDeviceIdentifier ?? null));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOnlineUserDevices(env: Env, userId: string): Promise<string[]> {
|
export async function getOnlineUserDevices(env: Env, userId: string): Promise<string[]> {
|
||||||
@@ -445,9 +414,79 @@ async function notifyUserUpdate(
|
|||||||
contextId: contextId || null,
|
contextId: contextId || null,
|
||||||
updateType,
|
updateType,
|
||||||
targetDeviceIdentifier: targetDeviceIdentifier || null,
|
targetDeviceIdentifier: targetDeviceIdentifier || null,
|
||||||
|
payload: {
|
||||||
|
UserId: userId,
|
||||||
|
Date: revisionDate,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to broadcast realtime notification:', 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types';
|
|||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { AuthService } from '../services/auth';
|
import { AuthService } from '../services/auth';
|
||||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||||
|
import { auditRequestMetadata, writeAuditEvent, safeWriteAuditEvent } from '../services/audit-events';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
@@ -9,6 +10,11 @@ import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
|||||||
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
||||||
import { buildAccountKeys } from '../utils/user-decryption';
|
import { buildAccountKeys } from '../utils/user-decryption';
|
||||||
|
|
||||||
|
// CONTRACT:
|
||||||
|
// users.master_password_hash is server-side login verification only. It does
|
||||||
|
// not decrypt vault data. Password changes must keep encrypted user key material,
|
||||||
|
// securityStamp, refresh-token invalidation, and client compatibility together.
|
||||||
|
// Password hints are non-secret reminders; never treat them as recovery secrets.
|
||||||
function looksLikeEncString(value: string): boolean {
|
function looksLikeEncString(value: string): boolean {
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
const firstDot = value.indexOf('.');
|
const firstDot = value.indexOf('.');
|
||||||
@@ -87,6 +93,7 @@ async function verifyUserSecret(
|
|||||||
|
|
||||||
function toProfile(user: User, env: Env): ProfileResponse {
|
function toProfile(user: User, env: Env): ProfileResponse {
|
||||||
void env;
|
void env;
|
||||||
|
const accountKeys = buildAccountKeys(user);
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
@@ -100,7 +107,7 @@ function toProfile(user: User, env: Env): ProfileResponse {
|
|||||||
twoFactorEnabled: !!user.totpSecret,
|
twoFactorEnabled: !!user.totpSecret,
|
||||||
key: user.key,
|
key: user.key,
|
||||||
privateKey: user.privateKey,
|
privateKey: user.privateKey,
|
||||||
accountKeys: buildAccountKeys(user),
|
accountKeys,
|
||||||
securityStamp: user.securityStamp || user.id,
|
securityStamp: user.securityStamp || user.id,
|
||||||
organizations: [],
|
organizations: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
@@ -208,6 +215,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
|||||||
verifyDevices: true,
|
verifyDevices: true,
|
||||||
totpSecret: null,
|
totpSecret: null,
|
||||||
totpRecoveryCode: null,
|
totpRecoveryCode: null,
|
||||||
|
apiKey: null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
@@ -220,14 +228,14 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
|||||||
return errorResponse('Registration is temporarily unavailable, retry once', 409);
|
return errorResponse('Registration is temporarily unavailable, retry once', 409);
|
||||||
}
|
}
|
||||||
await storage.setRegistered();
|
await storage.setRegistered();
|
||||||
await storage.createAuditLog({
|
await writeAuditEvent(storage, {
|
||||||
id: generateUUID(),
|
|
||||||
actorUserId: user.id,
|
actorUserId: user.id,
|
||||||
action: 'user.register.first_admin',
|
action: 'user.register.first_admin',
|
||||||
targetType: 'user',
|
targetType: 'user',
|
||||||
targetId: user.id,
|
targetId: user.id,
|
||||||
metadata: JSON.stringify({ email: user.email }),
|
category: 'security',
|
||||||
createdAt: now,
|
level: 'security',
|
||||||
|
metadata: { email: user.email, ...auditRequestMetadata(request) },
|
||||||
});
|
});
|
||||||
return jsonResponse({ success: true, role: user.role }, 200);
|
return jsonResponse({ success: true, role: user.role }, 200);
|
||||||
}
|
}
|
||||||
@@ -252,14 +260,14 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
|||||||
return errorResponse('Invite code is invalid or expired', 403);
|
return errorResponse('Invite code is invalid or expired', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
await storage.createAuditLog({
|
await writeAuditEvent(storage, {
|
||||||
id: generateUUID(),
|
|
||||||
actorUserId: user.id,
|
actorUserId: user.id,
|
||||||
action: 'user.register.invite',
|
action: 'user.register.invite',
|
||||||
targetType: 'user',
|
targetType: 'user',
|
||||||
targetId: user.id,
|
targetId: user.id,
|
||||||
metadata: JSON.stringify({ email: user.email, inviteCode }),
|
category: 'security',
|
||||||
createdAt: now,
|
level: 'info',
|
||||||
|
metadata: { email: user.email, inviteCode, ...auditRequestMetadata(request) },
|
||||||
});
|
});
|
||||||
|
|
||||||
return jsonResponse({ success: true, role: user.role }, 200);
|
return jsonResponse({ success: true, role: user.role }, 200);
|
||||||
@@ -371,6 +379,18 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
|
|||||||
user.masterPasswordHint = masterPasswordHint;
|
user.masterPasswordHint = masterPasswordHint;
|
||||||
user.updatedAt = new Date().toISOString();
|
user.updatedAt = new Date().toISOString();
|
||||||
await storage.saveUser(user);
|
await storage.saveUser(user);
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: user.id,
|
||||||
|
action: 'account.profile.update',
|
||||||
|
category: 'security',
|
||||||
|
level: 'info',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
metadata: {
|
||||||
|
updatedMasterPasswordHint: true,
|
||||||
|
...auditRequestMetadata(request),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return jsonResponse(toProfile(user, env));
|
return jsonResponse(toProfile(user, env));
|
||||||
}
|
}
|
||||||
@@ -405,6 +425,18 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId:
|
|||||||
user.verifyDevices = body.verifyDevices;
|
user.verifyDevices = body.verifyDevices;
|
||||||
user.updatedAt = new Date().toISOString();
|
user.updatedAt = new Date().toISOString();
|
||||||
await storage.saveUser(user);
|
await storage.saveUser(user);
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: user.id,
|
||||||
|
action: 'account.verify_devices.update',
|
||||||
|
category: 'security',
|
||||||
|
level: 'security',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
metadata: {
|
||||||
|
verifyDevices: user.verifyDevices,
|
||||||
|
...auditRequestMetadata(request),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
@@ -454,6 +486,20 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
|
|||||||
user.updatedAt = new Date().toISOString();
|
user.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
await storage.saveUser(user);
|
await storage.saveUser(user);
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: user.id,
|
||||||
|
action: 'account.keys.update',
|
||||||
|
category: 'security',
|
||||||
|
level: 'security',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
metadata: {
|
||||||
|
updatedKey: !!body.key,
|
||||||
|
updatedPrivateKey: !!body.encryptedPrivateKey,
|
||||||
|
updatedPublicKey: !!body.publicKey,
|
||||||
|
...auditRequestMetadata(request),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return handleGetProfile(request, env, userId);
|
return handleGetProfile(request, env, userId);
|
||||||
}
|
}
|
||||||
@@ -519,14 +565,15 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
|
|||||||
user.updatedAt = new Date().toISOString();
|
user.updatedAt = new Date().toISOString();
|
||||||
await storage.saveUser(user);
|
await storage.saveUser(user);
|
||||||
await storage.deleteRefreshTokensByUserId(user.id);
|
await storage.deleteRefreshTokensByUserId(user.id);
|
||||||
await storage.createAuditLog({
|
AuthService.invalidateUserCache(user.id);
|
||||||
id: generateUUID(),
|
await writeAuditEvent(storage, {
|
||||||
actorUserId: user.id,
|
actorUserId: user.id,
|
||||||
action: 'user.password.change',
|
action: 'user.password.change',
|
||||||
targetType: 'user',
|
targetType: 'user',
|
||||||
targetId: user.id,
|
targetId: user.id,
|
||||||
metadata: JSON.stringify({ email: user.email }),
|
category: 'security',
|
||||||
createdAt: user.updatedAt,
|
level: 'security',
|
||||||
|
metadata: { email: user.email, ...auditRequestMetadata(request) },
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
@@ -580,6 +627,16 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
|
|||||||
user.updatedAt = new Date().toISOString();
|
user.updatedAt = new Date().toISOString();
|
||||||
await storage.saveUser(user);
|
await storage.saveUser(user);
|
||||||
await storage.deleteRefreshTokensByUserId(user.id);
|
await storage.deleteRefreshTokensByUserId(user.id);
|
||||||
|
AuthService.invalidateUserCache(user.id);
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: user.id,
|
||||||
|
action: 'account.totp.enable',
|
||||||
|
category: 'security',
|
||||||
|
level: 'security',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
metadata: auditRequestMetadata(request),
|
||||||
|
});
|
||||||
return jsonResponse({ enabled: true, recoveryCode: user.totpRecoveryCode, object: 'twoFactor' });
|
return jsonResponse({ enabled: true, recoveryCode: user.totpRecoveryCode, object: 'twoFactor' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,6 +651,16 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
|
|||||||
user.updatedAt = new Date().toISOString();
|
user.updatedAt = new Date().toISOString();
|
||||||
await storage.saveUser(user);
|
await storage.saveUser(user);
|
||||||
await storage.deleteRefreshTokensByUserId(user.id);
|
await storage.deleteRefreshTokensByUserId(user.id);
|
||||||
|
AuthService.invalidateUserCache(user.id);
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: user.id,
|
||||||
|
action: 'account.totp.disable',
|
||||||
|
category: 'security',
|
||||||
|
level: 'security',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
metadata: auditRequestMetadata(request),
|
||||||
|
});
|
||||||
return jsonResponse({ enabled: false, object: 'twoFactor' });
|
return jsonResponse({ enabled: false, object: 'twoFactor' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,7 +768,17 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
|
|||||||
user.updatedAt = new Date().toISOString();
|
user.updatedAt = new Date().toISOString();
|
||||||
await storage.saveUser(user);
|
await storage.saveUser(user);
|
||||||
await storage.deleteRefreshTokensByUserId(user.id);
|
await storage.deleteRefreshTokensByUserId(user.id);
|
||||||
|
AuthService.invalidateUserCache(user.id);
|
||||||
await rateLimit.clearLoginAttempts(recoverLimitKey);
|
await rateLimit.clearLoginAttempts(recoverLimitKey);
|
||||||
|
await safeWriteAuditEvent(env, {
|
||||||
|
actorUserId: user.id,
|
||||||
|
action: 'account.totp.recover',
|
||||||
|
category: 'security',
|
||||||
|
level: 'security',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
metadata: auditRequestMetadata(request),
|
||||||
|
});
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -750,3 +827,84 @@ export async function handleVerifyPassword(request: Request, env: Env, userId: s
|
|||||||
|
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /api/accounts/api-key
|
||||||
|
export async function handleGetApiKey(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
return apiKey(request, env, userId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/accounts/rotate-api-key
|
||||||
|
export async function handleRotateApiKey(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
return apiKey(request, env, userId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiKey(request: Request, env: Env, userId: string, rotate: boolean): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const auth = new AuthService(env);
|
||||||
|
const user = await storage.getUserById(userId);
|
||||||
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
|
let body: Record<string, string | undefined>;
|
||||||
|
try {
|
||||||
|
const contentType = request.headers.get('content-type') || '';
|
||||||
|
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
body = Object.fromEntries(formData.entries()) as Record<string, string>;
|
||||||
|
} else {
|
||||||
|
body = await request.json();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentHash = String(body.masterPasswordHash || body.master_password_hash || body.password || '').trim();
|
||||||
|
if (!currentHash) return errorResponse('masterPasswordHash is required', 400);
|
||||||
|
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
|
||||||
|
if (!valid) return errorResponse('Invalid password', 400);
|
||||||
|
|
||||||
|
if (rotate || user.apiKey === null) {
|
||||||
|
// Upstream apikeys are 30-character random alphanumeric strings
|
||||||
|
user.apiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength);
|
||||||
|
if (rotate) {
|
||||||
|
user.securityStamp = generateUUID();
|
||||||
|
await storage.deleteRefreshTokensByUserId(user.id);
|
||||||
|
}
|
||||||
|
user.updatedAt = new Date().toISOString();
|
||||||
|
await storage.saveUser(user);
|
||||||
|
AuthService.invalidateUserCache(user.id);
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: user.id,
|
||||||
|
action: rotate ? 'account.api_key.rotate' : 'account.api_key.create',
|
||||||
|
category: 'security',
|
||||||
|
level: rotate ? 'security' : 'info',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
metadata: auditRequestMetadata(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
apiKey: user.apiKey,
|
||||||
|
revisionDate: user.updatedAt,
|
||||||
|
object: 'apiKey',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a random alphanumeric string of the given length using crypto.getRandomValues.
|
||||||
|
function randomStringAlphanum(length: number): string {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
let result = '';
|
||||||
|
const maxUnbiased = Math.floor(256 / chars.length) * chars.length;
|
||||||
|
const bytes = new Uint8Array(Math.max(16, length));
|
||||||
|
|
||||||
|
while (result.length < length) {
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
for (const value of bytes) {
|
||||||
|
if (value >= maxUnbiased) continue;
|
||||||
|
result += chars[value % chars.length];
|
||||||
|
if (result.length >= length) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Env, User, Invite } from '../types';
|
import { Env, User, Invite } from '../types';
|
||||||
|
import { AuthService } from '../services/auth';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
|
||||||
import { deleteBlobObject, getAttachmentObjectKey, getSendFileObjectKey } from '../services/blob-store';
|
import { deleteBlobObject, getAttachmentObjectKey, getSendFileObjectKey } from '../services/blob-store';
|
||||||
|
import { auditRequestMetadata, getAuditLogSettings, normalizeAuditLogSettings, saveAuditLogSettings, writeAuditEvent } from '../services/audit-events';
|
||||||
|
|
||||||
function isAdmin(user: User): boolean {
|
function isAdmin(user: User): boolean {
|
||||||
return user.role === 'admin' && user.status === 'active';
|
return user.role === 'admin' && user.status === 'active';
|
||||||
@@ -24,16 +25,20 @@ async function writeAuditLog(
|
|||||||
action: string,
|
action: string,
|
||||||
targetType: string | null,
|
targetType: string | null,
|
||||||
targetId: string | null,
|
targetId: string | null,
|
||||||
metadata: Record<string, unknown> | null
|
metadata: Record<string, unknown> | null,
|
||||||
|
request?: Request
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await storage.createAuditLog({
|
await writeAuditEvent(storage, {
|
||||||
id: generateUUID(),
|
|
||||||
actorUserId,
|
actorUserId,
|
||||||
action,
|
action,
|
||||||
targetType,
|
targetType,
|
||||||
targetId,
|
targetId,
|
||||||
metadata: metadata ? JSON.stringify(metadata) : null,
|
category: action.startsWith('admin.user.') ? 'security' : 'system',
|
||||||
createdAt: new Date().toISOString(),
|
level: action.startsWith('admin.user.') ? 'security' : 'info',
|
||||||
|
metadata: {
|
||||||
|
...(metadata || {}),
|
||||||
|
...(request ? auditRequestMetadata(request) : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +86,106 @@ export async function handleAdminListUsers(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /api/admin/logs
|
||||||
|
export async function handleAdminListAuditLogs(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
actorUser: User
|
||||||
|
): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) {
|
||||||
|
return errorResponse('Forbidden', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const limit = Math.max(1, Math.min(200, Number(url.searchParams.get('limit') || 50)));
|
||||||
|
const offset = Math.max(0, Number(url.searchParams.get('offset') || 0));
|
||||||
|
const category = String(url.searchParams.get('category') || '').trim() || null;
|
||||||
|
const level = String(url.searchParams.get('level') || '').trim() || null;
|
||||||
|
const q = String(url.searchParams.get('q') || '').trim().toLowerCase() || null;
|
||||||
|
const from = String(url.searchParams.get('from') || '').trim() || null;
|
||||||
|
const to = String(url.searchParams.get('to') || '').trim() || null;
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const result = await storage.listAuditLogs({ limit, offset, category, level, q, from, to });
|
||||||
|
return jsonResponse({
|
||||||
|
data: result.logs.map(log => ({
|
||||||
|
id: log.id,
|
||||||
|
actorUserId: log.actorUserId,
|
||||||
|
actorEmail: log.actorEmail,
|
||||||
|
action: log.action,
|
||||||
|
category: log.category,
|
||||||
|
level: log.level,
|
||||||
|
targetType: log.targetType,
|
||||||
|
targetId: log.targetId,
|
||||||
|
targetUserEmail: log.targetUserEmail,
|
||||||
|
metadata: log.metadata,
|
||||||
|
createdAt: log.createdAt,
|
||||||
|
object: 'auditLog',
|
||||||
|
})),
|
||||||
|
total: result.total,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
hasMore: result.hasMore,
|
||||||
|
object: 'list',
|
||||||
|
continuationToken: result.hasMore ? String(offset + result.logs.length) : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/admin/logs/settings
|
||||||
|
export async function handleAdminGetAuditLogSettings(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
actorUser: User
|
||||||
|
): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
if (!isAdmin(actorUser)) {
|
||||||
|
return errorResponse('Forbidden', 403);
|
||||||
|
}
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
return jsonResponse({
|
||||||
|
object: 'auditLogSettings',
|
||||||
|
...await getAuditLogSettings(storage),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/admin/logs/settings
|
||||||
|
export async function handleAdminUpdateAuditLogSettings(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
actorUser: User
|
||||||
|
): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) {
|
||||||
|
return errorResponse('Forbidden', 403);
|
||||||
|
}
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const settings = await saveAuditLogSettings(storage, normalizeAuditLogSettings(body));
|
||||||
|
await writeAuditLog(storage, actorUser.id, 'admin.audit.settings.update', 'auditLog', null, { ...settings }, request);
|
||||||
|
return jsonResponse({
|
||||||
|
object: 'auditLogSettings',
|
||||||
|
...settings,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/admin/logs
|
||||||
|
export async function handleAdminClearAuditLogs(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
actorUser: User
|
||||||
|
): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) {
|
||||||
|
return errorResponse('Forbidden', 403);
|
||||||
|
}
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const deleted = await storage.clearAuditLogs();
|
||||||
|
return jsonResponse({ object: 'auditLogClear', deleted });
|
||||||
|
}
|
||||||
|
|
||||||
// POST /api/admin/invites
|
// POST /api/admin/invites
|
||||||
export async function handleAdminCreateInvite(
|
export async function handleAdminCreateInvite(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -115,9 +220,9 @@ export async function handleAdminCreateInvite(
|
|||||||
};
|
};
|
||||||
|
|
||||||
await storage.createInvite(invite);
|
await storage.createInvite(invite);
|
||||||
await writeAuditLog(storage, actorUser.id, 'admin.invite.create', 'invite', invite.code, {
|
await writeAuditLog(storage, actorUser.id, 'admin.invite.create', 'invite', null, {
|
||||||
expiresInHours,
|
expiresInHours,
|
||||||
});
|
}, request);
|
||||||
|
|
||||||
return jsonResponse(toInviteResponse(request, invite), 201);
|
return jsonResponse(toInviteResponse(request, invite), 201);
|
||||||
}
|
}
|
||||||
@@ -160,7 +265,7 @@ export async function handleAdminRevokeInvite(
|
|||||||
return errorResponse('Invite not found or already inactive', 404);
|
return errorResponse('Invite not found or already inactive', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeAuditLog(storage, actorUser.id, 'admin.invite.revoke', 'invite', code, null);
|
await writeAuditLog(storage, actorUser.id, 'admin.invite.revoke', 'invite', null, null, request);
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +284,7 @@ export async function handleAdminDeleteAllInvites(
|
|||||||
const deleted = await storage.deleteAllInvites();
|
const deleted = await storage.deleteAllInvites();
|
||||||
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_all', 'invite', null, {
|
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_all', 'invite', null, {
|
||||||
deleted,
|
deleted,
|
||||||
});
|
}, request);
|
||||||
|
|
||||||
return jsonResponse({ deleted }, 200);
|
return jsonResponse({ deleted }, 200);
|
||||||
}
|
}
|
||||||
@@ -222,9 +327,10 @@ export async function handleAdminSetUserStatus(
|
|||||||
if (nextStatus === 'banned') {
|
if (nextStatus === 'banned') {
|
||||||
await storage.deleteRefreshTokensByUserId(target.id);
|
await storage.deleteRefreshTokensByUserId(target.id);
|
||||||
}
|
}
|
||||||
|
AuthService.invalidateUserCache(target.id);
|
||||||
await writeAuditLog(storage, actorUser.id, 'admin.user.status', 'user', target.id, {
|
await writeAuditLog(storage, actorUser.id, 'admin.user.status', 'user', target.id, {
|
||||||
status: nextStatus,
|
status: nextStatus,
|
||||||
});
|
}, request);
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
id: target.id,
|
id: target.id,
|
||||||
@@ -280,9 +386,10 @@ export async function handleAdminDeleteUser(
|
|||||||
|
|
||||||
await storage.deleteRefreshTokensByUserId(target.id);
|
await storage.deleteRefreshTokensByUserId(target.id);
|
||||||
await storage.deleteUserById(target.id);
|
await storage.deleteUserById(target.id);
|
||||||
|
AuthService.invalidateUserCache(target.id);
|
||||||
await writeAuditLog(storage, actorUser.id, 'admin.user.delete', 'user', target.id, {
|
await writeAuditLog(storage, actorUser.id, 'admin.user.delete', 'user', target.id, {
|
||||||
email: target.email,
|
targetEmail: target.email,
|
||||||
});
|
}, request);
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
verifyAttachmentUploadToken,
|
verifyAttachmentUploadToken,
|
||||||
verifyFileDownloadToken,
|
verifyFileDownloadToken,
|
||||||
} from '../utils/jwt';
|
} from '../utils/jwt';
|
||||||
import { cipherToResponse, shouldOmitPasskeysForResponse } from './ciphers';
|
import { applyCipherEmbeddedAttachmentMetadata, cipherToResponse } from './ciphers';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
import { readActingDeviceIdentifier } from '../utils/device';
|
import { readActingDeviceIdentifier } from '../utils/device';
|
||||||
import {
|
import {
|
||||||
@@ -20,14 +20,36 @@ import {
|
|||||||
getBlobStorageMaxBytes,
|
getBlobStorageMaxBytes,
|
||||||
putBlobObject,
|
putBlobObject,
|
||||||
} from '../services/blob-store';
|
} from '../services/blob-store';
|
||||||
|
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||||
|
|
||||||
async function notifyVaultSyncForRequest(
|
function notifyVaultSyncForRequest(
|
||||||
request: Request,
|
request: Request,
|
||||||
env: Env,
|
env: Env,
|
||||||
userId: string,
|
userId: string,
|
||||||
revisionDate: string
|
revisionDate: string
|
||||||
|
): void {
|
||||||
|
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeAttachmentAudit(
|
||||||
|
storage: StorageService,
|
||||||
|
request: Request,
|
||||||
|
userId: string,
|
||||||
|
action: string,
|
||||||
|
metadata: Record<string, unknown>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action,
|
||||||
|
category: 'data',
|
||||||
|
level: action.includes('delete') ? 'security' : 'info',
|
||||||
|
targetType: 'attachment',
|
||||||
|
targetId: typeof metadata.id === 'string' ? metadata.id : null,
|
||||||
|
metadata: {
|
||||||
|
...metadata,
|
||||||
|
...auditRequestMetadata(request),
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format file size to human readable
|
// Format file size to human readable
|
||||||
@@ -38,6 +60,18 @@ function formatSize(bytes: number): string {
|
|||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
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(
|
async function processAttachmentUpload(
|
||||||
request: Request,
|
request: Request,
|
||||||
env: Env,
|
env: Env,
|
||||||
@@ -81,7 +115,7 @@ async function processAttachmentUpload(
|
|||||||
|
|
||||||
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||||
if (revisionInfo) {
|
if (revisionInfo) {
|
||||||
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 201 });
|
return new Response(null, { status: 201 });
|
||||||
@@ -141,7 +175,7 @@ export async function handleCreateAttachment(
|
|||||||
// Update cipher revision date
|
// Update cipher revision date
|
||||||
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||||
if (revisionInfo) {
|
if (revisionInfo) {
|
||||||
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get updated cipher for response
|
// Get updated cipher for response
|
||||||
@@ -158,9 +192,7 @@ export async function handleCreateAttachment(
|
|||||||
attachmentId: attachmentId,
|
attachmentId: attachmentId,
|
||||||
url: buildDirectUploadUrl(request, `/api/ciphers/${cipherId}/attachment/${attachmentId}`, uploadToken),
|
url: buildDirectUploadUrl(request, `/api/ciphers/${cipherId}/attachment/${attachmentId}`, uploadToken),
|
||||||
fileUploadType: 1,
|
fileUploadType: 1,
|
||||||
cipherResponse: cipherToResponse(updatedCipher!, attachments, {
|
cipherResponse: cipherToResponse(updatedCipher!, attachments),
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,6 +282,7 @@ export async function handleGetAttachment(
|
|||||||
if (!attachment || attachment.cipherId !== cipherId) {
|
if (!attachment || attachment.cipherId !== cipherId) {
|
||||||
return errorResponse('Attachment not found', 404);
|
return errorResponse('Attachment not found', 404);
|
||||||
}
|
}
|
||||||
|
const responseAttachment = applyCipherEmbeddedAttachmentMetadata(cipher, [attachment])[0] || attachment;
|
||||||
|
|
||||||
// Generate short-lived download token
|
// Generate short-lived download token
|
||||||
const token = await createFileDownloadToken(cipherId, attachmentId, env.JWT_SECRET);
|
const token = await createFileDownloadToken(cipherId, attachmentId, env.JWT_SECRET);
|
||||||
@@ -260,8 +293,66 @@ export async function handleGetAttachment(
|
|||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
object: 'attachment',
|
object: 'attachment',
|
||||||
id: attachment.id,
|
id: responseAttachment.id,
|
||||||
url: downloadUrl,
|
url: downloadUrl,
|
||||||
|
fileName: responseAttachment.fileName,
|
||||||
|
key: responseAttachment.key,
|
||||||
|
size: String(Number(responseAttachment.size) || 0),
|
||||||
|
sizeName: responseAttachment.sizeName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/ciphers/{cipherId}/attachment/{attachmentId}/metadata
|
||||||
|
// 修正旧附件的加密元数据,供官方客户端按当前 Bitwarden 契约解密。
|
||||||
|
export async function handleUpdateAttachmentMetadata(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
cipherId: string,
|
||||||
|
attachmentId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
|
const cipher = await storage.getCipher(cipherId);
|
||||||
|
if (!cipher || cipher.userId !== userId) {
|
||||||
|
return errorResponse('Cipher not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachment = await storage.getAttachment(attachmentId);
|
||||||
|
if (!attachment || attachment.cipherId !== cipherId) {
|
||||||
|
return errorResponse('Attachment not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { fileName?: string | null; key?: string | null };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(body, 'fileName') && !Object.prototype.hasOwnProperty.call(body, 'key')) {
|
||||||
|
return errorResponse('No metadata fields supplied', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(body, 'fileName')) {
|
||||||
|
const fileName = String(body.fileName || '').trim();
|
||||||
|
if (!fileName) return errorResponse('fileName is required', 400);
|
||||||
|
attachment.fileName = fileName;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(body, 'key')) {
|
||||||
|
const key = body.key == null ? null : String(body.key || '').trim();
|
||||||
|
attachment.key = key || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.saveAttachment(attachment);
|
||||||
|
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||||
|
if (revisionInfo) {
|
||||||
|
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
object: 'attachment',
|
||||||
|
id: attachment.id,
|
||||||
fileName: attachment.fileName,
|
fileName: attachment.fileName,
|
||||||
key: attachment.key,
|
key: attachment.key,
|
||||||
size: String(Number(attachment.size) || 0),
|
size: String(Number(attachment.size) || 0),
|
||||||
@@ -358,13 +449,15 @@ export async function handleDeleteAttachment(
|
|||||||
// Delete attachment metadata
|
// Delete attachment metadata
|
||||||
await storage.deleteAttachment(attachmentId);
|
await storage.deleteAttachment(attachmentId);
|
||||||
|
|
||||||
// Remove attachment from cipher
|
|
||||||
await storage.removeAttachmentFromCipher(cipherId, attachmentId);
|
|
||||||
|
|
||||||
// Update cipher revision date
|
// Update cipher revision date
|
||||||
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||||
if (revisionInfo) {
|
if (revisionInfo) {
|
||||||
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||||
|
await writeAttachmentAudit(storage, request, revisionInfo.userId, 'attachment.delete', {
|
||||||
|
id: attachmentId,
|
||||||
|
cipherId,
|
||||||
|
size: attachment.size,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get updated cipher for response
|
// Get updated cipher for response
|
||||||
@@ -372,9 +465,7 @@ export async function handleDeleteAttachment(
|
|||||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
cipher: cipherToResponse(updatedCipher!, attachments, {
|
cipher: cipherToResponse(updatedCipher!, attachments),
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,12 +474,24 @@ export async function deleteAllAttachmentsForCipher(
|
|||||||
env: Env,
|
env: Env,
|
||||||
cipherId: string
|
cipherId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const storage = new StorageService(env.DB);
|
await deleteAllAttachmentsForCiphers(env, [cipherId]);
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
}
|
||||||
|
|
||||||
for (const attachment of attachments) {
|
export async function deleteAllAttachmentsForCiphers(
|
||||||
|
env: Env,
|
||||||
|
cipherIds: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(cipherIds);
|
||||||
|
const attachments = Array.from(attachmentsByCipher.entries()).flatMap(([ownedCipherId, items]) =>
|
||||||
|
items.map((attachment) => ({ attachment, cipherId: ownedCipherId }))
|
||||||
|
);
|
||||||
|
if (!attachments.length) return;
|
||||||
|
|
||||||
|
await runWithConcurrency(attachments, LIMITS.performance.attachmentDeleteConcurrency, async ({ attachment, cipherId }) => {
|
||||||
const path = getAttachmentObjectKey(cipherId, attachment.id);
|
const path = getAttachmentObjectKey(cipherId, attachment.id);
|
||||||
await deleteBlobObject(env, path);
|
await deleteBlobObject(env, path);
|
||||||
await storage.deleteAttachment(attachment.id);
|
});
|
||||||
}
|
|
||||||
|
await storage.bulkDeleteAttachmentsByIds(attachments.map(({ attachment }) => attachment.id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import type { Env, User } from '../types';
|
import type { Env, User } from '../types';
|
||||||
import { errorResponse, jsonResponse } from '../utils/response';
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { type BackupArchiveBundle, buildBackupArchive } from '../services/backup-archive';
|
import {
|
||||||
|
type BackupArchiveBundle,
|
||||||
|
buildBackupArchive,
|
||||||
|
inspectBackupArchiveFileNameChecksum,
|
||||||
|
verifyBackupArchiveFileNameChecksum,
|
||||||
|
} from '../services/backup-archive';
|
||||||
import {
|
import {
|
||||||
type BackupDestinationRecord,
|
type BackupDestinationRecord,
|
||||||
type BackupSettingsInput,
|
type BackupSettingsInput,
|
||||||
@@ -9,6 +14,7 @@ import {
|
|||||||
getBackupLocalDateKey,
|
getBackupLocalDateKey,
|
||||||
getDefaultBackupSettings,
|
getDefaultBackupSettings,
|
||||||
getBackupSettingsRepairState,
|
getBackupSettingsRepairState,
|
||||||
|
hasBackupSlotBetween,
|
||||||
isBackupDueNow,
|
isBackupDueNow,
|
||||||
loadBackupSettings,
|
loadBackupSettings,
|
||||||
normalizeBackupSettingsInput,
|
normalizeBackupSettingsInput,
|
||||||
@@ -17,19 +23,26 @@ import {
|
|||||||
requireBackupDestination,
|
requireBackupDestination,
|
||||||
saveBackupSettings,
|
saveBackupSettings,
|
||||||
} from '../services/backup-config';
|
} from '../services/backup-config';
|
||||||
import { type BackupImportExecutionResult, importBackupArchiveBytes, importRemoteBackupArchiveBytes } from '../services/backup-import';
|
|
||||||
import {
|
import {
|
||||||
|
type BackupImportExecutionResult,
|
||||||
|
type BackupRestoreProgressReporter,
|
||||||
|
importBackupArchiveBytes,
|
||||||
|
importRemoteBackupArchiveBytes,
|
||||||
|
} from '../services/backup-import';
|
||||||
|
import {
|
||||||
|
type RemoteBackupTransferSession,
|
||||||
|
createRemoteBackupTransferSession,
|
||||||
deleteRemoteBackupFile,
|
deleteRemoteBackupFile,
|
||||||
downloadRemoteBackupFile,
|
downloadRemoteBackupFile,
|
||||||
ensureRemoteRestoreCandidate,
|
ensureRemoteRestoreCandidate,
|
||||||
listRemoteBackupEntries,
|
listRemoteBackupEntries,
|
||||||
pruneRemoteBackupArchives,
|
pruneRemoteBackupArchives,
|
||||||
remoteBackupFileExists,
|
|
||||||
uploadRemoteBackupFile,
|
|
||||||
uploadBackupArchive,
|
uploadBackupArchive,
|
||||||
} from '../services/backup-uploader';
|
} from '../services/backup-uploader';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
|
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||||
import { getBlobObject } from '../services/blob-store';
|
import { getBlobObject } from '../services/blob-store';
|
||||||
|
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
|
||||||
|
|
||||||
function isAdmin(user: User): boolean {
|
function isAdmin(user: User): boolean {
|
||||||
return user.role === 'admin' && user.status === 'active';
|
return user.role === 'admin' && user.status === 'active';
|
||||||
@@ -41,16 +54,20 @@ async function writeAuditLog(
|
|||||||
action: string,
|
action: string,
|
||||||
targetType: string | null,
|
targetType: string | null,
|
||||||
targetId: string | null,
|
targetId: string | null,
|
||||||
metadata: Record<string, unknown> | null
|
metadata: Record<string, unknown> | null,
|
||||||
|
request?: Request
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await storage.createAuditLog({
|
await writeAuditEvent(storage, {
|
||||||
id: generateUUID(),
|
|
||||||
actorUserId,
|
actorUserId,
|
||||||
action,
|
action,
|
||||||
targetType,
|
targetType,
|
||||||
targetId,
|
targetId,
|
||||||
metadata: metadata ? JSON.stringify(metadata) : null,
|
category: 'data',
|
||||||
createdAt: new Date().toISOString(),
|
level: action.endsWith('.failed') ? 'error' : 'info',
|
||||||
|
metadata: {
|
||||||
|
...(metadata || {}),
|
||||||
|
...(request ? auditRequestMetadata(request) : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +86,102 @@ function getBackupDestinationSummary(destination: BackupDestinationRecord | null
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BACKUP_RUNNER_LOCK_KEY = 'backup.runner.lock.v1';
|
||||||
|
const BACKUP_RUNNER_LEASE_MS = 10 * 60 * 1000;
|
||||||
|
const BACKUP_RUNNER_HEARTBEAT_MS = 30 * 1000;
|
||||||
|
|
||||||
|
// CONTRACT:
|
||||||
|
// The runner lock is a config-row lease, not a queue. It only prevents two
|
||||||
|
// backup/restore jobs from overlapping. Manual runs return conflict when the
|
||||||
|
// lease is held; scheduled runs skip quietly. Never export this row in backups.
|
||||||
|
interface BackupRunnerLease {
|
||||||
|
token: string;
|
||||||
|
touch: () => Promise<void>;
|
||||||
|
release: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acquireBackupRunnerLease(env: Env, reason: string): Promise<BackupRunnerLease | null> {
|
||||||
|
const token = generateUUID();
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const expiresAtMs = nowMs + BACKUP_RUNNER_LEASE_MS;
|
||||||
|
const value = JSON.stringify({
|
||||||
|
token,
|
||||||
|
reason,
|
||||||
|
acquiredAt: new Date(nowMs).toISOString(),
|
||||||
|
touchedAt: new Date(nowMs).toISOString(),
|
||||||
|
expiresAtMs,
|
||||||
|
});
|
||||||
|
const result = await env.DB
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO config(key, value) VALUES(?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||||
|
WHERE COALESCE(CAST(json_extract(config.value, '$.expiresAtMs') AS INTEGER), 0) <= ?`
|
||||||
|
)
|
||||||
|
.bind(BACKUP_RUNNER_LOCK_KEY, value, nowMs)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
if ((result.meta?.changes || 0) < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
touch: async () => {
|
||||||
|
const nextNowMs = Date.now();
|
||||||
|
const nextValue = JSON.stringify({
|
||||||
|
token,
|
||||||
|
reason,
|
||||||
|
acquiredAt: new Date(nowMs).toISOString(),
|
||||||
|
touchedAt: new Date(nextNowMs).toISOString(),
|
||||||
|
expiresAtMs: nextNowMs + BACKUP_RUNNER_LEASE_MS,
|
||||||
|
});
|
||||||
|
await env.DB
|
||||||
|
.prepare(
|
||||||
|
`UPDATE config
|
||||||
|
SET value = ?
|
||||||
|
WHERE key = ?
|
||||||
|
AND json_extract(value, '$.token') = ?`
|
||||||
|
)
|
||||||
|
.bind(nextValue, BACKUP_RUNNER_LOCK_KEY, token)
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
release: async () => {
|
||||||
|
await env.DB
|
||||||
|
.prepare(
|
||||||
|
`DELETE FROM config
|
||||||
|
WHERE key = ?
|
||||||
|
AND json_extract(value, '$.token') = ?`
|
||||||
|
)
|
||||||
|
.bind(BACKUP_RUNNER_LOCK_KEY, token)
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withBackupRunnerLease<T>(
|
||||||
|
env: Env,
|
||||||
|
reason: string,
|
||||||
|
task: (keepAlive: () => Promise<void>) => Promise<T>
|
||||||
|
): Promise<T | null> {
|
||||||
|
const lease = await acquireBackupRunnerLease(env, reason);
|
||||||
|
if (!lease) return null;
|
||||||
|
|
||||||
|
let lastHeartbeatAt = 0;
|
||||||
|
const keepAlive = async () => {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
if (nowMs - lastHeartbeatAt < BACKUP_RUNNER_HEARTBEAT_MS) return;
|
||||||
|
lastHeartbeatAt = nowMs;
|
||||||
|
await lease.touch();
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await keepAlive();
|
||||||
|
return await task(keepAlive);
|
||||||
|
} finally {
|
||||||
|
await lease.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function ensureBackupBlobName(value: string): string {
|
function ensureBackupBlobName(value: string): string {
|
||||||
const normalized = String(value || '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
const normalized = String(value || '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
@@ -81,13 +194,91 @@ function ensureBackupBlobName(value: string): string {
|
|||||||
return parts.join('/');
|
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(
|
async function executeConfiguredBackup(
|
||||||
env: Env,
|
env: Env,
|
||||||
storage: StorageService,
|
storage: StorageService,
|
||||||
actorUserId: string | null,
|
actorUserId: string | null,
|
||||||
trigger: 'manual' | 'scheduled',
|
trigger: 'manual' | 'scheduled',
|
||||||
destinationId?: string | null
|
destinationId?: string | null,
|
||||||
|
keepAlive?: (() => Promise<void>) | 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,
|
||||||
|
auditMetadata?: Record<string, unknown> | null
|
||||||
): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> {
|
): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> {
|
||||||
|
const maxArchiveUploadAttempts = 3;
|
||||||
|
const touchLease = async () => {
|
||||||
|
await keepAlive?.();
|
||||||
|
};
|
||||||
const currentSettings = await loadBackupSettings(storage, env, 'UTC');
|
const currentSettings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
const destination = requireBackupDestination(currentSettings, destinationId);
|
const destination = requireBackupDestination(currentSettings, destinationId);
|
||||||
|
|
||||||
@@ -96,28 +287,124 @@ async function executeConfiguredBackup(
|
|||||||
destination.runtime.lastAttemptLocalDate = getBackupLocalDateKey(now, destination.schedule.timezone);
|
destination.runtime.lastAttemptLocalDate = getBackupLocalDateKey(now, destination.schedule.timezone);
|
||||||
destination.runtime.lastErrorAt = null;
|
destination.runtime.lastErrorAt = null;
|
||||||
destination.runtime.lastErrorMessage = null;
|
destination.runtime.lastErrorMessage = null;
|
||||||
|
await touchLease();
|
||||||
await saveBackupSettings(storage, env, currentSettings);
|
await saveBackupSettings(storage, env, currentSettings);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await touchLease();
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
await touchLease();
|
||||||
const archive = await buildBackupArchive(env, now, {
|
const archive = await buildBackupArchive(env, now, {
|
||||||
includeAttachments: destination.includeAttachments,
|
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,
|
||||||
});
|
});
|
||||||
for (const attachment of archive.manifest.attachmentBlobs || []) {
|
await progress?.({
|
||||||
const remotePath = `attachments/${attachment.blobName}`;
|
operation: 'backup-remote-run',
|
||||||
if (await remoteBackupFileExists(destination, remotePath)) continue;
|
step: 'remote_run_sync_attachments',
|
||||||
const object = await getBlobObject(env, attachment.blobName);
|
fileName: archive.fileName,
|
||||||
if (!object) {
|
stageTitle: 'txt_backup_remote_run_progress_sync_attachments_title',
|
||||||
throw new Error(`Attachment blob missing for ${attachment.blobName}`);
|
stageDetail: destination.includeAttachments
|
||||||
|
? 'txt_backup_remote_run_progress_sync_attachments_detail'
|
||||||
|
: 'txt_backup_remote_run_progress_sync_attachments_skipped_detail',
|
||||||
|
});
|
||||||
|
const remoteSession = createRemoteBackupTransferSession(destination);
|
||||||
|
if (destination.includeAttachments) {
|
||||||
|
await touchLease();
|
||||||
|
const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession);
|
||||||
|
let attachmentIndexChanged = false;
|
||||||
|
for (const attachment of archive.manifest.attachmentBlobs || []) {
|
||||||
|
await touchLease();
|
||||||
|
if (remoteAttachmentIndex.get(attachment.blobName) === attachment.sizeBytes) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const remotePath = `attachments/${attachment.blobName}`;
|
||||||
|
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 remoteSession.putFile(remotePath, bytes, {
|
||||||
|
contentType: object.contentType,
|
||||||
|
});
|
||||||
|
remoteAttachmentIndex.set(attachment.blobName, attachment.sizeBytes);
|
||||||
|
attachmentIndexChanged = true;
|
||||||
|
}
|
||||||
|
if (attachmentIndexChanged) {
|
||||||
|
await touchLease();
|
||||||
|
await saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex);
|
||||||
}
|
}
|
||||||
const bytes = new Uint8Array(await new Response(object.body).arrayBuffer());
|
|
||||||
await uploadRemoteBackupFile(destination, remotePath, bytes, {
|
|
||||||
contentType: object.contentType,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const upload = await uploadBackupArchive(destination, archive.bytes, archive.fileName);
|
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
|
||||||
|
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
|
||||||
|
await touchLease();
|
||||||
|
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 touchLease();
|
||||||
|
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');
|
||||||
|
}
|
||||||
let prunedFileCount = 0;
|
let prunedFileCount = 0;
|
||||||
let pruneErrorMessage: string | null = null;
|
let pruneErrorMessage: string | null = null;
|
||||||
try {
|
try {
|
||||||
|
await touchLease();
|
||||||
|
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);
|
prunedFileCount = await pruneRemoteBackupArchives(destination, destination.schedule.retentionCount, archive.fileName);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pruneErrorMessage = error instanceof Error ? error.message : 'Old backup cleanup failed';
|
pruneErrorMessage = error instanceof Error ? error.message : 'Old backup cleanup failed';
|
||||||
@@ -129,16 +416,30 @@ async function executeConfiguredBackup(
|
|||||||
destination.runtime.lastUploadedFileName = archive.fileName;
|
destination.runtime.lastUploadedFileName = archive.fileName;
|
||||||
destination.runtime.lastUploadedSizeBytes = archive.bytes.byteLength;
|
destination.runtime.lastUploadedSizeBytes = archive.bytes.byteLength;
|
||||||
destination.runtime.lastUploadedDestination = upload.remotePath;
|
destination.runtime.lastUploadedDestination = upload.remotePath;
|
||||||
|
await touchLease();
|
||||||
await saveBackupSettings(storage, env, currentSettings);
|
await saveBackupSettings(storage, env, currentSettings);
|
||||||
|
|
||||||
|
await touchLease();
|
||||||
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, {
|
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, {
|
||||||
...getBackupDestinationSummary(destination),
|
...getBackupDestinationSummary(destination),
|
||||||
provider: upload.provider,
|
provider: upload.provider,
|
||||||
remotePath: upload.remotePath,
|
remotePath: upload.remotePath,
|
||||||
fileName: archive.fileName,
|
fileName: archive.fileName,
|
||||||
fileBytes: archive.bytes.byteLength,
|
fileBytes: archive.bytes.byteLength,
|
||||||
|
uploadVerificationAttempts: maxArchiveUploadAttempts,
|
||||||
prunedFileCount,
|
prunedFileCount,
|
||||||
pruneError: pruneErrorMessage,
|
pruneError: pruneErrorMessage,
|
||||||
|
...(auditMetadata || {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
@@ -150,11 +451,24 @@ async function executeConfiguredBackup(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
destination.runtime.lastErrorAt = new Date().toISOString();
|
destination.runtime.lastErrorAt = new Date().toISOString();
|
||||||
destination.runtime.lastErrorMessage = error instanceof Error ? error.message : 'Backup upload failed';
|
destination.runtime.lastErrorMessage = error instanceof Error ? error.message : 'Backup upload failed';
|
||||||
|
await touchLease();
|
||||||
await saveBackupSettings(storage, env, currentSettings);
|
await saveBackupSettings(storage, env, currentSettings);
|
||||||
|
|
||||||
|
await touchLease();
|
||||||
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, {
|
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, {
|
||||||
...getBackupDestinationSummary(destination),
|
...getBackupDestinationSummary(destination),
|
||||||
error: destination.runtime.lastErrorMessage,
|
error: destination.runtime.lastErrorMessage,
|
||||||
|
...(auditMetadata || {}),
|
||||||
|
});
|
||||||
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -170,13 +484,35 @@ function toImportStatusCode(message: string): number {
|
|||||||
|
|
||||||
async function runImportAndAudit(
|
async function runImportAndAudit(
|
||||||
env: Env,
|
env: Env,
|
||||||
|
request: Request,
|
||||||
actorUser: User,
|
actorUser: User,
|
||||||
archiveBytes: Uint8Array,
|
archiveBytes: Uint8Array,
|
||||||
|
fileName: string,
|
||||||
replaceExisting: boolean,
|
replaceExisting: boolean,
|
||||||
metadata: Record<string, unknown>
|
metadata: Record<string, unknown>
|
||||||
): Promise<BackupImportExecutionResult> {
|
): Promise<BackupImportExecutionResult> {
|
||||||
const storage = new StorageService(env.DB);
|
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, {
|
await writeAuditLog(storage, imported.auditActorUserId, 'admin.backup.import', 'backup', null, {
|
||||||
users: imported.result.imported.users,
|
users: imported.result.imported.users,
|
||||||
ciphers: imported.result.imported.ciphers,
|
ciphers: imported.result.imported.ciphers,
|
||||||
@@ -185,18 +521,35 @@ async function runImportAndAudit(
|
|||||||
skippedReason: imported.result.skipped.reason,
|
skippedReason: imported.result.skipped.reason,
|
||||||
replaceExisting,
|
replaceExisting,
|
||||||
...metadata,
|
...metadata,
|
||||||
});
|
}, request);
|
||||||
return imported;
|
return imported;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runScheduledBackupIfDue(env: Env): Promise<void> {
|
export async function runScheduledBackupIfDue(env: Env): Promise<void> {
|
||||||
const storage = new StorageService(env.DB);
|
await withBackupRunnerLease(env, 'scheduled', async (keepAlive) => {
|
||||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
const storage = new StorageService(env.DB);
|
||||||
const now = new Date();
|
let scanStartMs = Date.now();
|
||||||
for (const destination of settings.destinations) {
|
|
||||||
if (!isBackupDueNow(destination, now, BACKUP_SCHEDULER_WINDOW_MINUTES)) continue;
|
while (true) {
|
||||||
await executeConfiguredBackup(env, storage, null, 'scheduled', destination.id);
|
await keepAlive();
|
||||||
}
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
|
const now = new Date();
|
||||||
|
const dueDestinations = settings.destinations.filter((destination) =>
|
||||||
|
isBackupDueNow(destination, now, BACKUP_SCHEDULER_WINDOW_MINUTES)
|
||||||
|
|| hasBackupSlotBetween(destination, new Date(scanStartMs), now)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!dueDestinations.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scanStartMs = now.getTime();
|
||||||
|
for (const destination of dueDestinations) {
|
||||||
|
await keepAlive();
|
||||||
|
await executeConfiguredBackup(env, storage, null, 'scheduled', destination.id, keepAlive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleGetAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
|
export async function handleGetAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
@@ -241,7 +594,7 @@ export async function handleUpdateAdminBackupSettings(request: Request, env: Env
|
|||||||
await writeAuditLog(storage, actorUser.id, 'admin.backup.settings.update', 'backup', null, {
|
await writeAuditLog(storage, actorUser.id, 'admin.backup.settings.update', 'backup', null, {
|
||||||
destinationCount: next.destinations.length,
|
destinationCount: next.destinations.length,
|
||||||
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
|
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
|
||||||
});
|
}, request);
|
||||||
return jsonResponse(next);
|
return jsonResponse(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,14 +644,13 @@ export async function handleRepairAdminBackupSettings(request: Request, env: Env
|
|||||||
await writeAuditLog(storage, actorUser.id, 'admin.backup.settings.repair', 'backup', null, {
|
await writeAuditLog(storage, actorUser.id, 'admin.backup.settings.repair', 'backup', null, {
|
||||||
destinationCount: next.destinations.length,
|
destinationCount: next.destinations.length,
|
||||||
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
|
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
|
||||||
});
|
}, request);
|
||||||
return jsonResponse(next);
|
return jsonResponse(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleRunAdminConfiguredBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
export async function handleRunAdminConfiguredBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
|
||||||
try {
|
try {
|
||||||
let body: { destinationId?: string } | null = null;
|
let body: { destinationId?: string } | null = null;
|
||||||
try {
|
try {
|
||||||
@@ -309,17 +661,46 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env,
|
|||||||
return errorResponse('Backup run payload is invalid', 400);
|
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 settings = await loadBackupSettings(storage, env, 'UTC');
|
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 outcome = await withBackupRunnerLease(env, `manual:${actorUser.id}`, async (keepAlive) => {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const result = await executeConfiguredBackup(
|
||||||
|
env,
|
||||||
|
storage,
|
||||||
|
actorUser.id,
|
||||||
|
'manual',
|
||||||
|
body?.destinationId || null,
|
||||||
|
keepAlive,
|
||||||
|
progress,
|
||||||
|
auditRequestMetadata(request)
|
||||||
|
);
|
||||||
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
|
return { result, settings };
|
||||||
|
});
|
||||||
|
if (!outcome) {
|
||||||
|
return errorResponse('Another backup run is already in progress', 409);
|
||||||
|
}
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
object: 'backup-run',
|
object: 'backup-run',
|
||||||
result: {
|
result: {
|
||||||
fileName: result.fileName,
|
fileName: outcome.result.fileName,
|
||||||
fileSize: result.fileSize,
|
fileSize: outcome.result.fileSize,
|
||||||
provider: result.provider,
|
provider: outcome.result.provider,
|
||||||
remotePath: result.remotePath,
|
remotePath: outcome.result.remotePath,
|
||||||
},
|
},
|
||||||
settings,
|
settings: outcome.settings,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(error instanceof Error ? error.message : 'Backup run failed', 500);
|
return errorResponse(error instanceof Error ? error.message : 'Backup run failed', 500);
|
||||||
@@ -369,6 +750,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> {
|
export async function handleDeleteAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
@@ -382,7 +786,7 @@ export async function handleDeleteAdminRemoteBackup(request: Request, env: Env,
|
|||||||
await writeAuditLog(storage, actorUser.id, 'admin.backup.remote.delete', 'backup', null, {
|
await writeAuditLog(storage, actorUser.id, 'admin.backup.remote.delete', 'backup', null, {
|
||||||
...getBackupDestinationSummary(destination),
|
...getBackupDestinationSummary(destination),
|
||||||
remotePath: path,
|
remotePath: path,
|
||||||
});
|
}, request);
|
||||||
return jsonResponse({ object: 'backup-remote-delete', deleted: true, path });
|
return jsonResponse({ object: 'backup-remote-delete', deleted: true, path });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(error instanceof Error ? error.message : 'Remote backup delete failed', 409);
|
return errorResponse(error instanceof Error ? error.message : 'Remote backup delete failed', 409);
|
||||||
@@ -392,7 +796,7 @@ export async function handleDeleteAdminRemoteBackup(request: Request, env: Env,
|
|||||||
export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
let body: { destinationId?: string; path?: string; replaceExisting?: boolean };
|
let body: { destinationId?: string; path?: string; replaceExisting?: boolean; allowChecksumMismatch?: boolean };
|
||||||
try {
|
try {
|
||||||
body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>();
|
body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -404,7 +808,39 @@ export async function handleRestoreAdminRemoteBackup(request: Request, env: Env,
|
|||||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
const destination = requireBackupDestination(settings, body.destinationId || null);
|
const destination = requireBackupDestination(settings, body.destinationId || null);
|
||||||
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
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 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 imported = await (async () => {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const result = await importRemoteBackupArchiveBytes(
|
const result = await importRemoteBackupArchiveBytes(
|
||||||
@@ -413,12 +849,13 @@ export async function handleRestoreAdminRemoteBackup(request: Request, env: Env,
|
|||||||
actorUser.id,
|
actorUser.id,
|
||||||
!!body.replaceExisting,
|
!!body.replaceExisting,
|
||||||
{
|
{
|
||||||
hasAttachment: async (blobName) => remoteBackupFileExists(destination, `attachments/${blobName}`),
|
loadAttachment: async (blobName) => {
|
||||||
loadAttachment: async (blobName) => {
|
const file = await downloadRemoteBackupFile(destination, `attachments/${blobName}`).catch(() => null);
|
||||||
const file = await downloadRemoteBackupFile(destination, `attachments/${blobName}`).catch(() => null);
|
return file?.bytes || null;
|
||||||
return file?.bytes || null;
|
},
|
||||||
},
|
},
|
||||||
}
|
progress,
|
||||||
|
restoreFileName
|
||||||
);
|
);
|
||||||
await writeAuditLog(storage, result.auditActorUserId, 'admin.backup.import', 'backup', null, {
|
await writeAuditLog(storage, result.auditActorUserId, 'admin.backup.import', 'backup', null, {
|
||||||
users: result.result.imported.users,
|
users: result.result.imported.users,
|
||||||
@@ -431,7 +868,8 @@ export async function handleRestoreAdminRemoteBackup(request: Request, env: Env,
|
|||||||
remotePath: path,
|
remotePath: path,
|
||||||
bytes: remoteFile.bytes.byteLength,
|
bytes: remoteFile.bytes.byteLength,
|
||||||
trigger: 'remote',
|
trigger: 'remote',
|
||||||
});
|
checksumMismatchAccepted: !checksumOk,
|
||||||
|
}, request);
|
||||||
return result;
|
return result;
|
||||||
})();
|
})();
|
||||||
return jsonResponse(imported.result);
|
return jsonResponse(imported.result);
|
||||||
@@ -445,6 +883,7 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
|
|||||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
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;
|
let body: { includeAttachments?: boolean } | null = null;
|
||||||
try {
|
try {
|
||||||
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
|
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
|
||||||
@@ -455,11 +894,49 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
|
|||||||
}
|
}
|
||||||
let archive: BackupArchiveBundle;
|
let archive: BackupArchiveBundle;
|
||||||
try {
|
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(), {
|
archive = await buildBackupArchive(env, new Date(), {
|
||||||
includeAttachments: !!body?.includeAttachments,
|
includeAttachments: !!body?.includeAttachments,
|
||||||
|
progress,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Backup export failed';
|
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);
|
return errorResponse(message, message.includes('blob missing') ? 409 : 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,7 +946,7 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
|
|||||||
attachments: archive.manifest.tableCounts.attachments,
|
attachments: archive.manifest.tableCounts.attachments,
|
||||||
compressedBytes: archive.bytes.byteLength,
|
compressedBytes: archive.bytes.byteLength,
|
||||||
includesAttachments: archive.manifest.includes.attachments,
|
includesAttachments: archive.manifest.includes.attachments,
|
||||||
});
|
}, request);
|
||||||
|
|
||||||
return new Response(archive.bytes, {
|
return new Response(archive.bytes, {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -520,6 +997,7 @@ export async function handleAdminImportBackup(request: Request, env: Env, actorU
|
|||||||
}
|
}
|
||||||
|
|
||||||
const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1';
|
const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1';
|
||||||
|
const allowChecksumMismatch = String(formData.get('allowChecksumMismatch') || '').trim() === '1';
|
||||||
let archiveBytes: Uint8Array;
|
let archiveBytes: Uint8Array;
|
||||||
try {
|
try {
|
||||||
archiveBytes = new Uint8Array(await (file as { arrayBuffer(): Promise<ArrayBuffer> }).arrayBuffer());
|
archiveBytes = new Uint8Array(await (file as { arrayBuffer(): Promise<ArrayBuffer> }).arrayBuffer());
|
||||||
@@ -528,9 +1006,15 @@ export async function handleAdminImportBackup(request: Request, env: Env, actorU
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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',
|
trigger: 'local',
|
||||||
bytes: archiveBytes.byteLength,
|
bytes: archiveBytes.byteLength,
|
||||||
|
checksumMismatchAccepted: !checksumOk,
|
||||||
});
|
});
|
||||||
return jsonResponse(imported.result);
|
return jsonResponse(imported.result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,19 +1,42 @@
|
|||||||
import { Env, Cipher, CipherResponse, Attachment } from '../types';
|
import {
|
||||||
|
Env,
|
||||||
|
Cipher,
|
||||||
|
CipherCard,
|
||||||
|
CipherIdentity,
|
||||||
|
CipherLogin,
|
||||||
|
CipherResponse,
|
||||||
|
CipherSecureNote,
|
||||||
|
CipherSshKey,
|
||||||
|
Attachment,
|
||||||
|
PasswordHistory,
|
||||||
|
} from '../types';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { deleteAllAttachmentsForCipher } from './attachments';
|
import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from './attachments';
|
||||||
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
||||||
import { readActingDeviceIdentifier } from '../utils/device';
|
import { readActingDeviceIdentifier } from '../utils/device';
|
||||||
|
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||||
|
|
||||||
async function notifyVaultSyncForRequest(
|
// CONTRACT:
|
||||||
|
// Cipher JSON is the highest-risk Bitwarden compatibility surface. Preserve
|
||||||
|
// unknown/future client fields by default, then override only server-owned
|
||||||
|
// fields. Any change to cipher response shape must be checked against /api/sync,
|
||||||
|
// attachments, import/export, and current official clients.
|
||||||
|
function normalizeOptionalId(value: unknown): string | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
return normalized ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyVaultSyncForRequest(
|
||||||
request: Request,
|
request: Request,
|
||||||
env: Env,
|
env: Env,
|
||||||
userId: string,
|
userId: string,
|
||||||
revisionDate: string
|
revisionDate: string
|
||||||
): Promise<void> {
|
): void {
|
||||||
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAliasedProp(source: any, aliases: string[]): { present: boolean; value: any } {
|
function getAliasedProp(source: any, aliases: string[]): { present: boolean; value: any } {
|
||||||
@@ -26,6 +49,10 @@ function getAliasedProp(source: any, aliases: string[]): { present: boolean; val
|
|||||||
return { present: false, value: undefined };
|
return { present: false, value: undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readCipherProp<T = unknown>(source: any, aliases: string[]): { present: boolean; value: T | undefined } {
|
||||||
|
return getAliasedProp(source, aliases) as { present: boolean; value: T | undefined };
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeCipherTimestamp(value: unknown): string | null {
|
function normalizeCipherTimestamp(value: unknown): string | null {
|
||||||
if (value == null || value === '') return null;
|
if (value == null || value === '') return null;
|
||||||
const parsed = new Date(String(value));
|
const parsed = new Date(String(value));
|
||||||
@@ -38,15 +65,87 @@ function readCipherArchivedAt(source: any, fallback: string | null = null): stri
|
|||||||
return archived.present ? normalizeCipherTimestamp(archived.value) : fallback;
|
return archived.present ? normalizeCipherTimestamp(archived.value) : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readCipherRevisionDate(source: any): string | null {
|
||||||
|
const revision = getAliasedProp(source, ['lastKnownRevisionDate', 'LastKnownRevisionDate']);
|
||||||
|
return revision.present ? normalizeCipherTimestamp(revision.value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStaleCipherUpdate(existingUpdatedAt: string, clientRevisionDate: string | null): boolean {
|
||||||
|
if (!clientRevisionDate) return false;
|
||||||
|
const existingTs = Date.parse(existingUpdatedAt);
|
||||||
|
const clientTs = Date.parse(clientRevisionDate);
|
||||||
|
if (Number.isNaN(existingTs) || Number.isNaN(clientTs)) return false;
|
||||||
|
return existingTs - clientTs > 1000;
|
||||||
|
}
|
||||||
|
|
||||||
function syncCipherComputedAliases(cipher: Cipher): Cipher {
|
function syncCipherComputedAliases(cipher: Cipher): Cipher {
|
||||||
cipher.archivedDate = cipher.archivedAt ?? null;
|
cipher.archivedDate = cipher.archivedAt ?? null;
|
||||||
cipher.deletedDate = cipher.deletedAt ?? null;
|
cipher.deletedDate = cipher.deletedAt ?? null;
|
||||||
return cipher;
|
return cipher;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function writeCipherAudit(
|
||||||
|
storage: StorageService,
|
||||||
|
request: Request,
|
||||||
|
userId: string,
|
||||||
|
action: string,
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
): Promise<void> {
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action,
|
||||||
|
category: 'data',
|
||||||
|
level: action.includes('delete') ? 'security' : 'info',
|
||||||
|
targetType: 'cipher',
|
||||||
|
targetId: typeof metadata.id === 'string' ? metadata.id : null,
|
||||||
|
metadata: {
|
||||||
|
...metadata,
|
||||||
|
...auditRequestMetadata(request),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidEncString(value: unknown): value is string {
|
||||||
|
if (typeof value !== 'string') return false;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
const dot = trimmed.indexOf('.');
|
||||||
|
if (dot <= 0) return false;
|
||||||
|
const type = Number(trimmed.slice(0, dot));
|
||||||
|
if (!Number.isInteger(type) || type < 0) return false;
|
||||||
|
const parts = trimmed.slice(dot + 1).split('|');
|
||||||
|
if (parts.some((part) => part.length === 0)) return false;
|
||||||
|
|
||||||
|
// Bitwarden's legacy symmetric EncString variants require IV + data,
|
||||||
|
// while the authenticated AES-CBC-HMAC variant requires IV + data + MAC.
|
||||||
|
if (type === 0 || type === 1 || type === 4) return parts.length >= 2;
|
||||||
|
if (type === 2) return parts.length === 3;
|
||||||
|
|
||||||
|
// Keep newer one-part formats, such as COSE Encrypt0, future-compatible.
|
||||||
|
return parts.length >= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalEncString(value: unknown): string | null {
|
||||||
|
if (value == null || value === '') return null;
|
||||||
|
return isValidEncString(value) ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeEncryptedObject<T extends Record<string, any>>(
|
||||||
|
source: T | null | undefined,
|
||||||
|
encryptedKeys: readonly string[]
|
||||||
|
): T | null {
|
||||||
|
if (!source || typeof source !== 'object') return source ?? null;
|
||||||
|
const next: Record<string, any> = { ...source };
|
||||||
|
for (const key of encryptedKeys) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(next, key)) continue;
|
||||||
|
next[key] = optionalEncString(next[key]);
|
||||||
|
}
|
||||||
|
return next as T;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeCipherForStorage(cipher: Cipher): Cipher {
|
function normalizeCipherForStorage(cipher: Cipher): Cipher {
|
||||||
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||||
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
|
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
|
||||||
|
cipher.folderId = normalizeOptionalId(cipher.folderId);
|
||||||
const hasArchivedAt = Object.prototype.hasOwnProperty.call(cipher as object, 'archivedAt');
|
const hasArchivedAt = Object.prototype.hasOwnProperty.call(cipher as object, 'archivedAt');
|
||||||
cipher.archivedAt = hasArchivedAt
|
cipher.archivedAt = hasArchivedAt
|
||||||
? normalizeCipherTimestamp(cipher.archivedAt) ?? null
|
? normalizeCipherTimestamp(cipher.archivedAt) ?? null
|
||||||
@@ -54,80 +153,74 @@ function normalizeCipherForStorage(cipher: Cipher): Cipher {
|
|||||||
return syncCipherComputedAliases(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 {
|
export function normalizeCipherLoginForStorage(login: any): any {
|
||||||
if (!login || typeof login !== 'object') return login ?? null;
|
if (!login || typeof login !== 'object') return login ?? null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...login,
|
...login,
|
||||||
fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
|
fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeCipherLoginForCompatibility(
|
export function normalizeCipherLoginForCompatibility(login: any): any {
|
||||||
login: any,
|
|
||||||
options?: { omitFido2Credentials?: boolean }
|
|
||||||
): any {
|
|
||||||
const normalized = normalizeCipherLoginForStorage(login);
|
const normalized = normalizeCipherLoginForStorage(login);
|
||||||
if (!normalized || typeof normalized !== 'object') return normalized ?? null;
|
if (!normalized || typeof normalized !== 'object') return normalized ?? null;
|
||||||
if (!options?.omitFido2Credentials) return normalized;
|
const next = sanitizeEncryptedObject(normalized, ['username', 'password', 'totp', 'uri']);
|
||||||
|
if (!next) return null;
|
||||||
|
next.uris = Array.isArray(next.uris)
|
||||||
|
? next.uris
|
||||||
|
.map((uri: any) => sanitizeEncryptedObject(uri, ['uri', 'uriChecksum']))
|
||||||
|
.filter((uri: any) => !!uri && (uri.uri || uri.uriChecksum || uri.match != null))
|
||||||
|
: null;
|
||||||
|
next.fido2Credentials = normalizeFido2CredentialsForCompatibility(next.fido2Credentials);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
const credentials = Array.isArray(normalized.fido2Credentials) ? normalized.fido2Credentials : null;
|
function hasMissingLoginUriChecksum(cipher: Cipher): boolean {
|
||||||
if (!credentials?.length) return normalized;
|
if (!cipher.key || !cipher.login || typeof cipher.login !== 'object') return false;
|
||||||
|
const uris = (cipher.login as any).uris;
|
||||||
const hasMalformedCredential = credentials.some((credential: any) => {
|
if (!Array.isArray(uris)) return false;
|
||||||
if (!credential || typeof credential !== 'object') return true;
|
return uris.some((uri: any) => {
|
||||||
const requiredEncryptedFields = [
|
if (!uri || typeof uri !== 'object') return false;
|
||||||
credential.credentialId,
|
return isValidEncString(uri.uri) && !isValidEncString(uri.uriChecksum);
|
||||||
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
|
function normalizeFido2CredentialsForCompatibility(credentials: any): any[] | null {
|
||||||
? {
|
if (!Array.isArray(credentials) || credentials.length === 0) return null;
|
||||||
...normalized,
|
const requiredEncryptedKeys = [
|
||||||
fido2Credentials: null,
|
'credentialId',
|
||||||
|
'keyType',
|
||||||
|
'keyAlgorithm',
|
||||||
|
'keyCurve',
|
||||||
|
'keyValue',
|
||||||
|
'rpId',
|
||||||
|
'counter',
|
||||||
|
'discoverable',
|
||||||
|
];
|
||||||
|
const optionalEncryptedKeys = ['userHandle', 'userName', 'rpName', 'userDisplayName'];
|
||||||
|
const out: any[] = [];
|
||||||
|
|
||||||
|
for (const credential of credentials) {
|
||||||
|
if (!credential || typeof credential !== 'object') continue;
|
||||||
|
const next: Record<string, any> = { ...credential };
|
||||||
|
let valid = true;
|
||||||
|
for (const key of requiredEncryptedKeys) {
|
||||||
|
if (!isValidEncString(next[key])) {
|
||||||
|
valid = false;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
: normalized;
|
next[key] = String(next[key]).trim();
|
||||||
|
}
|
||||||
|
if (!valid) continue;
|
||||||
|
for (const key of optionalEncryptedKeys) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(next, key)) {
|
||||||
|
next[key] = optionalEncString(next[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.length ? out : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Android 2026.2.0 requires sshKey.keyFingerprint in sync payloads.
|
// Android 2026.2.0 requires sshKey.keyFingerprint in sync payloads.
|
||||||
@@ -145,8 +238,18 @@ export function normalizeCipherSshKeyForCompatibility(sshKey: any): any {
|
|||||||
? ''
|
? ''
|
||||||
: String(candidate);
|
: String(candidate);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isValidEncString(sshKey.privateKey) ||
|
||||||
|
!isValidEncString(sshKey.publicKey) ||
|
||||||
|
!isValidEncString(normalizedFingerprint)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...sshKey,
|
...sshKey,
|
||||||
|
privateKey: String(sshKey.privateKey).trim(),
|
||||||
|
publicKey: String(sshKey.publicKey).trim(),
|
||||||
keyFingerprint: normalizedFingerprint,
|
keyFingerprint: normalizedFingerprint,
|
||||||
fingerprint: normalizedFingerprint,
|
fingerprint: normalizedFingerprint,
|
||||||
};
|
};
|
||||||
@@ -155,16 +258,242 @@ export function normalizeCipherSshKeyForCompatibility(sshKey: any): any {
|
|||||||
// Format attachments for API response
|
// Format attachments for API response
|
||||||
export function formatAttachments(attachments: Attachment[]): any[] | null {
|
export function formatAttachments(attachments: Attachment[]): any[] | null {
|
||||||
if (attachments.length === 0) return null;
|
if (attachments.length === 0) return null;
|
||||||
return attachments.map(a => ({
|
const formatted = attachments
|
||||||
id: a.id,
|
.filter((a) => isValidEncString(a.fileName))
|
||||||
fileName: a.fileName,
|
.map(a => ({
|
||||||
// Bitwarden clients decode attachment size as string in cipher payloads.
|
id: a.id,
|
||||||
size: String(Number(a.size) || 0),
|
fileName: a.fileName.trim(),
|
||||||
sizeName: a.sizeName,
|
// Bitwarden clients decode attachment size as string in cipher payloads.
|
||||||
key: a.key,
|
size: String(Number(a.size) || 0),
|
||||||
url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`, // Android requires non-null url!
|
sizeName: a.sizeName,
|
||||||
object: 'attachment',
|
key: optionalEncString(a.key),
|
||||||
}));
|
url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`, // Android requires non-null url!
|
||||||
|
object: 'attachment',
|
||||||
|
}));
|
||||||
|
return formatted.length ? formatted : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAttachmentSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} Bytes`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IncomingAttachmentMetadata {
|
||||||
|
id: string;
|
||||||
|
fileName?: unknown;
|
||||||
|
key?: unknown;
|
||||||
|
fileSize?: unknown;
|
||||||
|
hasFileName: boolean;
|
||||||
|
hasKey: boolean;
|
||||||
|
hasFileSize: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readIncomingAttachmentMetadataMap(
|
||||||
|
value: unknown,
|
||||||
|
options: { legacyFileNameMap?: boolean } = {}
|
||||||
|
): IncomingAttachmentMetadata[] {
|
||||||
|
if (!value || typeof value !== 'object') return [];
|
||||||
|
const out: IncomingAttachmentMetadata[] = [];
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) {
|
||||||
|
if (!item || typeof item !== 'object') continue;
|
||||||
|
const row = item as Record<string, unknown>;
|
||||||
|
const id = String(row.id ?? row.Id ?? '').trim();
|
||||||
|
if (!id) continue;
|
||||||
|
const fileName = getAliasedProp(row, ['fileName', 'FileName']);
|
||||||
|
const key = getAliasedProp(row, ['key', 'Key']);
|
||||||
|
const fileSize = getAliasedProp(row, ['fileSize', 'FileSize', 'size', 'Size']);
|
||||||
|
out.push({
|
||||||
|
id,
|
||||||
|
fileName: fileName.value,
|
||||||
|
key: key.value,
|
||||||
|
fileSize: fileSize.value,
|
||||||
|
hasFileName: fileName.present,
|
||||||
|
hasKey: key.present,
|
||||||
|
hasFileSize: fileSize.present,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [rawId, rawValue] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
const id = String(rawId || '').trim();
|
||||||
|
if (!id) continue;
|
||||||
|
|
||||||
|
if (options.legacyFileNameMap && (typeof rawValue === 'string' || rawValue == null)) {
|
||||||
|
out.push({
|
||||||
|
id,
|
||||||
|
fileName: rawValue,
|
||||||
|
key: undefined,
|
||||||
|
fileSize: undefined,
|
||||||
|
hasFileName: rawValue != null,
|
||||||
|
hasKey: false,
|
||||||
|
hasFileSize: false,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rawValue || typeof rawValue !== 'object') continue;
|
||||||
|
const row = rawValue as Record<string, unknown>;
|
||||||
|
const fileName = getAliasedProp(row, ['fileName', 'FileName']);
|
||||||
|
const key = getAliasedProp(row, ['key', 'Key']);
|
||||||
|
const fileSize = getAliasedProp(row, ['fileSize', 'FileSize', 'size', 'Size']);
|
||||||
|
out.push({
|
||||||
|
id,
|
||||||
|
fileName: fileName.value,
|
||||||
|
key: key.value,
|
||||||
|
fileSize: fileSize.value,
|
||||||
|
hasFileName: fileName.present,
|
||||||
|
hasKey: key.present,
|
||||||
|
hasFileSize: fileSize.present,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readIncomingAttachmentMetadata(source: any): IncomingAttachmentMetadata[] {
|
||||||
|
const merged = new Map<string, IncomingAttachmentMetadata>();
|
||||||
|
const legacy = getAliasedProp(source, ['attachments', 'Attachments']);
|
||||||
|
const current = getAliasedProp(source, ['attachments2', 'Attachments2']);
|
||||||
|
|
||||||
|
if (legacy.present) {
|
||||||
|
for (const item of readIncomingAttachmentMetadataMap(legacy.value, { legacyFileNameMap: true })) {
|
||||||
|
merged.set(item.id, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.present) {
|
||||||
|
for (const item of readIncomingAttachmentMetadataMap(current.value)) {
|
||||||
|
const previous = merged.get(item.id);
|
||||||
|
merged.set(item.id, {
|
||||||
|
id: item.id,
|
||||||
|
fileName: item.hasFileName ? item.fileName : previous?.fileName,
|
||||||
|
key: item.hasKey ? item.key : previous?.key,
|
||||||
|
fileSize: item.hasFileSize ? item.fileSize : previous?.fileSize,
|
||||||
|
hasFileName: item.hasFileName || previous?.hasFileName || false,
|
||||||
|
hasKey: item.hasKey || previous?.hasKey || false,
|
||||||
|
hasFileSize: item.hasFileSize || previous?.hasFileSize || false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...merged.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasIncomingAttachmentMetadata(source: any): boolean {
|
||||||
|
return readIncomingAttachmentMetadata(source).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncIncomingAttachmentMetadata(
|
||||||
|
storage: StorageService,
|
||||||
|
cipherId: string,
|
||||||
|
cipherData: any
|
||||||
|
): Promise<void> {
|
||||||
|
const incoming = readIncomingAttachmentMetadata(cipherData);
|
||||||
|
if (!incoming.length) return;
|
||||||
|
|
||||||
|
const currentById = new Map((await storage.getAttachmentsByCipher(cipherId)).map((attachment) => [attachment.id, attachment]));
|
||||||
|
for (const item of incoming) {
|
||||||
|
const attachment = currentById.get(item.id);
|
||||||
|
if (!attachment) continue;
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
if (item.hasFileName) {
|
||||||
|
const fileName = String(item.fileName || '').trim();
|
||||||
|
if (isValidEncString(fileName) && fileName !== attachment.fileName) {
|
||||||
|
attachment.fileName = fileName;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.hasKey) {
|
||||||
|
const key = optionalEncString(item.key);
|
||||||
|
if (key !== attachment.key) {
|
||||||
|
attachment.key = key;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.hasFileSize) {
|
||||||
|
const size = Number(item.fileSize);
|
||||||
|
if (Number.isFinite(size) && size >= 0 && size !== Number(attachment.size || 0)) {
|
||||||
|
attachment.size = size;
|
||||||
|
attachment.sizeName = formatAttachmentSize(size);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
await storage.saveAttachment(attachment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyCipherEmbeddedAttachmentMetadata(cipherData: any, attachments: Attachment[]): Attachment[] {
|
||||||
|
const incoming = readIncomingAttachmentMetadata(cipherData);
|
||||||
|
if (!incoming.length || !attachments.length) return attachments;
|
||||||
|
|
||||||
|
const incomingById = new Map(incoming.map((item) => [item.id, item]));
|
||||||
|
return attachments.map((attachment) => {
|
||||||
|
const item = incomingById.get(attachment.id);
|
||||||
|
if (!item) return attachment;
|
||||||
|
|
||||||
|
const next: Attachment = { ...attachment };
|
||||||
|
if (item.hasFileName) {
|
||||||
|
const fileName = String(item.fileName || '').trim();
|
||||||
|
if (isValidEncString(fileName)) {
|
||||||
|
next.fileName = fileName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (item.hasKey) {
|
||||||
|
next.key = optionalEncString(item.key);
|
||||||
|
}
|
||||||
|
if (item.hasFileSize) {
|
||||||
|
const size = Number(item.fileSize);
|
||||||
|
if (Number.isFinite(size) && size >= 0) {
|
||||||
|
next.size = size;
|
||||||
|
next.sizeName = formatAttachmentSize(size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCipherFieldsForCompatibility(fields: any): any[] | null {
|
||||||
|
if (!Array.isArray(fields) || fields.length === 0) return null;
|
||||||
|
const out = fields
|
||||||
|
.map((field: any) => {
|
||||||
|
if (!field || typeof field !== 'object') return null;
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
name: optionalEncString(field.name),
|
||||||
|
value: optionalEncString(field.value),
|
||||||
|
type: Number(field.type) || 0,
|
||||||
|
linkedId: field.linkedId ?? null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
return out.length ? out : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePasswordHistoryForCompatibility(passwordHistory: any): PasswordHistory[] | null {
|
||||||
|
if (!Array.isArray(passwordHistory) || passwordHistory.length === 0) return null;
|
||||||
|
const out = passwordHistory
|
||||||
|
.filter((entry: any) => entry && typeof entry === 'object' && isValidEncString(entry.password))
|
||||||
|
.map((entry: any) => ({
|
||||||
|
...entry,
|
||||||
|
password: String(entry.password).trim(),
|
||||||
|
lastUsedDate: normalizeCipherTimestamp(entry.lastUsedDate) ?? new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
return out.length ? out : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCipherResponseSyncCompatible(cipher: CipherResponse): boolean {
|
||||||
|
return isValidEncString(cipher.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert internal cipher to API response format.
|
// Convert internal cipher to API response format.
|
||||||
@@ -173,21 +502,43 @@ export function formatAttachments(attachments: Attachment[]): any[] | null {
|
|||||||
// survive a round-trip without code changes.
|
// survive a round-trip without code changes.
|
||||||
export function cipherToResponse(
|
export function cipherToResponse(
|
||||||
cipher: Cipher,
|
cipher: Cipher,
|
||||||
attachments: Attachment[] = [],
|
attachments: Attachment[] = []
|
||||||
options?: { omitFido2Credentials?: boolean }
|
|
||||||
): CipherResponse {
|
): CipherResponse {
|
||||||
// Strip internal-only fields that must not appear in the API response
|
// Strip internal-only fields that must not appear in the API response
|
||||||
const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher;
|
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 normalizedCard = sanitizeEncryptedObject((passthrough as any).card ?? null, ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code']);
|
||||||
|
const normalizedIdentity = sanitizeEncryptedObject((passthrough as any).identity ?? null, [
|
||||||
|
'title',
|
||||||
|
'firstName',
|
||||||
|
'middleName',
|
||||||
|
'lastName',
|
||||||
|
'address1',
|
||||||
|
'address2',
|
||||||
|
'address3',
|
||||||
|
'city',
|
||||||
|
'state',
|
||||||
|
'postalCode',
|
||||||
|
'country',
|
||||||
|
'company',
|
||||||
|
'email',
|
||||||
|
'phone',
|
||||||
|
'ssn',
|
||||||
|
'username',
|
||||||
|
'passportNumber',
|
||||||
|
'licenseNumber',
|
||||||
|
]);
|
||||||
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
|
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
|
||||||
|
const responseAttachments = applyCipherEmbeddedAttachmentMetadata(cipher, attachments);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Pass through ALL stored cipher fields (known + unknown)
|
// Pass through ALL stored cipher fields (known + unknown)
|
||||||
...passthrough,
|
...passthrough,
|
||||||
// Server-computed / enforced fields (always override)
|
// Server-computed / enforced fields (always override)
|
||||||
|
folderId: normalizeOptionalId(cipher.folderId),
|
||||||
type: Number(cipher.type) || 1,
|
type: Number(cipher.type) || 1,
|
||||||
organizationId: null,
|
organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null),
|
||||||
organizationUseTotp: false,
|
organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false),
|
||||||
creationDate: createdAt,
|
creationDate: createdAt,
|
||||||
revisionDate: updatedAt,
|
revisionDate: updatedAt,
|
||||||
deletedDate: deletedAt,
|
deletedDate: deletedAt,
|
||||||
@@ -198,12 +549,19 @@ export function cipherToResponse(
|
|||||||
delete: true,
|
delete: true,
|
||||||
restore: true,
|
restore: true,
|
||||||
},
|
},
|
||||||
object: 'cipher',
|
object: 'cipherDetails',
|
||||||
collectionIds: [],
|
collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [],
|
||||||
attachments: formatAttachments(attachments),
|
attachments: formatAttachments(responseAttachments),
|
||||||
|
name: isValidEncString(cipher.name) ? cipher.name.trim() : cipher.name,
|
||||||
|
notes: optionalEncString(cipher.notes),
|
||||||
login: normalizedLogin,
|
login: normalizedLogin,
|
||||||
|
card: normalizedCard,
|
||||||
|
identity: normalizedIdentity,
|
||||||
|
fields: normalizeCipherFieldsForCompatibility((passthrough as any).fields),
|
||||||
|
passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory),
|
||||||
sshKey: normalizedSshKey,
|
sshKey: normalizedSshKey,
|
||||||
encryptedFor: null,
|
key: optionalEncString(cipher.key),
|
||||||
|
encryptedFor: (passthrough as any).encryptedFor ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +571,6 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
|||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const includeDeleted = url.searchParams.get('deleted') === 'true';
|
const includeDeleted = url.searchParams.get('deleted') === 'true';
|
||||||
const pagination = parsePagination(url);
|
const pagination = parsePagination(url);
|
||||||
const omitFido2Credentials = shouldOmitPasskeysForResponse(request);
|
|
||||||
|
|
||||||
let filteredCiphers: Cipher[];
|
let filteredCiphers: Cipher[];
|
||||||
let continuationToken: string | null = null;
|
let continuationToken: string | null = null;
|
||||||
@@ -234,13 +591,15 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
|||||||
: ciphers.filter(c => !c.deletedAt);
|
: 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
|
// Build responses only for the current page to keep pagination cheap.
|
||||||
const cipherResponses = [];
|
const cipherResponses: CipherResponse[] = [];
|
||||||
for (const cipher of filteredCiphers) {
|
for (const cipher of filteredCiphers) {
|
||||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||||
cipherResponses.push(cipherToResponse(cipher, attachments, { omitFido2Credentials }));
|
cipherResponses.push(cipherToResponse(cipher, attachments));
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
@@ -261,9 +620,7 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
|
|||||||
|
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, attachments, {
|
cipherToResponse(cipher, attachments)
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,6 +644,14 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
|||||||
// Handle nested cipher object (from some clients)
|
// Handle nested cipher object (from some clients)
|
||||||
// Android client sends PascalCase "Cipher" for organization ciphers
|
// Android client sends PascalCase "Cipher" for organization ciphers
|
||||||
const cipherData = body.Cipher || body.cipher || body;
|
const cipherData = body.Cipher || body.cipher || body;
|
||||||
|
const createFolderId = readCipherProp<string | null>(cipherData, ['folderId', 'FolderId']);
|
||||||
|
const createKey = readCipherProp<string | null>(cipherData, ['key', 'Key']);
|
||||||
|
const createLogin = readCipherProp<CipherLogin | null>(cipherData, ['login', 'Login']);
|
||||||
|
const createCard = readCipherProp<CipherCard | null>(cipherData, ['card', 'Card']);
|
||||||
|
const createIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
|
||||||
|
const createSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
|
||||||
|
const createSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
|
||||||
|
const createPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
// Opaque passthrough: spread ALL client fields to preserve unknown/future ones,
|
// Opaque passthrough: spread ALL client fields to preserve unknown/future ones,
|
||||||
@@ -304,6 +669,14 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
|||||||
archivedAt: readCipherArchivedAt(cipherData, null),
|
archivedAt: readCipherArchivedAt(cipherData, null),
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
};
|
};
|
||||||
|
cipher.folderId = createFolderId.present ? normalizeOptionalId(createFolderId.value) : normalizeOptionalId(cipher.folderId);
|
||||||
|
cipher.key = createKey.present ? (createKey.value ?? null) : (cipher.key ?? null);
|
||||||
|
cipher.login = createLogin.present ? (createLogin.value ?? null) : (cipher.login ?? null);
|
||||||
|
cipher.card = createCard.present ? (createCard.value ?? null) : (cipher.card ?? null);
|
||||||
|
cipher.identity = createIdentity.present ? (createIdentity.value ?? null) : (cipher.identity ?? null);
|
||||||
|
cipher.secureNote = createSecureNote.present ? (createSecureNote.value ?? null) : (cipher.secureNote ?? null);
|
||||||
|
cipher.sshKey = createSshKey.present ? (createSshKey.value ?? null) : (cipher.sshKey ?? null);
|
||||||
|
cipher.passwordHistory = createPasswordHistory.present ? (createPasswordHistory.value ?? null) : (cipher.passwordHistory ?? null);
|
||||||
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
|
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
|
||||||
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
|
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
|
||||||
normalizeCipherForStorage(cipher);
|
normalizeCipherForStorage(cipher);
|
||||||
@@ -314,14 +687,16 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
|||||||
if (!folderOk) return errorResponse('Folder not found', 404);
|
if (!folderOk) return errorResponse('Folder not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasMissingLoginUriChecksum(cipher)) {
|
||||||
|
return errorResponse('Login URI checksum is required for item-key encrypted ciphers. Refresh NodeWarden and save the item again.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, [], {
|
cipherToResponse(cipher, []),
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
}),
|
|
||||||
200
|
200
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -345,6 +720,22 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
// Handle nested cipher object
|
// Handle nested cipher object
|
||||||
// Android client sends PascalCase "Cipher" for organization ciphers
|
// Android client sends PascalCase "Cipher" for organization ciphers
|
||||||
const cipherData = body.Cipher || body.cipher || body;
|
const cipherData = body.Cipher || body.cipher || body;
|
||||||
|
const incomingFolderId = readCipherProp<string | null>(cipherData, ['folderId', 'FolderId']);
|
||||||
|
const incomingKey = readCipherProp<string | null>(cipherData, ['key', 'Key']);
|
||||||
|
const incomingLogin = readCipherProp<CipherLogin | null>(cipherData, ['login', 'Login']);
|
||||||
|
const incomingCard = readCipherProp<CipherCard | null>(cipherData, ['card', 'Card']);
|
||||||
|
const incomingIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
|
||||||
|
const incomingSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
|
||||||
|
const incomingSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
|
||||||
|
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
|
||||||
|
const incomingRevisionDate = readCipherRevisionDate(cipherData);
|
||||||
|
const hasAttachmentMigrationMetadata = hasIncomingAttachmentMetadata(cipherData);
|
||||||
|
|
||||||
|
if (!hasAttachmentMigrationMetadata && isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) {
|
||||||
|
return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextType = Number(cipherData.type) || existingCipher.type;
|
||||||
|
|
||||||
// Opaque passthrough: merge existing stored data with ALL incoming client fields.
|
// Opaque passthrough: merge existing stored data with ALL incoming client fields.
|
||||||
// Unknown/future fields from the client are preserved; server-controlled fields are protected.
|
// Unknown/future fields from the client are preserved; server-controlled fields are protected.
|
||||||
@@ -354,7 +745,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
// Server-controlled fields (never from client)
|
// Server-controlled fields (never from client)
|
||||||
id: existingCipher.id,
|
id: existingCipher.id,
|
||||||
userId: existingCipher.userId,
|
userId: existingCipher.userId,
|
||||||
type: Number(cipherData.type) || existingCipher.type,
|
type: nextType,
|
||||||
favorite: cipherData.favorite ?? existingCipher.favorite,
|
favorite: cipherData.favorite ?? existingCipher.favorite,
|
||||||
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
|
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
|
||||||
createdAt: existingCipher.createdAt,
|
createdAt: existingCipher.createdAt,
|
||||||
@@ -362,6 +753,20 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
|
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
|
||||||
deletedAt: existingCipher.deletedAt,
|
deletedAt: existingCipher.deletedAt,
|
||||||
};
|
};
|
||||||
|
if (incomingFolderId.present) {
|
||||||
|
cipher.folderId = normalizeOptionalId(incomingFolderId.value);
|
||||||
|
}
|
||||||
|
if (incomingKey.present) {
|
||||||
|
cipher.key = incomingKey.value ?? null;
|
||||||
|
}
|
||||||
|
cipher.login = nextType === 1 ? (incomingLogin.present ? (incomingLogin.value ?? null) : (existingCipher.login ?? null)) : null;
|
||||||
|
cipher.secureNote = nextType === 2 ? (incomingSecureNote.present ? (incomingSecureNote.value ?? null) : (existingCipher.secureNote ?? null)) : null;
|
||||||
|
cipher.card = nextType === 3 ? (incomingCard.present ? (incomingCard.value ?? null) : (existingCipher.card ?? null)) : null;
|
||||||
|
cipher.identity = nextType === 4 ? (incomingIdentity.present ? (incomingIdentity.value ?? null) : (existingCipher.identity ?? null)) : null;
|
||||||
|
cipher.sshKey = nextType === 5 ? (incomingSshKey.present ? (incomingSshKey.value ?? null) : (existingCipher.sshKey ?? null)) : null;
|
||||||
|
if (incomingPasswordHistory.present) {
|
||||||
|
cipher.passwordHistory = incomingPasswordHistory.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
// Custom fields deletion compatibility:
|
// Custom fields deletion compatibility:
|
||||||
// - Accept both camelCase "fields" and PascalCase "Fields".
|
// - Accept both camelCase "fields" and PascalCase "Fields".
|
||||||
@@ -381,14 +786,18 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
if (!folderOk) return errorResponse('Folder not found', 404);
|
if (!folderOk) return errorResponse('Folder not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasMissingLoginUriChecksum(cipher)) {
|
||||||
|
return errorResponse('Login URI checksum is required for item-key encrypted ciphers. Refresh NodeWarden and save the item again.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncIncomingAttachmentMetadata(storage, cipher.id, cipherData);
|
||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, [], {
|
cipherToResponse(cipher, attachments)
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,12 +816,15 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
|
|||||||
syncCipherComputedAliases(cipher);
|
syncCipherComputedAliases(cipher);
|
||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
await writeCipherAudit(storage, request, userId, 'cipher.delete.soft', {
|
||||||
|
id: cipher.id,
|
||||||
|
type: cipher.type,
|
||||||
|
folderId: cipher.folderId ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, [], {
|
cipherToResponse(cipher, [])
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,7 +845,13 @@ export async function handleDeleteCipherCompat(request: Request, env: Env, userI
|
|||||||
await deleteAllAttachmentsForCipher(env, id);
|
await deleteAllAttachmentsForCipher(env, id);
|
||||||
await storage.deleteCipher(id, userId);
|
await storage.deleteCipher(id, userId);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', {
|
||||||
|
id,
|
||||||
|
type: cipher.type,
|
||||||
|
folderId: cipher.folderId ?? null,
|
||||||
|
compat: true,
|
||||||
|
});
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,7 +872,12 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
|
|||||||
|
|
||||||
await storage.deleteCipher(id, userId);
|
await storage.deleteCipher(id, userId);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', {
|
||||||
|
id,
|
||||||
|
type: cipher.type,
|
||||||
|
folderId: cipher.folderId ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
@@ -473,12 +896,10 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
|
|||||||
syncCipherComputedAliases(cipher);
|
syncCipherComputedAliases(cipher);
|
||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, [], {
|
cipherToResponse(cipher, [])
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,11 +920,12 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (body.folderId !== undefined) {
|
if (body.folderId !== undefined) {
|
||||||
if (body.folderId) {
|
const folderId = normalizeOptionalId(body.folderId);
|
||||||
const folderOk = await verifyFolderOwnership(storage, body.folderId, userId);
|
if (folderId) {
|
||||||
|
const folderOk = await verifyFolderOwnership(storage, folderId, userId);
|
||||||
if (!folderOk) return errorResponse('Folder not found', 404);
|
if (!folderOk) return errorResponse('Folder not found', 404);
|
||||||
}
|
}
|
||||||
cipher.folderId = body.folderId;
|
cipher.folderId = folderId;
|
||||||
}
|
}
|
||||||
if (body.favorite !== undefined) {
|
if (body.favorite !== undefined) {
|
||||||
cipher.favorite = body.favorite;
|
cipher.favorite = body.favorite;
|
||||||
@@ -513,12 +935,10 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
|
|||||||
|
|
||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, [], {
|
cipherToResponse(cipher, [])
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,14 +957,15 @@ export async function handleBulkMoveCiphers(request: Request, env: Env, userId:
|
|||||||
return errorResponse('ids array is required', 400);
|
return errorResponse('ids array is required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.folderId) {
|
const folderId = normalizeOptionalId(body.folderId);
|
||||||
const folderOk = await verifyFolderOwnership(storage, body.folderId, userId);
|
if (folderId) {
|
||||||
|
const folderOk = await verifyFolderOwnership(storage, folderId, userId);
|
||||||
if (!folderOk) return errorResponse('Folder not found', 404);
|
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) {
|
if (revisionDate) {
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
@@ -558,13 +979,10 @@ async function buildCipherListResponse(
|
|||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const ciphers = await storage.getCiphersByIds(ids, userId);
|
const ciphers = await storage.getCiphersByIds(ids, userId);
|
||||||
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map((cipher) => cipher.id));
|
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map((cipher) => cipher.id));
|
||||||
const omitFido2Credentials = shouldOmitPasskeysForResponse(request);
|
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
data: ciphers.map((cipher) =>
|
data: ciphers.map((cipher) =>
|
||||||
cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], {
|
cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [])
|
||||||
omitFido2Credentials,
|
|
||||||
})
|
|
||||||
),
|
),
|
||||||
object: 'list',
|
object: 'list',
|
||||||
continuationToken: null,
|
continuationToken: null,
|
||||||
@@ -593,13 +1011,11 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st
|
|||||||
normalizeCipherForStorage(cipher);
|
normalizeCipherForStorage(cipher);
|
||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, attachments, {
|
cipherToResponse(cipher, attachments)
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,13 +1033,11 @@ export async function handleUnarchiveCipher(request: Request, env: Env, userId:
|
|||||||
normalizeCipherForStorage(cipher);
|
normalizeCipherForStorage(cipher);
|
||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, attachments, {
|
cipherToResponse(cipher, attachments)
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -645,7 +1059,7 @@ export async function handleBulkArchiveCiphers(request: Request, env: Env, userI
|
|||||||
|
|
||||||
const revisionDate = await storage.bulkArchiveCiphers(ids, userId);
|
const revisionDate = await storage.bulkArchiveCiphers(ids, userId);
|
||||||
if (revisionDate) {
|
if (revisionDate) {
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildCipherListResponse(request, storage, userId, ids);
|
return buildCipherListResponse(request, storage, userId, ids);
|
||||||
@@ -669,7 +1083,7 @@ export async function handleBulkUnarchiveCiphers(request: Request, env: Env, use
|
|||||||
|
|
||||||
const revisionDate = await storage.bulkUnarchiveCiphers(ids, userId);
|
const revisionDate = await storage.bulkUnarchiveCiphers(ids, userId);
|
||||||
if (revisionDate) {
|
if (revisionDate) {
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildCipherListResponse(request, storage, userId, ids);
|
return buildCipherListResponse(request, storage, userId, ids);
|
||||||
@@ -692,7 +1106,10 @@ export async function handleBulkDeleteCiphers(request: Request, env: Env, userId
|
|||||||
|
|
||||||
const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId);
|
const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId);
|
||||||
if (revisionDate) {
|
if (revisionDate) {
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
await writeCipherAudit(storage, request, userId, 'cipher.delete.soft.bulk', {
|
||||||
|
count: body.ids.length,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
@@ -715,7 +1132,7 @@ export async function handleBulkRestoreCiphers(request: Request, env: Env, userI
|
|||||||
|
|
||||||
const revisionDate = await storage.bulkRestoreCiphers(body.ids, userId);
|
const revisionDate = await storage.bulkRestoreCiphers(body.ids, userId);
|
||||||
if (revisionDate) {
|
if (revisionDate) {
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
@@ -741,13 +1158,21 @@ export async function handleBulkPermanentDeleteCiphers(request: Request, env: En
|
|||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const id of ids) {
|
const ownedCiphers = await storage.getCiphersByIds(ids, userId);
|
||||||
await deleteAllAttachmentsForCipher(env, id);
|
const ownedIds = ownedCiphers.map((cipher) => cipher.id);
|
||||||
|
if (!ownedIds.length) {
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const revisionDate = await storage.bulkDeleteCiphers(ids, userId);
|
await deleteAllAttachmentsForCiphers(env, ownedIds);
|
||||||
|
|
||||||
|
const revisionDate = await storage.bulkDeleteCiphers(ownedIds, userId);
|
||||||
if (revisionDate) {
|
if (revisionDate) {
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent.bulk', {
|
||||||
|
count: ownedIds.length,
|
||||||
|
requestedCount: ids.length,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import type { Device, DevicePendingAuthRequest, DeviceResponse, ProtectedDeviceResponse as ProtectedDeviceWireResponse } from '../types';
|
import type { Device, DevicePendingAuthRequest, DeviceResponse, ProtectedDeviceResponse as ProtectedDeviceWireResponse } from '../types';
|
||||||
import { Env } from '../types';
|
import { Env } from '../types';
|
||||||
import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub';
|
import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub';
|
||||||
|
import { AuthService } from '../services/auth';
|
||||||
|
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { errorResponse, jsonResponse } from '../utils/response';
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
import { readKnownDeviceProbe } from '../utils/device';
|
import { readKnownDeviceProbe } from '../utils/device';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
|
||||||
|
const PERMANENT_TRUST_EXPIRES_AT_MS = Date.UTC(2099, 11, 31, 23, 59, 59);
|
||||||
|
|
||||||
function normalizeIdentifier(value: string | null | undefined): string {
|
function normalizeIdentifier(value: string | null | undefined): string {
|
||||||
return String(value || '').trim();
|
return String(value || '').trim();
|
||||||
}
|
}
|
||||||
@@ -23,13 +27,18 @@ function isTrustedDevice(device: Pick<Device, 'encryptedUserKey' | 'encryptedPub
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildDeviceResponse(device: Device): DeviceResponse {
|
function buildDeviceResponse(device: Device): DeviceResponse {
|
||||||
|
const displayName = String(device.deviceNote || '').trim() || device.name;
|
||||||
const response = {
|
const response = {
|
||||||
Id: device.deviceIdentifier,
|
Id: device.deviceIdentifier,
|
||||||
id: device.deviceIdentifier,
|
id: device.deviceIdentifier,
|
||||||
UserId: device.userId,
|
UserId: device.userId,
|
||||||
userId: device.userId,
|
userId: device.userId,
|
||||||
Name: device.name,
|
Name: displayName,
|
||||||
name: device.name,
|
name: displayName,
|
||||||
|
SystemName: device.name,
|
||||||
|
systemName: device.name,
|
||||||
|
DeviceNote: device.deviceNote,
|
||||||
|
deviceNote: device.deviceNote,
|
||||||
Identifier: device.deviceIdentifier,
|
Identifier: device.deviceIdentifier,
|
||||||
identifier: device.deviceIdentifier,
|
identifier: device.deviceIdentifier,
|
||||||
Type: device.type,
|
Type: device.type,
|
||||||
@@ -38,6 +47,10 @@ function buildDeviceResponse(device: Device): DeviceResponse {
|
|||||||
creationDate: device.createdAt,
|
creationDate: device.createdAt,
|
||||||
RevisionDate: device.updatedAt,
|
RevisionDate: device.updatedAt,
|
||||||
revisionDate: device.updatedAt,
|
revisionDate: device.updatedAt,
|
||||||
|
LastSeenAt: device.lastSeenAt,
|
||||||
|
lastSeenAt: device.lastSeenAt,
|
||||||
|
HasStoredDevice: true,
|
||||||
|
hasStoredDevice: true,
|
||||||
IsTrusted: isTrustedDevice(device),
|
IsTrusted: isTrustedDevice(device),
|
||||||
isTrusted: isTrustedDevice(device),
|
isTrusted: isTrustedDevice(device),
|
||||||
EncryptedUserKey: device.encryptedUserKey,
|
EncryptedUserKey: device.encryptedUserKey,
|
||||||
@@ -55,8 +68,12 @@ function buildProtectedDeviceResponse(device: Device): ProtectedDeviceWireRespon
|
|||||||
const response = {
|
const response = {
|
||||||
Id: device.deviceIdentifier,
|
Id: device.deviceIdentifier,
|
||||||
id: device.deviceIdentifier,
|
id: device.deviceIdentifier,
|
||||||
Name: device.name,
|
Name: String(device.deviceNote || '').trim() || device.name,
|
||||||
name: device.name,
|
name: String(device.deviceNote || '').trim() || device.name,
|
||||||
|
SystemName: device.name,
|
||||||
|
systemName: device.name,
|
||||||
|
DeviceNote: device.deviceNote,
|
||||||
|
deviceNote: device.deviceNote,
|
||||||
Identifier: device.deviceIdentifier,
|
Identifier: device.deviceIdentifier,
|
||||||
identifier: device.deviceIdentifier,
|
identifier: device.deviceIdentifier,
|
||||||
Type: device.type,
|
Type: device.type,
|
||||||
@@ -101,6 +118,10 @@ async function readJsonBody(request: Request): Promise<any> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseDeviceName(value: unknown): string {
|
||||||
|
return String(value || '').trim().slice(0, 128);
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/devices/knowndevice
|
// GET /api/devices/knowndevice
|
||||||
// Compatible with Bitwarden/Vaultwarden behavior:
|
// Compatible with Bitwarden/Vaultwarden behavior:
|
||||||
// - X-Request-Email: base64url(email) without padding
|
// - X-Request-Email: base64url(email) without padding
|
||||||
@@ -203,12 +224,15 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
|
|||||||
encryptedPublicKey: null,
|
encryptedPublicKey: null,
|
||||||
encryptedPrivateKey: null,
|
encryptedPrivateKey: null,
|
||||||
devicePendingAuthRequest: null,
|
devicePendingAuthRequest: null,
|
||||||
|
deviceNote: null,
|
||||||
|
lastSeenAt: null,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
updatedAt: '',
|
updatedAt: '',
|
||||||
};
|
};
|
||||||
data.push({
|
data.push({
|
||||||
...buildDeviceResponse(placeholderDevice),
|
...buildDeviceResponse(placeholderDevice),
|
||||||
isTrusted: true,
|
isTrusted: true,
|
||||||
|
hasStoredDevice: false,
|
||||||
online: onlineSet.has(row.deviceIdentifier),
|
online: onlineSet.has(row.deviceIdentifier),
|
||||||
trusted: true,
|
trusted: true,
|
||||||
trustedTokenCount: row.tokenCount,
|
trustedTokenCount: row.tokenCount,
|
||||||
@@ -245,9 +269,50 @@ export async function handleRevokeTrustedDevice(
|
|||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const removed = await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
const removed = await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action: 'device.trust.revoke',
|
||||||
|
category: 'device',
|
||||||
|
level: 'security',
|
||||||
|
targetType: 'device',
|
||||||
|
targetId: normalized,
|
||||||
|
metadata: { removed, ...auditRequestMetadata(request) },
|
||||||
|
});
|
||||||
return jsonResponse({ success: true, removed });
|
return jsonResponse({ success: true, removed });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /api/devices/authorized/:deviceIdentifier/permanent
|
||||||
|
// Upgrades an existing active 2FA remember-token record to permanent trust.
|
||||||
|
export async function handleTrustDevicePermanently(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const normalized = String(deviceIdentifier || '').trim();
|
||||||
|
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const updated = await storage.updateTrustedTwoFactorTokensExpiryByDevice(userId, normalized, PERMANENT_TRUST_EXPIRES_AT_MS);
|
||||||
|
if (!updated) return errorResponse('Device is not currently trusted', 409);
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action: 'device.trust.permanent',
|
||||||
|
category: 'device',
|
||||||
|
level: 'security',
|
||||||
|
targetType: 'device',
|
||||||
|
targetId: normalized,
|
||||||
|
metadata: { updated, ...auditRequestMetadata(request) },
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
success: true,
|
||||||
|
updated,
|
||||||
|
trustedUntil: new Date(PERMANENT_TRUST_EXPIRES_AT_MS).toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// DELETE /api/devices/:deviceIdentifier
|
// DELETE /api/devices/:deviceIdentifier
|
||||||
export async function handleDeleteDevice(
|
export async function handleDeleteDevice(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -264,11 +329,53 @@ export async function handleDeleteDevice(
|
|||||||
await storage.deleteRefreshTokensByDevice(userId, normalized);
|
await storage.deleteRefreshTokensByDevice(userId, normalized);
|
||||||
const deleted = await storage.deleteDevice(userId, normalized);
|
const deleted = await storage.deleteDevice(userId, normalized);
|
||||||
if (deleted) {
|
if (deleted) {
|
||||||
await notifyUserLogout(env, userId, normalized);
|
AuthService.invalidateDeviceCache(userId, normalized);
|
||||||
|
notifyUserLogout(env, userId, normalized);
|
||||||
}
|
}
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action: 'device.delete',
|
||||||
|
category: 'device',
|
||||||
|
level: 'security',
|
||||||
|
targetType: 'device',
|
||||||
|
targetId: normalized,
|
||||||
|
metadata: { deleted, ...auditRequestMetadata(request) },
|
||||||
|
});
|
||||||
return jsonResponse({ success: deleted });
|
return jsonResponse({ success: deleted });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PUT /api/devices/:deviceIdentifier/name
|
||||||
|
export async function handleUpdateDeviceName(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const normalized = String(deviceIdentifier || '').trim();
|
||||||
|
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
const name = parseDeviceName(body?.name);
|
||||||
|
if (!name) return errorResponse('Device name is required', 400);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const updated = await storage.updateDeviceName(userId, normalized, name);
|
||||||
|
if (!updated) return errorResponse('Device not found', 404);
|
||||||
|
|
||||||
|
const device = await storage.getDevice(userId, normalized);
|
||||||
|
if (!device) return errorResponse('Device not found', 404);
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action: 'device.name.update',
|
||||||
|
category: 'device',
|
||||||
|
level: 'info',
|
||||||
|
targetType: 'device',
|
||||||
|
targetId: normalized,
|
||||||
|
metadata: { name, ...auditRequestMetadata(request) },
|
||||||
|
});
|
||||||
|
return jsonResponse(buildDeviceResponse(device));
|
||||||
|
}
|
||||||
|
|
||||||
// DELETE /api/devices
|
// DELETE /api/devices
|
||||||
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
void request;
|
void request;
|
||||||
@@ -284,7 +391,17 @@ export async function handleDeleteAllDevices(request: Request, env: Env, userId:
|
|||||||
user.securityStamp = generateUUID();
|
user.securityStamp = generateUUID();
|
||||||
user.updatedAt = new Date().toISOString();
|
user.updatedAt = new Date().toISOString();
|
||||||
await storage.saveUser(user);
|
await storage.saveUser(user);
|
||||||
await notifyUserLogout(env, userId, null);
|
AuthService.invalidateUserCache(userId);
|
||||||
|
notifyUserLogout(env, userId, null);
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action: 'device.delete_all',
|
||||||
|
category: 'device',
|
||||||
|
level: 'security',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: userId,
|
||||||
|
metadata: { removedTrusted, removedSessions, removedDevices, ...auditRequestMetadata(request) },
|
||||||
|
});
|
||||||
return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices });
|
return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,6 +493,15 @@ export async function handleUntrustDevices(
|
|||||||
if (!deviceIdentifier) continue;
|
if (!deviceIdentifier) continue;
|
||||||
await storage.deleteTrustedTwoFactorTokensByDevice(userId, deviceIdentifier);
|
await storage.deleteTrustedTwoFactorTokensByDevice(userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action: 'device.trust.revoke_batch',
|
||||||
|
category: 'device',
|
||||||
|
level: 'security',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: userId,
|
||||||
|
metadata: { requested: devices.length, removed, ...auditRequestMetadata(request) },
|
||||||
|
});
|
||||||
return jsonResponse({ success: true, removed });
|
return jsonResponse({ success: true, removed });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,8 +541,18 @@ export async function handleDeactivateDevice(
|
|||||||
await storage.deleteRefreshTokensByDevice(userId, normalized);
|
await storage.deleteRefreshTokensByDevice(userId, normalized);
|
||||||
const deleted = await storage.deleteDevice(userId, normalized);
|
const deleted = await storage.deleteDevice(userId, normalized);
|
||||||
if (deleted) {
|
if (deleted) {
|
||||||
await notifyUserLogout(env, userId, normalized);
|
AuthService.invalidateDeviceCache(userId, normalized);
|
||||||
|
notifyUserLogout(env, userId, normalized);
|
||||||
}
|
}
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action: 'device.deactivate',
|
||||||
|
category: 'device',
|
||||||
|
level: 'security',
|
||||||
|
targetType: 'device',
|
||||||
|
targetId: normalized,
|
||||||
|
metadata: { deleted, ...auditRequestMetadata(request) },
|
||||||
|
});
|
||||||
return jsonResponse({ success: deleted });
|
return jsonResponse({ success: deleted });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { Env } from '../types';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import {
|
||||||
|
buildDomainsResponse,
|
||||||
|
customRulesToActiveEquivalentDomains,
|
||||||
|
normalizeCustomEquivalentDomains,
|
||||||
|
normalizeEquivalentDomains,
|
||||||
|
normalizeExcludedGlobalTypes,
|
||||||
|
} from '../services/domain-rules';
|
||||||
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
|
|
||||||
|
// CONTRACT:
|
||||||
|
// This route accepts both camelCase and PascalCase Bitwarden-compatible payloads.
|
||||||
|
// It stores custom rules, then derives equivalentDomains from the non-excluded
|
||||||
|
// custom rules. Keep this behavior aligned with backup import/export and
|
||||||
|
// src/services/storage-domain-rules-repo.ts.
|
||||||
|
function firstPresent(payload: Record<string, unknown>, keys: string[]): unknown {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(payload, key)) return payload[key];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPayload(request: Request): Promise<Record<string, unknown>> {
|
||||||
|
try {
|
||||||
|
const parsed = await request.json();
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||||
|
? parsed as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetDomains(env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const settings = await storage.getUserDomainSettings(userId);
|
||||||
|
return jsonResponse(buildDomainsResponse(
|
||||||
|
settings.equivalentDomains,
|
||||||
|
settings.customEquivalentDomains,
|
||||||
|
settings.excludedGlobalEquivalentDomains
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleUpdateDomains(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const payload = await readPayload(request);
|
||||||
|
const current = await storage.getUserDomainSettings(userId);
|
||||||
|
const equivalentDomainsRaw = firstPresent(payload, [
|
||||||
|
'equivalentDomains',
|
||||||
|
'EquivalentDomains',
|
||||||
|
]);
|
||||||
|
const customEquivalentDomainsRaw = firstPresent(payload, [
|
||||||
|
'customEquivalentDomains',
|
||||||
|
'CustomEquivalentDomains',
|
||||||
|
]);
|
||||||
|
const excludedGlobalEquivalentDomainsRaw = firstPresent(payload, [
|
||||||
|
'excludedGlobalEquivalentDomains',
|
||||||
|
'ExcludedGlobalEquivalentDomains',
|
||||||
|
// Some older compatible clients send the excluded type list under this key.
|
||||||
|
'globalEquivalentDomains',
|
||||||
|
'GlobalEquivalentDomains',
|
||||||
|
]);
|
||||||
|
const customEquivalentDomains = customEquivalentDomainsRaw === undefined
|
||||||
|
? (equivalentDomainsRaw === undefined
|
||||||
|
? current.customEquivalentDomains
|
||||||
|
: normalizeCustomEquivalentDomains(normalizeEquivalentDomains(equivalentDomainsRaw)))
|
||||||
|
: normalizeCustomEquivalentDomains(customEquivalentDomainsRaw);
|
||||||
|
const equivalentDomains = customRulesToActiveEquivalentDomains(customEquivalentDomains);
|
||||||
|
const excludedGlobalEquivalentDomains = excludedGlobalEquivalentDomainsRaw === undefined
|
||||||
|
? current.excludedGlobalEquivalentDomains
|
||||||
|
: normalizeExcludedGlobalTypes(excludedGlobalEquivalentDomainsRaw);
|
||||||
|
|
||||||
|
await storage.saveUserDomainSettings(userId, equivalentDomains, customEquivalentDomains, excludedGlobalEquivalentDomains);
|
||||||
|
|
||||||
|
const settings = await storage.getUserDomainSettings(userId);
|
||||||
|
if (!settings) {
|
||||||
|
return errorResponse('Domain settings unavailable', 500);
|
||||||
|
}
|
||||||
|
return jsonResponse(buildDomainsResponse(
|
||||||
|
settings.equivalentDomains,
|
||||||
|
settings.customEquivalentDomains,
|
||||||
|
settings.excludedGlobalEquivalentDomains
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -5,14 +5,36 @@ import { jsonResponse, errorResponse } from '../utils/response';
|
|||||||
import { readActingDeviceIdentifier } from '../utils/device';
|
import { readActingDeviceIdentifier } from '../utils/device';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
||||||
|
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||||
|
|
||||||
async function notifyVaultSyncForRequest(
|
function notifyVaultSyncForRequest(
|
||||||
request: Request,
|
request: Request,
|
||||||
env: Env,
|
env: Env,
|
||||||
userId: string,
|
userId: string,
|
||||||
revisionDate: string
|
revisionDate: string
|
||||||
|
): void {
|
||||||
|
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeFolderAudit(
|
||||||
|
storage: StorageService,
|
||||||
|
request: Request,
|
||||||
|
userId: string,
|
||||||
|
action: string,
|
||||||
|
metadata: Record<string, unknown>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action,
|
||||||
|
category: 'data',
|
||||||
|
level: action.includes('delete') ? 'security' : 'info',
|
||||||
|
targetType: 'folder',
|
||||||
|
targetId: typeof metadata.id === 'string' ? metadata.id : null,
|
||||||
|
metadata: {
|
||||||
|
...metadata,
|
||||||
|
...auditRequestMetadata(request),
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert internal folder to API response format
|
// Convert internal folder to API response format
|
||||||
@@ -21,6 +43,7 @@ function folderToResponse(folder: Folder): FolderResponse {
|
|||||||
id: folder.id,
|
id: folder.id,
|
||||||
name: folder.name,
|
name: folder.name,
|
||||||
revisionDate: folder.updatedAt,
|
revisionDate: folder.updatedAt,
|
||||||
|
creationDate: folder.createdAt,
|
||||||
object: 'folder',
|
object: 'folder',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -87,7 +110,7 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
|
|||||||
|
|
||||||
await storage.saveFolder(folder);
|
await storage.saveFolder(folder);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(folderToResponse(folder), 200);
|
return jsonResponse(folderToResponse(folder), 200);
|
||||||
}
|
}
|
||||||
@@ -115,7 +138,7 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
|
|||||||
|
|
||||||
await storage.saveFolder(folder);
|
await storage.saveFolder(folder);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(folderToResponse(folder));
|
return jsonResponse(folderToResponse(folder));
|
||||||
}
|
}
|
||||||
@@ -132,7 +155,10 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str
|
|||||||
await storage.clearFolderFromCiphers(userId, id);
|
await storage.clearFolderFromCiphers(userId, id);
|
||||||
await storage.deleteFolder(id, userId);
|
await storage.deleteFolder(id, userId);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
await writeFolderAudit(storage, request, userId, 'folder.delete', {
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
@@ -155,7 +181,10 @@ export async function handleBulkDeleteFolders(request: Request, env: Env, userId
|
|||||||
|
|
||||||
const revisionDate = await storage.bulkDeleteFolders(ids, userId);
|
const revisionDate = await storage.bulkDeleteFolders(ids, userId);
|
||||||
if (revisionDate) {
|
if (revisionDate) {
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
await writeFolderAudit(storage, request, userId, 'folder.delete.bulk', {
|
||||||
|
count: ids.length,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ import {
|
|||||||
buildAccountKeys,
|
buildAccountKeys,
|
||||||
buildUserDecryptionOptions,
|
buildUserDecryptionOptions,
|
||||||
} from '../utils/user-decryption';
|
} from '../utils/user-decryption';
|
||||||
|
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
|
||||||
|
|
||||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||||
const TWO_FACTOR_PROVIDER_REMEMBER = 5;
|
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.
|
// Android client (2026.2.x) deserializes TwoFactorProviders2 keys with -1 for recovery code.
|
||||||
// Keep request parsing backward-compatible with historical provider values (8 / 100).
|
// Keep request parsing backward-compatible with historical provider values (8 / 100).
|
||||||
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
|
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
|
||||||
@@ -31,6 +33,77 @@ function resolveTotpSecret(userSecret: string | null): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveDeviceSession(
|
||||||
|
storage: StorageService,
|
||||||
|
userId: string,
|
||||||
|
deviceInfo: ReturnType<typeof readAuthRequestDeviceInfo>
|
||||||
|
): Promise<{ identifier: string; sessionStamp: string } | null> {
|
||||||
|
if (!deviceInfo.deviceIdentifier) return null;
|
||||||
|
const existingDevice = await storage.getDevice(userId, deviceInfo.deviceIdentifier);
|
||||||
|
const sessionStamp = String(existingDevice?.sessionStamp || '').trim() || generateUUID();
|
||||||
|
return { identifier: deviceInfo.deviceIdentifier, sessionStamp };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 constantTimeEquals(a: string, b: string): boolean {
|
||||||
|
const encA = new TextEncoder().encode(a);
|
||||||
|
const encB = new TextEncoder().encode(b);
|
||||||
|
if (encA.length !== encB.length) return false;
|
||||||
|
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < encA.length; i++) {
|
||||||
|
diff |= encA[i] ^ encB[i];
|
||||||
|
}
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
function buildPreloginResponse(
|
||||||
email: string,
|
email: string,
|
||||||
kdfType: number,
|
kdfType: number,
|
||||||
@@ -179,11 +252,37 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
}
|
}
|
||||||
if (user.status !== 'active') {
|
if (user.status !== 'active') {
|
||||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||||
|
await safeWriteAuditEvent(env, {
|
||||||
|
actorUserId: user.id,
|
||||||
|
action: 'auth.login.failed.user_inactive',
|
||||||
|
category: 'auth',
|
||||||
|
level: 'warn',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
metadata: {
|
||||||
|
grantType,
|
||||||
|
deviceIdentifier: deviceInfo.deviceIdentifier,
|
||||||
|
...auditRequestMetadata(request),
|
||||||
|
},
|
||||||
|
});
|
||||||
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
|
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
|
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
|
await safeWriteAuditEvent(env, {
|
||||||
|
actorUserId: user.id,
|
||||||
|
action: 'auth.login.failed.bad_password',
|
||||||
|
category: 'auth',
|
||||||
|
level: 'warn',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
metadata: {
|
||||||
|
grantType,
|
||||||
|
deviceIdentifier: deviceInfo.deviceIdentifier,
|
||||||
|
...auditRequestMetadata(request),
|
||||||
|
},
|
||||||
|
});
|
||||||
return recordFailedLoginAndBuildResponse(
|
return recordFailedLoginAndBuildResponse(
|
||||||
rateLimit,
|
rateLimit,
|
||||||
loginIdentifier,
|
loginIdentifier,
|
||||||
@@ -259,10 +358,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Persist device only after successful password + (optional) 2FA verification.
|
// Persist device only after successful password + (optional) 2FA verification.
|
||||||
const deviceSession =
|
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo);
|
||||||
deviceInfo.deviceIdentifier
|
|
||||||
? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() }
|
|
||||||
: null;
|
|
||||||
if (deviceSession) {
|
if (deviceSession) {
|
||||||
await storage.upsertDevice(
|
await storage.upsertDevice(
|
||||||
user.id,
|
user.id,
|
||||||
@@ -278,17 +374,34 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
|
|
||||||
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
||||||
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
||||||
|
const accountKeys = buildAccountKeys(user);
|
||||||
|
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||||
|
await safeWriteAuditEvent(env, {
|
||||||
|
actorUserId: user.id,
|
||||||
|
action: 'auth.login.success',
|
||||||
|
category: 'auth',
|
||||||
|
level: 'info',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
metadata: {
|
||||||
|
grantType,
|
||||||
|
webSession: shouldUseWebSession(request),
|
||||||
|
deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier,
|
||||||
|
deviceType: deviceInfo.deviceType,
|
||||||
|
...auditRequestMetadata(request),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const response: TokenResponse = {
|
const response: TokenResponse = {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
refresh_token: refreshToken,
|
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
|
||||||
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
||||||
Key: user.key,
|
Key: user.key,
|
||||||
PrivateKey: user.privateKey,
|
PrivateKey: user.privateKey,
|
||||||
AccountKeys: buildAccountKeys(user),
|
AccountKeys: accountKeys,
|
||||||
accountKeys: buildAccountKeys(user),
|
accountKeys: accountKeys,
|
||||||
Kdf: user.kdfType,
|
Kdf: user.kdfType,
|
||||||
KdfIterations: user.kdfIterations,
|
KdfIterations: user.kdfIterations,
|
||||||
KdfMemory: user.kdfMemory,
|
KdfMemory: user.kdfMemory,
|
||||||
@@ -301,11 +414,144 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
ApiUseKeyConnector: false,
|
ApiUseKeyConnector: false,
|
||||||
scope: 'api offline_access',
|
scope: 'api offline_access',
|
||||||
unofficialServer: true,
|
unofficialServer: true,
|
||||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
UserDecryptionOptions: userDecryptionOptions,
|
||||||
userDecryptionOptions: buildUserDecryptionOptions(user),
|
userDecryptionOptions: userDecryptionOptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
return jsonResponse(response);
|
const baseResponse = jsonResponse(response);
|
||||||
|
return shouldUseWebSession(request)
|
||||||
|
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
||||||
|
: baseResponse;
|
||||||
|
|
||||||
|
} else if (grantType === 'client_credentials') {
|
||||||
|
// Login with client credentials
|
||||||
|
const clientId = body.client_id;
|
||||||
|
const clientSecret = body.client_secret;
|
||||||
|
const scope = body.scope;
|
||||||
|
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
||||||
|
|
||||||
|
const loginIdentifier = `${clientIdentifier}:${clientId}`;
|
||||||
|
const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope);
|
||||||
|
if (!parmValid) {
|
||||||
|
return identityErrorResponse('Parameter error', 'invalid_request', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check login lockout before user lookup to reduce user-enumeration signal
|
||||||
|
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
|
||||||
|
if (!loginCheck.allowed) {
|
||||||
|
return identityErrorResponse(
|
||||||
|
`Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`,
|
||||||
|
'TooManyRequests',
|
||||||
|
429
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uid = clientId.slice(5);
|
||||||
|
const user = await storage.getUserById(uid);
|
||||||
|
if (!user) {
|
||||||
|
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||||
|
return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400);
|
||||||
|
}
|
||||||
|
if (user.status !== 'active') {
|
||||||
|
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||||
|
await safeWriteAuditEvent(env, {
|
||||||
|
actorUserId: user.id,
|
||||||
|
action: 'auth.login.failed.user_inactive',
|
||||||
|
category: 'auth',
|
||||||
|
level: 'warn',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
metadata: {
|
||||||
|
grantType,
|
||||||
|
deviceIdentifier: deviceInfo.deviceIdentifier,
|
||||||
|
...auditRequestMetadata(request),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) {
|
||||||
|
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||||
|
await safeWriteAuditEvent(env, {
|
||||||
|
actorUserId: user.id,
|
||||||
|
action: 'auth.login.failed.bad_api_key',
|
||||||
|
category: 'auth',
|
||||||
|
level: 'warn',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
metadata: {
|
||||||
|
grantType,
|
||||||
|
deviceIdentifier: deviceInfo.deviceIdentifier,
|
||||||
|
...auditRequestMetadata(request),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist device only after successful client credential verification.
|
||||||
|
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo);
|
||||||
|
if (deviceSession) {
|
||||||
|
await storage.upsertDevice(
|
||||||
|
user.id,
|
||||||
|
deviceSession.identifier,
|
||||||
|
deviceInfo.deviceName,
|
||||||
|
deviceInfo.deviceType,
|
||||||
|
deviceSession.sessionStamp
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Successful login - clear failed attempts
|
||||||
|
await rateLimit.clearLoginAttempts(loginIdentifier);
|
||||||
|
|
||||||
|
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
||||||
|
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
||||||
|
const accountKeys = buildAccountKeys(user);
|
||||||
|
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||||
|
await safeWriteAuditEvent(env, {
|
||||||
|
actorUserId: user.id,
|
||||||
|
action: 'auth.login.success',
|
||||||
|
category: 'auth',
|
||||||
|
level: 'info',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
metadata: {
|
||||||
|
grantType,
|
||||||
|
webSession: shouldUseWebSession(request),
|
||||||
|
deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier,
|
||||||
|
deviceType: deviceInfo.deviceType,
|
||||||
|
...auditRequestMetadata(request),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: TokenResponse = {
|
||||||
|
access_token: accessToken,
|
||||||
|
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
||||||
|
token_type: 'Bearer',
|
||||||
|
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
|
||||||
|
Key: user.key,
|
||||||
|
PrivateKey: user.privateKey,
|
||||||
|
AccountKeys: accountKeys,
|
||||||
|
accountKeys: accountKeys,
|
||||||
|
Kdf: user.kdfType,
|
||||||
|
KdfIterations: user.kdfIterations,
|
||||||
|
KdfMemory: user.kdfMemory,
|
||||||
|
KdfParallelism: user.kdfParallelism,
|
||||||
|
ForcePasswordReset: false,
|
||||||
|
ResetMasterPassword: false,
|
||||||
|
MasterPasswordPolicy: {
|
||||||
|
Object: 'masterPasswordPolicy',
|
||||||
|
},
|
||||||
|
ApiUseKeyConnector: false,
|
||||||
|
scope: 'api offline_access',
|
||||||
|
unofficialServer: true,
|
||||||
|
UserDecryptionOptions: userDecryptionOptions,
|
||||||
|
userDecryptionOptions: userDecryptionOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseResponse = jsonResponse(response);
|
||||||
|
return shouldUseWebSession(request)
|
||||||
|
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
||||||
|
: baseResponse;
|
||||||
|
|
||||||
} else if (grantType === 'send_access') {
|
} else if (grantType === 'send_access') {
|
||||||
const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
|
const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
|
||||||
@@ -371,14 +617,35 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Refresh token
|
// Refresh token
|
||||||
const refreshToken = body.refresh_token;
|
const refreshToken = String(body.refresh_token || '').trim() || (
|
||||||
|
shouldUseWebSession(request)
|
||||||
|
? parseCookieValue(request, WEB_REFRESH_COOKIE)
|
||||||
|
: null
|
||||||
|
);
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
|
return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await auth.refreshAccessToken(refreshToken);
|
const result = await auth.refreshAccessTokenDetailed(refreshToken);
|
||||||
if (!result) {
|
if (!result.ok) {
|
||||||
return identityErrorResponse('Invalid refresh token', 'invalid_grant', 400);
|
await safeWriteAuditEvent(env, {
|
||||||
|
actorUserId: result.userId ?? null,
|
||||||
|
action: `auth.refresh.failed.${result.reason}`,
|
||||||
|
category: 'auth',
|
||||||
|
level: 'warn',
|
||||||
|
targetType: result.deviceIdentifier ? 'device' : 'refreshToken',
|
||||||
|
targetId: result.deviceIdentifier ?? null,
|
||||||
|
metadata: {
|
||||||
|
grantType,
|
||||||
|
reason: result.reason,
|
||||||
|
webSession: shouldUseWebSession(request),
|
||||||
|
...auditRequestMetadata(request),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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
|
// Keep a short overlap window for old refresh token to absorb
|
||||||
@@ -389,17 +656,22 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { accessToken, user, device } = result;
|
const { accessToken, user, device } = result;
|
||||||
|
if (device?.identifier) {
|
||||||
|
await storage.touchDeviceLastSeen(user.id, device.identifier);
|
||||||
|
}
|
||||||
const newRefreshToken = await auth.generateRefreshToken(user.id, device);
|
const newRefreshToken = await auth.generateRefreshToken(user.id, device);
|
||||||
|
const accountKeys = buildAccountKeys(user);
|
||||||
|
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||||
|
|
||||||
const response: TokenResponse = {
|
const response: TokenResponse = {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
refresh_token: newRefreshToken,
|
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: newRefreshToken }),
|
||||||
Key: user.key,
|
Key: user.key,
|
||||||
PrivateKey: user.privateKey,
|
PrivateKey: user.privateKey,
|
||||||
AccountKeys: buildAccountKeys(user),
|
AccountKeys: accountKeys,
|
||||||
accountKeys: buildAccountKeys(user),
|
accountKeys: accountKeys,
|
||||||
Kdf: user.kdfType,
|
Kdf: user.kdfType,
|
||||||
KdfIterations: user.kdfIterations,
|
KdfIterations: user.kdfIterations,
|
||||||
KdfMemory: user.kdfMemory,
|
KdfMemory: user.kdfMemory,
|
||||||
@@ -412,11 +684,14 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
ApiUseKeyConnector: false,
|
ApiUseKeyConnector: false,
|
||||||
scope: 'api offline_access',
|
scope: 'api offline_access',
|
||||||
unofficialServer: true,
|
unofficialServer: true,
|
||||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
UserDecryptionOptions: userDecryptionOptions,
|
||||||
userDecryptionOptions: buildUserDecryptionOptions(user),
|
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);
|
return identityErrorResponse('Unsupported grant type', 'unsupported_grant_type', 400);
|
||||||
@@ -470,10 +745,30 @@ export async function handleRevocation(request: Request, env: Env): Promise<Resp
|
|||||||
return new Response(null, { status: 200 });
|
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) {
|
if (token) {
|
||||||
await storage.deleteRefreshToken(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkClientCredentialsParam(clientId: string, clientSecret: string, scope: string): boolean {
|
||||||
|
if (scope !== 'api') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!clientId.startsWith('user.')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!clientSecret) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,11 @@ interface CiphersImportRequest {
|
|||||||
sshKey?: any | null;
|
sshKey?: any | null;
|
||||||
key?: string | null;
|
key?: string | null;
|
||||||
login?: {
|
login?: {
|
||||||
uris?: Array<{ uri: string | null; match?: number | null }> | null;
|
uris?: Array<{ uri: string | null; uriChecksum?: string | null; match?: number | null }> | null;
|
||||||
username?: string | null;
|
username?: string | null;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
totp?: string | null;
|
totp?: string | null;
|
||||||
autofillOnPageLoad?: boolean | null;
|
autofillOnPageLoad?: boolean | null;
|
||||||
fido2Credentials?: any[] | null;
|
|
||||||
uri?: string | null;
|
uri?: string | null;
|
||||||
passwordRevisionDate?: string | null;
|
passwordRevisionDate?: string | null;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
@@ -83,6 +82,16 @@ function bindNull(v: any): any {
|
|||||||
return v === undefined ? null : v;
|
return v === undefined ? null : v;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readAliasedImportProp<T = unknown>(source: any, aliases: string[]): T | undefined {
|
||||||
|
if (!source || typeof source !== 'object') return undefined;
|
||||||
|
for (const key of aliases) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||||
|
return source[key] as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> {
|
async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> {
|
||||||
for (let i = 0; i < statements.length; i += chunkSize) {
|
for (let i = 0; i < statements.length; i += chunkSize) {
|
||||||
const chunk = statements.slice(i, i + chunkSize);
|
const chunk = statements.slice(i, i + chunkSize);
|
||||||
@@ -159,9 +168,16 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = [];
|
const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = [];
|
||||||
for (let i = 0; i < ciphers.length; i++) {
|
for (let i = 0; i < ciphers.length; i++) {
|
||||||
const c = ciphers[i];
|
const c = ciphers[i];
|
||||||
const folderId = cipherFolderMap.get(i) || null;
|
const folderId = cipherFolderMap.get(i) || readAliasedImportProp<string | null>(c, ['folderId', 'FolderId']) || null;
|
||||||
const sourceIdRaw = String(c?.id ?? '').trim();
|
const sourceIdRaw = String(c?.id ?? '').trim();
|
||||||
const sourceId = sourceIdRaw || null;
|
const sourceId = sourceIdRaw || null;
|
||||||
|
const login = readAliasedImportProp<any | null>(c, ['login', 'Login']);
|
||||||
|
const card = readAliasedImportProp<any | null>(c, ['card', 'Card']);
|
||||||
|
const identity = readAliasedImportProp<any | null>(c, ['identity', 'Identity']);
|
||||||
|
const secureNote = readAliasedImportProp<any | null>(c, ['secureNote', 'SecureNote']);
|
||||||
|
const fields = readAliasedImportProp<any[] | null>(c, ['fields', 'Fields']);
|
||||||
|
const passwordHistory = readAliasedImportProp<any[] | null>(c, ['passwordHistory', 'PasswordHistory']);
|
||||||
|
const key = readAliasedImportProp<string | null>(c, ['key', 'Key']);
|
||||||
|
|
||||||
const cipher: Cipher = {
|
const cipher: Cipher = {
|
||||||
...c,
|
...c,
|
||||||
@@ -172,64 +188,64 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
name: c.name ?? 'Untitled',
|
name: c.name ?? 'Untitled',
|
||||||
notes: c.notes ?? null,
|
notes: c.notes ?? null,
|
||||||
favorite: c.favorite ?? false,
|
favorite: c.favorite ?? false,
|
||||||
login: c.login ? {
|
login: login ? {
|
||||||
...c.login,
|
...login,
|
||||||
username: c.login.username ?? null,
|
username: login.username ?? null,
|
||||||
password: c.login.password ?? null,
|
password: login.password ?? null,
|
||||||
uris: c.login.uris?.map(u => ({
|
uris: login.uris?.map((u: any) => ({
|
||||||
...u,
|
...u,
|
||||||
uri: u.uri ?? null,
|
uri: u.uri ?? null,
|
||||||
uriChecksum: null,
|
uriChecksum: u.uriChecksum ?? null,
|
||||||
match: u.match ?? null,
|
match: u.match ?? null,
|
||||||
})) || null,
|
})) || null,
|
||||||
totp: c.login.totp ?? null,
|
totp: login.totp ?? null,
|
||||||
autofillOnPageLoad: c.login.autofillOnPageLoad ?? null,
|
autofillOnPageLoad: login.autofillOnPageLoad ?? null,
|
||||||
fido2Credentials: c.login.fido2Credentials ?? null,
|
fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
|
||||||
uri: c.login.uri ?? null,
|
uri: login.uri ?? null,
|
||||||
passwordRevisionDate: c.login.passwordRevisionDate ?? null,
|
passwordRevisionDate: login.passwordRevisionDate ?? null,
|
||||||
} : null,
|
} : null,
|
||||||
card: c.card ? {
|
card: card ? {
|
||||||
...c.card,
|
...card,
|
||||||
cardholderName: c.card.cardholderName ?? null,
|
cardholderName: card.cardholderName ?? null,
|
||||||
brand: c.card.brand ?? null,
|
brand: card.brand ?? null,
|
||||||
number: c.card.number ?? null,
|
number: card.number ?? null,
|
||||||
expMonth: c.card.expMonth ?? null,
|
expMonth: card.expMonth ?? null,
|
||||||
expYear: c.card.expYear ?? null,
|
expYear: card.expYear ?? null,
|
||||||
code: c.card.code ?? null,
|
code: card.code ?? null,
|
||||||
} : null,
|
} : null,
|
||||||
identity: c.identity ? {
|
identity: identity ? {
|
||||||
...c.identity,
|
...identity,
|
||||||
title: c.identity.title ?? null,
|
title: identity.title ?? null,
|
||||||
firstName: c.identity.firstName ?? null,
|
firstName: identity.firstName ?? null,
|
||||||
middleName: c.identity.middleName ?? null,
|
middleName: identity.middleName ?? null,
|
||||||
lastName: c.identity.lastName ?? null,
|
lastName: identity.lastName ?? null,
|
||||||
address1: c.identity.address1 ?? null,
|
address1: identity.address1 ?? null,
|
||||||
address2: c.identity.address2 ?? null,
|
address2: identity.address2 ?? null,
|
||||||
address3: c.identity.address3 ?? null,
|
address3: identity.address3 ?? null,
|
||||||
city: c.identity.city ?? null,
|
city: identity.city ?? null,
|
||||||
state: c.identity.state ?? null,
|
state: identity.state ?? null,
|
||||||
postalCode: c.identity.postalCode ?? null,
|
postalCode: identity.postalCode ?? null,
|
||||||
country: c.identity.country ?? null,
|
country: identity.country ?? null,
|
||||||
company: c.identity.company ?? null,
|
company: identity.company ?? null,
|
||||||
email: c.identity.email ?? null,
|
email: identity.email ?? null,
|
||||||
phone: c.identity.phone ?? null,
|
phone: identity.phone ?? null,
|
||||||
ssn: c.identity.ssn ?? null,
|
ssn: identity.ssn ?? null,
|
||||||
username: c.identity.username ?? null,
|
username: identity.username ?? null,
|
||||||
passportNumber: c.identity.passportNumber ?? null,
|
passportNumber: identity.passportNumber ?? null,
|
||||||
licenseNumber: c.identity.licenseNumber ?? null,
|
licenseNumber: identity.licenseNumber ?? null,
|
||||||
} : null,
|
} : null,
|
||||||
secureNote: c.secureNote ?? null,
|
secureNote: secureNote ?? null,
|
||||||
fields: c.fields?.map(f => ({
|
fields: fields?.map((f: any) => ({
|
||||||
...f,
|
...f,
|
||||||
name: f.name ?? null,
|
name: f.name ?? null,
|
||||||
value: f.value ?? null,
|
value: f.value ?? null,
|
||||||
type: f.type,
|
type: f.type,
|
||||||
linkedId: f.linkedId ?? null,
|
linkedId: f.linkedId ?? null,
|
||||||
})) || null,
|
})) || null,
|
||||||
passwordHistory: c.passwordHistory ?? null,
|
passwordHistory: passwordHistory ?? null,
|
||||||
reprompt: c.reprompt ?? 0,
|
reprompt: c.reprompt ?? 0,
|
||||||
sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null),
|
sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null),
|
||||||
key: (c as any).key ?? null,
|
key: key ?? null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
@@ -273,7 +289,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
|
|
||||||
// Update revision date
|
// Update revision date
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||||
|
|
||||||
if (returnCipherMap) {
|
if (returnCipherMap) {
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
|
|||||||
@@ -29,6 +29,28 @@ import {
|
|||||||
setSendPassword,
|
setSendPassword,
|
||||||
validateDeletionDate,
|
validateDeletionDate,
|
||||||
} from './sends-shared';
|
} from './sends-shared';
|
||||||
|
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||||
|
|
||||||
|
async function writeSendAudit(
|
||||||
|
storage: StorageService,
|
||||||
|
request: Request,
|
||||||
|
userId: string,
|
||||||
|
action: string,
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
): Promise<void> {
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action,
|
||||||
|
category: 'data',
|
||||||
|
level: action.includes('delete') ? 'security' : 'info',
|
||||||
|
targetType: 'send',
|
||||||
|
targetId: typeof metadata.id === 'string' ? metadata.id : null,
|
||||||
|
metadata: {
|
||||||
|
...metadata,
|
||||||
|
...auditRequestMetadata(request),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function processSendFileUpload(
|
async function processSendFileUpload(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -76,7 +98,7 @@ async function processSendFileUpload(
|
|||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||||
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||||
|
|
||||||
return new Response(null, { status: 201 });
|
return new Response(null, { status: 201 });
|
||||||
}
|
}
|
||||||
@@ -97,8 +119,9 @@ export async function handleGetSends(request: Request, env: Env, userId: string)
|
|||||||
sends = await storage.getAllSends(userId);
|
sends = await storage.getAllSends(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sendResponses = sends.map(sendToResponse);
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
data: sends.map(sendToResponse),
|
data: sendResponses,
|
||||||
object: 'list',
|
object: 'list',
|
||||||
continuationToken,
|
continuationToken,
|
||||||
});
|
});
|
||||||
@@ -225,7 +248,7 @@ export async function handleCreateSend(request: Request, env: Env, userId: strin
|
|||||||
|
|
||||||
await storage.saveSend(send);
|
await storage.saveSend(send);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(sendToResponse(send));
|
return jsonResponse(sendToResponse(send));
|
||||||
}
|
}
|
||||||
@@ -348,7 +371,7 @@ export async function handleCreateFileSendV2(request: Request, env: Env, userId:
|
|||||||
|
|
||||||
await storage.saveSend(send);
|
await storage.saveSend(send);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
const jwtSecret = getSafeJwtSecret(env);
|
const jwtSecret = getSafeJwtSecret(env);
|
||||||
if (!jwtSecret) {
|
if (!jwtSecret) {
|
||||||
return errorResponse('Server configuration error', 500);
|
return errorResponse('Server configuration error', 500);
|
||||||
@@ -595,13 +618,12 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
|
|||||||
send.updatedAt = new Date().toISOString();
|
send.updatedAt = new Date().toISOString();
|
||||||
await storage.saveSend(send);
|
await storage.saveSend(send);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(sendToResponse(send));
|
return jsonResponse(sendToResponse(send));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||||
void request;
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const send = await storage.getSend(sendId);
|
const send = await storage.getSend(sendId);
|
||||||
if (!send || send.userId !== userId) {
|
if (!send || send.userId !== userId) {
|
||||||
@@ -618,7 +640,11 @@ export async function handleDeleteSend(request: Request, env: Env, userId: strin
|
|||||||
|
|
||||||
await storage.deleteSend(sendId, userId);
|
await storage.deleteSend(sendId, userId);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
await writeSendAudit(storage, request, userId, 'send.delete', {
|
||||||
|
id: sendId,
|
||||||
|
type: send.type,
|
||||||
|
});
|
||||||
|
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
@@ -649,14 +675,17 @@ export async function handleBulkDeleteSends(request: Request, env: Env, userId:
|
|||||||
|
|
||||||
const revisionDate = await storage.bulkDeleteSends(body.ids, userId);
|
const revisionDate = await storage.bulkDeleteSends(body.ids, userId);
|
||||||
if (revisionDate) {
|
if (revisionDate) {
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
await writeSendAudit(storage, request, userId, 'send.delete.bulk', {
|
||||||
|
count: sends.length,
|
||||||
|
requestedCount: body.ids.length,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||||
void request;
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const send = await storage.getSend(sendId);
|
const send = await storage.getSend(sendId);
|
||||||
if (!send || send.userId !== userId) {
|
if (!send || send.userId !== userId) {
|
||||||
@@ -667,13 +696,16 @@ export async function handleRemoveSendPassword(request: Request, env: Env, userI
|
|||||||
send.updatedAt = new Date().toISOString();
|
send.updatedAt = new Date().toISOString();
|
||||||
await storage.saveSend(send);
|
await storage.saveSend(send);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
await writeSendAudit(storage, request, userId, 'send.password.remove', {
|
||||||
|
id: send.id,
|
||||||
|
type: send.type,
|
||||||
|
});
|
||||||
|
|
||||||
return jsonResponse(sendToResponse(send));
|
return jsonResponse(sendToResponse(send));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||||
void request;
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const send = await storage.getSend(sendId);
|
const send = await storage.getSend(sendId);
|
||||||
if (!send || send.userId !== userId) {
|
if (!send || send.userId !== userId) {
|
||||||
@@ -685,7 +717,11 @@ export async function handleRemoveSendAuth(request: Request, env: Env, userId: s
|
|||||||
send.updatedAt = new Date().toISOString();
|
send.updatedAt = new Date().toISOString();
|
||||||
await storage.saveSend(send);
|
await storage.saveSend(send);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
await writeSendAudit(storage, request, userId, 'send.auth.remove', {
|
||||||
|
id: send.id,
|
||||||
|
type: send.type,
|
||||||
|
});
|
||||||
|
|
||||||
return jsonResponse(sendToResponse(send));
|
return jsonResponse(sendToResponse(send));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export async function handleAccessSend(request: Request, env: Env, accessId: str
|
|||||||
}
|
}
|
||||||
send.accessCount += 1;
|
send.accessCount += 1;
|
||||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||||
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
const creatorIdentifier = await getCreatorIdentifier(storage, send);
|
const creatorIdentifier = await getCreatorIdentifier(storage, send);
|
||||||
@@ -162,7 +162,7 @@ export async function handleAccessSendFile(
|
|||||||
}
|
}
|
||||||
send.accessCount += 1;
|
send.accessCount += 1;
|
||||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||||
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||||
|
|
||||||
const token = await createSendFileDownloadToken(send.id, fileId, secret);
|
const token = await createSendFileDownloadToken(send.id, fileId, secret);
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
@@ -202,7 +202,7 @@ export async function handleAccessSendV2(request: Request, env: Env): Promise<Re
|
|||||||
}
|
}
|
||||||
send.accessCount += 1;
|
send.accessCount += 1;
|
||||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||||
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
const creatorIdentifier = await getCreatorIdentifier(storage, send);
|
const creatorIdentifier = await getCreatorIdentifier(storage, send);
|
||||||
@@ -241,7 +241,7 @@ export async function handleAccessSendFileV2(request: Request, env: Env, fileId:
|
|||||||
}
|
}
|
||||||
send.accessCount += 1;
|
send.accessCount += 1;
|
||||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||||
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||||
|
|
||||||
const downloadToken = await createSendFileDownloadToken(send.id, fileId, jwt.secret);
|
const downloadToken = await createSendFileDownloadToken(send.id, fileId, jwt.secret);
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ export const SEND_INACCESSIBLE_MSG = 'Send does not exist or is no longer availa
|
|||||||
const SEND_PASSWORD_ITERATIONS = 100_000;
|
const SEND_PASSWORD_ITERATIONS = 100_000;
|
||||||
export const SEND_PASSWORD_LIMIT_SCOPE = 'send-password';
|
export const SEND_PASSWORD_LIMIT_SCOPE = 'send-password';
|
||||||
|
|
||||||
export async function notifyVaultSyncForRequest(
|
export function notifyVaultSyncForRequest(
|
||||||
request: Request,
|
request: Request,
|
||||||
env: Env,
|
env: Env,
|
||||||
userId: string,
|
userId: string,
|
||||||
revisionDate: string
|
revisionDate: string
|
||||||
): Promise<void> {
|
): void {
|
||||||
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAliasedProp(source: unknown, aliases: string[]): { present: boolean; value: unknown } {
|
export function getAliasedProp(source: unknown, aliases: string[]): { present: boolean; value: unknown } {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } from '../types';
|
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } from '../types';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { errorResponse } from '../utils/response';
|
import { errorResponse } from '../utils/response';
|
||||||
import { cipherToResponse } from './ciphers';
|
import { cipherToResponse, isCipherResponseSyncCompatible } from './ciphers';
|
||||||
import { sendToResponse } from './sends';
|
import { sendToResponse } from './sends';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
import {
|
import {
|
||||||
@@ -9,88 +9,30 @@ import {
|
|||||||
buildUserDecryptionCompat,
|
buildUserDecryptionCompat,
|
||||||
buildUserDecryptionOptions,
|
buildUserDecryptionOptions,
|
||||||
} from '../utils/user-decryption';
|
} from '../utils/user-decryption';
|
||||||
|
import { buildDomainsResponse } from '../services/domain-rules';
|
||||||
|
|
||||||
interface SyncCacheEntry {
|
// CONTRACT:
|
||||||
userId: string;
|
// /api/sync reuses cipherToResponse() as the single cipher response shaper.
|
||||||
revisionDate: string;
|
// Filtering invalid cipher responses here protects clients from stored rows that
|
||||||
body: string;
|
// would otherwise make official apps fail after an HTTP 200 sync.
|
||||||
expiresAt: number;
|
// Keep this aligned with src/handlers/ciphers.ts when adding new vault fields.
|
||||||
bytes: number;
|
function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean, excludeSends: boolean): Request {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const cacheUrl = new URL(
|
||||||
|
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${excludeDomains ? '1' : '0'}/${excludeSends ? '1' : '0'}`,
|
||||||
|
url.origin
|
||||||
|
);
|
||||||
|
return new Request(cacheUrl.toString(), { method: 'GET' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncResponseCache = new Map<string, SyncCacheEntry>();
|
async function readSyncCache(cacheRequest: Request): Promise<Response | null> {
|
||||||
let syncResponseCacheTotalBytes = 0;
|
const hit = await caches.default.match(cacheRequest);
|
||||||
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);
|
|
||||||
if (!hit) return null;
|
if (!hit) return null;
|
||||||
if (hit.expiresAt <= Date.now()) {
|
return new Response(hit.body, hit);
|
||||||
deleteSyncCacheEntry(key, hit);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return hit.body;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteSyncCacheEntry(key: string, entry?: SyncCacheEntry): void {
|
async function writeSyncCache(cacheRequest: Request, response: Response): Promise<void> {
|
||||||
const existing = entry ?? syncResponseCache.get(key);
|
await caches.default.put(cacheRequest, response.clone());
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/sync
|
// GET /api/sync
|
||||||
@@ -99,34 +41,31 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const excludeDomainsParam = url.searchParams.get('excludeDomains');
|
const excludeDomainsParam = url.searchParams.get('excludeDomains');
|
||||||
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
||||||
const userAgent = String(request.headers.get('user-agent') || '').toLowerCase();
|
const excludeSendsParam = url.searchParams.get('excludeSends');
|
||||||
const omitFido2Credentials =
|
const excludeSends = excludeSendsParam !== null && /^(1|true|yes)$/i.test(excludeSendsParam);
|
||||||
userAgent.includes('android') ||
|
|
||||||
userAgent.includes('iphone') ||
|
|
||||||
userAgent.includes('ipad') ||
|
|
||||||
userAgent.includes('ios');
|
|
||||||
|
|
||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return errorResponse('User not found', 404);
|
return errorResponse('User not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const revisionDate = await storage.getRevisionDate(userId);
|
const revisionDate = await storage.getRevisionDate(userId);
|
||||||
const cacheKey = buildSyncCacheKey(userId, revisionDate, excludeDomains);
|
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains, excludeSends);
|
||||||
const cachedBody = readSyncCache(cacheKey);
|
const cachedResponse = await readSyncCache(cacheRequest);
|
||||||
if (cachedBody) {
|
if (cachedResponse) {
|
||||||
return new Response(cachedBody, {
|
return cachedResponse;
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ciphers = await storage.getAllCiphers(userId);
|
const [ciphers, folders, sends, attachmentsByCipher, domainSettings] = await Promise.all([
|
||||||
const folders = await storage.getAllFolders(userId);
|
storage.getAllCiphers(userId),
|
||||||
const sends = await storage.getAllSends(userId);
|
storage.getAllFolders(userId),
|
||||||
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId);
|
excludeSends ? Promise.resolve([]) : storage.getAllSends(userId),
|
||||||
|
storage.getAttachmentsByUserId(userId),
|
||||||
|
excludeDomains ? Promise.resolve(null) : storage.getUserDomainSettings(userId),
|
||||||
|
]);
|
||||||
|
const accountKeys = buildAccountKeys(user);
|
||||||
|
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||||
|
|
||||||
// Build profile response
|
|
||||||
const profile: ProfileResponse = {
|
const profile: ProfileResponse = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
@@ -140,7 +79,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
twoFactorEnabled: !!user.totpSecret,
|
twoFactorEnabled: !!user.totpSecret,
|
||||||
key: user.key,
|
key: user.key,
|
||||||
privateKey: user.privateKey,
|
privateKey: user.privateKey,
|
||||||
accountKeys: buildAccountKeys(user),
|
accountKeys,
|
||||||
securityStamp: user.securityStamp || user.id,
|
securityStamp: user.securityStamp || user.id,
|
||||||
organizations: [],
|
organizations: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
@@ -152,53 +91,59 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
object: 'profile',
|
object: 'profile',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build cipher responses with attachments
|
|
||||||
const cipherResponses: CipherResponse[] = [];
|
const cipherResponses: CipherResponse[] = [];
|
||||||
for (const cipher of ciphers) {
|
for (const cipher of ciphers) {
|
||||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []);
|
||||||
cipherResponses.push(cipherToResponse(cipher, attachments, { omitFido2Credentials }));
|
if (isCipherResponseSyncCompatible(response)) {
|
||||||
|
cipherResponses.push(response);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build folder responses
|
const folderResponses: FolderResponse[] = [];
|
||||||
const folderResponses: FolderResponse[] = folders.map(folder => ({
|
for (const folder of folders) {
|
||||||
id: folder.id,
|
folderResponses.push({
|
||||||
name: folder.name,
|
id: folder.id,
|
||||||
revisionDate: folder.updatedAt,
|
name: folder.name,
|
||||||
object: 'folder',
|
revisionDate: folder.updatedAt,
|
||||||
}));
|
creationDate: folder.createdAt,
|
||||||
|
object: 'folder',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendResponses = sends.map(sendToResponse);
|
||||||
const syncResponse: SyncResponse = {
|
const syncResponse: SyncResponse = {
|
||||||
profile: profile,
|
profile,
|
||||||
folders: folderResponses,
|
folders: folderResponses,
|
||||||
collections: [],
|
collections: [],
|
||||||
ciphers: cipherResponses,
|
ciphers: cipherResponses,
|
||||||
domains: excludeDomains
|
domains: excludeDomains
|
||||||
? null
|
? null
|
||||||
: {
|
: buildDomainsResponse(
|
||||||
equivalentDomains: [],
|
domainSettings?.equivalentDomains || [],
|
||||||
globalEquivalentDomains: [],
|
domainSettings?.customEquivalentDomains || [],
|
||||||
object: 'domains',
|
domainSettings?.excludedGlobalEquivalentDomains || [],
|
||||||
},
|
{ omitExcludedGlobals: true }
|
||||||
|
),
|
||||||
policies: [],
|
policies: [],
|
||||||
sends: sends.map(sendToResponse),
|
sends: sendResponses,
|
||||||
UserDecryption: {
|
UserDecryption: {
|
||||||
MasterPasswordUnlock: buildUserDecryptionOptions(user).MasterPasswordUnlock,
|
MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock,
|
||||||
TrustedDeviceOption: null,
|
TrustedDeviceOption: null,
|
||||||
KeyConnectorOption: null,
|
KeyConnectorOption: null,
|
||||||
Object: 'userDecryption',
|
Object: 'userDecryption',
|
||||||
},
|
},
|
||||||
// PascalCase for desktop/browser clients
|
UserDecryptionOptions: userDecryptionOptions,
|
||||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
|
||||||
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
|
||||||
userDecryption: buildUserDecryptionCompat(user) as SyncResponse['userDecryption'],
|
userDecryption: buildUserDecryptionCompat(user) as SyncResponse['userDecryption'],
|
||||||
object: 'sync',
|
object: 'sync',
|
||||||
};
|
};
|
||||||
|
|
||||||
const body = JSON.stringify(syncResponse);
|
const response = new Response(JSON.stringify(syncResponse), {
|
||||||
writeSyncCache(userId, revisionDate, cacheKey, body);
|
|
||||||
|
|
||||||
return new Response(body, {
|
|
||||||
status: 200,
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ let dbInitialized = false;
|
|||||||
let dbInitError: string | null = null;
|
let dbInitError: string | null = null;
|
||||||
let dbInitPromise: Promise<void> | 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 {
|
function isWorkerHandledPath(path: string): boolean {
|
||||||
return (
|
return (
|
||||||
path.startsWith('/api/') ||
|
path.startsWith('/api/') ||
|
||||||
@@ -22,13 +31,33 @@ function isWorkerHandledPath(path: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addSearchIndexHeaders(request: Request, response: Response): Response {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const contentType = String(response.headers.get('Content-Type') || '').toLowerCase();
|
||||||
|
const shouldNoIndex =
|
||||||
|
url.pathname === '/robots.txt' ||
|
||||||
|
contentType.includes('text/html');
|
||||||
|
|
||||||
|
if (!shouldNoIndex) return response;
|
||||||
|
|
||||||
|
const headers = new Headers(response.headers);
|
||||||
|
headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive, nosnippet');
|
||||||
|
|
||||||
|
return new Response(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function maybeServeAsset(request: Request, env: Env): Promise<Response | null> {
|
async function maybeServeAsset(request: Request, env: Env): Promise<Response | null> {
|
||||||
if (!env.ASSETS) return null;
|
if (!env.ASSETS) return null;
|
||||||
if (request.method !== 'GET' && request.method !== 'HEAD') return null;
|
if (request.method !== 'GET' && request.method !== 'HEAD') return null;
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
if (isWorkerHandledPath(url.pathname)) return null;
|
if (isWorkerHandledPath(url.pathname)) return null;
|
||||||
|
|
||||||
return env.ASSETS.fetch(request);
|
const response = await env.ASSETS.fetch(request);
|
||||||
|
return addSearchIndexHeaders(request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureDatabaseInitialized(env: Env): Promise<void> {
|
async function ensureDatabaseInitialized(env: Env): Promise<void> {
|
||||||
@@ -56,9 +85,10 @@ async function ensureDatabaseInitialized(env: Env): Promise<void> {
|
|||||||
export default {
|
export default {
|
||||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||||
void ctx;
|
void ctx;
|
||||||
const assetResponse = await maybeServeAsset(request, env);
|
const normalizedRequest = normalizeRequestUrl(request);
|
||||||
|
const assetResponse = await maybeServeAsset(normalizedRequest, env);
|
||||||
if (assetResponse) {
|
if (assetResponse) {
|
||||||
return applyCors(request, assetResponse);
|
return applyCors(normalizedRequest, assetResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureDatabaseInitialized(env);
|
await ensureDatabaseInitialized(env);
|
||||||
@@ -76,11 +106,11 @@ export default {
|
|||||||
},
|
},
|
||||||
500
|
500
|
||||||
);
|
);
|
||||||
return applyCors(request, resp);
|
return applyCors(normalizedRequest, resp);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resp = await handleRequest(request, env);
|
const resp = await handleRequest(normalizedRequest, env);
|
||||||
return applyCors(request, resp);
|
return applyCors(normalizedRequest, resp);
|
||||||
},
|
},
|
||||||
|
|
||||||
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
handleDownloadAdminBackupAttachment,
|
handleDownloadAdminBackupAttachment,
|
||||||
handleGetAdminBackupSettings,
|
handleGetAdminBackupSettings,
|
||||||
handleGetAdminBackupSettingsRepairState,
|
handleGetAdminBackupSettingsRepairState,
|
||||||
|
handleInspectAdminRemoteBackup,
|
||||||
handleAdminImportBackup,
|
handleAdminImportBackup,
|
||||||
handleListAdminRemoteBackups,
|
handleListAdminRemoteBackups,
|
||||||
handleRepairAdminBackupSettings,
|
handleRepairAdminBackupSettings,
|
||||||
@@ -53,6 +54,10 @@ export async function handleAdminBackupRoute(
|
|||||||
return handleDownloadAdminRemoteBackup(request, env, actorUser);
|
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') {
|
if (path === '/api/admin/backup/remote/file' && method === 'DELETE') {
|
||||||
return handleDeleteAdminRemoteBackup(request, env, actorUser);
|
return handleDeleteAdminRemoteBackup(request, env, actorUser);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import {
|
|||||||
handleAdminRevokeInvite,
|
handleAdminRevokeInvite,
|
||||||
handleAdminSetUserStatus,
|
handleAdminSetUserStatus,
|
||||||
handleAdminDeleteUser,
|
handleAdminDeleteUser,
|
||||||
|
handleAdminListAuditLogs,
|
||||||
|
handleAdminGetAuditLogSettings,
|
||||||
|
handleAdminUpdateAuditLogSettings,
|
||||||
|
handleAdminClearAuditLogs,
|
||||||
} from './handlers/admin';
|
} from './handlers/admin';
|
||||||
import { handleAdminBackupRoute } from './router-admin-backup';
|
import { handleAdminBackupRoute } from './router-admin-backup';
|
||||||
|
|
||||||
@@ -21,6 +25,20 @@ export async function handleAdminRoute(
|
|||||||
return handleAdminListUsers(request, env, actorUser);
|
return handleAdminListUsers(request, env, actorUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/logs' && method === 'GET') {
|
||||||
|
return handleAdminListAuditLogs(request, env, actorUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/logs' && method === 'DELETE') {
|
||||||
|
return handleAdminClearAuditLogs(request, env, actorUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/logs/settings') {
|
||||||
|
if (method === 'GET') return handleAdminGetAuditLogSettings(request, env, actorUser);
|
||||||
|
if (method === 'PUT' || method === 'POST') return handleAdminUpdateAuditLogSettings(request, env, actorUser);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const adminBackupResponse = await handleAdminBackupRoute(request, env, actorUser, path, method);
|
const adminBackupResponse = await handleAdminBackupRoute(request, env, actorUser, path, method);
|
||||||
if (adminBackupResponse) return adminBackupResponse;
|
if (adminBackupResponse) return adminBackupResponse;
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
handleGetTotpStatus,
|
handleGetTotpStatus,
|
||||||
handleSetTotpStatus,
|
handleSetTotpStatus,
|
||||||
handleGetTotpRecoveryCode,
|
handleGetTotpRecoveryCode,
|
||||||
|
handleGetApiKey,
|
||||||
|
handleRotateApiKey,
|
||||||
} from './handlers/accounts';
|
} from './handlers/accounts';
|
||||||
import {
|
import {
|
||||||
handleGetCiphers,
|
handleGetCiphers,
|
||||||
@@ -58,10 +60,12 @@ import {
|
|||||||
handleCreateAttachment,
|
handleCreateAttachment,
|
||||||
handleUploadAttachment,
|
handleUploadAttachment,
|
||||||
handleGetAttachment,
|
handleGetAttachment,
|
||||||
|
handleUpdateAttachmentMetadata,
|
||||||
handleDeleteAttachment,
|
handleDeleteAttachment,
|
||||||
} from './handlers/attachments';
|
} from './handlers/attachments';
|
||||||
import { handleAuthenticatedDeviceRoute } from './router-devices';
|
import { handleAuthenticatedDeviceRoute } from './router-devices';
|
||||||
import { handleAdminRoute } from './router-admin';
|
import { handleAdminRoute } from './router-admin';
|
||||||
|
import { handleGetDomains, handleUpdateDomains } from './handlers/domains';
|
||||||
|
|
||||||
export async function handleAuthenticatedRoute(
|
export async function handleAuthenticatedRoute(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -119,6 +123,14 @@ export async function handleAuthenticatedRoute(
|
|||||||
return handleSetVerifyDevices(request, env, userId);
|
return handleSetVerifyDevices(request, env, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((path === '/api/accounts/api-key' || path === '/api/accounts/api_key') && method === 'POST') {
|
||||||
|
return handleGetApiKey(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((path === '/api/accounts/rotate-api-key' || path === '/api/accounts/rotate_api_key') && method === 'POST') {
|
||||||
|
return handleRotateApiKey(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
if (path === '/api/sync' && method === 'GET') {
|
if (path === '/api/sync' && method === 'GET') {
|
||||||
return handleSync(request, env, userId);
|
return handleSync(request, env, userId);
|
||||||
}
|
}
|
||||||
@@ -191,6 +203,11 @@ export async function handleAuthenticatedRoute(
|
|||||||
if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
|
if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const attachmentMetadataMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/metadata$/i);
|
||||||
|
if (attachmentMetadataMatch && (method === 'POST' || method === 'PUT')) {
|
||||||
|
return handleUpdateAttachmentMetadata(request, env, userId, cipherId, attachmentMetadataMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i);
|
const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i);
|
||||||
if (attachmentDeleteMatch && method === 'POST') {
|
if (attachmentDeleteMatch && method === 'POST') {
|
||||||
return handleDeleteAttachment(request, env, userId, cipherId, attachmentDeleteMatch[1]);
|
return handleDeleteAttachment(request, env, userId, cipherId, attachmentDeleteMatch[1]);
|
||||||
@@ -281,14 +298,9 @@ export async function handleAuthenticatedRoute(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/settings/domains') {
|
if (path === '/api/settings/domains' || path === '/settings/domains') {
|
||||||
if (method === 'GET' || method === 'PUT' || method === 'POST') {
|
if (method === 'GET') return handleGetDomains(env, userId);
|
||||||
return jsonResponse({
|
if (method === 'PUT' || method === 'POST') return handleUpdateDomains(request, env, userId);
|
||||||
equivalentDomains: [],
|
|
||||||
globalEquivalentDomains: [],
|
|
||||||
object: 'domains',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import {
|
|||||||
handleDeactivateDevice,
|
handleDeactivateDevice,
|
||||||
handleRevokeAllTrustedDevices,
|
handleRevokeAllTrustedDevices,
|
||||||
handleRevokeTrustedDevice,
|
handleRevokeTrustedDevice,
|
||||||
|
handleTrustDevicePermanently,
|
||||||
handleDeleteAllDevices,
|
handleDeleteAllDevices,
|
||||||
handleDeleteDevice,
|
handleDeleteDevice,
|
||||||
|
handleUpdateDeviceName,
|
||||||
handleUpdateDeviceToken,
|
handleUpdateDeviceToken,
|
||||||
handleUpdateDeviceWebPushAuth,
|
handleUpdateDeviceWebPushAuth,
|
||||||
handleClearDeviceToken,
|
handleClearDeviceToken,
|
||||||
@@ -43,6 +45,12 @@ export async function handleAuthenticatedDeviceRoute(
|
|||||||
return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier);
|
return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const permanentAuthorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)\/permanent$/i);
|
||||||
|
if (permanentAuthorizedDeviceMatch && method === 'POST') {
|
||||||
|
const deviceIdentifier = decodeURIComponent(permanentAuthorizedDeviceMatch[1]);
|
||||||
|
return handleTrustDevicePermanently(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i);
|
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i);
|
||||||
if (deleteDeviceMatch && method === 'GET') {
|
if (deleteDeviceMatch && method === 'GET') {
|
||||||
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
|
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
|
||||||
@@ -53,6 +61,12 @@ export async function handleAuthenticatedDeviceRoute(
|
|||||||
return handleDeleteDevice(request, env, userId, deviceIdentifier);
|
return handleDeleteDevice(request, env, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateDeviceNameMatch = path.match(/^\/api\/devices\/([^/]+)\/name$/i);
|
||||||
|
if (updateDeviceNameMatch && method === 'PUT') {
|
||||||
|
const deviceIdentifier = decodeURIComponent(updateDeviceNameMatch[1]);
|
||||||
|
return handleUpdateDeviceName(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i);
|
const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i);
|
||||||
if (identifierMatch && method === 'GET') {
|
if (identifierMatch && method === 'GET') {
|
||||||
const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
|
const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from './handlers/notifications';
|
} from './handlers/notifications';
|
||||||
import { handlePublicUploadSendFile } from './handlers/sends';
|
import { handlePublicUploadSendFile } from './handlers/sends';
|
||||||
import { jsonResponse } from './utils/response';
|
import { jsonResponse } from './utils/response';
|
||||||
|
import { StorageService } from './services/storage';
|
||||||
import type { Env } from './types';
|
import type { Env } from './types';
|
||||||
|
|
||||||
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
|
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
|
||||||
@@ -31,6 +32,7 @@ export interface WebBootstrapResponse {
|
|||||||
defaultKdfIterations: number;
|
defaultKdfIterations: number;
|
||||||
jwtUnsafeReason: JwtUnsafeReason;
|
jwtUnsafeReason: JwtUnsafeReason;
|
||||||
jwtSecretMinLength: number;
|
jwtSecretMinLength: number;
|
||||||
|
registrationInviteRequired: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSameOriginWriteRequest(request: Request): boolean {
|
function isSameOriginWriteRequest(request: Request): boolean {
|
||||||
@@ -52,20 +54,105 @@ function isSameOriginWriteRequest(request: Request): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNwIconSvg(): string {
|
function getDefaultWebsiteIconSvg(): string {
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="NW icon"><rect x="4" y="4" width="88" height="88" rx="20" fill="#111418"/><text x="48" y="60" text-anchor="middle" font-size="36" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-weight="800" letter-spacing="0.5" fill="#FFFFFF">NW</text></svg>`;
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="Globe icon"><circle cx="48" cy="48" r="34" fill="none" stroke="#8ea9c7" stroke-width="6"/><path d="M14 48h68M48 14c10 10 16 21.5 16 34s-6 24-16 34c-10-10-16-21.5-16-34s6-24 16-34zm-24 10c8 5 17 8 24 8s16-3 24-8m-48 48c8-5 17-8 24-8s16 3 24 8" fill="none" stroke="#8ea9c7" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNwFavicon(): Response {
|
function handleNwFavicon(): Response {
|
||||||
return new Response(getNwIconSvg(), {
|
return new Response(getDefaultWebsiteIconSvg(), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'image/svg+xml; charset=utf-8',
|
'Content-Type': 'image/svg+xml; charset=utf-8',
|
||||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
|
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleMissingWebsiteIcon(): Response {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 404,
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'public, max-age=300',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateIpv4(hostname: string): boolean {
|
||||||
|
const parts = hostname.split('.').map((part) => Number(part));
|
||||||
|
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
|
||||||
|
const [a, b] = parts;
|
||||||
|
return (
|
||||||
|
a === 10 ||
|
||||||
|
a === 127 ||
|
||||||
|
(a === 169 && b === 254) ||
|
||||||
|
(a === 172 && b >= 16 && b <= 31) ||
|
||||||
|
(a === 192 && b === 168) ||
|
||||||
|
a === 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedChangePasswordHost(hostname: string): boolean {
|
||||||
|
const normalized = hostname.toLowerCase().replace(/\.+$/, '');
|
||||||
|
return (
|
||||||
|
normalized === 'localhost' ||
|
||||||
|
normalized.endsWith('.localhost') ||
|
||||||
|
normalized.endsWith('.local') ||
|
||||||
|
normalized === '::1' ||
|
||||||
|
normalized.startsWith('[') ||
|
||||||
|
isPrivateIpv4(normalized)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePublicHttpUrl(rawUri: string | null): URL | null {
|
||||||
|
if (!rawUri) return null;
|
||||||
|
try {
|
||||||
|
const url = new URL(rawUri);
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||||
|
if (isBlockedChangePasswordHost(url.hostname)) return null;
|
||||||
|
return url;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleChangePasswordUri(request: Request): Promise<Response> {
|
||||||
|
const sourceUrl = parsePublicHttpUrl(new URL(request.url).searchParams.get('uri'));
|
||||||
|
if (!sourceUrl) {
|
||||||
|
return jsonResponse({ uri: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const wellKnownUrl = new URL('/.well-known/change-password', sourceUrl.origin);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), ICON_UPSTREAM_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const response = await fetch(wellKnownUrl.toString(), {
|
||||||
|
method: 'GET',
|
||||||
|
redirect: 'manual',
|
||||||
|
signal: controller.signal,
|
||||||
|
cf: {
|
||||||
|
cacheEverything: true,
|
||||||
|
cacheTtl: LIMITS.cache.iconTtlSeconds,
|
||||||
|
},
|
||||||
|
} as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } });
|
||||||
|
|
||||||
|
if (response.status < 300 || response.status >= 400) {
|
||||||
|
return jsonResponse({ uri: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = response.headers.get('Location');
|
||||||
|
if (!location) return jsonResponse({ uri: null });
|
||||||
|
|
||||||
|
const targetUrl = parsePublicHttpUrl(new URL(location, wellKnownUrl).toString());
|
||||||
|
if (!targetUrl) return jsonResponse({ uri: null });
|
||||||
|
|
||||||
|
return jsonResponse({ uri: targetUrl.toString() });
|
||||||
|
} catch {
|
||||||
|
return jsonResponse({ uri: null });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildIconServiceBase(origin: string): string {
|
function buildIconServiceBase(origin: string): string {
|
||||||
return `${origin}/icons`;
|
return `${origin}/icons`;
|
||||||
}
|
}
|
||||||
@@ -104,6 +191,7 @@ function buildConfigResponse(origin: string) {
|
|||||||
_icon_service_url: buildIconServiceTemplate(origin),
|
_icon_service_url: buildIconServiceTemplate(origin),
|
||||||
_icon_service_csp: buildIconServiceCsp(origin),
|
_icon_service_csp: buildIconServiceCsp(origin),
|
||||||
featureStates: {
|
featureStates: {
|
||||||
|
'cipher-key-encryption': true,
|
||||||
'duo-redirect': true,
|
'duo-redirect': true,
|
||||||
'email-verification': true,
|
'email-verification': true,
|
||||||
'pm-19051-send-email-verification': false,
|
'pm-19051-send-email-verification': false,
|
||||||
@@ -116,7 +204,12 @@ function buildConfigResponse(origin: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeIconHost(rawHost: string): string | null {
|
function normalizeIconHost(rawHost: string): string | null {
|
||||||
const decoded = decodeURIComponent(String(rawHost || '').trim()).toLowerCase().replace(/\.+$/, '');
|
let decoded: string;
|
||||||
|
try {
|
||||||
|
decoded = decodeURIComponent(String(rawHost || '').trim()).toLowerCase().replace(/\.+$/, '');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (!decoded || decoded.includes('/') || decoded.includes('\\')) return null;
|
if (!decoded || decoded.includes('/') || decoded.includes('\\')) return null;
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(`https://${decoded}`);
|
const parsed = new URL(`https://${decoded}`);
|
||||||
@@ -126,58 +219,104 @@ function normalizeIconHost(rawHost: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleWebsiteIcon(host: string): Promise<Response> {
|
const ICON_UPSTREAM_TIMEOUT_MS = 2500;
|
||||||
|
const BITWARDEN_DEFAULT_GLOBE_ICON_BYTES = 500;
|
||||||
|
const BITWARDEN_DEFAULT_GLOBE_ICON_SHA256 = 'aaa64871332ad5b7d28fe8874efb19c2d9cc2f1e6de75d52b080b438225a0783';
|
||||||
|
|
||||||
|
type IconSource = {
|
||||||
|
url: string;
|
||||||
|
rejectImage?: {
|
||||||
|
byteLength: number;
|
||||||
|
sha256: string;
|
||||||
|
};
|
||||||
|
headers?: HeadersInit;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchIconSource(source: { url: string; headers?: HeadersInit }): Promise<Response> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), ICON_UPSTREAM_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
return await fetch(source.url, {
|
||||||
|
headers: source.headers,
|
||||||
|
redirect: 'follow',
|
||||||
|
signal: controller.signal,
|
||||||
|
cf: {
|
||||||
|
cacheEverything: true,
|
||||||
|
cacheTtl: LIMITS.cache.iconTtlSeconds,
|
||||||
|
},
|
||||||
|
} as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256Hex(bytes: ArrayBuffer): Promise<string> {
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||||
|
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconResponse(body: BodyInit | null, contentType: string | null): Response {
|
||||||
|
return new Response(body, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType || 'image/png',
|
||||||
|
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
|
||||||
const normalizedHost = normalizeIconHost(host);
|
const normalizedHost = normalizeIconHost(host);
|
||||||
if (!normalizedHost) return handleNwFavicon();
|
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||||
|
|
||||||
const encodedHost = encodeURIComponent(normalizedHost);
|
const encodedHost = encodeURIComponent(normalizedHost);
|
||||||
const requestHeaders = { 'User-Agent': 'NodeWarden/1.0' };
|
const requestHeaders = { 'User-Agent': 'NodeWarden/1.0' };
|
||||||
const upstreamSources: Array<{ url: string; headers?: HeadersInit }> = [
|
const upstreamSources: IconSource[] = [
|
||||||
|
{
|
||||||
|
url: `https://favicon.im/zh/${encodedHost}?larger=true&throw-error-on-404=true`,
|
||||||
|
headers: requestHeaders,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
url: `https://icons.bitwarden.net/${encodedHost}/icon.png`,
|
url: `https://icons.bitwarden.net/${encodedHost}/icon.png`,
|
||||||
headers: requestHeaders,
|
rejectImage: {
|
||||||
},
|
byteLength: BITWARDEN_DEFAULT_GLOBE_ICON_BYTES,
|
||||||
{
|
sha256: BITWARDEN_DEFAULT_GLOBE_ICON_SHA256,
|
||||||
url: `https://favicon.im/${encodedHost}`,
|
},
|
||||||
headers: requestHeaders,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: `https://icons.duckduckgo.com/ip3/${encodedHost}.ico`,
|
|
||||||
headers: requestHeaders,
|
headers: requestHeaders,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
try {
|
for (const source of upstreamSources) {
|
||||||
for (const source of upstreamSources) {
|
try {
|
||||||
const resp = await fetch(source.url, {
|
const resp = await fetchIconSource(source);
|
||||||
headers: source.headers,
|
|
||||||
redirect: 'follow',
|
|
||||||
cf: {
|
|
||||||
cacheEverything: true,
|
|
||||||
cacheTtl: LIMITS.cache.iconTtlSeconds,
|
|
||||||
},
|
|
||||||
} as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } });
|
|
||||||
|
|
||||||
if (!resp.ok) continue;
|
if (!resp.ok) continue;
|
||||||
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
|
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
|
||||||
if (!contentType.startsWith('image/')) continue;
|
if (!contentType.startsWith('image/')) continue;
|
||||||
|
|
||||||
return new Response(resp.body, {
|
if (!source.rejectImage) {
|
||||||
status: 200,
|
return iconResponse(resp.body, resp.headers.get('Content-Type'));
|
||||||
headers: {
|
}
|
||||||
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
|
|
||||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return handleNwFavicon();
|
const contentLength = Number(resp.headers.get('Content-Length') || '');
|
||||||
} catch {
|
if (Number.isFinite(contentLength) && contentLength > 0 && contentLength !== source.rejectImage.byteLength) {
|
||||||
return handleNwFavicon();
|
return iconResponse(resp.body, resp.headers.get('Content-Type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await resp.arrayBuffer();
|
||||||
|
if (bytes.byteLength === 0) continue;
|
||||||
|
if (bytes.byteLength === source.rejectImage.byteLength && (await sha256Hex(bytes)) === source.rejectImage.sha256) continue;
|
||||||
|
|
||||||
|
return iconResponse(bytes, resp.headers.get('Content-Type'));
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWebBootstrapResponse(env: Env): WebBootstrapResponse {
|
export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapResponse> {
|
||||||
const secret = (env.JWT_SECRET || '').trim();
|
const secret = (env.JWT_SECRET || '').trim();
|
||||||
const jwtUnsafeReason =
|
const jwtUnsafeReason =
|
||||||
!secret
|
!secret
|
||||||
@@ -187,11 +326,14 @@ export function buildWebBootstrapResponse(env: Env): WebBootstrapResponse {
|
|||||||
: secret.length < LIMITS.auth.jwtSecretMinLength
|
: secret.length < LIMITS.auth.jwtSecretMinLength
|
||||||
? 'too_short'
|
? 'too_short'
|
||||||
: null;
|
: null;
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const userCount = await storage.getUserCount();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
|
defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
|
||||||
jwtUnsafeReason,
|
jwtUnsafeReason,
|
||||||
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
||||||
|
registrationInviteRequired: userCount > 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,12 +357,19 @@ export async function handlePublicRoute(
|
|||||||
if ((path === '/api/web-bootstrap' || path === '/web-bootstrap') && method === 'GET') {
|
if ((path === '/api/web-bootstrap' || path === '/web-bootstrap') && method === 'GET') {
|
||||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||||
if (blocked) return blocked;
|
if (blocked) return blocked;
|
||||||
return jsonResponse(buildWebBootstrapResponse(env));
|
return jsonResponse(await buildWebBootstrapResponse(env));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/icons/change-password-uri' && method === 'GET') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handleChangePasswordUri(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
||||||
if (iconMatch && method === 'GET') {
|
if (iconMatch && method === 'GET') {
|
||||||
return handleWebsiteIcon(iconMatch[1]);
|
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
|
||||||
|
return handleWebsiteIcon(iconMatch[1], fallbackMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import type { Env } from '../types';
|
||||||
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
import { StorageService } from './storage';
|
||||||
|
|
||||||
|
export type AuditLogCategory = 'auth' | 'security' | 'device' | 'data' | 'system';
|
||||||
|
export type AuditLogLevel = 'info' | 'warn' | 'error' | 'security';
|
||||||
|
|
||||||
|
export interface AuditEventInput {
|
||||||
|
actorUserId?: string | null;
|
||||||
|
action: string;
|
||||||
|
category: AuditLogCategory;
|
||||||
|
level?: AuditLogLevel;
|
||||||
|
targetType?: string | null;
|
||||||
|
targetId?: string | null;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SENSITIVE_KEY_RE = /(token|secret|password|key|hash|code|private)/i;
|
||||||
|
const MAX_METADATA_BYTES = 2048;
|
||||||
|
const AUDIT_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||||
|
const AUDIT_CLEANUP_PROBABILITY = 0.02;
|
||||||
|
const AUDIT_LOG_SETTINGS_KEY = 'audit.logs.settings.v1';
|
||||||
|
const DEFAULT_AUDIT_LOG_SETTINGS: AuditLogSettings = {
|
||||||
|
retentionDays: 90,
|
||||||
|
maxEntries: null,
|
||||||
|
};
|
||||||
|
let lastAuditCleanupAt = 0;
|
||||||
|
|
||||||
|
export interface AuditLogSettings {
|
||||||
|
retentionDays: number | null;
|
||||||
|
maxEntries: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOWED_METADATA_KEYS = new Set([
|
||||||
|
'method',
|
||||||
|
'path',
|
||||||
|
'ip',
|
||||||
|
'userAgent',
|
||||||
|
'email',
|
||||||
|
'targetEmail',
|
||||||
|
'grantType',
|
||||||
|
'webSession',
|
||||||
|
'deviceIdentifier',
|
||||||
|
'deviceType',
|
||||||
|
'reason',
|
||||||
|
'status',
|
||||||
|
'verifyDevices',
|
||||||
|
'changed',
|
||||||
|
'removed',
|
||||||
|
'updated',
|
||||||
|
'deleted',
|
||||||
|
'removedTrusted',
|
||||||
|
'removedSessions',
|
||||||
|
'removedDevices',
|
||||||
|
'requested',
|
||||||
|
'count',
|
||||||
|
'requestedCount',
|
||||||
|
'type',
|
||||||
|
'folderId',
|
||||||
|
'cipherId',
|
||||||
|
'size',
|
||||||
|
'users',
|
||||||
|
'ciphers',
|
||||||
|
'attachments',
|
||||||
|
'skippedAttachments',
|
||||||
|
'skippedReason',
|
||||||
|
'replaceExisting',
|
||||||
|
'provider',
|
||||||
|
'fileName',
|
||||||
|
'fileBytes',
|
||||||
|
'bytes',
|
||||||
|
'compressedBytes',
|
||||||
|
'includesAttachments',
|
||||||
|
'destinationName',
|
||||||
|
'destinationId',
|
||||||
|
'destinationType',
|
||||||
|
'destinationCount',
|
||||||
|
'scheduledDestinationCount',
|
||||||
|
'retentionDays',
|
||||||
|
'maxEntries',
|
||||||
|
'remotePath',
|
||||||
|
'trigger',
|
||||||
|
'prunedFileCount',
|
||||||
|
'pruneError',
|
||||||
|
'uploadVerificationAttempts',
|
||||||
|
'error',
|
||||||
|
'expiresInHours',
|
||||||
|
'checksumMismatchAccepted',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function normalizePositiveInteger(value: unknown, allowed: readonly number[]): number | null {
|
||||||
|
if (value === null || value === 0 || value === '0' || value === 'forever' || value === 'unlimited') return null;
|
||||||
|
const parsed = Math.floor(Number(value));
|
||||||
|
return allowed.includes(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAuditLogSettings(value: unknown): AuditLogSettings {
|
||||||
|
const input = value && typeof value === 'object' ? value as Record<string, unknown> : {};
|
||||||
|
const retentionDays = normalizePositiveInteger(input.retentionDays, [7, 30, 90, 180, 365]);
|
||||||
|
const maxEntries = normalizePositiveInteger(input.maxEntries, [1_000, 5_000, 10_000, 50_000]);
|
||||||
|
|
||||||
|
if (retentionDays) return { retentionDays, maxEntries: null };
|
||||||
|
if (maxEntries) return { retentionDays: null, maxEntries };
|
||||||
|
if (input.retentionDays === null || input.retentionDays === 0 || input.retentionDays === '0') {
|
||||||
|
return { retentionDays: null, maxEntries: null };
|
||||||
|
}
|
||||||
|
if (input.maxEntries === null || input.maxEntries === 0 || input.maxEntries === '0') {
|
||||||
|
return { retentionDays: null, maxEntries: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...DEFAULT_AUDIT_LOG_SETTINGS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function auditRequestMetadata(request: Request): Record<string, unknown> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
return {
|
||||||
|
method: request.method,
|
||||||
|
path: url.pathname,
|
||||||
|
ip: request.headers.get('CF-Connecting-IP') || request.headers.get('X-Forwarded-For') || null,
|
||||||
|
userAgent: request.headers.get('User-Agent') || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeMetadata(metadata: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const clean: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(metadata)) {
|
||||||
|
if (!ALLOWED_METADATA_KEYS.has(key)) continue;
|
||||||
|
if (value === undefined || value === null || value === '') continue;
|
||||||
|
if (SENSITIVE_KEY_RE.test(key)) continue;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
clean[key] = value.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') continue;
|
||||||
|
clean[key] = value;
|
||||||
|
}
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuditLogSettings(storage: StorageService): Promise<AuditLogSettings> {
|
||||||
|
const raw = await storage.getConfigValue(AUDIT_LOG_SETTINGS_KEY);
|
||||||
|
if (!raw) return { ...DEFAULT_AUDIT_LOG_SETTINGS };
|
||||||
|
try {
|
||||||
|
return normalizeAuditLogSettings(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_AUDIT_LOG_SETTINGS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAuditLogSettings(storage: StorageService, settings: AuditLogSettings): Promise<AuditLogSettings> {
|
||||||
|
const normalized = normalizeAuditLogSettings(settings);
|
||||||
|
await storage.setConfigValue(AUDIT_LOG_SETTINGS_KEY, JSON.stringify(normalized));
|
||||||
|
await applyAuditLogRetention(storage, normalized);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyAuditLogRetention(storage: StorageService, settings?: AuditLogSettings): Promise<void> {
|
||||||
|
const current = settings || await getAuditLogSettings(storage);
|
||||||
|
if (current.retentionDays) {
|
||||||
|
const before = new Date(Date.now() - current.retentionDays * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
await storage.pruneAuditLogs(before);
|
||||||
|
}
|
||||||
|
if (current.maxEntries) {
|
||||||
|
await storage.pruneAuditLogsToMax(current.maxEntries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybePruneAuditLogs(storage: StorageService): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastAuditCleanupAt < AUDIT_CLEANUP_INTERVAL_MS) return;
|
||||||
|
if (Math.random() > AUDIT_CLEANUP_PROBABILITY) return;
|
||||||
|
lastAuditCleanupAt = now;
|
||||||
|
await applyAuditLogRetention(storage);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertAuditEvent(storage: StorageService, event: AuditEventInput): Promise<void> {
|
||||||
|
const metadata = sanitizeMetadata(event.metadata || {});
|
||||||
|
let metadataJson = JSON.stringify(metadata);
|
||||||
|
if (new TextEncoder().encode(metadataJson).byteLength > MAX_METADATA_BYTES) {
|
||||||
|
metadataJson = JSON.stringify({ truncated: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.createAuditLog({
|
||||||
|
id: generateUUID(),
|
||||||
|
actorUserId: event.actorUserId ?? null,
|
||||||
|
action: event.action,
|
||||||
|
category: event.category,
|
||||||
|
level: event.level || 'info',
|
||||||
|
targetType: event.targetType ?? null,
|
||||||
|
targetId: event.targetId ?? null,
|
||||||
|
metadata: metadataJson,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
await maybePruneAuditLogs(storage);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeAuditEvent(storage: StorageService, event: AuditEventInput): Promise<void> {
|
||||||
|
try {
|
||||||
|
await insertAuditEvent(storage, event);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('audit log write failed', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function safeWriteAuditEvent(env: Env, event: AuditEventInput): Promise<void> {
|
||||||
|
await writeAuditEvent(new StorageService(env.DB), event);
|
||||||
|
}
|
||||||
@@ -6,19 +6,131 @@ import { StorageService } from './storage';
|
|||||||
// The client already does heavy PBKDF2 (600k iterations).
|
// The client already does heavy PBKDF2 (600k iterations).
|
||||||
// This second layer only needs to be non-trivial, not expensive.
|
// This second layer only needs to be non-trivial, not expensive.
|
||||||
const SERVER_HASH_ITERATIONS = 100_000;
|
const SERVER_HASH_ITERATIONS = 100_000;
|
||||||
|
const AUTH_CONTEXT_CACHE_TTL_MS = 15 * 1000;
|
||||||
|
|
||||||
|
interface CachedUserEntry {
|
||||||
|
user: User | null;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CachedDeviceEntry {
|
||||||
|
device: Awaited<ReturnType<StorageService['getDevice']>>;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VerifiedAccessContext {
|
export interface VerifiedAccessContext {
|
||||||
payload: JWTPayload;
|
payload: JWTPayload;
|
||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RefreshAccessTokenFailureReason =
|
||||||
|
| 'token_not_found_or_expired'
|
||||||
|
| 'user_missing'
|
||||||
|
| 'user_inactive'
|
||||||
|
| 'device_missing'
|
||||||
|
| 'device_session_mismatch';
|
||||||
|
|
||||||
|
export type RefreshAccessTokenResult =
|
||||||
|
| { ok: true; accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null }
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
reason: RefreshAccessTokenFailureReason;
|
||||||
|
userId?: string | null;
|
||||||
|
deviceIdentifier?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private storage: StorageService;
|
private storage: StorageService;
|
||||||
|
private static userCache = new Map<string, CachedUserEntry>();
|
||||||
|
private static deviceCache = new Map<string, CachedDeviceEntry>();
|
||||||
|
|
||||||
constructor(private env: Env) {
|
constructor(private env: Env) {
|
||||||
this.storage = new StorageService(env.DB);
|
this.storage = new StorageService(env.DB);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static invalidateUserCache(userId: string): void {
|
||||||
|
const normalizedUserId = String(userId || '').trim();
|
||||||
|
if (!normalizedUserId) return;
|
||||||
|
AuthService.userCache.delete(normalizedUserId);
|
||||||
|
const prefix = `${normalizedUserId}:`;
|
||||||
|
for (const key of AuthService.deviceCache.keys()) {
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
AuthService.deviceCache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static invalidateDeviceCache(userId: string, deviceId: string): void {
|
||||||
|
const normalizedUserId = String(userId || '').trim();
|
||||||
|
const normalizedDeviceId = String(deviceId || '').trim();
|
||||||
|
if (!normalizedUserId || !normalizedDeviceId) return;
|
||||||
|
AuthService.deviceCache.delete(`${normalizedUserId}:${normalizedDeviceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readCachedUser(userId: string): User | null | undefined {
|
||||||
|
const cached = AuthService.userCache.get(userId);
|
||||||
|
if (!cached) return undefined;
|
||||||
|
if (cached.expiresAt <= Date.now()) {
|
||||||
|
AuthService.userCache.delete(userId);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return cached.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeCachedUser(userId: string, user: User | null): void {
|
||||||
|
AuthService.userCache.set(userId, {
|
||||||
|
user,
|
||||||
|
expiresAt: Date.now() + AUTH_CONTEXT_CACHE_TTL_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCachedUser(userId: string): Promise<User | null> {
|
||||||
|
const cached = this.readCachedUser(userId);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
const user = await this.storage.getUserById(userId);
|
||||||
|
this.writeCachedUser(userId, user);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getFreshUser(userId: string): Promise<User | null> {
|
||||||
|
const user = await this.storage.getUserById(userId);
|
||||||
|
this.writeCachedUser(userId, user);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readCachedDevice(userId: string, deviceId: string) {
|
||||||
|
const cacheKey = `${userId}:${deviceId}`;
|
||||||
|
const cached = AuthService.deviceCache.get(cacheKey);
|
||||||
|
if (!cached) return undefined;
|
||||||
|
if (cached.expiresAt <= Date.now()) {
|
||||||
|
AuthService.deviceCache.delete(cacheKey);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return cached.device;
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeCachedDevice(userId: string, deviceId: string, device: Awaited<ReturnType<StorageService['getDevice']>>): void {
|
||||||
|
const cacheKey = `${userId}:${deviceId}`;
|
||||||
|
AuthService.deviceCache.set(cacheKey, {
|
||||||
|
device,
|
||||||
|
expiresAt: Date.now() + AUTH_CONTEXT_CACHE_TTL_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCachedDevice(userId: string, deviceId: string) {
|
||||||
|
const cached = this.readCachedDevice(userId, deviceId);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
const device = await this.storage.getDevice(userId, deviceId);
|
||||||
|
this.writeCachedDevice(userId, deviceId, device);
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getFreshDevice(userId: string, deviceId: string) {
|
||||||
|
const device = await this.storage.getDevice(userId, deviceId);
|
||||||
|
this.writeCachedDevice(userId, deviceId, device);
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
|
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
|
||||||
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
|
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
|
||||||
// Result is prefixed with "$s$" to distinguish from legacy raw client hashes.
|
// Result is prefixed with "$s$" to distinguish from legacy raw client hashes.
|
||||||
@@ -97,15 +209,22 @@ export class AuthService {
|
|||||||
const payload = await verifyJWT(parts[1], this.env.JWT_SECRET);
|
const payload = await verifyJWT(parts[1], this.env.JWT_SECRET);
|
||||||
if (!payload) return null;
|
if (!payload) return null;
|
||||||
|
|
||||||
const user = await this.storage.getUserById(payload.sub);
|
let user = await this.getCachedUser(payload.sub);
|
||||||
|
if (!user || user.status !== 'active' || payload.sstamp !== user.securityStamp) {
|
||||||
|
user = await this.getFreshUser(payload.sub);
|
||||||
|
}
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
if (user.status !== 'active') return null;
|
||||||
|
|
||||||
if (payload.sstamp !== user.securityStamp) {
|
if (payload.sstamp !== user.securityStamp) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.did) {
|
if (payload.did) {
|
||||||
const device = await this.storage.getDevice(user.id, payload.did);
|
let device = await this.getCachedDevice(user.id, payload.did);
|
||||||
|
if (!device || !payload.dstamp || payload.dstamp !== device.sessionStamp) {
|
||||||
|
device = await this.getFreshDevice(user.id, payload.did);
|
||||||
|
}
|
||||||
if (!device) return null;
|
if (!device) return null;
|
||||||
if (!payload.dstamp || payload.dstamp !== device.sessionStamp) return null;
|
if (!payload.dstamp || payload.dstamp !== device.sessionStamp) return null;
|
||||||
}
|
}
|
||||||
@@ -120,17 +239,18 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Refresh access token
|
// Refresh access token
|
||||||
async refreshAccessToken(
|
async refreshAccessTokenDetailed(refreshToken: string): Promise<RefreshAccessTokenResult> {
|
||||||
refreshToken: string
|
|
||||||
): Promise<{ accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } | null> {
|
|
||||||
const record = await this.storage.getRefreshTokenRecord(refreshToken);
|
const record = await this.storage.getRefreshTokenRecord(refreshToken);
|
||||||
if (!record?.userId) return null;
|
if (!record?.userId) return { ok: false, reason: 'token_not_found_or_expired' };
|
||||||
|
|
||||||
const user = await this.storage.getUserById(record.userId);
|
const user = await this.storage.getUserById(record.userId);
|
||||||
if (!user) return null;
|
if (!user) {
|
||||||
|
await this.storage.deleteRefreshToken(refreshToken);
|
||||||
|
return { ok: false, reason: 'user_missing', userId: record.userId, deviceIdentifier: record.deviceIdentifier };
|
||||||
|
}
|
||||||
if (user.status !== 'active') {
|
if (user.status !== 'active') {
|
||||||
await this.storage.deleteRefreshToken(refreshToken);
|
await this.storage.deleteRefreshToken(refreshToken);
|
||||||
return null;
|
return { ok: false, reason: 'user_inactive', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
||||||
}
|
}
|
||||||
|
|
||||||
let device: { identifier: string; sessionStamp: string } | null = null;
|
let device: { identifier: string; sessionStamp: string } | null = null;
|
||||||
@@ -138,16 +258,23 @@ export class AuthService {
|
|||||||
const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier);
|
const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier);
|
||||||
if (!boundDevice) {
|
if (!boundDevice) {
|
||||||
await this.storage.deleteRefreshToken(refreshToken);
|
await this.storage.deleteRefreshToken(refreshToken);
|
||||||
return null;
|
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
||||||
}
|
}
|
||||||
if (!record.deviceSessionStamp || boundDevice.sessionStamp !== record.deviceSessionStamp) {
|
if (!record.deviceSessionStamp || boundDevice.sessionStamp !== record.deviceSessionStamp) {
|
||||||
await this.storage.deleteRefreshToken(refreshToken);
|
await this.storage.deleteRefreshToken(refreshToken);
|
||||||
return null;
|
return { ok: false, reason: 'device_session_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
||||||
}
|
}
|
||||||
device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp };
|
device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp };
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessToken = await this.generateAccessToken(user, device);
|
const accessToken = await this.generateAccessToken(user, device);
|
||||||
return { accessToken, user, device };
|
return { ok: true, accessToken, user, device };
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshAccessToken(
|
||||||
|
refreshToken: string
|
||||||
|
): Promise<{ accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } | null> {
|
||||||
|
const result = await this.refreshAccessTokenDetailed(refreshToken);
|
||||||
|
return result.ok ? result : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
import { zipSync, unzipSync } from 'fflate';
|
import { zipSync, unzipSync } from 'fflate';
|
||||||
import type { Env } from '../types';
|
import type { Env } from '../types';
|
||||||
import { APP_VERSION } from '../../shared/app-version';
|
import { APP_VERSION } from '../../shared/app-version';
|
||||||
|
import { BACKUP_SETTINGS_CONFIG_KEY } from './backup-config';
|
||||||
|
import { exportPortableBackupSettingsEnvelope } from './backup-settings-crypto';
|
||||||
import {
|
import {
|
||||||
getAttachmentObjectKey,
|
getAttachmentObjectKey,
|
||||||
getBlobStorageKind,
|
getBlobStorageKind,
|
||||||
} from './blob-store';
|
} from './blob-store';
|
||||||
|
|
||||||
|
// CONTRACT:
|
||||||
|
// This file defines the exported instance-backup archive shape. Keep it in lock
|
||||||
|
// step with src/services/backup-import.ts and webapp/src/lib/api/backup.ts.
|
||||||
|
//
|
||||||
|
// WHEN CHANGING THIS:
|
||||||
|
// - Add persistent tables to BackupPayload, export SQL, manifest tableCounts,
|
||||||
|
// and validateBackupPayloadContents().
|
||||||
|
// - Keep secrets and transient runtime rows sanitized before writing db.json.
|
||||||
|
// - users.api_key is intentionally not exported.
|
||||||
|
// - backup.settings.v1 is exported as portable-only; the current server runtime
|
||||||
|
// envelope must not leave the instance.
|
||||||
type SqlRow = Record<string, string | number | null>;
|
type SqlRow = Record<string, string | number | null>;
|
||||||
|
|
||||||
const BACKUP_FORMAT_VERSION = 1;
|
const BACKUP_FORMAT_VERSION = 1;
|
||||||
|
const BACKUP_RUNNER_LOCK_CONFIG_KEY = 'backup.runner.lock.v1';
|
||||||
|
const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
|
||||||
// Worker-side backup export must stay well below Cloudflare CPU limits.
|
// Worker-side backup export must stay well below Cloudflare CPU limits.
|
||||||
// Prefer store-only ZIP entries over heavier compression to keep exports reliable.
|
// Prefer store-only ZIP entries over heavier compression to keep exports reliable.
|
||||||
const BACKUP_TEXT_COMPRESSION_LEVEL = 0;
|
const BACKUP_TEXT_COMPRESSION_LEVEL = 0;
|
||||||
@@ -47,6 +62,7 @@ export interface BackupPayload {
|
|||||||
db: {
|
db: {
|
||||||
config: SqlRow[];
|
config: SqlRow[];
|
||||||
users: SqlRow[];
|
users: SqlRow[];
|
||||||
|
domain_settings: SqlRow[];
|
||||||
user_revisions: SqlRow[];
|
user_revisions: SqlRow[];
|
||||||
folders: SqlRow[];
|
folders: SqlRow[];
|
||||||
ciphers: SqlRow[];
|
ciphers: SqlRow[];
|
||||||
@@ -60,25 +76,106 @@ export interface BackupArchiveBundle {
|
|||||||
manifest: BackupManifest;
|
manifest: BackupManifest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BackupFileIntegrityCheckResult {
|
||||||
|
hasChecksumPrefix: boolean;
|
||||||
|
expectedPrefix: string | null;
|
||||||
|
actualPrefix: string;
|
||||||
|
matches: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BuildBackupArchiveOptions {
|
export interface BuildBackupArchiveOptions {
|
||||||
includeAttachments?: boolean;
|
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[]> {
|
async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Promise<SqlRow[]> {
|
||||||
const result = await db.prepare(sql).bind(...values).all<SqlRow>();
|
const result = await db.prepare(sql).bind(...values).all<SqlRow>();
|
||||||
return (result.results || []).map((row) => ({ ...row }));
|
return (result.results || []).map((row) => ({ ...row }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBackupFileName(date: Date = new Date()): string {
|
function sanitizeConfigRowsForExport(rows: SqlRow[]): SqlRow[] {
|
||||||
const parts = [
|
const sanitized: SqlRow[] = [];
|
||||||
date.getUTCFullYear().toString().padStart(4, '0'),
|
for (const row of rows) {
|
||||||
(date.getUTCMonth() + 1).toString().padStart(2, '0'),
|
const key = String(row.key || '').trim();
|
||||||
date.getUTCDate().toString().padStart(2, '0'),
|
if (!key || key === BACKUP_RUNNER_LOCK_CONFIG_KEY) continue;
|
||||||
date.getUTCHours().toString().padStart(2, '0'),
|
|
||||||
date.getUTCMinutes().toString().padStart(2, '0'),
|
if (key === BACKUP_SETTINGS_CONFIG_KEY) {
|
||||||
date.getUTCSeconds().toString().padStart(2, '0'),
|
const portableOnly = exportPortableBackupSettingsEnvelope(typeof row.value === 'string' ? row.value : null);
|
||||||
];
|
if (portableOnly) sanitized.push({ ...row, value: portableOnly });
|
||||||
return `nodewarden_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}.zip`;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitized.push({ ...row });
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
function validateArchiveSize(bytes: Uint8Array): void {
|
||||||
@@ -199,6 +296,7 @@ export function validateBackupPayloadContents(
|
|||||||
const configRows = ensureRowArray(payload.db.config, 'config');
|
const configRows = ensureRowArray(payload.db.config, 'config');
|
||||||
const userRows = ensureRowArray(payload.db.users, 'users');
|
const userRows = ensureRowArray(payload.db.users, 'users');
|
||||||
const revisionRows = ensureRowArray(payload.db.user_revisions, 'user_revisions');
|
const revisionRows = ensureRowArray(payload.db.user_revisions, 'user_revisions');
|
||||||
|
const domainSettingsRows = ensureRowArray(payload.db.domain_settings || [], 'domain_settings');
|
||||||
const folderRows = ensureRowArray(payload.db.folders, 'folders');
|
const folderRows = ensureRowArray(payload.db.folders, 'folders');
|
||||||
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
|
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
|
||||||
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
|
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
|
||||||
@@ -229,6 +327,18 @@ export function validateBackupPayloadContents(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const domainSettingUserIds = new Set<string>();
|
||||||
|
for (const row of domainSettingsRows) {
|
||||||
|
const userId = String(row.user_id || '').trim();
|
||||||
|
if (!userId || !userIds.has(userId)) {
|
||||||
|
throw new Error(`Backup archive contains domain settings for an unknown user: ${userId || '(empty)'}`);
|
||||||
|
}
|
||||||
|
if (domainSettingUserIds.has(userId)) {
|
||||||
|
throw new Error(`Backup archive contains duplicate domain settings for user: ${userId}`);
|
||||||
|
}
|
||||||
|
domainSettingUserIds.add(userId);
|
||||||
|
}
|
||||||
|
|
||||||
const folderIds = new Set<string>();
|
const folderIds = new Set<string>();
|
||||||
for (const row of folderRows) {
|
for (const row of folderRows) {
|
||||||
const id = String(row.id || '').trim();
|
const id = String(row.id || '').trim();
|
||||||
@@ -269,16 +379,27 @@ export async function buildBackupArchive(
|
|||||||
date: Date = new Date(),
|
date: Date = new Date(),
|
||||||
options: BuildBackupArchiveOptions = {}
|
options: BuildBackupArchiveOptions = {}
|
||||||
): Promise<BackupArchiveBundle> {
|
): 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 encoder = new TextEncoder();
|
||||||
const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows] = await Promise.all([
|
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows] = await Promise.all([
|
||||||
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
|
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, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'),
|
||||||
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id 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, 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'),
|
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 exportedConfigRows = sanitizeConfigRowsForExport(configRows);
|
||||||
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
|
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
|
||||||
const attachmentBlobs: BackupManifestAttachmentBlob[] = exportedAttachmentRows.map((row) => {
|
const attachmentBlobs: BackupManifestAttachmentBlob[] = exportedAttachmentRows.map((row) => {
|
||||||
const cipherId = String(row.cipher_id || '').trim();
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
@@ -297,8 +418,9 @@ export async function buildBackupArchive(
|
|||||||
appVersion: APP_VERSION,
|
appVersion: APP_VERSION,
|
||||||
storageKind: getBlobStorageKind(env),
|
storageKind: getBlobStorageKind(env),
|
||||||
tableCounts: {
|
tableCounts: {
|
||||||
config: configRows.length,
|
config: exportedConfigRows.length,
|
||||||
users: userRows.length,
|
users: userRows.length,
|
||||||
|
domain_settings: domainSettingsRows.length,
|
||||||
user_revisions: revisionRows.length,
|
user_revisions: revisionRows.length,
|
||||||
folders: folderRows.length,
|
folders: folderRows.length,
|
||||||
ciphers: cipherRows.length,
|
ciphers: cipherRows.length,
|
||||||
@@ -318,8 +440,9 @@ export async function buildBackupArchive(
|
|||||||
const files: Record<string, Uint8Array> = {
|
const files: Record<string, Uint8Array> = {
|
||||||
'manifest.json': encoder.encode(JSON.stringify(manifestBase, null, BACKUP_JSON_INDENT)),
|
'manifest.json': encoder.encode(JSON.stringify(manifestBase, null, BACKUP_JSON_INDENT)),
|
||||||
'db.json': encoder.encode(JSON.stringify({
|
'db.json': encoder.encode(JSON.stringify({
|
||||||
config: configRows,
|
config: exportedConfigRows,
|
||||||
users: userRows,
|
users: userRows,
|
||||||
|
domain_settings: domainSettingsRows,
|
||||||
user_revisions: revisionRows,
|
user_revisions: revisionRows,
|
||||||
folders: folderRows,
|
folders: folderRows,
|
||||||
ciphers: cipherRows,
|
ciphers: cipherRows,
|
||||||
@@ -327,9 +450,30 @@ export async function buildBackupArchive(
|
|||||||
}, null, BACKUP_JSON_INDENT)),
|
}, 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 {
|
return {
|
||||||
bytes: zipSync(createZipEntries(files)),
|
bytes,
|
||||||
fileName: buildBackupFileName(date),
|
fileName,
|
||||||
manifest: manifestBase,
|
manifest: manifestBase,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Env } from '../types';
|
import type { Env, User } from '../types';
|
||||||
import { StorageService } from './storage';
|
import { StorageService } from './storage';
|
||||||
import {
|
import {
|
||||||
type BackupSettingsPortableEnvelope,
|
type BackupSettingsPortableEnvelope,
|
||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
type BackupRuntimeState,
|
type BackupRuntimeState,
|
||||||
type BackupScheduleConfig,
|
type BackupScheduleConfig,
|
||||||
type BackupSettings,
|
type BackupSettings,
|
||||||
type E3BackupDestination,
|
type S3BackupDestination,
|
||||||
type WebDavBackupDestination,
|
type WebDavBackupDestination,
|
||||||
createBackupRandomId,
|
createBackupRandomId,
|
||||||
createDefaultBackupDestinationName,
|
createDefaultBackupDestinationName,
|
||||||
@@ -35,7 +35,7 @@ export type {
|
|||||||
BackupRuntimeState,
|
BackupRuntimeState,
|
||||||
BackupScheduleConfig,
|
BackupScheduleConfig,
|
||||||
BackupSettings,
|
BackupSettings,
|
||||||
E3BackupDestination,
|
S3BackupDestination,
|
||||||
WebDavBackupDestination,
|
WebDavBackupDestination,
|
||||||
} from '../../shared/backup-schema';
|
} from '../../shared/backup-schema';
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ function normalizeStartTime(value: unknown, fallback: string = BACKUP_DEFAULT_ST
|
|||||||
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeE3Destination(value: unknown, allowIncomplete = false): E3BackupDestination {
|
function normalizeS3Destination(value: unknown, allowIncomplete = false): S3BackupDestination {
|
||||||
const source = isPlainObject(value) ? value : {};
|
const source = isPlainObject(value) ? value : {};
|
||||||
const endpoint = asTrimmedString(source.endpoint);
|
const endpoint = asTrimmedString(source.endpoint);
|
||||||
const bucket = asTrimmedString(source.bucket);
|
const bucket = asTrimmedString(source.bucket);
|
||||||
@@ -115,17 +115,17 @@ function normalizeE3Destination(value: unknown, allowIncomplete = false): E3Back
|
|||||||
const rootPath = normalizePath(source.rootPath);
|
const rootPath = normalizePath(source.rootPath);
|
||||||
|
|
||||||
if (!allowIncomplete || endpoint) {
|
if (!allowIncomplete || endpoint) {
|
||||||
if (!endpoint) throw new Error('E3 endpoint is required');
|
if (!endpoint) throw new Error('S3 endpoint is required');
|
||||||
if (!/^https?:\/\//i.test(endpoint)) throw new Error('E3 endpoint must start with http:// or https://');
|
if (!/^https?:\/\//i.test(endpoint)) throw new Error('S3 endpoint must start with http:// or https://');
|
||||||
}
|
}
|
||||||
if (!allowIncomplete || bucket) {
|
if (!allowIncomplete || bucket) {
|
||||||
if (!bucket) throw new Error('E3 bucket is required');
|
if (!bucket) throw new Error('S3 bucket is required');
|
||||||
}
|
}
|
||||||
if (!allowIncomplete || accessKeyId) {
|
if (!allowIncomplete || accessKeyId) {
|
||||||
if (!accessKeyId) throw new Error('E3 access key is required');
|
if (!accessKeyId) throw new Error('S3 access key is required');
|
||||||
}
|
}
|
||||||
if (!allowIncomplete || secretAccessKey) {
|
if (!allowIncomplete || secretAccessKey) {
|
||||||
if (!secretAccessKey) throw new Error('E3 secret key is required');
|
if (!secretAccessKey) throw new Error('S3 secret key is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -169,7 +169,7 @@ function normalizeDestination(
|
|||||||
destination: unknown,
|
destination: unknown,
|
||||||
allowIncomplete = false
|
allowIncomplete = false
|
||||||
): BackupDestinationConfig {
|
): BackupDestinationConfig {
|
||||||
if (destinationType === 'e3') return normalizeE3Destination(destination, allowIncomplete);
|
if (destinationType === 's3') return normalizeS3Destination(destination, allowIncomplete);
|
||||||
return normalizeWebDavDestination(destination, allowIncomplete);
|
return normalizeWebDavDestination(destination, allowIncomplete);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +204,8 @@ function defaultDestinationName(type: BackupDestinationType, index: number): str
|
|||||||
|
|
||||||
function getDestinationType(raw: unknown): BackupDestinationType {
|
function getDestinationType(raw: unknown): BackupDestinationType {
|
||||||
const value = asTrimmedString(raw);
|
const value = asTrimmedString(raw);
|
||||||
if (value === 'e3' || value === 'webdav') return value;
|
if (value === 'e3') return 's3';
|
||||||
|
if (value === 's3' || value === 'webdav') return value;
|
||||||
throw new Error('Backup destination type is invalid');
|
throw new Error('Backup destination type is invalid');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,8 +267,8 @@ function parseLegacyBackupSettings(rawValue: Record<string, unknown>, fallbackTi
|
|||||||
: BACKUP_DEFAULT_INTERVAL_HOURS;
|
: BACKUP_DEFAULT_INTERVAL_HOURS;
|
||||||
const destinationTypeRaw = asTrimmedString(rawValue.destinationType);
|
const destinationTypeRaw = asTrimmedString(rawValue.destinationType);
|
||||||
const destinationType: BackupDestinationType =
|
const destinationType: BackupDestinationType =
|
||||||
destinationTypeRaw === 'e3' || destinationTypeRaw === 'webdav'
|
destinationTypeRaw === 'e3' || destinationTypeRaw === 's3' || destinationTypeRaw === 'webdav'
|
||||||
? destinationTypeRaw
|
? getDestinationType(destinationTypeRaw)
|
||||||
: 'webdav';
|
: 'webdav';
|
||||||
const destination = {
|
const destination = {
|
||||||
id: createBackupRandomId(),
|
id: createBackupRandomId(),
|
||||||
@@ -422,20 +423,45 @@ export async function saveBackupSettings(storage: StorageService, env: Env, sett
|
|||||||
export async function normalizeImportedBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<void> {
|
export async function normalizeImportedBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<void> {
|
||||||
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
|
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
|
||||||
if (!raw) return;
|
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);
|
const envelope = parseBackupSettingsEnvelope(raw);
|
||||||
if (envelope) {
|
if (envelope) {
|
||||||
try {
|
try {
|
||||||
const decrypted = await decryptBackupSettingsRuntime(raw, env);
|
const decrypted = await decryptBackupSettingsRuntime(raw, env);
|
||||||
const settings = parseBackupSettings(decrypted, fallbackTimezone);
|
const settings = parseBackupSettings(decrypted, fallbackTimezone);
|
||||||
await saveBackupSettings(storage, env, settings);
|
const hasPortableAdmins = users.some(
|
||||||
return;
|
(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 {
|
} catch {
|
||||||
// Keep imported portable recovery data intact until an admin signs in and repairs it.
|
// Keep imported portable recovery data intact until an admin signs in and repairs it.
|
||||||
return;
|
return raw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const settings = parseBackupSettings(raw, fallbackTimezone);
|
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> {
|
export async function getBackupSettingsRepairState(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettingsRepairState> {
|
||||||
@@ -573,6 +599,50 @@ function getBackupSlotStartsForLocalDay(
|
|||||||
return slots;
|
return slots;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hasBackupSlotBetween(
|
||||||
|
destination: BackupDestinationRecord,
|
||||||
|
startInclusive: Date,
|
||||||
|
endExclusive: Date
|
||||||
|
): boolean {
|
||||||
|
if (!destination.schedule.enabled) return false;
|
||||||
|
const startMs = startInclusive.getTime();
|
||||||
|
const endMs = endExclusive.getTime();
|
||||||
|
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return false;
|
||||||
|
|
||||||
|
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
|
||||||
|
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime())
|
||||||
|
? lastAttemptAt.getTime()
|
||||||
|
: Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
const dayCursor = new Date(startMs);
|
||||||
|
dayCursor.setUTCHours(0, 0, 0, 0);
|
||||||
|
const endDay = new Date(endMs);
|
||||||
|
endDay.setUTCHours(0, 0, 0, 0);
|
||||||
|
const checkedLocalDateKeys = new Set<string>();
|
||||||
|
|
||||||
|
while (dayCursor.getTime() <= endDay.getTime() + 24 * 60 * 60 * 1000) {
|
||||||
|
const localDateKey = getBackupLocalDateKey(dayCursor, destination.schedule.timezone);
|
||||||
|
if (!checkedLocalDateKeys.has(localDateKey)) {
|
||||||
|
checkedLocalDateKeys.add(localDateKey);
|
||||||
|
const slotStarts = getBackupSlotStartsForLocalDay(
|
||||||
|
localDateKey,
|
||||||
|
destination.schedule.timezone,
|
||||||
|
destination.schedule.startTime,
|
||||||
|
destination.schedule.intervalHours
|
||||||
|
);
|
||||||
|
for (const slotStart of slotStarts) {
|
||||||
|
const slotStartMs = slotStart.getTime();
|
||||||
|
if (slotStartMs < startMs || slotStartMs >= endMs) continue;
|
||||||
|
if (lastAttemptMs >= slotStartMs) continue;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dayCursor.setUTCDate(dayCursor.getUTCDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export function isBackupDueNow(
|
export function isBackupDueNow(
|
||||||
destination: BackupDestinationRecord,
|
destination: BackupDestinationRecord,
|
||||||
now: Date,
|
now: Date,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { Env } from '../types';
|
import type { Env, User } from '../types';
|
||||||
import { StorageService } from './storage';
|
|
||||||
import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobStorageKind, putBlobObject } from './blob-store';
|
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 {
|
import {
|
||||||
type BackupManifestAttachmentBlob,
|
type BackupManifestAttachmentBlob,
|
||||||
type BackupPayload,
|
type BackupPayload,
|
||||||
@@ -9,13 +8,46 @@ import {
|
|||||||
validateBackupPayloadContents,
|
validateBackupPayloadContents,
|
||||||
} from './backup-archive';
|
} from './backup-archive';
|
||||||
|
|
||||||
|
// CONTRACT:
|
||||||
|
// Restore is intentionally whitelist-based. Old backups may contain retired
|
||||||
|
// fields, but only the columns listed here are imported. Keep this file in sync
|
||||||
|
// with src/services/backup-archive.ts whenever backup contents change.
|
||||||
|
//
|
||||||
|
// WHEN CHANGING THIS:
|
||||||
|
// - Update BackupTableName, BACKUP_TABLES, reset statements, prepared payloads,
|
||||||
|
// shadow-table count validation, insert column lists, and frontend import
|
||||||
|
// count types together.
|
||||||
|
// - Do not import users.api_key, even if an older backup contains it.
|
||||||
type SqlRow = Record<string, string | number | null>;
|
type SqlRow = Record<string, string | number | null>;
|
||||||
|
type BackupTableName =
|
||||||
|
| 'config'
|
||||||
|
| 'users'
|
||||||
|
| 'domain_settings'
|
||||||
|
| 'user_revisions'
|
||||||
|
| 'folders'
|
||||||
|
| 'ciphers'
|
||||||
|
| 'attachments';
|
||||||
|
|
||||||
|
const BACKUP_TABLES: BackupTableName[] = [
|
||||||
|
'config',
|
||||||
|
'users',
|
||||||
|
'domain_settings',
|
||||||
|
'user_revisions',
|
||||||
|
'folders',
|
||||||
|
'ciphers',
|
||||||
|
'attachments',
|
||||||
|
];
|
||||||
|
|
||||||
|
function shadowTableName(table: BackupTableName): string {
|
||||||
|
return `${table}__restore`;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BackupImportResultBody {
|
export interface BackupImportResultBody {
|
||||||
object: 'instance-backup-import';
|
object: 'instance-backup-import';
|
||||||
imported: {
|
imported: {
|
||||||
config: number;
|
config: number;
|
||||||
users: number;
|
users: number;
|
||||||
|
domainSettings: number;
|
||||||
userRevisions: number;
|
userRevisions: number;
|
||||||
folders: number;
|
folders: number;
|
||||||
ciphers: number;
|
ciphers: number;
|
||||||
@@ -43,6 +75,81 @@ async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Pro
|
|||||||
return (response.results || []).map((row) => ({ ...row }));
|
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> {
|
async function ensureImportTargetIsFresh(db: D1Database): Promise<void> {
|
||||||
const counts = await Promise.all([
|
const counts = await Promise.all([
|
||||||
db.prepare('SELECT COUNT(*) AS count FROM ciphers').first<{ count: number }>(),
|
db.prepare('SELECT COUNT(*) AS count FROM ciphers').first<{ count: number }>(),
|
||||||
@@ -61,18 +168,10 @@ function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[]
|
|||||||
'DELETE FROM attachments',
|
'DELETE FROM attachments',
|
||||||
'DELETE FROM ciphers',
|
'DELETE FROM ciphers',
|
||||||
'DELETE FROM folders',
|
'DELETE FROM folders',
|
||||||
'DELETE FROM sends',
|
'DELETE FROM domain_settings',
|
||||||
'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 user_revisions',
|
||||||
'DELETE FROM users',
|
'DELETE FROM users',
|
||||||
'DELETE FROM config',
|
'DELETE FROM config',
|
||||||
'DELETE FROM login_attempts_ip',
|
|
||||||
'DELETE FROM api_rate_limits',
|
|
||||||
'DELETE FROM used_attachment_download_tokens',
|
|
||||||
].map((sql) => db.prepare(sql));
|
].map((sql) => db.prepare(sql));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,10 +218,91 @@ interface AttachmentRestoreResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface RemoteAttachmentSource {
|
interface RemoteAttachmentSource {
|
||||||
hasAttachment(blobName: string): Promise<boolean>;
|
|
||||||
loadAttachment(blobName: string): Promise<Uint8Array | null>;
|
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,
|
||||||
|
})),
|
||||||
|
domain_settings: cloneRows(payload.domain_settings || []),
|
||||||
|
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 {
|
function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files: Record<string, Uint8Array>): PreparedBackupImportPayload {
|
||||||
const storageKind = getBlobStorageKind(env);
|
const storageKind = getBlobStorageKind(env);
|
||||||
if (storageKind === 'r2') {
|
if (storageKind === 'r2') {
|
||||||
@@ -147,7 +327,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files:
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
const result = {
|
||||||
payload: {
|
payload: {
|
||||||
...payload,
|
...payload,
|
||||||
db: {
|
db: {
|
||||||
@@ -161,6 +341,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files:
|
|||||||
items: skippedItems,
|
items: skippedItems,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const oversizedAttachmentPaths = new Set<string>();
|
const oversizedAttachmentPaths = new Set<string>();
|
||||||
@@ -197,7 +378,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files:
|
|||||||
throw new Error('Backup restore requires ATTACHMENTS_KV when using KV blob storage');
|
throw new Error('Backup restore requires ATTACHMENTS_KV when using KV blob storage');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const result = {
|
||||||
payload: nextPayload,
|
payload: nextPayload,
|
||||||
skipped: {
|
skipped: {
|
||||||
reason: skippedItems.length ? KV_BLOB_SKIP_REASON : null,
|
reason: skippedItems.length ? KV_BLOB_SKIP_REASON : null,
|
||||||
@@ -205,6 +386,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files:
|
|||||||
items: skippedItems,
|
items: skippedItems,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildInsertStatements(db: D1Database, table: string, columns: string[], rows: SqlRow[], upsert = false): D1PreparedStatement[] {
|
function buildInsertStatements(db: D1Database, table: string, columns: string[], rows: SqlRow[], upsert = false): D1PreparedStatement[] {
|
||||||
@@ -214,6 +396,16 @@ function buildInsertStatements(db: D1Database, table: string, columns: string[],
|
|||||||
return rows.map((row) => db.prepare(sql).bind(...columns.map((column) => row[column] ?? null)));
|
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> {
|
async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record<string, Uint8Array>): Promise<AttachmentRestoreResult> {
|
||||||
const restoredAttachments: SqlRow[] = [];
|
const restoredAttachments: SqlRow[] = [];
|
||||||
const skippedItems: BackupImportSkipSummary['items'] = [];
|
const skippedItems: BackupImportSkipSummary['items'] = [];
|
||||||
@@ -300,14 +492,10 @@ async function prepareRemoteAttachmentPayload(
|
|||||||
skippedItems.push({ kind: 'attachment', path, sizeBytes });
|
skippedItems.push({ kind: 'attachment', path, sizeBytes });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!(await source.hasAttachment(ref.blobName))) {
|
|
||||||
skippedItems.push({ kind: 'attachment', path, sizeBytes });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
nextAttachments.push(row);
|
nextAttachments.push(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const result = {
|
||||||
payload: {
|
payload: {
|
||||||
...payload,
|
...payload,
|
||||||
db: {
|
db: {
|
||||||
@@ -321,16 +509,18 @@ async function prepareRemoteAttachmentPayload(
|
|||||||
items: skippedItems,
|
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;
|
if (!attachmentRows.length) return;
|
||||||
|
const tableName = useShadowTable ? shadowTableName('attachments') : 'attachments';
|
||||||
const statements = attachmentRows
|
const statements = attachmentRows
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
const attachmentId = String(row.id || '').trim();
|
const attachmentId = String(row.id || '').trim();
|
||||||
const cipherId = String(row.cipher_id || '').trim();
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
if (!attachmentId || !cipherId) return null;
|
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);
|
.filter((statement): statement is D1PreparedStatement => !!statement);
|
||||||
if (!statements.length) return;
|
if (!statements.length) return;
|
||||||
@@ -406,36 +596,69 @@ async function cleanupOrphanedBlobFiles(env: Env, beforeKeys: Set<string>, after
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importBackupRows(db: D1Database, payload: BackupPayload['db']): Promise<void> {
|
async function importBackupRows(db: D1Database, payload: BackupPayload['db'], useShadowTables: boolean = false): Promise<void> {
|
||||||
const statements: D1PreparedStatement[] = [
|
const tableName = (table: BackupTableName): string => (useShadowTables ? shadowTableName(table) : table);
|
||||||
...buildResetImportTargetStatements(db),
|
await runInsertBatch(
|
||||||
...buildInsertStatements(db, 'config', ['key', 'value'], payload.config || [], true),
|
db,
|
||||||
...buildInsertStatements(
|
tableName('config'),
|
||||||
|
buildInsertStatements(db, tableName('config'), ['key', 'value'], payload.config || [], true)
|
||||||
|
);
|
||||||
|
await runInsertBatch(
|
||||||
|
db,
|
||||||
|
tableName('users'),
|
||||||
|
buildInsertStatements(
|
||||||
db,
|
db,
|
||||||
'users',
|
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', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'],
|
['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 || []
|
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 || []),
|
await runInsertBatch(
|
||||||
...buildInsertStatements(
|
db,
|
||||||
|
tableName('user_revisions'),
|
||||||
|
buildInsertStatements(db, tableName('user_revisions'), ['user_id', 'revision_date'], payload.user_revisions || [], true)
|
||||||
|
);
|
||||||
|
await runInsertBatch(
|
||||||
|
db,
|
||||||
|
tableName('domain_settings'),
|
||||||
|
buildInsertStatements(
|
||||||
db,
|
db,
|
||||||
'ciphers',
|
tableName('domain_settings'),
|
||||||
['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'deleted_at'],
|
['user_id', 'equivalent_domains', 'custom_equivalent_domains', 'excluded_global_equivalent_domains', 'updated_at'],
|
||||||
|
payload.domain_settings || [],
|
||||||
|
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,
|
||||||
|
tableName('ciphers'),
|
||||||
|
['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'archived_at', 'deleted_at'],
|
||||||
payload.ciphers || []
|
payload.ciphers || []
|
||||||
),
|
)
|
||||||
...buildInsertStatements(db, 'attachments', ['id', 'cipher_id', 'file_name', 'size', 'size_name', 'key'], payload.attachments || []),
|
);
|
||||||
];
|
await runInsertBatch(
|
||||||
await db.batch(statements);
|
db,
|
||||||
|
tableName('attachments'),
|
||||||
|
buildInsertStatements(db, tableName('attachments'), ['id', 'cipher_id', 'file_name', 'size', 'size_name', 'key'], payload.attachments || [])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importBackupArchiveBytes(
|
export async function importBackupArchiveBytes(
|
||||||
archiveBytes: Uint8Array,
|
archiveBytes: Uint8Array,
|
||||||
env: Env,
|
env: Env,
|
||||||
actorUserId: string,
|
actorUserId: string,
|
||||||
replaceExisting: boolean
|
replaceExisting: boolean,
|
||||||
|
progress?: BackupRestoreProgressReporter,
|
||||||
|
fileName: string = 'nodewarden_backup.zip'
|
||||||
): Promise<BackupImportExecutionResult> {
|
): Promise<BackupImportExecutionResult> {
|
||||||
const storage = new StorageService(env.DB);
|
|
||||||
const parsed = parseBackupArchive(archiveBytes);
|
const parsed = parseBackupArchive(archiveBytes);
|
||||||
validateBackupPayloadContents(parsed.payload, parsed.files);
|
validateBackupPayloadContents(parsed.payload, parsed.files);
|
||||||
const prepared = prepareImportPayloadForTarget(env, parsed.payload, parsed.files);
|
const prepared = prepareImportPayloadForTarget(env, parsed.payload, parsed.files);
|
||||||
@@ -448,40 +671,121 @@ export async function importBackupArchiveBytes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await resetRestoreArtifacts(env.DB);
|
||||||
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
|
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
|
||||||
const { db } = prepared.payload;
|
try {
|
||||||
await importBackupRows(env.DB, db);
|
await progress?.({
|
||||||
await normalizeImportedBackupSettings(storage, env, 'UTC');
|
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,
|
||||||
|
domain_settings: (db.domain_settings || []).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);
|
await progress?.({
|
||||||
const failedRestoreRows = (db.attachments || []).filter((row) => !restored.restoredAttachments.includes(row));
|
source: 'local',
|
||||||
await removeAttachmentRows(env.DB, failedRestoreRows);
|
step: 'local_restore_files',
|
||||||
if (replaceExisting && previousBlobKeys.size) {
|
fileName,
|
||||||
await cleanupOrphanedBlobFiles(env, previousBlobKeys, await collectCurrentBlobKeys(env.DB));
|
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,
|
||||||
|
domain_settings: (db.domain_settings || []).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,
|
||||||
|
domainSettings: (db.domain_settings || []).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(
|
export async function importRemoteBackupArchiveBytes(
|
||||||
@@ -489,9 +793,10 @@ export async function importRemoteBackupArchiveBytes(
|
|||||||
env: Env,
|
env: Env,
|
||||||
actorUserId: string,
|
actorUserId: string,
|
||||||
replaceExisting: boolean,
|
replaceExisting: boolean,
|
||||||
source: RemoteAttachmentSource
|
source: RemoteAttachmentSource,
|
||||||
|
progress?: BackupRestoreProgressReporter,
|
||||||
|
fileName: string = 'nodewarden_backup.zip'
|
||||||
): Promise<BackupImportExecutionResult> {
|
): Promise<BackupImportExecutionResult> {
|
||||||
const storage = new StorageService(env.DB);
|
|
||||||
const parsed = parseBackupArchive(archiveBytes, { allowExternalAttachmentBlobs: true });
|
const parsed = parseBackupArchive(archiveBytes, { allowExternalAttachmentBlobs: true });
|
||||||
const preparedRemote = await prepareRemoteAttachmentPayload(env, parsed.payload, parsed.files, source);
|
const preparedRemote = await prepareRemoteAttachmentPayload(env, parsed.payload, parsed.files, source);
|
||||||
validateBackupPayloadContents(preparedRemote.payload, parsed.files, { allowExternalAttachmentBlobs: true });
|
validateBackupPayloadContents(preparedRemote.payload, parsed.files, { allowExternalAttachmentBlobs: true });
|
||||||
@@ -504,44 +809,125 @@ export async function importRemoteBackupArchiveBytes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await resetRestoreArtifacts(env.DB);
|
||||||
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
|
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
|
||||||
const { db } = preparedRemote.payload;
|
try {
|
||||||
await importBackupRows(env.DB, db);
|
await progress?.({
|
||||||
await normalizeImportedBackupSettings(storage, env, 'UTC');
|
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,
|
||||||
|
domain_settings: (db.domain_settings || []).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);
|
await progress?.({
|
||||||
const failedRestoreRows = (db.attachments || []).filter((row) => !restored.restoredAttachments.includes(row));
|
source: 'remote',
|
||||||
await removeAttachmentRows(env.DB, failedRestoreRows);
|
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,
|
||||||
|
domain_settings: (db.domain_settings || []).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) {
|
if (replaceExisting && previousBlobKeys.size) {
|
||||||
await cleanupOrphanedBlobFiles(env, previousBlobKeys, await collectCurrentBlobKeys(env.DB));
|
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,
|
||||||
|
domainSettings: (db.domain_settings || []).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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import type { Env, User } from '../types';
|
import type { Env, User } from '../types';
|
||||||
|
|
||||||
|
// CONTRACT:
|
||||||
|
// Backup settings contain provider credentials. They are stored as a v2 envelope:
|
||||||
|
// - runtime: AES-GCM encrypted with a key derived from JWT_SECRET for the current
|
||||||
|
// server's scheduled backup runner.
|
||||||
|
// - portable: AES-GCM encrypted with a random DEK; that DEK is RSA-wrapped for
|
||||||
|
// active admin public keys so settings can be repaired after restore/migration.
|
||||||
|
//
|
||||||
|
// New admin-entered provider secrets, such as mail API keys, should use this
|
||||||
|
// pattern or a deliberately documented replacement. Do not store provider
|
||||||
|
// secrets as plain config JSON.
|
||||||
const RUNTIME_SALT = 'nodewarden.backup-settings.runtime.v2';
|
const RUNTIME_SALT = 'nodewarden.backup-settings.runtime.v2';
|
||||||
const RUNTIME_INFO = 'runtime';
|
const RUNTIME_INFO = 'runtime';
|
||||||
const PORTABLE_ALGORITHM = 'RSA-OAEP';
|
const PORTABLE_ALGORITHM = 'RSA-OAEP';
|
||||||
@@ -155,6 +165,20 @@ export function parseBackupSettingsEnvelope(raw: string | null): BackupSettingsE
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function exportPortableBackupSettingsEnvelope(raw: string | null): string | null {
|
||||||
|
const envelope = parseBackupSettingsEnvelope(raw);
|
||||||
|
if (!envelope) return null;
|
||||||
|
return JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
portableOnly: true,
|
||||||
|
runtime: {
|
||||||
|
iv: '',
|
||||||
|
ciphertext: '',
|
||||||
|
},
|
||||||
|
portable: envelope.portable,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function encryptBackupSettingsEnvelope(
|
export async function encryptBackupSettingsEnvelope(
|
||||||
plaintext: string,
|
plaintext: string,
|
||||||
env: Env,
|
env: Env,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
BackupDestinationRecord,
|
BackupDestinationRecord,
|
||||||
BackupDestinationType,
|
BackupDestinationType,
|
||||||
E3BackupDestination,
|
S3BackupDestination,
|
||||||
WebDavBackupDestination,
|
WebDavBackupDestination,
|
||||||
} from './backup-config';
|
} from './backup-config';
|
||||||
|
|
||||||
@@ -213,13 +213,13 @@ function ensureDestinationConfigReady(destination: BackupDestinationRecord): voi
|
|||||||
if (!String(config.password || '')) throw new Error('WebDAV password is required');
|
if (!String(config.password || '')) throw new Error('WebDAV password is required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (destination.type === 'e3') {
|
if (destination.type === 's3') {
|
||||||
const config = destination.destination as E3BackupDestination;
|
const config = destination.destination as S3BackupDestination;
|
||||||
if (!String(config.endpoint || '').trim()) throw new Error('E3 endpoint is required');
|
if (!String(config.endpoint || '').trim()) throw new Error('S3 endpoint is required');
|
||||||
if (!/^https?:\/\//i.test(String(config.endpoint || '').trim())) throw new Error('E3 endpoint must start with http:// or https://');
|
if (!/^https?:\/\//i.test(String(config.endpoint || '').trim())) throw new Error('S3 endpoint must start with http:// or https://');
|
||||||
if (!String(config.bucket || '').trim()) throw new Error('E3 bucket is required');
|
if (!String(config.bucket || '').trim()) throw new Error('S3 bucket is required');
|
||||||
if (!String(config.accessKeyId || '').trim()) throw new Error('E3 access key is required');
|
if (!String(config.accessKeyId || '').trim()) throw new Error('S3 access key is required');
|
||||||
if (!String(config.secretAccessKey || '')) throw new Error('E3 secret key is required');
|
if (!String(config.secretAccessKey || '')) throw new Error('S3 secret key is required');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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(
|
async function putToWebDav(
|
||||||
config: WebDavBackupDestination,
|
config: WebDavBackupDestination,
|
||||||
relativePath: string,
|
relativePath: string,
|
||||||
bytes: Uint8Array,
|
bytes: Uint8Array,
|
||||||
options: RemoteBackupFilePutOptions = {}
|
options: RemoteBackupFilePutOptions = {},
|
||||||
|
ensuredDirectories?: Set<string>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const authHeader = toBasicAuthHeader(config.username, config.password);
|
const authHeader = toBasicAuthHeader(config.username, config.password);
|
||||||
const remoteFilePath = buildJoinedPath(config.remotePath, relativePath);
|
const remoteFilePath = buildJoinedPath(config.remotePath, relativePath);
|
||||||
const remoteDir = parentPath(remoteFilePath);
|
const remoteDir = parentPath(remoteFilePath);
|
||||||
|
|
||||||
if (remoteDir) {
|
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), {
|
const response = await fetch(buildWebDavUrl(config.baseUrl, remoteFilePath), {
|
||||||
@@ -417,16 +448,16 @@ async function existsInWebDav(config: WebDavBackupDestination, relativePath: str
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function e3BucketBaseUrl(config: E3BackupDestination): URL {
|
function s3BucketBaseUrl(config: S3BackupDestination): URL {
|
||||||
return new URL(`${config.endpoint.replace(/\/+$/, '')}/${encodeURIComponent(config.bucket)}`);
|
return new URL(`${config.endpoint.replace(/\/+$/, '')}/${encodeURIComponent(config.bucket)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeE3ObjectKey(config: E3BackupDestination, relativePath: string): string {
|
function normalizeS3ObjectKey(config: S3BackupDestination, relativePath: string): string {
|
||||||
return buildJoinedPath(config.rootPath, normalizeRelativePath(relativePath));
|
return buildJoinedPath(config.rootPath, normalizeRelativePath(relativePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function signedE3Request(
|
async function signedS3Request(
|
||||||
config: E3BackupDestination,
|
config: S3BackupDestination,
|
||||||
method: 'GET' | 'PUT' | 'DELETE' | 'HEAD',
|
method: 'GET' | 'PUT' | 'DELETE' | 'HEAD',
|
||||||
url: URL,
|
url: URL,
|
||||||
body?: Uint8Array,
|
body?: Uint8Array,
|
||||||
@@ -463,41 +494,41 @@ async function signedE3Request(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function putToE3(
|
async function putToS3(
|
||||||
config: E3BackupDestination,
|
config: S3BackupDestination,
|
||||||
relativePath: string,
|
relativePath: string,
|
||||||
bytes: Uint8Array,
|
bytes: Uint8Array,
|
||||||
options: RemoteBackupFilePutOptions = {}
|
options: RemoteBackupFilePutOptions = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const objectKey = normalizeE3ObjectKey(config, relativePath);
|
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||||
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||||
const response = await signedE3Request(config, 'PUT', url, bytes, options.contentType);
|
const response = await signedS3Request(config, 'PUT', url, bytes, options.contentType);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`E3 upload failed: ${response.status}`);
|
throw new Error(`S3 upload failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadToE3(config: E3BackupDestination, archive: Uint8Array, fileName: string): Promise<BackupUploadResult> {
|
async function uploadToS3(config: S3BackupDestination, archive: Uint8Array, fileName: string): Promise<BackupUploadResult> {
|
||||||
await putToE3(config, fileName, archive, { contentType: 'application/zip' });
|
await putToS3(config, fileName, archive, { contentType: 'application/zip' });
|
||||||
return {
|
return {
|
||||||
provider: 'e3',
|
provider: 's3',
|
||||||
remotePath: normalizeE3ObjectKey(config, fileName),
|
remotePath: normalizeS3ObjectKey(config, fileName),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listE3Entries(config: E3BackupDestination, relativePath: string): Promise<RemoteBackupListResult> {
|
async function listS3Entries(config: S3BackupDestination, relativePath: string): Promise<RemoteBackupListResult> {
|
||||||
const currentPath = normalizeRelativePath(relativePath);
|
const currentPath = normalizeRelativePath(relativePath);
|
||||||
const targetPrefixBase = normalizeE3ObjectKey(config, currentPath);
|
const targetPrefixBase = normalizeS3ObjectKey(config, currentPath);
|
||||||
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
|
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
|
||||||
const url = e3BucketBaseUrl(config);
|
const url = s3BucketBaseUrl(config);
|
||||||
url.searchParams.set('list-type', '2');
|
url.searchParams.set('list-type', '2');
|
||||||
url.searchParams.set('delimiter', '/');
|
url.searchParams.set('delimiter', '/');
|
||||||
if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
|
if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
|
||||||
|
|
||||||
const response = await signedE3Request(config, 'GET', url);
|
const response = await signedS3Request(config, 'GET', url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`E3 listing failed: ${response.status}`);
|
throw new Error(`S3 listing failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const xml = await response.text();
|
const xml = await response.text();
|
||||||
@@ -550,26 +581,26 @@ async function listE3Entries(config: E3BackupDestination, relativePath: string):
|
|||||||
for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item);
|
for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
provider: 'e3',
|
provider: 's3',
|
||||||
currentPath,
|
currentPath,
|
||||||
parentPath: parentPath(currentPath),
|
parentPath: parentPath(currentPath),
|
||||||
items: sortRemoteItems(Array.from(deduped.values())),
|
items: sortRemoteItems(Array.from(deduped.values())),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadFromE3(config: E3BackupDestination, relativePath: string): Promise<RemoteBackupFile> {
|
async function downloadFromS3(config: S3BackupDestination, relativePath: string): Promise<RemoteBackupFile> {
|
||||||
const normalized = normalizeRelativePath(relativePath);
|
const normalized = normalizeRelativePath(relativePath);
|
||||||
if (!normalized || normalized.endsWith('/')) {
|
if (!normalized || normalized.endsWith('/')) {
|
||||||
throw new Error('Please select a backup file');
|
throw new Error('Please select a backup file');
|
||||||
}
|
}
|
||||||
const objectKey = normalizeE3ObjectKey(config, normalized);
|
const objectKey = normalizeS3ObjectKey(config, normalized);
|
||||||
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||||
const response = await signedE3Request(config, 'GET', url);
|
const response = await signedS3Request(config, 'GET', url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`E3 download failed: ${response.status}`);
|
throw new Error(`S3 download failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
provider: 'e3',
|
provider: 's3',
|
||||||
remotePath: normalized,
|
remotePath: normalized,
|
||||||
fileName: basename(normalized) || 'backup.zip',
|
fileName: basename(normalized) || 'backup.zip',
|
||||||
contentType: String(response.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip',
|
contentType: String(response.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip',
|
||||||
@@ -577,35 +608,45 @@ async function downloadFromE3(config: E3BackupDestination, relativePath: string)
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteFromE3(config: E3BackupDestination, relativePath: string): Promise<void> {
|
async function deleteFromS3(config: S3BackupDestination, relativePath: string): Promise<void> {
|
||||||
const objectKey = normalizeE3ObjectKey(config, relativePath);
|
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||||
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||||
const response = await signedE3Request(config, 'DELETE', url);
|
const response = await signedS3Request(config, 'DELETE', url);
|
||||||
if (!response.ok && response.status !== 404) {
|
if (!response.ok && response.status !== 404) {
|
||||||
throw new Error(`E3 delete failed: ${response.status}`);
|
throw new Error(`S3 delete failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function existsInE3(config: E3BackupDestination, relativePath: string): Promise<boolean> {
|
async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> {
|
||||||
const objectKey = normalizeE3ObjectKey(config, relativePath);
|
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||||
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||||
const response = await signedE3Request(config, 'HEAD', url);
|
const response = await signedS3Request(config, 'HEAD', url);
|
||||||
if (response.status === 404) return false;
|
if (response.status === 404) return false;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`E3 existence check failed: ${response.status}`);
|
throw new Error(`S3 existence check failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConfiguredDestinationAdapter {
|
interface ConfiguredDestinationAdapter {
|
||||||
provider: 'webdav' | 'e3';
|
provider: 'webdav' | 's3';
|
||||||
config: WebDavBackupDestination | E3BackupDestination;
|
config: WebDavBackupDestination | S3BackupDestination;
|
||||||
upload: (config: WebDavBackupDestination | E3BackupDestination, archive: Uint8Array, fileName: string) => Promise<BackupUploadResult>;
|
upload: (config: WebDavBackupDestination | S3BackupDestination, archive: Uint8Array, fileName: string) => Promise<BackupUploadResult>;
|
||||||
putFile: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions) => Promise<void>;
|
putFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions) => Promise<void>;
|
||||||
list: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<RemoteBackupListResult>;
|
list: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupListResult>;
|
||||||
download: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
|
download: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
|
||||||
deleteFile: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<void>;
|
deleteFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<void>;
|
||||||
exists: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<boolean>;
|
exists: (config: WebDavBackupDestination | S3BackupDestination, 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(
|
function resolveConfiguredDestinationAdapter(
|
||||||
@@ -625,51 +666,78 @@ function resolveConfiguredDestinationAdapter(
|
|||||||
exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath),
|
exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (destination.type === 'e3') {
|
if (destination.type === 's3') {
|
||||||
return {
|
return {
|
||||||
provider: 'e3',
|
provider: 's3',
|
||||||
config: destination.destination as E3BackupDestination,
|
config: destination.destination as S3BackupDestination,
|
||||||
upload: (config, archive, fileName) => uploadToE3(config as E3BackupDestination, archive, fileName),
|
upload: (config, archive, fileName) => uploadToS3(config as S3BackupDestination, archive, fileName),
|
||||||
putFile: (config, relativePath, bytes, options) => putToE3(config as E3BackupDestination, relativePath, bytes, options),
|
putFile: (config, relativePath, bytes, options) => putToS3(config as S3BackupDestination, relativePath, bytes, options),
|
||||||
list: (config, relativePath) => listE3Entries(config as E3BackupDestination, relativePath),
|
list: (config, relativePath) => listS3Entries(config as S3BackupDestination, relativePath),
|
||||||
download: (config, relativePath) => downloadFromE3(config as E3BackupDestination, relativePath),
|
download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath),
|
||||||
deleteFile: (config, relativePath) => deleteFromE3(config as E3BackupDestination, relativePath),
|
deleteFile: (config, relativePath) => deleteFromS3(config as S3BackupDestination, relativePath),
|
||||||
exists: (config, relativePath) => existsInE3(config as E3BackupDestination, relativePath),
|
exists: (config, relativePath) => existsInS3(config as S3BackupDestination, relativePath),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('Unsupported backup destination type');
|
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)
|
||||||
|
: normalizeS3ObjectKey(adapter.config as S3BackupDestination, 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(
|
export async function uploadBackupArchive(
|
||||||
destination: BackupDestinationRecord,
|
destination: BackupDestinationRecord,
|
||||||
archive: Uint8Array,
|
archive: Uint8Array,
|
||||||
fileName: string
|
fileName: string
|
||||||
): Promise<BackupUploadResult> {
|
): Promise<BackupUploadResult> {
|
||||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
return createRemoteBackupTransferSession(destination).uploadArchive(archive, fileName);
|
||||||
return adapter.upload(adapter.config, archive, fileName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listRemoteBackupEntries(destination: BackupDestinationRecord, relativePath: string): Promise<RemoteBackupListResult> {
|
export async function listRemoteBackupEntries(destination: BackupDestinationRecord, relativePath: string): Promise<RemoteBackupListResult> {
|
||||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
return createRemoteBackupTransferSession(destination).list(relativePath);
|
||||||
return adapter.list(adapter.config, relativePath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise<RemoteBackupFile> {
|
export async function downloadRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise<RemoteBackupFile> {
|
||||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
return createRemoteBackupTransferSession(destination).download(relativePath);
|
||||||
return adapter.download(adapter.config, relativePath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise<void> {
|
export async function deleteRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise<void> {
|
||||||
const normalized = ensureRemoteRestoreCandidate(relativePath);
|
const normalized = ensureRemoteRestoreCandidate(relativePath);
|
||||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
await createRemoteBackupTransferSession(destination).deleteFile(normalized);
|
||||||
await adapter.deleteFile(adapter.config, normalized);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function remoteBackupFileExists(destination: BackupDestinationRecord, relativePath: string): Promise<boolean> {
|
export async function remoteBackupFileExists(destination: BackupDestinationRecord, relativePath: string): Promise<boolean> {
|
||||||
const normalized = normalizeRelativePath(relativePath);
|
const normalized = normalizeRelativePath(relativePath);
|
||||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
return createRemoteBackupTransferSession(destination).exists(normalized);
|
||||||
return adapter.exists(adapter.config, normalized);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadRemoteBackupFile(
|
export async function uploadRemoteBackupFile(
|
||||||
@@ -679,8 +747,7 @@ export async function uploadRemoteBackupFile(
|
|||||||
options: RemoteBackupFilePutOptions = {}
|
options: RemoteBackupFilePutOptions = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const normalized = normalizeRelativePath(relativePath);
|
const normalized = normalizeRelativePath(relativePath);
|
||||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
await createRemoteBackupTransferSession(destination).putFile(normalized, bytes, options);
|
||||||
await adapter.putFile(adapter.config, normalized, bytes, options);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function compareBackupItemsByRecency(a: RemoteBackupItem, b: RemoteBackupItem, preferredFileName?: string): number {
|
function compareBackupItemsByRecency(a: RemoteBackupItem, b: RemoteBackupItem, preferredFileName?: string): number {
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import bitwardenGlobalDomainsRaw from '../static/global_domains.bitwarden.json';
|
||||||
|
import customGlobalDomainsRaw from '../static/global_domains.custom.json';
|
||||||
|
import type { CustomEquivalentDomain, DomainRulesResponse, GlobalEquivalentDomain } from '../types';
|
||||||
|
import { normalizeEquivalentDomain } from '../../shared/domain-normalize';
|
||||||
|
|
||||||
|
// CONTRACT:
|
||||||
|
// Equivalent domains are a Bitwarden compatibility surface. The DB stores both
|
||||||
|
// the full custom rule list and the derived active equivalent-domain groups:
|
||||||
|
// - custom_equivalent_domains: UI/client rules with id + excluded state.
|
||||||
|
// - equivalent_domains: active groups derived from non-excluded custom rules.
|
||||||
|
// - excluded_global_equivalent_domains: disabled global rule type ids.
|
||||||
|
// Do not treat equivalent_domains and custom_equivalent_domains as accidental
|
||||||
|
// duplicates without a migration and compatibility plan.
|
||||||
|
type RawGlobalDomain = Partial<GlobalEquivalentDomain> & {
|
||||||
|
Type?: unknown;
|
||||||
|
Domains?: unknown;
|
||||||
|
Excluded?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeDomain(value: unknown): string {
|
||||||
|
return normalizeEquivalentDomain(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGlobalDomain(entry: RawGlobalDomain): GlobalEquivalentDomain | null {
|
||||||
|
const type = Number(entry.type ?? entry.Type);
|
||||||
|
if (!Number.isInteger(type)) return null;
|
||||||
|
|
||||||
|
const rawDomains = entry.domains ?? entry.Domains;
|
||||||
|
if (!Array.isArray(rawDomains)) return null;
|
||||||
|
|
||||||
|
const domains = Array.from(new Set(rawDomains.map(normalizeDomain).filter(Boolean)));
|
||||||
|
if (domains.length < 2) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
domains,
|
||||||
|
excluded: Boolean(entry.excluded ?? entry.Excluded ?? false),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGlobalDomains(input: unknown): GlobalEquivalentDomain[] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const out: GlobalEquivalentDomain[] = [];
|
||||||
|
for (const entry of input) {
|
||||||
|
const normalized = normalizeGlobalDomain(entry as RawGlobalDomain);
|
||||||
|
if (!normalized || seen.has(normalized.type)) continue;
|
||||||
|
seen.add(normalized.type);
|
||||||
|
out.push(normalized);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bitwardenGlobalDomains = normalizeGlobalDomains(bitwardenGlobalDomainsRaw);
|
||||||
|
const customGlobalDomains = normalizeGlobalDomains(customGlobalDomainsRaw);
|
||||||
|
|
||||||
|
export const globalDomains: readonly GlobalEquivalentDomain[] = [
|
||||||
|
...bitwardenGlobalDomains,
|
||||||
|
...customGlobalDomains,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function normalizeEquivalentDomains(input: unknown): string[][] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
|
||||||
|
const groups: string[][] = [];
|
||||||
|
const seenGroups = new Set<string>();
|
||||||
|
for (const group of input) {
|
||||||
|
if (!Array.isArray(group)) continue;
|
||||||
|
const domains = Array.from(new Set(group.map(normalizeDomain).filter(Boolean)));
|
||||||
|
if (domains.length < 2) continue;
|
||||||
|
const key = domains.slice().sort().join('\n');
|
||||||
|
if (seenGroups.has(key)) continue;
|
||||||
|
seenGroups.add(key);
|
||||||
|
groups.push(domains);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeEquivalentDomainGroups(input: string[][]): string[][] {
|
||||||
|
const parent = new Map<string, string>();
|
||||||
|
|
||||||
|
function find(domain: string): string {
|
||||||
|
const current = parent.get(domain);
|
||||||
|
if (!current) {
|
||||||
|
parent.set(domain, domain);
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
if (current === domain) return domain;
|
||||||
|
const root = find(current);
|
||||||
|
parent.set(domain, root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function union(a: string, b: string): void {
|
||||||
|
const rootA = find(a);
|
||||||
|
const rootB = find(b);
|
||||||
|
if (rootA !== rootB) parent.set(rootB, rootA);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const group of normalizeEquivalentDomains(input)) {
|
||||||
|
if (group.length < 2) continue;
|
||||||
|
const [first, ...rest] = group;
|
||||||
|
find(first);
|
||||||
|
for (const domain of rest) union(first, domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
const components = new Map<string, string[]>();
|
||||||
|
for (const domain of parent.keys()) {
|
||||||
|
const root = find(domain);
|
||||||
|
const group = components.get(root) || [];
|
||||||
|
group.push(domain);
|
||||||
|
components.set(root, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(components.values())
|
||||||
|
.map((group) => group.sort())
|
||||||
|
.filter((group) => group.length >= 2)
|
||||||
|
.sort((a, b) => a[0].localeCompare(b[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expandCustomEquivalentDomainsWithGlobals(
|
||||||
|
customGroups: string[][],
|
||||||
|
activeGlobalGroups: string[][]
|
||||||
|
): string[][] {
|
||||||
|
const normalizedCustomGroups = normalizeEquivalentDomains(customGroups);
|
||||||
|
if (!normalizedCustomGroups.length) return [];
|
||||||
|
|
||||||
|
const customDomains = new Set(normalizedCustomGroups.flat());
|
||||||
|
return mergeEquivalentDomainGroups([
|
||||||
|
...activeGlobalGroups,
|
||||||
|
...normalizedCustomGroups,
|
||||||
|
]).filter((group) => group.some((domain) => customDomains.has(domain)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCustomDomainId(domains: string[], index: number): string {
|
||||||
|
return `custom:${domains.slice().sort().join('|')}:${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCustomEquivalentDomains(input: unknown): CustomEquivalentDomain[] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
|
||||||
|
const rules: CustomEquivalentDomain[] = [];
|
||||||
|
const seenGroups = new Set<string>();
|
||||||
|
for (const [index, item] of input.entries()) {
|
||||||
|
const record = Array.isArray(item)
|
||||||
|
? { domains: item, excluded: false, id: '' }
|
||||||
|
: item && typeof item === 'object'
|
||||||
|
? item as Record<string, unknown>
|
||||||
|
: null;
|
||||||
|
if (!record) continue;
|
||||||
|
|
||||||
|
const domains = normalizeEquivalentDomains([record.domains ?? record.Domains])[0];
|
||||||
|
if (!domains) continue;
|
||||||
|
|
||||||
|
const key = domains.slice().sort().join('\n');
|
||||||
|
if (seenGroups.has(key)) continue;
|
||||||
|
seenGroups.add(key);
|
||||||
|
|
||||||
|
const rawId = String(record.id ?? record.Id ?? '').trim();
|
||||||
|
rules.push({
|
||||||
|
id: rawId || createCustomDomainId(domains, index),
|
||||||
|
domains,
|
||||||
|
excluded: Boolean(record.excluded ?? record.Excluded ?? false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function customRulesToActiveEquivalentDomains(rules: CustomEquivalentDomain[]): string[][] {
|
||||||
|
return mergeEquivalentDomainGroups(rules
|
||||||
|
.filter((rule) => !rule.excluded)
|
||||||
|
.map((rule) => rule.domains));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeExcludedGlobalTypes(input: unknown): number[] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
|
||||||
|
const validTypes = new Set(globalDomains.map((entry) => entry.type));
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const out: number[] = [];
|
||||||
|
for (const item of input) {
|
||||||
|
const type = Number(typeof item === 'object' && item !== null ? (item as Record<string, unknown>).type : item);
|
||||||
|
const excluded = typeof item === 'object' && item !== null
|
||||||
|
? Boolean((item as Record<string, unknown>).excluded)
|
||||||
|
: true;
|
||||||
|
if (!excluded || !Number.isInteger(type) || !validTypes.has(type) || seen.has(type)) continue;
|
||||||
|
seen.add(type);
|
||||||
|
out.push(type);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDomainsResponse(
|
||||||
|
equivalentDomains: string[][],
|
||||||
|
customEquivalentDomains: CustomEquivalentDomain[],
|
||||||
|
excludedGlobalEquivalentDomains: number[],
|
||||||
|
options: { omitExcludedGlobals?: boolean } = {}
|
||||||
|
): DomainRulesResponse {
|
||||||
|
const excluded = new Set(excludedGlobalEquivalentDomains);
|
||||||
|
const activeGlobalDomainGroups = globalDomains
|
||||||
|
.filter((entry) => !excluded.has(entry.type))
|
||||||
|
.map((entry) => entry.domains);
|
||||||
|
const mergedEquivalentDomains = expandCustomEquivalentDomainsWithGlobals(
|
||||||
|
equivalentDomains,
|
||||||
|
activeGlobalDomainGroups
|
||||||
|
);
|
||||||
|
const globals = globalDomains
|
||||||
|
.map((entry) => ({
|
||||||
|
type: entry.type,
|
||||||
|
domains: entry.domains,
|
||||||
|
excluded: excluded.has(entry.type),
|
||||||
|
}))
|
||||||
|
.filter((entry) => !options.omitExcludedGlobals || !entry.excluded);
|
||||||
|
|
||||||
|
return {
|
||||||
|
equivalentDomains: mergedEquivalentDomains,
|
||||||
|
customEquivalentDomains,
|
||||||
|
globalEquivalentDomains: globals,
|
||||||
|
object: 'domains',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,72 @@
|
|||||||
import type { AuditLog, Invite } from '../types';
|
import type { AuditLog, Invite } from '../types';
|
||||||
|
|
||||||
|
export interface AuditLogListOptions {
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
category?: string | null;
|
||||||
|
level?: string | null;
|
||||||
|
q?: string | null;
|
||||||
|
from?: string | null;
|
||||||
|
to?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogListResult {
|
||||||
|
logs: AuditLog[];
|
||||||
|
total: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditLogFromRow(row: any): AuditLog {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
actorUserId: row.actor_user_id ?? null,
|
||||||
|
actorEmail: row.actor_email ?? null,
|
||||||
|
action: row.action,
|
||||||
|
category: row.category || 'system',
|
||||||
|
level: row.level || 'info',
|
||||||
|
targetType: row.target_type ?? null,
|
||||||
|
targetId: row.target_id ?? null,
|
||||||
|
targetUserEmail: row.target_user_email ?? null,
|
||||||
|
metadata: row.metadata ?? null,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAuditWhere(options: AuditLogListOptions): { where: string; params: unknown[] } {
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
|
||||||
|
if (options.from) {
|
||||||
|
conditions.push('l.created_at >= ?');
|
||||||
|
params.push(options.from);
|
||||||
|
}
|
||||||
|
if (options.to) {
|
||||||
|
conditions.push('l.created_at <= ?');
|
||||||
|
params.push(options.to);
|
||||||
|
}
|
||||||
|
if (options.category) {
|
||||||
|
conditions.push('l.category = ?');
|
||||||
|
params.push(options.category);
|
||||||
|
}
|
||||||
|
if (options.level) {
|
||||||
|
conditions.push('l.level = ?');
|
||||||
|
params.push(options.level);
|
||||||
|
}
|
||||||
|
if (options.q) {
|
||||||
|
const q = options.q.toLowerCase().slice(0, 48);
|
||||||
|
const like = `%${q}%`;
|
||||||
|
conditions.push(
|
||||||
|
'(LOWER(l.action) LIKE ? OR LOWER(COALESCE(l.actor_user_id, \'\')) LIKE ? OR LOWER(COALESCE(l.target_type, \'\')) LIKE ? OR LOWER(COALESCE(l.target_id, \'\')) LIKE ? OR LOWER(COALESCE(actor.email, \'\')) LIKE ? OR LOWER(COALESCE(target.email, \'\')) LIKE ?)'
|
||||||
|
);
|
||||||
|
params.push(like, like, like, like, like, like);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
where: conditions.length ? `WHERE ${conditions.join(' AND ')}` : '',
|
||||||
|
params,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function createInvite(db: D1Database, invite: Invite): Promise<void> {
|
export async function createInvite(db: D1Database, invite: Invite): Promise<void> {
|
||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -77,8 +144,60 @@ export async function deleteAllInvites(db: D1Database): Promise<number> {
|
|||||||
export async function createAuditLog(db: D1Database, log: AuditLog): Promise<void> {
|
export async function createAuditLog(db: D1Database, log: AuditLog): Promise<void> {
|
||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
'INSERT INTO audit_logs(id, actor_user_id, action, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?)'
|
'INSERT INTO audit_logs(id, actor_user_id, action, category, level, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
)
|
)
|
||||||
.bind(log.id, log.actorUserId, log.action, log.targetType, log.targetId, log.metadata, log.createdAt)
|
.bind(log.id, log.actorUserId, log.action, log.category, log.level, log.targetType, log.targetId, log.metadata, log.createdAt)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function pruneAuditLogs(db: D1Database, beforeIso: string): Promise<number> {
|
||||||
|
const result = await db
|
||||||
|
.prepare('DELETE FROM audit_logs WHERE created_at < ?')
|
||||||
|
.bind(beforeIso)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pruneAuditLogsToMax(db: D1Database, maxEntries: number): Promise<number> {
|
||||||
|
const limit = Math.max(1, Math.floor(maxEntries));
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
'DELETE FROM audit_logs WHERE id IN (' +
|
||||||
|
'SELECT id FROM audit_logs ORDER BY created_at DESC LIMIT -1 OFFSET ?' +
|
||||||
|
')'
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearAuditLogs(db: D1Database): Promise<number> {
|
||||||
|
const result = await db.prepare('DELETE FROM audit_logs').run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAuditLogs(db: D1Database, options: AuditLogListOptions): Promise<AuditLogListResult> {
|
||||||
|
const limit = Math.max(1, Math.min(200, Math.floor(options.limit || 50)));
|
||||||
|
const offset = Math.max(0, Math.floor(options.offset || 0));
|
||||||
|
const { where, params } = buildAuditWhere(options);
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT l.id, l.actor_user_id, actor.email AS actor_email, l.action, l.category, l.level, l.target_type, l.target_id, target.email AS target_user_email, l.metadata, l.created_at ' +
|
||||||
|
'FROM audit_logs l ' +
|
||||||
|
'LEFT JOIN users actor ON actor.id = l.actor_user_id ' +
|
||||||
|
"LEFT JOIN users target ON l.target_type = 'user' AND target.id = l.target_id " +
|
||||||
|
`${where} ORDER BY l.created_at DESC LIMIT ? OFFSET ?`
|
||||||
|
)
|
||||||
|
.bind(...params, limit + 1, offset)
|
||||||
|
.all<any>();
|
||||||
|
const results = rows.results || [];
|
||||||
|
const logs = results.slice(0, limit).map(auditLogFromRow);
|
||||||
|
const hasMore = results.length > limit;
|
||||||
|
|
||||||
|
return {
|
||||||
|
logs,
|
||||||
|
total: offset + logs.length + (hasMore ? 1 : 0),
|
||||||
|
hasMore,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,22 @@ export async function deleteAttachment(db: D1Database, id: string): Promise<void
|
|||||||
await db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run();
|
await db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function bulkDeleteAttachmentsByIds(
|
||||||
|
db: D1Database,
|
||||||
|
sqlChunkSize: SqlChunkSize,
|
||||||
|
attachmentIds: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
const uniqueIds = [...new Set(attachmentIds.map((id) => String(id || '').trim()).filter(Boolean))];
|
||||||
|
if (!uniqueIds.length) return;
|
||||||
|
const chunkSize = sqlChunkSize(0);
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
|
await db.prepare(`DELETE FROM attachments WHERE id IN (${placeholders})`).bind(...chunk).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getAttachmentsByCipher(db: D1Database, cipherId: string): Promise<Attachment[]> {
|
export async function getAttachmentsByCipher(db: D1Database, cipherId: string): Promise<Attachment[]> {
|
||||||
const res = await db
|
const res = await db
|
||||||
.prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id = ?')
|
.prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id = ?')
|
||||||
@@ -119,11 +135,6 @@ export async function addAttachmentToCipher(db: D1Database, cipherId: string, at
|
|||||||
await db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run();
|
await db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise<void> {
|
|
||||||
void cipherId;
|
|
||||||
void attachmentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteAllAttachmentsByCipher(db: D1Database, cipherId: string): Promise<void> {
|
export async function deleteAllAttachmentsByCipher(db: D1Database, cipherId: string): Promise<void> {
|
||||||
await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run();
|
await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { Cipher } from '../types';
|
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 SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
|
||||||
type SqlChunkSize = (fixedBindCount: number) => number;
|
type SqlChunkSize = (fixedBindCount: number) => number;
|
||||||
type UpdateRevisionDate = (userId: string) => Promise<string>;
|
type UpdateRevisionDate = (userId: string) => Promise<string>;
|
||||||
@@ -21,16 +27,58 @@ interface CipherRow {
|
|||||||
deleted_at: string | null;
|
deleted_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CIPHER_SCALAR_DATA_KEYS = new Set([
|
||||||
|
'id',
|
||||||
|
'userId',
|
||||||
|
'user_id',
|
||||||
|
'type',
|
||||||
|
'folderId',
|
||||||
|
'folder_id',
|
||||||
|
'name',
|
||||||
|
'notes',
|
||||||
|
'favorite',
|
||||||
|
'reprompt',
|
||||||
|
'key',
|
||||||
|
'attachments',
|
||||||
|
'Attachments',
|
||||||
|
'attachments2',
|
||||||
|
'Attachments2',
|
||||||
|
'createdAt',
|
||||||
|
'created_at',
|
||||||
|
'creationDate',
|
||||||
|
'updatedAt',
|
||||||
|
'updated_at',
|
||||||
|
'revisionDate',
|
||||||
|
'archivedAt',
|
||||||
|
'archived_at',
|
||||||
|
'archivedDate',
|
||||||
|
'deletedAt',
|
||||||
|
'deleted_at',
|
||||||
|
'deletedDate',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function buildCipherData(cipher: Cipher, folderId: string | null): string {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
...cipher,
|
||||||
|
folderId,
|
||||||
|
};
|
||||||
|
for (const key of CIPHER_SCALAR_DATA_KEYS) {
|
||||||
|
delete payload[key];
|
||||||
|
}
|
||||||
|
return JSON.stringify(payload);
|
||||||
|
}
|
||||||
|
|
||||||
function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
|
function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
|
||||||
if (!row?.data) return null;
|
if (!row?.data) return null;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(row.data) as Cipher;
|
const parsed = JSON.parse(row.data) as Cipher;
|
||||||
|
const folderId = normalizeOptionalId(row.folder_id ?? parsed.folderId ?? null);
|
||||||
return {
|
return {
|
||||||
...parsed,
|
...parsed,
|
||||||
id: row.id,
|
id: row.id,
|
||||||
userId: row.user_id,
|
userId: row.user_id,
|
||||||
type: Number(row.type) || Number(parsed.type) || 1,
|
type: Number(row.type) || Number(parsed.type) || 1,
|
||||||
folderId: row.folder_id ?? parsed.folderId ?? null,
|
folderId,
|
||||||
name: row.name ?? parsed.name ?? null,
|
name: row.name ?? parsed.name ?? null,
|
||||||
notes: row.notes ?? parsed.notes ?? null,
|
notes: row.notes ?? parsed.notes ?? null,
|
||||||
favorite: row.favorite != null ? !!row.favorite : !!parsed.favorite,
|
favorite: row.favorite != null ? !!row.favorite : !!parsed.favorite,
|
||||||
@@ -60,7 +108,8 @@ export async function getCipher(db: D1Database, id: string): Promise<Cipher | nu
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> {
|
export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> {
|
||||||
const data = JSON.stringify(cipher);
|
const folderId = normalizeOptionalId(cipher.folderId);
|
||||||
|
const data = buildCipherData(cipher, folderId);
|
||||||
const stmt = db.prepare(
|
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) ' +
|
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
|
||||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||||
@@ -72,7 +121,7 @@ export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cip
|
|||||||
cipher.id,
|
cipher.id,
|
||||||
cipher.userId,
|
cipher.userId,
|
||||||
Number(cipher.type) || 1,
|
Number(cipher.type) || 1,
|
||||||
cipher.folderId,
|
folderId,
|
||||||
cipher.name,
|
cipher.name,
|
||||||
cipher.notes,
|
cipher.notes,
|
||||||
cipher.favorite ? 1 : 0,
|
cipher.favorite ? 1 : 0,
|
||||||
@@ -106,8 +155,7 @@ export async function bulkSoftDeleteCiphers(
|
|||||||
if (!uniqueIds.length) return null;
|
if (!uniqueIds.length) return null;
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const patch = JSON.stringify({ deletedAt: now, updatedAt: now });
|
const chunkSize = sqlChunkSize(3);
|
||||||
const chunkSize = sqlChunkSize(4);
|
|
||||||
|
|
||||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
@@ -115,10 +163,11 @@ export async function bulkSoftDeleteCiphers(
|
|||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE ciphers
|
`UPDATE ciphers
|
||||||
SET deleted_at = ?, updated_at = ?, data = json_patch(data, ?)
|
SET deleted_at = ?, updated_at = ?,
|
||||||
|
data = json_remove(data, '$.deletedAt', '$.deletedDate', '$.updatedAt', '$.revisionDate')
|
||||||
WHERE user_id = ? AND id IN (${placeholders})`
|
WHERE user_id = ? AND id IN (${placeholders})`
|
||||||
)
|
)
|
||||||
.bind(now, now, patch, userId, ...chunk)
|
.bind(now, now, userId, ...chunk)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,8 +186,7 @@ export async function bulkRestoreCiphers(
|
|||||||
if (!uniqueIds.length) return null;
|
if (!uniqueIds.length) return null;
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const patch = JSON.stringify({ deletedAt: null, updatedAt: now });
|
const chunkSize = sqlChunkSize(2);
|
||||||
const chunkSize = sqlChunkSize(3);
|
|
||||||
|
|
||||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
@@ -146,10 +194,11 @@ export async function bulkRestoreCiphers(
|
|||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE ciphers
|
`UPDATE ciphers
|
||||||
SET deleted_at = NULL, updated_at = ?, data = json_patch(data, ?)
|
SET deleted_at = NULL, updated_at = ?,
|
||||||
|
data = json_remove(data, '$.deletedAt', '$.deletedDate', '$.updatedAt', '$.revisionDate')
|
||||||
WHERE user_id = ? AND id IN (${placeholders})`
|
WHERE user_id = ? AND id IN (${placeholders})`
|
||||||
)
|
)
|
||||||
.bind(now, patch, userId, ...chunk)
|
.bind(now, userId, ...chunk)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,9 +298,9 @@ export async function bulkMoveCiphers(
|
|||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
if (ids.length === 0) return null;
|
if (ids.length === 0) return null;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
const normalizedFolderId = normalizeOptionalId(folderId);
|
||||||
const uniqueIds = sanitizeIds(ids);
|
const uniqueIds = sanitizeIds(ids);
|
||||||
const patch = JSON.stringify({ folderId, updatedAt: now });
|
const chunkSize = sqlChunkSize(3);
|
||||||
const chunkSize = sqlChunkSize(4);
|
|
||||||
|
|
||||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
@@ -259,10 +308,11 @@ export async function bulkMoveCiphers(
|
|||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE ciphers
|
`UPDATE ciphers
|
||||||
SET folder_id = ?, updated_at = ?, data = json_patch(data, ?)
|
SET folder_id = ?, updated_at = ?,
|
||||||
|
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
|
||||||
WHERE user_id = ? AND id IN (${placeholders})`
|
WHERE user_id = ? AND id IN (${placeholders})`
|
||||||
)
|
)
|
||||||
.bind(folderId, now, patch, userId, ...chunk)
|
.bind(normalizedFolderId, now, userId, ...chunk)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,8 +331,7 @@ export async function bulkArchiveCiphers(
|
|||||||
if (!uniqueIds.length) return null;
|
if (!uniqueIds.length) return null;
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const patch = JSON.stringify({ archivedAt: now, archivedDate: now, updatedAt: now });
|
const chunkSize = sqlChunkSize(3);
|
||||||
const chunkSize = sqlChunkSize(4);
|
|
||||||
|
|
||||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
@@ -290,10 +339,11 @@ export async function bulkArchiveCiphers(
|
|||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE ciphers
|
`UPDATE ciphers
|
||||||
SET archived_at = ?, updated_at = ?, data = json_patch(data, ?)
|
SET archived_at = ?, updated_at = ?,
|
||||||
|
data = json_remove(data, '$.archivedAt', '$.archivedDate', '$.updatedAt', '$.revisionDate')
|
||||||
WHERE user_id = ? AND id IN (${placeholders}) AND deleted_at IS NULL`
|
WHERE user_id = ? AND id IN (${placeholders}) AND deleted_at IS NULL`
|
||||||
)
|
)
|
||||||
.bind(now, now, patch, userId, ...chunk)
|
.bind(now, now, userId, ...chunk)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,8 +362,7 @@ export async function bulkUnarchiveCiphers(
|
|||||||
if (!uniqueIds.length) return null;
|
if (!uniqueIds.length) return null;
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const patch = JSON.stringify({ archivedAt: null, archivedDate: null, updatedAt: now });
|
const chunkSize = sqlChunkSize(2);
|
||||||
const chunkSize = sqlChunkSize(3);
|
|
||||||
|
|
||||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
@@ -321,10 +370,11 @@ export async function bulkUnarchiveCiphers(
|
|||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE ciphers
|
`UPDATE ciphers
|
||||||
SET archived_at = NULL, updated_at = ?, data = json_patch(data, ?)
|
SET archived_at = NULL, updated_at = ?,
|
||||||
|
data = json_remove(data, '$.archivedAt', '$.archivedDate', '$.updatedAt', '$.revisionDate')
|
||||||
WHERE user_id = ? AND id IN (${placeholders})`
|
WHERE user_id = ? AND id IN (${placeholders})`
|
||||||
)
|
)
|
||||||
.bind(now, patch, userId, ...chunk)
|
.bind(now, userId, ...chunk)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ function mapDeviceRow(row: any): Device {
|
|||||||
userId: row.user_id,
|
userId: row.user_id,
|
||||||
deviceIdentifier: row.device_identifier,
|
deviceIdentifier: row.device_identifier,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
deviceNote: row.device_note ?? null,
|
||||||
type: row.type,
|
type: row.type,
|
||||||
sessionStamp: row.session_stamp || '',
|
sessionStamp: row.session_stamp || '',
|
||||||
encryptedUserKey: row.encrypted_user_key ?? null,
|
encryptedUserKey: row.encrypted_user_key ?? null,
|
||||||
encryptedPublicKey: row.encrypted_public_key ?? null,
|
encryptedPublicKey: row.encrypted_public_key ?? null,
|
||||||
encryptedPrivateKey: row.encrypted_private_key ?? null,
|
encryptedPrivateKey: row.encrypted_private_key ?? null,
|
||||||
|
lastSeenAt: row.last_seen_at ?? null,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
};
|
};
|
||||||
@@ -33,31 +35,62 @@ export async function upsertDevice(
|
|||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const effectiveSessionStamp = String(sessionStamp || '').trim() || (await getDeviceById(userId, deviceIdentifier))?.sessionStamp || '';
|
const existingDevice = await getDeviceById(userId, deviceIdentifier);
|
||||||
|
const effectiveSessionStamp = String(sessionStamp || '').trim() || existingDevice?.sessionStamp || '';
|
||||||
|
const effectiveName = String(name || '').trim() || String(existingDevice?.name || '').trim();
|
||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?) ' +
|
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' +
|
||||||
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' +
|
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' +
|
||||||
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
|
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
|
||||||
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
|
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
|
||||||
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
|
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
|
||||||
|
'last_seen_at=excluded.last_seen_at, ' +
|
||||||
'updated_at=excluded.updated_at'
|
'updated_at=excluded.updated_at'
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
userId,
|
userId,
|
||||||
deviceIdentifier,
|
deviceIdentifier,
|
||||||
name,
|
effectiveName,
|
||||||
type,
|
type,
|
||||||
effectiveSessionStamp,
|
effectiveSessionStamp,
|
||||||
keys?.encryptedUserKey ?? null,
|
keys?.encryptedUserKey ?? null,
|
||||||
keys?.encryptedPublicKey ?? null,
|
keys?.encryptedPublicKey ?? null,
|
||||||
keys?.encryptedPrivateKey ?? null,
|
keys?.encryptedPrivateKey ?? null,
|
||||||
|
existingDevice?.deviceNote ?? null,
|
||||||
|
now,
|
||||||
now,
|
now,
|
||||||
now
|
now
|
||||||
)
|
)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateDeviceName(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string,
|
||||||
|
name: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await db
|
||||||
|
.prepare('UPDATE devices SET device_note = ? WHERE user_id = ? AND device_identifier = ?')
|
||||||
|
.bind(String(name || '').trim(), userId, deviceIdentifier)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function touchDeviceLastSeen(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const result = await db
|
||||||
|
.prepare('UPDATE devices SET last_seen_at = ? WHERE user_id = ? AND device_identifier = ?')
|
||||||
|
.bind(now, userId, deviceIdentifier)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateDeviceKeys(
|
export async function updateDeviceKeys(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -133,8 +166,8 @@ export async function isKnownDeviceByEmail(
|
|||||||
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
|
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
|
||||||
const res = await db
|
const res = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' +
|
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
|
||||||
'FROM devices WHERE user_id = ? ORDER BY updated_at DESC'
|
'FROM devices WHERE user_id = ? ORDER BY COALESCE(last_seen_at, created_at) DESC, updated_at DESC'
|
||||||
)
|
)
|
||||||
.bind(userId)
|
.bind(userId)
|
||||||
.all<any>();
|
.all<any>();
|
||||||
@@ -144,7 +177,7 @@ export async function getDevicesByUserId(db: D1Database, userId: string): Promis
|
|||||||
export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> {
|
export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> {
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' +
|
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
|
||||||
'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
|
'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
|
||||||
)
|
)
|
||||||
.bind(userId, deviceIdentifier)
|
.bind(userId, deviceIdentifier)
|
||||||
@@ -200,6 +233,21 @@ export async function deleteTrustedTwoFactorTokensByUserId(db: D1Database, userI
|
|||||||
return Number(result.meta.changes ?? 0);
|
return Number(result.meta.changes ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateTrustedTwoFactorTokensExpiryByDevice(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string,
|
||||||
|
expiresAtMs: number
|
||||||
|
): Promise<number> {
|
||||||
|
const now = Date.now();
|
||||||
|
await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(now).run();
|
||||||
|
const result = await db
|
||||||
|
.prepare('UPDATE trusted_two_factor_device_tokens SET expires_at = ? WHERE user_id = ? AND device_identifier = ? AND expires_at >= ?')
|
||||||
|
.bind(expiresAtMs, userId, deviceIdentifier, now)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveTrustedTwoFactorDeviceToken(
|
export async function saveTrustedTwoFactorDeviceToken(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
trustedTokenKey: TrustedTokenKeyFn,
|
trustedTokenKey: TrustedTokenKeyFn,
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { UserDomainSettings } from '../types';
|
||||||
|
import { normalizeCustomEquivalentDomains, normalizeEquivalentDomains } from './domain-rules';
|
||||||
|
|
||||||
|
// Storage adapter for the domain_settings table.
|
||||||
|
//
|
||||||
|
// CONTRACT:
|
||||||
|
// equivalent_domains is kept as the active derived groups for compatibility and
|
||||||
|
// fallback reads. custom_equivalent_domains is the full rule list that preserves
|
||||||
|
// UI/client state. Save both together through saveUserDomainSettings().
|
||||||
|
function parseJsonArray<T>(raw: string | null | undefined, fallback: T[]): T[] {
|
||||||
|
if (!raw) return fallback;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
return Array.isArray(parsed) ? parsed as T[] : fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserDomainSettings(db: D1Database, userId: string): Promise<UserDomainSettings> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings WHERE user_id = ?')
|
||||||
|
.bind(userId)
|
||||||
|
.first<{
|
||||||
|
equivalent_domains: string | null;
|
||||||
|
custom_equivalent_domains: string | null;
|
||||||
|
excluded_global_equivalent_domains: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
}>();
|
||||||
|
const equivalentDomains = normalizeEquivalentDomains(parseJsonArray<string[]>(row?.equivalent_domains, []));
|
||||||
|
const storedCustomEquivalentDomains = row?.custom_equivalent_domains
|
||||||
|
? normalizeCustomEquivalentDomains(parseJsonArray<unknown>(row.custom_equivalent_domains, []))
|
||||||
|
: [];
|
||||||
|
const customEquivalentDomains = storedCustomEquivalentDomains.length
|
||||||
|
? storedCustomEquivalentDomains
|
||||||
|
: normalizeCustomEquivalentDomains(equivalentDomains);
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
equivalentDomains,
|
||||||
|
customEquivalentDomains,
|
||||||
|
excludedGlobalEquivalentDomains: parseJsonArray<number>(row?.excluded_global_equivalent_domains, []),
|
||||||
|
updatedAt: row?.updated_at || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveUserDomainSettings(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
equivalentDomains: string[][],
|
||||||
|
customEquivalentDomains: UserDomainSettings['customEquivalentDomains'],
|
||||||
|
excludedGlobalEquivalentDomains: number[],
|
||||||
|
updatedAt: string
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO domain_settings(user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at) ' +
|
||||||
|
'VALUES(?, ?, ?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(user_id) DO UPDATE SET ' +
|
||||||
|
'equivalent_domains = excluded.equivalent_domains, ' +
|
||||||
|
'custom_equivalent_domains = excluded.custom_equivalent_domains, ' +
|
||||||
|
'excluded_global_equivalent_domains = excluded.excluded_global_equivalent_domains, ' +
|
||||||
|
'updated_at = excluded.updated_at'
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
userId,
|
||||||
|
JSON.stringify(equivalentDomains),
|
||||||
|
JSON.stringify(customEquivalentDomains),
|
||||||
|
JSON.stringify(excludedGlobalEquivalentDomains),
|
||||||
|
updatedAt
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Cipher, Folder } from '../types';
|
import type { Folder } from '../types';
|
||||||
|
|
||||||
function mapFolderRow(row: any): Folder {
|
function mapFolderRow(row: any): Folder {
|
||||||
return {
|
return {
|
||||||
@@ -36,26 +36,18 @@ export async function deleteFolder(db: D1Database, id: string, userId: string):
|
|||||||
export async function clearFolderFromCiphers(
|
export async function clearFolderFromCiphers(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
userId: string,
|
userId: string,
|
||||||
folderId: string,
|
folderId: string
|
||||||
saveCipher: (cipher: Cipher) => Promise<void>
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const res = await db
|
await db
|
||||||
.prepare('SELECT data FROM ciphers WHERE user_id = ? AND folder_id = ?')
|
.prepare(
|
||||||
.bind(userId, folderId)
|
`UPDATE ciphers
|
||||||
.all<{ data: string }>();
|
SET folder_id = NULL, updated_at = ?,
|
||||||
|
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
|
||||||
for (const row of (res.results || [])) {
|
WHERE user_id = ? AND folder_id = ?`
|
||||||
let cipher: Cipher;
|
)
|
||||||
try {
|
.bind(now, userId, folderId)
|
||||||
cipher = JSON.parse(row.data) as Cipher;
|
.run();
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
cipher.folderId = null;
|
|
||||||
cipher.updatedAt = now;
|
|
||||||
await saveCipher(cipher);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bulkDeleteFolders(
|
export async function bulkDeleteFolders(
|
||||||
@@ -63,34 +55,26 @@ export async function bulkDeleteFolders(
|
|||||||
userId: string,
|
userId: string,
|
||||||
ids: string[],
|
ids: string[],
|
||||||
sqlChunkSize: (fixedBindCount: number) => number,
|
sqlChunkSize: (fixedBindCount: number) => number,
|
||||||
saveCipher: (cipher: Cipher) => Promise<void>,
|
|
||||||
updateRevisionDate: (userId: string) => Promise<string>
|
updateRevisionDate: (userId: string) => Promise<string>
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||||
if (!uniqueIds.length) return null;
|
if (!uniqueIds.length) return null;
|
||||||
|
|
||||||
const chunkSize = sqlChunkSize(1);
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
const chunkSize = sqlChunkSize(2);
|
||||||
|
|
||||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
const placeholders = chunk.map(() => '?').join(',');
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
const res = await db
|
await db
|
||||||
.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND folder_id IN (${placeholders})`)
|
.prepare(
|
||||||
.bind(userId, ...chunk)
|
`UPDATE ciphers
|
||||||
.all<{ data: string }>();
|
SET folder_id = NULL, updated_at = ?,
|
||||||
|
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
|
||||||
for (const row of res.results || []) {
|
WHERE user_id = ? AND folder_id IN (${placeholders})`
|
||||||
let cipher: Cipher;
|
)
|
||||||
try {
|
.bind(now, userId, ...chunk)
|
||||||
cipher = JSON.parse(row.data) as Cipher;
|
.run();
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
cipher.folderId = null;
|
|
||||||
cipher.updatedAt = now;
|
|
||||||
await saveCipher(cipher);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.prepare(`DELETE FROM folders WHERE user_id = ? AND id IN (${placeholders})`)
|
.prepare(`DELETE FROM folders WHERE user_id = ? AND id IN (${placeholders})`)
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
// IMPORTANT:
|
// IMPORTANT:
|
||||||
// Keep this schema list in sync with migrations/0001_init.sql.
|
// This is the runtime D1 schema bootstrap. Keep it in sync with
|
||||||
// Any new table/column/index must be added to both places together.
|
// migrations/0001_init.sql. Any new table/column/index must be added to both
|
||||||
|
// places together.
|
||||||
|
//
|
||||||
|
// WHEN CHANGING THIS:
|
||||||
|
// - Bump STORAGE_SCHEMA_VERSION in src/services/storage.ts so existing installs
|
||||||
|
// rerun these idempotent statements.
|
||||||
|
// - If the new table stores persistent data, update the backup export/import
|
||||||
|
// contract in src/services/backup-archive.ts and backup-import.ts.
|
||||||
|
// - Keep statements idempotent; D1 may execute them again on later requests.
|
||||||
const SCHEMA_STATEMENTS: readonly string[] = [
|
const SCHEMA_STATEMENTS: readonly string[] = [
|
||||||
'CREATE TABLE IF NOT EXISTS users (' +
|
'CREATE TABLE IF NOT EXISTS users (' +
|
||||||
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
|
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
|
||||||
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
|
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
|
||||||
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
|
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
|
||||||
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||||
'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
|
'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
|
||||||
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
|
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
|
||||||
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
|
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
|
||||||
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1',
|
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1',
|
||||||
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
|
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
|
||||||
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
|
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
|
||||||
|
'ALTER TABLE users ADD COLUMN api_key TEXT',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS domain_settings (' +
|
||||||
|
'user_id TEXT PRIMARY KEY, equivalent_domains TEXT NOT NULL DEFAULT \'[]\', custom_equivalent_domains TEXT NOT NULL DEFAULT \'[]\', excluded_global_equivalent_domains TEXT NOT NULL DEFAULT \'[]\', updated_at TEXT NOT NULL, ' +
|
||||||
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
'ALTER TABLE domain_settings ADD COLUMN custom_equivalent_domains TEXT NOT NULL DEFAULT \'[]\'',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS user_revisions (' +
|
'CREATE TABLE IF NOT EXISTS user_revisions (' +
|
||||||
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
|
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
|
||||||
@@ -27,6 +41,8 @@ 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_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_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 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 INDEX IF NOT EXISTS idx_ciphers_user_folder ON ciphers(user_id, folder_id)',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS folders (' +
|
'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, ' +
|
'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 +63,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
'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_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_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 auth_type INTEGER NOT NULL DEFAULT 2',
|
||||||
'ALTER TABLE sends ADD COLUMN emails TEXT',
|
'ALTER TABLE sends ADD COLUMN emails TEXT',
|
||||||
|
|
||||||
@@ -65,13 +82,19 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)',
|
'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS audit_logs (' +
|
'CREATE TABLE IF NOT EXISTS audit_logs (' +
|
||||||
'id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, ' +
|
'id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, category TEXT NOT NULL DEFAULT \'system\', level TEXT NOT NULL DEFAULT \'info\', target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, ' +
|
||||||
'FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL)',
|
'FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL)',
|
||||||
|
'ALTER TABLE audit_logs ADD COLUMN category TEXT NOT NULL DEFAULT \'system\'',
|
||||||
|
'ALTER TABLE audit_logs ADD COLUMN level TEXT NOT NULL DEFAULT \'info\'',
|
||||||
|
'UPDATE audit_logs SET category = json_extract(metadata, \'$.category\') WHERE json_valid(metadata) AND json_extract(metadata, \'$.category\') IN (\'auth\', \'security\', \'device\', \'data\', \'system\')',
|
||||||
|
'UPDATE audit_logs SET level = json_extract(metadata, \'$.level\') WHERE json_valid(metadata) AND json_extract(metadata, \'$.level\') IN (\'info\', \'warn\', \'error\', \'security\')',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at)',
|
'CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at)',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
|
'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_audit_logs_category_created ON audit_logs(category, created_at)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at)',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS devices (' +
|
'CREATE TABLE IF NOT EXISTS devices (' +
|
||||||
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, ' +
|
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' +
|
||||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||||
'PRIMARY KEY (user_id, device_identifier), ' +
|
'PRIMARY KEY (user_id, device_identifier), ' +
|
||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
@@ -82,17 +105,15 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT',
|
'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT',
|
||||||
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
|
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
|
||||||
'ALTER TABLE devices ADD COLUMN banned_at TEXT',
|
'ALTER TABLE devices ADD COLUMN banned_at TEXT',
|
||||||
|
'ALTER TABLE devices ADD COLUMN device_note TEXT',
|
||||||
|
'ALTER TABLE devices ADD COLUMN last_seen_at TEXT',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' +
|
'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' +
|
||||||
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
|
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
|
||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)',
|
'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS api_rate_limits (' +
|
|
||||||
'identifier TEXT NOT NULL, window_start INTEGER NOT NULL, count INTEGER NOT NULL, ' +
|
|
||||||
'PRIMARY KEY (identifier, window_start))',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start)',
|
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
|
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
|
||||||
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
|
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedState
|
|||||||
const USER_SELECT_COLUMNS =
|
const USER_SELECT_COLUMNS =
|
||||||
'id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, ' +
|
'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, ' +
|
'kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, ' +
|
||||||
'totp_secret, totp_recovery_code, created_at, updated_at';
|
'totp_secret, totp_recovery_code, api_key, created_at, updated_at';
|
||||||
|
|
||||||
function mapUserRow(row: any): User {
|
function mapUserRow(row: any): User {
|
||||||
return {
|
return {
|
||||||
@@ -26,6 +26,7 @@ function mapUserRow(row: any): User {
|
|||||||
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
|
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
|
||||||
totpSecret: row.totp_secret ?? null,
|
totpSecret: row.totp_secret ?? null,
|
||||||
totpRecoveryCode: row.totp_recovery_code ?? null,
|
totpRecoveryCode: row.totp_recovery_code ?? null,
|
||||||
|
apiKey: row.api_key ?? null,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
};
|
};
|
||||||
@@ -64,11 +65,11 @@ export async function getAllUsers(db: D1Database): Promise<User[]> {
|
|||||||
export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
|
export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
|
||||||
const email = user.email.toLowerCase();
|
const email = user.email.toLowerCase();
|
||||||
const stmt = db.prepare(
|
const stmt = db.prepare(
|
||||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at) ' +
|
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' +
|
||||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||||
'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' +
|
'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' +
|
||||||
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at'
|
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, api_key=excluded.api_key, updated_at=excluded.updated_at'
|
||||||
);
|
);
|
||||||
await safeBind(
|
await safeBind(
|
||||||
stmt,
|
stmt,
|
||||||
@@ -90,6 +91,7 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User):
|
|||||||
user.verifyDevices ? 1 : 0,
|
user.verifyDevices ? 1 : 0,
|
||||||
user.totpSecret,
|
user.totpSecret,
|
||||||
user.totpRecoveryCode,
|
user.totpRecoveryCode,
|
||||||
|
user.apiKey,
|
||||||
user.createdAt,
|
user.createdAt,
|
||||||
user.updatedAt
|
user.updatedAt
|
||||||
).run();
|
).run();
|
||||||
@@ -102,8 +104,8 @@ export async function createUser(db: D1Database, safeBind: SafeBind, user: User)
|
|||||||
export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
|
export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
|
||||||
const email = user.email.toLowerCase();
|
const email = user.email.toLowerCase();
|
||||||
const stmt = db.prepare(
|
const stmt = db.prepare(
|
||||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at) ' +
|
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' +
|
||||||
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||||
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
|
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
|
||||||
);
|
);
|
||||||
const result = await safeBind(
|
const result = await safeBind(
|
||||||
@@ -126,6 +128,7 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user:
|
|||||||
user.verifyDevices ? 1 : 0,
|
user.verifyDevices ? 1 : 0,
|
||||||
user.totpSecret,
|
user.totpSecret,
|
||||||
user.totpRecoveryCode,
|
user.totpRecoveryCode,
|
||||||
|
user.apiKey,
|
||||||
user.createdAt,
|
user.createdAt,
|
||||||
user.updatedAt
|
user.updatedAt
|
||||||
).run();
|
).run();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord } from '../types';
|
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain } from '../types';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
import { ensureStorageSchema } from './storage-schema';
|
import { ensureStorageSchema } from './storage-schema';
|
||||||
import {
|
import {
|
||||||
@@ -18,12 +18,17 @@ import {
|
|||||||
saveUser as saveStoredUser,
|
saveUser as saveStoredUser,
|
||||||
} from './storage-user-repo';
|
} from './storage-user-repo';
|
||||||
import {
|
import {
|
||||||
|
type AuditLogListOptions,
|
||||||
createAuditLog as createStoredAuditLog,
|
createAuditLog as createStoredAuditLog,
|
||||||
|
clearAuditLogs as clearStoredAuditLogs,
|
||||||
createInvite as createStoredInvite,
|
createInvite as createStoredInvite,
|
||||||
deleteAllInvites as deleteStoredInvites,
|
deleteAllInvites as deleteStoredInvites,
|
||||||
getInvite as findStoredInvite,
|
getInvite as findStoredInvite,
|
||||||
|
listAuditLogs as listStoredAuditLogs,
|
||||||
listInvites as listStoredInvites,
|
listInvites as listStoredInvites,
|
||||||
markInviteUsed as markStoredInviteUsed,
|
markInviteUsed as markStoredInviteUsed,
|
||||||
|
pruneAuditLogs as pruneStoredAuditLogs,
|
||||||
|
pruneAuditLogsToMax as pruneStoredAuditLogsToMax,
|
||||||
revokeInvite as revokeStoredInvite,
|
revokeInvite as revokeStoredInvite,
|
||||||
} from './storage-admin-repo';
|
} from './storage-admin-repo';
|
||||||
import {
|
import {
|
||||||
@@ -51,13 +56,13 @@ import {
|
|||||||
} from './storage-cipher-repo';
|
} from './storage-cipher-repo';
|
||||||
import {
|
import {
|
||||||
addAttachmentToCipher as attachStoredAttachmentToCipher,
|
addAttachmentToCipher as attachStoredAttachmentToCipher,
|
||||||
|
bulkDeleteAttachmentsByIds as deleteStoredAttachmentsByIds,
|
||||||
deleteAllAttachmentsByCipher as deleteStoredAttachmentsByCipher,
|
deleteAllAttachmentsByCipher as deleteStoredAttachmentsByCipher,
|
||||||
deleteAttachment as deleteStoredAttachment,
|
deleteAttachment as deleteStoredAttachment,
|
||||||
getAttachment as findStoredAttachment,
|
getAttachment as findStoredAttachment,
|
||||||
getAttachmentsByCipher as listStoredAttachmentsByCipher,
|
getAttachmentsByCipher as listStoredAttachmentsByCipher,
|
||||||
getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds,
|
getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds,
|
||||||
getAttachmentsByUserId as listStoredAttachmentsByUserId,
|
getAttachmentsByUserId as listStoredAttachmentsByUserId,
|
||||||
removeAttachmentFromCipher as detachStoredAttachmentFromCipher,
|
|
||||||
saveAttachment as saveStoredAttachment,
|
saveAttachment as saveStoredAttachment,
|
||||||
updateCipherRevisionDate as updateStoredCipherRevisionDate,
|
updateCipherRevisionDate as updateStoredCipherRevisionDate,
|
||||||
} from './storage-attachment-repo';
|
} from './storage-attachment-repo';
|
||||||
@@ -92,8 +97,11 @@ import {
|
|||||||
isKnownDevice as getKnownStoredDevice,
|
isKnownDevice as getKnownStoredDevice,
|
||||||
isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
|
isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
|
||||||
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
|
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
|
||||||
|
touchDeviceLastSeen as touchStoredDeviceLastSeen,
|
||||||
upsertDevice as saveStoredDevice,
|
upsertDevice as saveStoredDevice,
|
||||||
|
updateDeviceName as updateStoredDeviceName,
|
||||||
updateDeviceKeys as updateStoredDeviceKeys,
|
updateDeviceKeys as updateStoredDeviceKeys,
|
||||||
|
updateTrustedTwoFactorTokensExpiryByDevice as updateStoredTrustedTokensExpiryByDevice,
|
||||||
} from './storage-device-repo';
|
} from './storage-device-repo';
|
||||||
import {
|
import {
|
||||||
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
|
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
|
||||||
@@ -103,10 +111,18 @@ import {
|
|||||||
getRevisionDate as getStoredRevisionDate,
|
getRevisionDate as getStoredRevisionDate,
|
||||||
updateRevisionDate as updateStoredRevisionDate,
|
updateRevisionDate as updateStoredRevisionDate,
|
||||||
} from './storage-revision-repo';
|
} from './storage-revision-repo';
|
||||||
|
import {
|
||||||
|
getUserDomainSettings as getStoredUserDomainSettings,
|
||||||
|
saveUserDomainSettings as saveStoredUserDomainSettings,
|
||||||
|
} from './storage-domain-rules-repo';
|
||||||
|
|
||||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
||||||
const STORAGE_SCHEMA_VERSION = '2026-03-23.1';
|
// IMPORTANT:
|
||||||
|
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
|
||||||
|
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
|
||||||
|
// differs from config.schema.version.
|
||||||
|
const STORAGE_SCHEMA_VERSION = '2026-05-14-lightweight-audit-logs';
|
||||||
|
|
||||||
// D1-backed storage.
|
// D1-backed storage.
|
||||||
// Contract:
|
// Contract:
|
||||||
@@ -268,6 +284,45 @@ export class StorageService {
|
|||||||
await createStoredAuditLog(this.db, log);
|
await createStoredAuditLog(this.db, log);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listAuditLogs(options: AuditLogListOptions): Promise<{ logs: AuditLog[]; total: number; hasMore: boolean }> {
|
||||||
|
return listStoredAuditLogs(this.db, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
async pruneAuditLogs(beforeIso: string): Promise<number> {
|
||||||
|
return pruneStoredAuditLogs(this.db, beforeIso);
|
||||||
|
}
|
||||||
|
|
||||||
|
async pruneAuditLogsToMax(maxEntries: number): Promise<number> {
|
||||||
|
return pruneStoredAuditLogsToMax(this.db, maxEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearAuditLogs(): Promise<number> {
|
||||||
|
return clearStoredAuditLogs(this.db);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Domain rules ---
|
||||||
|
|
||||||
|
async getUserDomainSettings(userId: string) {
|
||||||
|
return getStoredUserDomainSettings(this.db, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveUserDomainSettings(
|
||||||
|
userId: string,
|
||||||
|
equivalentDomains: string[][],
|
||||||
|
customEquivalentDomains: CustomEquivalentDomain[],
|
||||||
|
excludedGlobalEquivalentDomains: number[]
|
||||||
|
): Promise<void> {
|
||||||
|
await saveStoredUserDomainSettings(
|
||||||
|
this.db,
|
||||||
|
userId,
|
||||||
|
equivalentDomains,
|
||||||
|
customEquivalentDomains,
|
||||||
|
excludedGlobalEquivalentDomains,
|
||||||
|
new Date().toISOString()
|
||||||
|
);
|
||||||
|
await this.updateRevisionDate(userId);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Ciphers ---
|
// --- Ciphers ---
|
||||||
|
|
||||||
async getCipher(id: string): Promise<Cipher | null> {
|
async getCipher(id: string): Promise<Cipher | null> {
|
||||||
@@ -338,7 +393,6 @@ export class StorageService {
|
|||||||
userId,
|
userId,
|
||||||
ids,
|
ids,
|
||||||
this.sqlChunkSize.bind(this),
|
this.sqlChunkSize.bind(this),
|
||||||
this.saveCipher.bind(this),
|
|
||||||
this.updateRevisionDate.bind(this)
|
this.updateRevisionDate.bind(this)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -346,7 +400,7 @@ export class StorageService {
|
|||||||
// Clear folder references from all ciphers owned by the user.
|
// Clear folder references from all ciphers owned by the user.
|
||||||
// Without this, deleting a folder leaves stale folderId values in cipher JSON.
|
// Without this, deleting a folder leaves stale folderId values in cipher JSON.
|
||||||
async clearFolderFromCiphers(userId: string, folderId: string): Promise<void> {
|
async clearFolderFromCiphers(userId: string, folderId: string): Promise<void> {
|
||||||
await clearStoredFolderFromCiphers(this.db, userId, folderId, this.saveCipher.bind(this));
|
await clearStoredFolderFromCiphers(this.db, userId, folderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllFolders(userId: string): Promise<Folder[]> {
|
async getAllFolders(userId: string): Promise<Folder[]> {
|
||||||
@@ -371,6 +425,10 @@ export class StorageService {
|
|||||||
await deleteStoredAttachment(this.db, id);
|
await deleteStoredAttachment(this.db, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async bulkDeleteAttachmentsByIds(ids: string[]): Promise<void> {
|
||||||
|
await deleteStoredAttachmentsByIds(this.db, this.sqlChunkSize.bind(this), ids);
|
||||||
|
}
|
||||||
|
|
||||||
async getAttachmentsByCipher(cipherId: string): Promise<Attachment[]> {
|
async getAttachmentsByCipher(cipherId: string): Promise<Attachment[]> {
|
||||||
return listStoredAttachmentsByCipher(this.db, cipherId);
|
return listStoredAttachmentsByCipher(this.db, cipherId);
|
||||||
}
|
}
|
||||||
@@ -387,10 +445,6 @@ export class StorageService {
|
|||||||
await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId);
|
await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise<void> {
|
|
||||||
await detachStoredAttachmentFromCipher(cipherId, attachmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> {
|
async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> {
|
||||||
await deleteStoredAttachmentsByCipher(this.db, cipherId);
|
await deleteStoredAttachmentsByCipher(this.db, cipherId);
|
||||||
}
|
}
|
||||||
@@ -550,6 +604,14 @@ export class StorageService {
|
|||||||
return updateStoredDeviceKeys(this.db, userId, deviceIdentifier, keys);
|
return updateStoredDeviceKeys(this.db, userId, deviceIdentifier, keys);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateDeviceName(userId: string, deviceIdentifier: string, name: string): Promise<boolean> {
|
||||||
|
return updateStoredDeviceName(this.db, userId, deviceIdentifier, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async touchDeviceLastSeen(userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||||
|
return touchStoredDeviceLastSeen(this.db, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> {
|
async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> {
|
||||||
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
|
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
|
||||||
}
|
}
|
||||||
@@ -574,6 +636,10 @@ export class StorageService {
|
|||||||
return deleteStoredTrustedTokensByUserId(this.db, userId);
|
return deleteStoredTrustedTokensByUserId(this.db, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateTrustedTwoFactorTokensExpiryByDevice(userId: string, deviceIdentifier: string, expiresAtMs: number): Promise<number> {
|
||||||
|
return updateStoredTrustedTokensExpiryByDevice(this.db, userId, deviceIdentifier, expiresAtMs);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Trusted 2FA remember tokens (device-bound) ---
|
// --- Trusted 2FA remember tokens (device-bound) ---
|
||||||
|
|
||||||
async saveTrustedTwoFactorDeviceToken(
|
async saveTrustedTwoFactorDeviceToken(
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
[
|
||||||
|
{"type":2,"domains":["ameritrade.com","tdameritrade.com"],"excluded":false},
|
||||||
|
{"type":3,"domains":["bankofamerica.com","bofa.com","mbna.com","usecfo.com"],"excluded":false},
|
||||||
|
{"type":4,"domains":["sprint.com","sprintpcs.com","nextel.com"],"excluded":false},
|
||||||
|
{"type":0,"domains":["youtube.com","google.com","gmail.com"],"excluded":false},
|
||||||
|
{"type":1,"domains":["apple.com","icloud.com"],"excluded":false},
|
||||||
|
{"type":5,"domains":["wellsfargo.com","wf.com","wellsfargoadvisors.com"],"excluded":false},
|
||||||
|
{"type":6,"domains":["mymerrill.com","ml.com","merrilledge.com"],"excluded":false},
|
||||||
|
{"type":7,"domains":["accountonline.com","citi.com","citibank.com","citicards.com","citibankonline.com"],"excluded":false},
|
||||||
|
{"type":8,"domains":["cnet.com","cnettv.com","com.com","download.com","news.com","search.com","upload.com"],"excluded":false},
|
||||||
|
{"type":9,"domains":["bananarepublic.com","gap.com","oldnavy.com","piperlime.com"],"excluded":false},
|
||||||
|
{"type":10,"domains":["bing.com","hotmail.com","live.com","microsoft.com","msn.com","passport.net","windows.com","microsoftonline.com","office.com","office365.com","microsoftstore.com","xbox.com","azure.com","windowsazure.com","cloud.microsoft"],"excluded":false},
|
||||||
|
{"type":11,"domains":["ua2go.com","ual.com","united.com","unitedwifi.com"],"excluded":false},
|
||||||
|
{"type":12,"domains":["overture.com","yahoo.com"],"excluded":false},
|
||||||
|
{"type":13,"domains":["zonealarm.com","zonelabs.com"],"excluded":false},
|
||||||
|
{"type":14,"domains":["paypal.com","paypal-search.com"],"excluded":false},
|
||||||
|
{"type":15,"domains":["avon.com","youravon.com"],"excluded":false},
|
||||||
|
{"type":16,"domains":["diapers.com","soap.com","wag.com","yoyo.com","beautybar.com","casa.com","afterschool.com","vine.com","bookworm.com","look.com","vinemarket.com"],"excluded":false},
|
||||||
|
{"type":17,"domains":["1800contacts.com","800contacts.com"],"excluded":false},
|
||||||
|
{"type":18,"domains":["amazon.com","amazon.com.be","amazon.ae","amazon.ca","amazon.co.uk","amazon.com.au","amazon.com.br","amazon.com.mx","amazon.com.tr","amazon.de","amazon.es","amazon.fr","amazon.in","amazon.it","amazon.nl","amazon.pl","amazon.sa","amazon.se","amazon.sg"],"excluded":false},
|
||||||
|
{"type":19,"domains":["cox.com","cox.net","coxbusiness.com"],"excluded":false},
|
||||||
|
{"type":20,"domains":["mynortonaccount.com","norton.com"],"excluded":false},
|
||||||
|
{"type":21,"domains":["verizon.com","verizon.net"],"excluded":false},
|
||||||
|
{"type":22,"domains":["rakuten.com","buy.com"],"excluded":false},
|
||||||
|
{"type":23,"domains":["siriusxm.com","sirius.com"],"excluded":false},
|
||||||
|
{"type":24,"domains":["ea.com","origin.com","play4free.com","tiberiumalliance.com"],"excluded":false},
|
||||||
|
{"type":25,"domains":["37signals.com","basecamp.com","basecamphq.com","highrisehq.com"],"excluded":false},
|
||||||
|
{"type":26,"domains":["steampowered.com","steamcommunity.com","steamgames.com"],"excluded":false},
|
||||||
|
{"type":27,"domains":["chart.io","chartio.com"],"excluded":false},
|
||||||
|
{"type":28,"domains":["gotomeeting.com","citrixonline.com"],"excluded":false},
|
||||||
|
{"type":29,"domains":["gogoair.com","gogoinflight.com"],"excluded":false},
|
||||||
|
{"type":30,"domains":["mysql.com","oracle.com"],"excluded":false},
|
||||||
|
{"type":31,"domains":["discover.com","discovercard.com"],"excluded":false},
|
||||||
|
{"type":32,"domains":["dcu.org","dcu-online.org"],"excluded":false},
|
||||||
|
{"type":33,"domains":["healthcare.gov","cuidadodesalud.gov","cms.gov"],"excluded":false},
|
||||||
|
{"type":34,"domains":["pepco.com","pepcoholdings.com"],"excluded":false},
|
||||||
|
{"type":35,"domains":["century21.com","21online.com"],"excluded":false},
|
||||||
|
{"type":36,"domains":["comcast.com","comcast.net","xfinity.com"],"excluded":false},
|
||||||
|
{"type":37,"domains":["cricketwireless.com","aiowireless.com"],"excluded":false},
|
||||||
|
{"type":38,"domains":["mandtbank.com","mtb.com"],"excluded":false},
|
||||||
|
{"type":39,"domains":["dropbox.com","getdropbox.com"],"excluded":false},
|
||||||
|
{"type":40,"domains":["snapfish.com","snapfish.ca"],"excluded":false},
|
||||||
|
{"type":41,"domains":["alibaba.com","aliexpress.com","aliyun.com","net.cn"],"excluded":false},
|
||||||
|
{"type":42,"domains":["playstation.com","sonyentertainmentnetwork.com"],"excluded":false},
|
||||||
|
{"type":43,"domains":["mercadolivre.com","mercadolivre.com.br","mercadolibre.com","mercadolibre.com.ar","mercadolibre.com.mx"],"excluded":false},
|
||||||
|
{"type":44,"domains":["zendesk.com","zopim.com"],"excluded":false},
|
||||||
|
{"type":45,"domains":["autodesk.com","tinkercad.com"],"excluded":false},
|
||||||
|
{"type":46,"domains":["railnation.ru","railnation.de","rail-nation.com","railnation.gr","railnation.us","trucknation.de","traviangames.com"],"excluded":false},
|
||||||
|
{"type":47,"domains":["wpcu.coop","wpcuonline.com"],"excluded":false},
|
||||||
|
{"type":48,"domains":["mathletics.com","mathletics.com.au","mathletics.co.uk"],"excluded":false},
|
||||||
|
{"type":49,"domains":["discountbank.co.il","telebank.co.il"],"excluded":false},
|
||||||
|
{"type":50,"domains":["mi.com","xiaomi.com"],"excluded":false},
|
||||||
|
{"type":52,"domains":["postepay.it","poste.it"],"excluded":false},
|
||||||
|
{"type":51,"domains":["facebook.com","messenger.com"],"excluded":false},
|
||||||
|
{"type":53,"domains":["skysports.com","skybet.com","skyvegas.com"],"excluded":false},
|
||||||
|
{"type":54,"domains":["disneymoviesanywhere.com","go.com","disney.com","dadt.com","disneyplus.com"],"excluded":false},
|
||||||
|
{"type":55,"domains":["pokemon-gl.com","pokemon.com"],"excluded":false},
|
||||||
|
{"type":56,"domains":["myuv.com","uvvu.com"],"excluded":false},
|
||||||
|
{"type":58,"domains":["mdsol.com","imedidata.com"],"excluded":false},
|
||||||
|
{"type":57,"domains":["bank-yahav.co.il","bankhapoalim.co.il"],"excluded":false},
|
||||||
|
{"type":59,"domains":["sears.com","shld.net"],"excluded":false},
|
||||||
|
{"type":60,"domains":["xiami.com","alipay.com"],"excluded":false},
|
||||||
|
{"type":61,"domains":["belkin.com","seedonk.com"],"excluded":false},
|
||||||
|
{"type":62,"domains":["turbotax.com","intuit.com"],"excluded":false},
|
||||||
|
{"type":63,"domains":["shopify.com","myshopify.com"],"excluded":false},
|
||||||
|
{"type":64,"domains":["ebay.com","ebay.at","ebay.be","ebay.ca","ebay.ch","ebay.cn","ebay.co.jp","ebay.co.th","ebay.co.uk","ebay.com.au","ebay.com.hk","ebay.com.my","ebay.com.sg","ebay.com.tw","ebay.de","ebay.es","ebay.fr","ebay.ie","ebay.in","ebay.it","ebay.nl","ebay.ph","ebay.pl"],"excluded":false},
|
||||||
|
{"type":65,"domains":["techdata.com","techdata.ch"],"excluded":false},
|
||||||
|
{"type":66,"domains":["schwab.com","schwabplan.com"],"excluded":false},
|
||||||
|
{"type":68,"domains":["tesla.com","teslamotors.com"],"excluded":false},
|
||||||
|
{"type":69,"domains":["morganstanley.com","morganstanleyclientserv.com","stockplanconnect.com","ms.com"],"excluded":false},
|
||||||
|
{"type":70,"domains":["taxact.com","taxactonline.com"],"excluded":false},
|
||||||
|
{"type":71,"domains":["mediawiki.org","wikibooks.org","wikidata.org","wikimedia.org","wikinews.org","wikipedia.org","wikiquote.org","wikisource.org","wikiversity.org","wikivoyage.org","wiktionary.org"],"excluded":false},
|
||||||
|
{"type":72,"domains":["airbnb.at","airbnb.be","airbnb.ca","airbnb.ch","airbnb.cl","airbnb.co.cr","airbnb.co.id","airbnb.co.in","airbnb.co.kr","airbnb.co.nz","airbnb.co.uk","airbnb.co.ve","airbnb.com","airbnb.com.ar","airbnb.com.au","airbnb.com.bo","airbnb.com.br","airbnb.com.bz","airbnb.com.co","airbnb.com.ec","airbnb.com.gt","airbnb.com.hk","airbnb.com.hn","airbnb.com.mt","airbnb.com.my","airbnb.com.ni","airbnb.com.pa","airbnb.com.pe","airbnb.com.py","airbnb.com.sg","airbnb.com.sv","airbnb.com.tr","airbnb.com.tw","airbnb.cz","airbnb.de","airbnb.dk","airbnb.es","airbnb.fi","airbnb.fr","airbnb.gr","airbnb.gy","airbnb.hu","airbnb.ie","airbnb.is","airbnb.it","airbnb.jp","airbnb.mx","airbnb.nl","airbnb.no","airbnb.pl","airbnb.pt","airbnb.ru","airbnb.se"],"excluded":false},
|
||||||
|
{"type":73,"domains":["eventbrite.at","eventbrite.be","eventbrite.ca","eventbrite.ch","eventbrite.cl","eventbrite.co","eventbrite.co.nz","eventbrite.co.uk","eventbrite.com","eventbrite.com.ar","eventbrite.com.au","eventbrite.com.br","eventbrite.com.mx","eventbrite.com.pe","eventbrite.de","eventbrite.dk","eventbrite.es","eventbrite.fi","eventbrite.fr","eventbrite.hk","eventbrite.ie","eventbrite.it","eventbrite.nl","eventbrite.pt","eventbrite.se","eventbrite.sg"],"excluded":false},
|
||||||
|
{"type":74,"domains":["stackexchange.com","superuser.com","stackoverflow.com","serverfault.com","mathoverflow.net","askubuntu.com","stackapps.com"],"excluded":false},
|
||||||
|
{"type":75,"domains":["docusign.com","docusign.net"],"excluded":false},
|
||||||
|
{"type":76,"domains":["envato.com","themeforest.net","codecanyon.net","videohive.net","audiojungle.net","graphicriver.net","photodune.net","3docean.net"],"excluded":false},
|
||||||
|
{"type":77,"domains":["x10hosting.com","x10premium.com"],"excluded":false},
|
||||||
|
{"type":78,"domains":["dnsomatic.com","opendns.com","umbrella.com"],"excluded":false},
|
||||||
|
{"type":79,"domains":["cagreatamerica.com","canadaswonderland.com","carowinds.com","cedarfair.com","cedarpoint.com","dorneypark.com","kingsdominion.com","knotts.com","miadventure.com","schlitterbahn.com","valleyfair.com","visitkingsisland.com","worldsoffun.com"],"excluded":false},
|
||||||
|
{"type":80,"domains":["ubnt.com","ui.com"],"excluded":false},
|
||||||
|
{"type":81,"domains":["discordapp.com","discord.com"],"excluded":false},
|
||||||
|
{"type":82,"domains":["netcup.de","netcup.eu","customercontrolpanel.de"],"excluded":false},
|
||||||
|
{"type":83,"domains":["yandex.com","ya.ru","yandex.az","yandex.by","yandex.co.il","yandex.com.am","yandex.com.ge","yandex.com.tr","yandex.ee","yandex.fi","yandex.fr","yandex.kg","yandex.kz","yandex.lt","yandex.lv","yandex.md","yandex.pl","yandex.ru","yandex.tj","yandex.tm","yandex.ua","yandex.uz"],"excluded":false},
|
||||||
|
{"type":84,"domains":["sonyentertainmentnetwork.com","sony.com"],"excluded":false},
|
||||||
|
{"type":85,"domains":["proton.me","protonmail.com","protonvpn.com"],"excluded":false},
|
||||||
|
{"type":86,"domains":["ubisoft.com","ubi.com"],"excluded":false},
|
||||||
|
{"type":87,"domains":["transferwise.com","wise.com"],"excluded":false},
|
||||||
|
{"type":88,"domains":["takeaway.com","just-eat.dk","just-eat.no","just-eat.fr","just-eat.ch","lieferando.de","lieferando.at","thuisbezorgd.nl","pyszne.pl"],"excluded":false},
|
||||||
|
{"type":89,"domains":["atlassian.com","bitbucket.org","trello.com","statuspage.io","atlassian.net","jira.com"],"excluded":false},
|
||||||
|
{"type":90,"domains":["pinterest.com","pinterest.com.au","pinterest.cl","pinterest.de","pinterest.dk","pinterest.es","pinterest.fr","pinterest.co.uk","pinterest.jp","pinterest.co.kr","pinterest.nz","pinterest.pt","pinterest.se"],"excluded":false},
|
||||||
|
{"type":91,"domains":["twitter.com","x.com"],"excluded":false}
|
||||||
|
]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"source": "https://github.com/bitwarden/server",
|
||||||
|
"ref": "main",
|
||||||
|
"generatedAt": "2026-05-05T00:00:00.000Z",
|
||||||
|
"rulesCount": 91,
|
||||||
|
"domainsCount": 436,
|
||||||
|
"sourceFiles": [
|
||||||
|
"src/Core/Enums/GlobalEquivalentDomainsType.cs",
|
||||||
|
"src/Core/Utilities/StaticStore.cs"
|
||||||
|
],
|
||||||
|
"sourceUrls": [
|
||||||
|
"https://raw.githubusercontent.com/bitwarden/server/main/src/Core/Enums/GlobalEquivalentDomainsType.cs",
|
||||||
|
"https://raw.githubusercontent.com/bitwarden/server/main/src/Core/Utilities/StaticStore.cs"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[
|
||||||
|
{"type":-10001,"domains":["nodewarden.example","nw.example"],"excluded":false,"source":"nodewarden"}
|
||||||
|
]
|
||||||
@@ -50,10 +50,39 @@ export interface User {
|
|||||||
verifyDevices?: boolean;
|
verifyDevices?: boolean;
|
||||||
totpSecret: string | null;
|
totpSecret: string | null;
|
||||||
totpRecoveryCode: string | null;
|
totpRecoveryCode: string | null;
|
||||||
|
apiKey: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserDomainSettings {
|
||||||
|
userId: string;
|
||||||
|
equivalentDomains: string[][];
|
||||||
|
customEquivalentDomains: CustomEquivalentDomain[];
|
||||||
|
excludedGlobalEquivalentDomains: number[];
|
||||||
|
updatedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomEquivalentDomain {
|
||||||
|
id: string;
|
||||||
|
domains: string[];
|
||||||
|
excluded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlobalEquivalentDomain {
|
||||||
|
type: number;
|
||||||
|
domains: string[];
|
||||||
|
excluded: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DomainRulesResponse {
|
||||||
|
equivalentDomains: string[][];
|
||||||
|
customEquivalentDomains: CustomEquivalentDomain[];
|
||||||
|
globalEquivalentDomains: GlobalEquivalentDomain[];
|
||||||
|
object: 'domains';
|
||||||
|
}
|
||||||
|
|
||||||
export interface Invite {
|
export interface Invite {
|
||||||
code: string;
|
code: string;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
@@ -67,9 +96,13 @@ export interface Invite {
|
|||||||
export interface AuditLog {
|
export interface AuditLog {
|
||||||
id: string;
|
id: string;
|
||||||
actorUserId: string | null;
|
actorUserId: string | null;
|
||||||
|
actorEmail?: string | null;
|
||||||
action: string;
|
action: string;
|
||||||
|
category: 'auth' | 'security' | 'device' | 'data' | 'system';
|
||||||
|
level: 'info' | 'warn' | 'error' | 'security';
|
||||||
targetType: string | null;
|
targetType: string | null;
|
||||||
targetId: string | null;
|
targetId: string | null;
|
||||||
|
targetUserEmail?: string | null;
|
||||||
metadata: string | null;
|
metadata: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -189,12 +222,14 @@ export interface Device {
|
|||||||
userId: string;
|
userId: string;
|
||||||
deviceIdentifier: string;
|
deviceIdentifier: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
deviceNote: string | null;
|
||||||
type: number;
|
type: number;
|
||||||
sessionStamp: string;
|
sessionStamp: string;
|
||||||
encryptedUserKey: string | null;
|
encryptedUserKey: string | null;
|
||||||
encryptedPublicKey: string | null;
|
encryptedPublicKey: string | null;
|
||||||
encryptedPrivateKey: string | null;
|
encryptedPrivateKey: string | null;
|
||||||
devicePendingAuthRequest?: DevicePendingAuthRequest | null;
|
devicePendingAuthRequest?: DevicePendingAuthRequest | null;
|
||||||
|
lastSeenAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -208,10 +243,14 @@ export interface DeviceResponse {
|
|||||||
id: string;
|
id: string;
|
||||||
userId?: string | null;
|
userId?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
systemName?: string | null;
|
||||||
|
deviceNote?: string | null;
|
||||||
identifier: string;
|
identifier: string;
|
||||||
type: number;
|
type: number;
|
||||||
creationDate: string;
|
creationDate: string;
|
||||||
revisionDate: string;
|
revisionDate: string;
|
||||||
|
lastSeenAt?: string | null;
|
||||||
|
hasStoredDevice?: boolean;
|
||||||
isTrusted: boolean;
|
isTrusted: boolean;
|
||||||
encryptedUserKey: string | null;
|
encryptedUserKey: string | null;
|
||||||
encryptedPublicKey: string | null;
|
encryptedPublicKey: string | null;
|
||||||
@@ -347,7 +386,8 @@ export interface TokenResponse {
|
|||||||
access_token: string;
|
access_token: string;
|
||||||
expires_in: number;
|
expires_in: number;
|
||||||
token_type: string;
|
token_type: string;
|
||||||
refresh_token: string;
|
refresh_token?: string;
|
||||||
|
web_session?: boolean;
|
||||||
TwoFactorToken?: string;
|
TwoFactorToken?: string;
|
||||||
Key: string;
|
Key: string;
|
||||||
PrivateKey: string | null;
|
PrivateKey: string | null;
|
||||||
@@ -367,6 +407,10 @@ export interface TokenResponse {
|
|||||||
accountKeys?: any | null;
|
accountKeys?: any | null;
|
||||||
UserDecryptionOptions: UserDecryptionOptions;
|
UserDecryptionOptions: UserDecryptionOptions;
|
||||||
userDecryptionOptions?: UserDecryptionOptions;
|
userDecryptionOptions?: UserDecryptionOptions;
|
||||||
|
VaultKeys?: {
|
||||||
|
symEncKey: string;
|
||||||
|
symMacKey: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProfileResponse {
|
export interface ProfileResponse {
|
||||||
@@ -438,6 +482,7 @@ export interface FolderResponse {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
revisionDate: string;
|
revisionDate: string;
|
||||||
|
creationDate: string;
|
||||||
object: string;
|
object: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { JWTPayload } from '../types';
|
import { JWTPayload } from '../types';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
|
|
||||||
|
const hmacKeyCache = new Map<string, Promise<CryptoKey>>();
|
||||||
|
|
||||||
// Base64 URL encode
|
// Base64 URL encode
|
||||||
function base64UrlEncode(data: Uint8Array): string {
|
function base64UrlEncode(data: Uint8Array): string {
|
||||||
const base64 = btoa(String.fromCharCode(...data));
|
const base64 = btoa(String.fromCharCode(...data));
|
||||||
@@ -19,6 +21,23 @@ function base64UrlDecode(str: string): Uint8Array {
|
|||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getHmacKey(secret: string): Promise<CryptoKey> {
|
||||||
|
const cacheKey = secret;
|
||||||
|
let cached = hmacKeyCache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
cached = crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(secret),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign', 'verify']
|
||||||
|
);
|
||||||
|
hmacKeyCache.set(cacheKey, cached);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
// Create JWT
|
// Create JWT
|
||||||
export async function createJWT(payload: Omit<JWTPayload, 'iat' | 'exp' | 'iss' | 'premium' | 'email_verified' | 'amr'>, secret: string, expiresIn: number = LIMITS.auth.accessTokenTtlSeconds): Promise<string> {
|
export async function createJWT(payload: Omit<JWTPayload, 'iat' | 'exp' | 'iss' | 'premium' | 'email_verified' | 'amr'>, secret: string, expiresIn: number = LIMITS.auth.accessTokenTtlSeconds): Promise<string> {
|
||||||
const header = { alg: 'HS256', typ: 'JWT' };
|
const header = { alg: 'HS256', typ: 'JWT' };
|
||||||
@@ -40,13 +59,7 @@ export async function createJWT(payload: Omit<JWTPayload, 'iat' | 'exp' | 'iss'
|
|||||||
|
|
||||||
const data = `${headerB64}.${payloadB64}`;
|
const data = `${headerB64}.${payloadB64}`;
|
||||||
|
|
||||||
const key = await crypto.subtle.importKey(
|
const key = await getHmacKey(secret);
|
||||||
'raw',
|
|
||||||
encoder.encode(secret),
|
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['sign']
|
|
||||||
);
|
|
||||||
|
|
||||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||||
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
||||||
@@ -63,13 +76,7 @@ export async function verifyJWT(token: string, secret: string): Promise<JWTPaylo
|
|||||||
const [headerB64, payloadB64, signatureB64] = parts;
|
const [headerB64, payloadB64, signatureB64] = parts;
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
const key = await crypto.subtle.importKey(
|
const key = await getHmacKey(secret);
|
||||||
'raw',
|
|
||||||
encoder.encode(secret),
|
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['verify']
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = `${headerB64}.${payloadB64}`;
|
const data = `${headerB64}.${payloadB64}`;
|
||||||
const signature = base64UrlDecode(signatureB64);
|
const signature = base64UrlDecode(signatureB64);
|
||||||
@@ -133,13 +140,7 @@ export async function createFileDownloadToken(
|
|||||||
|
|
||||||
const data = `${headerB64}.${payloadB64}`;
|
const data = `${headerB64}.${payloadB64}`;
|
||||||
|
|
||||||
const key = await crypto.subtle.importKey(
|
const key = await getHmacKey(secret);
|
||||||
'raw',
|
|
||||||
encoder.encode(secret),
|
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['sign']
|
|
||||||
);
|
|
||||||
|
|
||||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||||
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
||||||
@@ -159,13 +160,7 @@ export async function verifyFileDownloadToken(
|
|||||||
const [headerB64, payloadB64, signatureB64] = parts;
|
const [headerB64, payloadB64, signatureB64] = parts;
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
const key = await crypto.subtle.importKey(
|
const key = await getHmacKey(secret);
|
||||||
'raw',
|
|
||||||
encoder.encode(secret),
|
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['verify']
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = `${headerB64}.${payloadB64}`;
|
const data = `${headerB64}.${payloadB64}`;
|
||||||
const signature = base64UrlDecode(signatureB64);
|
const signature = base64UrlDecode(signatureB64);
|
||||||
@@ -205,13 +200,7 @@ export async function createAttachmentUploadToken(
|
|||||||
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
|
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
|
||||||
const data = `${headerB64}.${payloadB64}`;
|
const data = `${headerB64}.${payloadB64}`;
|
||||||
|
|
||||||
const key = await crypto.subtle.importKey(
|
const key = await getHmacKey(secret);
|
||||||
'raw',
|
|
||||||
encoder.encode(secret),
|
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['sign']
|
|
||||||
);
|
|
||||||
|
|
||||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||||
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
||||||
@@ -229,13 +218,7 @@ export async function verifyAttachmentUploadToken(
|
|||||||
const [headerB64, payloadB64, signatureB64] = parts;
|
const [headerB64, payloadB64, signatureB64] = parts;
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
const key = await crypto.subtle.importKey(
|
const key = await getHmacKey(secret);
|
||||||
'raw',
|
|
||||||
encoder.encode(secret),
|
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['verify']
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = `${headerB64}.${payloadB64}`;
|
const data = `${headerB64}.${payloadB64}`;
|
||||||
const signature = base64UrlDecode(signatureB64);
|
const signature = base64UrlDecode(signatureB64);
|
||||||
@@ -285,13 +268,7 @@ export async function createSendFileDownloadToken(
|
|||||||
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
|
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
|
||||||
const data = `${headerB64}.${payloadB64}`;
|
const data = `${headerB64}.${payloadB64}`;
|
||||||
|
|
||||||
const key = await crypto.subtle.importKey(
|
const key = await getHmacKey(secret);
|
||||||
'raw',
|
|
||||||
encoder.encode(secret),
|
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['sign']
|
|
||||||
);
|
|
||||||
|
|
||||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||||
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
||||||
@@ -309,13 +286,7 @@ export async function verifySendFileDownloadToken(
|
|||||||
const [headerB64, payloadB64, signatureB64] = parts;
|
const [headerB64, payloadB64, signatureB64] = parts;
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
const key = await crypto.subtle.importKey(
|
const key = await getHmacKey(secret);
|
||||||
'raw',
|
|
||||||
encoder.encode(secret),
|
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['verify']
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = `${headerB64}.${payloadB64}`;
|
const data = `${headerB64}.${payloadB64}`;
|
||||||
const signature = base64UrlDecode(signatureB64);
|
const signature = base64UrlDecode(signatureB64);
|
||||||
@@ -361,13 +332,7 @@ export async function createSendFileUploadToken(
|
|||||||
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
|
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
|
||||||
const data = `${headerB64}.${payloadB64}`;
|
const data = `${headerB64}.${payloadB64}`;
|
||||||
|
|
||||||
const key = await crypto.subtle.importKey(
|
const key = await getHmacKey(secret);
|
||||||
'raw',
|
|
||||||
encoder.encode(secret),
|
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['sign']
|
|
||||||
);
|
|
||||||
|
|
||||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||||
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
||||||
@@ -385,13 +350,7 @@ export async function verifySendFileUploadToken(
|
|||||||
const [headerB64, payloadB64, signatureB64] = parts;
|
const [headerB64, payloadB64, signatureB64] = parts;
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
const key = await crypto.subtle.importKey(
|
const key = await getHmacKey(secret);
|
||||||
'raw',
|
|
||||||
encoder.encode(secret),
|
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['verify']
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = `${headerB64}.${payloadB64}`;
|
const data = `${headerB64}.${payloadB64}`;
|
||||||
const signature = base64UrlDecode(signatureB64);
|
const signature = base64UrlDecode(signatureB64);
|
||||||
@@ -430,13 +389,7 @@ export async function createSendAccessToken(sendId: string, secret: string): Pro
|
|||||||
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
|
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
|
||||||
const data = `${headerB64}.${payloadB64}`;
|
const data = `${headerB64}.${payloadB64}`;
|
||||||
|
|
||||||
const key = await crypto.subtle.importKey(
|
const key = await getHmacKey(secret);
|
||||||
'raw',
|
|
||||||
encoder.encode(secret),
|
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['sign']
|
|
||||||
);
|
|
||||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||||
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
||||||
return `${data}.${signatureB64}`;
|
return `${data}.${signatureB64}`;
|
||||||
@@ -450,13 +403,7 @@ export async function verifySendAccessToken(token: string, secret: string): Prom
|
|||||||
const [headerB64, payloadB64, signatureB64] = parts;
|
const [headerB64, payloadB64, signatureB64] = parts;
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
const key = await crypto.subtle.importKey(
|
const key = await getHmacKey(secret);
|
||||||
'raw',
|
|
||||||
encoder.encode(secret),
|
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['verify']
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = `${headerB64}.${payloadB64}`;
|
const data = `${headerB64}.${payloadB64}`;
|
||||||
const signature = base64UrlDecode(signatureB64);
|
const signature = base64UrlDecode(signatureB64);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,12 +15,44 @@ const DEFAULT_CORS_HEADERS = [
|
|||||||
'X-Request-Email',
|
'X-Request-Email',
|
||||||
'X-Device-Identifier',
|
'X-Device-Identifier',
|
||||||
'X-Device-Name',
|
'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');
|
const origin = request.headers.get('Origin');
|
||||||
if (!origin) return '*';
|
if (!origin) {
|
||||||
return origin;
|
return isWildcardCorsPath(url.pathname)
|
||||||
|
? { allowOrigin: '*', allowCredentials: false }
|
||||||
|
: { allowOrigin: null, allowCredentials: false };
|
||||||
|
}
|
||||||
|
if (origin === url.origin) {
|
||||||
|
return { allowOrigin: origin, allowCredentials: true };
|
||||||
|
}
|
||||||
|
if (isExtensionOrigin(origin)) {
|
||||||
|
return { allowOrigin: origin, allowCredentials: true };
|
||||||
|
}
|
||||||
|
if (isWildcardCorsPath(url.pathname)) {
|
||||||
|
return { allowOrigin: '*', allowCredentials: false };
|
||||||
|
}
|
||||||
|
return { allowOrigin: null, allowCredentials: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCorsHeaders(request: Request): Record<string, string> {
|
function buildCorsHeaders(request: Request): Record<string, string> {
|
||||||
@@ -35,13 +67,14 @@ function buildCorsHeaders(request: Request): Record<string, string> {
|
|||||||
'Access-Control-Allow-Headers': allowHeaders.join(', '),
|
'Access-Control-Allow-Headers': allowHeaders.join(', '),
|
||||||
'Access-Control-Expose-Headers': '*',
|
'Access-Control-Expose-Headers': '*',
|
||||||
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
|
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
|
||||||
'Access-Control-Allow-Private-Network': 'true',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const allowedOrigin = getAllowedOrigin(request);
|
const corsPolicy = getCorsPolicy(request);
|
||||||
if (allowedOrigin) {
|
if (corsPolicy.allowOrigin) {
|
||||||
headers['Access-Control-Allow-Origin'] = allowedOrigin;
|
headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin;
|
||||||
headers['Access-Control-Allow-Credentials'] = 'true';
|
if (corsPolicy.allowCredentials) {
|
||||||
|
headers['Access-Control-Allow-Credentials'] = 'true';
|
||||||
|
}
|
||||||
headers['Vary'] = 'Origin, Access-Control-Request-Headers';
|
headers['Vary'] = 'Origin, Access-Control-Request-Headers';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import { User, UserDecryptionOptions } from '../types';
|
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 {
|
export function buildAccountKeys(user: Pick<User, 'privateKey' | 'publicKey'>): Record<string, unknown> | null {
|
||||||
if (!user.privateKey || !user.publicKey) {
|
if (!user.privateKey) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const publicKey = normalizeOptionalPublicKey(user.publicKey);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
publicKeyEncryptionKeyPair: {
|
publicKeyEncryptionKeyPair: {
|
||||||
wrappedPrivateKey: user.privateKey,
|
wrappedPrivateKey: user.privateKey,
|
||||||
publicKey: user.publicKey,
|
publicKey,
|
||||||
Object: 'publicKeyEncryptionKeyPair',
|
Object: 'publicKeyEncryptionKeyPair',
|
||||||
},
|
},
|
||||||
Object: 'privateKeys',
|
Object: 'privateKeys',
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./webapp/index.html', './webapp/src/**/*.{ts,tsx}'],
|
||||||
|
darkMode: ['class', '[data-theme="dark"]'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
canvas: 'var(--bg-accent)',
|
||||||
|
panel: 'var(--panel)',
|
||||||
|
'panel-soft': 'var(--panel-soft)',
|
||||||
|
'panel-muted': 'var(--panel-muted)',
|
||||||
|
line: 'var(--line)',
|
||||||
|
'line-soft': 'var(--line-soft)',
|
||||||
|
ink: 'var(--text)',
|
||||||
|
muted: 'var(--muted)',
|
||||||
|
'muted-strong': 'var(--muted-strong)',
|
||||||
|
brand: 'var(--primary)',
|
||||||
|
'brand-hover': 'var(--primary-hover)',
|
||||||
|
'brand-strong': 'var(--primary-strong)',
|
||||||
|
danger: 'var(--danger)',
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
soft: 'var(--shadow-sm)',
|
||||||
|
panel: 'var(--shadow-md)',
|
||||||
|
elevated: 'var(--shadow-lg)',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', 'sans-serif'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
@@ -3,13 +3,92 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' https://cloudflareinsights.com https://*.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://cloudflareinsights.com https://*.cloudflareinsights.com; connect-src 'self' https://api.pwnedpasswords.com https://cloudflareinsights.com https://*.cloudflareinsights.com; font-src 'self'; form-action 'self'; base-uri 'self';" />
|
|
||||||
<link rel="icon" type="image/png" href="/favicon.ico" />
|
<meta http-equiv="Content-Security-Policy" content="
|
||||||
|
default-src 'self';
|
||||||
|
script-src 'self' 'unsafe-inline';
|
||||||
|
style-src 'self' 'unsafe-inline';
|
||||||
|
img-src 'self' data:;
|
||||||
|
connect-src 'self';
|
||||||
|
font-src 'self';
|
||||||
|
form-action 'self';
|
||||||
|
base-uri 'self';
|
||||||
|
" />
|
||||||
|
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/nodewarden-logo-bg.svg" />
|
||||||
|
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
|
||||||
<title>NodeWarden</title>
|
<title>NodeWarden</title>
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #eef4ff;
|
||||||
|
color: #0f172a;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot-screen {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot-card {
|
||||||
|
width: min(420px, 100%);
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
justify-items: center;
|
||||||
|
padding: 28px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
border-radius: 22px;
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot-logo {
|
||||||
|
width: 74px;
|
||||||
|
height: 58px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot-line {
|
||||||
|
width: 72%;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, #dbeafe, #bfdbfe, #dbeafe);
|
||||||
|
background-size: 180% 100%;
|
||||||
|
animation: boot-shimmer 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot-line.short {
|
||||||
|
width: 46%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes boot-shimmer {
|
||||||
|
0% { background-position: 180% 0; }
|
||||||
|
100% { background-position: -180% 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root">
|
||||||
|
<div class="boot-screen">
|
||||||
|
<div class="boot-card" aria-label="Loading NodeWarden">
|
||||||
|
<img class="boot-logo" src="/nodewarden-logo.svg" alt="" />
|
||||||
|
<div class="boot-line"></div>
|
||||||
|
<div class="boot-line short"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="760" height="760" viewBox="0 0 760 760" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="760" height="760" fill="#116FF9"/>
|
||||||
|
<path d="M386.5 183C497.785 183 588 271.2 588 380C588 419.877 575.879 456.986 555.046 488H17.6816C16.5766 481.834 16 475.484 16 469C16 413.617 58.0774 368.061 112.008 362.558C108.771 353.989 107 344.701 107 335C107 291.922 141.922 257 185 257C198.365 257 210.945 260.362 221.94 266.286C258.437 215.895 318.539 183 386.5 183Z" fill="#F6821F"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M92.6568 91.0069C88.7796 262.923 101.55 381.119 143.869 469.459C186.188 557.799 258.092 616.353 372.665 668.892C485.877 616.354 556.929 557.802 598.746 469.461C640.564 381.12 653.181 262.923 649.35 91.0069H92.6568ZM539.796 432.933C570.479 365.533 581.347 278.379 582.419 153.939L582.422 153.432H377.661V593.786L378.405 593.364C458.602 547.962 509.101 500.36 539.796 432.933Z" fill="white"/>
|
||||||
|
<path d="M604.465 305C680.976 305 743 367.233 743 444C743 459.378 740.509 474.172 735.913 488H379V423.553C391.721 397.751 418.287 380 449 380C459.483 380 469.482 382.068 478.613 385.818C500.559 338.11 548.658 305 604.465 305Z" fill="#FD9C33"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="727" height="580" viewBox="0 0 727 580" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M370.5 93C481.785 93 572 181.2 572 290C572 329.877 559.879 366.986 539.046 398H1.68164C0.576599 391.834 0 385.484 0 379C0 323.617 42.0774 278.061 96.0078 272.558C92.7712 263.989 91 254.701 91 245C91 201.922 125.922 167 169 167C182.365 167 194.945 170.362 205.94 176.286C242.437 125.895 302.539 93 370.5 93Z" fill="#F6821F"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M76.6568 1.00686C72.7796 172.923 85.5495 291.119 127.869 379.459C170.188 467.799 242.092 526.353 356.665 578.892C469.877 526.354 540.929 467.802 582.746 379.461C624.564 291.12 637.181 172.923 633.35 1.00686H76.6568ZM523.796 342.933C554.479 275.533 565.347 188.379 566.419 63.9394L566.422 63.432H361.661V503.786L362.405 503.364C442.602 457.962 493.101 410.36 523.796 342.933Z" fill="#116FF9"/>
|
||||||
|
<path d="M588.465 215C664.976 215 727 277.233 727 354C727 369.378 724.509 384.172 719.913 398H363V333.553C375.721 307.751 402.287 290 433 290C443.483 290 453.482 292.068 462.613 295.818C484.559 248.11 532.658 215 588.465 215Z" fill="#FD9C33"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<svg width="862" height="102" viewBox="0 0 8620 1017" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M238.439 995.188H0V209.944C0 111.788 76.3004 53.1675 156.688 53.1675C220.726 53.1675 276.589 74.9799 309.289 126.784L633.566 640.737V74.9799H872.005V860.224C872.005 958.379 795.704 1015.64 715.317 1015.64C652.641 1015.64 595.416 993.824 562.716 942.02L238.439 428.067V995.188Z" fill="black"/>
|
||||||
|
<path d="M1389.81 1015.64C1177.26 1015.64 1015.12 852.044 1015.12 653.007C1015.12 455.332 1177.26 291.74 1389.81 291.74C1602.36 291.74 1764.5 455.332 1764.5 653.007C1764.5 852.044 1602.36 1015.64 1389.81 1015.64ZM1389.81 785.244C1467.47 785.244 1519.25 725.26 1519.25 654.37C1519.25 582.117 1467.47 522.133 1389.81 522.133C1312.15 522.133 1260.37 582.117 1260.37 654.37C1260.37 725.26 1312.15 785.244 1389.81 785.244Z" fill="black"/>
|
||||||
|
<path d="M2221.42 1015.64C2008.87 1015.64 1846.73 853.407 1846.73 655.733C1846.73 437.61 1991.16 293.103 2207.79 293.103C2258.21 293.103 2308.62 308.099 2350.86 331.275V0H2596.11V655.733C2596.11 864.314 2439.42 1015.64 2221.42 1015.64ZM2221.42 785.244C2299.08 785.244 2350.86 726.623 2350.86 654.37C2350.86 583.48 2299.08 523.496 2221.42 523.496C2143.76 523.496 2091.98 583.48 2091.98 654.37C2091.98 726.623 2143.76 785.244 2221.42 785.244Z" fill="black"/>
|
||||||
|
<path d="M3086.45 1014.27C2868.45 1014.27 2704.95 869.767 2704.95 646.19C2704.95 449.879 2852.1 286.287 3067.38 286.287C3290.83 286.287 3414.82 452.606 3414.82 635.284V696.631H2940.66C2957.01 764.795 3008.79 805.693 3083.73 805.693C3149.13 805.693 3200.9 770.248 3225.43 717.08L3413.45 811.146C3354.87 937.93 3239.05 1014.27 3086.45 1014.27ZM2951.56 569.847H3170.93C3160.03 531.676 3121.88 496.231 3064.65 496.231C3006.06 496.231 2966.55 530.312 2951.56 569.847Z" fill="black"/>
|
||||||
|
<path d="M3604.95 845.228L3441.45 74.9799H3693.51L3812.05 704.811L3915.6 246.752C3945.58 111.788 4009.62 54.5308 4107.72 54.5308C4205.82 54.5308 4269.85 111.788 4299.83 246.752L4403.38 704.811L4521.92 74.9799H4773.98L4610.48 845.228C4587.32 955.653 4513.74 1017 4414.28 1017C4324.35 1017 4243.97 957.016 4220.8 856.134L4107.72 358.54L3994.63 856.134C3971.46 957.016 3891.08 1017 3801.15 1017C3701.69 1017 3628.11 955.653 3604.95 845.228Z" fill="black"/>
|
||||||
|
<path d="M5121.11 1015.64C4922.19 1015.64 4787.3 852.044 4787.3 653.007C4787.3 455.332 4949.44 291.74 5161.99 291.74C5379.99 291.74 5536.68 444.426 5536.68 653.007V995.188H5305.05V944.747C5261.45 989.735 5200.14 1015.64 5121.11 1015.64ZM5161.99 785.244C5239.65 785.244 5291.43 725.26 5291.43 654.37C5291.43 582.117 5239.65 522.133 5161.99 522.133C5084.33 522.133 5032.55 582.117 5032.55 654.37C5032.55 725.26 5084.33 785.244 5161.99 785.244Z" fill="black"/>
|
||||||
|
<path d="M5918.02 995.188H5672.77V617.562C5672.77 436.247 5776.32 291.74 5998.41 291.74C6044.73 291.74 6095.15 299.92 6129.21 314.916V550.761C6096.51 533.039 6055.63 523.496 6021.57 523.496C5957.53 523.496 5918.02 560.304 5918.02 625.741V995.188Z" fill="black"/>
|
||||||
|
<path d="M6565.74 1015.64C6353.19 1015.64 6191.05 853.407 6191.05 655.733C6191.05 437.61 6335.48 293.103 6552.12 293.103C6602.53 293.103 6652.94 308.099 6695.18 331.275V0H6940.43V655.733C6940.43 864.314 6783.74 1015.64 6565.74 1015.64ZM6565.74 785.244C6643.41 785.244 6695.18 726.623 6695.18 654.37C6695.18 583.48 6643.41 523.496 6565.74 523.496C6488.08 523.496 6436.31 583.48 6436.31 654.37C6436.31 726.623 6488.08 785.244 6565.74 785.244Z" fill="black"/>
|
||||||
|
<path d="M7430.78 1014.27C7212.77 1014.27 7049.27 869.767 7049.27 646.19C7049.27 449.879 7196.42 286.287 7411.7 286.287C7635.15 286.287 7759.14 452.606 7759.14 635.284V696.631H7284.99C7301.34 764.795 7353.11 805.693 7428.05 805.693C7493.45 805.693 7545.23 770.248 7569.75 717.08L7757.78 811.146C7699.19 937.93 7583.38 1014.27 7430.78 1014.27ZM7295.89 569.847H7515.25C7504.35 531.676 7466.2 496.231 7408.98 496.231C7350.39 496.231 7310.88 530.312 7295.89 569.847Z" fill="black"/>
|
||||||
|
<path d="M8250.76 531.676C8160.84 531.676 8126.77 603.929 8126.77 689.815V995.188H7881.52V659.823C7881.52 459.422 7998.7 293.103 8250.76 293.103C8502.82 293.103 8620 459.422 8620 659.823V995.188H8374.75V689.815C8374.75 603.929 8340.69 531.676 8250.76 531.676Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,428 @@
|
|||||||
|
Attribution-ShareAlike 4.0 International
|
||||||
|
|
||||||
|
=======================================================================
|
||||||
|
|
||||||
|
Creative Commons Corporation ("Creative Commons") is not a law firm and
|
||||||
|
does not provide legal services or legal advice. Distribution of
|
||||||
|
Creative Commons public licenses does not create a lawyer-client or
|
||||||
|
other relationship. Creative Commons makes its licenses and related
|
||||||
|
information available on an "as-is" basis. Creative Commons gives no
|
||||||
|
warranties regarding its licenses, any material licensed under their
|
||||||
|
terms and conditions, or any related information. Creative Commons
|
||||||
|
disclaims all liability for damages resulting from their use to the
|
||||||
|
fullest extent possible.
|
||||||
|
|
||||||
|
Using Creative Commons Public Licenses
|
||||||
|
|
||||||
|
Creative Commons public licenses provide a standard set of terms and
|
||||||
|
conditions that creators and other rights holders may use to share
|
||||||
|
original works of authorship and other material subject to copyright
|
||||||
|
and certain other rights specified in the public license below. The
|
||||||
|
following considerations are for informational purposes only, are not
|
||||||
|
exhaustive, and do not form part of our licenses.
|
||||||
|
|
||||||
|
Considerations for licensors: Our public licenses are
|
||||||
|
intended for use by those authorized to give the public
|
||||||
|
permission to use material in ways otherwise restricted by
|
||||||
|
copyright and certain other rights. Our licenses are
|
||||||
|
irrevocable. Licensors should read and understand the terms
|
||||||
|
and conditions of the license they choose before applying it.
|
||||||
|
Licensors should also secure all rights necessary before
|
||||||
|
applying our licenses so that the public can reuse the
|
||||||
|
material as expected. Licensors should clearly mark any
|
||||||
|
material not subject to the license. This includes other CC-
|
||||||
|
licensed material, or material used under an exception or
|
||||||
|
limitation to copyright. More considerations for licensors:
|
||||||
|
wiki.creativecommons.org/Considerations_for_licensors
|
||||||
|
|
||||||
|
Considerations for the public: By using one of our public
|
||||||
|
licenses, a licensor grants the public permission to use the
|
||||||
|
licensed material under specified terms and conditions. If
|
||||||
|
the licensor's permission is not necessary for any reason--for
|
||||||
|
example, because of any applicable exception or limitation to
|
||||||
|
copyright--then that use is not regulated by the license. Our
|
||||||
|
licenses grant only permissions under copyright and certain
|
||||||
|
other rights that a licensor has authority to grant. Use of
|
||||||
|
the licensed material may still be restricted for other
|
||||||
|
reasons, including because others have copyright or other
|
||||||
|
rights in the material. A licensor may make special requests,
|
||||||
|
such as asking that all changes be marked or described.
|
||||||
|
Although not required by our licenses, you are encouraged to
|
||||||
|
respect those requests where reasonable. More_considerations
|
||||||
|
for the public:
|
||||||
|
wiki.creativecommons.org/Considerations_for_licensees
|
||||||
|
|
||||||
|
=======================================================================
|
||||||
|
|
||||||
|
Creative Commons Attribution-ShareAlike 4.0 International Public
|
||||||
|
License
|
||||||
|
|
||||||
|
By exercising the Licensed Rights (defined below), You accept and agree
|
||||||
|
to be bound by the terms and conditions of this Creative Commons
|
||||||
|
Attribution-ShareAlike 4.0 International Public License ("Public
|
||||||
|
License"). To the extent this Public License may be interpreted as a
|
||||||
|
contract, You are granted the Licensed Rights in consideration of Your
|
||||||
|
acceptance of these terms and conditions, and the Licensor grants You
|
||||||
|
such rights in consideration of benefits the Licensor receives from
|
||||||
|
making the Licensed Material available under these terms and
|
||||||
|
conditions.
|
||||||
|
|
||||||
|
|
||||||
|
Section 1 -- Definitions.
|
||||||
|
|
||||||
|
a. Adapted Material means material subject to Copyright and Similar
|
||||||
|
Rights that is derived from or based upon the Licensed Material
|
||||||
|
and in which the Licensed Material is translated, altered,
|
||||||
|
arranged, transformed, or otherwise modified in a manner requiring
|
||||||
|
permission under the Copyright and Similar Rights held by the
|
||||||
|
Licensor. For purposes of this Public License, where the Licensed
|
||||||
|
Material is a musical work, performance, or sound recording,
|
||||||
|
Adapted Material is always produced where the Licensed Material is
|
||||||
|
synched in timed relation with a moving image.
|
||||||
|
|
||||||
|
b. Adapter's License means the license You apply to Your Copyright
|
||||||
|
and Similar Rights in Your contributions to Adapted Material in
|
||||||
|
accordance with the terms and conditions of this Public License.
|
||||||
|
|
||||||
|
c. BY-SA Compatible License means a license listed at
|
||||||
|
creativecommons.org/compatiblelicenses, approved by Creative
|
||||||
|
Commons as essentially the equivalent of this Public License.
|
||||||
|
|
||||||
|
d. Copyright and Similar Rights means copyright and/or similar rights
|
||||||
|
closely related to copyright including, without limitation,
|
||||||
|
performance, broadcast, sound recording, and Sui Generis Database
|
||||||
|
Rights, without regard to how the rights are labeled or
|
||||||
|
categorized. For purposes of this Public License, the rights
|
||||||
|
specified in Section 2(b)(1)-(2) are not Copyright and Similar
|
||||||
|
Rights.
|
||||||
|
|
||||||
|
e. Effective Technological Measures means those measures that, in the
|
||||||
|
absence of proper authority, may not be circumvented under laws
|
||||||
|
fulfilling obligations under Article 11 of the WIPO Copyright
|
||||||
|
Treaty adopted on December 20, 1996, and/or similar international
|
||||||
|
agreements.
|
||||||
|
|
||||||
|
f. Exceptions and Limitations means fair use, fair dealing, and/or
|
||||||
|
any other exception or limitation to Copyright and Similar Rights
|
||||||
|
that applies to Your use of the Licensed Material.
|
||||||
|
|
||||||
|
g. License Elements means the license attributes listed in the name
|
||||||
|
of a Creative Commons Public License. The License Elements of this
|
||||||
|
Public License are Attribution and ShareAlike.
|
||||||
|
|
||||||
|
h. Licensed Material means the artistic or literary work, database,
|
||||||
|
or other material to which the Licensor applied this Public
|
||||||
|
License.
|
||||||
|
|
||||||
|
i. Licensed Rights means the rights granted to You subject to the
|
||||||
|
terms and conditions of this Public License, which are limited to
|
||||||
|
all Copyright and Similar Rights that apply to Your use of the
|
||||||
|
Licensed Material and that the Licensor has authority to license.
|
||||||
|
|
||||||
|
j. Licensor means the individual(s) or entity(ies) granting rights
|
||||||
|
under this Public License.
|
||||||
|
|
||||||
|
k. Share means to provide material to the public by any means or
|
||||||
|
process that requires permission under the Licensed Rights, such
|
||||||
|
as reproduction, public display, public performance, distribution,
|
||||||
|
dissemination, communication, or importation, and to make material
|
||||||
|
available to the public including in ways that members of the
|
||||||
|
public may access the material from a place and at a time
|
||||||
|
individually chosen by them.
|
||||||
|
|
||||||
|
l. Sui Generis Database Rights means rights other than copyright
|
||||||
|
resulting from Directive 96/9/EC of the European Parliament and of
|
||||||
|
the Council of 11 March 1996 on the legal protection of databases,
|
||||||
|
as amended and/or succeeded, as well as other essentially
|
||||||
|
equivalent rights anywhere in the world.
|
||||||
|
|
||||||
|
m. You means the individual or entity exercising the Licensed Rights
|
||||||
|
under this Public License. Your has a corresponding meaning.
|
||||||
|
|
||||||
|
|
||||||
|
Section 2 -- Scope.
|
||||||
|
|
||||||
|
a. License grant.
|
||||||
|
|
||||||
|
1. Subject to the terms and conditions of this Public License,
|
||||||
|
the Licensor hereby grants You a worldwide, royalty-free,
|
||||||
|
non-sublicensable, non-exclusive, irrevocable license to
|
||||||
|
exercise the Licensed Rights in the Licensed Material to:
|
||||||
|
|
||||||
|
a. reproduce and Share the Licensed Material, in whole or
|
||||||
|
in part; and
|
||||||
|
|
||||||
|
b. produce, reproduce, and Share Adapted Material.
|
||||||
|
|
||||||
|
2. Exceptions and Limitations. For the avoidance of doubt, where
|
||||||
|
Exceptions and Limitations apply to Your use, this Public
|
||||||
|
License does not apply, and You do not need to comply with
|
||||||
|
its terms and conditions.
|
||||||
|
|
||||||
|
3. Term. The term of this Public License is specified in Section
|
||||||
|
6(a).
|
||||||
|
|
||||||
|
4. Media and formats; technical modifications allowed. The
|
||||||
|
Licensor authorizes You to exercise the Licensed Rights in
|
||||||
|
all media and formats whether now known or hereafter created,
|
||||||
|
and to make technical modifications necessary to do so. The
|
||||||
|
Licensor waives and/or agrees not to assert any right or
|
||||||
|
authority to forbid You from making technical modifications
|
||||||
|
necessary to exercise the Licensed Rights, including
|
||||||
|
technical modifications necessary to circumvent Effective
|
||||||
|
Technological Measures. For purposes of this Public License,
|
||||||
|
simply making modifications authorized by this Section 2(a)
|
||||||
|
(4) never produces Adapted Material.
|
||||||
|
|
||||||
|
5. Downstream recipients.
|
||||||
|
|
||||||
|
a. Offer from the Licensor -- Licensed Material. Every
|
||||||
|
recipient of the Licensed Material automatically
|
||||||
|
receives an offer from the Licensor to exercise the
|
||||||
|
Licensed Rights under the terms and conditions of this
|
||||||
|
Public License.
|
||||||
|
|
||||||
|
b. Additional offer from the Licensor -- Adapted Material.
|
||||||
|
Every recipient of Adapted Material from You
|
||||||
|
automatically receives an offer from the Licensor to
|
||||||
|
exercise the Licensed Rights in the Adapted Material
|
||||||
|
under the conditions of the Adapter's License You apply.
|
||||||
|
|
||||||
|
c. No downstream restrictions. You may not offer or impose
|
||||||
|
any additional or different terms or conditions on, or
|
||||||
|
apply any Effective Technological Measures to, the
|
||||||
|
Licensed Material if doing so restricts exercise of the
|
||||||
|
Licensed Rights by any recipient of the Licensed
|
||||||
|
Material.
|
||||||
|
|
||||||
|
6. No endorsement. Nothing in this Public License constitutes or
|
||||||
|
may be construed as permission to assert or imply that You
|
||||||
|
are, or that Your use of the Licensed Material is, connected
|
||||||
|
with, or sponsored, endorsed, or granted official status by,
|
||||||
|
the Licensor or others designated to receive attribution as
|
||||||
|
provided in Section 3(a)(1)(A)(i).
|
||||||
|
|
||||||
|
b. Other rights.
|
||||||
|
|
||||||
|
1. Moral rights, such as the right of integrity, are not
|
||||||
|
licensed under this Public License, nor are publicity,
|
||||||
|
privacy, and/or other similar personality rights; however, to
|
||||||
|
the extent possible, the Licensor waives and/or agrees not to
|
||||||
|
assert any such rights held by the Licensor to the limited
|
||||||
|
extent necessary to allow You to exercise the Licensed
|
||||||
|
Rights, but not otherwise.
|
||||||
|
|
||||||
|
2. Patent and trademark rights are not licensed under this
|
||||||
|
Public License.
|
||||||
|
|
||||||
|
3. To the extent possible, the Licensor waives any right to
|
||||||
|
collect royalties from You for the exercise of the Licensed
|
||||||
|
Rights, whether directly or through a collecting society
|
||||||
|
under any voluntary or waivable statutory or compulsory
|
||||||
|
licensing scheme. In all other cases the Licensor expressly
|
||||||
|
reserves any right to collect such royalties.
|
||||||
|
|
||||||
|
|
||||||
|
Section 3 -- License Conditions.
|
||||||
|
|
||||||
|
Your exercise of the Licensed Rights is expressly made subject to the
|
||||||
|
following conditions.
|
||||||
|
|
||||||
|
a. Attribution.
|
||||||
|
|
||||||
|
1. If You Share the Licensed Material (including in modified
|
||||||
|
form), You must:
|
||||||
|
|
||||||
|
a. retain the following if it is supplied by the Licensor
|
||||||
|
with the Licensed Material:
|
||||||
|
|
||||||
|
i. identification of the creator(s) of the Licensed
|
||||||
|
Material and any others designated to receive
|
||||||
|
attribution, in any reasonable manner requested by
|
||||||
|
the Licensor (including by pseudonym if
|
||||||
|
designated);
|
||||||
|
|
||||||
|
ii. a copyright notice;
|
||||||
|
|
||||||
|
iii. a notice that refers to this Public License;
|
||||||
|
|
||||||
|
iv. a notice that refers to the disclaimer of
|
||||||
|
warranties;
|
||||||
|
|
||||||
|
v. a URI or hyperlink to the Licensed Material to the
|
||||||
|
extent reasonably practicable;
|
||||||
|
|
||||||
|
b. indicate if You modified the Licensed Material and
|
||||||
|
retain an indication of any previous modifications; and
|
||||||
|
|
||||||
|
c. indicate the Licensed Material is licensed under this
|
||||||
|
Public License, and include the text of, or the URI or
|
||||||
|
hyperlink to, this Public License.
|
||||||
|
|
||||||
|
2. You may satisfy the conditions in Section 3(a)(1) in any
|
||||||
|
reasonable manner based on the medium, means, and context in
|
||||||
|
which You Share the Licensed Material. For example, it may be
|
||||||
|
reasonable to satisfy the conditions by providing a URI or
|
||||||
|
hyperlink to a resource that includes the required
|
||||||
|
information.
|
||||||
|
|
||||||
|
3. If requested by the Licensor, You must remove any of the
|
||||||
|
information required by Section 3(a)(1)(A) to the extent
|
||||||
|
reasonably practicable.
|
||||||
|
|
||||||
|
b. ShareAlike.
|
||||||
|
|
||||||
|
In addition to the conditions in Section 3(a), if You Share
|
||||||
|
Adapted Material You produce, the following conditions also apply.
|
||||||
|
|
||||||
|
1. The Adapter's License You apply must be a Creative Commons
|
||||||
|
license with the same License Elements, this version or
|
||||||
|
later, or a BY-SA Compatible License.
|
||||||
|
|
||||||
|
2. You must include the text of, or the URI or hyperlink to, the
|
||||||
|
Adapter's License You apply. You may satisfy this condition
|
||||||
|
in any reasonable manner based on the medium, means, and
|
||||||
|
context in which You Share Adapted Material.
|
||||||
|
|
||||||
|
3. You may not offer or impose any additional or different terms
|
||||||
|
or conditions on, or apply any Effective Technological
|
||||||
|
Measures to, Adapted Material that restrict exercise of the
|
||||||
|
rights granted under the Adapter's License You apply.
|
||||||
|
|
||||||
|
|
||||||
|
Section 4 -- Sui Generis Database Rights.
|
||||||
|
|
||||||
|
Where the Licensed Rights include Sui Generis Database Rights that
|
||||||
|
apply to Your use of the Licensed Material:
|
||||||
|
|
||||||
|
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
|
||||||
|
to extract, reuse, reproduce, and Share all or a substantial
|
||||||
|
portion of the contents of the database;
|
||||||
|
|
||||||
|
b. if You include all or a substantial portion of the database
|
||||||
|
contents in a database in which You have Sui Generis Database
|
||||||
|
Rights, then the database in which You have Sui Generis Database
|
||||||
|
Rights (but not its individual contents) is Adapted Material,
|
||||||
|
|
||||||
|
including for purposes of Section 3(b); and
|
||||||
|
c. You must comply with the conditions in Section 3(a) if You Share
|
||||||
|
all or a substantial portion of the contents of the database.
|
||||||
|
|
||||||
|
For the avoidance of doubt, this Section 4 supplements and does not
|
||||||
|
replace Your obligations under this Public License where the Licensed
|
||||||
|
Rights include other Copyright and Similar Rights.
|
||||||
|
|
||||||
|
|
||||||
|
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
|
||||||
|
|
||||||
|
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
|
||||||
|
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
|
||||||
|
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
|
||||||
|
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
|
||||||
|
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
|
||||||
|
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
|
||||||
|
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
|
||||||
|
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
|
||||||
|
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
|
||||||
|
|
||||||
|
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
|
||||||
|
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
|
||||||
|
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
|
||||||
|
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
|
||||||
|
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
|
||||||
|
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
|
||||||
|
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
|
||||||
|
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
|
||||||
|
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
|
||||||
|
|
||||||
|
c. The disclaimer of warranties and limitation of liability provided
|
||||||
|
above shall be interpreted in a manner that, to the extent
|
||||||
|
possible, most closely approximates an absolute disclaimer and
|
||||||
|
waiver of all liability.
|
||||||
|
|
||||||
|
|
||||||
|
Section 6 -- Term and Termination.
|
||||||
|
|
||||||
|
a. This Public License applies for the term of the Copyright and
|
||||||
|
Similar Rights licensed here. However, if You fail to comply with
|
||||||
|
this Public License, then Your rights under this Public License
|
||||||
|
terminate automatically.
|
||||||
|
|
||||||
|
b. Where Your right to use the Licensed Material has terminated under
|
||||||
|
Section 6(a), it reinstates:
|
||||||
|
|
||||||
|
1. automatically as of the date the violation is cured, provided
|
||||||
|
it is cured within 30 days of Your discovery of the
|
||||||
|
violation; or
|
||||||
|
|
||||||
|
2. upon express reinstatement by the Licensor.
|
||||||
|
|
||||||
|
For the avoidance of doubt, this Section 6(b) does not affect any
|
||||||
|
right the Licensor may have to seek remedies for Your violations
|
||||||
|
of this Public License.
|
||||||
|
|
||||||
|
c. For the avoidance of doubt, the Licensor may also offer the
|
||||||
|
Licensed Material under separate terms or conditions or stop
|
||||||
|
distributing the Licensed Material at any time; however, doing so
|
||||||
|
will not terminate this Public License.
|
||||||
|
|
||||||
|
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
|
||||||
|
License.
|
||||||
|
|
||||||
|
|
||||||
|
Section 7 -- Other Terms and Conditions.
|
||||||
|
|
||||||
|
a. The Licensor shall not be bound by any additional or different
|
||||||
|
terms or conditions communicated by You unless expressly agreed.
|
||||||
|
|
||||||
|
b. Any arrangements, understandings, or agreements regarding the
|
||||||
|
Licensed Material not stated herein are separate from and
|
||||||
|
independent of the terms and conditions of this Public License.
|
||||||
|
|
||||||
|
|
||||||
|
Section 8 -- Interpretation.
|
||||||
|
|
||||||
|
a. For the avoidance of doubt, this Public License does not, and
|
||||||
|
shall not be interpreted to, reduce, limit, restrict, or impose
|
||||||
|
conditions on any use of the Licensed Material that could lawfully
|
||||||
|
be made without permission under this Public License.
|
||||||
|
|
||||||
|
b. To the extent possible, if any provision of this Public License is
|
||||||
|
deemed unenforceable, it shall be automatically reformed to the
|
||||||
|
minimum extent necessary to make it enforceable. If the provision
|
||||||
|
cannot be reformed, it shall be severed from this Public License
|
||||||
|
without affecting the enforceability of the remaining terms and
|
||||||
|
conditions.
|
||||||
|
|
||||||
|
c. No term or condition of this Public License will be waived and no
|
||||||
|
failure to comply consented to unless expressly agreed to by the
|
||||||
|
Licensor.
|
||||||
|
|
||||||
|
d. Nothing in this Public License constitutes or may be interpreted
|
||||||
|
as a limitation upon, or waiver of, any privileges and immunities
|
||||||
|
that apply to the Licensor or You, including from the legal
|
||||||
|
processes of any jurisdiction or authority.
|
||||||
|
|
||||||
|
|
||||||
|
=======================================================================
|
||||||
|
|
||||||
|
Creative Commons is not a party to its public
|
||||||
|
licenses. Notwithstanding, Creative Commons may elect to apply one of
|
||||||
|
its public licenses to material it publishes and in those instances
|
||||||
|
will be considered the “Licensor.” The text of the Creative Commons
|
||||||
|
public licenses is dedicated to the public domain under the CC0 Public
|
||||||
|
Domain Dedication. Except for the limited purpose of indicating that
|
||||||
|
material is shared under a Creative Commons public license or as
|
||||||
|
otherwise permitted by the Creative Commons policies published at
|
||||||
|
creativecommons.org/policies, Creative Commons does not authorize the
|
||||||
|
use of the trademark "Creative Commons" or any other trademark or logo
|
||||||
|
of Creative Commons without its prior written consent including,
|
||||||
|
without limitation, in connection with any unauthorized modifications
|
||||||
|
to any of its public licenses or any other arrangements,
|
||||||
|
understandings, or agreements concerning use of licensed material. For
|
||||||
|
the avoidance of doubt, this paragraph does not form part of the
|
||||||
|
public licenses.
|
||||||
|
|
||||||
|
Creative Commons may be contacted at creativecommons.org.
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Payment logos in this directory are from datatrans/payment-logos.
|
||||||
|
|
||||||
|
Source: https://github.com/datatrans/payment-logos
|
||||||
|
License: CC-BY-SA-4.0
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="120" height="80" version="1.1" viewBox="0 0 120 80" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="40" width="80" height="80" rx="4" fill="#fff" fill-rule="evenodd" />
|
||||||
|
<path d="m120 76v-8.6763h-9.651l-4.969-5.4944-4.994 5.4944h-31.822v-25.607h-10.27l12.74-28.831h12.286l4.3857 9.877v-9.877h15.208l2.64 7.4429 2.658-7.4429h11.789v-8.8854c0-2.2091-1.7909-4-4-4h-112c-2.2091 4.4409e-16 -4 1.7909-4 4v72c4.4409e-16 2.2091 1.7909 4 4 4h112c2.2091 0 4-1.7909 4-4zm-8.026-11.882h8.026l-10.616-11.258 10.616-11.13h-7.898l-6.556 7.1645-6.4935-7.1645h-8.0275l10.554 11.194-10.554 11.194h7.8041l6.5889-7.2283 6.556 7.2283zm1.878-11.249 6.148 6.5406v-13.027l-6.148 6.4861zm-35.78 6.0675v-3.4864h12.633v-5.0534h-12.633v-3.4859h12.953l5e-4 -5.1815h-19.062v22.388h19.062l-5e-4 -5.1813h-12.953zm35.883-20.456h6.045v-22.388h-9.403l-5.022 13.944-4.989-13.944h-9.5631v22.388h6.0446v-15.672l5.7575 15.672h5.373l5.757-15.704v15.704zm-29.809 0h6.8765l-9.8824-22.388h-7.8682l-9.8833 22.388h6.7166l1.8554-4.4776h10.298l1.887 4.4776zm-3.9976-9.4992h-6.0773l3.0387-7.3242 3.0386 7.3242z" fill="#0690FF"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
<svg width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="120" height="80" rx="4" fill="url(#paint0_linear_804_2)"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M65.3997 64.8343C79.0213 64.8992 91.4542 53.7631 91.4542 40.2157C91.4542 25.4007 79.0213 15.1605 65.3997 15.1654H53.6768C39.8921 15.1605 28.5459 25.4038 28.5459 40.2157C28.5459 53.7661 39.8921 64.8993 53.6768 64.8343H65.3997Z" fill="#3477B9"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M53.6852 17.1522C41.0891 17.1561 30.8821 27.3313 30.8792 39.8896C30.8821 52.4456 41.089 62.6199 53.6852 62.6238C66.2843 62.6199 76.4934 52.4456 76.4952 39.8896C76.4933 27.3313 66.2843 17.1561 53.6852 17.1522ZM39.2291 39.8896C39.241 33.7529 43.0866 28.5199 48.5095 26.4404V53.3355C43.0866 51.2572 39.2409 46.0271 39.2291 39.8896ZM58.859 53.3415V26.4396C64.2838 28.514 68.1355 33.7499 68.1453 39.8896C68.1355 46.0311 64.2838 51.263 58.859 53.3415Z" fill="white"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0_linear_804_2" x1="1.68141e-06" y1="21" x2="120" y2="54" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#3479C0"/>
|
||||||
|
<stop offset="1" stop-color="#133362"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
<svg width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="120" height="80" rx="4" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M29 80H116.002C118.21 80 120 78.211 120 75.9957V48C120 48 87.8616 70.1063 29 80Z" fill="#E7792B"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M113.088 33.8624C113.088 30.7125 110.888 28.8951 107.053 28.8951H102.12V45.7197H105.443V38.9609H105.877L110.481 45.7197H114.571L109.202 38.6314C111.708 38.129 113.088 36.4383 113.088 33.8624ZM106.414 36.6411H105.443V31.5451H106.467C108.538 31.5451 109.665 32.4018 109.665 34.0385C109.665 35.7305 108.538 36.6411 106.414 36.6411Z" fill="#1A1918"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M90.4839 45.7197H99.9176V42.8713H93.8077V38.3298H99.6923V35.4802H93.8077V31.746H99.9176V28.8951H90.4839V45.7197Z" fill="#1A1918"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M80.7677 40.1959L76.2205 28.8951H72.5864L79.8236 46.1512H81.613L88.9799 28.8951H85.3742L80.7677 40.1959Z" fill="#1A1918"/>
|
||||||
|
<path d="M64.6178 46.7197C69.7118 46.7197 73.8414 42.6454 73.8414 37.6197C73.8414 32.5939 69.7118 28.5197 64.6178 28.5197C59.5238 28.5197 55.3943 32.5939 55.3943 37.6197C55.3943 42.6454 59.5238 46.7197 64.6178 46.7197Z" fill="url(#paint0_radial_823_341)"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M41.2231 37.3191C41.2231 42.2643 45.159 46.0986 50.224 46.0986C51.6556 46.0986 52.8817 45.8211 54.3943 45.1184V41.2555C53.0642 42.5685 51.8869 43.0982 50.3788 43.0982C47.0287 43.0982 44.651 40.7017 44.651 37.2944C44.651 34.0645 47.1038 31.5165 50.224 31.5165C51.8104 31.5165 53.0115 32.0749 54.3943 33.4093V29.5483C52.9344 28.8177 51.7334 28.5148 50.3024 28.5148C45.2631 28.5148 41.2231 32.4272 41.2231 37.3191Z" fill="#1A1918"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M35.2687 35.3515C33.2725 34.6229 32.6868 34.1419 32.6868 33.2332C32.6868 32.173 33.731 31.3683 35.1646 31.3683C36.1614 31.3683 36.9803 31.772 37.8467 32.7307L39.5873 30.4824C38.157 29.248 36.446 28.6169 34.5763 28.6169C31.5589 28.6169 29.2576 30.6839 29.2576 33.4379C29.2576 35.7558 30.3295 36.9421 33.453 38.0516C34.7555 38.5047 35.4182 38.8063 35.7529 39.0097C36.417 39.4381 36.7497 40.0439 36.7497 40.7504C36.7497 42.1135 35.6515 43.1236 34.1671 43.1236C32.5807 43.1236 31.3032 42.341 30.537 40.8798L28.3879 42.9214C29.9204 45.1405 31.7611 46.124 34.2923 46.124C37.7485 46.124 40.1736 43.8568 40.1736 40.5996C40.1736 37.9268 39.0523 36.7165 35.2687 35.3515Z" fill="#1A1918"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.8091 28.8951H27.1355V45.7197H23.8091V28.8951Z" fill="#1A1918"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.1242 28.8951H8.2417V45.7197H13.0985C15.6811 45.7197 17.5456 45.1184 19.1828 43.7775C21.1283 42.1889 22.2786 39.7949 22.2786 37.319C22.2786 32.3537 18.5187 28.8951 13.1242 28.8951ZM17.01 41.5336C15.9644 42.4651 14.6073 42.8713 12.4582 42.8713H11.5655V31.746H12.4582C14.6073 31.746 15.9111 32.1249 17.01 33.1064C18.1603 34.1171 18.8521 35.683 18.8521 37.2943C18.8521 38.9096 18.1603 40.5235 17.01 41.5336Z" fill="#1A1918"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M115.21 29.5275C115.21 29.233 115.005 29.0712 114.643 29.0712H114.162V30.5499H114.52V29.9766L114.939 30.5499H115.376L114.883 29.9402C115.094 29.8843 115.21 29.7329 115.21 29.5275ZM114.58 29.7296H114.52V29.3429H114.584C114.761 29.3429 114.853 29.4059 114.853 29.5327C114.853 29.664 114.76 29.7296 114.58 29.7296Z" fill="#1A1918"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M114.715 28.5187C113.987 28.5187 113.41 29.092 113.41 29.8077C113.41 30.5233 113.994 31.0973 114.715 31.0973C115.424 31.0973 116.005 30.5175 116.005 29.8077C116.005 29.1018 115.424 28.5187 114.715 28.5187ZM114.71 30.8672C114.138 30.8672 113.669 30.3966 113.669 29.8096C113.669 29.2207 114.132 28.7508 114.71 28.7508C115.28 28.7508 115.745 29.2318 115.745 29.8096C115.745 30.3914 115.28 30.8672 114.71 30.8672Z" fill="#1A1918"/>
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="paint0_radial_823_341" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(71.5 44) rotate(-142.431) scale(16.4012 16.1816)">
|
||||||
|
<stop stop-color="#F59900"/>
|
||||||
|
<stop offset="0.210082" stop-color="#F39501"/>
|
||||||
|
<stop offset="0.908163" stop-color="#CE3C0B"/>
|
||||||
|
<stop offset="1" stop-color="#A4420A"/>
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,42 @@
|
|||||||
|
<svg width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="120" height="80" rx="4" fill="white"/>
|
||||||
|
<path d="M100.9 58.8C100.9 65.8 95.1996 71.5 88.1996 71.5H19.0996V21.2C19.0996 14.2 24.7996 8.5 31.7996 8.5H100.9V58.8Z" fill="white"/>
|
||||||
|
<path d="M78.3994 45.9H83.6494C83.7994 45.9 84.1494 45.85 84.2994 45.85C85.2994 45.65 86.1494 44.75 86.1494 43.5C86.1494 42.3 85.2994 41.4 84.2994 41.15C84.1494 41.1 83.8494 41.1 83.6494 41.1H78.3994V45.9Z" fill="url(#paint0_linear_833_6149)"/>
|
||||||
|
<path d="M83.0494 12.75C78.0494 12.75 73.9494 16.8 73.9494 21.85V31.3H86.7994C87.0994 31.3 87.4494 31.3 87.6994 31.35C90.5994 31.5 92.7494 33 92.7494 35.6C92.7494 37.65 91.2994 39.4 88.5994 39.75V39.85C91.5494 40.05 93.7994 41.7 93.7994 44.25C93.7994 47 91.2994 48.8 87.9994 48.8H73.8994V67.3H87.2494C92.2494 67.3 96.3494 63.25 96.3494 58.2V12.75H83.0494Z" fill="url(#paint1_linear_833_6149)"/>
|
||||||
|
<path d="M85.4994 36.2C85.4994 35 84.6494 34.2 83.6494 34.05C83.5494 34.05 83.2994 34 83.1494 34H78.3994V38.4H83.1494C83.2994 38.4 83.5994 38.4 83.6494 38.35C84.6494 38.2 85.4994 37.4 85.4994 36.2Z" fill="url(#paint2_linear_833_6149)"/>
|
||||||
|
<path d="M57.8988 12.75C52.8988 12.75 48.7988 16.8 48.7988 21.85V33.75C51.0988 31.8 55.0988 30.55 61.5488 30.85C64.9988 31 68.6988 31.95 68.6988 31.95V35.8C66.8488 34.85 64.6488 34 61.7988 33.8C56.8988 33.45 53.9488 35.85 53.9488 40.05C53.9488 44.3 56.8988 46.7 61.7988 46.3C64.6488 46.1 66.8488 45.2 68.6988 44.3V48.15C68.6988 48.15 65.0488 49.1 61.5488 49.25C55.0988 49.55 51.0988 48.3 48.7988 46.35V67.35H62.1488C67.1488 67.35 71.2488 63.3 71.2488 58.25V12.75H57.8988Z" fill="url(#paint3_linear_833_6149)"/>
|
||||||
|
<path d="M32.7496 12.75C27.7496 12.75 23.6496 16.8 23.6496 21.85V44.3C26.1996 45.55 28.8496 46.35 31.4996 46.35C34.6496 46.35 36.3496 44.45 36.3496 41.85V31.25H44.1496V41.8C44.1496 45.9 41.5996 49.25 32.9496 49.25C27.6996 49.25 23.5996 48.1 23.5996 48.1V67.25H36.9496C41.9496 67.25 46.0496 63.2 46.0496 58.15V12.75H32.7496Z" fill="url(#paint4_linear_833_6149)"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0_linear_833_6149" x1="60.9804" y1="40.0821" x2="126.075" y2="40.0821" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#007940"/>
|
||||||
|
<stop offset="0.2285" stop-color="#00873F"/>
|
||||||
|
<stop offset="0.7433" stop-color="#40A737"/>
|
||||||
|
<stop offset="1" stop-color="#5CB531"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint1_linear_833_6149" x1="73.9404" y1="40.0023" x2="96.4108" y2="40.0023" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#007940"/>
|
||||||
|
<stop offset="0.2285" stop-color="#00873F"/>
|
||||||
|
<stop offset="0.7433" stop-color="#40A737"/>
|
||||||
|
<stop offset="1" stop-color="#5CB531"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint2_linear_833_6149" x1="73.9396" y1="36.1925" x2="96.409" y2="36.1925" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#007940"/>
|
||||||
|
<stop offset="0.2285" stop-color="#00873F"/>
|
||||||
|
<stop offset="0.7433" stop-color="#40A737"/>
|
||||||
|
<stop offset="1" stop-color="#5CB531"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint3_linear_833_6149" x1="48.6689" y1="40.0023" x2="70.8287" y2="40.0023" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#6C2C2F"/>
|
||||||
|
<stop offset="0.1735" stop-color="#882730"/>
|
||||||
|
<stop offset="0.5731" stop-color="#BE1833"/>
|
||||||
|
<stop offset="0.8585" stop-color="#DC0436"/>
|
||||||
|
<stop offset="1" stop-color="#E60039"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint4_linear_833_6149" x1="23.6382" y1="40.0023" x2="46.4553" y2="40.0023" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#1F286F"/>
|
||||||
|
<stop offset="0.4751" stop-color="#004E94"/>
|
||||||
|
<stop offset="0.8261" stop-color="#0066B1"/>
|
||||||
|
<stop offset="1" stop-color="#006FBC"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="120" height="80" rx="4" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M97.5288 54.6562V53.7384H97.289L97.0137 54.3698L96.7378 53.7384H96.498V54.6562H96.6675V53.9637L96.9257 54.5609H97.1011L97.36 53.9624V54.6562H97.5288ZM96.0111 54.6562V53.8947H96.318V53.7397H95.5361V53.8947H95.843V54.6562H96.0111Z" fill="#00A2E5"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6521 58.595H70.3479V21.4044H49.6521V58.595Z" fill="#7375CF"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M98.2675 40.0003C98.2675 53.063 87.6791 63.652 74.6171 63.652C69.0996 63.652 64.0229 61.7624 60 58.5956C65.5011 54.2646 69.0339 47.5448 69.0339 40.0003C69.0339 32.4552 65.5011 25.7354 60 21.4044C64.0229 18.2376 69.0996 16.348 74.6171 16.348C87.6791 16.348 98.2675 26.937 98.2675 40.0003Z" fill="#00A2E5"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M50.966 40.0003C50.966 32.4552 54.4988 25.7354 59.9999 21.4044C55.977 18.2376 50.9003 16.348 45.3828 16.348C32.3208 16.348 21.7324 26.937 21.7324 40.0003C21.7324 53.063 32.3208 63.652 45.3828 63.652C50.9003 63.652 55.977 61.7624 59.9999 58.5956C54.4988 54.2646 50.966 47.5448 50.966 40.0003Z" fill="#EB001B"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="120" height="80" rx="4" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M97.5288 54.6562V53.7384H97.289L97.0137 54.3698L96.7378 53.7384H96.498V54.6562H96.6675V53.9637L96.9257 54.5609H97.1011L97.36 53.9624V54.6562H97.5288ZM96.0111 54.6562V53.8947H96.318V53.7397H95.5361V53.8947H95.843V54.6562H96.0111Z" fill="#F79E1B"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6521 58.595H70.3479V21.4044H49.6521V58.595Z" fill="#FF5F00"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M98.2675 40.0003C98.2675 53.063 87.6791 63.652 74.6171 63.652C69.0996 63.652 64.0229 61.7624 60 58.5956C65.5011 54.2646 69.0339 47.5448 69.0339 40.0003C69.0339 32.4552 65.5011 25.7354 60 21.4044C64.0229 18.2376 69.0996 16.348 74.6171 16.348C87.6791 16.348 98.2675 26.937 98.2675 40.0003Z" fill="#F79E1B"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M50.966 40.0003C50.966 32.4552 54.4988 25.7354 59.9999 21.4044C55.977 18.2376 50.9003 16.348 45.3828 16.348C32.3208 16.348 21.7324 26.937 21.7324 40.0003C21.7324 53.063 32.3208 63.652 45.3828 63.652C50.9003 63.652 55.977 61.7624 59.9999 58.5956C54.4988 54.2646 50.966 47.5448 50.966 40.0003Z" fill="#EB001B"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
<svg width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="120" height="80" rx="4" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M76.5282 14.1788C73.7421 14.2654 70.3351 16.4596 69.7146 19.1653L60.2981 60.8371C59.6776 63.568 61.3656 65.7903 64.0813 65.8312H84.9996C87.6739 65.6989 90.2725 63.5298 90.8824 60.8549L100.299 19.1828C100.93 16.424 99.201 14.1839 96.4402 14.1839L76.5282 14.1788Z" fill="#01798A"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M60.2982 60.8371L69.7148 19.1653C70.3353 16.4596 73.7422 14.2654 76.4776 14.1815L68.5607 14.1764L54.2967 14.1737C51.5536 14.2298 48.1023 16.4394 47.482 19.1653L38.0627 60.8371C37.4399 63.568 39.1304 65.7903 41.8443 65.8312H64.0814C61.3657 65.7903 59.6777 63.568 60.2982 60.8371Z" fill="#024381"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.0627 60.8371L47.482 19.1653C48.1023 16.4394 51.5536 14.2298 54.2967 14.1737L36.0237 14.1689C33.2653 14.1689 29.7287 16.4039 29.0983 19.1653L19.6789 60.8371C19.6216 61.0914 19.5898 61.3406 19.5708 61.5845V62.3576C19.7552 64.3483 21.2754 65.798 23.4605 65.8312H41.8443C39.1304 65.7903 37.4399 63.568 38.0627 60.8371Z" fill="#DD0228"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M54.6818 44.5384H55.0276C55.3454 44.5384 55.5592 44.4318 55.6594 44.2206L56.558 42.8756H58.9644L58.4626 43.7603H61.3479L60.9819 45.1157H57.5486C57.1532 45.7107 56.6665 45.9904 56.0812 45.9572H54.2929L54.6818 44.5384ZM54.2867 46.4811H60.608L60.2051 47.9535H57.6629L57.275 49.3747H59.7488L59.3458 50.8469H56.872L56.2974 52.947C56.1551 53.298 56.3422 53.4559 56.8556 53.4201H58.8717L58.4982 54.7882H54.6274C53.8937 54.7882 53.642 54.3685 53.8722 53.527L54.6069 50.8469H53.0256L53.4273 49.3747H55.0088L55.3964 47.9535H53.8848L54.2867 46.4811ZM64.3762 42.8656L64.2766 43.7275C64.2766 43.7275 65.4691 42.8322 66.552 42.8322H70.5538L69.0234 48.3727C68.8965 49.0061 68.3523 49.3211 67.3911 49.3211H62.8554L61.7929 53.2116C61.7317 53.4201 61.8182 53.527 62.0471 53.527H62.9395L62.6115 54.7346H60.3426C59.4717 54.7346 59.1095 54.4727 59.2531 53.9466L62.2554 42.8656H64.3762ZM67.765 44.4318H64.1932L63.7659 45.9268C63.7659 45.9268 64.3608 45.4973 65.3548 45.4819C66.3461 45.4664 67.4776 45.4819 67.4776 45.4819L67.765 44.4318ZM66.471 47.8999C66.735 47.9357 66.8828 47.8312 66.9006 47.5845L67.1192 46.7964H63.5419L63.2419 47.8999H66.471ZM64.0581 49.6899H66.12L66.0817 50.5823H66.6307C66.9081 50.5823 67.0456 50.4935 67.0456 50.3181L67.2081 49.7408H68.9218L68.693 50.5823C68.4994 51.2842 67.9863 51.6503 67.1523 51.6861H66.054L66.0488 53.2116C66.0287 53.4559 66.2496 53.5804 66.7046 53.5804H67.7369L67.4037 54.7882H64.9276C64.2335 54.8212 63.8932 54.4905 63.9004 53.7889L64.0581 49.6899Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M72.8218 44.5995L73.3 42.9164H75.7177L75.6133 43.534C75.6133 43.534 76.8488 42.9164 77.7385 42.9164H80.7282L80.253 44.5995H79.7827L77.5275 52.5378H77.9978L77.5504 54.1143H77.0801L76.8844 54.7983H74.543L74.7383 54.1143H70.1191L70.5693 52.5378H71.0321L73.2894 44.5995H72.8218ZM75.4303 44.5995L74.815 46.7479C74.815 46.7479 75.8678 46.3439 76.7753 46.2295C76.9758 45.4792 77.2378 44.5995 77.2378 44.5995H75.4303ZM74.53 47.755L73.9126 50.0053C73.9126 50.0053 75.0794 49.4307 75.8801 49.3823C76.1114 48.5126 76.3429 47.755 76.3429 47.755H74.53ZM74.9826 52.5378L75.4454 50.9055H73.6407L73.1755 52.5378H74.9826ZM80.8301 42.8122H83.1031L83.1995 43.651C83.1845 43.8645 83.3114 43.9665 83.5809 43.9665H83.9825L83.5762 45.3877H81.9055C81.2676 45.4207 80.9395 45.1768 80.9091 44.6503L80.8301 42.8122ZM87.5266 45.8608L87.0946 47.3865H84.7504L84.3485 48.805H86.6903L86.2555 50.3282H83.6473L83.0572 51.2209H84.3338L84.6287 53.0082C84.6639 53.1862 84.8216 53.2727 85.0911 53.2727H85.4876L85.071 54.7447H83.6675C82.9403 54.7805 82.5643 54.5363 82.5336 54.0101L82.1953 52.3777L81.0336 54.1143C80.7589 54.605 80.3368 54.834 79.7677 54.7983H77.6243L78.0413 53.3259H78.71C78.9847 53.3259 79.2132 53.2039 79.4191 52.9573L81.2371 50.3282H78.893L79.3274 48.805H81.87L82.2743 47.3865H79.7293L80.1641 45.8608H87.5266Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M89.8554 40.9179C89.0926 42.5399 88.3657 43.4857 87.9388 43.9256C87.5113 44.3606 86.665 45.3726 84.626 45.2962L84.8015 44.058C86.5172 43.5291 87.4452 41.1464 87.9741 40.0913L87.3437 32.3209L88.6708 32.3031H89.7843L89.904 37.1775L91.9909 32.3031H94.1038L89.8554 40.9179Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M83.9472 32.8904L83.1078 33.4677C82.2308 32.7813 81.43 32.3566 79.8846 33.0735C77.7792 34.0499 76.02 41.5382 81.8165 39.0717L82.147 39.4633L84.4275 39.5218L85.925 32.7175L83.9472 32.8904ZM82.6505 36.6104C82.2841 37.6911 81.4659 38.4055 80.8252 38.2022C80.1846 38.0038 79.9557 36.9612 80.3269 35.8781C80.6929 34.7949 81.5165 34.083 82.1521 34.2864C82.7928 34.4847 83.024 35.5272 82.6505 36.6104Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M70.7625 27.8254H75.9591C76.9583 27.8254 77.731 28.0519 78.2622 28.4966C78.7911 28.9468 79.0558 29.5927 79.0558 30.4343V30.4596C79.0558 30.6197 79.0452 30.8003 79.0302 30.996C79.0045 31.1893 78.9713 31.385 78.9286 31.5885C78.6997 32.7023 78.1682 33.5973 77.3472 34.2762C76.5232 34.9525 75.5473 35.2933 74.4236 35.2933H71.6369L70.7752 39.5218H68.3623L70.7625 27.8254ZM72.0613 33.2592H74.3727C74.975 33.2592 75.4529 33.1192 75.8014 32.8422C76.1473 32.5625 76.3761 32.1354 76.503 31.5557C76.5232 31.4486 76.5358 31.3521 76.5512 31.2632C76.5591 31.1794 76.569 31.0952 76.569 31.0141C76.569 30.5995 76.4219 30.2995 76.1267 30.1113C75.8319 29.9204 75.3694 29.8291 74.7284 29.8291H72.7657L72.0613 33.2592Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M61.9146 32.3006H63.7046L63.5015 33.3431L63.7553 33.0456C64.3354 32.4253 65.0421 32.1175 65.8709 32.1175C66.6235 32.1175 67.1673 32.3363 67.5053 32.776C67.8388 33.216 67.9353 33.8237 67.7724 34.6043L66.7911 39.5218H64.9504L65.8405 35.0646C65.9318 34.6043 65.9065 34.261 65.7666 34.0397C65.6216 33.8185 65.3595 33.7093 64.9761 33.7093C64.503 33.7093 64.1066 33.8566 63.7785 34.1492C63.4529 34.4441 63.237 34.8535 63.1355 35.3747L62.3118 39.5218H60.4722L61.9146 32.3006Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M53.4452 38.97C52.9265 38.4742 52.6646 37.8055 52.6621 36.9563C52.6621 36.8112 52.6709 36.6461 52.6902 36.4654C52.7093 36.2823 52.7335 36.1043 52.7677 35.9392C53.0028 34.767 53.5038 33.8364 54.2753 33.1498C55.0456 32.4608 55.975 32.1149 57.0631 32.1149C57.9541 32.1149 58.6608 32.3642 59.1784 32.8625C59.6956 33.3635 59.9548 34.0397 59.9548 34.8994C59.9548 35.0466 59.9436 35.217 59.9244 35.4001C59.9015 35.5857 59.8738 35.7637 59.8416 35.9392C59.6118 37.0935 59.1124 38.014 58.3407 38.6879C57.569 39.3668 56.6423 39.7047 55.5618 39.7047C54.6669 39.7047 53.9626 39.4607 53.4452 38.97ZM57.2245 37.541C57.5741 37.1622 57.8245 36.5874 57.9771 35.8222C58 35.7028 58.0202 35.5781 58.0328 35.4535C58.0455 35.3314 58.0506 35.217 58.0506 35.1128C58.0506 34.6678 57.9374 34.3221 57.7099 34.0779C57.4838 33.8312 57.1623 33.7093 56.7467 33.7093C56.1973 33.7093 55.7499 33.9023 55.3993 34.2889C55.0456 34.6755 54.7952 35.2603 54.6375 36.0383C54.616 36.1578 54.5982 36.2774 54.5818 36.3943C54.5691 36.5138 54.5653 36.6257 54.5677 36.7273C54.5677 37.1698 54.681 37.5106 54.9084 37.752C55.1345 37.9937 55.4547 38.1131 55.8758 38.1131C56.4275 38.1131 56.8749 37.9224 57.2245 37.541Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M49.7065 32.3031H51.6897L50.1362 39.5193H48.157L49.7065 32.3031ZM50.3308 29.6741H52.3316L51.9579 31.4257H49.9572L50.3308 29.6741Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M41.4044 32.3006H43.1929L42.9884 33.3431L43.245 33.0456C43.8248 32.4253 44.529 32.1175 45.3603 32.1175C46.1129 32.1175 46.6556 32.3363 46.9964 32.776C47.3318 33.216 47.4234 33.8237 47.2647 34.6043L46.2794 39.5218H44.4413L45.3311 35.0646C45.4227 34.6043 45.3974 34.261 45.256 34.0397C45.1164 33.8185 44.8492 33.7093 44.4641 33.7093C43.9913 33.7093 43.5934 33.8566 43.2692 34.1492C42.9437 34.4441 42.729 34.8535 42.6235 35.3747L41.8036 39.5218H39.9617L41.4044 32.3006Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M39.0721 35.484C38.7924 36.8546 38.1441 37.9072 37.1385 38.6548C36.142 39.3896 34.8567 39.7582 33.2831 39.7582C31.8022 39.7582 30.7165 39.3819 30.0237 38.6267C29.5432 38.0902 29.3042 37.4087 29.3042 36.5849C29.3042 36.2443 29.345 35.8781 29.4263 35.484L31.103 27.3984H33.6352L31.9814 35.3925C31.9305 35.6138 31.9102 35.8197 31.9128 36.0053C31.9102 36.4148 32.0118 36.7503 32.2177 37.0121C32.5177 37.4013 33.0046 37.5944 33.6822 37.5944C34.4613 37.5944 35.1033 37.4038 35.6016 37.0197C36.1 36.6384 36.4254 36.0969 36.5715 35.3925L38.2305 27.3984H40.7499L39.0721 35.484Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.6 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="120" height="80" rx="4" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M86.6666 44.9375L90.3239 35.0625L92.3809 44.9375H86.6666ZM100.952 52.8375L95.8086 27.1625H88.7383C86.3525 27.1625 85.7723 29.0759 85.7723 29.0759L76.1904 52.8375H82.8868L84.2269 49.0244H92.3947L93.1479 52.8375H100.952Z" fill="#1434CB"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M77.1866 33.5711L78.0952 28.244C78.0952 28.244 75.2896 27.1625 72.3648 27.1625C69.2031 27.1625 61.6955 28.5638 61.6955 35.3738C61.6955 41.7825 70.5071 41.8621 70.5071 45.2266C70.5071 48.5912 62.6034 47.9901 59.9955 45.8676L59.0476 51.4362C59.0476 51.4362 61.8919 52.8375 66.2397 52.8375C70.5869 52.8375 77.1467 50.5544 77.1467 44.3455C77.1467 37.8964 68.2552 37.296 68.2552 34.4921C68.2552 31.6882 74.4602 32.0484 77.1866 33.5711Z" fill="#1434CB"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M54.6517 52.8375H47.6191L52.0144 27.1625H59.0477L54.6517 52.8375Z" fill="#1434CB"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M42.3113 27.1625L35.9217 44.8213L35.1663 41.0185L35.167 41.0199L32.9114 29.4749C32.9114 29.4749 32.6394 27.1625 29.7324 27.1625H19.1709L19.0476 27.5966C19.0476 27.5966 22.2782 28.2669 26.057 30.5326L31.8793 52.8375H38.8617L49.5238 27.1625H42.3113Z" fill="#1434CB"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'preact/hooks';
|
import { useState } from 'preact/hooks';
|
||||||
import { ChevronLeft, ChevronRight, Clipboard, Plus, RefreshCw, Trash2, UserCheck, UserX } from 'lucide-preact';
|
import { ChevronLeft, ChevronRight, Clipboard, Plus, RefreshCw, Trash2, UserCheck, UserX } from 'lucide-preact';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
|
import LoadingState from '@/components/LoadingState';
|
||||||
import type { AdminInvite, AdminUser } from '@/lib/types';
|
import type { AdminInvite, AdminUser } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -8,6 +9,8 @@ interface AdminPageProps {
|
|||||||
currentUserId: string;
|
currentUserId: string;
|
||||||
users: AdminUser[];
|
users: AdminUser[];
|
||||||
invites: AdminInvite[];
|
invites: AdminInvite[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onCreateInvite: (hours: number) => Promise<void>;
|
onCreateInvite: (hours: number) => Promise<void>;
|
||||||
onDeleteAllInvites: () => Promise<void>;
|
onDeleteAllInvites: () => Promise<void>;
|
||||||
@@ -40,10 +43,30 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
return status || '-';
|
return status || '-';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeToggleableStatus = (status: string): 'active' | 'banned' | null => {
|
||||||
|
const normalized = String(status || '').toLowerCase();
|
||||||
|
if (normalized === 'active' || normalized === 'banned') return normalized;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
|
{!!props.error && (
|
||||||
|
<div className="local-error">
|
||||||
|
<span>{props.error}</span>
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={props.onRefresh}>
|
||||||
|
<RefreshCw size={14} className="btn-icon" />
|
||||||
|
{t('txt_refresh')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h3>{t('txt_users')}</h3>
|
<div className="section-head">
|
||||||
|
<h3>{t('txt_users')}</h3>
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
|
||||||
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_refresh')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<table className="table">
|
<table className="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -55,8 +78,10 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{props.users.map((user) => (
|
{props.users.map((user) => {
|
||||||
<tr key={user.id}>
|
const toggleableStatus = normalizeToggleableStatus(user.status);
|
||||||
|
return (
|
||||||
|
<tr key={user.id}>
|
||||||
<td data-label={t('txt_email')}>{user.email}</td>
|
<td data-label={t('txt_email')}>{user.email}</td>
|
||||||
<td data-label={t('txt_name')}>{user.name || t('txt_dash')}</td>
|
<td data-label={t('txt_name')}>{user.name || t('txt_dash')}</td>
|
||||||
<td data-label={t('txt_role')}>{roleText(user.role)}</td>
|
<td data-label={t('txt_role')}>{roleText(user.role)}</td>
|
||||||
@@ -66,8 +91,11 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
disabled={user.id === props.currentUserId}
|
disabled={user.id === props.currentUserId || !toggleableStatus}
|
||||||
onClick={() => void props.onToggleUserStatus(user.id, user.status)}
|
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' ? <UserX size={14} className="btn-icon" /> : <UserCheck size={14} className="btn-icon" />}
|
||||||
{user.status === 'active' ? t('txt_ban') : t('txt_unban')}
|
{user.status === 'active' ? t('txt_ban') : t('txt_unban')}
|
||||||
@@ -80,21 +108,41 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{props.loading && !props.users.length && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5}>
|
||||||
|
<LoadingState lines={4} compact />
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
)}
|
||||||
|
{!props.loading && !props.users.length && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5}>
|
||||||
|
<div className="empty empty-comfortable">{t('txt_no_users_found')}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card">
|
<section className="card admin-invites-card">
|
||||||
<div className="section-head">
|
<div className="section-head admin-invites-head">
|
||||||
<h3>{t('txt_invites')}</h3>
|
<h3>{t('txt_invites')}</h3>
|
||||||
<button type="button" className="btn btn-secondary" onClick={props.onRefresh}>
|
<div className="actions admin-invites-head-actions">
|
||||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync')}
|
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
|
||||||
</button>
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-danger small" onClick={() => void props.onDeleteAllInvites()}>
|
||||||
|
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_all')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="invite-toolbar">
|
<div className="invite-toolbar">
|
||||||
<div className="actions invite-create-group">
|
<div className="invite-create-group">
|
||||||
<label className="field invite-hours-field">
|
<label className="field invite-hours-field">
|
||||||
<span>{t('txt_invite_validity_hours')}</span>
|
<span>{t('txt_invite_validity_hours')}</span>
|
||||||
<input
|
<input
|
||||||
@@ -111,11 +159,8 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
{t('txt_create_timed_invite')}
|
{t('txt_create_timed_invite')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-danger" onClick={() => void props.onDeleteAllInvites()}>
|
|
||||||
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_all')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<table className="table">
|
<table className="table invite-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{t('txt_code')}</th>
|
<th>{t('txt_code')}</th>
|
||||||
@@ -148,9 +193,23 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
{props.loading && !props.invites.length && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4}>
|
||||||
|
<LoadingState lines={4} compact />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{!props.loading && !props.invites.length && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4}>
|
||||||
|
<div className="empty empty-comfortable">{t('txt_no_invites_found')}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div className="actions">
|
<div className="actions admin-pagination invite-pagination">
|
||||||
<button type="button" className="btn btn-secondary small" disabled={safePage <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
<button type="button" className="btn btn-secondary small" disabled={safePage <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||||
<ChevronLeft size={14} className="btn-icon" />
|
<ChevronLeft size={14} className="btn-icon" />
|
||||||
{t('txt_prev')}
|
{t('txt_prev')}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, Globe2, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldUser, SlidersHorizontal, Users } from 'lucide-preact';
|
||||||
|
import type { ComponentChildren } from 'preact';
|
||||||
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import { Link } from 'wouter';
|
import { Link } from 'wouter';
|
||||||
import AppMainRoutes from '@/components/AppMainRoutes';
|
import AppMainRoutes from '@/components/AppMainRoutes';
|
||||||
import ThemeSwitch from '@/components/ThemeSwitch';
|
import ThemeSwitch from '@/components/ThemeSwitch';
|
||||||
@@ -21,19 +23,217 @@ interface AppAuthenticatedShellProps {
|
|||||||
onLock: () => void;
|
onLock: () => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
onToggleTheme: () => void;
|
onToggleTheme: () => void;
|
||||||
|
onToggleMobileSidebar: () => void;
|
||||||
mainRoutesProps: AppMainRoutesProps;
|
mainRoutesProps: AppMainRoutesProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NavLayoutMode = 'flat' | 'grouped-expanded' | 'grouped-smart';
|
||||||
|
|
||||||
|
const NAV_LAYOUT_STORAGE_KEY = 'nodewarden.navLayoutMode';
|
||||||
|
|
||||||
|
function readNavLayoutMode(): NavLayoutMode {
|
||||||
|
if (typeof window === 'undefined') return 'flat';
|
||||||
|
try {
|
||||||
|
const saved = window.localStorage.getItem(NAV_LAYOUT_STORAGE_KEY);
|
||||||
|
if (saved === 'flat' || saved === 'grouped-expanded' || saved === 'grouped-smart') return saved;
|
||||||
|
} catch {
|
||||||
|
// Ignore local preference read failures.
|
||||||
|
}
|
||||||
|
return 'flat';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAdminProfile(profile: Profile | null): boolean {
|
||||||
|
return String(profile?.role || '').toLowerCase() === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
|
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
|
||||||
const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location;
|
const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location;
|
||||||
|
const isDomainRulesRoute = props.location === '/settings/domain-rules';
|
||||||
|
const isLogRoute = props.location === '/logs';
|
||||||
|
const isAdmin = isAdminProfile(props.profile);
|
||||||
|
const vaultActive = props.location === '/vault' || props.location === '/vault/totp';
|
||||||
|
const settingsActive = props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules';
|
||||||
|
const dataActive = props.location === '/backup' || props.isImportRoute;
|
||||||
|
const managementActive = props.location === '/admin' || props.location === '/security/devices' || props.location === '/logs';
|
||||||
|
const [navLayoutMode, setNavLayoutMode] = useState<NavLayoutMode>(readNavLayoutMode);
|
||||||
|
const [navLayoutPickerOpen, setNavLayoutPickerOpen] = useState(false);
|
||||||
|
const navLayoutPickerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState({
|
||||||
|
vault: true,
|
||||||
|
settings: false,
|
||||||
|
data: false,
|
||||||
|
management: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPointerDown = (event: Event) => {
|
||||||
|
if (!navLayoutPickerOpen) return;
|
||||||
|
const target = event.target as Node | null;
|
||||||
|
if (navLayoutPickerRef.current && target && !navLayoutPickerRef.current.contains(target)) {
|
||||||
|
setNavLayoutPickerOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') setNavLayoutPickerOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', onPointerDown);
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', onPointerDown);
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
};
|
||||||
|
}, [navLayoutPickerOpen]);
|
||||||
|
|
||||||
|
function setNavMode(mode: NavLayoutMode): void {
|
||||||
|
setNavLayoutMode(mode);
|
||||||
|
setNavLayoutPickerOpen(false);
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(NAV_LAYOUT_STORAGE_KEY, mode);
|
||||||
|
} catch {
|
||||||
|
// Ignore local preference write failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGroup(group: keyof typeof expandedGroups): void {
|
||||||
|
setExpandedGroups((current) => ({ ...current, [group]: !current[group] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupOpen(group: keyof typeof expandedGroups, active: boolean): boolean {
|
||||||
|
if (navLayoutMode === 'grouped-expanded') return true;
|
||||||
|
return expandedGroups[group] || active;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSideLink(href: string, active: boolean, icon: ComponentChildren, label: string) {
|
||||||
|
return (
|
||||||
|
<Link href={href} className={`side-link ${active ? 'active' : ''}`}>
|
||||||
|
{icon}
|
||||||
|
<span>{label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSubLink(href: string, active: boolean, label: string) {
|
||||||
|
return (
|
||||||
|
<Link href={href} className={`side-sub-link ${active ? 'active' : ''}`}>
|
||||||
|
<span>{label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNavGroup(
|
||||||
|
group: keyof typeof expandedGroups,
|
||||||
|
title: string,
|
||||||
|
icon: ComponentChildren,
|
||||||
|
active: boolean,
|
||||||
|
children: ComponentChildren
|
||||||
|
) {
|
||||||
|
const open = groupOpen(group, active);
|
||||||
|
return (
|
||||||
|
<div className={`side-nav-group ${open ? 'open' : ''}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`side-group-trigger ${active ? 'active' : ''}`}
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => toggleGroup(group)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span>{title}</span>
|
||||||
|
<ChevronDown size={15} className="side-group-chevron" />
|
||||||
|
</button>
|
||||||
|
<div className={`side-subnav ${open ? 'open' : ''}`}>
|
||||||
|
<div className="side-subnav-inner">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const navLayoutOptions: Array<{ mode: NavLayoutMode; label: string }> = [
|
||||||
|
{
|
||||||
|
mode: 'flat',
|
||||||
|
label: t('txt_nav_layout_flat'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: 'grouped-expanded',
|
||||||
|
label: t('txt_nav_layout_grouped_expanded'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: 'grouped-smart',
|
||||||
|
label: t('txt_nav_layout_grouped_smart'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const navLayoutLabel = navLayoutOptions.find((option) => option.mode === navLayoutMode)?.label || t('txt_nav_layout_flat');
|
||||||
|
const flatNav = (
|
||||||
|
<>
|
||||||
|
{renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))}
|
||||||
|
{renderSideLink('/vault/totp', props.location === '/vault/totp', <Clock3 size={16} />, t('txt_verification_code'))}
|
||||||
|
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
|
||||||
|
{renderSideLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, <SettingsIcon size={16} />, t('nav_account_settings'))}
|
||||||
|
{renderSideLink('/settings/domain-rules', props.location === '/settings/domain-rules', <Globe2 size={16} />, t('nav_domain_rules'))}
|
||||||
|
{isAdmin && renderSideLink('/backup', props.location === '/backup', <Cloud size={16} />, t('nav_backup_strategy'))}
|
||||||
|
{renderSideLink(props.importRoute, props.isImportRoute, <ArrowUpDown size={16} />, t('nav_import_export'))}
|
||||||
|
{isAdmin && renderSideLink('/admin', props.location === '/admin', <Users size={16} />, t('nav_admin_panel'))}
|
||||||
|
{isAdmin && renderSideLink('/logs', props.location === '/logs', <FileClock size={16} />, t('nav_log_center'))}
|
||||||
|
{renderSideLink('/security/devices', props.location === '/security/devices', <MonitorSmartphone size={16} />, t('nav_device_management'))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupedNav = (
|
||||||
|
<>
|
||||||
|
{renderNavGroup(
|
||||||
|
'vault',
|
||||||
|
t('nav_my_vault'),
|
||||||
|
<KeyRound size={16} />,
|
||||||
|
vaultActive,
|
||||||
|
<>
|
||||||
|
{renderSubLink('/vault', props.location === '/vault', t('nav_vault_items'))}
|
||||||
|
{renderSubLink('/vault/totp', props.location === '/vault/totp', t('txt_verification_code'))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
|
||||||
|
{renderNavGroup(
|
||||||
|
'settings',
|
||||||
|
t('txt_settings'),
|
||||||
|
<SettingsIcon size={16} />,
|
||||||
|
settingsActive,
|
||||||
|
<>
|
||||||
|
{renderSubLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, t('nav_account_settings'))}
|
||||||
|
{renderSubLink('/settings/domain-rules', props.location === '/settings/domain-rules', t('nav_domain_rules'))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{renderNavGroup(
|
||||||
|
'data',
|
||||||
|
t('nav_group_data_backup'),
|
||||||
|
<Cloud size={16} />,
|
||||||
|
dataActive,
|
||||||
|
<>
|
||||||
|
{isAdmin && renderSubLink('/backup', props.location === '/backup', t('nav_backup_strategy'))}
|
||||||
|
{renderSubLink(props.importRoute, props.isImportRoute, t('nav_import_export'))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{renderNavGroup(
|
||||||
|
'management',
|
||||||
|
t('nav_group_management'),
|
||||||
|
<ShieldUser size={16} />,
|
||||||
|
managementActive,
|
||||||
|
<>
|
||||||
|
{isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))}
|
||||||
|
{isAdmin && renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))}
|
||||||
|
{renderSubLink('/security/devices', props.location === '/security/devices', t('nav_device_management'))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-page">
|
<div className="app-page">
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div className="brand">
|
<div className="brand">
|
||||||
<img src="/logo-64.png" alt="NodeWarden logo" className="brand-logo" />
|
<img src="/nodewarden-logo.svg" alt="NodeWarden logo" className="brand-logo" />
|
||||||
<span className="brand-name">NodeWarden</span>
|
<span className="brand-wordmark" role="img" aria-label="NodeWarden" />
|
||||||
<span className="mobile-page-title">{props.currentPageTitle}</span>
|
<span className="mobile-page-title">{props.currentPageTitle}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="topbar-actions">
|
<div className="topbar-actions">
|
||||||
@@ -51,7 +251,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
className="btn btn-secondary small mobile-sidebar-toggle"
|
className="btn btn-secondary small mobile-sidebar-toggle"
|
||||||
aria-label={props.sidebarToggleTitle}
|
aria-label={props.sidebarToggleTitle}
|
||||||
title={props.sidebarToggleTitle}
|
title={props.sidebarToggleTitle}
|
||||||
onClick={() => window.dispatchEvent(new CustomEvent('nodewarden:toggle-sidebar'))}
|
onClick={props.onToggleMobileSidebar}
|
||||||
>
|
>
|
||||||
<FolderIcon size={16} className="btn-icon" />
|
<FolderIcon size={16} className="btn-icon" />
|
||||||
</button>
|
</button>
|
||||||
@@ -70,45 +270,43 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
|
|
||||||
<div className="app-main">
|
<div className="app-main">
|
||||||
<aside className="app-side">
|
<aside className="app-side">
|
||||||
<Link href="/vault" className={`side-link ${props.location === '/vault' ? 'active' : ''}`}>
|
<div className="side-nav-main">
|
||||||
<KeyRound size={16} />
|
{navLayoutMode === 'flat' ? flatNav : groupedNav}
|
||||||
<span>{t('nav_my_vault')}</span>
|
</div>
|
||||||
</Link>
|
<div className="nav-layout-control" ref={navLayoutPickerRef}>
|
||||||
<Link href="/vault/totp" className={`side-link ${props.location === '/vault/totp' ? 'active' : ''}`}>
|
{navLayoutPickerOpen && (
|
||||||
<Clock3 size={16} />
|
<div className="nav-layout-menu" role="menu">
|
||||||
<span>{t('txt_verification_code')}</span>
|
{navLayoutOptions.map((option) => (
|
||||||
</Link>
|
<button
|
||||||
<Link href="/sends" className={`side-link ${props.location === '/sends' ? 'active' : ''}`}>
|
key={option.mode}
|
||||||
<SendIcon size={16} />
|
type="button"
|
||||||
<span>{t('nav_sends')}</span>
|
className={`nav-layout-option ${navLayoutMode === option.mode ? 'active' : ''}`}
|
||||||
</Link>
|
onClick={() => setNavMode(option.mode)}
|
||||||
{props.profile?.role === 'admin' && (
|
role="menuitemradio"
|
||||||
<Link href="/admin" className={`side-link ${props.location === '/admin' ? 'active' : ''}`}>
|
aria-checked={navLayoutMode === option.mode}
|
||||||
<ShieldUser size={16} />
|
>
|
||||||
<span>{t('nav_admin_panel')}</span>
|
<span className="nav-layout-option-text">
|
||||||
</Link>
|
<strong>{option.label}</strong>
|
||||||
)}
|
</span>
|
||||||
<Link href={props.settingsAccountRoute} className={`side-link ${props.location === props.settingsAccountRoute ? 'active' : ''}`}>
|
{navLayoutMode === option.mode && <Check size={15} className="nav-layout-check" />}
|
||||||
<SettingsIcon size={16} />
|
</button>
|
||||||
<span>{t('nav_account_settings')}</span>
|
))}
|
||||||
</Link>
|
</div>
|
||||||
<Link href="/security/devices" className={`side-link ${props.location === '/security/devices' ? 'active' : ''}`}>
|
)}
|
||||||
<Shield size={16} />
|
<button
|
||||||
<span>{t('nav_device_management')}</span>
|
type="button"
|
||||||
</Link>
|
className={`nav-layout-trigger ${navLayoutPickerOpen ? 'active' : ''}`}
|
||||||
{props.profile?.role === 'admin' && (
|
aria-haspopup="menu"
|
||||||
<Link href="/backup" className={`side-link ${props.location === '/backup' ? 'active' : ''}`}>
|
aria-expanded={navLayoutPickerOpen}
|
||||||
<Cloud size={16} />
|
onClick={() => setNavLayoutPickerOpen((open) => !open)}
|
||||||
<span>{t('nav_backup_strategy')}</span>
|
title={t('txt_nav_layout')}
|
||||||
</Link>
|
>
|
||||||
)}
|
<SlidersHorizontal size={15} />
|
||||||
<Link href={props.importRoute} className={`side-link ${props.isImportRoute ? 'active' : ''}`}>
|
</button>
|
||||||
<ArrowUpDown size={14} />
|
</div>
|
||||||
<span>{t('nav_import_export')}</span>
|
|
||||||
</Link>
|
|
||||||
</aside>
|
</aside>
|
||||||
<main className="content">
|
<main className="content">
|
||||||
<div key={routeAnimationKey} className="route-stage">
|
<div key={routeAnimationKey} className={`route-stage ${isDomainRulesRoute ? 'route-stage-fixed' : ''} ${isLogRoute ? 'route-stage-log-fixed' : ''}`}>
|
||||||
<AppMainRoutes {...props.mainRoutesProps} />
|
<AppMainRoutes {...props.mainRoutesProps} />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -27,11 +27,13 @@ interface AppGlobalOverlaysProps {
|
|||||||
onConfirmTotp: () => void;
|
onConfirmTotp: () => void;
|
||||||
onCancelTotp: () => void;
|
onCancelTotp: () => void;
|
||||||
onUseRecoveryCode: () => void;
|
onUseRecoveryCode: () => void;
|
||||||
|
totpSubmitting: boolean;
|
||||||
disableTotpOpen: boolean;
|
disableTotpOpen: boolean;
|
||||||
disableTotpPassword: string;
|
disableTotpPassword: string;
|
||||||
onDisableTotpPasswordChange: (value: string) => void;
|
onDisableTotpPasswordChange: (value: string) => void;
|
||||||
onConfirmDisableTotp: () => void;
|
onConfirmDisableTotp: () => void;
|
||||||
onCancelDisableTotp: () => void;
|
onCancelDisableTotp: () => void;
|
||||||
|
disableTotpSubmitting: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||||
@@ -57,12 +59,14 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
|||||||
confirmText={t('txt_verify')}
|
confirmText={t('txt_verify')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
showIcon={false}
|
showIcon={false}
|
||||||
|
confirmDisabled={props.totpSubmitting}
|
||||||
|
cancelDisabled={props.totpSubmitting}
|
||||||
onConfirm={props.onConfirmTotp}
|
onConfirm={props.onConfirmTotp}
|
||||||
onCancel={props.onCancelTotp}
|
onCancel={props.onCancelTotp}
|
||||||
afterActions={(
|
afterActions={(
|
||||||
<div className="dialog-extra">
|
<div className="dialog-extra">
|
||||||
<div className="dialog-divider" />
|
<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')}
|
{t('txt_use_recovery_code')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,7 +76,7 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
|||||||
<span>{t('txt_totp_code')}</span>
|
<span>{t('txt_totp_code')}</span>
|
||||||
<input className="input" value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
|
<input className="input" value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
|
||||||
</label>
|
</label>
|
||||||
<label className="check-line" style={{ marginBottom: 0 }}>
|
<label className="check-line check-line-compact">
|
||||||
<input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} />
|
<input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} />
|
||||||
<span>{t('txt_trust_this_device_for_30_days')}</span>
|
<span>{t('txt_trust_this_device_for_30_days')}</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -86,6 +90,8 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
|||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
danger
|
danger
|
||||||
showIcon={false}
|
showIcon={false}
|
||||||
|
confirmDisabled={props.disableTotpSubmitting}
|
||||||
|
cancelDisabled={props.disableTotpSubmitting}
|
||||||
onConfirm={props.onConfirmDisableTotp}
|
onConfirm={props.onConfirmDisableTotp}
|
||||||
onCancel={props.onCancelDisableTotp}
|
onCancel={props.onCancelDisableTotp}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
import { lazy, Suspense } from 'preact/compat';
|
import { lazy, Suspense } from 'preact/compat';
|
||||||
import { useEffect } from 'preact/hooks';
|
import { useEffect } from 'preact/hooks';
|
||||||
import { Link, Route, Switch } from 'wouter';
|
import { Link, Route, Switch } from 'wouter';
|
||||||
import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
import { ArrowUpDown, Cloud, FileClock, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||||
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||||
|
import LoadingState from '@/components/LoadingState';
|
||||||
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
||||||
|
import type { AuditLogFilters } from '@/lib/api/admin';
|
||||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
|
import type { AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
|
||||||
import type { ExportRequest } from '@/lib/export-formats';
|
import type { ExportRequest } from '@/lib/export-formats';
|
||||||
|
|
||||||
|
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
||||||
const SendsPage = lazy(() => import('@/components/SendsPage'));
|
const SendsPage = lazy(() => import('@/components/SendsPage'));
|
||||||
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
|
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
|
||||||
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
|
||||||
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
|
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
|
||||||
|
const DomainRulesPage = lazy(() => import('@/components/DomainRulesPage'));
|
||||||
const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage'));
|
const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage'));
|
||||||
const AdminPage = lazy(() => import('@/components/AdminPage'));
|
const AdminPage = lazy(() => import('@/components/AdminPage'));
|
||||||
|
const LogCenterPage = lazy(() => import('@/components/LogCenterPage'));
|
||||||
const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage'));
|
const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage'));
|
||||||
const ImportPage = lazy(() => import('@/components/ImportPage'));
|
const ImportPage = lazy(() => import('@/components/ImportPage'));
|
||||||
|
|
||||||
function RouteContentFallback() {
|
function RouteContentFallback() {
|
||||||
return <div className="loading-screen">{t('txt_loading_nodewarden')}</div>;
|
return <LoadingState card lines={5} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function LegacyBackupRedirect(props: { onNavigate: (path: string) => void }) {
|
function LegacyBackupRedirect(props: { onNavigate: (path: string) => void }) {
|
||||||
@@ -31,22 +35,33 @@ function LegacyBackupRedirect(props: { onNavigate: (path: string) => void }) {
|
|||||||
|
|
||||||
export interface AppMainRoutesProps {
|
export interface AppMainRoutesProps {
|
||||||
profile: Profile | null;
|
profile: Profile | null;
|
||||||
|
profileLoading: boolean;
|
||||||
session: SessionState | null;
|
session: SessionState | null;
|
||||||
mobileLayout: boolean;
|
mobileLayout: boolean;
|
||||||
|
mobileSidebarToggleKey: number;
|
||||||
importRoute: string;
|
importRoute: string;
|
||||||
settingsHomeRoute: string;
|
settingsHomeRoute: string;
|
||||||
settingsAccountRoute: string;
|
settingsAccountRoute: string;
|
||||||
decryptedCiphers: Cipher[];
|
decryptedCiphers: Cipher[];
|
||||||
decryptedFolders: VaultFolder[];
|
decryptedFolders: VaultFolder[];
|
||||||
decryptedSends: Send[];
|
decryptedSends: Send[];
|
||||||
|
vaultError: string;
|
||||||
ciphersLoading: boolean;
|
ciphersLoading: boolean;
|
||||||
foldersLoading: boolean;
|
foldersLoading: boolean;
|
||||||
sendsLoading: boolean;
|
sendsLoading: boolean;
|
||||||
users: AdminUser[];
|
users: AdminUser[];
|
||||||
invites: AdminInvite[];
|
invites: AdminInvite[];
|
||||||
|
adminLoading: boolean;
|
||||||
|
adminError: string;
|
||||||
totpEnabled: boolean;
|
totpEnabled: boolean;
|
||||||
|
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||||
|
sessionTimeoutAction: 'lock' | 'logout';
|
||||||
authorizedDevices: AuthorizedDevice[];
|
authorizedDevices: AuthorizedDevice[];
|
||||||
authorizedDevicesLoading: boolean;
|
authorizedDevicesLoading: boolean;
|
||||||
|
authorizedDevicesError: string;
|
||||||
|
domainRules: DomainRules | null;
|
||||||
|
domainRulesLoading: boolean;
|
||||||
|
domainRulesError: string;
|
||||||
onNavigate: (path: string) => void;
|
onNavigate: (path: string) => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||||
@@ -66,6 +81,7 @@ export interface AppMainRoutesProps {
|
|||||||
onDeleteVaultItem: (cipher: Cipher) => Promise<void>;
|
onDeleteVaultItem: (cipher: Cipher) => Promise<void>;
|
||||||
onArchiveVaultItem: (cipher: Cipher) => Promise<void>;
|
onArchiveVaultItem: (cipher: Cipher) => Promise<void>;
|
||||||
onUnarchiveVaultItem: (cipher: Cipher) => Promise<void>;
|
onUnarchiveVaultItem: (cipher: Cipher) => Promise<void>;
|
||||||
|
onRestoreVaultItems: (ids: string[]) => Promise<void>;
|
||||||
onBulkDeleteVaultItems: (ids: string[]) => Promise<void>;
|
onBulkDeleteVaultItems: (ids: string[]) => Promise<void>;
|
||||||
onBulkPermanentDeleteVaultItems: (ids: string[]) => Promise<void>;
|
onBulkPermanentDeleteVaultItems: (ids: string[]) => Promise<void>;
|
||||||
onBulkRestoreVaultItems: (ids: string[]) => Promise<void>;
|
onBulkRestoreVaultItems: (ids: string[]) => Promise<void>;
|
||||||
@@ -74,6 +90,7 @@ export interface AppMainRoutesProps {
|
|||||||
onBulkMoveVaultItems: (ids: string[], folderId: string | null) => Promise<void>;
|
onBulkMoveVaultItems: (ids: string[], folderId: string | null) => Promise<void>;
|
||||||
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
|
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
|
||||||
onCreateFolder: (name: string) => Promise<void>;
|
onCreateFolder: (name: string) => Promise<void>;
|
||||||
|
onRenameFolder: (folderId: string, name: string) => Promise<void>;
|
||||||
onDeleteFolder: (folderId: string) => Promise<void>;
|
onDeleteFolder: (folderId: string) => Promise<void>;
|
||||||
onBulkDeleteFolders: (folderIds: string[]) => Promise<void>;
|
onBulkDeleteFolders: (folderIds: string[]) => Promise<void>;
|
||||||
onDownloadVaultAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
|
onDownloadVaultAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
|
||||||
@@ -93,8 +110,16 @@ export interface AppMainRoutesProps {
|
|||||||
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
||||||
onOpenDisableTotp: () => void;
|
onOpenDisableTotp: () => void;
|
||||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||||
|
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||||
|
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||||
|
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
||||||
|
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
|
||||||
onRefreshAuthorizedDevices: () => Promise<void>;
|
onRefreshAuthorizedDevices: () => Promise<void>;
|
||||||
|
onRefreshDomainRules: () => void;
|
||||||
|
onSaveDomainRules: (customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]) => Promise<void>;
|
||||||
|
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
||||||
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
||||||
|
onTrustDevicePermanently: (device: AuthorizedDevice) => void;
|
||||||
onRemoveDevice: (device: AuthorizedDevice) => void;
|
onRemoveDevice: (device: AuthorizedDevice) => void;
|
||||||
onRevokeAllDeviceTrust: () => void;
|
onRevokeAllDeviceTrust: () => void;
|
||||||
onRemoveAllDevices: () => void;
|
onRemoveAllDevices: () => void;
|
||||||
@@ -104,19 +129,27 @@ export interface AppMainRoutesProps {
|
|||||||
onToggleUserStatus: (userId: string, status: 'active' | 'banned') => Promise<void>;
|
onToggleUserStatus: (userId: string, status: 'active' | 'banned') => Promise<void>;
|
||||||
onDeleteUser: (userId: string) => Promise<void>;
|
onDeleteUser: (userId: string) => Promise<void>;
|
||||||
onRevokeInvite: (code: string) => Promise<void>;
|
onRevokeInvite: (code: string) => Promise<void>;
|
||||||
|
onLoadAuditLogs: (filters: AuditLogFilters) => Promise<AuditLogListResult>;
|
||||||
|
onLoadAuditLogSettings: () => Promise<AuditLogSettings>;
|
||||||
|
onSaveAuditLogSettings: (settings: AuditLogSettings) => Promise<AuditLogSettings>;
|
||||||
|
onClearAuditLogs: () => Promise<number>;
|
||||||
onExportBackup: (includeAttachments?: boolean) => Promise<void>;
|
onExportBackup: (includeAttachments?: boolean) => Promise<void>;
|
||||||
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
|
onImportBackupAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
onLoadBackupSettings: () => Promise<AdminBackupSettings>;
|
onLoadBackupSettings: () => Promise<AdminBackupSettings>;
|
||||||
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||||
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||||
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||||
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
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>;
|
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
||||||
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
|
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AppMainRoutes(props: AppMainRoutesProps) {
|
export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||||
const importRoutePaths = [props.importRoute, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const;
|
const importRoutePaths = [props.importRoute, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const;
|
||||||
|
const isAdmin = String(props.profile?.role || '').toLowerCase() === 'admin';
|
||||||
const importPageContent = (
|
const importPageContent = (
|
||||||
<Suspense fallback={<RouteContentFallback />}>
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
<ImportPage
|
<ImportPage
|
||||||
@@ -158,6 +191,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
onBulkDelete={props.onBulkDeleteSends}
|
onBulkDelete={props.onBulkDeleteSends}
|
||||||
uploadingSendFileName={props.uploadingSendFileName}
|
uploadingSendFileName={props.uploadingSendFileName}
|
||||||
sendUploadPercent={props.sendUploadPercent}
|
sendUploadPercent={props.sendUploadPercent}
|
||||||
|
mobileSidebarToggleKey={props.mobileSidebarToggleKey}
|
||||||
onNotify={props.onNotify}
|
onNotify={props.onNotify}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
@@ -173,6 +207,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
ciphers={props.decryptedCiphers}
|
ciphers={props.decryptedCiphers}
|
||||||
folders={props.decryptedFolders}
|
folders={props.decryptedFolders}
|
||||||
loading={props.ciphersLoading || props.foldersLoading}
|
loading={props.ciphersLoading || props.foldersLoading}
|
||||||
|
error={props.vaultError}
|
||||||
emailForReprompt={props.profile?.email || props.session?.email || ''}
|
emailForReprompt={props.profile?.email || props.session?.email || ''}
|
||||||
onRefresh={props.onRefreshVault}
|
onRefresh={props.onRefreshVault}
|
||||||
onCreate={props.onCreateVaultItem}
|
onCreate={props.onCreateVaultItem}
|
||||||
@@ -180,6 +215,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
onDelete={props.onDeleteVaultItem}
|
onDelete={props.onDeleteVaultItem}
|
||||||
onArchive={props.onArchiveVaultItem}
|
onArchive={props.onArchiveVaultItem}
|
||||||
onUnarchive={props.onUnarchiveVaultItem}
|
onUnarchive={props.onUnarchiveVaultItem}
|
||||||
|
onRestore={props.onRestoreVaultItems}
|
||||||
onBulkDelete={props.onBulkDeleteVaultItems}
|
onBulkDelete={props.onBulkDeleteVaultItems}
|
||||||
onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems}
|
onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems}
|
||||||
onBulkRestore={props.onBulkRestoreVaultItems}
|
onBulkRestore={props.onBulkRestoreVaultItems}
|
||||||
@@ -189,6 +225,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
onVerifyMasterPassword={props.onVerifyMasterPassword}
|
onVerifyMasterPassword={props.onVerifyMasterPassword}
|
||||||
onNotify={props.onNotify}
|
onNotify={props.onNotify}
|
||||||
onCreateFolder={props.onCreateFolder}
|
onCreateFolder={props.onCreateFolder}
|
||||||
|
onRenameFolder={props.onRenameFolder}
|
||||||
onDeleteFolder={props.onDeleteFolder}
|
onDeleteFolder={props.onDeleteFolder}
|
||||||
onBulkDeleteFolders={props.onBulkDeleteFolders}
|
onBulkDeleteFolders={props.onBulkDeleteFolders}
|
||||||
onDownloadAttachment={props.onDownloadVaultAttachment}
|
onDownloadAttachment={props.onDownloadVaultAttachment}
|
||||||
@@ -196,11 +233,12 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
attachmentDownloadPercent={props.attachmentDownloadPercent}
|
attachmentDownloadPercent={props.attachmentDownloadPercent}
|
||||||
uploadingAttachmentName={props.uploadingAttachmentName}
|
uploadingAttachmentName={props.uploadingAttachmentName}
|
||||||
attachmentUploadPercent={props.attachmentUploadPercent}
|
attachmentUploadPercent={props.attachmentUploadPercent}
|
||||||
|
mobileSidebarToggleKey={props.mobileSidebarToggleKey}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path={props.settingsAccountRoute}>
|
<Route path={props.settingsAccountRoute}>
|
||||||
{props.profile && (
|
{props.profile ? (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
{props.mobileLayout && (
|
{props.mobileLayout && (
|
||||||
<div className="mobile-settings-subhead">
|
<div className="mobile-settings-subhead">
|
||||||
@@ -214,19 +252,27 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
<SettingsPage
|
<SettingsPage
|
||||||
profile={props.profile}
|
profile={props.profile}
|
||||||
totpEnabled={props.totpEnabled}
|
totpEnabled={props.totpEnabled}
|
||||||
|
lockTimeoutMinutes={props.lockTimeoutMinutes}
|
||||||
|
sessionTimeoutAction={props.sessionTimeoutAction}
|
||||||
onChangePassword={props.onChangePassword}
|
onChangePassword={props.onChangePassword}
|
||||||
onSavePasswordHint={props.onSavePasswordHint}
|
onSavePasswordHint={props.onSavePasswordHint}
|
||||||
onEnableTotp={props.onEnableTotp}
|
onEnableTotp={props.onEnableTotp}
|
||||||
onOpenDisableTotp={props.onOpenDisableTotp}
|
onOpenDisableTotp={props.onOpenDisableTotp}
|
||||||
onGetRecoveryCode={props.onGetRecoveryCode}
|
onGetRecoveryCode={props.onGetRecoveryCode}
|
||||||
|
onGetApiKey={props.onGetApiKey}
|
||||||
|
onRotateApiKey={props.onRotateApiKey}
|
||||||
|
onLockTimeoutChange={props.onLockTimeoutChange}
|
||||||
|
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
|
||||||
onNotify={props.onNotify}
|
onNotify={props.onNotify}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : props.profileLoading ? (
|
||||||
|
<LoadingState card lines={5} />
|
||||||
|
) : null}
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/settings">
|
<Route path="/settings">
|
||||||
{props.profile && (
|
{props.profile ? (
|
||||||
<section className="card mobile-settings-card">
|
<section className="card mobile-settings-card">
|
||||||
<div className="mobile-settings-links">
|
<div className="mobile-settings-links">
|
||||||
<Link href={props.settingsAccountRoute} className="mobile-settings-link">
|
<Link href={props.settingsAccountRoute} className="mobile-settings-link">
|
||||||
@@ -237,17 +283,27 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
<Shield size={18} />
|
<Shield size={18} />
|
||||||
<span>{t('nav_device_management')}</span>
|
<span>{t('nav_device_management')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/settings/domain-rules" className="mobile-settings-link">
|
||||||
|
<Globe2 size={18} />
|
||||||
|
<span>{t('nav_domain_rules')}</span>
|
||||||
|
</Link>
|
||||||
<Link href={props.importRoute} className="mobile-settings-link">
|
<Link href={props.importRoute} className="mobile-settings-link">
|
||||||
<ArrowUpDown size={18} />
|
<ArrowUpDown size={18} />
|
||||||
<span>{t('nav_import_export')}</span>
|
<span>{t('nav_import_export')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
{props.profile.role === 'admin' && (
|
{isAdmin && (
|
||||||
<Link href="/admin" className="mobile-settings-link">
|
<Link href="/admin" className="mobile-settings-link">
|
||||||
<ShieldUser size={18} />
|
<ShieldUser size={18} />
|
||||||
<span>{t('nav_admin_panel')}</span>
|
<span>{t('nav_admin_panel')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{props.profile.role === 'admin' && (
|
{isAdmin && (
|
||||||
|
<Link href="/logs" className="mobile-settings-link">
|
||||||
|
<FileClock size={18} />
|
||||||
|
<span>{t('nav_log_center')}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
<Link href="/backup" className="mobile-settings-link">
|
<Link href="/backup" className="mobile-settings-link">
|
||||||
<Cloud size={18} />
|
<Cloud size={18} />
|
||||||
<span>{t('nav_backup_strategy')}</span>
|
<span>{t('nav_backup_strategy')}</span>
|
||||||
@@ -259,7 +315,9 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
{t('txt_sign_out')}
|
{t('txt_sign_out')}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
)}
|
) : props.profileLoading ? (
|
||||||
|
<LoadingState card lines={4} />
|
||||||
|
) : null}
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/security/devices">
|
<Route path="/security/devices">
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
@@ -275,8 +333,11 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
<SecurityDevicesPage
|
<SecurityDevicesPage
|
||||||
devices={props.authorizedDevices}
|
devices={props.authorizedDevices}
|
||||||
loading={props.authorizedDevicesLoading}
|
loading={props.authorizedDevicesLoading}
|
||||||
|
error={props.authorizedDevicesError}
|
||||||
onRefresh={() => void props.onRefreshAuthorizedDevices()}
|
onRefresh={() => void props.onRefreshAuthorizedDevices()}
|
||||||
|
onRenameDevice={props.onRenameAuthorizedDevice}
|
||||||
onRevokeTrust={props.onRevokeDeviceTrust}
|
onRevokeTrust={props.onRevokeDeviceTrust}
|
||||||
|
onTrustPermanently={props.onTrustDevicePermanently}
|
||||||
onRemoveDevice={props.onRemoveDevice}
|
onRemoveDevice={props.onRemoveDevice}
|
||||||
onRevokeAll={props.onRevokeAllDeviceTrust}
|
onRevokeAll={props.onRevokeAllDeviceTrust}
|
||||||
onRemoveAll={props.onRemoveAllDevices}
|
onRemoveAll={props.onRemoveAllDevices}
|
||||||
@@ -284,6 +345,28 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="/settings/domain-rules">
|
||||||
|
<div className="stack domain-rules-route">
|
||||||
|
{props.mobileLayout && (
|
||||||
|
<div className="mobile-settings-subhead">
|
||||||
|
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
|
||||||
|
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||||
|
{t('txt_back')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
|
<DomainRulesPage
|
||||||
|
rules={props.domainRules}
|
||||||
|
loading={props.domainRulesLoading}
|
||||||
|
error={props.domainRulesError}
|
||||||
|
onRefresh={props.onRefreshDomainRules}
|
||||||
|
onSave={props.onSaveDomainRules}
|
||||||
|
onNotify={props.onNotify}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</Route>
|
||||||
<Route path="/admin">
|
<Route path="/admin">
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
{props.mobileLayout && (
|
{props.mobileLayout && (
|
||||||
@@ -299,6 +382,8 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
currentUserId={props.profile?.id || ''}
|
currentUserId={props.profile?.id || ''}
|
||||||
users={props.users}
|
users={props.users}
|
||||||
invites={props.invites}
|
invites={props.invites}
|
||||||
|
loading={props.adminLoading}
|
||||||
|
error={props.adminError}
|
||||||
onRefresh={props.onRefreshAdmin}
|
onRefresh={props.onRefreshAdmin}
|
||||||
onCreateInvite={props.onCreateInvite}
|
onCreateInvite={props.onCreateInvite}
|
||||||
onDeleteAllInvites={props.onDeleteAllInvites}
|
onDeleteAllInvites={props.onDeleteAllInvites}
|
||||||
@@ -309,6 +394,23 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="/logs">
|
||||||
|
{isAdmin ? (
|
||||||
|
<div className="stack">
|
||||||
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
|
<LogCenterPage
|
||||||
|
onLoadLogs={props.onLoadAuditLogs}
|
||||||
|
onLoadSettings={props.onLoadAuditLogSettings}
|
||||||
|
onSaveSettings={props.onSaveAuditLogSettings}
|
||||||
|
onClearLogs={props.onClearAuditLogs}
|
||||||
|
onNotify={props.onNotify}
|
||||||
|
mobileLayout={props.mobileLayout}
|
||||||
|
onMobileBack={() => props.onNavigate(props.settingsHomeRoute)}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Route>
|
||||||
{importRoutePaths.map((path) => (
|
{importRoutePaths.map((path) => (
|
||||||
<Route key={path} path={path}>
|
<Route key={path} path={path}>
|
||||||
{renderImportPageRoute()}
|
{renderImportPageRoute()}
|
||||||
@@ -318,7 +420,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
<LegacyBackupRedirect onNavigate={props.onNavigate} />
|
<LegacyBackupRedirect onNavigate={props.onNavigate} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/backup">
|
<Route path="/backup">
|
||||||
{props.profile?.role === 'admin' ? (
|
{isAdmin ? (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
{props.mobileLayout && (
|
{props.mobileLayout && (
|
||||||
<div className="mobile-settings-subhead">
|
<div className="mobile-settings-subhead">
|
||||||
@@ -333,11 +435,14 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
currentUserId={props.profile?.id || null}
|
currentUserId={props.profile?.id || null}
|
||||||
onExport={props.onExportBackup}
|
onExport={props.onExportBackup}
|
||||||
onImport={props.onImportBackup}
|
onImport={props.onImportBackup}
|
||||||
|
onImportAllowingChecksumMismatch={props.onImportBackupAllowingChecksumMismatch}
|
||||||
onLoadSettings={props.onLoadBackupSettings}
|
onLoadSettings={props.onLoadBackupSettings}
|
||||||
onListRemoteBackups={props.onListRemoteBackups}
|
onListRemoteBackups={props.onListRemoteBackups}
|
||||||
onDownloadRemoteBackup={props.onDownloadRemoteBackup}
|
onDownloadRemoteBackup={props.onDownloadRemoteBackup}
|
||||||
|
onInspectRemoteBackup={props.onInspectRemoteBackup}
|
||||||
onDeleteRemoteBackup={props.onDeleteRemoteBackup}
|
onDeleteRemoteBackup={props.onDeleteRemoteBackup}
|
||||||
onRestoreRemoteBackup={props.onRestoreRemoteBackup}
|
onRestoreRemoteBackup={props.onRestoreRemoteBackup}
|
||||||
|
onRestoreRemoteBackupAllowingChecksumMismatch={props.onRestoreRemoteBackupAllowingChecksumMismatch}
|
||||||
onSaveSettings={props.onSaveBackupSettings}
|
onSaveSettings={props.onSaveBackupSettings}
|
||||||
onRunRemoteBackup={props.onRunRemoteBackup}
|
onRunRemoteBackup={props.onRunRemoteBackup}
|
||||||
onNotify={props.onNotify}
|
onNotify={props.onNotify}
|
||||||
|
|||||||
@@ -19,10 +19,15 @@ interface RegisterValues {
|
|||||||
|
|
||||||
interface AuthViewsProps {
|
interface AuthViewsProps {
|
||||||
mode: 'login' | 'register' | 'locked';
|
mode: 'login' | 'register' | 'locked';
|
||||||
|
relaxedLoginInput?: boolean;
|
||||||
|
authPlaceholder?: string;
|
||||||
|
unlockPlaceholder?: string;
|
||||||
pendingAction: 'login' | 'register' | 'unlock' | null;
|
pendingAction: 'login' | 'register' | 'unlock' | null;
|
||||||
unlockReady: boolean;
|
unlockReady: boolean;
|
||||||
|
unlockPreparing: boolean;
|
||||||
loginValues: LoginValues;
|
loginValues: LoginValues;
|
||||||
registerValues: RegisterValues;
|
registerValues: RegisterValues;
|
||||||
|
registrationInviteRequired?: boolean;
|
||||||
unlockPassword: string;
|
unlockPassword: string;
|
||||||
emailForLock: string;
|
emailForLock: string;
|
||||||
loginHintLoading: boolean;
|
loginHintLoading: boolean;
|
||||||
@@ -45,6 +50,7 @@ function PasswordField(props: {
|
|||||||
onInput: (v: string) => void;
|
onInput: (v: string) => void;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
autoComplete?: string;
|
autoComplete?: string;
|
||||||
|
placeholder?: string;
|
||||||
}) {
|
}) {
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
return (
|
return (
|
||||||
@@ -58,6 +64,7 @@ function PasswordField(props: {
|
|||||||
onInput={(e) => props.onInput((e.currentTarget as HTMLInputElement).value)}
|
onInput={(e) => props.onInput((e.currentTarget as HTMLInputElement).value)}
|
||||||
autoFocus={props.autoFocus}
|
autoFocus={props.autoFocus}
|
||||||
autoComplete={props.autoComplete}
|
autoComplete={props.autoComplete}
|
||||||
|
placeholder={props.placeholder}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="eye-btn" onClick={() => setShow((v) => !v)}>
|
<button type="button" className="eye-btn" onClick={() => setShow((v) => !v)}>
|
||||||
{show ? <EyeOff size={16} /> : <Eye size={16} />}
|
{show ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
@@ -71,6 +78,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
const loginBusy = props.pendingAction === 'login';
|
const loginBusy = props.pendingAction === 'login';
|
||||||
const registerBusy = props.pendingAction === 'register';
|
const registerBusy = props.pendingAction === 'register';
|
||||||
const unlockBusy = props.pendingAction === 'unlock';
|
const unlockBusy = props.pendingAction === 'unlock';
|
||||||
|
const showInviteCodeField = props.registrationInviteRequired !== false || !!props.registerValues.inviteCode.trim();
|
||||||
|
|
||||||
if (props.mode === 'locked') {
|
if (props.mode === 'locked') {
|
||||||
return (
|
return (
|
||||||
@@ -89,6 +97,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
value={props.unlockPassword}
|
value={props.unlockPassword}
|
||||||
autoFocus
|
autoFocus
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
|
placeholder={props.unlockPlaceholder}
|
||||||
onInput={props.onChangeUnlock}
|
onInput={props.onChangeUnlock}
|
||||||
/>
|
/>
|
||||||
<div className="auth-support-row">
|
<div className="auth-support-row">
|
||||||
@@ -97,14 +106,17 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
className="auth-link-btn"
|
className="auth-link-btn"
|
||||||
onClick={props.onShowLockedPasswordHint}
|
onClick={props.onShowLockedPasswordHint}
|
||||||
disabled={unlockBusy}
|
disabled={unlockBusy || props.unlockPreparing}
|
||||||
>
|
>
|
||||||
{t('txt_show_password_hint')}
|
{t('txt_show_password_hint')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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" />
|
<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>
|
</button>
|
||||||
<div className="or">{t('txt_or')}</div>
|
<div className="or">{t('txt_or')}</div>
|
||||||
<button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy}>
|
<button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy}>
|
||||||
@@ -174,17 +186,19 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
{showInviteCodeField ? (
|
||||||
<span>{t('txt_invite_code_optional')}</span>
|
<label className="field">
|
||||||
<input
|
<span>{t('txt_invite_code_required')}</span>
|
||||||
className="input"
|
<input
|
||||||
value={props.registerValues.inviteCode}
|
className="input"
|
||||||
autoComplete="off"
|
value={props.registerValues.inviteCode}
|
||||||
onInput={(e) =>
|
autoComplete="off"
|
||||||
props.onChangeRegister({ ...props.registerValues, inviteCode: (e.currentTarget as HTMLInputElement).value })
|
onInput={(e) =>
|
||||||
}
|
props.onChangeRegister({ ...props.registerValues, inviteCode: (e.currentTarget as HTMLInputElement).value })
|
||||||
/>
|
}
|
||||||
</label>
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
<button type="submit" className="btn btn-primary full" disabled={registerBusy}>
|
<button type="submit" className="btn btn-primary full" disabled={registerBusy}>
|
||||||
<UserPlus size={16} className="btn-icon" />
|
<UserPlus size={16} className="btn-icon" />
|
||||||
{registerBusy ? t('txt_registering') : t('txt_create_account')}
|
{registerBusy ? t('txt_registering') : t('txt_create_account')}
|
||||||
@@ -213,9 +227,11 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
<span>{t('txt_email')}</span>
|
<span>{t('txt_email')}</span>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
type="email"
|
type={props.relaxedLoginInput ? 'text' : 'email'}
|
||||||
value={props.loginValues.email}
|
value={props.loginValues.email}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
|
placeholder={props.authPlaceholder}
|
||||||
|
autoFocus
|
||||||
onInput={(e) => props.onChangeLogin({ ...props.loginValues, email: (e.currentTarget as HTMLInputElement).value })}
|
onInput={(e) => props.onChangeLogin({ ...props.loginValues, email: (e.currentTarget as HTMLInputElement).value })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -223,8 +239,8 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
label={t('txt_master_password')}
|
label={t('txt_master_password')}
|
||||||
value={props.loginValues.password}
|
value={props.loginValues.password}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
|
placeholder={props.authPlaceholder}
|
||||||
onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })}
|
onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })}
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
<div className="auth-support-row">
|
<div className="auth-support-row">
|
||||||
<span />
|
<span />
|
||||||
@@ -232,7 +248,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
className="auth-link-btn"
|
className="auth-link-btn"
|
||||||
onClick={props.onTogglePasswordHint}
|
onClick={props.onTogglePasswordHint}
|
||||||
disabled={loginBusy || !props.loginValues.email.trim()}
|
disabled={loginBusy || props.loginHintLoading || !props.loginValues.email.trim()}
|
||||||
>
|
>
|
||||||
{props.loginHintLoading
|
{props.loginHintLoading
|
||||||
? t('txt_loading_password_hint')
|
? t('txt_loading_password_hint')
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
|
import { createPortal } from 'preact/compat';
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
import {
|
import {
|
||||||
type AdminBackupImportResponse,
|
type AdminBackupImportResponse,
|
||||||
type AdminBackupRunResponse,
|
type AdminBackupRunResponse,
|
||||||
type AdminBackupSettings,
|
type AdminBackupSettings,
|
||||||
|
type BackupFileIntegrityCheckResult,
|
||||||
type BackupDestinationRecord,
|
type BackupDestinationRecord,
|
||||||
type BackupDestinationType,
|
type BackupDestinationType,
|
||||||
type RemoteBackupBrowserResponse,
|
type RemoteBackupBrowserResponse,
|
||||||
|
verifyBackupFileIntegrity,
|
||||||
} from '@/lib/api/backup';
|
} from '@/lib/api/backup';
|
||||||
import {
|
import {
|
||||||
REMOTE_BROWSER_ITEMS_PER_PAGE,
|
REMOTE_BROWSER_ITEMS_PER_PAGE,
|
||||||
@@ -22,6 +25,7 @@ import {
|
|||||||
loadPersistedRemoteBrowserState,
|
loadPersistedRemoteBrowserState,
|
||||||
persistRemoteBrowserState,
|
persistRemoteBrowserState,
|
||||||
} from '@/lib/backup-center';
|
} 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 { RECOMMENDED_PROVIDERS, type RecommendedProvider } from '@/lib/backup-recommendations';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import { BackupDestinationDetail } from './backup-center/BackupDestinationDetail';
|
import { BackupDestinationDetail } from './backup-center/BackupDestinationDetail';
|
||||||
@@ -32,16 +36,82 @@ interface BackupCenterPageProps {
|
|||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
onExport: (includeAttachments?: boolean) => Promise<void>;
|
onExport: (includeAttachments?: boolean) => Promise<void>;
|
||||||
onImport: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
onImport: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
|
onImportAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
onLoadSettings: () => Promise<AdminBackupSettings>;
|
onLoadSettings: () => Promise<AdminBackupSettings>;
|
||||||
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||||
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||||
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||||
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
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>;
|
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
||||||
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
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;
|
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 {
|
function buildSkippedImportMessage(result: AdminBackupImportResponse): string | null {
|
||||||
const skipped = result.skipped;
|
const skipped = result.skipped;
|
||||||
if (!skipped || !skipped.attachments) return null;
|
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) {
|
export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||||
const persistedRemoteStateRef = useRef(loadPersistedRemoteBrowserState(props.currentUserId));
|
const persistedRemoteStateRef = useRef(loadPersistedRemoteBrowserState(props.currentUserId));
|
||||||
const persistedRemoteState = persistedRemoteStateRef.current;
|
const persistedRemoteState = persistedRemoteStateRef.current;
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
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 [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
@@ -67,14 +183,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
const [downloadingRemotePath, setDownloadingRemotePath] = useState('');
|
const [downloadingRemotePath, setDownloadingRemotePath] = useState('');
|
||||||
const [downloadingRemotePercent, setDownloadingRemotePercent] = useState<number | null>(null);
|
const [downloadingRemotePercent, setDownloadingRemotePercent] = useState<number | null>(null);
|
||||||
const [restoringRemotePath, setRestoringRemotePath] = useState('');
|
const [restoringRemotePath, setRestoringRemotePath] = useState('');
|
||||||
const [remoteRestoreStatusText, setRemoteRestoreStatusText] = useState('');
|
|
||||||
const [deletingRemotePath, setDeletingRemotePath] = useState('');
|
const [deletingRemotePath, setDeletingRemotePath] = useState('');
|
||||||
const [localError, setLocalError] = useState('');
|
const [localError, setLocalError] = useState('');
|
||||||
|
const [restoreProgress, setRestoreProgress] = useState<BackupProgressState | null>(null);
|
||||||
|
const [restoreElapsedSeconds, setRestoreElapsedSeconds] = useState(0);
|
||||||
const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false);
|
const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false);
|
||||||
const [confirmReplaceOpen, setConfirmReplaceOpen] = useState(false);
|
const [confirmReplaceOpen, setConfirmReplaceOpen] = useState(false);
|
||||||
const [confirmRemoteReplaceOpen, setConfirmRemoteReplaceOpen] = useState(false);
|
const [confirmRemoteReplaceOpen, setConfirmRemoteReplaceOpen] = useState(false);
|
||||||
|
const [confirmIntegrityWarningOpen, setConfirmIntegrityWarningOpen] = useState(false);
|
||||||
const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false);
|
const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false);
|
||||||
const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false);
|
const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false);
|
||||||
|
const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null);
|
||||||
const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
|
const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
|
||||||
const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState('');
|
const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState('');
|
||||||
const [savedSettings, setSavedSettings] = useState<AdminBackupSettings | null>(null);
|
const [savedSettings, setSavedSettings] = useState<AdminBackupSettings | null>(null);
|
||||||
@@ -148,6 +267,59 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
});
|
});
|
||||||
}, [props.currentUserId, remoteBrowserCache, remoteBrowserPageByKey, remoteBrowserPathByDestination, selectedDestinationId]);
|
}, [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) {
|
function updateSettings(mutator: (current: AdminBackupSettings) => AdminBackupSettings) {
|
||||||
setSettings((current) => {
|
setSettings((current) => {
|
||||||
const next = mutator(current);
|
const next = mutator(current);
|
||||||
@@ -225,6 +397,67 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
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) {
|
function handleAddDestination(type: BackupDestinationType) {
|
||||||
updateSettings((current) => {
|
updateSettings((current) => {
|
||||||
const nextDestination = createDraftDestinationRecord(type, current.destinations.filter((destination) => destination.type === type).length + 1);
|
const nextDestination = createDraftDestinationRecord(type, current.destinations.filter((destination) => destination.type === type).length + 1);
|
||||||
@@ -277,18 +510,25 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
setLocalError('');
|
setLocalError('');
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
try {
|
try {
|
||||||
|
startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments });
|
||||||
await props.onExport(exportIncludeAttachments);
|
await props.onExport(exportIncludeAttachments);
|
||||||
props.onNotify('success', t('txt_backup_export_success'));
|
props.onNotify('success', t('txt_backup_export_success'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_export_failed');
|
const message = error instanceof Error ? error.message : t('txt_backup_export_failed');
|
||||||
setLocalError(message);
|
setLocalError(message);
|
||||||
props.onNotify('error', message);
|
props.onNotify('error', message);
|
||||||
|
window.setTimeout(() => clearRestoreProgress(), 1200);
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runLocalRestore(replaceExisting: boolean) {
|
async function runLocalRestore(
|
||||||
|
replaceExisting: boolean,
|
||||||
|
allowChecksumMismatch: boolean = false,
|
||||||
|
knownIntegrity?: BackupFileIntegrityCheckResult
|
||||||
|
) {
|
||||||
|
if (importing) return;
|
||||||
if (!selectedFile) {
|
if (!selectedFile) {
|
||||||
const message = t('txt_backup_file_required');
|
const message = t('txt_backup_file_required');
|
||||||
setLocalError(message);
|
setLocalError(message);
|
||||||
@@ -296,17 +536,29 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
|
setConfirmLocalRestoreOpen(false);
|
||||||
|
setConfirmReplaceOpen(false);
|
||||||
|
setConfirmIntegrityWarningOpen(false);
|
||||||
setImporting(true);
|
setImporting(true);
|
||||||
try {
|
try {
|
||||||
const result = await props.onImport(selectedFile, replaceExisting);
|
const integrity = knownIntegrity || await inspectLocalBackupFile(selectedFile);
|
||||||
props.onNotify('success', t('txt_backup_restore_success_relogin'));
|
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);
|
const skippedMessage = buildSkippedImportMessage(result);
|
||||||
if (skippedMessage) props.onNotify('warning', skippedMessage);
|
if (skippedMessage) props.onNotify('warning', skippedMessage);
|
||||||
resetSelectedFile();
|
resetSelectedFile();
|
||||||
setConfirmLocalRestoreOpen(false);
|
setConfirmLocalRestoreOpen(false);
|
||||||
setConfirmReplaceOpen(false);
|
setConfirmReplaceOpen(false);
|
||||||
|
resetPendingIntegrityWarning();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!replaceExisting && isReplaceRequiredError(error)) {
|
if (!replaceExisting && isReplaceRequiredError(error)) {
|
||||||
|
clearRestoreProgress();
|
||||||
setConfirmLocalRestoreOpen(false);
|
setConfirmLocalRestoreOpen(false);
|
||||||
setConfirmReplaceOpen(true);
|
setConfirmReplaceOpen(true);
|
||||||
return;
|
return;
|
||||||
@@ -314,6 +566,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
const message = error instanceof Error ? error.message : t('txt_backup_restore_failed');
|
const message = error instanceof Error ? error.message : t('txt_backup_restore_failed');
|
||||||
setLocalError(message);
|
setLocalError(message);
|
||||||
props.onNotify('error', message);
|
props.onNotify('error', message);
|
||||||
|
window.setTimeout(() => clearRestoreProgress(), 1200);
|
||||||
} finally {
|
} finally {
|
||||||
setImporting(false);
|
setImporting(false);
|
||||||
}
|
}
|
||||||
@@ -364,16 +617,21 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
setRunningRemoteBackup(true);
|
setRunningRemoteBackup(true);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
try {
|
try {
|
||||||
|
startRestoreProgress('backup-remote-run', selectedDestination.name || t('txt_backup_run_now'), {
|
||||||
|
source: 'remote',
|
||||||
|
includeAttachments: !!selectedDestination.includeAttachments,
|
||||||
|
});
|
||||||
const result = await props.onRunRemoteBackup(selectedDestination.id);
|
const result = await props.onRunRemoteBackup(selectedDestination.id);
|
||||||
setSavedSettings(result.settings);
|
setSavedSettings(result.settings);
|
||||||
setSettings(result.settings);
|
setSettings(result.settings);
|
||||||
setSelectedDestinationId(selectedDestination.id);
|
setSelectedDestinationId(selectedDestination.id);
|
||||||
await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true });
|
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) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed');
|
const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed');
|
||||||
setLocalError(message);
|
setLocalError(message);
|
||||||
props.onNotify('error', message);
|
props.onNotify('error', message);
|
||||||
|
window.setTimeout(() => clearRestoreProgress(), 1200);
|
||||||
} finally {
|
} finally {
|
||||||
setRunningRemoteBackup(false);
|
setRunningRemoteBackup(false);
|
||||||
}
|
}
|
||||||
@@ -397,6 +655,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteRemote(path: string) {
|
async function handleDeleteRemote(path: string) {
|
||||||
|
if (deletingRemotePath) return;
|
||||||
if (!savedSelectedDestination) return;
|
if (!savedSelectedDestination) return;
|
||||||
setDeletingRemotePath(path);
|
setDeletingRemotePath(path);
|
||||||
setLocalError('');
|
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;
|
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);
|
setRestoringRemotePath(path);
|
||||||
setRemoteRestoreStatusText(replaceExisting ? t('txt_backup_remote_restore_stage_replace') : t('txt_backup_remote_restore_stage_prepare'));
|
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
try {
|
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);
|
setConfirmRemoteReplaceOpen(false);
|
||||||
setPendingRemoteRestorePath('');
|
setPendingRemoteRestorePath('');
|
||||||
setRemoteRestoreStatusText('');
|
props.onNotify('success', `${buildIntegrityStatusMessage(integrity.result, { remote: true })} ${t('txt_backup_restore_success_relogin')}`);
|
||||||
props.onNotify('success', t('txt_backup_restore_success_relogin'));
|
|
||||||
const skippedMessage = buildSkippedImportMessage(result);
|
const skippedMessage = buildSkippedImportMessage(result);
|
||||||
if (skippedMessage) props.onNotify('warning', skippedMessage);
|
if (skippedMessage) props.onNotify('warning', skippedMessage);
|
||||||
|
resetPendingIntegrityWarning();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!replaceExisting && isReplaceRequiredError(error)) {
|
if (!replaceExisting && isReplaceRequiredError(error)) {
|
||||||
setPendingRemoteRestorePath(path);
|
setPendingRemoteRestorePath(path);
|
||||||
setConfirmRemoteReplaceOpen(true);
|
setConfirmRemoteReplaceOpen(true);
|
||||||
setRemoteRestoreStatusText('');
|
clearRestoreProgress();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_restore_failed');
|
const message = error instanceof Error ? error.message : t('txt_backup_remote_restore_failed');
|
||||||
setRemoteRestoreStatusText('');
|
|
||||||
setLocalError(message);
|
setLocalError(message);
|
||||||
props.onNotify('error', message);
|
props.onNotify('error', message);
|
||||||
|
window.setTimeout(() => clearRestoreProgress(), 1200);
|
||||||
} finally {
|
} finally {
|
||||||
setRestoringRemotePath('');
|
setRestoringRemotePath('');
|
||||||
}
|
}
|
||||||
@@ -454,9 +772,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
disabled={disableWhileBusy}
|
disabled={disableWhileBusy}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const nextFile = (event.currentTarget as HTMLInputElement).files?.[0] || null;
|
const nextFile = (event.currentTarget as HTMLInputElement).files?.[0] || null;
|
||||||
setSelectedFile(nextFile);
|
void handleSelectedLocalFile(nextFile);
|
||||||
setLocalError('');
|
|
||||||
if (nextFile) setConfirmLocalRestoreOpen(true);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -521,7 +837,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
if (savedSelectedDestination) showRemoteBrowserPath(savedSelectedDestination.id, path);
|
if (savedSelectedDestination) showRemoteBrowserPath(savedSelectedDestination.id, path);
|
||||||
}}
|
}}
|
||||||
onDownloadRemoteBackup={(path) => void handleDownloadRemote(path)}
|
onDownloadRemoteBackup={(path) => void handleDownloadRemote(path)}
|
||||||
onRestoreRemoteBackup={(path) => void runRemoteRestore(path, false)}
|
onRestoreRemoteBackup={(path) => void handlePromptRemoteRestore(path)}
|
||||||
onPromptDeleteRemoteBackup={(path) => {
|
onPromptDeleteRemoteBackup={(path) => {
|
||||||
setPendingRemoteDeletePath(path);
|
setPendingRemoteDeletePath(path);
|
||||||
setConfirmRemoteDeleteOpen(true);
|
setConfirmRemoteDeleteOpen(true);
|
||||||
@@ -533,7 +849,49 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{localError ? <div className="local-error">{localError}</div> : null}
|
{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
|
<ConfirmDialog
|
||||||
open={confirmLocalRestoreOpen}
|
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')}
|
message={selectedFile ? t('txt_backup_selected_file_name', { name: selectedFile.name }) : t('txt_backup_restore_note')}
|
||||||
confirmText={t('txt_backup_import')}
|
confirmText={t('txt_backup_import')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
|
confirmDisabled={importing}
|
||||||
|
cancelDisabled={importing}
|
||||||
danger
|
danger
|
||||||
onConfirm={() => void runLocalRestore(false)}
|
onConfirm={() => void runLocalRestore(false)}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
|
if (importing) return;
|
||||||
setConfirmLocalRestoreOpen(false);
|
setConfirmLocalRestoreOpen(false);
|
||||||
resetSelectedFile();
|
resetSelectedFile();
|
||||||
|
resetPendingIntegrityWarning();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -558,11 +920,16 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
confirmDisabled={importing}
|
confirmDisabled={importing}
|
||||||
cancelDisabled={importing}
|
cancelDisabled={importing}
|
||||||
danger
|
danger
|
||||||
onConfirm={() => void runLocalRestore(true)}
|
onConfirm={() => void runLocalRestore(
|
||||||
|
true,
|
||||||
|
pendingRestoreIntegrity?.source === 'local',
|
||||||
|
pendingRestoreIntegrity?.source === 'local' ? pendingRestoreIntegrity.result : undefined
|
||||||
|
)}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
if (importing) return;
|
if (importing) return;
|
||||||
setConfirmReplaceOpen(false);
|
setConfirmReplaceOpen(false);
|
||||||
resetSelectedFile();
|
resetSelectedFile();
|
||||||
|
resetPendingIntegrityWarning();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -575,11 +942,47 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
confirmDisabled={!!restoringRemotePath}
|
confirmDisabled={!!restoringRemotePath}
|
||||||
cancelDisabled={!!restoringRemotePath}
|
cancelDisabled={!!restoringRemotePath}
|
||||||
danger
|
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={() => {
|
onCancel={() => {
|
||||||
if (restoringRemotePath) return;
|
if (restoringRemotePath) return;
|
||||||
setConfirmRemoteReplaceOpen(false);
|
setConfirmRemoteReplaceOpen(false);
|
||||||
setPendingRemoteRestorePath('');
|
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 })}
|
message={t('txt_backup_remote_delete_confirm_message', { name: pendingRemoteDeletePath.split('/').pop() || pendingRemoteDeletePath })}
|
||||||
confirmText={t('txt_delete')}
|
confirmText={t('txt_delete')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
|
confirmDisabled={!!deletingRemotePath}
|
||||||
|
cancelDisabled={!!deletingRemotePath}
|
||||||
danger
|
danger
|
||||||
onConfirm={() => void handleDeleteRemote(pendingRemoteDeletePath)}
|
onConfirm={() => void handleDeleteRemote(pendingRemoteDeletePath)}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
@@ -606,6 +1011,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
})}
|
})}
|
||||||
confirmText={t('txt_delete')}
|
confirmText={t('txt_delete')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
|
confirmDisabled={savingSettings}
|
||||||
|
cancelDisabled={savingSettings}
|
||||||
danger
|
danger
|
||||||
onConfirm={() => void handleDeleteDestination()}
|
onConfirm={() => void handleDeleteDestination()}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useEffect, useState } from 'preact/hooks';
|
import { createPortal } from 'preact/compat';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import type { ComponentChildren } from 'preact';
|
import type { ComponentChildren } from 'preact';
|
||||||
|
import { TriangleAlert } from 'lucide-preact';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
interface ConfirmDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
variant?: 'default' | 'warning';
|
||||||
showIcon?: boolean;
|
showIcon?: boolean;
|
||||||
confirmText?: string;
|
confirmText?: string;
|
||||||
cancelText?: string;
|
cancelText?: string;
|
||||||
@@ -19,9 +22,72 @@ interface ConfirmDialogProps {
|
|||||||
afterActions?: ComponentChildren;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FOCUSABLE_SELECTOR = [
|
||||||
|
'a[href]',
|
||||||
|
'button:not([disabled])',
|
||||||
|
'input:not([disabled]):not([type="hidden"])',
|
||||||
|
'select:not([disabled])',
|
||||||
|
'textarea:not([disabled])',
|
||||||
|
'[tabindex]:not([tabindex="-1"])',
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
let dialogIdCounter = 0;
|
||||||
|
|
||||||
|
function getFocusableElements(root: HTMLElement): HTMLElement[] {
|
||||||
|
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter((element) => {
|
||||||
|
if (element.hasAttribute('disabled') || element.getAttribute('aria-hidden') === 'true') return false;
|
||||||
|
return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||||
const [present, setPresent] = useState(props.open);
|
const [present, setPresent] = useState(props.open);
|
||||||
const [closing, setClosing] = useState(false);
|
const [closing, setClosing] = useState(false);
|
||||||
|
const cardRef = useRef<HTMLFormElement | null>(null);
|
||||||
|
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
||||||
|
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
|
||||||
|
const titleId = `${dialogId}-title`;
|
||||||
|
const messageId = `${dialogId}-message`;
|
||||||
|
const canDismiss = !props.cancelDisabled && !closing;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (props.open) {
|
if (props.open) {
|
||||||
@@ -38,24 +104,117 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
|||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [props.open, present]);
|
}, [props.open, present]);
|
||||||
|
|
||||||
if (!present) return null;
|
useDialogLifecycle(present, canDismiss ? props.onCancel : null);
|
||||||
return (
|
|
||||||
<div className={`dialog-mask ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}>
|
useEffect(() => {
|
||||||
|
if (!props.open || typeof document === 'undefined') return;
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
restoreFocusRef.current = activeElement instanceof HTMLElement ? activeElement : null;
|
||||||
|
|
||||||
|
const frameId = window.requestAnimationFrame(() => {
|
||||||
|
const card = cardRef.current;
|
||||||
|
if (!card) return;
|
||||||
|
const focusable = getFocusableElements(card);
|
||||||
|
const firstField = focusable.find((element) => (
|
||||||
|
element instanceof HTMLInputElement ||
|
||||||
|
element instanceof HTMLSelectElement ||
|
||||||
|
element instanceof HTMLTextAreaElement
|
||||||
|
));
|
||||||
|
const cancelButton = focusable.find((element) => element.dataset.dialogCancel === 'true');
|
||||||
|
const confirmButton = focusable.find((element) => element.dataset.dialogConfirm === 'true');
|
||||||
|
const target = firstField || (props.danger ? cancelButton : confirmButton) || cancelButton || focusable[0] || card;
|
||||||
|
target.focus({ preventScroll: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => window.cancelAnimationFrame(frameId);
|
||||||
|
}, [props.open, props.danger]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.open || present || typeof document === 'undefined') return;
|
||||||
|
const target = restoreFocusRef.current;
|
||||||
|
restoreFocusRef.current = null;
|
||||||
|
if (!target || !document.contains(target)) return;
|
||||||
|
target.focus({ preventScroll: true });
|
||||||
|
}, [props.open, present]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
const target = restoreFocusRef.current;
|
||||||
|
if (!target || typeof document === 'undefined' || !document.contains(target)) return;
|
||||||
|
target.focus({ preventScroll: true });
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleDialogKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key !== 'Tab') return;
|
||||||
|
const card = cardRef.current;
|
||||||
|
if (!card) return;
|
||||||
|
const focusable = getFocusableElements(card);
|
||||||
|
if (focusable.length === 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
card.focus({ preventScroll: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
if (event.shiftKey) {
|
||||||
|
if (activeElement === first || activeElement === card || !card.contains(activeElement)) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activeElement === last || activeElement === card || !card.contains(activeElement)) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
<form
|
||||||
className={`dialog-card ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
ref={cardRef}
|
||||||
|
className={`dialog-card ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
aria-describedby={messageId}
|
||||||
|
tabIndex={-1}
|
||||||
|
onKeyDown={handleDialogKeyDown}
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (props.confirmDisabled || closing) return;
|
if (props.confirmDisabled || closing) return;
|
||||||
props.onConfirm();
|
props.onConfirm();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h3 className="dialog-title">{props.title}</h3>
|
{props.variant === 'warning' ? (
|
||||||
<div className="dialog-message">{props.message}</div>
|
<>
|
||||||
|
<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 id={titleId} className="dialog-title">{props.title}</h3>
|
||||||
|
<div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>
|
||||||
{props.children}
|
{props.children}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
|
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
|
||||||
disabled={props.confirmDisabled}
|
disabled={props.confirmDisabled}
|
||||||
|
data-dialog-confirm="true"
|
||||||
>
|
>
|
||||||
{props.confirmText || t('txt_yes')}
|
{props.confirmText || t('txt_yes')}
|
||||||
</button>
|
</button>
|
||||||
@@ -64,6 +223,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary dialog-btn"
|
className="btn btn-secondary dialog-btn"
|
||||||
disabled={props.cancelDisabled}
|
disabled={props.cancelDisabled}
|
||||||
|
data-dialog-cancel="true"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (props.cancelDisabled) return;
|
if (props.cancelDisabled) return;
|
||||||
props.onCancel();
|
props.onCancel();
|
||||||
@@ -75,5 +235,5 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
|||||||
{props.afterActions}
|
{props.afterActions}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
), document.body);
|
||||||
}
|
}
|
||||||
|
|||||||