Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 9e892e85a2 | |||
| 3e5a80e498 | |||
| 89308fc8a6 | |||
| fe0bd80f43 | |||
| 0062fd6c48 | |||
| 7373eeb501 | |||
| 8b07cd4409 | |||
| 0fc7bd7985 | |||
| 58c029beba | |||
| ac79cbd8bd | |||
| 96fc3ae485 | |||
| cb4632cd04 | |||
| f7b5534cd0 | |||
| b50673f7d9 | |||
| 98e94e766f |
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npx vite *)",
|
||||||
|
"Bash(npx tsc *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
name: "Bug Report"
|
||||||
|
description: "Report a reproducible bug / 反馈可复现问题"
|
||||||
|
title: "[Bug] "
|
||||||
|
labels: ["bug", "needs-triage"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for reporting. Please provide enough detail so maintainers can reproduce quickly.
|
||||||
|
感谢反馈,请尽量提供可复现信息,方便快速定位。
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Pre-check / 提交前确认
|
||||||
|
options:
|
||||||
|
- label: I have searched existing issues and did not find a duplicate. / 我已搜索现有 issue,确认不是重复问题。
|
||||||
|
required: true
|
||||||
|
- label: I have read README and Project Wiki / 我已阅读 README 与 项目 Wiki。
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Version / 版本
|
||||||
|
description: "Which version of NodeWarden are you using? Please provide the exact version or commit hash."
|
||||||
|
placeholder: "1.0.0"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: reproduce_steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to Reproduce / 复现步骤
|
||||||
|
placeholder: |
|
||||||
|
1. Start service with ...
|
||||||
|
2. Open ...
|
||||||
|
3. Click ...
|
||||||
|
4. Observe ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: Expected Behavior / 预期行为
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: actual
|
||||||
|
attributes:
|
||||||
|
label: Actual Behavior / 实际行为
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Logs and Screenshots / 日志与截图
|
||||||
|
description: "Please paste key logs (docker logs / browser console / network errors)."
|
||||||
|
render: shell
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: extra
|
||||||
|
attributes:
|
||||||
|
label: Additional Context / 补充信息
|
||||||
|
description: "Any workaround, frequency, impact scope, etc."
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Project Wiki/ 项目文档
|
||||||
|
url: https://github.com/shuaiplus/nodewarden/wiki
|
||||||
|
about: |
|
||||||
|
Please check the documentation for common questions and troubleshooting steps.
|
||||||
|
请先查看文档,常见问题和排查步骤可能已经覆盖了你的问题。
|
||||||
|
- name: Project Discussions / 讨论区
|
||||||
|
url: https://github.com/shuaiplus/nodewarden/discussions
|
||||||
|
about: |
|
||||||
|
For general questions, feature discussions, or if you're not sure which template to use, please post in the Discussions section.
|
||||||
|
如果你有一般性问题、功能讨论,或者不确定使用哪个模板,请在讨论区发帖。
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
name: "Feature Request"
|
||||||
|
description: "Suggest an improvement / 功能建议"
|
||||||
|
title: "[Feature] "
|
||||||
|
labels: ["enhancement", "needs-triage"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Proposals with clear use-case and expected value are easier to evaluate.
|
||||||
|
说明清晰的使用场景和价值,有助于快速评估。
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Pre-check / 提交前确认
|
||||||
|
options:
|
||||||
|
- label: I have searched existing issues and this request is not duplicated. / 我已搜索现有 issue,确认不是重复建议。
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Problem Statement / 现存问题
|
||||||
|
description: "What is difficult or missing today?"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: proposal
|
||||||
|
attributes:
|
||||||
|
label: Proposed Solution / 建议方案
|
||||||
|
description: "Describe your expected behavior, UI flow, API changes, etc."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives Considered / 备选方案
|
||||||
|
description: "Any alternatives or workarounds you've considered."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: impact
|
||||||
|
attributes:
|
||||||
|
label: Expected Impact / 预期价值
|
||||||
|
description: "Who benefits? Any performance/security/maintenance concerns?"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: scope
|
||||||
|
attributes:
|
||||||
|
label: Scope (Optional) / 影响范围(可选)
|
||||||
|
placeholder: "frontend / backend / docs / deployment"
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: extra
|
||||||
|
attributes:
|
||||||
|
label: Additional Context / 补充信息
|
||||||
|
description: "Mockups, references, related links, etc."
|
||||||
@@ -4,6 +4,11 @@ on:
|
|||||||
schedule:
|
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
|
||||||
|
|||||||
@@ -38,4 +38,9 @@ npm-debug.log*
|
|||||||
# Package lock (optional - remove if you want to commit it)
|
# Package lock (optional - remove if you want to commit it)
|
||||||
# package-lock.json
|
# package-lock.json
|
||||||
|
|
||||||
tmp/
|
tmp/
|
||||||
|
.tmp/
|
||||||
|
|
||||||
|
nodewarden.wiki/
|
||||||
|
AGENTS.md
|
||||||
|
settings.json
|
||||||
|
|||||||
|
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,122 +1,147 @@
|
|||||||
<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="./RELEASE_NOTES.md">更新日志</a> |
|
||||||
English:[`README_EN.md`](./README_EN.md)
|
<a href="https://github.com/shuaiplus/NodeWarden/issues/new/choose">提交问题</a> |
|
||||||
|
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest">最新发布</a><br />
|
||||||
|
<a href="./nodewarden.wiki/Home.md">文档首页</a> |
|
||||||
|
<a href="./nodewarden.wiki/快速开始.md">快速开始</a><br />
|
||||||
|
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
|
||||||
|
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a><br />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
English: <a href="./README_EN.md"><code>README_EN.md</code></a>
|
||||||
|
|
||||||
> **免责声明**
|
> **免责声明**
|
||||||
> 本项目仅供学习交流使用。我们不对任何数据丢失负责,强烈建议定期备份您的密码库。
|
> 本项目仅供学习与交流使用,请定期备份你的密码库。
|
||||||
> 本项目与 Bitwarden 官方无关,请勿向 Bitwarden 官方反馈问题。
|
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 与 Bitwarden 官方服务端能力对比
|
## 与 Bitwarden 官方服务端能力对比
|
||||||
|
|
||||||
| 能力项 | Bitwarden | NodeWarden | 说明 |
|
| 能力 | Bitwarden | NodeWarden | 说明 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Web Vault(登录/笔记/卡片/身份) | ✅ | ✅ | 网页端密码库管理页面 |
|
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
|
||||||
| 文件夹 / 收藏 | ✅ | ✅ | 常用管理能力可用 |
|
| 全量同步 `/api/sync` | ✅ | ✅ | 已针对官方客户端做兼容优化 |
|
||||||
| 全量同步 `/api/sync` | ✅ | ✅ | 已做兼容与性能优化 |
|
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
|
||||||
| 附件上传/下载 | ✅ | ✅ | Cloudflare R2 和 KV 二选一 |
|
| Send | ✅ | ✅ | 支持文本与文件 Send |
|
||||||
| 导入导出功能 | ✅ | ✅ | 完整实现,含 Bitwarden 密码库+附件 ZIP 导入 |
|
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
|
||||||
| 网站图标代理 | ✅ | ✅ | 通过 `/icons/{hostname}/icon.png` |
|
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / E3 定时备份** |
|
||||||
| passkey、TOTP 字段 | ✅ | ✅ | 完全支持,无需高级版 |
|
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
|
||||||
| Send | ✅ | ✅ | Cloudflare R2 和 KV 二选一 |
|
| TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 |
|
||||||
| 多用户 | ✅ | ✅ | 完整的用户管理,邀请机制 |
|
| 多用户 | ✅ | ✅ | 支持邀请码注册 |
|
||||||
| 组织/集合/成员权限 | ✅ | ❌ | 没必要实现 |
|
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
|
||||||
| 登录 2FA(TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ 部分支持 | 仅支持用户级 TOTP |
|
| 登录 2FA | ✅ | ⚠️ 部分支持 | 当前仅支持用户级 TOTP |
|
||||||
| SSO / SCIM / 企业目录 | ✅ | ❌ | 没必要实现 |
|
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
|
||||||
| 紧急访问 | ✅ | ❌ | 没必要实现 |
|
|
||||||
| 管理后台 / 计费订阅 | ✅ | ❌ | 纯免费 |
|
|
||||||
| 推送通知完整链路 | ✅ | ❌ | 没必要实现 |
|
|
||||||
|
|
||||||
## 测试情况:
|
|
||||||
|
|
||||||
- ✅ Windows 客户端(v2026.1.0)
|
|
||||||
- ✅ 手机 App(v2026.1.0)
|
|
||||||
- ✅ 浏览器扩展(v2026.1.0)
|
|
||||||
- ✅ Linux 客户端(v2026.1.0)
|
|
||||||
- ⬜ macOS 客户端(未测试)
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 已测试客户端
|
||||||
|
|
||||||
|
- ✅ Windows 桌面端
|
||||||
|
- ✅ 手机 App
|
||||||
|
- ✅ 浏览器扩展
|
||||||
|
- ✅ Linux 桌面端
|
||||||
|
- ⚠️ macOS 桌面端尚未完整验证
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 网页部署
|
## 网页部署
|
||||||
|
|
||||||
|
1. Fork `NodeWarden` 仓库到自己的 GitHub 账号
|
||||||
1. Fork 本仓库。若本项目对你有帮助,欢迎点个 Star。
|
2. 进入 [Cloudflare Workers 创建页面](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
||||||
2. 打开 [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) ➜ `Continue with GitHub` ➜ 选择你 Fork 后的仓库(`NodeWarden`)➜ 下一步 ➜ (默认使用 R2 存储;若未开通,可切换为 KV,并将部署命令改为 `npm run deploy:kv`)➜ 部署 ➜ 打开生成的链接
|
3. 选择 `Continue with GitHub`
|
||||||
|
4. 选择你刚刚 Fork 的仓库
|
||||||
| 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|
5. 保持默认配置继续部署
|
||||||
|---|---|---|---|
|
6. 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
|
||||||
| R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
|
7. 等部署完成后,打开生成的 Workers 域名
|
||||||
| KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
8. 根据页面提示设置`JWT_SECRET` ,不建议临时乱填。这个值直接关系到令牌签发安全,正式环境至少使用 32 个字符以上的随机字符串。
|
||||||
|
|
||||||
> [!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 点自动同步上游。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## CLI 部署
|
## CLI 部署
|
||||||
|
|
||||||
```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
|
||||||
|
|
||||||
# Cloudflare CLI 登录
|
|
||||||
npx wrangler login
|
npx wrangler login
|
||||||
|
|
||||||
# 部署到 Cloudflare
|
# 默认:R2 模式
|
||||||
npm run deploy
|
npm run deploy
|
||||||
|
|
||||||
# (可选)KV 模式(无 R2 / 无信用卡)
|
# 可选:KV 模式
|
||||||
npm run deploy:kv
|
npm run deploy:kv
|
||||||
|
|
||||||
# 本地开发
|
# 本地开发
|
||||||
npm run dev
|
npm run dev
|
||||||
npm run dev:kv
|
npm run dev:kv
|
||||||
|
|
||||||
# 后续更新时重新拉取仓库并重新部署即可,无需重复创建云资源
|
|
||||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
|
||||||
cd NodeWarden
|
|
||||||
npm run deploy
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 云端备份说明
|
||||||
|
|
||||||
|
- 远程备份支持 **WebDAV** 与 **E3**
|
||||||
|
- 勾选“包含附件”后:
|
||||||
|
- ZIP 内仍只包含 `db.json` 与 `manifest.json`
|
||||||
|
- 真实附件单独存放在 `attachments/`
|
||||||
|
- 后续备份会按稳定 blob 名复用已有附件,不会每次全量重传
|
||||||
|
- 远程还原时:
|
||||||
|
- 会从 `attachments/` 目录按需读取附件
|
||||||
|
- 缺失的附件会被安全跳过
|
||||||
|
- 被跳过的附件不会在恢复后的数据库中留下脏记录
|
||||||
|
|
||||||
---
|
---
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
**Q: 如何备份数据?**
|
## 导入 / 导出
|
||||||
A: 在客户端中选择「导出密码库」,保存 JSON 文件。
|
|
||||||
|
|
||||||
**Q: 导入导出支持哪些格式?**
|
当前支持的导入来源包括:
|
||||||
A: 支持 Bitwarden `json/csv/密码库+附件 zip` 和 NodeWarden `密码库+附件 json`(均含加密模式),且导入下拉中看到的格式都可直接导入。
|
|
||||||
A: 另外还支持直接导入 Bitwarden `密码库+附件 zip`,这条路径官方 Bitwarden Web 暂不支持。
|
|
||||||
|
|
||||||
**Q: 忘记主密码怎么办?**
|
- Bitwarden JSON
|
||||||
A: 无法恢复,这是端到端加密的特性。建议妥善保管主密码。
|
- Bitwarden CSV
|
||||||
|
- Bitwarden 密码库 + 附件 ZIP
|
||||||
|
- NodeWarden JSON
|
||||||
|
- 网页导入器里可见的多种浏览器 / 密码管理器格式
|
||||||
|
|
||||||
**Q: 可以多人使用吗?**
|
当前支持的导出方式包括:
|
||||||
A: 支持。第一个注册的用户会自动成为管理员;管理员可在管理页面生成邀请码,其他用户凭邀请码注册。
|
|
||||||
|
- Bitwarden JSON
|
||||||
|
- Bitwarden 加密 JSON
|
||||||
|
- 带附件的 ZIP 导出
|
||||||
|
- NodeWarden JSON 系列
|
||||||
|
- 备份中心中的实例级完整手动导出
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
## 开源协议
|
## 开源协议
|
||||||
|
|
||||||
LGPL-3.0 License
|
LGPL-3.0 License
|
||||||
@@ -125,11 +150,12 @@ LGPL-3.0 License
|
|||||||
|
|
||||||
## 致谢
|
## 致谢
|
||||||
|
|
||||||
- [Bitwarden](https://bitwarden.com/) - 原始设计和客户端
|
- [Bitwarden](https://bitwarden.com/) - 原始设计与客户端
|
||||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务器实现参考
|
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务端实现参考
|
||||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
|
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
||||||
|
|||||||
@@ -1,115 +1,136 @@
|
|||||||
<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 server running on Cloudflare Workers, fully compatible with official clients.
|
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="./RELEASE_NOTES.md">Release Notes</a> |
|
||||||
|
<a href="https://github.com/shuaiplus/NodeWarden/issues/new/choose">Report an Issue</a> |
|
||||||
|
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest">Latest Release</a><br />
|
||||||
|
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
|
||||||
|
<a href="https://t.me/NodeWarden_Official">Telegram Group</a><br />
|
||||||
|
|
||||||
中文文档:[`README.md`](./README.md)
|
</p>
|
||||||
|
|
||||||
> **Disclaimer**
|
中文说明:<a href="./README.md"><code>README.md</code></a>
|
||||||
> This project is for learning and communication purposes only. We are not responsible for any data loss; regular vault backups are strongly recommended.
|
|
||||||
> This project is not affiliated with Bitwarden. Please do not report issues to the official Bitwarden team.
|
> **Disclaimer**
|
||||||
|
>
|
||||||
|
> This project is for learning and discussion purposes only. Please back up your vault regularly.
|
||||||
|
>
|
||||||
|
> This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Feature Comparison Table (vs Official Bitwarden Server)
|
## Feature Comparison with the Official Bitwarden Server
|
||||||
|
|
||||||
| Capability | Bitwarden | NodeWarden | Notes |
|
| Capability | Bitwarden | NodeWarden | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Web Vault (logins/notes/cards/identities) | ✅ | ✅ | Web-based vault management UI |
|
| Web Vault | ✅ | ✅ | **Original Web Vault interface** |
|
||||||
| Folders / Favorites | ✅ | ✅ | Common vault organization supported |
|
| Full sync `/api/sync` | ✅ | ✅ | Compatibility optimized for official clients |
|
||||||
| Full sync `/api/sync` | ✅ | ✅ | Compatibility and performance optimized |
|
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
|
||||||
| Attachment upload/download | ✅ | ✅ | Choose either Cloudflare R2 or KV |
|
| Send | ✅ | ✅ | Supports both text and file Sends |
|
||||||
| Import / export | ✅ | ✅ | Fully implemented, including Bitwarden vault + attachments ZIP import |
|
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
|
||||||
| Website icon proxy | ✅ | ✅ | Via `/icons/{hostname}/icon.png` |
|
| **Cloud Backup Center** | ❌ | ✅ | **Scheduled backup to WebDAV / E3** |
|
||||||
| passkey / TOTP fields | ✅ | ✅ | Fully supported, no premium required |
|
| Password hint (web) | ⚠️ Limited | ✅ | **No email required** |
|
||||||
| Send | ✅ | ✅ | Choose either Cloudflare R2 or KV |
|
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
|
||||||
| Multi-user | ✅ | ✅ | Full user management with invitation mechanism |
|
| Multi-user | ✅ | ✅ | Invite-based registration |
|
||||||
| Organizations / Collections / Member roles | ✅ | ❌ | Not necessary to implement |
|
| Organizations / Collections / Member roles | ✅ | ❌ | Not implemented |
|
||||||
| Login 2FA (TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ Partial | User-level TOTP only |
|
| Login 2FA | ✅ | ⚠️ Partial | Currently only user-level TOTP |
|
||||||
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not necessary to implement |
|
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not implemented |
|
||||||
| Emergency access | ✅ | ❌ | Not necessary to implement |
|
|
||||||
| Admin console / Billing & subscription | ✅ | ❌ | Free only |
|
|
||||||
| Full push notification pipeline | ✅ | ❌ | Not necessary to implement |
|
|
||||||
|
|
||||||
## Tested clients / platforms
|
|
||||||
|
|
||||||
- ✅ Windows desktop client (v2026.1.0)
|
|
||||||
- ✅ Mobile app (v2026.1.0)
|
|
||||||
- ✅ Browser extension (v2026.1.0)
|
|
||||||
- ✅ Linux desktop client (v2026.1.0)
|
|
||||||
- ⬜ macOS desktop client (not tested)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Web deploy
|
## Tested Clients
|
||||||
|
|
||||||
1. Fork this repository. If you find this project helpful, please consider giving it a Star.
|
- ✅ Windows desktop client
|
||||||
2. Open [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) -> `Continue with GitHub` -> select your forked repository (`NodeWarden`) -> `Next` -> (R2 storage is used by default; if R2 is unavailable for your account, switch to KV and change the deploy command to `npm run deploy:kv`) -> deploy -> open the generated URL.
|
- ✅ Mobile app
|
||||||
|
- ✅ Browser extension
|
||||||
|
- ✅ Linux desktop client
|
||||||
|
- ⚠️ macOS desktop client has not been fully verified yet
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Web Deploy
|
||||||
|
|
||||||
|
1. Fork this repository. If this project helps you, consider giving it a Star.
|
||||||
|
2. Open [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) -> `Continue with GitHub` -> select your forked repository (`NodeWarden`) -> continue.
|
||||||
|
3. R2 is used by default. If R2 is not enabled on your account, you can use KV instead by changing the **deploy command** to `npm run deploy:kv`.
|
||||||
|
4. Deploy and open the generated URL.
|
||||||
|
|
||||||
| Storage | Card required | Single attachment / Send file limit | Free tier |
|
| Storage | Card required | Single attachment / Send file limit | Free tier |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| R2 | Yes | 100 MB (soft limit, can be changed) | 10 GB |
|
| R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
|
||||||
| KV | No | 25 MiB (Cloudflare limit, cannot be changed) | 1 GB |
|
| KV | No | 25 MiB (Cloudflare limit) | 1 GB |
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Sync upstream (keep your fork updated):
|
> How to keep your fork updated:
|
||||||
>- Manual: open your fork on GitHub, click `Sync fork`, then click `Update branch`.
|
> - Manual: open your fork on GitHub, click `Sync fork`, then `Update branch`
|
||||||
>- Automatic: in your fork, go to `Actions` -> `Sync upstream` -> `Enable workflow`. It will automatically sync from upstream every day at 3 AM.
|
> - Automatic: go to your fork -> `Actions` -> `Sync upstream` -> `Enable workflow`; it will sync upstream automatically every day at 3 AM
|
||||||
|
|
||||||
## CLI deploy
|
## CLI Deploy
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Clone repository
|
|
||||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||||
cd NodeWarden
|
cd NodeWarden
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# Cloudflare CLI login
|
|
||||||
npx wrangler login
|
npx wrangler login
|
||||||
|
|
||||||
# Deploy to Cloudflare
|
# Default: R2 mode
|
||||||
npm run deploy
|
npm run deploy
|
||||||
|
|
||||||
# (Optional) KV mode (no R2 / no credit card)
|
# Optional: KV mode
|
||||||
npm run deploy:kv
|
npm run deploy:kv
|
||||||
|
|
||||||
# Local development
|
# Local development
|
||||||
npm run dev
|
npm run dev
|
||||||
npm run dev:kv
|
npm run dev:kv
|
||||||
|
|
||||||
# To update later, pull the repository again and redeploy
|
|
||||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
|
||||||
cd NodeWarden
|
|
||||||
npm run deploy
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## FAQ
|
## Cloud Backup Notes
|
||||||
|
|
||||||
**Q: How do I back up my data?**
|
- Remote backup supports **WebDAV** and **E3**
|
||||||
A: Use **Export vault** in your client and save the JSON file.
|
- When `Include attachments` is enabled:
|
||||||
|
- the ZIP still contains only `db.json` and `manifest.json`
|
||||||
|
- actual attachment files are stored separately under `attachments/`
|
||||||
|
- later backups reuse existing attachments by stable blob name instead of re-uploading everything every time
|
||||||
|
- During remote restore:
|
||||||
|
- required attachment files are loaded from `attachments/` on demand
|
||||||
|
- missing attachments are skipped safely
|
||||||
|
- skipped attachments do not leave broken rows in the restored database
|
||||||
|
|
||||||
**Q: Which import/export formats are supported?**
|
---
|
||||||
A: NodeWarden supports Bitwarden `json/csv/vault + attachments zip` and NodeWarden `vault + attachments json` in both plain and encrypted modes, and every format visible in the import selector is directly importable.
|
|
||||||
A: It also supports direct import of Bitwarden `vault + attachments zip`, which is not directly supported by official Bitwarden Web import.
|
|
||||||
|
|
||||||
**Q: What if I forget the master password?**
|
## Import / Export
|
||||||
A: It can’t be recovered (end-to-end encryption). Keep it safe.
|
|
||||||
|
|
||||||
**Q: Can multiple people use it?**
|
Current supported import sources include:
|
||||||
A: Yes. The first registered user becomes the admin. The admin can generate invite codes from the admin panel, and other users register with those codes.
|
|
||||||
|
- Bitwarden JSON
|
||||||
|
- Bitwarden CSV
|
||||||
|
- Bitwarden vault + attachments ZIP
|
||||||
|
- NodeWarden JSON
|
||||||
|
- Multiple browser / password-manager formats available in the web import selector
|
||||||
|
|
||||||
|
Current supported export formats include:
|
||||||
|
|
||||||
|
- Bitwarden JSON
|
||||||
|
- Bitwarden encrypted JSON
|
||||||
|
- ZIP export with attachments
|
||||||
|
- NodeWarden JSON variants
|
||||||
|
- Full manual instance export from the backup center
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -121,13 +142,12 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
security_stamp TEXT NOT NULL,
|
security_stamp TEXT NOT NULL,
|
||||||
role TEXT NOT NULL DEFAULT 'user',
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
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
|
||||||
);
|
);
|
||||||
@@ -51,11 +53,15 @@ CREATE TABLE IF NOT EXISTS ciphers (
|
|||||||
key TEXT,
|
key TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
|
archived_at TEXT,
|
||||||
deleted_at TEXT,
|
deleted_at 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_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_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,
|
||||||
@@ -103,6 +109,7 @@ CREATE TABLE IF NOT EXISTS sends (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at);
|
CREATE INDEX IF NOT EXISTS idx_sends_user_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,
|
||||||
@@ -144,12 +151,19 @@ CREATE TABLE IF NOT EXISTS devices (
|
|||||||
device_identifier TEXT NOT NULL,
|
device_identifier TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
type INTEGER NOT NULL,
|
type INTEGER NOT NULL,
|
||||||
|
session_stamp TEXT,
|
||||||
|
encrypted_user_key TEXT,
|
||||||
|
encrypted_public_key TEXT,
|
||||||
|
encrypted_private_key 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,
|
||||||
@@ -169,14 +183,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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.4.0",
|
"version": "1.4.6",
|
||||||
"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",
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
"dev": "wrangler dev -c wrangler.toml",
|
"dev": "wrangler dev -c wrangler.toml",
|
||||||
"dev:kv": "wrangler dev -c wrangler.kv.toml",
|
"dev:kv": "wrangler dev -c wrangler.kv.toml",
|
||||||
"build": "vite build --config webapp/vite.config.ts",
|
"build": "vite build --config webapp/vite.config.ts",
|
||||||
|
"i18n": "node scripts/i18n-validate.cjs",
|
||||||
|
"i18n:validate": "node scripts/i18n-validate.cjs",
|
||||||
"deploy": "wrangler deploy",
|
"deploy": "wrangler deploy",
|
||||||
"deploy:kv": "wrangler deploy -c wrangler.kv.toml"
|
"deploy:kv": "wrangler deploy -c wrangler.kv.toml"
|
||||||
},
|
},
|
||||||
@@ -40,12 +42,19 @@
|
|||||||
"@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,41 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
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,57 @@
|
|||||||
|
const { localeFiles, readLocale } = require('./i18n-utils.cjs');
|
||||||
|
|
||||||
|
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',
|
||||||
|
]);
|
||||||
|
|
||||||
|
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] && !intentionallyEnglishKeys.has(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);
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = '1.4.0';
|
export const APP_VERSION = '1.4.6';
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
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 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;
|
||||||
@@ -23,7 +24,7 @@ export interface WebDavBackupDestination {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type BackupDestinationConfig =
|
export type BackupDestinationConfig =
|
||||||
| E3BackupDestination
|
| S3BackupDestination
|
||||||
| WebDavBackupDestination;
|
| WebDavBackupDestination;
|
||||||
|
|
||||||
export interface BackupRuntimeState {
|
export interface BackupRuntimeState {
|
||||||
@@ -40,6 +41,7 @@ export interface BackupRuntimeState {
|
|||||||
export interface BackupScheduleConfig {
|
export interface BackupScheduleConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
intervalHours: number;
|
intervalHours: number;
|
||||||
|
startTime: string;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
retentionCount: number | null;
|
retentionCount: number | null;
|
||||||
}
|
}
|
||||||
@@ -82,17 +84,18 @@ export function createDefaultBackupScheduleConfig(timezone: string = BACKUP_DEFA
|
|||||||
return {
|
return {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
intervalHours: BACKUP_DEFAULT_INTERVAL_HOURS,
|
intervalHours: BACKUP_DEFAULT_INTERVAL_HOURS,
|
||||||
|
startTime: BACKUP_DEFAULT_START_TIME,
|
||||||
timezone,
|
timezone,
|
||||||
retentionCount: BACKUP_DEFAULT_RETENTION_COUNT,
|
retentionCount: BACKUP_DEFAULT_RETENTION_COUNT,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
@@ -107,7 +110,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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -75,8 +75,19 @@ function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' |
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function verifyUserSecret(
|
||||||
|
auth: AuthService,
|
||||||
|
user: User,
|
||||||
|
secret: string | null | undefined
|
||||||
|
): Promise<boolean> {
|
||||||
|
const normalized = String(secret || '').trim();
|
||||||
|
if (!normalized) return false;
|
||||||
|
return auth.verifyPassword(normalized, user.masterPasswordHash, user.email);
|
||||||
|
}
|
||||||
|
|
||||||
function toProfile(user: User, env: Env): ProfileResponse {
|
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,
|
||||||
@@ -90,7 +101,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: [],
|
||||||
@@ -98,6 +109,7 @@ function toProfile(user: User, env: Env): ProfileResponse {
|
|||||||
forcePasswordReset: false,
|
forcePasswordReset: false,
|
||||||
avatarColor: null,
|
avatarColor: null,
|
||||||
creationDate: user.createdAt,
|
creationDate: user.createdAt,
|
||||||
|
verifyDevices: user.verifyDevices,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
status: user.status,
|
status: user.status,
|
||||||
object: 'profile',
|
object: 'profile',
|
||||||
@@ -194,8 +206,10 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
|||||||
securityStamp: generateUUID(),
|
securityStamp: generateUUID(),
|
||||||
role: 'user',
|
role: 'user',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
verifyDevices: true,
|
||||||
totpSecret: null,
|
totpSecret: null,
|
||||||
totpRecoveryCode: null,
|
totpRecoveryCode: null,
|
||||||
|
apiKey: null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
@@ -363,6 +377,40 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
|
|||||||
return jsonResponse(toProfile(user, env));
|
return jsonResponse(toProfile(user, env));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PUT/POST /api/accounts/verify-devices
|
||||||
|
export async function handleSetVerifyDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const auth = new AuthService(env);
|
||||||
|
const user = await storage.getUserById(userId);
|
||||||
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
|
let body: {
|
||||||
|
secret?: string;
|
||||||
|
masterPasswordHash?: string;
|
||||||
|
verifyDevices?: boolean;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body.verifyDevices !== 'boolean') {
|
||||||
|
return errorResponse('verifyDevices must be true or false', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const verified = await verifyUserSecret(auth, user, body.secret || body.masterPasswordHash);
|
||||||
|
if (!verified) {
|
||||||
|
return errorResponse('User verification failed.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.verifyDevices = body.verifyDevices;
|
||||||
|
user.updatedAt = new Date().toISOString();
|
||||||
|
await storage.saveUser(user);
|
||||||
|
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
// POST /api/accounts/keys
|
// POST /api/accounts/keys
|
||||||
export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
@@ -704,3 +752,74 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
verifyAttachmentUploadToken,
|
verifyAttachmentUploadToken,
|
||||||
verifyFileDownloadToken,
|
verifyFileDownloadToken,
|
||||||
} from '../utils/jwt';
|
} from '../utils/jwt';
|
||||||
import { cipherToResponse, shouldOmitPasskeysForResponse } from './ciphers';
|
import { 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 {
|
||||||
@@ -21,13 +21,13 @@ import {
|
|||||||
putBlobObject,
|
putBlobObject,
|
||||||
} from '../services/blob-store';
|
} from '../services/blob-store';
|
||||||
|
|
||||||
async function notifyVaultSyncForRequest(
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format file size to human readable
|
// Format file size to human readable
|
||||||
@@ -38,6 +38,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 +93,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 +153,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 +170,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),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +279,64 @@ export async function handleGetAttachment(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
key: attachment.key,
|
||||||
|
size: String(Number(attachment.size) || 0),
|
||||||
|
sizeName: attachment.sizeName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/attachments/{cipherId}/{attachmentId}?token=xxx
|
// GET /api/attachments/{cipherId}/{attachmentId}?token=xxx
|
||||||
// Public download endpoint (uses token for auth instead of header)
|
// Public download endpoint (uses token for auth instead of header)
|
||||||
export async function handlePublicDownloadAttachment(
|
export async function handlePublicDownloadAttachment(
|
||||||
@@ -358,13 +426,10 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get updated cipher for response
|
// Get updated cipher for response
|
||||||
@@ -372,9 +437,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 +446,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,25 @@ 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 { 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';
|
||||||
@@ -69,6 +81,98 @@ 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;
|
||||||
|
|
||||||
|
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 +185,90 @@ 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
|
||||||
): 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 +277,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,18 +406,31 @@ 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 {
|
||||||
fileName: archive.fileName,
|
fileName: archive.fileName,
|
||||||
fileSize: archive.bytes.byteLength,
|
fileSize: archive.bytes.byteLength,
|
||||||
@@ -150,12 +440,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,
|
||||||
});
|
});
|
||||||
|
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 +472,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,
|
||||||
@@ -190,13 +514,30 @@ async function runImportAndAudit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
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> {
|
||||||
@@ -298,7 +639,6 @@ export async function handleRepairAdminBackupSettings(request: Request, env: Env
|
|||||||
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 +649,45 @@ 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
|
||||||
|
);
|
||||||
|
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 +737,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);
|
||||||
|
|
||||||
@@ -392,7 +783,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 +795,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 +836,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,6 +855,7 @@ 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,
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
})();
|
})();
|
||||||
@@ -445,6 +870,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 +881,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,6 +984,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 +993,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,36 @@
|
|||||||
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';
|
||||||
|
|
||||||
async function notifyVaultSyncForRequest(
|
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,80 +43,147 @@ function getAliasedProp(source: any, aliases: string[]): { present: boolean; val
|
|||||||
return { present: false, value: undefined };
|
return { present: false, value: undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
function looksLikeCipherString(value: unknown): boolean {
|
function readCipherProp<T = unknown>(source: any, aliases: string[]): { present: boolean; value: T | undefined } {
|
||||||
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
|
return getAliasedProp(source, aliases) as { present: boolean; value: T | undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldOmitPasskeysForResponse(request: Request | null | undefined): boolean {
|
function normalizeCipherTimestamp(value: unknown): string | null {
|
||||||
const userAgent = String(request?.headers.get('user-agent') || '').toLowerCase();
|
if (value == null || value === '') return null;
|
||||||
if (!userAgent) return false;
|
const parsed = new Date(String(value));
|
||||||
|
if (Number.isNaN(parsed.getTime())) return null;
|
||||||
|
return parsed.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
// Temporary compatibility fallback:
|
function readCipherArchivedAt(source: any, fallback: string | null = null): string | null {
|
||||||
// mobile clients expect official EncString payloads for most FIDO2 fields.
|
const archived = getAliasedProp(source, ['archivedAt', 'ArchivedAt', 'archivedDate', 'ArchivedDate']);
|
||||||
// Keep passkeys available everywhere, but suppress only legacy malformed data
|
return archived.present ? normalizeCipherTimestamp(archived.value) : fallback;
|
||||||
// for mobile clients so newly-saved credentials can flow through unchanged.
|
}
|
||||||
return (
|
|
||||||
userAgent.includes('android') ||
|
function readCipherRevisionDate(source: any): string | null {
|
||||||
userAgent.includes('iphone') ||
|
const revision = getAliasedProp(source, ['lastKnownRevisionDate', 'LastKnownRevisionDate']);
|
||||||
userAgent.includes('ipad') ||
|
return revision.present ? normalizeCipherTimestamp(revision.value) : null;
|
||||||
userAgent.includes('ios')
|
}
|
||||||
);
|
|
||||||
|
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 {
|
||||||
|
cipher.archivedDate = cipher.archivedAt ?? null;
|
||||||
|
cipher.deletedDate = cipher.deletedAt ?? null;
|
||||||
|
return cipher;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||||
|
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
|
||||||
|
cipher.folderId = normalizeOptionalId(cipher.folderId);
|
||||||
|
const hasArchivedAt = Object.prototype.hasOwnProperty.call(cipher as object, 'archivedAt');
|
||||||
|
cipher.archivedAt = hasArchivedAt
|
||||||
|
? normalizeCipherTimestamp(cipher.archivedAt) ?? null
|
||||||
|
: normalizeCipherTimestamp(cipher.archivedDate) ?? null;
|
||||||
|
return syncCipherComputedAliases(cipher);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 normalizeFido2CredentialsForCompatibility(credentials: any): any[] | null {
|
||||||
if (!credentials?.length) return normalized;
|
if (!Array.isArray(credentials) || credentials.length === 0) return null;
|
||||||
|
const requiredEncryptedKeys = [
|
||||||
|
'credentialId',
|
||||||
|
'keyType',
|
||||||
|
'keyAlgorithm',
|
||||||
|
'keyCurve',
|
||||||
|
'keyValue',
|
||||||
|
'rpId',
|
||||||
|
'counter',
|
||||||
|
'discoverable',
|
||||||
|
];
|
||||||
|
const optionalEncryptedKeys = ['userHandle', 'userName', 'rpName', 'userDisplayName'];
|
||||||
|
const out: any[] = [];
|
||||||
|
|
||||||
const hasMalformedCredential = credentials.some((credential: any) => {
|
for (const credential of credentials) {
|
||||||
if (!credential || typeof credential !== 'object') return true;
|
if (!credential || typeof credential !== 'object') continue;
|
||||||
const requiredEncryptedFields = [
|
const next: Record<string, any> = { ...credential };
|
||||||
credential.credentialId,
|
let valid = true;
|
||||||
credential.keyType,
|
for (const key of requiredEncryptedKeys) {
|
||||||
credential.keyAlgorithm,
|
if (!isValidEncString(next[key])) {
|
||||||
credential.keyCurve,
|
valid = false;
|
||||||
credential.keyValue,
|
break;
|
||||||
credential.rpId,
|
|
||||||
credential.counter,
|
|
||||||
credential.discoverable,
|
|
||||||
];
|
|
||||||
const optionalEncryptedFields = [
|
|
||||||
credential.userHandle,
|
|
||||||
credential.userName,
|
|
||||||
credential.rpName,
|
|
||||||
credential.userDisplayName,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (requiredEncryptedFields.some((value) => !looksLikeCipherString(value))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (optionalEncryptedFields.some((value) => value != null && !looksLikeCipherString(value))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
return hasMalformedCredential
|
|
||||||
? {
|
|
||||||
...normalized,
|
|
||||||
fido2Credentials: null,
|
|
||||||
}
|
}
|
||||||
: normalized;
|
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.
|
||||||
@@ -117,8 +201,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,
|
||||||
};
|
};
|
||||||
@@ -127,16 +221,52 @@ 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 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.
|
||||||
@@ -145,37 +275,65 @@ 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, 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);
|
||||||
|
|
||||||
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,
|
||||||
archivedDate: null,
|
archivedDate: archivedAt ?? null,
|
||||||
edit: true,
|
edit: true,
|
||||||
viewPassword: true,
|
viewPassword: true,
|
||||||
permissions: {
|
permissions: {
|
||||||
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(attachments),
|
||||||
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +343,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;
|
||||||
@@ -206,13 +363,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({
|
||||||
@@ -233,9 +392,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),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,6 +416,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,
|
||||||
@@ -273,12 +438,20 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
|||||||
reprompt: cipherData.reprompt || 0,
|
reprompt: cipherData.reprompt || 0,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
archivedAt: readCipherArchivedAt(cipherData, null),
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
};
|
};
|
||||||
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
cipher.folderId = createFolderId.present ? normalizeOptionalId(createFolderId.value) : normalizeOptionalId(cipher.folderId);
|
||||||
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
|
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);
|
||||||
|
|
||||||
// Prevent referencing a folder owned by another user.
|
// Prevent referencing a folder owned by another user.
|
||||||
if (cipher.folderId) {
|
if (cipher.folderId) {
|
||||||
@@ -288,12 +461,10 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
|||||||
|
|
||||||
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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -317,6 +488,21 @@ 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);
|
||||||
|
|
||||||
|
if (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.
|
||||||
@@ -326,15 +512,28 @@ 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,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
|
||||||
deletedAt: existingCipher.deletedAt,
|
deletedAt: existingCipher.deletedAt,
|
||||||
};
|
};
|
||||||
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
if (incomingFolderId.present) {
|
||||||
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
|
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".
|
||||||
@@ -346,6 +545,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
} else if (request.method === 'PUT' || request.method === 'POST') {
|
} else if (request.method === 'PUT' || request.method === 'POST') {
|
||||||
cipher.fields = null;
|
cipher.fields = null;
|
||||||
}
|
}
|
||||||
|
normalizeCipherForStorage(cipher);
|
||||||
|
|
||||||
// Prevent referencing a folder owned by another user.
|
// Prevent referencing a folder owned by another user.
|
||||||
if (cipher.folderId) {
|
if (cipher.folderId) {
|
||||||
@@ -355,12 +555,11 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
|
|
||||||
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),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,14 +575,13 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
|
|||||||
// Soft delete
|
// Soft delete
|
||||||
cipher.deletedAt = new Date().toISOString();
|
cipher.deletedAt = new Date().toISOString();
|
||||||
cipher.updatedAt = cipher.deletedAt;
|
cipher.updatedAt = cipher.deletedAt;
|
||||||
|
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),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,7 +602,7 @@ 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);
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,7 +623,7 @@ 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);
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
@@ -441,14 +639,13 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
|
|||||||
|
|
||||||
cipher.deletedAt = null;
|
cipher.deletedAt = null;
|
||||||
cipher.updatedAt = new Date().toISOString();
|
cipher.updatedAt = new Date().toISOString();
|
||||||
|
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),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,25 +666,25 @@ 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;
|
||||||
}
|
}
|
||||||
cipher.updatedAt = new Date().toISOString();
|
cipher.updatedAt = new Date().toISOString();
|
||||||
|
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),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,19 +703,138 @@ 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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function buildCipherListResponse(
|
||||||
|
request: Request,
|
||||||
|
storage: StorageService,
|
||||||
|
userId: string,
|
||||||
|
ids: string[]
|
||||||
|
): Promise<Response> {
|
||||||
|
const ciphers = await storage.getCiphersByIds(ids, userId);
|
||||||
|
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map((cipher) => cipher.id));
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
data: ciphers.map((cipher) =>
|
||||||
|
cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [])
|
||||||
|
),
|
||||||
|
object: 'list',
|
||||||
|
continuationToken: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCipherIdList(body: { ids?: unknown }): string[] | null {
|
||||||
|
if (!Array.isArray(body.ids)) return null;
|
||||||
|
return Array.from(new Set(body.ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT/POST /api/ciphers/:id/archive
|
||||||
|
export async function handleArchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const cipher = await storage.getCipher(id);
|
||||||
|
|
||||||
|
if (!cipher || cipher.userId !== userId) {
|
||||||
|
return errorResponse('Cipher not found', 404);
|
||||||
|
}
|
||||||
|
if (cipher.deletedAt) {
|
||||||
|
return errorResponse('Cannot archive a deleted cipher', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
cipher.archivedAt = new Date().toISOString();
|
||||||
|
cipher.updatedAt = cipher.archivedAt;
|
||||||
|
normalizeCipherForStorage(cipher);
|
||||||
|
await storage.saveCipher(cipher);
|
||||||
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
|
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||||
|
return jsonResponse(
|
||||||
|
cipherToResponse(cipher, attachments)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT/POST /api/ciphers/:id/unarchive
|
||||||
|
export async function handleUnarchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const cipher = await storage.getCipher(id);
|
||||||
|
|
||||||
|
if (!cipher || cipher.userId !== userId) {
|
||||||
|
return errorResponse('Cipher not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
cipher.archivedAt = null;
|
||||||
|
cipher.updatedAt = new Date().toISOString();
|
||||||
|
normalizeCipherForStorage(cipher);
|
||||||
|
await storage.saveCipher(cipher);
|
||||||
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
|
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||||
|
return jsonResponse(
|
||||||
|
cipherToResponse(cipher, attachments)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT/POST /api/ciphers/archive
|
||||||
|
export async function handleBulkArchiveCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
|
let body: { ids?: unknown };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = parseCipherIdList(body);
|
||||||
|
if (!ids) {
|
||||||
|
return errorResponse('ids array is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisionDate = await storage.bulkArchiveCiphers(ids, userId);
|
||||||
|
if (revisionDate) {
|
||||||
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildCipherListResponse(request, storage, userId, ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT/POST /api/ciphers/unarchive
|
||||||
|
export async function handleBulkUnarchiveCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
|
let body: { ids?: unknown };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = parseCipherIdList(body);
|
||||||
|
if (!ids) {
|
||||||
|
return errorResponse('ids array is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisionDate = await storage.bulkUnarchiveCiphers(ids, userId);
|
||||||
|
if (revisionDate) {
|
||||||
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildCipherListResponse(request, storage, userId, ids);
|
||||||
|
}
|
||||||
|
|
||||||
// POST /api/ciphers/delete - Bulk soft delete
|
// POST /api/ciphers/delete - Bulk soft delete
|
||||||
export async function handleBulkDeleteCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleBulkDeleteCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
@@ -536,7 +852,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
@@ -559,7 +875,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 });
|
||||||
@@ -585,13 +901,17 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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 { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
@@ -5,6 +6,118 @@ 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';
|
||||||
|
|
||||||
|
function normalizeIdentifier(value: string | null | undefined): string {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDevicePendingAuthRequest(value?: { id?: string | null; creationDate?: string | null } | null): DevicePendingAuthRequest | null {
|
||||||
|
if (!value?.id || !value.creationDate) return null;
|
||||||
|
return {
|
||||||
|
id: String(value.id),
|
||||||
|
creationDate: String(value.creationDate),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTrustedDevice(device: Pick<Device, 'encryptedUserKey' | 'encryptedPublicKey'>): boolean {
|
||||||
|
return !!(device.encryptedUserKey && device.encryptedPublicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDeviceResponse(device: Device): DeviceResponse {
|
||||||
|
const displayName = String(device.deviceNote || '').trim() || device.name;
|
||||||
|
const response = {
|
||||||
|
Id: device.deviceIdentifier,
|
||||||
|
id: device.deviceIdentifier,
|
||||||
|
UserId: device.userId,
|
||||||
|
userId: device.userId,
|
||||||
|
Name: displayName,
|
||||||
|
name: displayName,
|
||||||
|
SystemName: device.name,
|
||||||
|
systemName: device.name,
|
||||||
|
DeviceNote: device.deviceNote,
|
||||||
|
deviceNote: device.deviceNote,
|
||||||
|
Identifier: device.deviceIdentifier,
|
||||||
|
identifier: device.deviceIdentifier,
|
||||||
|
Type: device.type,
|
||||||
|
type: device.type,
|
||||||
|
CreationDate: device.createdAt,
|
||||||
|
creationDate: device.createdAt,
|
||||||
|
RevisionDate: device.updatedAt,
|
||||||
|
revisionDate: device.updatedAt,
|
||||||
|
LastSeenAt: device.lastSeenAt,
|
||||||
|
lastSeenAt: device.lastSeenAt,
|
||||||
|
HasStoredDevice: true,
|
||||||
|
hasStoredDevice: true,
|
||||||
|
IsTrusted: isTrustedDevice(device),
|
||||||
|
isTrusted: isTrustedDevice(device),
|
||||||
|
EncryptedUserKey: device.encryptedUserKey,
|
||||||
|
encryptedUserKey: device.encryptedUserKey,
|
||||||
|
EncryptedPublicKey: device.encryptedPublicKey,
|
||||||
|
encryptedPublicKey: device.encryptedPublicKey,
|
||||||
|
DevicePendingAuthRequest: buildDevicePendingAuthRequest(device.devicePendingAuthRequest),
|
||||||
|
devicePendingAuthRequest: buildDevicePendingAuthRequest(device.devicePendingAuthRequest),
|
||||||
|
object: 'device',
|
||||||
|
};
|
||||||
|
return response as DeviceResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProtectedDeviceResponse(device: Device): ProtectedDeviceWireResponse {
|
||||||
|
const response = {
|
||||||
|
Id: device.deviceIdentifier,
|
||||||
|
id: device.deviceIdentifier,
|
||||||
|
Name: String(device.deviceNote || '').trim() || 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,
|
||||||
|
Type: device.type,
|
||||||
|
type: device.type,
|
||||||
|
CreationDate: device.createdAt,
|
||||||
|
creationDate: device.createdAt,
|
||||||
|
EncryptedUserKey: device.encryptedUserKey,
|
||||||
|
encryptedUserKey: device.encryptedUserKey,
|
||||||
|
EncryptedPublicKey: device.encryptedPublicKey,
|
||||||
|
encryptedPublicKey: device.encryptedPublicKey,
|
||||||
|
object: 'protectedDevice',
|
||||||
|
};
|
||||||
|
return response as ProtectedDeviceWireResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseKeysBody(body: any, fallback?: Device): {
|
||||||
|
encryptedUserKey?: string | null;
|
||||||
|
encryptedPublicKey?: string | null;
|
||||||
|
encryptedPrivateKey?: string | null;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
encryptedUserKey:
|
||||||
|
Object.prototype.hasOwnProperty.call(body || {}, 'encryptedUserKey')
|
||||||
|
? body?.encryptedUserKey ?? null
|
||||||
|
: fallback?.encryptedUserKey ?? null,
|
||||||
|
encryptedPublicKey:
|
||||||
|
Object.prototype.hasOwnProperty.call(body || {}, 'encryptedPublicKey')
|
||||||
|
? body?.encryptedPublicKey ?? null
|
||||||
|
: fallback?.encryptedPublicKey ?? null,
|
||||||
|
encryptedPrivateKey:
|
||||||
|
Object.prototype.hasOwnProperty.call(body || {}, 'encryptedPrivateKey')
|
||||||
|
? body?.encryptedPrivateKey ?? null
|
||||||
|
: fallback?.encryptedPrivateKey ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonBody(request: Request): Promise<any> {
|
||||||
|
try {
|
||||||
|
return await request.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -28,20 +141,42 @@ export async function handleGetDevices(request: Request, env: Env, userId: strin
|
|||||||
const devices = await storage.getDevicesByUserId(userId);
|
const devices = await storage.getDevicesByUserId(userId);
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
data: devices.map(device => ({
|
data: devices.map((device) => buildDeviceResponse(device)),
|
||||||
id: device.deviceIdentifier,
|
|
||||||
name: device.name,
|
|
||||||
identifier: device.deviceIdentifier,
|
|
||||||
type: device.type,
|
|
||||||
creationDate: device.createdAt,
|
|
||||||
revisionDate: device.updatedAt,
|
|
||||||
object: 'device',
|
|
||||||
})),
|
|
||||||
object: 'list',
|
object: 'list',
|
||||||
continuationToken: null,
|
continuationToken: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /api/devices/identifier/:deviceIdentifier
|
||||||
|
export async function handleGetDeviceByIdentifier(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const normalized = normalizeIdentifier(deviceIdentifier);
|
||||||
|
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const device = await storage.getDevice(userId, normalized);
|
||||||
|
if (!device) {
|
||||||
|
return errorResponse('Device not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(buildDeviceResponse(device));
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/devices/:deviceIdentifier
|
||||||
|
export async function handleGetDevice(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<Response> {
|
||||||
|
return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/devices/authorized
|
// GET /api/devices/authorized
|
||||||
// Returns known devices together with active 2FA remember-token expiry.
|
// Returns known devices together with active 2FA remember-token expiry.
|
||||||
export async function handleGetAuthorizedDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleGetAuthorizedDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
@@ -64,12 +199,7 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
|
|||||||
knownIdentifiers.add(device.deviceIdentifier);
|
knownIdentifiers.add(device.deviceIdentifier);
|
||||||
const trustedInfo = trustedByIdentifier.get(device.deviceIdentifier);
|
const trustedInfo = trustedByIdentifier.get(device.deviceIdentifier);
|
||||||
return {
|
return {
|
||||||
id: device.deviceIdentifier,
|
...buildDeviceResponse(device),
|
||||||
name: device.name,
|
|
||||||
identifier: device.deviceIdentifier,
|
|
||||||
type: device.type,
|
|
||||||
creationDate: device.createdAt,
|
|
||||||
revisionDate: device.updatedAt,
|
|
||||||
online: onlineSet.has(device.deviceIdentifier),
|
online: onlineSet.has(device.deviceIdentifier),
|
||||||
trusted: !!trustedInfo,
|
trusted: !!trustedInfo,
|
||||||
trustedTokenCount: trustedInfo?.tokenCount || 0,
|
trustedTokenCount: trustedInfo?.tokenCount || 0,
|
||||||
@@ -80,13 +210,25 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
|
|||||||
|
|
||||||
for (const row of trusted) {
|
for (const row of trusted) {
|
||||||
if (knownIdentifiers.has(row.deviceIdentifier)) continue;
|
if (knownIdentifiers.has(row.deviceIdentifier)) continue;
|
||||||
data.push({
|
const placeholderDevice: Device = {
|
||||||
id: row.deviceIdentifier,
|
userId,
|
||||||
|
deviceIdentifier: row.deviceIdentifier,
|
||||||
name: 'Unknown device',
|
name: 'Unknown device',
|
||||||
identifier: row.deviceIdentifier,
|
|
||||||
type: 14,
|
type: 14,
|
||||||
creationDate: '',
|
sessionStamp: '',
|
||||||
revisionDate: '',
|
encryptedUserKey: null,
|
||||||
|
encryptedPublicKey: null,
|
||||||
|
encryptedPrivateKey: null,
|
||||||
|
devicePendingAuthRequest: null,
|
||||||
|
deviceNote: null,
|
||||||
|
lastSeenAt: null,
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: '',
|
||||||
|
};
|
||||||
|
data.push({
|
||||||
|
...buildDeviceResponse(placeholderDevice),
|
||||||
|
isTrusted: true,
|
||||||
|
hasStoredDevice: false,
|
||||||
online: onlineSet.has(row.deviceIdentifier),
|
online: onlineSet.has(row.deviceIdentifier),
|
||||||
trusted: true,
|
trusted: true,
|
||||||
trustedTokenCount: row.tokenCount,
|
trustedTokenCount: row.tokenCount,
|
||||||
@@ -142,11 +284,34 @@ 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);
|
notifyUserLogout(env, userId, normalized);
|
||||||
}
|
}
|
||||||
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);
|
||||||
|
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;
|
||||||
@@ -162,10 +327,142 @@ 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);
|
notifyUserLogout(env, userId, null);
|
||||||
return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices });
|
return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PUT/POST /api/devices/identifier/:deviceIdentifier/keys
|
||||||
|
export async function handleUpdateDeviceKeys(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const normalized = normalizeIdentifier(deviceIdentifier);
|
||||||
|
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const device = await storage.getDevice(userId, normalized);
|
||||||
|
if (!device) {
|
||||||
|
return errorResponse('Device not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await storage.updateDeviceKeys(userId, normalized, parseKeysBody(body, device));
|
||||||
|
if (!updated) {
|
||||||
|
return errorResponse('Device not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextDevice = await storage.getDevice(userId, normalized);
|
||||||
|
return jsonResponse(buildDeviceResponse(nextDevice || device));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/devices/update-trust
|
||||||
|
export async function handleUpdateDeviceTrust(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const currentDeviceIdentifier =
|
||||||
|
normalizeIdentifier(request.headers.get('Device-Identifier')) ||
|
||||||
|
normalizeIdentifier(request.headers.get('X-Device-Identifier'));
|
||||||
|
|
||||||
|
const updates: Array<{
|
||||||
|
deviceIdentifier: string;
|
||||||
|
keys: {
|
||||||
|
encryptedUserKey?: string | null;
|
||||||
|
encryptedPublicKey?: string | null;
|
||||||
|
encryptedPrivateKey?: string | null;
|
||||||
|
};
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
if (currentDeviceIdentifier && body?.currentDevice) {
|
||||||
|
updates.push({
|
||||||
|
deviceIdentifier: currentDeviceIdentifier,
|
||||||
|
keys: parseKeysBody(body.currentDevice, await storage.getDevice(userId, currentDeviceIdentifier) || undefined),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(body?.otherDevices)) {
|
||||||
|
for (const item of body.otherDevices) {
|
||||||
|
const deviceIdentifier = normalizeIdentifier(item?.deviceId);
|
||||||
|
if (!deviceIdentifier) continue;
|
||||||
|
updates.push({
|
||||||
|
deviceIdentifier,
|
||||||
|
keys: parseKeysBody(item, await storage.getDevice(userId, deviceIdentifier) || undefined),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let updatedCount = 0;
|
||||||
|
for (const update of updates) {
|
||||||
|
const ok = await storage.updateDeviceKeys(userId, update.deviceIdentifier, update.keys);
|
||||||
|
if (ok) updatedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({ success: true, updated: updatedCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/devices/untrust
|
||||||
|
export async function handleUntrustDevices(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const devices = Array.isArray(body?.devices) ? body.devices.map((id: unknown) => normalizeIdentifier(String(id))) : [];
|
||||||
|
const removed = await storage.clearDeviceKeys(userId, devices);
|
||||||
|
for (const deviceIdentifier of devices) {
|
||||||
|
if (!deviceIdentifier) continue;
|
||||||
|
await storage.deleteTrustedTwoFactorTokensByDevice(userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
return jsonResponse({ success: true, removed });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/devices/:deviceIdentifier/retrieve-keys
|
||||||
|
export async function handleRetrieveDeviceKeys(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const normalized = normalizeIdentifier(deviceIdentifier);
|
||||||
|
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const device = await storage.getDevice(userId, normalized);
|
||||||
|
if (!device) {
|
||||||
|
return errorResponse('Device not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(buildProtectedDeviceResponse(device));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/devices/:id/deactivate
|
||||||
|
export async function handleDeactivateDevice(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const normalized = normalizeIdentifier(deviceIdentifier);
|
||||||
|
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
||||||
|
await storage.deleteRefreshTokensByDevice(userId, normalized);
|
||||||
|
const deleted = await storage.deleteDevice(userId, normalized);
|
||||||
|
if (deleted) {
|
||||||
|
notifyUserLogout(env, userId, normalized);
|
||||||
|
}
|
||||||
|
return jsonResponse({ success: deleted });
|
||||||
|
}
|
||||||
|
|
||||||
// PUT /api/devices/identifier/{deviceIdentifier}/token
|
// PUT /api/devices/identifier/{deviceIdentifier}/token
|
||||||
// Bitwarden mobile reports push token updates to this endpoint.
|
// Bitwarden mobile reports push token updates to this endpoint.
|
||||||
// NodeWarden does not implement push notifications, so accept and no-op.
|
// NodeWarden does not implement push notifications, so accept and no-op.
|
||||||
@@ -182,3 +479,31 @@ export async function handleUpdateDeviceToken(
|
|||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PUT/POST /api/devices/:deviceIdentifier/web-push-auth
|
||||||
|
export async function handleUpdateDeviceWebPushAuth(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
void env;
|
||||||
|
void userId;
|
||||||
|
void deviceIdentifier;
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT/POST /api/devices/:deviceIdentifier/clear-token
|
||||||
|
export async function handleClearDeviceToken(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
void env;
|
||||||
|
void userId;
|
||||||
|
void deviceIdentifier;
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ 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';
|
||||||
|
|
||||||
async function notifyVaultSyncForRequest(
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert internal folder to API response format
|
// Convert internal folder to API response format
|
||||||
@@ -21,6 +21,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 +88,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 +116,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 +133,7 @@ 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);
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
@@ -155,7 +156,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
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 +32,88 @@ function resolveTotpSecret(userSecret: string | null): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldUseWebSession(request: Request): boolean {
|
||||||
|
return String(request.headers.get('X-NodeWarden-Web-Session') || '').trim() === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCookieValue(request: Request, name: string): string | null {
|
||||||
|
const rawCookie = String(request.headers.get('Cookie') || '').trim();
|
||||||
|
if (!rawCookie) return null;
|
||||||
|
for (const part of rawCookie.split(';')) {
|
||||||
|
const [key, ...rest] = part.trim().split('=');
|
||||||
|
if (key !== name) continue;
|
||||||
|
const value = rest.join('=').trim();
|
||||||
|
return value ? decodeURIComponent(value) : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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(
|
||||||
|
email: string,
|
||||||
|
kdfType: number,
|
||||||
|
kdfIterations: number,
|
||||||
|
kdfMemory: number | null,
|
||||||
|
kdfParallelism: number | null
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
kdf: kdfType,
|
||||||
|
kdfIterations,
|
||||||
|
kdfMemory,
|
||||||
|
kdfParallelism,
|
||||||
|
KdfSettings: {
|
||||||
|
KdfType: kdfType,
|
||||||
|
Iterations: kdfIterations,
|
||||||
|
Memory: kdfMemory,
|
||||||
|
Parallelism: kdfParallelism,
|
||||||
|
},
|
||||||
|
Salt: email.toLowerCase(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function twoFactorRequiredResponse(message: string = 'Two factor required.', includeRecoveryCode: boolean = false): Response {
|
function twoFactorRequiredResponse(message: string = 'Two factor required.', includeRecoveryCode: boolean = false): Response {
|
||||||
const providers = includeRecoveryCode
|
const providers = includeRecoveryCode
|
||||||
? [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR), TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE]
|
? [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR), TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE]
|
||||||
@@ -256,17 +339,19 @@ 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);
|
||||||
|
|
||||||
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,
|
||||||
@@ -279,11 +364,106 @@ 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);
|
||||||
|
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) {
|
||||||
|
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||||
|
return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist device only after successful client credential verification.
|
||||||
|
const deviceSession =
|
||||||
|
deviceInfo.deviceIdentifier
|
||||||
|
? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() }
|
||||||
|
: null;
|
||||||
|
if (deviceSession) {
|
||||||
|
await storage.upsertDevice(
|
||||||
|
user.id,
|
||||||
|
deviceSession.identifier,
|
||||||
|
deviceInfo.deviceName,
|
||||||
|
deviceInfo.deviceType,
|
||||||
|
deviceSession.sessionStamp
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Successful login - clear failed attempts
|
||||||
|
await rateLimit.clearLoginAttempts(loginIdentifier);
|
||||||
|
|
||||||
|
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
||||||
|
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
||||||
|
const accountKeys = buildAccountKeys(user);
|
||||||
|
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||||
|
|
||||||
|
const response: TokenResponse = {
|
||||||
|
access_token: accessToken,
|
||||||
|
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
||||||
|
token_type: 'Bearer',
|
||||||
|
...(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);
|
||||||
@@ -349,14 +529,21 @@ 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.refreshAccessToken(refreshToken);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return identityErrorResponse('Invalid refresh token', 'invalid_grant', 400);
|
const invalidResponse = identityErrorResponse('Invalid refresh token', 'invalid_grant', 400);
|
||||||
|
return shouldUseWebSession(request)
|
||||||
|
? withWebRefreshCookie(request, invalidResponse, null)
|
||||||
|
: invalidResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep a short overlap window for old refresh token to absorb
|
// Keep a short overlap window for old refresh token to absorb
|
||||||
@@ -367,17 +554,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,
|
||||||
@@ -390,11 +582,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);
|
||||||
@@ -426,12 +621,7 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
|
|||||||
const kdfMemory = user?.kdfMemory ?? null;
|
const kdfMemory = user?.kdfMemory ?? null;
|
||||||
const kdfParallelism = user?.kdfParallelism ?? null;
|
const kdfParallelism = user?.kdfParallelism ?? null;
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse(buildPreloginResponse(email, kdfType, kdfIterations, kdfMemory, kdfParallelism));
|
||||||
kdf: kdfType,
|
|
||||||
kdfIterations: kdfIterations,
|
|
||||||
kdfMemory: kdfMemory,
|
|
||||||
kdfParallelism: kdfParallelism,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /identity/connect/revocation
|
// POST /identity/connect/revocation
|
||||||
@@ -453,10 +643,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ interface CiphersImportRequest {
|
|||||||
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,66 +188,67 @@ 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: 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,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
};
|
};
|
||||||
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||||
@@ -245,10 +262,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
const data = JSON.stringify(cipher);
|
const data = JSON.stringify(cipher);
|
||||||
return env.DB
|
return env.DB
|
||||||
.prepare(
|
.prepare(
|
||||||
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||||
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at'
|
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at'
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
cipher.id,
|
cipher.id,
|
||||||
@@ -263,6 +280,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
bindNull(cipher.key),
|
bindNull(cipher.key),
|
||||||
cipher.createdAt,
|
cipher.createdAt,
|
||||||
cipher.updatedAt,
|
cipher.updatedAt,
|
||||||
|
bindNull(cipher.archivedAt),
|
||||||
bindNull(cipher.deletedAt)
|
bindNull(cipher.deletedAt)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -271,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({
|
||||||
|
|||||||
@@ -76,7 +76,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 +97,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 +226,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 +349,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,7 +596,7 @@ 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));
|
||||||
}
|
}
|
||||||
@@ -618,7 +619,7 @@ 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);
|
||||||
|
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
@@ -649,7 +650,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
@@ -667,7 +668,7 @@ 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);
|
||||||
|
|
||||||
return jsonResponse(sendToResponse(send));
|
return jsonResponse(sendToResponse(send));
|
||||||
}
|
}
|
||||||
@@ -685,7 +686,7 @@ 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);
|
||||||
|
|
||||||
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 {
|
||||||
@@ -10,87 +10,23 @@ import {
|
|||||||
buildUserDecryptionOptions,
|
buildUserDecryptionOptions,
|
||||||
} from '../utils/user-decryption';
|
} from '../utils/user-decryption';
|
||||||
|
|
||||||
interface SyncCacheEntry {
|
function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean, excludeSends: boolean): Request {
|
||||||
userId: string;
|
const url = new URL(request.url);
|
||||||
revisionDate: string;
|
const cacheUrl = new URL(
|
||||||
body: string;
|
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${excludeDomains ? '1' : '0'}/${excludeSends ? '1' : '0'}`,
|
||||||
expiresAt: number;
|
url.origin
|
||||||
bytes: number;
|
);
|
||||||
|
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 +35,30 @@ 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] = 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),
|
||||||
|
]);
|
||||||
|
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 +72,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: [],
|
||||||
@@ -148,26 +80,32 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
forcePasswordReset: false,
|
forcePasswordReset: false,
|
||||||
avatarColor: null,
|
avatarColor: null,
|
||||||
creationDate: user.createdAt,
|
creationDate: user.createdAt,
|
||||||
|
verifyDevices: user.verifyDevices,
|
||||||
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,
|
||||||
@@ -179,19 +117,25 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
object: 'domains',
|
object: 'domains',
|
||||||
},
|
},
|
||||||
policies: [],
|
policies: [],
|
||||||
sends: sends.map(sendToResponse),
|
sends: sendResponses,
|
||||||
// PascalCase for desktop/browser clients
|
UserDecryption: {
|
||||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock,
|
||||||
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
TrustedDeviceOption: null,
|
||||||
|
KeyConnectorOption: null,
|
||||||
|
Object: 'userDecryption',
|
||||||
|
},
|
||||||
|
UserDecryptionOptions: userDecryptionOptions,
|
||||||
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/') ||
|
||||||
@@ -56,9 +65,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 +86,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,9 +7,12 @@ import {
|
|||||||
handleGetRevisionDate,
|
handleGetRevisionDate,
|
||||||
handleVerifyPassword,
|
handleVerifyPassword,
|
||||||
handleChangePassword,
|
handleChangePassword,
|
||||||
|
handleSetVerifyDevices,
|
||||||
handleGetTotpStatus,
|
handleGetTotpStatus,
|
||||||
handleSetTotpStatus,
|
handleSetTotpStatus,
|
||||||
handleGetTotpRecoveryCode,
|
handleGetTotpRecoveryCode,
|
||||||
|
handleGetApiKey,
|
||||||
|
handleRotateApiKey,
|
||||||
} from './handlers/accounts';
|
} from './handlers/accounts';
|
||||||
import {
|
import {
|
||||||
handleGetCiphers,
|
handleGetCiphers,
|
||||||
@@ -20,11 +23,15 @@ import {
|
|||||||
handleDeleteCipherCompat,
|
handleDeleteCipherCompat,
|
||||||
handlePermanentDeleteCipher,
|
handlePermanentDeleteCipher,
|
||||||
handleRestoreCipher,
|
handleRestoreCipher,
|
||||||
|
handleBulkArchiveCiphers,
|
||||||
handlePartialUpdateCipher,
|
handlePartialUpdateCipher,
|
||||||
|
handleBulkUnarchiveCiphers,
|
||||||
handleBulkMoveCiphers,
|
handleBulkMoveCiphers,
|
||||||
handleBulkDeleteCiphers,
|
handleBulkDeleteCiphers,
|
||||||
handleBulkPermanentDeleteCiphers,
|
handleBulkPermanentDeleteCiphers,
|
||||||
handleBulkRestoreCiphers,
|
handleBulkRestoreCiphers,
|
||||||
|
handleArchiveCipher,
|
||||||
|
handleUnarchiveCipher,
|
||||||
} from './handlers/ciphers';
|
} from './handlers/ciphers';
|
||||||
import {
|
import {
|
||||||
handleGetFolders,
|
handleGetFolders,
|
||||||
@@ -53,6 +60,7 @@ 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';
|
||||||
@@ -110,6 +118,18 @@ export async function handleAuthenticatedRoute(
|
|||||||
return handleVerifyPassword(request, env, userId);
|
return handleVerifyPassword(request, env, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === '/api/accounts/verify-devices' && (method === 'PUT' || method === 'POST')) {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
@@ -140,6 +160,14 @@ export async function handleAuthenticatedRoute(
|
|||||||
return handleBulkRestoreCiphers(request, env, userId);
|
return handleBulkRestoreCiphers(request, env, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === '/api/ciphers/archive' && (method === 'PUT' || method === 'POST')) {
|
||||||
|
return handleBulkArchiveCiphers(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/ciphers/unarchive' && (method === 'PUT' || method === 'POST')) {
|
||||||
|
return handleBulkUnarchiveCiphers(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
if (path === '/api/ciphers/move' && (method === 'POST' || method === 'PUT')) {
|
if (path === '/api/ciphers/move' && (method === 'POST' || method === 'PUT')) {
|
||||||
return handleBulkMoveCiphers(request, env, userId);
|
return handleBulkMoveCiphers(request, env, userId);
|
||||||
}
|
}
|
||||||
@@ -158,6 +186,8 @@ export async function handleAuthenticatedRoute(
|
|||||||
if (subPath === '/delete' && method === 'PUT') return handleDeleteCipher(request, env, userId, cipherId);
|
if (subPath === '/delete' && method === 'PUT') return handleDeleteCipher(request, env, userId, cipherId);
|
||||||
if (subPath === '/delete' && method === 'DELETE') return handlePermanentDeleteCipher(request, env, userId, cipherId);
|
if (subPath === '/delete' && method === 'DELETE') return handlePermanentDeleteCipher(request, env, userId, cipherId);
|
||||||
if (subPath === '/restore' && method === 'PUT') return handleRestoreCipher(request, env, userId, cipherId);
|
if (subPath === '/restore' && method === 'PUT') return handleRestoreCipher(request, env, userId, cipherId);
|
||||||
|
if (subPath === '/archive' && (method === 'PUT' || method === 'POST')) return handleArchiveCipher(request, env, userId, cipherId);
|
||||||
|
if (subPath === '/unarchive' && (method === 'PUT' || method === 'POST')) return handleUnarchiveCipher(request, env, userId, cipherId);
|
||||||
if (subPath === '/partial' && (method === 'PUT' || method === 'POST')) return handlePartialUpdateCipher(request, env, userId, cipherId);
|
if (subPath === '/partial' && (method === 'PUT' || method === 'POST')) return handlePartialUpdateCipher(request, env, userId, cipherId);
|
||||||
if (subPath === '/share' && method === 'POST') return handleGetCipher(request, env, userId, cipherId);
|
if (subPath === '/share' && method === 'POST') return handleGetCipher(request, env, userId, cipherId);
|
||||||
if (subPath === '/details' && method === 'GET') return handleGetCipher(request, env, userId, cipherId);
|
if (subPath === '/details' && method === 'GET') return handleGetCipher(request, env, userId, cipherId);
|
||||||
@@ -172,6 +202,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]);
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import type { Env } from './types';
|
import type { Env } from './types';
|
||||||
import {
|
import {
|
||||||
handleGetAuthorizedDevices,
|
handleGetAuthorizedDevices,
|
||||||
|
handleGetDevice,
|
||||||
handleGetDevices,
|
handleGetDevices,
|
||||||
|
handleGetDeviceByIdentifier,
|
||||||
|
handleUpdateDeviceKeys,
|
||||||
|
handleUpdateDeviceTrust,
|
||||||
|
handleUntrustDevices,
|
||||||
|
handleRetrieveDeviceKeys,
|
||||||
|
handleDeactivateDevice,
|
||||||
handleRevokeAllTrustedDevices,
|
handleRevokeAllTrustedDevices,
|
||||||
handleRevokeTrustedDevice,
|
handleRevokeTrustedDevice,
|
||||||
handleDeleteAllDevices,
|
handleDeleteAllDevices,
|
||||||
handleDeleteDevice,
|
handleDeleteDevice,
|
||||||
|
handleUpdateDeviceName,
|
||||||
handleUpdateDeviceToken,
|
handleUpdateDeviceToken,
|
||||||
|
handleUpdateDeviceWebPushAuth,
|
||||||
|
handleClearDeviceToken,
|
||||||
} from './handlers/devices';
|
} from './handlers/devices';
|
||||||
|
|
||||||
export async function handleAuthenticatedDeviceRoute(
|
export async function handleAuthenticatedDeviceRoute(
|
||||||
@@ -35,16 +45,70 @@ export async function handleAuthenticatedDeviceRoute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i);
|
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i);
|
||||||
|
if (deleteDeviceMatch && method === 'GET') {
|
||||||
|
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
|
||||||
|
return handleGetDevice(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
if (deleteDeviceMatch && method === 'DELETE') {
|
if (deleteDeviceMatch && method === 'DELETE') {
|
||||||
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
|
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
|
||||||
return handleDeleteDevice(request, env, userId, deviceIdentifier);
|
return handleDeleteDevice(request, env, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
const deviceTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/token$/i);
|
const updateDeviceNameMatch = path.match(/^\/api\/devices\/([^/]+)\/name$/i);
|
||||||
if (deviceTokenMatch && (method === 'PUT' || method === 'POST')) {
|
if (updateDeviceNameMatch && method === 'PUT') {
|
||||||
const deviceIdentifier = decodeURIComponent(deviceTokenMatch[1]);
|
const deviceIdentifier = decodeURIComponent(updateDeviceNameMatch[1]);
|
||||||
|
return handleUpdateDeviceName(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i);
|
||||||
|
if (identifierMatch && method === 'GET') {
|
||||||
|
const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
|
||||||
|
return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/keys$/i) || path.match(/^\/api\/devices\/identifier\/([^/]+)\/keys$/i);
|
||||||
|
if (deviceKeysMatch && (method === 'PUT' || method === 'POST')) {
|
||||||
|
const deviceIdentifier = decodeURIComponent(deviceKeysMatch[1]);
|
||||||
|
return handleUpdateDeviceKeys(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifierTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/token$/i);
|
||||||
|
if (identifierTokenMatch && (method === 'PUT' || method === 'POST')) {
|
||||||
|
const deviceIdentifier = decodeURIComponent(identifierTokenMatch[1]);
|
||||||
return handleUpdateDeviceToken(request, env, userId, deviceIdentifier);
|
return handleUpdateDeviceToken(request, env, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const identifierWebPushMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/web-push-auth$/i);
|
||||||
|
if (identifierWebPushMatch && (method === 'PUT' || method === 'POST')) {
|
||||||
|
const deviceIdentifier = decodeURIComponent(identifierWebPushMatch[1]);
|
||||||
|
return handleUpdateDeviceWebPushAuth(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifierClearTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/clear-token$/i);
|
||||||
|
if (identifierClearTokenMatch && (method === 'PUT' || method === 'POST')) {
|
||||||
|
const deviceIdentifier = decodeURIComponent(identifierClearTokenMatch[1]);
|
||||||
|
return handleClearDeviceToken(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifierRetrieveKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/retrieve-keys$/i);
|
||||||
|
if (identifierRetrieveKeysMatch && method === 'POST') {
|
||||||
|
const deviceIdentifier = decodeURIComponent(identifierRetrieveKeysMatch[1]);
|
||||||
|
return handleRetrieveDeviceKeys(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifierDeactivateMatch = path.match(/^\/api\/devices\/([^/]+)\/deactivate$/i);
|
||||||
|
if (identifierDeactivateMatch && (method === 'POST' || method === 'DELETE')) {
|
||||||
|
const deviceIdentifier = decodeURIComponent(identifierDeactivateMatch[1]);
|
||||||
|
return handleDeactivateDevice(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/devices/update-trust' && method === 'POST') {
|
||||||
|
return handleUpdateDeviceTrust(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/devices/untrust' && method === 'POST') {
|
||||||
|
return handleUntrustDevices(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,16 +52,25 @@ 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',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -78,8 +87,51 @@ function buildIconServiceCsp(origin: string): string {
|
|||||||
return `img-src 'self' data: ${origin}`;
|
return `img-src 'self' data: ${origin}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildConfigResponse(origin: string) {
|
||||||
|
return {
|
||||||
|
version: LIMITS.compatibility.bitwardenServerVersion,
|
||||||
|
gitHash: 'nodewarden',
|
||||||
|
server: null,
|
||||||
|
environment: {
|
||||||
|
cloudRegion: 'self-hosted',
|
||||||
|
vault: origin,
|
||||||
|
api: origin + '/api',
|
||||||
|
identity: origin + '/identity',
|
||||||
|
notifications: origin + '/notifications',
|
||||||
|
icons: origin,
|
||||||
|
sso: '',
|
||||||
|
fillAssistRules: null,
|
||||||
|
},
|
||||||
|
push: {
|
||||||
|
pushTechnology: 0,
|
||||||
|
vapidPublicKey: null,
|
||||||
|
},
|
||||||
|
communication: null,
|
||||||
|
settings: {
|
||||||
|
disableUserRegistration: false,
|
||||||
|
},
|
||||||
|
_icon_service_url: buildIconServiceTemplate(origin),
|
||||||
|
_icon_service_csp: buildIconServiceCsp(origin),
|
||||||
|
featureStates: {
|
||||||
|
'cipher-key-encryption': true,
|
||||||
|
'duo-redirect': true,
|
||||||
|
'email-verification': true,
|
||||||
|
'pm-19051-send-email-verification': false,
|
||||||
|
'pm-19148-innovation-archive': true,
|
||||||
|
'unauth-ui-refresh': true,
|
||||||
|
'web-push': false,
|
||||||
|
},
|
||||||
|
object: 'config',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeIconHost(rawHost: string): string | null {
|
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}`);
|
||||||
@@ -89,9 +141,9 @@ function normalizeIconHost(rawHost: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleWebsiteIcon(host: string): Promise<Response> {
|
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' };
|
||||||
@@ -129,14 +181,14 @@ async function handleWebsiteIcon(host: string): Promise<Response> {
|
|||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
|
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
|
||||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
|
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return handleNwFavicon();
|
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||||
} catch {
|
} catch {
|
||||||
return handleNwFavicon();
|
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +235,8 @@ export async function handlePublicRoute(
|
|||||||
|
|
||||||
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);
|
||||||
@@ -243,6 +296,11 @@ export async function handlePublicRoute(
|
|||||||
return handleKnownDevice(request, env);
|
return handleKnownDevice(request, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearDeviceTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/clear-token$/i);
|
||||||
|
if (clearDeviceTokenMatch && (method === 'PUT' || method === 'POST')) {
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
if ((path === '/identity/connect/revocation' || path === '/identity/connect/revoke') && method === 'POST') {
|
if ((path === '/identity/connect/revocation' || path === '/identity/connect/revoke') && method === 'POST') {
|
||||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||||
if (blocked) return blocked;
|
if (blocked) return blocked;
|
||||||
@@ -255,6 +313,12 @@ export async function handlePublicRoute(
|
|||||||
return handlePrelogin(request, env);
|
return handlePrelogin(request, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === '/identity/accounts/prelogin/password' && method === 'POST') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handlePrelogin(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
|
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
|
||||||
return handleRecoverTwoFactor(request, env);
|
return handleRecoverTwoFactor(request, env);
|
||||||
}
|
}
|
||||||
@@ -275,28 +339,7 @@ export async function handlePublicRoute(
|
|||||||
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;
|
||||||
const origin = new URL(request.url).origin;
|
const origin = new URL(request.url).origin;
|
||||||
return jsonResponse({
|
return jsonResponse(buildConfigResponse(origin));
|
||||||
version: LIMITS.compatibility.bitwardenServerVersion,
|
|
||||||
gitHash: 'nodewarden',
|
|
||||||
server: null,
|
|
||||||
environment: {
|
|
||||||
vault: origin,
|
|
||||||
api: origin + '/api',
|
|
||||||
identity: origin + '/identity',
|
|
||||||
notifications: origin + '/notifications',
|
|
||||||
icons: origin,
|
|
||||||
sso: '',
|
|
||||||
},
|
|
||||||
_icon_service_url: buildIconServiceTemplate(origin),
|
|
||||||
_icon_service_csp: buildIconServiceCsp(origin),
|
|
||||||
featureStates: {
|
|
||||||
'duo-redirect': true,
|
|
||||||
'email-verification': true,
|
|
||||||
'pm-19051-send-email-verification': false,
|
|
||||||
'unauth-ui-refresh': true,
|
|
||||||
},
|
|
||||||
object: 'config',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/version' && method === 'GET') {
|
if (path === '/api/version' && method === 'GET') {
|
||||||
|
|||||||
@@ -6,6 +6,17 @@ 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;
|
||||||
@@ -14,11 +25,65 @@ export interface VerifiedAccessContext {
|
|||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
|
||||||
// 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 +162,16 @@ 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);
|
const user = await this.getCachedUser(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);
|
const device = await this.getCachedDevice(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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
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_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;
|
||||||
@@ -60,25 +61,89 @@ 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 {
|
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
||||||
const parts = [
|
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||||
date.getUTCFullYear().toString().padStart(4, '0'),
|
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
(date.getUTCMonth() + 1).toString().padStart(2, '0'),
|
}
|
||||||
date.getUTCDate().toString().padStart(2, '0'),
|
|
||||||
date.getUTCHours().toString().padStart(2, '0'),
|
function getDateParts(date: Date, timeZone: string): string {
|
||||||
date.getUTCMinutes().toString().padStart(2, '0'),
|
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||||
date.getUTCSeconds().toString().padStart(2, '0'),
|
timeZone,
|
||||||
];
|
year: 'numeric',
|
||||||
return `nodewarden_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}.zip`;
|
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 {
|
||||||
@@ -269,16 +334,25 @@ 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, 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, api_key, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
||||||
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
|
queryRows(env.DB, 'SELECT 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 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();
|
||||||
@@ -327,9 +401,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,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from './backup-settings-crypto';
|
} from './backup-settings-crypto';
|
||||||
import {
|
import {
|
||||||
BACKUP_DEFAULT_INTERVAL_HOURS,
|
BACKUP_DEFAULT_INTERVAL_HOURS,
|
||||||
|
BACKUP_DEFAULT_START_TIME,
|
||||||
BACKUP_DEFAULT_TIMEZONE,
|
BACKUP_DEFAULT_TIMEZONE,
|
||||||
type BackupDestinationConfig,
|
type BackupDestinationConfig,
|
||||||
type BackupDestinationRecord,
|
type BackupDestinationRecord,
|
||||||
@@ -15,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,
|
||||||
@@ -34,7 +35,7 @@ export type {
|
|||||||
BackupRuntimeState,
|
BackupRuntimeState,
|
||||||
BackupScheduleConfig,
|
BackupScheduleConfig,
|
||||||
BackupSettings,
|
BackupSettings,
|
||||||
E3BackupDestination,
|
S3BackupDestination,
|
||||||
WebDavBackupDestination,
|
WebDavBackupDestination,
|
||||||
} from '../../shared/backup-schema';
|
} from '../../shared/backup-schema';
|
||||||
|
|
||||||
@@ -90,7 +91,21 @@ function normalizeIntervalHours(value: unknown, fallback: number = BACKUP_DEFAUL
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeE3Destination(value: unknown, allowIncomplete = false): E3BackupDestination {
|
function normalizeStartTime(value: unknown, fallback: string = BACKUP_DEFAULT_START_TIME): string {
|
||||||
|
const raw = asTrimmedString(value) || fallback;
|
||||||
|
const match = raw.match(/^(\d{1,2})(?::(\d{1,2}))?$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error('Backup start time must be in HH:mm format');
|
||||||
|
}
|
||||||
|
const hour = Number(match[1]);
|
||||||
|
const minute = Number(match[2] ?? '0');
|
||||||
|
if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||||
|
throw new Error('Backup start time must be in HH:mm format');
|
||||||
|
}
|
||||||
|
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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);
|
||||||
@@ -100,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 {
|
||||||
@@ -154,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,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');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +235,10 @@ function normalizeDestinationRecord(
|
|||||||
scheduleSource.intervalHours ?? previousSchedule.intervalHours,
|
scheduleSource.intervalHours ?? previousSchedule.intervalHours,
|
||||||
previousSchedule.intervalHours || BACKUP_DEFAULT_INTERVAL_HOURS
|
previousSchedule.intervalHours || BACKUP_DEFAULT_INTERVAL_HOURS
|
||||||
),
|
),
|
||||||
|
startTime: normalizeStartTime(
|
||||||
|
scheduleSource.startTime ?? previousSchedule.startTime,
|
||||||
|
previousSchedule.startTime || BACKUP_DEFAULT_START_TIME
|
||||||
|
),
|
||||||
timezone: assertValidTimeZone(asTrimmedString(scheduleSource.timezone ?? previousSchedule.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
|
timezone: assertValidTimeZone(asTrimmedString(scheduleSource.timezone ?? previousSchedule.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
|
||||||
retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount),
|
retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount),
|
||||||
};
|
};
|
||||||
@@ -247,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(),
|
||||||
@@ -259,6 +279,7 @@ function parseLegacyBackupSettings(rawValue: Record<string, unknown>, fallbackTi
|
|||||||
schedule: {
|
schedule: {
|
||||||
enabled: !!rawValue.enabled,
|
enabled: !!rawValue.enabled,
|
||||||
intervalHours,
|
intervalHours,
|
||||||
|
startTime: BACKUP_DEFAULT_START_TIME,
|
||||||
timezone: assertValidTimeZone(asTrimmedString(rawValue.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
|
timezone: assertValidTimeZone(asTrimmedString(rawValue.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
|
||||||
retentionCount: 30,
|
retentionCount: 30,
|
||||||
},
|
},
|
||||||
@@ -326,6 +347,7 @@ export function parseBackupSettings(raw: string | null, fallbackTimezone: string
|
|||||||
schedule: {
|
schedule: {
|
||||||
enabled: scheduleEnabled,
|
enabled: scheduleEnabled,
|
||||||
intervalHours: globalIntervalHours,
|
intervalHours: globalIntervalHours,
|
||||||
|
startTime: BACKUP_DEFAULT_START_TIME,
|
||||||
timezone: globalTimezone,
|
timezone: globalTimezone,
|
||||||
retentionCount: 30,
|
retentionCount: 30,
|
||||||
},
|
},
|
||||||
@@ -401,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> {
|
||||||
@@ -495,15 +542,131 @@ export function getBackupLocalTime(date: Date, timezone: string): string {
|
|||||||
return `${parts.hour}:${parts.minute}`;
|
return `${parts.hour}:${parts.minute}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseLocalDateKey(dateKey: string): { year: number; month: number; day: number } | null {
|
||||||
|
const match = String(dateKey || '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const year = Number(match[1]);
|
||||||
|
const month = Number(match[2]);
|
||||||
|
const day = Number(match[3]);
|
||||||
|
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return null;
|
||||||
|
return { year, month, day };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUtcDateForLocalTime(timezone: string, year: number, month: number, day: number, hour: number, minute: number): Date {
|
||||||
|
const utcGuess = Date.UTC(year, month - 1, day, hour, minute, 0, 0);
|
||||||
|
const actual = getDateTimeParts(new Date(utcGuess), timezone);
|
||||||
|
const actualUtc = Date.UTC(
|
||||||
|
Number(actual.year),
|
||||||
|
Number(actual.month) - 1,
|
||||||
|
Number(actual.day),
|
||||||
|
Number(actual.hour),
|
||||||
|
Number(actual.minute),
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const desiredUtc = Date.UTC(year, month - 1, day, hour, minute, 0, 0);
|
||||||
|
return new Date(utcGuess - (actualUtc - desiredUtc));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackupSlotStartsForLocalDay(
|
||||||
|
dateKey: string,
|
||||||
|
timezone: string,
|
||||||
|
startTime: string,
|
||||||
|
intervalHours: number
|
||||||
|
): Date[] {
|
||||||
|
const parsedDate = parseLocalDateKey(dateKey);
|
||||||
|
const parsedTime = normalizeStartTime(startTime).split(':').map((value) => Number(value));
|
||||||
|
if (!parsedDate || parsedTime.length !== 2) return [];
|
||||||
|
|
||||||
|
const [hour, minute] = parsedTime;
|
||||||
|
const firstSlot = getUtcDateForLocalTime(timezone, parsedDate.year, parsedDate.month, parsedDate.day, hour, minute);
|
||||||
|
const nextLocalDay = new Date(Date.UTC(parsedDate.year, parsedDate.month - 1, parsedDate.day, 0, 0, 0, 0));
|
||||||
|
nextLocalDay.setUTCDate(nextLocalDay.getUTCDate() + 1);
|
||||||
|
const nextDay = getUtcDateForLocalTime(
|
||||||
|
timezone,
|
||||||
|
nextLocalDay.getUTCFullYear(),
|
||||||
|
nextLocalDay.getUTCMonth() + 1,
|
||||||
|
nextLocalDay.getUTCDate(),
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const intervalMs = intervalHours * 60 * 60 * 1000;
|
||||||
|
const slots: Date[] = [];
|
||||||
|
|
||||||
|
for (let slotMs = firstSlot.getTime(); slotMs < nextDay.getTime(); slotMs += intervalMs) {
|
||||||
|
slots.push(new Date(slotMs));
|
||||||
|
}
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function 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,
|
||||||
windowMinutes: number = BACKUP_SCHEDULER_WINDOW_MINUTES
|
windowMinutes: number = BACKUP_SCHEDULER_WINDOW_MINUTES
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!destination.schedule.enabled) return false;
|
if (!destination.schedule.enabled) return false;
|
||||||
const intervalMs = destination.schedule.intervalHours * 60 * 60 * 1000;
|
|
||||||
const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000;
|
const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000;
|
||||||
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
|
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
|
||||||
if (!lastAttemptAt || !Number.isFinite(lastAttemptAt.getTime())) return true;
|
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime())
|
||||||
return now.getTime() - lastAttemptAt.getTime() >= Math.max(0, intervalMs - toleranceMs);
|
? lastAttemptAt.getTime()
|
||||||
|
: Number.NEGATIVE_INFINITY;
|
||||||
|
const localDateKey = getBackupLocalDateKey(now, destination.schedule.timezone);
|
||||||
|
const slotStarts = getBackupSlotStartsForLocalDay(
|
||||||
|
localDateKey,
|
||||||
|
destination.schedule.timezone,
|
||||||
|
destination.schedule.startTime,
|
||||||
|
destination.schedule.intervalHours
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const slotStart of slotStarts) {
|
||||||
|
const slotStartMs = slotStart.getTime();
|
||||||
|
if (now.getTime() < slotStartMs || now.getTime() >= slotStartMs + toleranceMs) continue;
|
||||||
|
if (lastAttemptMs >= slotStartMs) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -10,6 +9,26 @@ import {
|
|||||||
} from './backup-archive';
|
} from './backup-archive';
|
||||||
|
|
||||||
type SqlRow = Record<string, string | number | null>;
|
type SqlRow = Record<string, string | number | null>;
|
||||||
|
type BackupTableName =
|
||||||
|
| 'config'
|
||||||
|
| 'users'
|
||||||
|
| 'user_revisions'
|
||||||
|
| 'folders'
|
||||||
|
| 'ciphers'
|
||||||
|
| 'attachments';
|
||||||
|
|
||||||
|
const BACKUP_TABLES: BackupTableName[] = [
|
||||||
|
'config',
|
||||||
|
'users',
|
||||||
|
'user_revisions',
|
||||||
|
'folders',
|
||||||
|
'ciphers',
|
||||||
|
'attachments',
|
||||||
|
];
|
||||||
|
|
||||||
|
function shadowTableName(table: BackupTableName): string {
|
||||||
|
return `${table}__restore`;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BackupImportResultBody {
|
export interface BackupImportResultBody {
|
||||||
object: 'instance-backup-import';
|
object: 'instance-backup-import';
|
||||||
@@ -43,6 +62,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 +155,9 @@ 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 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 +204,90 @@ 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,
|
||||||
|
})),
|
||||||
|
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 +312,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files:
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
const result = {
|
||||||
payload: {
|
payload: {
|
||||||
...payload,
|
...payload,
|
||||||
db: {
|
db: {
|
||||||
@@ -161,6 +326,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 +363,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 +371,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 +381,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 +477,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 +494,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 +581,58 @@ 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', 'api_key', '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('folders'),
|
||||||
|
buildInsertStatements(db, tableName('folders'), ['id', 'user_id', 'name', 'created_at', 'updated_at'], payload.folders || [])
|
||||||
|
);
|
||||||
|
await runInsertBatch(
|
||||||
|
db,
|
||||||
|
tableName('ciphers'),
|
||||||
|
buildInsertStatements(
|
||||||
db,
|
db,
|
||||||
'ciphers',
|
tableName('ciphers'),
|
||||||
['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'deleted_at'],
|
['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 +645,118 @@ 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,
|
||||||
|
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,
|
||||||
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
folders: (db.folders || []).length,
|
||||||
|
ciphers: (db.ciphers || []).length,
|
||||||
|
attachments: restored.restoredAttachments.length,
|
||||||
|
});
|
||||||
|
await progress?.({
|
||||||
|
source: 'local',
|
||||||
|
step: 'local_finalize',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
await swapShadowTablesIntoPlace(env.DB);
|
||||||
|
await resetRestoreArtifacts(env.DB).catch(() => undefined);
|
||||||
|
if (replaceExisting && previousBlobKeys.size) {
|
||||||
|
const nextBlobKeys = await collectCurrentBlobKeys(env.DB).catch(() => null);
|
||||||
|
if (nextBlobKeys) {
|
||||||
|
await cleanupOrphanedBlobFiles(env, previousBlobKeys, nextBlobKeys).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await progress?.({
|
||||||
|
source: 'local',
|
||||||
|
step: 'local_complete',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
|
||||||
|
replaceExisting,
|
||||||
|
done: true,
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
auditActorUserId: (db.users || []).some((row) => String(row.id || '').trim() === actorUserId) ? actorUserId : null,
|
||||||
|
result: {
|
||||||
|
object: 'instance-backup-import',
|
||||||
|
imported: {
|
||||||
|
config: (db.config || []).length,
|
||||||
|
users: (db.users || []).length,
|
||||||
|
userRevisions: (db.user_revisions || []).length,
|
||||||
|
folders: (db.folders || []).length,
|
||||||
|
ciphers: (db.ciphers || []).length,
|
||||||
|
attachments: restored.restoredAttachments.length,
|
||||||
|
attachmentFiles: restored.imported,
|
||||||
|
},
|
||||||
|
skipped: {
|
||||||
|
reason: restored.skipped.reason || prepared.skipped.reason,
|
||||||
|
attachments: prepared.skipped.attachments + restored.skipped.attachments,
|
||||||
|
items: [...prepared.skipped.items, ...restored.skipped.items],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await progress?.({
|
||||||
|
source: 'local',
|
||||||
|
step: 'local_failed',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
|
||||||
|
replaceExisting,
|
||||||
|
done: true,
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
await resetRestoreArtifacts(env.DB).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
await storage.setRegistered();
|
|
||||||
|
|
||||||
return {
|
|
||||||
auditActorUserId: (db.users || []).some((row) => String(row.id || '').trim() === actorUserId) ? actorUserId : null,
|
|
||||||
result: {
|
|
||||||
object: 'instance-backup-import',
|
|
||||||
imported: {
|
|
||||||
config: (db.config || []).length,
|
|
||||||
users: (db.users || []).length,
|
|
||||||
userRevisions: (db.user_revisions || []).length,
|
|
||||||
folders: (db.folders || []).length,
|
|
||||||
ciphers: (db.ciphers || []).length,
|
|
||||||
attachments: restored.restoredAttachments.length,
|
|
||||||
attachmentFiles: restored.imported,
|
|
||||||
},
|
|
||||||
skipped: {
|
|
||||||
reason: restored.skipped.reason || prepared.skipped.reason,
|
|
||||||
attachments: prepared.skipped.attachments + restored.skipped.attachments,
|
|
||||||
items: [...prepared.skipped.items, ...restored.skipped.items],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importRemoteBackupArchiveBytes(
|
export async function importRemoteBackupArchiveBytes(
|
||||||
@@ -489,9 +764,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 +780,122 @@ 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,
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
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,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 {
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -17,19 +23,58 @@ interface CipherRow {
|
|||||||
key: string | null;
|
key: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
archived_at: string | null;
|
||||||
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',
|
||||||
|
'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,
|
||||||
@@ -37,6 +82,7 @@ function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
|
|||||||
key: row.key ?? parsed.key ?? null,
|
key: row.key ?? parsed.key ?? null,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
|
archivedAt: row.archived_at ?? parsed.archivedAt ?? parsed.archivedDate ?? null,
|
||||||
deletedAt: row.deleted_at ?? null,
|
deletedAt: row.deleted_at ?? null,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
@@ -46,7 +92,7 @@ function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectCipherColumns(): string {
|
function selectCipherColumns(): string {
|
||||||
return 'id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at';
|
return 'id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at';
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCipher(db: D1Database, id: string): Promise<Cipher | null> {
|
export async function getCipher(db: D1Database, id: string): Promise<Cipher | null> {
|
||||||
@@ -58,19 +104,20 @@ 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, 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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||||
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at'
|
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at'
|
||||||
);
|
);
|
||||||
await safeBind(
|
await safeBind(
|
||||||
stmt,
|
stmt,
|
||||||
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,
|
||||||
@@ -79,10 +126,15 @@ export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cip
|
|||||||
cipher.key,
|
cipher.key,
|
||||||
cipher.createdAt,
|
cipher.createdAt,
|
||||||
cipher.updatedAt,
|
cipher.updatedAt,
|
||||||
|
cipher.archivedAt ?? null,
|
||||||
cipher.deletedAt
|
cipher.deletedAt
|
||||||
).run();
|
).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeIds(ids: string[]): string[] {
|
||||||
|
return Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteCipher(db: D1Database, id: string, userId: string): Promise<void> {
|
export async function deleteCipher(db: D1Database, id: string, userId: string): Promise<void> {
|
||||||
await db.prepare('DELETE FROM ciphers WHERE id = ? AND user_id = ?').bind(id, userId).run();
|
await db.prepare('DELETE FROM ciphers WHERE id = ? AND user_id = ?').bind(id, userId).run();
|
||||||
}
|
}
|
||||||
@@ -95,12 +147,11 @@ export async function bulkSoftDeleteCiphers(
|
|||||||
userId: string
|
userId: string
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
if (ids.length === 0) return null;
|
if (ids.length === 0) return null;
|
||||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
const uniqueIds = sanitizeIds(ids);
|
||||||
if (!uniqueIds.length) return null;
|
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);
|
||||||
@@ -108,10 +159,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,12 +178,11 @@ export async function bulkRestoreCiphers(
|
|||||||
userId: string
|
userId: string
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
if (ids.length === 0) return null;
|
if (ids.length === 0) return null;
|
||||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
const uniqueIds = sanitizeIds(ids);
|
||||||
if (!uniqueIds.length) return null;
|
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);
|
||||||
@@ -139,10 +190,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +209,7 @@ export async function bulkDeleteCiphers(
|
|||||||
userId: string
|
userId: string
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
if (ids.length === 0) return null;
|
if (ids.length === 0) return null;
|
||||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
const uniqueIds = sanitizeIds(ids);
|
||||||
if (!uniqueIds.length) return null;
|
if (!uniqueIds.length) return null;
|
||||||
|
|
||||||
const chunkSize = sqlChunkSize(1);
|
const chunkSize = sqlChunkSize(1);
|
||||||
@@ -212,7 +264,7 @@ export async function getCiphersByIds(
|
|||||||
userId: string
|
userId: string
|
||||||
): Promise<Cipher[]> {
|
): Promise<Cipher[]> {
|
||||||
if (ids.length === 0) return [];
|
if (ids.length === 0) return [];
|
||||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
const uniqueIds = sanitizeIds(ids);
|
||||||
if (!uniqueIds.length) return [];
|
if (!uniqueIds.length) return [];
|
||||||
|
|
||||||
const chunkSize = sqlChunkSize(1);
|
const chunkSize = sqlChunkSize(1);
|
||||||
@@ -242,9 +294,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 uniqueIds = Array.from(new Set(ids));
|
const normalizedFolderId = normalizeOptionalId(folderId);
|
||||||
const patch = JSON.stringify({ folderId, updatedAt: now });
|
const uniqueIds = sanitizeIds(ids);
|
||||||
const chunkSize = sqlChunkSize(4);
|
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);
|
||||||
@@ -252,10 +304,73 @@ 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateRevisionDate(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkArchiveCiphers(
|
||||||
|
db: D1Database,
|
||||||
|
sqlChunkSize: SqlChunkSize,
|
||||||
|
updateRevisionDate: UpdateRevisionDate,
|
||||||
|
ids: string[],
|
||||||
|
userId: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (ids.length === 0) return null;
|
||||||
|
const uniqueIds = sanitizeIds(ids);
|
||||||
|
if (!uniqueIds.length) return null;
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const chunkSize = sqlChunkSize(3);
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE ciphers
|
||||||
|
SET archived_at = ?, updated_at = ?,
|
||||||
|
data = json_remove(data, '$.archivedAt', '$.archivedDate', '$.updatedAt', '$.revisionDate')
|
||||||
|
WHERE user_id = ? AND id IN (${placeholders}) AND deleted_at IS NULL`
|
||||||
|
)
|
||||||
|
.bind(now, now, userId, ...chunk)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateRevisionDate(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkUnarchiveCiphers(
|
||||||
|
db: D1Database,
|
||||||
|
sqlChunkSize: SqlChunkSize,
|
||||||
|
updateRevisionDate: UpdateRevisionDate,
|
||||||
|
ids: string[],
|
||||||
|
userId: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (ids.length === 0) return null;
|
||||||
|
const uniqueIds = sanitizeIds(ids);
|
||||||
|
if (!uniqueIds.length) return null;
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const chunkSize = sqlChunkSize(2);
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE ciphers
|
||||||
|
SET archived_at = NULL, updated_at = ?,
|
||||||
|
data = json_remove(data, '$.archivedAt', '$.archivedDate', '$.updatedAt', '$.revisionDate')
|
||||||
|
WHERE user_id = ? AND id IN (${placeholders})`
|
||||||
|
)
|
||||||
|
.bind(now, userId, ...chunk)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +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,
|
||||||
|
encryptedPublicKey: row.encrypted_public_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,
|
||||||
};
|
};
|
||||||
@@ -22,19 +27,123 @@ export async function upsertDevice(
|
|||||||
deviceIdentifier: string,
|
deviceIdentifier: string,
|
||||||
name: string,
|
name: string,
|
||||||
type: number,
|
type: number,
|
||||||
sessionStamp?: string
|
sessionStamp?: string,
|
||||||
|
keys?: {
|
||||||
|
encryptedUserKey?: string | null;
|
||||||
|
encryptedPublicKey?: string | null;
|
||||||
|
encryptedPrivateKey?: string | null;
|
||||||
|
}
|
||||||
): 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, 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, updated_at=excluded.updated_at'
|
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' +
|
||||||
|
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
|
||||||
|
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
|
||||||
|
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
|
||||||
|
'last_seen_at=excluded.last_seen_at, ' +
|
||||||
|
'updated_at=excluded.updated_at'
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
userId,
|
||||||
|
deviceIdentifier,
|
||||||
|
effectiveName,
|
||||||
|
type,
|
||||||
|
effectiveSessionStamp,
|
||||||
|
keys?.encryptedUserKey ?? null,
|
||||||
|
keys?.encryptedPublicKey ?? null,
|
||||||
|
keys?.encryptedPrivateKey ?? null,
|
||||||
|
existingDevice?.deviceNote ?? null,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
now
|
||||||
)
|
)
|
||||||
.bind(userId, deviceIdentifier, name, type, effectiveSessionStamp, 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(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string,
|
||||||
|
keys: {
|
||||||
|
encryptedUserKey?: string | null;
|
||||||
|
encryptedPublicKey?: string | null;
|
||||||
|
encryptedPrivateKey?: string | null;
|
||||||
|
}
|
||||||
|
): Promise<boolean> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
'UPDATE devices SET encrypted_user_key = ?, encrypted_public_key = ?, encrypted_private_key = ?, updated_at = ? ' +
|
||||||
|
'WHERE user_id = ? AND device_identifier = ?'
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
keys.encryptedUserKey ?? null,
|
||||||
|
keys.encryptedPublicKey ?? null,
|
||||||
|
keys.encryptedPrivateKey ?? null,
|
||||||
|
now,
|
||||||
|
userId,
|
||||||
|
deviceIdentifier
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearDeviceKeys(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifiers: string[]
|
||||||
|
): Promise<number> {
|
||||||
|
const uniqueIds = Array.from(
|
||||||
|
new Set(deviceIdentifiers.map((id) => String(id || '').trim()).filter(Boolean))
|
||||||
|
);
|
||||||
|
if (!uniqueIds.length) return 0;
|
||||||
|
|
||||||
|
const placeholders = uniqueIds.map(() => '?').join(',');
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE devices
|
||||||
|
SET encrypted_user_key = NULL,
|
||||||
|
encrypted_public_key = NULL,
|
||||||
|
encrypted_private_key = NULL,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE user_id = ? AND device_identifier IN (${placeholders})`
|
||||||
|
)
|
||||||
|
.bind(new Date().toISOString(), userId, ...uniqueIds)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
export async function isKnownDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> {
|
export async function isKnownDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
|
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
|
||||||
@@ -57,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, 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>();
|
||||||
@@ -68,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, 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)
|
||||||
|
|||||||
@@ -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})`)
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
|
'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\', 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 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 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, ' +
|
||||||
@@ -20,10 +22,14 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'CREATE TABLE IF NOT EXISTS ciphers (' +
|
'CREATE TABLE IF NOT EXISTS ciphers (' +
|
||||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, folder_id TEXT, name TEXT, notes TEXT, ' +
|
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, folder_id TEXT, name TEXT, notes TEXT, ' +
|
||||||
'favorite INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL, reprompt INTEGER, key TEXT, ' +
|
'favorite INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL, reprompt INTEGER, key TEXT, ' +
|
||||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT, ' +
|
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, archived_at TEXT, deleted_at TEXT, ' +
|
||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
'ALTER TABLE ciphers ADD COLUMN archived_at TEXT',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)',
|
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at)',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
|
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted 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, ' +
|
||||||
@@ -44,6 +50,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',
|
||||||
|
|
||||||
@@ -68,25 +75,26 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'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 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, 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)',
|
||||||
'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)',
|
||||||
'ALTER TABLE devices ADD COLUMN session_stamp TEXT',
|
'ALTER TABLE devices ADD COLUMN session_stamp TEXT',
|
||||||
|
'ALTER TABLE devices ADD COLUMN encrypted_user_key TEXT',
|
||||||
|
'ALTER TABLE devices ADD COLUMN encrypted_public_key TEXT',
|
||||||
|
'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT',
|
||||||
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
|
'ALTER TABLE devices ADD COLUMN banned 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)',
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { User } from '../types';
|
import type { User } from '../types';
|
||||||
|
|
||||||
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
|
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
|
||||||
|
const USER_SELECT_COLUMNS =
|
||||||
|
'id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, ' +
|
||||||
|
'kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, ' +
|
||||||
|
'totp_secret, totp_recovery_code, api_key, created_at, updated_at';
|
||||||
|
|
||||||
function mapUserRow(row: any): User {
|
function mapUserRow(row: any): User {
|
||||||
return {
|
return {
|
||||||
@@ -19,8 +23,10 @@ function mapUserRow(row: any): User {
|
|||||||
securityStamp: row.security_stamp,
|
securityStamp: row.security_stamp,
|
||||||
role: row.role === 'admin' ? 'admin' : 'user',
|
role: row.role === 'admin' ? 'admin' : 'user',
|
||||||
status: row.status === 'banned' ? 'banned' : 'active',
|
status: row.status === 'banned' ? 'banned' : 'active',
|
||||||
|
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,
|
||||||
};
|
};
|
||||||
@@ -28,9 +34,7 @@ function mapUserRow(row: any): User {
|
|||||||
|
|
||||||
export async function getUser(db: D1Database, email: string): Promise<User | null> {
|
export async function getUser(db: D1Database, email: string): Promise<User | null> {
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare(
|
.prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users WHERE email = ?`)
|
||||||
'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE email = ?'
|
|
||||||
)
|
|
||||||
.bind(email.toLowerCase())
|
.bind(email.toLowerCase())
|
||||||
.first<any>();
|
.first<any>();
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
@@ -39,9 +43,7 @@ export async function getUser(db: D1Database, email: string): Promise<User | nul
|
|||||||
|
|
||||||
export async function getUserById(db: D1Database, id: string): Promise<User | null> {
|
export async function getUserById(db: D1Database, id: string): Promise<User | null> {
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare(
|
.prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users WHERE id = ?`)
|
||||||
'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE id = ?'
|
|
||||||
)
|
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.first<any>();
|
.first<any>();
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
@@ -55,9 +57,7 @@ export async function getUserCount(db: D1Database): Promise<number> {
|
|||||||
|
|
||||||
export async function getAllUsers(db: D1Database): Promise<User[]> {
|
export async function getAllUsers(db: D1Database): Promise<User[]> {
|
||||||
const res = await db
|
const res = await db
|
||||||
.prepare(
|
.prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users ORDER BY created_at ASC`)
|
||||||
'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'
|
|
||||||
)
|
|
||||||
.all<any>();
|
.all<any>();
|
||||||
return (res.results || []).map((row) => mapUserRow(row));
|
return (res.results || []).map((row) => mapUserRow(row));
|
||||||
}
|
}
|
||||||
@@ -65,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, 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, 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,
|
||||||
@@ -88,8 +88,10 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User):
|
|||||||
user.securityStamp,
|
user.securityStamp,
|
||||||
user.role,
|
user.role,
|
||||||
user.status,
|
user.status,
|
||||||
|
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, 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(
|
||||||
@@ -123,8 +125,10 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user:
|
|||||||
user.securityStamp,
|
user.securityStamp,
|
||||||
user.role,
|
user.role,
|
||||||
user.status,
|
user.status,
|
||||||
|
user.verifyDevices ? 1 : 0,
|
||||||
user.totpSecret,
|
user.totpSecret,
|
||||||
user.totpRecoveryCode,
|
user.totpRecoveryCode,
|
||||||
|
user.apiKey,
|
||||||
user.createdAt,
|
user.createdAt,
|
||||||
user.updatedAt
|
user.updatedAt
|
||||||
).run();
|
).run();
|
||||||
|
|||||||
@@ -36,10 +36,12 @@ import {
|
|||||||
saveFolder as saveStoredFolder,
|
saveFolder as saveStoredFolder,
|
||||||
} from './storage-folder-repo';
|
} from './storage-folder-repo';
|
||||||
import {
|
import {
|
||||||
|
bulkArchiveCiphers as archiveStoredCiphers,
|
||||||
bulkDeleteCiphers as deleteStoredCiphers,
|
bulkDeleteCiphers as deleteStoredCiphers,
|
||||||
bulkMoveCiphers as moveStoredCiphers,
|
bulkMoveCiphers as moveStoredCiphers,
|
||||||
bulkRestoreCiphers as restoreStoredCiphers,
|
bulkRestoreCiphers as restoreStoredCiphers,
|
||||||
bulkSoftDeleteCiphers as softDeleteStoredCiphers,
|
bulkSoftDeleteCiphers as softDeleteStoredCiphers,
|
||||||
|
bulkUnarchiveCiphers as unarchiveStoredCiphers,
|
||||||
getAllCiphers as listStoredCiphers,
|
getAllCiphers as listStoredCiphers,
|
||||||
getCipher as findStoredCipher,
|
getCipher as findStoredCipher,
|
||||||
getCiphersByIds as listStoredCiphersByIds,
|
getCiphersByIds as listStoredCiphersByIds,
|
||||||
@@ -49,13 +51,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';
|
||||||
@@ -80,6 +82,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
deleteDevice as deleteStoredDevice,
|
deleteDevice as deleteStoredDevice,
|
||||||
deleteDevicesByUserId as deleteStoredDevicesByUserId,
|
deleteDevicesByUserId as deleteStoredDevicesByUserId,
|
||||||
|
clearDeviceKeys as clearStoredDeviceKeys,
|
||||||
deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice,
|
deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice,
|
||||||
deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId,
|
deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId,
|
||||||
getDevice as findStoredDevice,
|
getDevice as findStoredDevice,
|
||||||
@@ -89,7 +92,10 @@ 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,
|
||||||
} from './storage-device-repo';
|
} from './storage-device-repo';
|
||||||
import {
|
import {
|
||||||
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
|
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
|
||||||
@@ -102,7 +108,7 @@ import {
|
|||||||
|
|
||||||
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-19.1';
|
const STORAGE_SCHEMA_VERSION = '2026-04-28';
|
||||||
|
|
||||||
// D1-backed storage.
|
// D1-backed storage.
|
||||||
// Contract:
|
// Contract:
|
||||||
@@ -286,6 +292,14 @@ export class StorageService {
|
|||||||
return restoreStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
|
return restoreStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async bulkArchiveCiphers(ids: string[], userId: string): Promise<string | null> {
|
||||||
|
return archiveStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async bulkUnarchiveCiphers(ids: string[], userId: string): Promise<string | null> {
|
||||||
|
return unarchiveStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
|
||||||
|
}
|
||||||
|
|
||||||
async bulkDeleteCiphers(ids: string[], userId: string): Promise<string | null> {
|
async bulkDeleteCiphers(ids: string[], userId: string): Promise<string | null> {
|
||||||
return deleteStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
|
return deleteStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
|
||||||
}
|
}
|
||||||
@@ -326,7 +340,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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -334,7 +347,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[]> {
|
||||||
@@ -359,6 +372,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);
|
||||||
}
|
}
|
||||||
@@ -375,10 +392,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);
|
||||||
}
|
}
|
||||||
@@ -495,8 +508,19 @@ export class StorageService {
|
|||||||
|
|
||||||
// --- Devices ---
|
// --- Devices ---
|
||||||
|
|
||||||
async upsertDevice(userId: string, deviceIdentifier: string, name: string, type: number, sessionStamp?: string): Promise<void> {
|
async upsertDevice(
|
||||||
await saveStoredDevice(this.db, this.getDevice.bind(this), userId, deviceIdentifier, name, type, sessionStamp);
|
userId: string,
|
||||||
|
deviceIdentifier: string,
|
||||||
|
name: string,
|
||||||
|
type: number,
|
||||||
|
sessionStamp?: string,
|
||||||
|
keys?: {
|
||||||
|
encryptedUserKey?: string | null;
|
||||||
|
encryptedPublicKey?: string | null;
|
||||||
|
encryptedPrivateKey?: string | null;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
await saveStoredDevice(this.db, this.getDevice.bind(this), userId, deviceIdentifier, name, type, sessionStamp, keys);
|
||||||
}
|
}
|
||||||
|
|
||||||
async isKnownDevice(userId: string, deviceIdentifier: string): Promise<boolean> {
|
async isKnownDevice(userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||||
@@ -515,6 +539,30 @@ export class StorageService {
|
|||||||
return findStoredDevice(this.db, userId, deviceIdentifier);
|
return findStoredDevice(this.db, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateDeviceKeys(
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string,
|
||||||
|
keys: {
|
||||||
|
encryptedUserKey?: string | null;
|
||||||
|
encryptedPublicKey?: string | null;
|
||||||
|
encryptedPrivateKey?: string | null;
|
||||||
|
}
|
||||||
|
): Promise<boolean> {
|
||||||
|
return updateStoredDeviceKeys(this.db, userId, deviceIdentifier, keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
async 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> {
|
||||||
|
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
|
||||||
|
}
|
||||||
|
|
||||||
async deleteDevice(userId: string, deviceIdentifier: string): Promise<boolean> {
|
async deleteDevice(userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||||
return deleteStoredDevice(this.db, userId, deviceIdentifier);
|
return deleteStoredDevice(this.db, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,8 +47,10 @@ export interface User {
|
|||||||
securityStamp: string;
|
securityStamp: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
status: UserStatus;
|
status: UserStatus;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
@@ -169,6 +171,7 @@ export interface Cipher {
|
|||||||
key: string | null;
|
key: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
archivedAt: string | null;
|
||||||
deletedAt: string | null;
|
deletedAt: string | null;
|
||||||
/** Allow unknown fields from Bitwarden clients to be stored and passed through transparently. */
|
/** Allow unknown fields from Bitwarden clients to be stored and passed through transparently. */
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
@@ -187,12 +190,55 @@ 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;
|
||||||
|
encryptedPublicKey: string | null;
|
||||||
|
encryptedPrivateKey: string | null;
|
||||||
|
devicePendingAuthRequest?: DevicePendingAuthRequest | null;
|
||||||
|
lastSeenAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DevicePendingAuthRequest {
|
||||||
|
id: string;
|
||||||
|
creationDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceResponse {
|
||||||
|
id: string;
|
||||||
|
userId?: string | null;
|
||||||
|
name: string;
|
||||||
|
systemName?: string | null;
|
||||||
|
deviceNote?: string | null;
|
||||||
|
identifier: string;
|
||||||
|
type: number;
|
||||||
|
creationDate: string;
|
||||||
|
revisionDate: string;
|
||||||
|
lastSeenAt?: string | null;
|
||||||
|
hasStoredDevice?: boolean;
|
||||||
|
isTrusted: boolean;
|
||||||
|
encryptedUserKey: string | null;
|
||||||
|
encryptedPublicKey: string | null;
|
||||||
|
devicePendingAuthRequest: DevicePendingAuthRequest | null;
|
||||||
|
object: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProtectedDeviceResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
identifier: string;
|
||||||
|
type: number;
|
||||||
|
creationDate: string;
|
||||||
|
encryptedUserKey: string | null;
|
||||||
|
encryptedPublicKey: string | null;
|
||||||
|
object: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RefreshTokenRecord {
|
export interface RefreshTokenRecord {
|
||||||
userId: string;
|
userId: string;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
@@ -308,7 +354,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;
|
||||||
@@ -328,6 +375,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 {
|
||||||
@@ -351,6 +402,7 @@ export interface ProfileResponse {
|
|||||||
forcePasswordReset: boolean;
|
forcePasswordReset: boolean;
|
||||||
avatarColor: string | null;
|
avatarColor: string | null;
|
||||||
creationDate: string;
|
creationDate: string;
|
||||||
|
verifyDevices?: boolean;
|
||||||
role?: UserRole;
|
role?: UserRole;
|
||||||
status?: UserStatus;
|
status?: UserStatus;
|
||||||
object: string;
|
object: string;
|
||||||
@@ -398,6 +450,7 @@ export interface FolderResponse {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
revisionDate: string;
|
revisionDate: string;
|
||||||
|
creationDate: string;
|
||||||
object: string;
|
object: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,6 +462,13 @@ export interface SyncResponse {
|
|||||||
domains: any;
|
domains: any;
|
||||||
policies: any[];
|
policies: any[];
|
||||||
sends: SendResponse[];
|
sends: SendResponse[];
|
||||||
|
UserDecryption?: {
|
||||||
|
MasterPasswordUnlock: MasterPasswordUnlock | null;
|
||||||
|
TrustedDeviceOption?: null;
|
||||||
|
KeyConnectorOption?: null;
|
||||||
|
WebAuthnPrfOption?: null;
|
||||||
|
Object?: string;
|
||||||
|
} | null;
|
||||||
// PascalCase for desktop/browser clients
|
// PascalCase for desktop/browser clients
|
||||||
UserDecryptionOptions: UserDecryptionOptions | null;
|
UserDecryptionOptions: UserDecryptionOptions | null;
|
||||||
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
||||||
|
|||||||
@@ -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,42 @@ 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 (isWildcardCorsPath(url.pathname)) {
|
||||||
return origin;
|
return { allowOrigin: '*', allowCredentials: false };
|
||||||
|
}
|
||||||
|
if (!origin) {
|
||||||
|
return { allowOrigin: null, allowCredentials: false };
|
||||||
|
}
|
||||||
|
if (origin === url.origin) {
|
||||||
|
return { allowOrigin: origin, allowCredentials: true };
|
||||||
|
}
|
||||||
|
if (isExtensionOrigin(origin)) {
|
||||||
|
return { allowOrigin: origin, allowCredentials: true };
|
||||||
|
}
|
||||||
|
return { allowOrigin: null, allowCredentials: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCorsHeaders(request: Request): Record<string, string> {
|
function buildCorsHeaders(request: Request): Record<string, string> {
|
||||||
@@ -35,13 +65,14 @@ function buildCorsHeaders(request: Request): Record<string, string> {
|
|||||||
'Access-Control-Allow-Headers': allowHeaders.join(', '),
|
'Access-Control-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,26 @@
|
|||||||
<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>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></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 |
@@ -40,6 +40,12 @@ 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">
|
||||||
<section className="card">
|
<section className="card">
|
||||||
@@ -55,8 +61,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 +74,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,8 +91,9 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||||
import { Link } from 'wouter';
|
import { Link } from 'wouter';
|
||||||
import AppMainRoutes from '@/components/AppMainRoutes';
|
import AppMainRoutes from '@/components/AppMainRoutes';
|
||||||
|
import ThemeSwitch from '@/components/ThemeSwitch';
|
||||||
import type { AppMainRoutesProps } from '@/components/AppMainRoutes';
|
import type { AppMainRoutesProps } from '@/components/AppMainRoutes';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import type { Profile } from '@/lib/types';
|
import type { Profile } from '@/lib/types';
|
||||||
@@ -15,19 +16,30 @@ interface AppAuthenticatedShellProps {
|
|||||||
settingsAccountRoute: string;
|
settingsAccountRoute: string;
|
||||||
importRoute: string;
|
importRoute: string;
|
||||||
isImportRoute: boolean;
|
isImportRoute: boolean;
|
||||||
|
darkMode: boolean;
|
||||||
|
themeToggleTitle: string;
|
||||||
onLock: () => void;
|
onLock: () => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
|
onToggleTheme: () => void;
|
||||||
|
onToggleMobileSidebar: () => void;
|
||||||
mainRoutesProps: AppMainRoutesProps;
|
mainRoutesProps: AppMainRoutesProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 isAdmin = isAdminProfile(props.profile);
|
||||||
|
|
||||||
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">
|
||||||
@@ -35,6 +47,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
<ShieldUser size={16} />
|
<ShieldUser size={16} />
|
||||||
<span>{props.profile?.email}</span>
|
<span>{props.profile?.email}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<ThemeSwitch checked={props.darkMode} title={props.themeToggleTitle} onToggle={props.onToggleTheme} />
|
||||||
<button type="button" className="btn btn-secondary small" onClick={props.onLock}>
|
<button type="button" className="btn btn-secondary small" onClick={props.onLock}>
|
||||||
<Lock size={14} className="btn-icon" /> {t('txt_lock')}
|
<Lock size={14} className="btn-icon" /> {t('txt_lock')}
|
||||||
</button>
|
</button>
|
||||||
@@ -44,11 +57,14 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
<div className="mobile-theme-btn">
|
||||||
|
<ThemeSwitch checked={props.darkMode} title={props.themeToggleTitle} onToggle={props.onToggleTheme} />
|
||||||
|
</div>
|
||||||
<button type="button" className="btn btn-secondary small mobile-lock-btn" aria-label={t('txt_lock')} title={t('txt_lock')} onClick={props.onLock}>
|
<button type="button" className="btn btn-secondary small mobile-lock-btn" aria-label={t('txt_lock')} title={t('txt_lock')} onClick={props.onLock}>
|
||||||
<Lock size={14} className="btn-icon" />
|
<Lock size={14} className="btn-icon" />
|
||||||
</button>
|
</button>
|
||||||
@@ -72,7 +88,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
<SendIcon size={16} />
|
<SendIcon size={16} />
|
||||||
<span>{t('nav_sends')}</span>
|
<span>{t('nav_sends')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
{props.profile?.role === 'admin' && (
|
{isAdmin && (
|
||||||
<Link href="/admin" className={`side-link ${props.location === '/admin' ? 'active' : ''}`}>
|
<Link href="/admin" className={`side-link ${props.location === '/admin' ? 'active' : ''}`}>
|
||||||
<ShieldUser size={16} />
|
<ShieldUser size={16} />
|
||||||
<span>{t('nav_admin_panel')}</span>
|
<span>{t('nav_admin_panel')}</span>
|
||||||
@@ -86,7 +102,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
<Shield size={16} />
|
<Shield size={16} />
|
||||||
<span>{t('nav_device_management')}</span>
|
<span>{t('nav_device_management')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
{props.profile?.role === 'admin' && (
|
{isAdmin && (
|
||||||
<Link href="/backup" className={`side-link ${props.location === '/backup' ? 'active' : ''}`}>
|
<Link href="/backup" className={`side-link ${props.location === '/backup' ? 'active' : ''}`}>
|
||||||
<Cloud size={16} />
|
<Cloud size={16} />
|
||||||
<span>{t('nav_backup_strategy')}</span>
|
<span>{t('nav_backup_strategy')}</span>
|
||||||
@@ -98,7 +114,9 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
</Link>
|
</Link>
|
||||||
</aside>
|
</aside>
|
||||||
<main className="content">
|
<main className="content">
|
||||||
<AppMainRoutes {...props.mainRoutesProps} />
|
<div key={routeAnimationKey} className="route-stage">
|
||||||
|
<AppMainRoutes {...props.mainRoutesProps} />
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ 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, AuthorizedDevice, Cipher, 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 SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage'));
|
const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage'));
|
||||||
const AdminPage = lazy(() => import('@/components/AdminPage'));
|
const AdminPage = lazy(() => import('@/components/AdminPage'));
|
||||||
@@ -33,6 +33,7 @@ export interface AppMainRoutesProps {
|
|||||||
profile: Profile | null;
|
profile: Profile | null;
|
||||||
session: SessionState | null;
|
session: SessionState | null;
|
||||||
mobileLayout: boolean;
|
mobileLayout: boolean;
|
||||||
|
mobileSidebarToggleKey: number;
|
||||||
importRoute: string;
|
importRoute: string;
|
||||||
settingsHomeRoute: string;
|
settingsHomeRoute: string;
|
||||||
settingsAccountRoute: string;
|
settingsAccountRoute: string;
|
||||||
@@ -45,6 +46,8 @@ export interface AppMainRoutesProps {
|
|||||||
users: AdminUser[];
|
users: AdminUser[];
|
||||||
invites: AdminInvite[];
|
invites: AdminInvite[];
|
||||||
totpEnabled: boolean;
|
totpEnabled: boolean;
|
||||||
|
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||||
|
sessionTimeoutAction: 'lock' | 'logout';
|
||||||
authorizedDevices: AuthorizedDevice[];
|
authorizedDevices: AuthorizedDevice[];
|
||||||
authorizedDevicesLoading: boolean;
|
authorizedDevicesLoading: boolean;
|
||||||
onNavigate: (path: string) => void;
|
onNavigate: (path: string) => void;
|
||||||
@@ -64,12 +67,17 @@ export interface AppMainRoutesProps {
|
|||||||
onCreateVaultItem: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
|
onCreateVaultItem: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
|
||||||
onUpdateVaultItem: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>;
|
onUpdateVaultItem: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>;
|
||||||
onDeleteVaultItem: (cipher: Cipher) => Promise<void>;
|
onDeleteVaultItem: (cipher: Cipher) => Promise<void>;
|
||||||
|
onArchiveVaultItem: (cipher: Cipher) => Promise<void>;
|
||||||
|
onUnarchiveVaultItem: (cipher: Cipher) => 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>;
|
||||||
|
onBulkArchiveVaultItems: (ids: string[]) => Promise<void>;
|
||||||
|
onBulkUnarchiveVaultItems: (ids: string[]) => Promise<void>;
|
||||||
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>;
|
||||||
@@ -89,7 +97,12 @@ 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>;
|
||||||
|
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
||||||
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
||||||
onRemoveDevice: (device: AuthorizedDevice) => void;
|
onRemoveDevice: (device: AuthorizedDevice) => void;
|
||||||
onRevokeAllDeviceTrust: () => void;
|
onRevokeAllDeviceTrust: () => void;
|
||||||
@@ -102,17 +115,21 @@ export interface AppMainRoutesProps {
|
|||||||
onRevokeInvite: (code: string) => Promise<void>;
|
onRevokeInvite: (code: string) => Promise<void>;
|
||||||
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
|
||||||
@@ -154,6 +171,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>
|
||||||
@@ -174,13 +192,18 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
onCreate={props.onCreateVaultItem}
|
onCreate={props.onCreateVaultItem}
|
||||||
onUpdate={props.onUpdateVaultItem}
|
onUpdate={props.onUpdateVaultItem}
|
||||||
onDelete={props.onDeleteVaultItem}
|
onDelete={props.onDeleteVaultItem}
|
||||||
|
onArchive={props.onArchiveVaultItem}
|
||||||
|
onUnarchive={props.onUnarchiveVaultItem}
|
||||||
onBulkDelete={props.onBulkDeleteVaultItems}
|
onBulkDelete={props.onBulkDeleteVaultItems}
|
||||||
onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems}
|
onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems}
|
||||||
onBulkRestore={props.onBulkRestoreVaultItems}
|
onBulkRestore={props.onBulkRestoreVaultItems}
|
||||||
|
onBulkArchive={props.onBulkArchiveVaultItems}
|
||||||
|
onBulkUnarchive={props.onBulkUnarchiveVaultItems}
|
||||||
onBulkMove={props.onBulkMoveVaultItems}
|
onBulkMove={props.onBulkMoveVaultItems}
|
||||||
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}
|
||||||
@@ -188,6 +211,7 @@ 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>
|
||||||
@@ -206,11 +230,17 @@ 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>
|
||||||
@@ -233,13 +263,13 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
<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="/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>
|
||||||
@@ -268,6 +298,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
devices={props.authorizedDevices}
|
devices={props.authorizedDevices}
|
||||||
loading={props.authorizedDevicesLoading}
|
loading={props.authorizedDevicesLoading}
|
||||||
onRefresh={() => void props.onRefreshAuthorizedDevices()}
|
onRefresh={() => void props.onRefreshAuthorizedDevices()}
|
||||||
|
onRenameDevice={props.onRenameAuthorizedDevice}
|
||||||
onRevokeTrust={props.onRevokeDeviceTrust}
|
onRevokeTrust={props.onRevokeDeviceTrust}
|
||||||
onRemoveDevice={props.onRemoveDevice}
|
onRemoveDevice={props.onRemoveDevice}
|
||||||
onRevokeAll={props.onRevokeAllDeviceTrust}
|
onRevokeAll={props.onRevokeAllDeviceTrust}
|
||||||
@@ -310,7 +341,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">
|
||||||
@@ -325,11 +356,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}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface AuthViewsProps {
|
|||||||
mode: 'login' | 'register' | 'locked';
|
mode: 'login' | 'register' | 'locked';
|
||||||
pendingAction: 'login' | 'register' | 'unlock' | null;
|
pendingAction: 'login' | 'register' | 'unlock' | null;
|
||||||
unlockReady: boolean;
|
unlockReady: boolean;
|
||||||
|
unlockPreparing: boolean;
|
||||||
loginValues: LoginValues;
|
loginValues: LoginValues;
|
||||||
registerValues: RegisterValues;
|
registerValues: RegisterValues;
|
||||||
unlockPassword: string;
|
unlockPassword: string;
|
||||||
@@ -97,14 +98,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}>
|
||||||
|
|||||||
@@ -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 { createPortal } from 'preact/compat';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import type { ComponentChildren } from 'preact';
|
import type { ComponentChildren } from 'preact';
|
||||||
import { Check, X } from 'lucide-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,27 +22,200 @@ 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) {
|
||||||
if (!props.open) return null;
|
const [present, setPresent] = useState(props.open);
|
||||||
return (
|
const [closing, setClosing] = useState(false);
|
||||||
<div className="dialog-mask">
|
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(() => {
|
||||||
|
if (props.open) {
|
||||||
|
setPresent(true);
|
||||||
|
setClosing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!present) return;
|
||||||
|
setClosing(true);
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setPresent(false);
|
||||||
|
setClosing(false);
|
||||||
|
}, 240);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [props.open, present]);
|
||||||
|
|
||||||
|
useDialogLifecycle(present, canDismiss ? props.onCancel : null);
|
||||||
|
|
||||||
|
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"
|
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) 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"
|
||||||
>
|
>
|
||||||
<Check size={14} className="btn-icon" />
|
|
||||||
{props.confirmText || t('txt_yes')}
|
{props.confirmText || t('txt_yes')}
|
||||||
</button>
|
</button>
|
||||||
{!props.hideCancel && (
|
{!props.hideCancel && (
|
||||||
@@ -47,17 +223,17 @@ 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();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<X size={14} className="btn-icon" />
|
|
||||||
{props.cancelText || t('txt_no')}
|
{props.cancelText || t('txt_no')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{props.afterActions}
|
{props.afterActions}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
), document.body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState } from 'preact/hooks';
|
import { useState } from 'preact/hooks';
|
||||||
import { argon2idAsync } from '@noble/hashes/argon2.js';
|
import { argon2idAsync } from '@noble/hashes/argon2.js';
|
||||||
|
import { createPortal } from 'preact/compat';
|
||||||
import { strFromU8, unzipSync } from 'fflate';
|
import { strFromU8, unzipSync } from 'fflate';
|
||||||
import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js';
|
import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js';
|
||||||
import { Download, FileUp } from 'lucide-preact';
|
import { Download, FileUp } from 'lucide-preact';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
import ConfirmDialog, { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||||
import {
|
import {
|
||||||
type EncryptedJsonMode,
|
type EncryptedJsonMode,
|
||||||
@@ -311,6 +312,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
const [exportAuthDialogOpen, setExportAuthDialogOpen] = useState(false);
|
const [exportAuthDialogOpen, setExportAuthDialogOpen] = useState(false);
|
||||||
const [exportAuthPassword, setExportAuthPassword] = useState('');
|
const [exportAuthPassword, setExportAuthPassword] = useState('');
|
||||||
const [importSummary, setImportSummary] = useState<ImportResultSummary | null>(null);
|
const [importSummary, setImportSummary] = useState<ImportResultSummary | null>(null);
|
||||||
|
|
||||||
|
useDialogLifecycle(!!importSummary, importSummary ? () => setImportSummary(null) : null);
|
||||||
const commonSourceSet = new Set<ImportSourceId>(COMMON_IMPORT_SOURCE_IDS);
|
const commonSourceSet = new Set<ImportSourceId>(COMMON_IMPORT_SOURCE_IDS);
|
||||||
const commonSources = IMPORT_SOURCES.filter((item) => commonSourceSet.has(item.id as ImportSourceId));
|
const commonSources = IMPORT_SOURCES.filter((item) => commonSourceSet.has(item.id as ImportSourceId));
|
||||||
const otherSources = IMPORT_SOURCES.filter((item) => !commonSourceSet.has(item.id as ImportSourceId));
|
const otherSources = IMPORT_SOURCES.filter((item) => !commonSourceSet.has(item.id as ImportSourceId));
|
||||||
@@ -465,6 +468,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handlePasswordImportConfirm() {
|
async function handlePasswordImportConfirm() {
|
||||||
|
if (isPasswordSubmitting) return;
|
||||||
if (!pendingPasswordImport) return;
|
if (!pendingPasswordImport) return;
|
||||||
setIsPasswordSubmitting(true);
|
setIsPasswordSubmitting(true);
|
||||||
try {
|
try {
|
||||||
@@ -483,6 +487,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleZipPasswordImportConfirm() {
|
async function handleZipPasswordImportConfirm() {
|
||||||
|
if (isZipPasswordSubmitting) return;
|
||||||
if (!pendingZipFile) return;
|
if (!pendingZipFile) return;
|
||||||
setIsZipPasswordSubmitting(true);
|
setIsZipPasswordSubmitting(true);
|
||||||
try {
|
try {
|
||||||
@@ -555,6 +560,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleExportConfirmPassword() {
|
async function handleExportConfirmPassword() {
|
||||||
|
if (isExporting) return;
|
||||||
const masterPassword = String(exportAuthPassword || '').trim();
|
const masterPassword = String(exportAuthPassword || '').trim();
|
||||||
if (!masterPassword) {
|
if (!masterPassword) {
|
||||||
onNotify('error', t('txt_master_password_is_required'));
|
onNotify('error', t('txt_master_password_is_required'));
|
||||||
@@ -733,6 +739,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
confirmText={isExporting ? t('txt_loading') : t('txt_verify')}
|
confirmText={isExporting ? t('txt_loading') : t('txt_verify')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
showIcon={false}
|
showIcon={false}
|
||||||
|
confirmDisabled={isExporting}
|
||||||
|
cancelDisabled={isExporting}
|
||||||
onConfirm={() => void handleExportConfirmPassword()}
|
onConfirm={() => void handleExportConfirmPassword()}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
if (isExporting) return;
|
if (isExporting) return;
|
||||||
@@ -758,6 +766,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
confirmText={isPasswordSubmitting ? t('txt_loading') : t('txt_import')}
|
confirmText={isPasswordSubmitting ? t('txt_loading') : t('txt_import')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
showIcon={false}
|
showIcon={false}
|
||||||
|
confirmDisabled={isPasswordSubmitting}
|
||||||
|
cancelDisabled={isPasswordSubmitting}
|
||||||
onConfirm={() => void handlePasswordImportConfirm()}
|
onConfirm={() => void handlePasswordImportConfirm()}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
if (isPasswordSubmitting) return;
|
if (isPasswordSubmitting) return;
|
||||||
@@ -784,6 +794,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
confirmText={isZipPasswordSubmitting ? t('txt_loading') : t('txt_import')}
|
confirmText={isZipPasswordSubmitting ? t('txt_loading') : t('txt_import')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
showIcon={false}
|
showIcon={false}
|
||||||
|
confirmDisabled={isZipPasswordSubmitting}
|
||||||
|
cancelDisabled={isZipPasswordSubmitting}
|
||||||
onConfirm={() => void handleZipPasswordImportConfirm()}
|
onConfirm={() => void handleZipPasswordImportConfirm()}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
if (isZipPasswordSubmitting) return;
|
if (isZipPasswordSubmitting) return;
|
||||||
@@ -803,9 +815,15 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
</label>
|
</label>
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|
||||||
{importSummary && (
|
{importSummary && typeof document !== 'undefined' ? createPortal((
|
||||||
<div className="dialog-mask">
|
<div
|
||||||
<section className="dialog-card import-summary-dialog">
|
className="dialog-mask"
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target !== event.currentTarget) return;
|
||||||
|
setImportSummary(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<section className="dialog-card import-summary-dialog" role="dialog" aria-modal="true" aria-label={t('txt_import_success')}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="import-summary-close"
|
className="import-summary-close"
|
||||||
@@ -866,7 +884,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
), document.body) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
interface LoadingStateProps {
|
||||||
|
lines?: number;
|
||||||
|
compact?: boolean;
|
||||||
|
card?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoadingState(props: LoadingStateProps) {
|
||||||
|
const lines = Math.max(1, props.lines || 4);
|
||||||
|
return (
|
||||||
|
<div className={`${props.card ? 'loading-state-card card' : 'loading-state'}${props.compact ? ' compact' : ''}${props.className ? ` ${props.className}` : ''}`} aria-hidden="true">
|
||||||
|
{Array.from({ length: lines }, (_, index) => (
|
||||||
|
<div key={index} className="loading-state-row">
|
||||||
|
<div className="loading-state-icon shimmer" />
|
||||||
|
<div className="loading-state-text">
|
||||||
|
<div className="loading-state-line shimmer" />
|
||||||
|
<div className="loading-state-line short shimmer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import { Download, Eye, Lock } from 'lucide-preact';
|
import { Download, Eye, Lock } from 'lucide-preact';
|
||||||
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send';
|
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send';
|
||||||
|
import { toBufferSource } from '@/lib/crypto';
|
||||||
import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download';
|
import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download';
|
||||||
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
@@ -10,29 +11,95 @@ interface PublicSendPageProps {
|
|||||||
keyPart: string | null;
|
keyPart: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PublicSendFileData {
|
||||||
|
id: string;
|
||||||
|
fileName?: string | null;
|
||||||
|
sizeName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PublicSendData {
|
||||||
|
id: string;
|
||||||
|
type: 0 | 1;
|
||||||
|
decName?: string | null;
|
||||||
|
decText?: string | null;
|
||||||
|
decFileName?: string | null;
|
||||||
|
expirationDate?: string | null;
|
||||||
|
file?: PublicSendFileData | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return value && typeof value === 'object' ? value as Record<string, unknown> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalString(value: unknown): string | null {
|
||||||
|
return typeof value === 'string' ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePublicSendData(value: unknown): PublicSendData | null {
|
||||||
|
const source = asRecord(value);
|
||||||
|
if (!source) return null;
|
||||||
|
const id = optionalString(source.id);
|
||||||
|
const rawType = Number(source.type);
|
||||||
|
if (!id || (rawType !== 0 && rawType !== 1)) return null;
|
||||||
|
|
||||||
|
const fileSource = asRecord(source.file);
|
||||||
|
const fileId = optionalString(fileSource?.id);
|
||||||
|
const file = fileSource && fileId
|
||||||
|
? {
|
||||||
|
id: fileId,
|
||||||
|
fileName: optionalString(fileSource.fileName),
|
||||||
|
sizeName: optionalString(fileSource.sizeName),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
if (rawType === 1 && !file) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: rawType,
|
||||||
|
decName: optionalString(source.decName),
|
||||||
|
decText: optionalString(source.decText),
|
||||||
|
decFileName: optionalString(source.decFileName),
|
||||||
|
expirationDate: optionalString(source.expirationDate),
|
||||||
|
file,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function PublicSendPage(props: PublicSendPageProps) {
|
export default function PublicSendPage(props: PublicSendPageProps) {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [needPassword, setNeedPassword] = useState(false);
|
const [needPassword, setNeedPassword] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [sendData, setSendData] = useState<any>(null);
|
const [sendData, setSendData] = useState<PublicSendData | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [downloadPercent, setDownloadPercent] = useState<number | null>(null);
|
const [downloadPercent, setDownloadPercent] = useState<number | null>(null);
|
||||||
|
const loadRequestRef = useRef(0);
|
||||||
|
const loadAbortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
async function loadSend(pass?: string): Promise<void> {
|
async function loadSend(pass?: string): Promise<void> {
|
||||||
|
loadAbortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const requestId = loadRequestRef.current + 1;
|
||||||
|
loadRequestRef.current = requestId;
|
||||||
|
loadAbortRef.current = controller;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await accessPublicSend(props.accessId, props.keyPart, pass);
|
const data = await accessPublicSend(props.accessId, props.keyPart, pass, { signal: controller.signal });
|
||||||
|
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
|
||||||
if (!props.keyPart) {
|
if (!props.keyPart) {
|
||||||
setError(t('txt_this_link_is_missing_decryption_key'));
|
setError(t('txt_this_link_is_missing_decryption_key'));
|
||||||
setSendData(null);
|
setSendData(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const decrypted = await decryptPublicSend(data, props.keyPart);
|
const decrypted = await decryptPublicSend(data, props.keyPart);
|
||||||
setSendData(decrypted);
|
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
|
||||||
|
const parsed = parsePublicSendData(decrypted);
|
||||||
|
if (!parsed) throw new Error(t('txt_send_unavailable'));
|
||||||
|
setSendData(parsed);
|
||||||
setNeedPassword(false);
|
setNeedPassword(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
|
||||||
const err = e as Error & { status?: number };
|
const err = e as Error & { status?: number };
|
||||||
if (err.status === 401) {
|
if (err.status === 401) {
|
||||||
setNeedPassword(true);
|
setNeedPassword(true);
|
||||||
@@ -42,6 +109,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
|||||||
}
|
}
|
||||||
setSendData(null);
|
setSendData(null);
|
||||||
} finally {
|
} finally {
|
||||||
|
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -61,13 +129,13 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
|||||||
if (props.keyPart) {
|
if (props.keyPart) {
|
||||||
try {
|
try {
|
||||||
const decryptedBytes = await decryptPublicSendFileBytes(encryptedBytes, props.keyPart);
|
const decryptedBytes = await decryptPublicSendFileBytes(encryptedBytes, props.keyPart);
|
||||||
blob = new Blob([decryptedBytes as unknown as BlobPart], { type: 'application/octet-stream' });
|
blob = new Blob([toBufferSource(decryptedBytes)], { type: 'application/octet-stream' });
|
||||||
} catch {
|
} catch {
|
||||||
// Legacy compatibility: early web-created file sends uploaded plaintext bytes.
|
// Legacy compatibility: early web-created file sends uploaded plaintext bytes.
|
||||||
blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
|
blob = new Blob([toBufferSource(encryptedBytes)], { type: 'application/octet-stream' });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
|
blob = new Blob([toBufferSource(encryptedBytes)], { type: 'application/octet-stream' });
|
||||||
}
|
}
|
||||||
downloadBytesAsFile(
|
downloadBytesAsFile(
|
||||||
new Uint8Array(await blob.arrayBuffer()),
|
new Uint8Array(await blob.arrayBuffer()),
|
||||||
@@ -85,6 +153,9 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadSend();
|
void loadSend();
|
||||||
|
return () => {
|
||||||
|
loadAbortRef.current?.abort();
|
||||||
|
};
|
||||||
}, [props.accessId, props.keyPart]);
|
}, [props.accessId, props.keyPart]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -119,13 +190,13 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
|||||||
|
|
||||||
{!loading && sendData && (
|
{!loading && sendData && (
|
||||||
<>
|
<>
|
||||||
<h2 style={{ marginTop: '8px' }}>{sendData.decName || t('txt_no_name')}</h2>
|
<h2 className="public-send-title">{sendData.decName || t('txt_no_name')}</h2>
|
||||||
{sendData.type === 0 ? (
|
{sendData.type === 0 ? (
|
||||||
<div className="card" style={{ marginTop: '10px' }}>
|
<div className="card public-send-card">
|
||||||
<div className="notes">{sendData.decText || ''}</div>
|
<div className="notes">{sendData.decText || ''}</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="card" style={{ marginTop: '10px' }}>
|
<div className="card public-send-card">
|
||||||
<div className="kv-line">
|
<div className="kv-line">
|
||||||
<span>{t('txt_file')}</span>
|
<span>{t('txt_file')}</span>
|
||||||
<strong>{sendData.decFileName || sendData.file?.fileName || sendData.file?.sizeName || t('txt_encrypted_file')}</strong>
|
<strong>{sendData.decFileName || sendData.file?.fileName || sendData.file?.sizeName || t('txt_encrypted_file')}</strong>
|
||||||
@@ -141,7 +212,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
|||||||
|
|
||||||
{!loading && !sendData && !needPassword && !error && (
|
{!loading && !sendData && !needPassword && !error && (
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
<Eye size={14} style={{ verticalAlign: 'text-bottom' }} /> {t('txt_send_unavailable')}
|
<Eye size={14} className="inline-status-icon" /> {t('txt_send_unavailable')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!!error && <p className="local-error">{error}</p>}
|
{!!error && <p className="local-error">{error}</p>}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Clock3, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact';
|
import { useState } from 'preact/hooks';
|
||||||
|
import { Clock3, Pencil, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact';
|
||||||
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
import type { AuthorizedDevice } from '@/lib/types';
|
import type { AuthorizedDevice } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -6,6 +8,7 @@ interface SecurityDevicesPageProps {
|
|||||||
devices: AuthorizedDevice[];
|
devices: AuthorizedDevice[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
|
onRenameDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
||||||
onRevokeTrust: (device: AuthorizedDevice) => void;
|
onRevokeTrust: (device: AuthorizedDevice) => void;
|
||||||
onRemoveDevice: (device: AuthorizedDevice) => void;
|
onRemoveDevice: (device: AuthorizedDevice) => void;
|
||||||
onRevokeAll: () => void;
|
onRevokeAll: () => void;
|
||||||
@@ -41,13 +44,30 @@ function mapDeviceTypeName(type: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||||
|
const [editingDevice, setEditingDevice] = useState<AuthorizedDevice | null>(null);
|
||||||
|
const [deviceNote, setDeviceNote] = useState('');
|
||||||
|
const [savingNote, setSavingNote] = useState(false);
|
||||||
|
|
||||||
|
async function handleSaveDeviceNote(): Promise<void> {
|
||||||
|
if (!editingDevice || savingNote) return;
|
||||||
|
setSavingNote(true);
|
||||||
|
try {
|
||||||
|
await props.onRenameDevice(editingDevice, deviceNote);
|
||||||
|
setEditingDevice(null);
|
||||||
|
setDeviceNote('');
|
||||||
|
} finally {
|
||||||
|
setSavingNote(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stack">
|
<>
|
||||||
<section className="card">
|
<div className="stack">
|
||||||
|
<section className="card">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<div>
|
<div>
|
||||||
<h3 style={{ margin: 0 }}>{t('txt_device_management')}</h3>
|
<h3 className="flush-title">{t('txt_device_management')}</h3>
|
||||||
<div className="muted-inline" style={{ marginTop: 4 }}>
|
<div className="muted-inline section-note">
|
||||||
{t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')}
|
{t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,10 +86,10 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h3 style={{ marginTop: 0 }}>{t('txt_authorized_devices')}</h3>
|
<h3 className="section-title-flush">{t('txt_authorized_devices')}</h3>
|
||||||
<table className="table">
|
<table className="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -87,6 +107,9 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
<tr key={device.identifier}>
|
<tr key={device.identifier}>
|
||||||
<td data-label={t('txt_device')}>
|
<td data-label={t('txt_device')}>
|
||||||
<div>{device.name || t('txt_unknown_device')}</div>
|
<div>{device.name || t('txt_unknown_device')}</div>
|
||||||
|
{!!device.deviceNote && !!device.systemName && device.systemName !== device.name && (
|
||||||
|
<div className="muted-inline">{device.systemName}</div>
|
||||||
|
)}
|
||||||
<div className="muted-inline">{device.identifier}</div>
|
<div className="muted-inline">{device.identifier}</div>
|
||||||
</td>
|
</td>
|
||||||
<td data-label={t('txt_type')}>{mapDeviceTypeName(device.type)}</td>
|
<td data-label={t('txt_type')}>{mapDeviceTypeName(device.type)}</td>
|
||||||
@@ -96,7 +119,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td data-label={t('txt_added')}>{formatDateTime(device.creationDate)}</td>
|
<td data-label={t('txt_added')}>{formatDateTime(device.creationDate)}</td>
|
||||||
<td data-label={t('txt_last_seen')}>{formatDateTime(device.revisionDate)}</td>
|
<td data-label={t('txt_last_seen')}>{formatDateTime(device.lastSeenAt || device.revisionDate)}</td>
|
||||||
<td data-label={t('txt_trusted_until')}>
|
<td data-label={t('txt_trusted_until')}>
|
||||||
{device.trusted ? (
|
{device.trusted ? (
|
||||||
<div className="trusted-cell">
|
<div className="trusted-cell">
|
||||||
@@ -116,11 +139,28 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
onClick={() => props.onRevokeTrust(device)}
|
onClick={() => props.onRevokeTrust(device)}
|
||||||
>
|
>
|
||||||
<ShieldOff size={14} className="btn-icon" />
|
<ShieldOff size={14} className="btn-icon" />
|
||||||
{t('txt_revoke_trust')}
|
{t('txt_untrust')}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-danger small" onClick={() => props.onRemoveDevice(device)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small"
|
||||||
|
disabled={device.hasStoredDevice === false}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingDevice(device);
|
||||||
|
setDeviceNote(device.deviceNote || device.name || '');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil size={14} className="btn-icon" />
|
||||||
|
{t('txt_device_note')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-danger small"
|
||||||
|
disabled={device.hasStoredDevice === false}
|
||||||
|
onClick={() => props.onRemoveDevice(device)}
|
||||||
|
>
|
||||||
<Trash2 size={14} className="btn-icon" />
|
<Trash2 size={14} className="btn-icon" />
|
||||||
{t('txt_remove_device_2')}
|
{t('txt_delete')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -129,13 +169,41 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
{!props.loading && props.devices.length === 0 && (
|
{!props.loading && props.devices.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7}>
|
<td colSpan={7}>
|
||||||
<div className="empty" style={{ minHeight: 80 }}>{t('txt_no_devices_found')}</div>
|
<div className="empty empty-comfortable">{t('txt_no_devices_found')}</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!editingDevice}
|
||||||
|
title={t('txt_device_note')}
|
||||||
|
message={t('txt_replace_device_name_with_note')}
|
||||||
|
confirmText={t('txt_save')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
showIcon={false}
|
||||||
|
confirmDisabled={savingNote}
|
||||||
|
cancelDisabled={savingNote}
|
||||||
|
onConfirm={() => void handleSaveDeviceNote()}
|
||||||
|
onCancel={() => {
|
||||||
|
if (savingNote) return;
|
||||||
|
setEditingDevice(null);
|
||||||
|
setDeviceNote('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_device_note')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
maxLength={128}
|
||||||
|
value={deviceNote}
|
||||||
|
onInput={(e) => setDeviceNote((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</ConfirmDialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { CheckCheck, ChevronLeft, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact';
|
import { CheckCheck, ChevronLeft, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
|
import LoadingState from '@/components/LoadingState';
|
||||||
import type { Send, SendDraft } from '@/lib/types';
|
import type { Send, SendDraft } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -14,12 +15,13 @@ interface SendsPageProps {
|
|||||||
onBulkDelete: (ids: string[]) => Promise<void>;
|
onBulkDelete: (ids: string[]) => Promise<void>;
|
||||||
uploadingSendFileName: string;
|
uploadingSendFileName: string;
|
||||||
sendUploadPercent: number | null;
|
sendUploadPercent: number | null;
|
||||||
|
mobileSidebarToggleKey: number;
|
||||||
onNotify: (type: 'success' | 'error', text: string) => void;
|
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SendTypeFilter = 'all' | 'text' | 'file';
|
type SendTypeFilter = 'all' | 'text' | 'file';
|
||||||
const AUTO_COPY_KEY = 'nodewarden.send.auto_copy_link.v1';
|
const AUTO_COPY_KEY = 'nodewarden.send.auto_copy_link.v1';
|
||||||
const MOBILE_LAYOUT_QUERY = '(max-width: 900px)';
|
const MOBILE_LAYOUT_QUERY = '(max-width: 1180px)';
|
||||||
|
|
||||||
function daysFromNow(iso: string | null | undefined, fallback: number): string {
|
function daysFromNow(iso: string | null | undefined, fallback: number): string {
|
||||||
if (!iso) return String(fallback);
|
if (!iso) return String(fallback);
|
||||||
@@ -62,6 +64,10 @@ function draftFromSend(send: Send): SendDraft {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SendsPage(props: SendsPageProps) {
|
export default function SendsPage(props: SendsPageProps) {
|
||||||
|
const getInitialIsMobileLayout = () =>
|
||||||
|
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||||
|
? window.matchMedia(MOBILE_LAYOUT_QUERY).matches
|
||||||
|
: false;
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [typeFilter, setTypeFilter] = useState<SendTypeFilter>('all');
|
const [typeFilter, setTypeFilter] = useState<SendTypeFilter>('all');
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
@@ -71,9 +77,10 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
const [draft, setDraft] = useState<SendDraft | null>(null);
|
const [draft, setDraft] = useState<SendDraft | null>(null);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
||||||
const [isMobileLayout, setIsMobileLayout] = useState(false);
|
const [isMobileLayout, setIsMobileLayout] = useState(getInitialIsMobileLayout);
|
||||||
const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list');
|
const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list');
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
|
const mobileSidebarToggleKeyRef = useRef(props.mobileSidebarToggleKey);
|
||||||
const [autoCopyLink, setAutoCopyLink] = useState<boolean>(() => {
|
const [autoCopyLink, setAutoCopyLink] = useState<boolean>(() => {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(AUTO_COPY_KEY) === '1';
|
return localStorage.getItem(AUTO_COPY_KEY) === '1';
|
||||||
@@ -103,12 +110,10 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onToggleSidebar = () => {
|
if (props.mobileSidebarToggleKey === mobileSidebarToggleKeyRef.current) return;
|
||||||
setMobileSidebarOpen((open) => !open);
|
mobileSidebarToggleKeyRef.current = props.mobileSidebarToggleKey;
|
||||||
};
|
setMobileSidebarOpen((open) => !open);
|
||||||
window.addEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
|
}, [props.mobileSidebarToggleKey]);
|
||||||
return () => window.removeEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
@@ -226,7 +231,15 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
|
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
|
||||||
{isMobileLayout && mobileSidebarOpen && <div className="mobile-sidebar-mask" onClick={() => setMobileSidebarOpen(false)} />}
|
{isMobileLayout && (
|
||||||
|
<div
|
||||||
|
className={`mobile-sidebar-mask ${mobileSidebarOpen ? 'open' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (!mobileSidebarOpen) return;
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<aside className={`sidebar ${isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${isMobileLayout && mobileSidebarOpen ? 'open' : ''}`}>
|
<aside className={`sidebar ${isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${isMobileLayout && mobileSidebarOpen ? 'open' : ''}`}>
|
||||||
{isMobileLayout && (
|
{isMobileLayout && (
|
||||||
<div className="mobile-sidebar-head">
|
<div className="mobile-sidebar-head">
|
||||||
@@ -310,12 +323,27 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="list-panel">
|
<div className="list-panel">
|
||||||
{filteredSends.map((send) => (
|
{props.loading && !filteredSends.length && <LoadingState lines={6} compact />}
|
||||||
<div key={send.id} className={`list-item ${selectedId === send.id ? 'active' : ''}`}>
|
{filteredSends.map((send, index) => (
|
||||||
|
<div
|
||||||
|
key={send.id}
|
||||||
|
className={`list-item stagger-item stagger-delay-${Math.min(index, 10)} ${selectedId === send.id ? 'active' : ''}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target.closest('.row-check')) return;
|
||||||
|
setSelectedId(send.id);
|
||||||
|
setIsEditing(false);
|
||||||
|
setIsCreating(false);
|
||||||
|
setDraft(null);
|
||||||
|
if (isMobileLayout) setMobilePanel('detail');
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="row-check"
|
className="row-check"
|
||||||
checked={!!selectedMap[send.id]}
|
checked={!!selectedMap[send.id]}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
onInput={(e) =>
|
onInput={(e) =>
|
||||||
setSelectedMap((prev) => ({
|
setSelectedMap((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -349,7 +377,7 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!filteredSends.length && <div className="empty">{t('txt_no_sends')}</div>}
|
{!props.loading && !filteredSends.length && <div className="empty">{t('txt_no_sends')}</div>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -377,10 +405,11 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isEditing && draft && (
|
{isEditing && draft && (
|
||||||
<div className="card">
|
<div key={`send-editor-${draft.id || selectedSend?.id || 'new'}-${draft.type}`} className="detail-switch-stage">
|
||||||
<h3 className="detail-title">{isCreating ? t('txt_new_send') : t('txt_edit_send')}</h3>
|
<div className="card stagger-item stagger-delay-0">
|
||||||
{!!props.uploadingSendFileName && <div className="detail-sub">{sendUploadLabel}</div>}
|
<h3 className="detail-title">{isCreating ? t('txt_new_send') : t('txt_edit_send')}</h3>
|
||||||
<div className="field-grid">
|
{!!props.uploadingSendFileName && <div className="detail-sub">{sendUploadLabel}</div>}
|
||||||
|
<div className="field-grid">
|
||||||
<label className="field field-span-2">
|
<label className="field field-span-2">
|
||||||
<span>{t('txt_name')}</span>
|
<span>{t('txt_name')}</span>
|
||||||
<input className="input" value={draft.name} onInput={(e) => setDraft({ ...draft, name: (e.currentTarget as HTMLInputElement).value })} />
|
<input className="input" value={draft.name} onInput={(e) => setDraft({ ...draft, name: (e.currentTarget as HTMLInputElement).value })} />
|
||||||
@@ -451,8 +480,8 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
<label><input type="checkbox" checked={autoCopyLink} onInput={(e) => setAutoCopyLink((e.currentTarget as HTMLInputElement).checked)} /> {t('txt_auto_copy_link_after_save')}</label>
|
<label><input type="checkbox" checked={autoCopyLink} onInput={(e) => setAutoCopyLink((e.currentTarget as HTMLInputElement).checked)} /> {t('txt_auto_copy_link_after_save')}</label>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="detail-actions">
|
<div className="detail-actions">
|
||||||
<button type="button" className="btn btn-primary small" disabled={busy} onClick={() => void saveDraft()}>
|
<button type="button" className="btn btn-primary small" disabled={busy} onClick={() => void saveDraft()}>
|
||||||
<Save size={14} className="btn-icon" /> {t('txt_save')}
|
<Save size={14} className="btn-icon" /> {t('txt_save')}
|
||||||
</button>
|
</button>
|
||||||
@@ -470,18 +499,19 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
>
|
>
|
||||||
<X size={14} className="btn-icon" /> {t('txt_cancel')}
|
<X size={14} className="btn-icon" /> {t('txt_cancel')}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isEditing && selectedSend && (
|
{!isEditing && selectedSend && (
|
||||||
<>
|
<div key={`send-detail-${selectedSend.id}`} className="detail-switch-stage">
|
||||||
<div className="card">
|
<div className="card stagger-item stagger-delay-1">
|
||||||
<h3 className="detail-title">{selectedSend.decName || t('txt_no_name')}</h3>
|
<h3 className="detail-title">{selectedSend.decName || t('txt_no_name')}</h3>
|
||||||
<div className="detail-sub">{Number(selectedSend.type) === 1 ? t('txt_file_send') : t('txt_text_send')}</div>
|
<div className="detail-sub">{Number(selectedSend.type) === 1 ? t('txt_file_send') : t('txt_text_send')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card stagger-item stagger-delay-2">
|
||||||
<h4>{t('txt_send_details')}</h4>
|
<h4>{t('txt_send_details')}</h4>
|
||||||
<div className="kv-line"><span>{t('txt_access_count')}</span><strong>{selectedSend.accessCount || 0}</strong></div>
|
<div className="kv-line"><span>{t('txt_access_count')}</span><strong>{selectedSend.accessCount || 0}</strong></div>
|
||||||
<div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{selectedSend.deletionDate || t('txt_dash')}</strong></div>
|
<div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{selectedSend.deletionDate || t('txt_dash')}</strong></div>
|
||||||
@@ -504,7 +534,7 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!!(selectedSend.decNotes || '').trim() && (
|
{!!(selectedSend.decNotes || '').trim() && (
|
||||||
<div className="card">
|
<div className="card stagger-item stagger-delay-3">
|
||||||
<h4>{t('txt_notes')}</h4>
|
<h4>{t('txt_notes')}</h4>
|
||||||
<div className="notes">{selectedSend.decNotes || ''}</div>
|
<div className="notes">{selectedSend.decNotes || ''}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -523,8 +553,9 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
|
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!isEditing && !selectedSend && props.loading && <LoadingState card lines={4} />}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,19 +3,34 @@ import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-p
|
|||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import qrcode from 'qrcode-generator';
|
import qrcode from 'qrcode-generator';
|
||||||
import type { Profile } from '@/lib/types';
|
import type { Profile } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n';
|
||||||
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
|
|
||||||
interface SettingsPageProps {
|
interface SettingsPageProps {
|
||||||
profile: Profile;
|
profile: Profile;
|
||||||
totpEnabled: boolean;
|
totpEnabled: boolean;
|
||||||
|
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||||
|
sessionTimeoutAction: 'lock' | 'logout';
|
||||||
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
||||||
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
|
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
|
||||||
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;
|
||||||
onNotify?: (type: 'success' | 'error', text: string) => void;
|
onNotify?: (type: 'success' | 'error', text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LOCK_TIMEOUT_OPTIONS = [
|
||||||
|
{ value: 1, labelKey: 'txt_timeout_1_minute' },
|
||||||
|
{ value: 5, labelKey: 'txt_timeout_5_minutes' },
|
||||||
|
{ value: 15, labelKey: 'txt_timeout_15_minutes' },
|
||||||
|
{ value: 30, labelKey: 'txt_timeout_30_minutes' },
|
||||||
|
{ value: 0, labelKey: 'txt_timeout_never' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
function randomBase32Secret(length: number): string {
|
function randomBase32Secret(length: number): string {
|
||||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||||
let out = '';
|
let out = '';
|
||||||
@@ -36,17 +51,39 @@ function buildOtpUri(email: string, secret: string): string {
|
|||||||
return `otpauth://totp/${encodeURIComponent(`${issuer}:${email}`)}?secret=${encodeURIComponent(secret)}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
|
return `otpauth://totp/${encodeURIComponent(`${issuer}:${email}`)}?secret=${encodeURIComponent(secret)}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearLegacyTotpSetupSecrets(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const prefix = 'nodewarden.totp.secret.';
|
||||||
|
const keys: string[] = [];
|
||||||
|
for (let index = 0; index < window.localStorage.length; index += 1) {
|
||||||
|
const key = window.localStorage.key(index);
|
||||||
|
if (key?.startsWith(prefix)) keys.push(key);
|
||||||
|
}
|
||||||
|
for (const key of keys) {
|
||||||
|
window.localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage(props: SettingsPageProps) {
|
export default function SettingsPage(props: SettingsPageProps) {
|
||||||
const totpSecretStorageKey = `nodewarden.totp.secret.${props.profile.id}`;
|
|
||||||
const [currentPassword, setCurrentPassword] = useState('');
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [newPassword2, setNewPassword2] = useState('');
|
const [newPassword2, setNewPassword2] = useState('');
|
||||||
const [passwordHint, setPasswordHint] = useState(props.profile.masterPasswordHint || '');
|
const [passwordHint, setPasswordHint] = useState(props.profile.masterPasswordHint || '');
|
||||||
const [secret, setSecret] = useState(() => localStorage.getItem(totpSecretStorageKey) || randomBase32Secret(32));
|
const [secret, setSecret] = useState(() => randomBase32Secret(32));
|
||||||
const [token, setToken] = useState('');
|
const [token, setToken] = useState('');
|
||||||
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
|
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
|
||||||
const [recoveryMasterPassword, setRecoveryMasterPassword] = useState('');
|
|
||||||
const [recoveryCode, setRecoveryCode] = useState('');
|
const [recoveryCode, setRecoveryCode] = useState('');
|
||||||
|
const [apiKey, setApiKey] = useState('');
|
||||||
|
const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false);
|
||||||
|
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
|
||||||
|
const [masterPasswordPrompt, setMasterPasswordPrompt] = useState<null | 'recovery' | 'apiKey' | 'rotateApiKey'>(null);
|
||||||
|
const [masterPasswordPromptValue, setMasterPasswordPromptValue] = useState('');
|
||||||
|
const [masterPasswordPromptSubmitting, setMasterPasswordPromptSubmitting] = useState(false);
|
||||||
|
const [selectedLocale, setSelectedLocale] = useState<Locale>(() => getLocale());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
clearLegacyTotpSetupSecrets();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!props.totpEnabled) {
|
if (!props.totpEnabled) {
|
||||||
@@ -64,52 +101,133 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
const qr = qrcode(0, 'M');
|
const qr = qrcode(0, 'M');
|
||||||
qr.addData(buildOtpUri(props.profile.email, secret));
|
qr.addData(buildOtpUri(props.profile.email, secret));
|
||||||
qr.make();
|
qr.make();
|
||||||
const svg = qr.createSvgTag({ scalable: true, margin: 0 });
|
// Keep a visible quiet zone so authenticator apps can scan reliably in both themes.
|
||||||
|
const svg = qr.createSvgTag({ scalable: true, margin: 4 });
|
||||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||||
}, [props.profile.email, secret]);
|
}, [props.profile.email, secret]);
|
||||||
|
|
||||||
async function enableTotp(): Promise<void> {
|
async function enableTotp(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await props.onEnableTotp(secret, token);
|
await props.onEnableTotp(secret, token);
|
||||||
// Secret is now stored on the server; remove plaintext copy from localStorage.
|
|
||||||
localStorage.removeItem(totpSecretStorageKey);
|
|
||||||
setTotpLocked(true);
|
setTotpLocked(true);
|
||||||
} catch {
|
} catch {
|
||||||
// Keep inputs editable after a failed attempt.
|
// Keep inputs editable after a failed attempt.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRecoveryCode(): Promise<void> {
|
function openMasterPasswordPrompt(action: 'recovery' | 'apiKey' | 'rotateApiKey'): void {
|
||||||
const code = await props.onGetRecoveryCode(recoveryMasterPassword);
|
setMasterPasswordPrompt(action);
|
||||||
setRecoveryCode(code);
|
setMasterPasswordPromptValue('');
|
||||||
props.onNotify?.('success', t('txt_recovery_code_loaded'));
|
}
|
||||||
|
|
||||||
|
function closeMasterPasswordPrompt(): void {
|
||||||
|
if (masterPasswordPromptSubmitting) return;
|
||||||
|
setMasterPasswordPrompt(null);
|
||||||
|
setMasterPasswordPromptValue('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitMasterPasswordPrompt(): Promise<void> {
|
||||||
|
if (!masterPasswordPrompt || masterPasswordPromptSubmitting) return;
|
||||||
|
const masterPassword = masterPasswordPromptValue;
|
||||||
|
setMasterPasswordPromptSubmitting(true);
|
||||||
|
try {
|
||||||
|
if (masterPasswordPrompt === 'recovery') {
|
||||||
|
const code = await props.onGetRecoveryCode(masterPassword);
|
||||||
|
setRecoveryCode(code);
|
||||||
|
props.onNotify?.('success', t('txt_recovery_code_loaded'));
|
||||||
|
} else if (masterPasswordPrompt === 'apiKey') {
|
||||||
|
const key = await props.onGetApiKey(masterPassword);
|
||||||
|
setApiKey(key);
|
||||||
|
setApiKeyDialogOpen(true);
|
||||||
|
} else {
|
||||||
|
const key = await props.onRotateApiKey(masterPassword);
|
||||||
|
setApiKey(key);
|
||||||
|
setApiKeyDialogOpen(true);
|
||||||
|
props.onNotify?.('success', t('txt_api_key_rotated'));
|
||||||
|
}
|
||||||
|
setMasterPasswordPrompt(null);
|
||||||
|
setMasterPasswordPromptValue('');
|
||||||
|
} catch (error) {
|
||||||
|
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_master_password_is_required_2'));
|
||||||
|
} finally {
|
||||||
|
setMasterPasswordPromptSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const masterPasswordPromptTitle =
|
||||||
|
masterPasswordPrompt === 'recovery'
|
||||||
|
? t('txt_view_recovery_code')
|
||||||
|
: masterPasswordPrompt === 'rotateApiKey'
|
||||||
|
? t('txt_rotate_api_key')
|
||||||
|
: t('txt_view_api_key');
|
||||||
|
|
||||||
|
function formatDateTime(value: string | null | undefined): string {
|
||||||
|
if (!value) return t('txt_dash');
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return value;
|
||||||
|
return parsed.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeLocale(next: Locale): Promise<void> {
|
||||||
|
if (next === getLocale()) return;
|
||||||
|
setSelectedLocale(next);
|
||||||
|
await setLocale(next);
|
||||||
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stack">
|
<div className="settings-modules-grid">
|
||||||
<section className="card">
|
<section className="card settings-module">
|
||||||
<h3>{t('txt_profile')}</h3>
|
<h3>{t('txt_session_timeout')}</h3>
|
||||||
<label className="field">
|
<div className="session-timeout-fields">
|
||||||
<span>{t('txt_password_hint_optional')}</span>
|
<label className="field">
|
||||||
<input
|
<span>{t('txt_timeout_time')}</span>
|
||||||
className="input"
|
<select
|
||||||
maxLength={120}
|
className="input"
|
||||||
value={passwordHint}
|
value={String(props.lockTimeoutMinutes)}
|
||||||
placeholder={t('txt_password_hint_placeholder')}
|
onInput={(e) => props.onLockTimeoutChange(Number((e.currentTarget as HTMLSelectElement).value) as 0 | 1 | 5 | 15 | 30)}
|
||||||
onInput={(e) => setPasswordHint((e.currentTarget as HTMLInputElement).value)}
|
>
|
||||||
/>
|
{LOCK_TIMEOUT_OPTIONS.map((option) => (
|
||||||
<div className="field-help">{t('txt_password_hint_register_help')}</div>
|
<option key={option.value} value={option.value}>
|
||||||
</label>
|
{t(option.labelKey)}
|
||||||
<button
|
</option>
|
||||||
type="button"
|
))}
|
||||||
className="btn btn-secondary"
|
</select>
|
||||||
onClick={() => void props.onSavePasswordHint(passwordHint)}
|
</label>
|
||||||
>
|
<label className="field">
|
||||||
{t('txt_save_profile')}
|
<span>{t('txt_timeout_action')}</span>
|
||||||
</button>
|
<select
|
||||||
|
className="input"
|
||||||
|
value={props.sessionTimeoutAction}
|
||||||
|
onInput={(e) => props.onSessionTimeoutActionChange((e.currentTarget as HTMLSelectElement).value === 'logout' ? 'logout' : 'lock')}
|
||||||
|
>
|
||||||
|
<option value="logout">{t('txt_timeout_action_logout')}</option>
|
||||||
|
<option value="lock">{t('txt_timeout_action_lock')}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card">
|
<section className="card settings-module">
|
||||||
|
<h3>{t('txt_language')}</h3>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_display_language')}</span>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={selectedLocale}
|
||||||
|
onInput={(e) => void changeLocale((e.currentTarget as HTMLSelectElement).value as Locale)}
|
||||||
|
>
|
||||||
|
{AVAILABLE_LOCALES.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="field-help">{t('txt_language_saved_locally')}</div>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card settings-module">
|
||||||
<h3>{t('txt_change_master_password')}</h3>
|
<h3>{t('txt_change_master_password')}</h3>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_current_password')}</span>
|
<span>{t('txt_current_password')}</span>
|
||||||
@@ -140,71 +258,98 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card">
|
<section className="card settings-module">
|
||||||
<div className="settings-twofactor-grid">
|
<h3>{t('txt_password_hint_optional')}</h3>
|
||||||
<div className="settings-subcard">
|
<label className="field">
|
||||||
<h3>{t('txt_totp')}</h3>
|
<span>{t('txt_password_hint')}</span>
|
||||||
{totpLocked && <div className="status-ok">{t('txt_totp_is_enabled_for_this_account')}</div>}
|
<input
|
||||||
<div className="totp-grid">
|
className="input"
|
||||||
<div className="totp-qr">
|
maxLength={120}
|
||||||
<img src={qrDataUrl} alt="TOTP QR" />
|
value={passwordHint}
|
||||||
</div>
|
placeholder={t('txt_password_hint_placeholder')}
|
||||||
<div>
|
onInput={(e) => setPasswordHint((e.currentTarget as HTMLInputElement).value)}
|
||||||
<div>
|
/>
|
||||||
<label className="field">
|
<div className="field-help">{t('txt_password_hint_register_help')}</div>
|
||||||
<span>{t('txt_authenticator_key')}</span>
|
</label>
|
||||||
<input className="input" value={secret} disabled={totpLocked} onInput={(e) => setSecret((e.currentTarget as HTMLInputElement).value.toUpperCase())} />
|
<button
|
||||||
</label>
|
type="button"
|
||||||
<label className="field">
|
className="btn btn-secondary"
|
||||||
<span>{t('txt_verification_code')}</span>
|
onClick={() => void props.onSavePasswordHint(passwordHint)}
|
||||||
<input className="input" value={token} disabled={totpLocked} onInput={(e) => setToken((e.currentTarget as HTMLInputElement).value)} />
|
>
|
||||||
</label>
|
{t('txt_save_profile')}
|
||||||
<div className="actions">
|
</button>
|
||||||
<button type="button" className="btn btn-primary" disabled={totpLocked} onClick={() => void enableTotp()}>
|
</section>
|
||||||
<ShieldCheck size={14} className="btn-icon" />
|
|
||||||
{totpLocked ? t('txt_enabled') : t('txt_enable_totp')}
|
<section className="card settings-module">
|
||||||
</button>
|
<h3>{t('txt_totp')}</h3>
|
||||||
<button type="button" className="btn btn-secondary" disabled={totpLocked} onClick={() => setSecret(randomBase32Secret(32))}>
|
{totpLocked && <div className="status-ok">{t('txt_totp_is_enabled_for_this_account')}</div>}
|
||||||
|
<div className="totp-grid">
|
||||||
|
<div className="totp-qr">
|
||||||
|
<img src={qrDataUrl} alt="TOTP QR" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_authenticator_key')}</span>
|
||||||
|
<div className="totp-secret-input-wrap">
|
||||||
|
<input className="input totp-secret-input" value={secret} disabled={totpLocked} onInput={(e) => setSecret((e.currentTarget as HTMLInputElement).value.toUpperCase())} />
|
||||||
|
<div className="totp-secret-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small totp-secret-icon-btn"
|
||||||
|
disabled={totpLocked}
|
||||||
|
title={t('txt_regenerate')}
|
||||||
|
aria-label={t('txt_regenerate')}
|
||||||
|
onClick={() => setSecret(randomBase32Secret(32))}
|
||||||
|
>
|
||||||
<RefreshCw size={14} className="btn-icon" />
|
<RefreshCw size={14} className="btn-icon" />
|
||||||
{t('txt_regenerate')}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary small totp-secret-icon-btn"
|
||||||
disabled={totpLocked}
|
disabled={totpLocked}
|
||||||
|
title={t('txt_copy_secret')}
|
||||||
|
aria-label={t('txt_copy_secret')}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void copyTextToClipboard(secret, { successMessage: t('txt_secret_copied') });
|
void copyTextToClipboard(secret, { successMessage: t('txt_secret_copied') });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Clipboard size={14} className="btn-icon" />
|
<Clipboard size={14} className="btn-icon" />
|
||||||
{t('txt_copy_secret')}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_verification_code')}</span>
|
||||||
|
<input className="input" value={token} disabled={totpLocked} onInput={(e) => setToken((e.currentTarget as HTMLInputElement).value)} />
|
||||||
|
</label>
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" className="btn btn-primary" disabled={totpLocked} onClick={() => void enableTotp()}>
|
||||||
|
<ShieldCheck size={14} className="btn-icon" />
|
||||||
|
{totpLocked ? t('txt_enabled') : t('txt_enable_totp')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-danger" disabled={!totpLocked} onClick={props.onOpenDisableTotp}>
|
||||||
|
<ShieldOff size={14} className="btn-icon" />
|
||||||
|
{t('txt_disable_totp')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-danger" disabled={!totpLocked} onClick={props.onOpenDisableTotp}>
|
|
||||||
<ShieldOff size={14} className="btn-icon" />
|
|
||||||
{t('txt_disable_totp')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="settings-subcard">
|
<section className="card settings-module">
|
||||||
<h3>{t('txt_recovery_code')}</h3>
|
<h3>{t('txt_recovery_code_and_api_key')}</h3>
|
||||||
<p className="muted-inline" style={{ marginBottom: 8 }}>
|
<div className="sensitive-actions-grid">
|
||||||
{t('txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically')}
|
<div className="sensitive-action">
|
||||||
</p>
|
<div>
|
||||||
<label className="field">
|
<h4>{t('txt_recovery_code')}</h4>
|
||||||
<span>{t('txt_master_password')}</span>
|
<p className="muted-inline settings-field-note">
|
||||||
<input
|
{t('txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically')}
|
||||||
className="input"
|
</p>
|
||||||
type="password"
|
</div>
|
||||||
value={recoveryMasterPassword}
|
|
||||||
onInput={(e) => setRecoveryMasterPassword((e.currentTarget as HTMLInputElement).value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => void loadRecoveryCode()}>
|
<button type="button" className="btn btn-secondary" onClick={() => openMasterPasswordPrompt('recovery')}>
|
||||||
<ShieldCheck size={14} className="btn-icon" />
|
<ShieldCheck size={14} className="btn-icon" />
|
||||||
{t('txt_view_recovery_code')}
|
{t('txt_view_recovery_code')}
|
||||||
</button>
|
</button>
|
||||||
@@ -221,13 +366,109 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{recoveryCode && (
|
{recoveryCode && (
|
||||||
<div className="card" style={{ marginTop: 10, marginBottom: 0 }}>
|
<div className="recovery-code-card">
|
||||||
<div style={{ fontWeight: 800, letterSpacing: '0.08em' }}>{recoveryCode}</div>
|
<div className="recovery-code-value">{recoveryCode}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="sensitive-action">
|
||||||
|
<div>
|
||||||
|
<h4>{t('txt_api_key')}</h4>
|
||||||
|
<p className="muted-inline settings-field-note">{t('txt_api_key_dialog_intro')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => openMasterPasswordPrompt('apiKey')}>
|
||||||
|
<KeyRound size={14} className="btn-icon" />
|
||||||
|
{t('txt_view_api_key')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => setRotateApiKeyConfirmOpen(true)}
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} className="btn-icon" />
|
||||||
|
{t('txt_rotate_api_key')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={masterPasswordPrompt !== null}
|
||||||
|
title={masterPasswordPromptTitle}
|
||||||
|
message={t('txt_enter_master_password_to_continue')}
|
||||||
|
confirmText={t('txt_continue')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
confirmDisabled={masterPasswordPromptSubmitting || !masterPasswordPromptValue.trim()}
|
||||||
|
cancelDisabled={masterPasswordPromptSubmitting}
|
||||||
|
onConfirm={() => void submitMasterPasswordPrompt()}
|
||||||
|
onCancel={closeMasterPasswordPrompt}
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_master_password')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={masterPasswordPromptValue}
|
||||||
|
onInput={(e) => setMasterPasswordPromptValue((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</ConfirmDialog>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={apiKeyDialogOpen}
|
||||||
|
title={t('txt_api_key')}
|
||||||
|
message={t('txt_api_key_dialog_intro')}
|
||||||
|
hideCancel
|
||||||
|
confirmText={t('txt_close')}
|
||||||
|
onConfirm={() => setApiKeyDialogOpen(false)}
|
||||||
|
onCancel={() => setApiKeyDialogOpen(false)}
|
||||||
|
>
|
||||||
|
<div className="api-key-warning-panel">
|
||||||
|
<div className="api-key-warning-title">{t('txt_warning')}</div>
|
||||||
|
<div className="api-key-warning-body">{t('txt_api_key_warning_body')}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="api-key-credentials-panel">
|
||||||
|
<div className="api-key-credentials-title">
|
||||||
|
<KeyRound size={15} />
|
||||||
|
<span>{t('txt_oauth_client_credentials')}</span>
|
||||||
|
</div>
|
||||||
|
{([
|
||||||
|
[t('txt_client_id'), `user.${props.profile.id}`],
|
||||||
|
[t('txt_client_secret'), apiKey],
|
||||||
|
[t('txt_scope'), 'api'],
|
||||||
|
[t('txt_grant_type'), 'client_credentials'],
|
||||||
|
] as [string, string][]).map(([label, value]) => (
|
||||||
|
<label key={label} className="field">
|
||||||
|
<span>{label}</span>
|
||||||
|
<div className="api-key-credential-row">
|
||||||
|
<input className="input" readOnly value={value} onFocus={(e) => (e.currentTarget as HTMLInputElement).select()} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small"
|
||||||
|
onClick={() => void copyTextToClipboard(value, { successMessage: t('txt_copied') })}
|
||||||
|
>
|
||||||
|
<Clipboard size={14} className="btn-icon" />
|
||||||
|
{t('txt_copy')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ConfirmDialog>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={rotateApiKeyConfirmOpen}
|
||||||
|
title={t('txt_rotate_api_key')}
|
||||||
|
message={t('txt_rotate_api_key_confirm')}
|
||||||
|
danger
|
||||||
|
onConfirm={() => {
|
||||||
|
setRotateApiKeyConfirmOpen(false);
|
||||||
|
openMasterPasswordPrompt('rotateApiKey');
|
||||||
|
}}
|
||||||
|
onCancel={() => setRotateApiKeyConfirmOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ export default function StandalonePageFrame(props: StandalonePageFrameProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="standalone-shell">
|
<div className="standalone-shell">
|
||||||
<div className="standalone-brand standalone-brand-outside">
|
<div className="standalone-brand standalone-brand-outside">
|
||||||
<img src="/logo-64.png" alt="NodeWarden logo" className="standalone-brand-logo" />
|
<img src="/nodewarden-logo.svg" alt="NodeWarden logo" className="standalone-brand-logo" />
|
||||||
<div>
|
<div>
|
||||||
<div className="standalone-brand-title">NodeWarden</div>
|
<span className="standalone-brand-wordmark" role="img" aria-label="NodeWarden" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
interface ThemeSwitchProps {
|
||||||
|
checked: boolean;
|
||||||
|
title: string;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ThemeSwitch(props: ThemeSwitchProps) {
|
||||||
|
return (
|
||||||
|
<div className="theme-switch-wrap" title={props.title}>
|
||||||
|
<label className="theme-switch" aria-label={props.title}>
|
||||||
|
<span className="sun" aria-hidden="true">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<g fill="#ffd43b">
|
||||||
|
<circle r={5} cy={12} cx={12} />
|
||||||
|
<path d="m21 13h-1a1 1 0 0 1 0-2h1a1 1 0 0 1 0 2zm-17 0h-1a1 1 0 0 1 0-2h1a1 1 0 0 1 0 2zm13.66-5.66a1 1 0 0 1 -.66-.29 1 1 0 0 1 0-1.41l.71-.71a1 1 0 1 1 1.41 1.41l-.71.71a1 1 0 0 1 -.75.29zm-12.02 12.02a1 1 0 0 1 -.71-.29 1 1 0 0 1 0-1.41l.71-.66a1 1 0 0 1 1.41 1.41l-.71.71a1 1 0 0 1 -.7.24zm6.36-14.36a1 1 0 0 1 -1-1v-1a1 1 0 0 1 2 0v1a1 1 0 0 1 -1 1zm0 17a1 1 0 0 1 -1-1v-1a1 1 0 0 1 2 0v1a1 1 0 0 1 -1 1zm-5.66-14.66a1 1 0 0 1 -.7-.29l-.71-.71a1 1 0 0 1 1.41-1.41l.71.71a1 1 0 0 1 0 1.41 1 1 0 0 1 -.71.29zm12.02 12.02a1 1 0 0 1 -.7-.29l-.66-.71a1 1 0 0 1 1.36-1.36l.71.71a1 1 0 0 1 0 1.41 1 1 0 0 1 -.71.24z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className="moon" aria-hidden="true">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512">
|
||||||
|
<path d="m223.5 32c-123.5 0-223.5 100.3-223.5 224s100 224 223.5 224c60.6 0 115.5-24.2 155.8-63.4 5-4.9 6.3-12.5 3.1-18.7s-10.1-9.7-17-8.5c-9.8 1.7-19.8 2.6-30.1 2.6-96.9 0-175.5-78.8-175.5-176 0-65.8 36-123.1 89.3-153.3 6.1-3.5 9.2-10.5 7.7-17.3s-7.3-11.9-14.3-12.5c-6.3-.5-12.6-.8-19-.8z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input type="checkbox" className="theme-switch-input" checked={props.checked} onInput={props.onToggle} />
|
||||||
|
<span className="theme-switch-slider" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,29 @@
|
|||||||
|
import type { JSX } from 'preact';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { Clipboard, Globe } from 'lucide-preact';
|
import { Clipboard, Globe, GripVertical } from 'lucide-preact';
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
type DragEndEvent,
|
||||||
|
PointerSensor,
|
||||||
|
TouchSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
rectSortingStrategy,
|
||||||
|
SortableContext,
|
||||||
|
useSortable,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
|
import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
|
||||||
import { calcTotpNow } from '@/lib/crypto';
|
import { calcTotpNow } from '@/lib/crypto';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import type { Cipher } from '@/lib/types';
|
import type { Cipher } from '@/lib/types';
|
||||||
import { websiteIconUrl } from '@/components/vault/vault-page-helpers';
|
import LoadingState from '@/components/LoadingState';
|
||||||
|
import WebsiteIcon from '@/components/vault/WebsiteIcon';
|
||||||
|
import { isCipherVisibleInNormalVault } from '@/components/vault/vault-page-helpers';
|
||||||
|
|
||||||
interface TotpCodesPageProps {
|
interface TotpCodesPageProps {
|
||||||
ciphers: Cipher[];
|
ciphers: Cipher[];
|
||||||
@@ -15,7 +34,15 @@ interface TotpCodesPageProps {
|
|||||||
const TOTP_PERIOD_SECONDS = 30;
|
const TOTP_PERIOD_SECONDS = 30;
|
||||||
const TOTP_RING_RADIUS = 14;
|
const TOTP_RING_RADIUS = 14;
|
||||||
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
||||||
const failedIconHosts = new Set<string>();
|
const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order';
|
||||||
|
const TOTP_REFRESH_BATCH_SIZE = 16;
|
||||||
|
function getTotpTimeState(): { windowId: number; remain: number } {
|
||||||
|
const epoch = Math.floor(Date.now() / 1000);
|
||||||
|
return {
|
||||||
|
windowId: Math.floor(epoch / TOTP_PERIOD_SECONDS),
|
||||||
|
remain: TOTP_PERIOD_SECONDS - (epoch % TOTP_PERIOD_SECONDS),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function formatTotp(code: string): string {
|
function formatTotp(code: string): string {
|
||||||
if (!code) return code;
|
if (!code) return code;
|
||||||
@@ -24,97 +51,235 @@ function formatTotp(code: string): string {
|
|||||||
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
|
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function firstCipherUri(cipher: Cipher): string {
|
|
||||||
const uris = cipher.login?.uris || [];
|
|
||||||
for (const uri of uris) {
|
|
||||||
const raw = uri.decUri || uri.uri || '';
|
|
||||||
if (raw.trim()) return raw.trim();
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function hostFromUri(uri: string): string {
|
|
||||||
if (!uri.trim()) return '';
|
|
||||||
try {
|
|
||||||
const normalized = /^https?:\/\//i.test(uri) ? uri : `https://${uri}`;
|
|
||||||
return new URL(normalized).hostname || '';
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
||||||
const uri = firstCipherUri(cipher);
|
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
|
||||||
const host = hostFromUri(uri);
|
}
|
||||||
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
|
|
||||||
if (host && !errored) {
|
interface SortableTotpRowProps {
|
||||||
return (
|
cipher: Cipher;
|
||||||
<img
|
live: { code: string; remain: number } | null;
|
||||||
className="list-icon"
|
onCopy: (value: string) => void;
|
||||||
src={websiteIconUrl(host)}
|
}
|
||||||
alt=""
|
|
||||||
loading="lazy"
|
function SortableTotpRow(props: SortableTotpRowProps) {
|
||||||
referrerPolicy="no-referrer"
|
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
onError={() => {
|
id: props.cipher.id,
|
||||||
failedIconHosts.add(host);
|
});
|
||||||
setErrored(true);
|
const dragButtonAttributes = attributes as JSX.HTMLAttributes<HTMLButtonElement>;
|
||||||
}}
|
|
||||||
/>
|
const style = {
|
||||||
);
|
transform: CSS.Transform.toString(transform),
|
||||||
}
|
transition,
|
||||||
|
};
|
||||||
|
|
||||||
|
const name = props.cipher.decName || props.cipher.name || t('txt_no_name');
|
||||||
|
const username = props.cipher.login?.decUsername || '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="list-icon-fallback">
|
<div ref={setNodeRef} style={style} className={`totp-code-row${isDragging ? ' is-dragging' : ''}`}>
|
||||||
<Globe size={18} />
|
<button
|
||||||
</span>
|
type="button"
|
||||||
|
ref={setActivatorNodeRef}
|
||||||
|
className="btn btn-secondary small totp-drag-btn"
|
||||||
|
title={t('txt_drag_to_reorder')}
|
||||||
|
aria-label={t('txt_drag_to_reorder')}
|
||||||
|
{...dragButtonAttributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical size={14} className="btn-icon" />
|
||||||
|
</button>
|
||||||
|
<div className="totp-code-info">
|
||||||
|
<div className="list-icon-wrap">
|
||||||
|
<TotpListIcon cipher={props.cipher} />
|
||||||
|
</div>
|
||||||
|
<div className="totp-code-meta">
|
||||||
|
<div className="totp-code-name" title={name}>{name}</div>
|
||||||
|
<div className="totp-code-username" title={username}>{username || t('txt_no_username')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="totp-code-main">
|
||||||
|
<strong>{props.live ? formatTotp(props.live.code) : t('txt_text_3')}</strong>
|
||||||
|
<div
|
||||||
|
className="totp-timer"
|
||||||
|
title={t('txt_refresh_in_seconds_s', { seconds: props.live ? props.live.remain : 0 })}
|
||||||
|
aria-label={t('txt_refresh_in_seconds_s', { seconds: props.live ? props.live.remain : 0 })}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 36 36" className="totp-ring" role="presentation" aria-hidden="true">
|
||||||
|
<circle className="totp-ring-track" cx="18" cy="18" r={TOTP_RING_RADIUS} />
|
||||||
|
<circle
|
||||||
|
className="totp-ring-progress"
|
||||||
|
cx="18"
|
||||||
|
cy="18"
|
||||||
|
r={TOTP_RING_RADIUS}
|
||||||
|
style={{
|
||||||
|
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
|
||||||
|
strokeDashoffset: String(
|
||||||
|
TOTP_RING_CIRCUMFERENCE -
|
||||||
|
TOTP_RING_CIRCUMFERENCE *
|
||||||
|
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.live?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="totp-timer-value">{props.live ? props.live.remain : 0}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-secondary small totp-copy-btn" onClick={() => props.onCopy(props.live?.code || '')} aria-label={t('txt_copy')}>
|
||||||
|
<Clipboard size={14} className="btn-icon" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TotpCodesPage(props: TotpCodesPageProps) {
|
export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||||
const [totpMap, setTotpMap] = useState<Record<string, { code: string; remain: number } | null>>({});
|
const [totpCodes, setTotpCodes] = useState<Record<string, string | null>>({});
|
||||||
|
const [remainingSeconds, setRemainingSeconds] = useState(() => getTotpTimeState().remain);
|
||||||
const [columnCount, setColumnCount] = useState(1);
|
const [columnCount, setColumnCount] = useState(1);
|
||||||
|
const [orderedIds, setOrderedIds] = useState<string[]>(() => {
|
||||||
|
if (typeof window === 'undefined') return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(String(window.localStorage.getItem(TOTP_ORDER_STORAGE_KEY) || '[]'));
|
||||||
|
return Array.isArray(parsed) ? parsed.map((id) => String(id || '').trim()).filter(Boolean) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
const listRef = useRef<HTMLDivElement | null>(null);
|
const listRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const hasLoadedTotpItemsRef = useRef(false);
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 6,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 120,
|
||||||
|
tolerance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
async function copyToClipboard(value: string): Promise<void> {
|
async function copyToClipboard(value: string): Promise<void> {
|
||||||
await copyTextWithFeedback(value, { successMessage: t('txt_code_copied') });
|
await copyTextWithFeedback(value, { successMessage: t('txt_code_copied') });
|
||||||
}
|
}
|
||||||
|
|
||||||
const totpItems = useMemo(
|
const nameCollator = useMemo(
|
||||||
|
() => new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseTotpItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
props.ciphers
|
props.ciphers
|
||||||
.filter((cipher) => {
|
.filter((cipher) => isCipherVisibleInNormalVault(cipher) && !!cipher.login?.decTotp)
|
||||||
const isDeleted = !!(cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt);
|
|
||||||
return !isDeleted && !!cipher.login?.decTotp;
|
|
||||||
})
|
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const nameA = (a.decName || a.name || '').trim().toLowerCase();
|
const nameA = (a.decName || a.name || '').trim();
|
||||||
const nameB = (b.decName || b.name || '').trim().toLowerCase();
|
const nameB = (b.decName || b.name || '').trim();
|
||||||
return nameA.localeCompare(nameB);
|
return nameCollator.compare(nameA, nameB);
|
||||||
}),
|
}),
|
||||||
[props.ciphers]
|
[props.ciphers, nameCollator]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const totpItems = useMemo(() => {
|
||||||
|
if (!baseTotpItems.length) return [];
|
||||||
|
const orderMap = new Map(orderedIds.map((id, index) => [id, index]));
|
||||||
|
return [...baseTotpItems].sort((a, b) => {
|
||||||
|
const orderA = orderMap.get(a.id);
|
||||||
|
const orderB = orderMap.get(b.id);
|
||||||
|
if (orderA != null && orderB != null) return orderA - orderB;
|
||||||
|
if (orderA != null) return -1;
|
||||||
|
if (orderB != null) return 1;
|
||||||
|
const nameA = (a.decName || a.name || '').trim();
|
||||||
|
const nameB = (b.decName || b.name || '').trim();
|
||||||
|
return nameCollator.compare(nameA, nameB);
|
||||||
|
});
|
||||||
|
}, [baseTotpItems, orderedIds, nameCollator]);
|
||||||
|
|
||||||
|
const sortableTotpItems = useMemo(() => totpItems.map((cipher) => cipher.id), [totpItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!baseTotpItems.length) return;
|
||||||
|
hasLoadedTotpItemsRef.current = true;
|
||||||
|
const validIds = new Set(baseTotpItems.map((cipher) => cipher.id));
|
||||||
|
setOrderedIds((prev) => {
|
||||||
|
const filtered = prev.filter((id) => validIds.has(id));
|
||||||
|
const missing = baseTotpItems.map((cipher) => cipher.id).filter((id) => !filtered.includes(id));
|
||||||
|
const next = [...filtered, ...missing];
|
||||||
|
if (next.length === prev.length && next.every((id, index) => id === prev[index])) return prev;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [baseTotpItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (!hasLoadedTotpItemsRef.current) return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(TOTP_ORDER_STORAGE_KEY, JSON.stringify(orderedIds));
|
||||||
|
} catch {
|
||||||
|
// ignore storage write failures
|
||||||
|
}
|
||||||
|
}, [orderedIds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!totpItems.length) {
|
if (!totpItems.length) {
|
||||||
setTotpMap({});
|
setTotpCodes({});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
|
let activeRun = 0;
|
||||||
let timer = 0;
|
let timer = 0;
|
||||||
const tick = async () => {
|
let currentWindowId = -1;
|
||||||
const entries = await Promise.all(
|
|
||||||
totpItems.map(async (cipher) => {
|
const refreshCodes = async () => {
|
||||||
try {
|
const runId = ++activeRun;
|
||||||
const next = await calcTotpNow(cipher.login?.decTotp || '');
|
const nextCodes: Record<string, string | null> = {};
|
||||||
return [cipher.id, next] as const;
|
for (let start = 0; start < totpItems.length; start += TOTP_REFRESH_BATCH_SIZE) {
|
||||||
} catch {
|
if (stopped || runId !== activeRun) return;
|
||||||
return [cipher.id, null] as const;
|
const batch = totpItems.slice(start, start + TOTP_REFRESH_BATCH_SIZE);
|
||||||
}
|
const entries = await Promise.all(
|
||||||
})
|
batch.map(async (cipher) => {
|
||||||
);
|
try {
|
||||||
if (!stopped) setTotpMap(Object.fromEntries(entries));
|
const next = await calcTotpNow(cipher.login?.decTotp || '');
|
||||||
|
return [cipher.id, next?.code || null] as const;
|
||||||
|
} catch {
|
||||||
|
return [cipher.id, null] as const;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
for (const [id, code] of entries) nextCodes[id] = code;
|
||||||
|
if (start + TOTP_REFRESH_BATCH_SIZE < totpItems.length) {
|
||||||
|
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stopped || runId !== activeRun) return;
|
||||||
|
setTotpCodes((prev) => {
|
||||||
|
let changed = false;
|
||||||
|
const next: Record<string, string | null> = { ...prev };
|
||||||
|
for (const id of Object.keys(next)) {
|
||||||
|
if (id in nextCodes) continue;
|
||||||
|
delete next[id];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
for (const [id, code] of Object.entries(nextCodes)) {
|
||||||
|
if (next[id] === code) continue;
|
||||||
|
next[id] = code;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
void tick();
|
|
||||||
timer = window.setInterval(() => void tick(), 1000);
|
const tick = () => {
|
||||||
|
const next = getTotpTimeState();
|
||||||
|
setRemainingSeconds((prev) => (prev === next.remain ? prev : next.remain));
|
||||||
|
if (next.windowId === currentWindowId) return;
|
||||||
|
currentWindowId = next.windowId;
|
||||||
|
void refreshCodes();
|
||||||
|
};
|
||||||
|
|
||||||
|
tick();
|
||||||
|
timer = window.setInterval(tick, 1000);
|
||||||
return () => {
|
return () => {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
@@ -142,6 +307,16 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const activeId = String(event.active.id);
|
||||||
|
const overId = event.over ? String(event.over.id) : null;
|
||||||
|
if (!overId || activeId === overId) return;
|
||||||
|
const fromIndex = orderedIds.indexOf(activeId);
|
||||||
|
const toIndex = orderedIds.indexOf(overId);
|
||||||
|
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return;
|
||||||
|
setOrderedIds((prev) => arrayMove(prev, fromIndex, toIndex));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="totp-codes-page">
|
<div className="totp-codes-page">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -153,55 +328,20 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
className="totp-codes-list"
|
className="totp-codes-list"
|
||||||
style={{ '--totp-columns': String(columnCount) } as Record<string, string>}
|
style={{ '--totp-columns': String(columnCount) } as Record<string, string>}
|
||||||
>
|
>
|
||||||
|
{!totpItems.length && props.loading && <LoadingState lines={6} />}
|
||||||
{!totpItems.length && !props.loading && <div className="empty">{t('txt_no_verification_codes')}</div>}
|
{!totpItems.length && !props.loading && <div className="empty">{t('txt_no_verification_codes')}</div>}
|
||||||
{totpItems.map((cipher) => {
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
const live = totpMap[cipher.id] || null;
|
<SortableContext items={sortableTotpItems} strategy={rectSortingStrategy}>
|
||||||
const name = cipher.decName || cipher.name || t('txt_no_name');
|
{totpItems.map((cipher) => (
|
||||||
const username = cipher.login?.decUsername || '';
|
<SortableTotpRow
|
||||||
return (
|
key={cipher.id}
|
||||||
<div key={cipher.id} className="totp-code-row">
|
cipher={cipher}
|
||||||
<div className="totp-code-info">
|
live={totpCodes[cipher.id] ? { code: totpCodes[cipher.id] || '', remain: remainingSeconds } : null}
|
||||||
<div className="list-icon-wrap">
|
onCopy={(value) => void copyToClipboard(value)}
|
||||||
<TotpListIcon cipher={cipher} />
|
/>
|
||||||
</div>
|
))}
|
||||||
<div className="totp-code-meta">
|
</SortableContext>
|
||||||
<div className="totp-code-name" title={name}>{name}</div>
|
</DndContext>
|
||||||
<div className="totp-code-username" title={username}>{username || t('txt_no_username')}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="totp-code-main">
|
|
||||||
<strong>{live ? formatTotp(live.code) : t('txt_text_3')}</strong>
|
|
||||||
<div
|
|
||||||
className="totp-timer"
|
|
||||||
title={t('txt_refresh_in_seconds_s', { seconds: live ? live.remain : 0 })}
|
|
||||||
aria-label={t('txt_refresh_in_seconds_s', { seconds: live ? live.remain : 0 })}
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 36 36" className="totp-ring" role="presentation" aria-hidden="true">
|
|
||||||
<circle className="totp-ring-track" cx="18" cy="18" r={TOTP_RING_RADIUS} />
|
|
||||||
<circle
|
|
||||||
className="totp-ring-progress"
|
|
||||||
cx="18"
|
|
||||||
cy="18"
|
|
||||||
r={TOTP_RING_RADIUS}
|
|
||||||
style={{
|
|
||||||
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
|
|
||||||
strokeDashoffset: String(
|
|
||||||
TOTP_RING_CIRCUMFERENCE -
|
|
||||||
TOTP_RING_CIRCUMFERENCE *
|
|
||||||
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, live?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span className="totp-timer-value">{live ? live.remain : 0}</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" className="btn btn-secondary small totp-copy-btn" onClick={() => void copyToClipboard(live?.code || '')} aria-label={t('txt_copy')}>
|
|
||||||
<Clipboard size={14} className="btn-icon" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
|
import LoadingState from '@/components/LoadingState';
|
||||||
import VaultDialogs from '@/components/vault/VaultDialogs';
|
import VaultDialogs from '@/components/vault/VaultDialogs';
|
||||||
import VaultDetailView from '@/components/vault/VaultDetailView';
|
import VaultDetailView from '@/components/vault/VaultDetailView';
|
||||||
import VaultEditor from '@/components/vault/VaultEditor';
|
import VaultEditor from '@/components/vault/VaultEditor';
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
MOBILE_LAYOUT_QUERY,
|
MOBILE_LAYOUT_QUERY,
|
||||||
VAULT_LIST_OVERSCAN,
|
VAULT_LIST_OVERSCAN,
|
||||||
VAULT_LIST_ROW_HEIGHT,
|
VAULT_LIST_ROW_HEIGHT,
|
||||||
|
FOLDER_SORT_STORAGE_KEY,
|
||||||
VAULT_SORT_STORAGE_KEY,
|
VAULT_SORT_STORAGE_KEY,
|
||||||
cipherTypeKey,
|
cipherTypeKey,
|
||||||
cipherTypeLabel,
|
cipherTypeLabel,
|
||||||
@@ -17,6 +19,9 @@ import {
|
|||||||
buildCipherDuplicateSignature,
|
buildCipherDuplicateSignature,
|
||||||
firstCipherUri,
|
firstCipherUri,
|
||||||
firstPasskeyCreationTime,
|
firstPasskeyCreationTime,
|
||||||
|
isCipherVisibleInArchive,
|
||||||
|
isCipherVisibleInNormalVault,
|
||||||
|
isCipherVisibleInTrash,
|
||||||
sortTimeValue,
|
sortTimeValue,
|
||||||
type SidebarFilter,
|
type SidebarFilter,
|
||||||
type VaultSortMode,
|
type VaultSortMode,
|
||||||
@@ -36,13 +41,18 @@ interface VaultPageProps {
|
|||||||
onCreate: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
|
onCreate: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
|
||||||
onUpdate: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>;
|
onUpdate: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>;
|
||||||
onDelete: (cipher: Cipher) => Promise<void>;
|
onDelete: (cipher: Cipher) => Promise<void>;
|
||||||
|
onArchive: (cipher: Cipher) => Promise<void>;
|
||||||
|
onUnarchive: (cipher: Cipher) => Promise<void>;
|
||||||
onBulkDelete: (ids: string[]) => Promise<void>;
|
onBulkDelete: (ids: string[]) => Promise<void>;
|
||||||
onBulkPermanentDelete: (ids: string[]) => Promise<void>;
|
onBulkPermanentDelete: (ids: string[]) => Promise<void>;
|
||||||
onBulkRestore: (ids: string[]) => Promise<void>;
|
onBulkRestore: (ids: string[]) => Promise<void>;
|
||||||
|
onBulkArchive: (ids: string[]) => Promise<void>;
|
||||||
|
onBulkUnarchive: (ids: string[]) => Promise<void>;
|
||||||
onBulkMove: (ids: string[], folderId: string | null) => Promise<void>;
|
onBulkMove: (ids: string[], folderId: string | null) => Promise<void>;
|
||||||
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
|
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
|
||||||
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
onNotify: (type: 'success' | 'error' | 'warning', text: string) => 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>;
|
||||||
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
|
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
|
||||||
@@ -50,15 +60,22 @@ interface VaultPageProps {
|
|||||||
attachmentDownloadPercent: number | null;
|
attachmentDownloadPercent: number | null;
|
||||||
uploadingAttachmentName: string;
|
uploadingAttachmentName: string;
|
||||||
attachmentUploadPercent: number | null;
|
attachmentUploadPercent: number | null;
|
||||||
|
mobileSidebarToggleKey: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default function VaultPage(props: VaultPageProps) {
|
export default function VaultPage(props: VaultPageProps) {
|
||||||
|
const getInitialIsMobileLayout = () =>
|
||||||
|
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||||
|
? window.matchMedia(MOBILE_LAYOUT_QUERY).matches
|
||||||
|
: false;
|
||||||
const [searchInput, setSearchInput] = useState('');
|
const [searchInput, setSearchInput] = useState('');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchComposing, setSearchComposing] = useState(false);
|
const [searchComposing, setSearchComposing] = useState(false);
|
||||||
const [sortMode, setSortMode] = useState<VaultSortMode>('edited');
|
const [sortMode, setSortMode] = useState<VaultSortMode>('edited');
|
||||||
const [sortMenuOpen, setSortMenuOpen] = useState(false);
|
const [sortMenuOpen, setSortMenuOpen] = useState(false);
|
||||||
|
const [folderSortMode, setFolderSortMode] = useState<VaultSortMode>('name');
|
||||||
|
const [folderSortMenuOpen, setFolderSortMenuOpen] = useState(false);
|
||||||
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
|
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
|
||||||
const [selectedCipherId, setSelectedCipherId] = useState('');
|
const [selectedCipherId, setSelectedCipherId] = useState('');
|
||||||
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
||||||
@@ -72,12 +89,16 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
const [fieldLabel, setFieldLabel] = useState('');
|
const [fieldLabel, setFieldLabel] = useState('');
|
||||||
const [fieldValue, setFieldValue] = useState('');
|
const [fieldValue, setFieldValue] = useState('');
|
||||||
const [localError, setLocalError] = useState('');
|
const [localError, setLocalError] = useState('');
|
||||||
|
const [pendingArchive, setPendingArchive] = useState<Cipher | null>(null);
|
||||||
const [pendingDelete, setPendingDelete] = useState<Cipher | null>(null);
|
const [pendingDelete, setPendingDelete] = useState<Cipher | null>(null);
|
||||||
|
const [bulkArchiveOpen, setBulkArchiveOpen] = useState(false);
|
||||||
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
||||||
const [moveOpen, setMoveOpen] = useState(false);
|
const [moveOpen, setMoveOpen] = useState(false);
|
||||||
const [moveFolderId, setMoveFolderId] = useState('__none__');
|
const [moveFolderId, setMoveFolderId] = useState('__none__');
|
||||||
const [createFolderOpen, setCreateFolderOpen] = useState(false);
|
const [createFolderOpen, setCreateFolderOpen] = useState(false);
|
||||||
const [newFolderName, setNewFolderName] = useState('');
|
const [newFolderName, setNewFolderName] = useState('');
|
||||||
|
const [pendingRenameFolder, setPendingRenameFolder] = useState<Folder | null>(null);
|
||||||
|
const [renameFolderName, setRenameFolderName] = useState('');
|
||||||
const [pendingDeleteFolder, setPendingDeleteFolder] = useState<Folder | null>(null);
|
const [pendingDeleteFolder, setPendingDeleteFolder] = useState<Folder | null>(null);
|
||||||
const [deleteAllFoldersOpen, setDeleteAllFoldersOpen] = useState(false);
|
const [deleteAllFoldersOpen, setDeleteAllFoldersOpen] = useState(false);
|
||||||
const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null);
|
const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null);
|
||||||
@@ -88,15 +109,20 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
const [repromptOpen, setRepromptOpen] = useState(false);
|
const [repromptOpen, setRepromptOpen] = useState(false);
|
||||||
const [repromptPassword, setRepromptPassword] = useState('');
|
const [repromptPassword, setRepromptPassword] = useState('');
|
||||||
const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null);
|
const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null);
|
||||||
const [isMobileLayout, setIsMobileLayout] = useState(false);
|
const [pendingDeletePasskeyIndex, setPendingDeletePasskeyIndex] = useState<number | null>(null);
|
||||||
|
const [isMobileLayout, setIsMobileLayout] = useState(getInitialIsMobileLayout);
|
||||||
const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list');
|
const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list');
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
const createMenuRef = useRef<HTMLDivElement | null>(null);
|
const createMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
const sortMenuRef = useRef<HTMLDivElement | null>(null);
|
const sortMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const folderSortMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const listPanelRef = useRef<HTMLDivElement | null>(null);
|
const listPanelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const mobileSidebarToggleKeyRef = useRef(props.mobileSidebarToggleKey);
|
||||||
|
|
||||||
const sshSeedTicketRef = useRef(0);
|
const sshSeedTicketRef = useRef(0);
|
||||||
const sshFingerprintTicketRef = useRef(0);
|
const sshFingerprintTicketRef = useRef(0);
|
||||||
|
const listScrollBucketRef = useRef(0);
|
||||||
const [listScrollTop, setListScrollTop] = useState(0);
|
const [listScrollTop, setListScrollTop] = useState(0);
|
||||||
const [listViewportHeight, setListViewportHeight] = useState(0);
|
const [listViewportHeight, setListViewportHeight] = useState(0);
|
||||||
|
|
||||||
@@ -114,12 +140,10 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onToggleSidebar = () => {
|
if (props.mobileSidebarToggleKey === mobileSidebarToggleKeyRef.current) return;
|
||||||
setMobileSidebarOpen((open) => !open);
|
mobileSidebarToggleKeyRef.current = props.mobileSidebarToggleKey;
|
||||||
};
|
setMobileSidebarOpen((open) => !open);
|
||||||
window.addEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
|
}, [props.mobileSidebarToggleKey]);
|
||||||
return () => window.removeEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onQuickAdd = () => {
|
const onQuickAdd = () => {
|
||||||
@@ -148,6 +172,25 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
}
|
}
|
||||||
}, [sortMode]);
|
}, [sortMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const saved = String(localStorage.getItem(FOLDER_SORT_STORAGE_KEY) || '').trim() as VaultSortMode;
|
||||||
|
if (saved === 'edited' || saved === 'created' || saved === 'name') {
|
||||||
|
setFolderSortMode(saved);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore storage read failures
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(FOLDER_SORT_STORAGE_KEY, folderSortMode);
|
||||||
|
} catch {
|
||||||
|
// ignore storage write failures
|
||||||
|
}
|
||||||
|
}, [folderSortMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const node = listPanelRef.current;
|
const node = listPanelRef.current;
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
@@ -196,6 +239,25 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
};
|
};
|
||||||
}, [sortMenuOpen]);
|
}, [sortMenuOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPointerDown = (event: Event) => {
|
||||||
|
if (!folderSortMenuOpen) return;
|
||||||
|
const target = event.target as Node | null;
|
||||||
|
if (folderSortMenuRef.current && target && !folderSortMenuRef.current.contains(target)) {
|
||||||
|
setFolderSortMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') setFolderSortMenuOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', onPointerDown);
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', onPointerDown);
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
};
|
||||||
|
}, [folderSortMenuOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRepromptApprovedCipherId(null);
|
setRepromptApprovedCipherId(null);
|
||||||
setRepromptPassword('');
|
setRepromptPassword('');
|
||||||
@@ -226,29 +288,75 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
void recalculateSshFingerprint(draft.sshPublicKey);
|
void recalculateSshFingerprint(draft.sshPublicKey);
|
||||||
}, [isEditing, draft?.id, draft?.type]);
|
}, [isEditing, draft?.id, draft?.type]);
|
||||||
|
|
||||||
const duplicateSignatureCounts = useMemo(() => {
|
const cipherMetaById = useMemo(() => {
|
||||||
|
const meta = new Map<string, {
|
||||||
|
name: string;
|
||||||
|
searchText: string;
|
||||||
|
firstUri: string;
|
||||||
|
typeKey: string;
|
||||||
|
sortTime: number;
|
||||||
|
creationTime: number;
|
||||||
|
}>();
|
||||||
|
for (const cipher of props.ciphers) {
|
||||||
|
const name = String(cipher.decName || cipher.name || '');
|
||||||
|
const username = String(cipher.login?.decUsername || '');
|
||||||
|
const uri = firstCipherUri(cipher);
|
||||||
|
meta.set(cipher.id, {
|
||||||
|
name,
|
||||||
|
searchText: `${name}\n${username}\n${uri}`.toLowerCase(),
|
||||||
|
firstUri: uri,
|
||||||
|
typeKey: cipherTypeKey(Number(cipher.type || 1)),
|
||||||
|
sortTime: sortTimeValue(cipher),
|
||||||
|
creationTime: creationTimeValue(cipher),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return meta;
|
||||||
|
}, [props.ciphers]);
|
||||||
|
|
||||||
|
const cipherById = useMemo(() => {
|
||||||
|
const map = new Map<string, Cipher>();
|
||||||
|
for (const cipher of props.ciphers) map.set(cipher.id, cipher);
|
||||||
|
return map;
|
||||||
|
}, [props.ciphers]);
|
||||||
|
|
||||||
|
const folderById = useMemo(() => {
|
||||||
|
const map = new Map<string, Folder>();
|
||||||
|
for (const folder of props.folders) map.set(folder.id, folder);
|
||||||
|
return map;
|
||||||
|
}, [props.folders]);
|
||||||
|
|
||||||
|
const nameCollator = useMemo(
|
||||||
|
() => new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const duplicateSignatureInfo = useMemo(() => {
|
||||||
|
if (sidebarFilter.kind !== 'duplicates') return null;
|
||||||
|
const byId = new Map<string, string>();
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
for (const cipher of props.ciphers) {
|
for (const cipher of props.ciphers) {
|
||||||
const isDeleted = !!(cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt);
|
if (!isCipherVisibleInNormalVault(cipher)) continue;
|
||||||
if (isDeleted) continue;
|
|
||||||
const signature = buildCipherDuplicateSignature(cipher);
|
const signature = buildCipherDuplicateSignature(cipher);
|
||||||
|
byId.set(cipher.id, signature);
|
||||||
counts.set(signature, (counts.get(signature) || 0) + 1);
|
counts.set(signature, (counts.get(signature) || 0) + 1);
|
||||||
}
|
}
|
||||||
return counts;
|
return { byId, counts };
|
||||||
}, [props.ciphers]);
|
}, [props.ciphers, sidebarFilter.kind]);
|
||||||
|
|
||||||
const filteredCiphers = useMemo(() => {
|
const filteredCiphers = useMemo(() => {
|
||||||
const next = props.ciphers.filter((cipher) => {
|
const next = props.ciphers.filter((cipher) => {
|
||||||
const isDeleted = !!(cipher.deletedDate || (cipher as any).deletedAt);
|
const meta = cipherMetaById.get(cipher.id);
|
||||||
if (sidebarFilter.kind === 'trash') {
|
if (sidebarFilter.kind === 'trash') {
|
||||||
if (!isDeleted) return false;
|
if (!isCipherVisibleInTrash(cipher)) return false;
|
||||||
|
} else if (sidebarFilter.kind === 'archive') {
|
||||||
|
if (!isCipherVisibleInArchive(cipher)) return false;
|
||||||
} else {
|
} else {
|
||||||
if (isDeleted) return false;
|
if (!isCipherVisibleInNormalVault(cipher)) return false;
|
||||||
if (sidebarFilter.kind === 'duplicates' && (duplicateSignatureCounts.get(buildCipherDuplicateSignature(cipher)) || 0) < 2) {
|
if (sidebarFilter.kind === 'duplicates' && ((duplicateSignatureInfo?.counts.get(duplicateSignatureInfo.byId.get(cipher.id) || '') || 0) < 2)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false;
|
if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false;
|
||||||
if (sidebarFilter.kind === 'type' && cipherTypeKey(Number(cipher.type || 1)) !== sidebarFilter.value) return false;
|
if (sidebarFilter.kind === 'type' && meta?.typeKey !== sidebarFilter.value) return false;
|
||||||
if (sidebarFilter.kind === 'folder') {
|
if (sidebarFilter.kind === 'folder') {
|
||||||
if (sidebarFilter.folderId === null) {
|
if (sidebarFilter.folderId === null) {
|
||||||
if (cipher.folderId) return false;
|
if (cipher.folderId) return false;
|
||||||
@@ -258,24 +366,20 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!searchQuery) return true;
|
if (!searchQuery) return true;
|
||||||
const name = (cipher.decName || '').toLowerCase();
|
return !!meta?.searchText.includes(searchQuery);
|
||||||
const username = (cipher.login?.decUsername || '').toLowerCase();
|
|
||||||
const uri = firstCipherUri(cipher).toLowerCase();
|
|
||||||
return name.includes(searchQuery) || username.includes(searchQuery) || uri.includes(searchQuery);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
next.sort((a, b) => {
|
next.sort((a, b) => {
|
||||||
|
const metaA = cipherMetaById.get(a.id);
|
||||||
|
const metaB = cipherMetaById.get(b.id);
|
||||||
if (sortMode === 'edited') {
|
if (sortMode === 'edited') {
|
||||||
const diff = sortTimeValue(b) - sortTimeValue(a);
|
const diff = (metaB?.sortTime || 0) - (metaA?.sortTime || 0);
|
||||||
if (diff !== 0) return diff;
|
if (diff !== 0) return diff;
|
||||||
} else if (sortMode === 'created') {
|
} else if (sortMode === 'created') {
|
||||||
const diff = creationTimeValue(b) - creationTimeValue(a);
|
const diff = (metaB?.creationTime || 0) - (metaA?.creationTime || 0);
|
||||||
if (diff !== 0) return diff;
|
if (diff !== 0) return diff;
|
||||||
} else {
|
} else {
|
||||||
const nameDiff = String(a.decName || a.name || '').localeCompare(String(b.decName || b.name || ''), undefined, {
|
const nameDiff = nameCollator.compare(metaA?.name || '', metaB?.name || '');
|
||||||
sensitivity: 'base',
|
|
||||||
numeric: true,
|
|
||||||
});
|
|
||||||
if (nameDiff !== 0) return nameDiff;
|
if (nameDiff !== 0) return nameDiff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +387,13 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
}, [props.ciphers, sidebarFilter, searchQuery, sortMode, duplicateSignatureCounts]);
|
}, [props.ciphers, cipherMetaById, sidebarFilter, searchQuery, sortMode, duplicateSignatureInfo, nameCollator]);
|
||||||
|
|
||||||
|
const filteredCipherIds = useMemo(() => {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const cipher of filteredCiphers) ids.add(cipher.id);
|
||||||
|
return ids;
|
||||||
|
}, [filteredCiphers]);
|
||||||
|
|
||||||
const sidebarFilterKey = useMemo(() => {
|
const sidebarFilterKey = useMemo(() => {
|
||||||
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
|
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
|
||||||
@@ -293,6 +403,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setListScrollTop(0);
|
setListScrollTop(0);
|
||||||
|
listScrollBucketRef.current = 0;
|
||||||
listPanelRef.current?.scrollTo({ top: 0 });
|
listPanelRef.current?.scrollTo({ top: 0 });
|
||||||
}, [searchQuery, sortMode, sidebarFilterKey]);
|
}, [searchQuery, sortMode, sidebarFilterKey]);
|
||||||
|
|
||||||
@@ -308,15 +419,12 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
if (selectedCipherId) setSelectedCipherId('');
|
if (selectedCipherId) setSelectedCipherId('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!selectedCipherId || !filteredCiphers.some((x) => x.id === selectedCipherId)) {
|
if (!selectedCipherId || !filteredCipherIds.has(selectedCipherId)) {
|
||||||
setSelectedCipherId(filteredCiphers[0].id);
|
setSelectedCipherId(filteredCiphers[0].id);
|
||||||
}
|
}
|
||||||
}, [filteredCiphers, selectedCipherId, isCreating]);
|
}, [filteredCiphers, filteredCipherIds, selectedCipherId, isCreating]);
|
||||||
|
|
||||||
const selectedCipher = useMemo(
|
const selectedCipher = useMemo(() => cipherById.get(selectedCipherId) || null, [cipherById, selectedCipherId]);
|
||||||
() => props.ciphers.find((x) => x.id === selectedCipherId) || null,
|
|
||||||
[props.ciphers, selectedCipherId]
|
|
||||||
);
|
|
||||||
const virtualRange = useMemo(() => {
|
const virtualRange = useMemo(() => {
|
||||||
if (!filteredCiphers.length) {
|
if (!filteredCiphers.length) {
|
||||||
return { start: 0, end: 0, padTop: 0, padBottom: 0 };
|
return { start: 0, end: 0, padTop: 0, padBottom: 0 };
|
||||||
@@ -336,7 +444,6 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
() => filteredCiphers.slice(virtualRange.start, virtualRange.end),
|
() => filteredCiphers.slice(virtualRange.start, virtualRange.end),
|
||||||
[filteredCiphers, virtualRange.start, virtualRange.end]
|
[filteredCiphers, virtualRange.start, virtualRange.end]
|
||||||
);
|
);
|
||||||
const passkeyCreatedAt = firstPasskeyCreationTime(selectedCipher);
|
|
||||||
const selectedAttachments = useMemo(
|
const selectedAttachments = useMemo(
|
||||||
() => (Array.isArray(selectedCipher?.attachments) ? selectedCipher.attachments : []),
|
() => (Array.isArray(selectedCipher?.attachments) ? selectedCipher.attachments : []),
|
||||||
[selectedCipher]
|
[selectedCipher]
|
||||||
@@ -381,20 +488,27 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
);
|
);
|
||||||
const totalCipherCount = filteredCiphers.length;
|
const totalCipherCount = filteredCiphers.length;
|
||||||
|
|
||||||
function folderName(id: string | null | undefined): string {
|
const folderName = useCallback((id: string | null | undefined): string => {
|
||||||
if (!id) return t('txt_no_folder');
|
if (!id) return t('txt_no_folder');
|
||||||
const folder = props.folders.find((x) => x.id === id);
|
const folder = folderById.get(id);
|
||||||
return folder?.decName || folder?.name || id;
|
return folder?.decName || folder?.name || id;
|
||||||
}
|
}, [folderById]);
|
||||||
|
|
||||||
function listSubtitle(cipher: Cipher): string {
|
const listSubtitle = useCallback((cipher: Cipher): string => {
|
||||||
if (Number(cipher.type || 1) === 1) {
|
if (Number(cipher.type || 1) === 1) {
|
||||||
return cipher.login?.decUsername || firstCipherUri(cipher) || '';
|
return cipher.login?.decUsername || cipherMetaById.get(cipher.id)?.firstUri || '';
|
||||||
}
|
}
|
||||||
return cipherTypeLabel(Number(cipher.type || 1));
|
return cipherTypeLabel(Number(cipher.type || 1));
|
||||||
}
|
}, [cipherMetaById]);
|
||||||
|
|
||||||
function startCreate(type: number): void {
|
const handleListScroll = useCallback((top: number): void => {
|
||||||
|
const bucket = Math.floor(Math.max(0, top) / VAULT_LIST_ROW_HEIGHT);
|
||||||
|
if (bucket === listScrollBucketRef.current) return;
|
||||||
|
listScrollBucketRef.current = bucket;
|
||||||
|
setListScrollTop(top);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startCreate = useCallback((type: number): void => {
|
||||||
setDraft(createEmptyDraft(type));
|
setDraft(createEmptyDraft(type));
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
@@ -407,9 +521,9 @@ function folderName(id: string | null | undefined): string {
|
|||||||
if (isMobileLayout) setMobilePanel('edit');
|
if (isMobileLayout) setMobilePanel('edit');
|
||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
if (type === 5) void seedSshDefaults();
|
if (type === 5) void seedSshDefaults();
|
||||||
}
|
}, [isMobileLayout]);
|
||||||
|
|
||||||
function startEdit(): void {
|
const startEdit = useCallback((): void => {
|
||||||
if (!selectedCipher) return;
|
if (!selectedCipher) return;
|
||||||
setDraft(draftFromCipher(selectedCipher));
|
setDraft(draftFromCipher(selectedCipher));
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
@@ -420,9 +534,9 @@ function folderName(id: string | null | undefined): string {
|
|||||||
setRemovedAttachmentIds({});
|
setRemovedAttachmentIds({});
|
||||||
if (isMobileLayout) setMobilePanel('edit');
|
if (isMobileLayout) setMobilePanel('edit');
|
||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
}
|
}, [selectedCipher, isMobileLayout]);
|
||||||
|
|
||||||
function cancelEdit(): void {
|
const cancelEdit = useCallback((): void => {
|
||||||
const returnToDetail = isMobileLayout && !isCreating && !!selectedCipher;
|
const returnToDetail = isMobileLayout && !isCreating && !!selectedCipher;
|
||||||
setDraft(null);
|
setDraft(null);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
@@ -430,11 +544,24 @@ function folderName(id: string | null | undefined): string {
|
|||||||
setLocalError('');
|
setLocalError('');
|
||||||
setAttachmentQueue([]);
|
setAttachmentQueue([]);
|
||||||
setRemovedAttachmentIds({});
|
setRemovedAttachmentIds({});
|
||||||
|
setPendingDeletePasskeyIndex(null);
|
||||||
if (isMobileLayout) setMobilePanel(returnToDetail ? 'detail' : 'list');
|
if (isMobileLayout) setMobilePanel(returnToDetail ? 'detail' : 'list');
|
||||||
}
|
}, [isMobileLayout, isCreating, selectedCipher]);
|
||||||
|
|
||||||
function updateDraft(patch: Partial<VaultDraft>): void {
|
const updateDraft = useCallback((patch: Partial<VaultDraft>): void => {
|
||||||
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
|
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function confirmDeleteLoginPasskey(): void {
|
||||||
|
if (pendingDeletePasskeyIndex == null) return;
|
||||||
|
setDraft((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
loginFido2Credentials: prev.loginFido2Credentials.filter((_, index) => index !== pendingDeletePasskeyIndex),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setPendingDeletePasskeyIndex(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedSshDefaults(force = false): Promise<void> {
|
async function seedSshDefaults(force = false): Promise<void> {
|
||||||
@@ -494,7 +621,30 @@ function folderName(id: string | null | undefined): string {
|
|||||||
setDraft((prev) => {
|
setDraft((prev) => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
const next = [...prev.loginUris];
|
const next = [...prev.loginUris];
|
||||||
next[index] = value;
|
next[index] = { ...(next[index] || { uri: '', match: null }), uri: value };
|
||||||
|
return { ...prev, loginUris: next };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDraftLoginUriMatch(index: number, value: number | null): void {
|
||||||
|
setDraft((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const next = [...prev.loginUris];
|
||||||
|
next[index] = { ...(next[index] || { uri: '', match: null }), match: value };
|
||||||
|
return { ...prev, loginUris: next };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function reorderDraftLoginUri(fromIndex: number, toIndex: number): void {
|
||||||
|
setDraft((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
if (fromIndex < 0 || toIndex < 0 || fromIndex >= prev.loginUris.length || toIndex >= prev.loginUris.length || fromIndex === toIndex) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = [...prev.loginUris];
|
||||||
|
const [moved] = next.splice(fromIndex, 1);
|
||||||
|
if (!moved) return prev;
|
||||||
|
next.splice(toIndex, 0, moved);
|
||||||
return { ...prev, loginUris: next };
|
return { ...prev, loginUris: next };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -663,6 +813,23 @@ function folderName(id: string | null | undefined): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function confirmRenameFolder(): Promise<void> {
|
||||||
|
if (!pendingRenameFolder) return;
|
||||||
|
const nextName = renameFolderName.trim();
|
||||||
|
if (!nextName) {
|
||||||
|
props.onNotify('error', t('txt_folder_name_is_required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await props.onRenameFolder(pendingRenameFolder.id, nextName);
|
||||||
|
setPendingRenameFolder(null);
|
||||||
|
setRenameFolderName('');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function confirmBulkRestore(): Promise<void> {
|
async function confirmBulkRestore(): Promise<void> {
|
||||||
const ids = Object.entries(selectedMap)
|
const ids = Object.entries(selectedMap)
|
||||||
.filter(([, selected]) => selected)
|
.filter(([, selected]) => selected)
|
||||||
@@ -677,6 +844,63 @@ function folderName(id: string | null | undefined): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function confirmArchiveSelected(): Promise<void> {
|
||||||
|
if (!pendingArchive) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await props.onArchive(pendingArchive);
|
||||||
|
setPendingArchive(null);
|
||||||
|
if (isMobileLayout && selectedCipherId === pendingArchive.id) {
|
||||||
|
setMobilePanel('list');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUnarchiveSelected(cipher: Cipher): Promise<void> {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await props.onBulkUnarchive([cipher.id]);
|
||||||
|
setSelectedMap((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[cipher.id];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmBulkArchive(): Promise<void> {
|
||||||
|
const ids = Object.entries(selectedMap)
|
||||||
|
.filter(([, selected]) => selected)
|
||||||
|
.map(([id]) => id);
|
||||||
|
if (!ids.length) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await props.onBulkArchive(ids);
|
||||||
|
setSelectedMap({});
|
||||||
|
setBulkArchiveOpen(false);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmBulkUnarchive(): Promise<void> {
|
||||||
|
const ids = Object.entries(selectedMap)
|
||||||
|
.filter(([, selected]) => selected)
|
||||||
|
.map(([id]) => id);
|
||||||
|
if (!ids.length) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await props.onBulkUnarchive(ids);
|
||||||
|
setSelectedMap({});
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function confirmDeleteAllFolders(): Promise<void> {
|
async function confirmDeleteAllFolders(): Promise<void> {
|
||||||
if (!props.folders.length) return;
|
if (!props.folders.length) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -691,21 +915,107 @@ function folderName(id: string | null | undefined): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleClearSearch = useCallback(() => setSearchInput(''), []);
|
||||||
|
const handleSearchCompositionStart = useCallback(() => setSearchComposing(true), []);
|
||||||
|
const handleSearchCompositionEnd = useCallback((value: string) => {
|
||||||
|
setSearchComposing(false);
|
||||||
|
setSearchInput(value);
|
||||||
|
}, []);
|
||||||
|
const handleToggleSortMenu = useCallback(() => setSortMenuOpen((open) => !open), []);
|
||||||
|
const handleSelectSortMode = useCallback((value: VaultSortMode) => {
|
||||||
|
setSortMode(value);
|
||||||
|
setSortMenuOpen(false);
|
||||||
|
}, []);
|
||||||
|
const handleSyncVault = useCallback(() => { void syncVault(); }, [props.onRefresh]);
|
||||||
|
const handleOpenBulkDelete = useCallback(() => setBulkDeleteOpen(true), []);
|
||||||
|
const handleSelectDuplicates = useCallback(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const cipher of filteredCiphers) {
|
||||||
|
const signature = duplicateSignatureInfo?.byId.get(cipher.id) || buildCipherDuplicateSignature(cipher);
|
||||||
|
if (seen.has(signature)) {
|
||||||
|
map[cipher.id] = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(signature);
|
||||||
|
}
|
||||||
|
setSelectedMap(map);
|
||||||
|
}, [filteredCiphers, duplicateSignatureInfo]);
|
||||||
|
const handleSelectAll = useCallback(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
for (const cipher of filteredCiphers) map[cipher.id] = true;
|
||||||
|
setSelectedMap(map);
|
||||||
|
}, [filteredCiphers]);
|
||||||
|
const handleToggleCreateMenu = useCallback(() => setCreateMenuOpen((open) => !open), []);
|
||||||
|
const handleBulkRestore = useCallback(() => { void confirmBulkRestore(); }, [selectedMap, props.onBulkRestore]);
|
||||||
|
const handleBulkArchive = useCallback(() => setBulkArchiveOpen(true), []);
|
||||||
|
const handleBulkUnarchive = useCallback(() => { void confirmBulkUnarchive(); }, [selectedMap, props.onBulkUnarchive]);
|
||||||
|
const handleOpenMove = useCallback(() => {
|
||||||
|
setMoveFolderId('__none__');
|
||||||
|
setMoveOpen(true);
|
||||||
|
}, []);
|
||||||
|
const handleClearSelection = useCallback(() => setSelectedMap({}), []);
|
||||||
|
const handleToggleSelected = useCallback((cipherId: string, checked: boolean) =>
|
||||||
|
setSelectedMap((prev) => {
|
||||||
|
if (checked) return { ...prev, [cipherId]: true };
|
||||||
|
if (!prev[cipherId]) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[cipherId];
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
, []);
|
||||||
|
const handleSelectCipher = useCallback((cipherId: string) => {
|
||||||
|
if (isEditing || isCreating) {
|
||||||
|
cancelEdit();
|
||||||
|
}
|
||||||
|
setSelectedCipherId(cipherId);
|
||||||
|
setRepromptApprovedCipherId(null);
|
||||||
|
if (isMobileLayout) setMobilePanel('detail');
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
|
}, [isEditing, isCreating, cancelEdit, isMobileLayout]);
|
||||||
|
const handleCloseMobileSidebar = useCallback(() => setMobileSidebarOpen(false), []);
|
||||||
|
const handleOpenDeleteAllFolders = useCallback(() => setDeleteAllFoldersOpen(true), []);
|
||||||
|
const handleOpenCreateFolder = useCallback(() => setCreateFolderOpen(true), []);
|
||||||
|
const handleOpenRenameFolder = useCallback((folder: Folder) => {
|
||||||
|
setPendingRenameFolder(folder);
|
||||||
|
setRenameFolderName(folder.decName || folder.name || '');
|
||||||
|
}, []);
|
||||||
|
const handleToggleFolderSortMenu = useCallback(() => setFolderSortMenuOpen((open) => !open), []);
|
||||||
|
const handleSelectFolderSortMode = useCallback((value: VaultSortMode) => {
|
||||||
|
setFolderSortMode(value);
|
||||||
|
setFolderSortMenuOpen(false);
|
||||||
|
}, []);
|
||||||
|
const handleMobileSidebarMaskClick = useCallback(() => {
|
||||||
|
if (!mobileSidebarOpen) return;
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
|
}, [mobileSidebarOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
|
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
|
||||||
{isMobileLayout && mobileSidebarOpen && <div className="mobile-sidebar-mask" onClick={() => setMobileSidebarOpen(false)} />}
|
{isMobileLayout && (
|
||||||
|
<div
|
||||||
|
className={`mobile-sidebar-mask ${mobileSidebarOpen ? 'open' : ''}`}
|
||||||
|
onClick={handleMobileSidebarMaskClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<VaultSidebar
|
<VaultSidebar
|
||||||
folders={props.folders}
|
folders={props.folders}
|
||||||
sidebarFilter={sidebarFilter}
|
sidebarFilter={sidebarFilter}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
isMobileLayout={isMobileLayout}
|
isMobileLayout={isMobileLayout}
|
||||||
mobileSidebarOpen={mobileSidebarOpen}
|
mobileSidebarOpen={mobileSidebarOpen}
|
||||||
onCloseMobileSidebar={() => setMobileSidebarOpen(false)}
|
folderSortMode={folderSortMode}
|
||||||
|
folderSortMenuOpen={folderSortMenuOpen}
|
||||||
|
folderSortMenuRef={folderSortMenuRef}
|
||||||
|
onCloseMobileSidebar={handleCloseMobileSidebar}
|
||||||
onChangeFilter={setSidebarFilter}
|
onChangeFilter={setSidebarFilter}
|
||||||
onOpenDeleteAllFolders={() => setDeleteAllFoldersOpen(true)}
|
onOpenDeleteAllFolders={handleOpenDeleteAllFolders}
|
||||||
onOpenCreateFolder={() => setCreateFolderOpen(true)}
|
onOpenCreateFolder={handleOpenCreateFolder}
|
||||||
|
onOpenRenameFolder={handleOpenRenameFolder}
|
||||||
onOpenDeleteFolder={setPendingDeleteFolder}
|
onOpenDeleteFolder={setPendingDeleteFolder}
|
||||||
|
onToggleFolderSortMenu={handleToggleFolderSortMenu}
|
||||||
|
onSelectFolderSortMode={handleSelectFolderSortMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<VaultListPanel
|
<VaultListPanel
|
||||||
@@ -722,65 +1032,32 @@ function folderName(id: string | null | undefined): string {
|
|||||||
selectedCipherId={selectedCipherId}
|
selectedCipherId={selectedCipherId}
|
||||||
selectedMap={selectedMap}
|
selectedMap={selectedMap}
|
||||||
sidebarFilter={sidebarFilter}
|
sidebarFilter={sidebarFilter}
|
||||||
|
isMobileLayout={isMobileLayout}
|
||||||
|
mobileFabVisible={!isMobileLayout || mobilePanel === 'list'}
|
||||||
createMenuOpen={createMenuOpen}
|
createMenuOpen={createMenuOpen}
|
||||||
createMenuRef={createMenuRef}
|
createMenuRef={createMenuRef}
|
||||||
sortMenuRef={sortMenuRef}
|
sortMenuRef={sortMenuRef}
|
||||||
listPanelRef={listPanelRef}
|
listPanelRef={listPanelRef}
|
||||||
onSearchInput={setSearchInput}
|
onSearchInput={setSearchInput}
|
||||||
onSearchCompositionStart={() => setSearchComposing(true)}
|
onClearSearch={handleClearSearch}
|
||||||
onSearchCompositionEnd={(value) => {
|
onSearchCompositionStart={handleSearchCompositionStart}
|
||||||
setSearchComposing(false);
|
onSearchCompositionEnd={handleSearchCompositionEnd}
|
||||||
setSearchInput(value);
|
onToggleSortMenu={handleToggleSortMenu}
|
||||||
}}
|
onSelectSortMode={handleSelectSortMode}
|
||||||
onToggleSortMenu={() => setSortMenuOpen((open) => !open)}
|
onSyncVault={handleSyncVault}
|
||||||
onSelectSortMode={(value) => {
|
onOpenBulkDelete={handleOpenBulkDelete}
|
||||||
setSortMode(value);
|
onSelectDuplicates={handleSelectDuplicates}
|
||||||
setSortMenuOpen(false);
|
onSelectAll={handleSelectAll}
|
||||||
}}
|
onToggleCreateMenu={handleToggleCreateMenu}
|
||||||
onSyncVault={() => void syncVault()}
|
|
||||||
onOpenBulkDelete={() => setBulkDeleteOpen(true)}
|
|
||||||
onSelectDuplicates={() => {
|
|
||||||
const map: Record<string, boolean> = {};
|
|
||||||
const seen = new Set<string>();
|
|
||||||
for (const cipher of filteredCiphers) {
|
|
||||||
const signature = buildCipherDuplicateSignature(cipher);
|
|
||||||
if (seen.has(signature)) {
|
|
||||||
map[cipher.id] = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
seen.add(signature);
|
|
||||||
}
|
|
||||||
setSelectedMap(map);
|
|
||||||
}}
|
|
||||||
onSelectAll={() => {
|
|
||||||
const map: Record<string, boolean> = {};
|
|
||||||
for (const cipher of filteredCiphers) map[cipher.id] = true;
|
|
||||||
setSelectedMap(map);
|
|
||||||
}}
|
|
||||||
onToggleCreateMenu={() => setCreateMenuOpen((open) => !open)}
|
|
||||||
onStartCreate={startCreate}
|
onStartCreate={startCreate}
|
||||||
onBulkRestore={() => void confirmBulkRestore()}
|
onBulkRestore={handleBulkRestore}
|
||||||
onOpenMove={() => {
|
onBulkArchive={handleBulkArchive}
|
||||||
setMoveFolderId('__none__');
|
onBulkUnarchive={handleBulkUnarchive}
|
||||||
setMoveOpen(true);
|
onOpenMove={handleOpenMove}
|
||||||
}}
|
onClearSelection={handleClearSelection}
|
||||||
onClearSelection={() => setSelectedMap({})}
|
onScroll={handleListScroll}
|
||||||
onScroll={setListScrollTop}
|
onToggleSelected={handleToggleSelected}
|
||||||
onToggleSelected={(cipherId, checked) =>
|
onSelectCipher={handleSelectCipher}
|
||||||
setSelectedMap((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[cipherId]: checked,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
onSelectCipher={(cipherId) => {
|
|
||||||
if (isEditing || isCreating) {
|
|
||||||
cancelEdit();
|
|
||||||
}
|
|
||||||
setSelectedCipherId(cipherId);
|
|
||||||
setRepromptApprovedCipherId(null);
|
|
||||||
if (isMobileLayout) setMobilePanel('detail');
|
|
||||||
setMobileSidebarOpen(false);
|
|
||||||
}}
|
|
||||||
listSubtitle={listSubtitle}
|
listSubtitle={listSubtitle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -801,68 +1078,80 @@ function folderName(id: string | null | undefined): string {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isEditing && draft && (
|
{isEditing && draft && (
|
||||||
<VaultEditor
|
<div key={`editor-${draft.id || selectedCipher?.id || 'new'}-${draft.type}`} className="detail-switch-stage">
|
||||||
draft={draft}
|
<VaultEditor
|
||||||
isCreating={isCreating}
|
draft={draft}
|
||||||
busy={busy}
|
isCreating={isCreating}
|
||||||
folders={props.folders}
|
busy={busy}
|
||||||
selectedCipher={selectedCipher}
|
folders={props.folders}
|
||||||
editExistingAttachments={editExistingAttachments}
|
selectedCipher={selectedCipher}
|
||||||
removedAttachmentIds={removedAttachmentIds}
|
editExistingAttachments={editExistingAttachments}
|
||||||
removedAttachmentCount={removedAttachmentCount}
|
removedAttachmentIds={removedAttachmentIds}
|
||||||
attachmentQueue={attachmentQueue}
|
removedAttachmentCount={removedAttachmentCount}
|
||||||
attachmentInputRef={attachmentInputRef}
|
attachmentQueue={attachmentQueue}
|
||||||
localError={localError}
|
attachmentInputRef={attachmentInputRef}
|
||||||
onUpdateDraft={updateDraft}
|
localError={localError}
|
||||||
onSeedSshDefaults={(force) => void seedSshDefaults(force)}
|
onUpdateDraft={updateDraft}
|
||||||
onUpdateSshPublicKey={updateSshPublicKey}
|
onSeedSshDefaults={(force) => void seedSshDefaults(force)}
|
||||||
onUpdateDraftLoginUri={updateDraftLoginUri}
|
onUpdateSshPublicKey={updateSshPublicKey}
|
||||||
onQueueAttachmentFiles={queueAttachmentFiles}
|
onUpdateDraftLoginUri={updateDraftLoginUri}
|
||||||
onToggleExistingAttachmentRemoval={toggleExistingAttachmentRemoval}
|
onUpdateDraftLoginUriMatch={updateDraftLoginUriMatch}
|
||||||
onRemoveQueuedAttachment={removeQueuedAttachment}
|
onReorderDraftLoginUri={reorderDraftLoginUri}
|
||||||
onDownloadAttachment={(cipher, attachmentId) => void props.onDownloadAttachment(cipher, attachmentId)}
|
onRequestDeleteLoginPasskey={setPendingDeletePasskeyIndex}
|
||||||
downloadingAttachmentKey={props.downloadingAttachmentKey}
|
onQueueAttachmentFiles={queueAttachmentFiles}
|
||||||
attachmentDownloadPercent={props.attachmentDownloadPercent}
|
onToggleExistingAttachmentRemoval={toggleExistingAttachmentRemoval}
|
||||||
uploadingAttachmentName={props.uploadingAttachmentName}
|
onRemoveQueuedAttachment={removeQueuedAttachment}
|
||||||
attachmentUploadPercent={props.attachmentUploadPercent}
|
onDownloadAttachment={(cipher, attachmentId) => void props.onDownloadAttachment(cipher, attachmentId)}
|
||||||
onPatchDraftCustomField={patchDraftCustomField}
|
downloadingAttachmentKey={props.downloadingAttachmentKey}
|
||||||
onUpdateDraftCustomFields={updateDraftCustomFields}
|
attachmentDownloadPercent={props.attachmentDownloadPercent}
|
||||||
onOpenFieldModal={() => setFieldModalOpen(true)}
|
uploadingAttachmentName={props.uploadingAttachmentName}
|
||||||
onSave={() => void saveDraft()}
|
attachmentUploadPercent={props.attachmentUploadPercent}
|
||||||
onCancel={cancelEdit}
|
onPatchDraftCustomField={patchDraftCustomField}
|
||||||
onDeleteSelected={() => selectedCipher && setPendingDelete(selectedCipher)}
|
onUpdateDraftCustomFields={updateDraftCustomFields}
|
||||||
/>
|
onOpenFieldModal={() => setFieldModalOpen(true)}
|
||||||
|
onSave={() => void saveDraft()}
|
||||||
|
onCancel={cancelEdit}
|
||||||
|
onDeleteSelected={() => selectedCipher && setPendingDelete(selectedCipher)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isEditing && selectedCipher && (
|
{!isEditing && selectedCipher && (
|
||||||
<VaultDetailView
|
<div key={`detail-${selectedCipher.id}`} className="detail-switch-stage">
|
||||||
selectedCipher={selectedCipher}
|
<VaultDetailView
|
||||||
repromptApprovedCipherId={repromptApprovedCipherId}
|
selectedCipher={selectedCipher}
|
||||||
showPassword={showPassword}
|
repromptApprovedCipherId={repromptApprovedCipherId}
|
||||||
totpLive={totpLive}
|
showPassword={showPassword}
|
||||||
passkeyCreatedAt={passkeyCreatedAt}
|
totpLive={totpLive}
|
||||||
hiddenFieldVisibleMap={hiddenFieldVisibleMap}
|
passkeyCreatedAt={firstPasskeyCreationTime(selectedCipher)}
|
||||||
folderName={folderName}
|
hiddenFieldVisibleMap={hiddenFieldVisibleMap}
|
||||||
onOpenReprompt={() => setRepromptOpen(true)}
|
folderName={folderName}
|
||||||
onToggleShowPassword={() => setShowPassword((value) => !value)}
|
onOpenReprompt={() => setRepromptOpen(true)}
|
||||||
onToggleHiddenField={(index) => setHiddenFieldVisibleMap((prev) => ({ ...prev, [index]: !prev[index] }))}
|
onToggleShowPassword={() => setShowPassword((value) => !value)}
|
||||||
onDownloadAttachment={(cipher, attachmentId) => void props.onDownloadAttachment(cipher, attachmentId)}
|
onToggleHiddenField={(index) => setHiddenFieldVisibleMap((prev) => ({ ...prev, [index]: !prev[index] }))}
|
||||||
downloadingAttachmentKey={props.downloadingAttachmentKey}
|
onDownloadAttachment={(cipher, attachmentId) => void props.onDownloadAttachment(cipher, attachmentId)}
|
||||||
attachmentDownloadPercent={props.attachmentDownloadPercent}
|
downloadingAttachmentKey={props.downloadingAttachmentKey}
|
||||||
onStartEdit={startEdit}
|
attachmentDownloadPercent={props.attachmentDownloadPercent}
|
||||||
onDelete={setPendingDelete}
|
onStartEdit={startEdit}
|
||||||
/>
|
onDelete={setPendingDelete}
|
||||||
|
onArchive={(cipher) => setPendingArchive(cipher)}
|
||||||
|
onUnarchive={(cipher) => void handleUnarchiveSelected(cipher)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isEditing && !selectedCipher && <div className="empty card">{t('txt_select_an_item')}</div>}
|
{!isEditing && !selectedCipher && (props.loading ? <LoadingState card lines={5} /> : <div className="empty card">{t('txt_select_an_item')}</div>)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VaultDialogs
|
<VaultDialogs
|
||||||
|
busy={busy}
|
||||||
fieldModalOpen={fieldModalOpen}
|
fieldModalOpen={fieldModalOpen}
|
||||||
fieldType={fieldType}
|
fieldType={fieldType}
|
||||||
fieldLabel={fieldLabel}
|
fieldLabel={fieldLabel}
|
||||||
fieldValue={fieldValue}
|
fieldValue={fieldValue}
|
||||||
|
archiveConfirmOpen={!!pendingArchive}
|
||||||
|
bulkArchiveOpen={bulkArchiveOpen}
|
||||||
pendingDeleteOpen={!!pendingDelete}
|
pendingDeleteOpen={!!pendingDelete}
|
||||||
bulkDeleteOpen={bulkDeleteOpen}
|
bulkDeleteOpen={bulkDeleteOpen}
|
||||||
sidebarTrashMode={sidebarFilter.kind === 'trash'}
|
sidebarTrashMode={sidebarFilter.kind === 'trash'}
|
||||||
@@ -872,10 +1161,13 @@ function folderName(id: string | null | undefined): string {
|
|||||||
folders={props.folders}
|
folders={props.folders}
|
||||||
createFolderOpen={createFolderOpen}
|
createFolderOpen={createFolderOpen}
|
||||||
newFolderName={newFolderName}
|
newFolderName={newFolderName}
|
||||||
|
renameFolderOpen={!!pendingRenameFolder}
|
||||||
|
renameFolderName={renameFolderName}
|
||||||
pendingDeleteFolder={pendingDeleteFolder}
|
pendingDeleteFolder={pendingDeleteFolder}
|
||||||
deleteAllFoldersOpen={deleteAllFoldersOpen}
|
deleteAllFoldersOpen={deleteAllFoldersOpen}
|
||||||
repromptOpen={repromptOpen}
|
repromptOpen={repromptOpen}
|
||||||
repromptPassword={repromptPassword}
|
repromptPassword={repromptPassword}
|
||||||
|
deletePasskeyOpen={pendingDeletePasskeyIndex != null}
|
||||||
onConfirmAddField={() => {
|
onConfirmAddField={() => {
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
if (!fieldLabel.trim()) {
|
if (!fieldLabel.trim()) {
|
||||||
@@ -905,6 +1197,10 @@ function folderName(id: string | null | undefined): string {
|
|||||||
onFieldTypeChange={setFieldType}
|
onFieldTypeChange={setFieldType}
|
||||||
onFieldLabelChange={setFieldLabel}
|
onFieldLabelChange={setFieldLabel}
|
||||||
onFieldValueChange={setFieldValue}
|
onFieldValueChange={setFieldValue}
|
||||||
|
onConfirmArchive={() => void confirmArchiveSelected()}
|
||||||
|
onCancelArchive={() => setPendingArchive(null)}
|
||||||
|
onConfirmBulkArchive={() => void confirmBulkArchive()}
|
||||||
|
onCancelBulkArchive={() => setBulkArchiveOpen(false)}
|
||||||
onConfirmDelete={() => void deleteSelected()}
|
onConfirmDelete={() => void deleteSelected()}
|
||||||
onCancelDelete={() => setPendingDelete(null)}
|
onCancelDelete={() => setPendingDelete(null)}
|
||||||
onConfirmBulkDelete={() => void confirmBulkDelete()}
|
onConfirmBulkDelete={() => void confirmBulkDelete()}
|
||||||
@@ -918,6 +1214,12 @@ function folderName(id: string | null | undefined): string {
|
|||||||
setNewFolderName('');
|
setNewFolderName('');
|
||||||
}}
|
}}
|
||||||
onNewFolderNameChange={setNewFolderName}
|
onNewFolderNameChange={setNewFolderName}
|
||||||
|
onConfirmRenameFolder={() => void confirmRenameFolder()}
|
||||||
|
onCancelRenameFolder={() => {
|
||||||
|
setPendingRenameFolder(null);
|
||||||
|
setRenameFolderName('');
|
||||||
|
}}
|
||||||
|
onRenameFolderNameChange={setRenameFolderName}
|
||||||
onConfirmDeleteFolder={() => void confirmDeleteFolder()}
|
onConfirmDeleteFolder={() => void confirmDeleteFolder()}
|
||||||
onCancelDeleteFolder={() => setPendingDeleteFolder(null)}
|
onCancelDeleteFolder={() => setPendingDeleteFolder(null)}
|
||||||
onConfirmDeleteAllFolders={() => void confirmDeleteAllFolders()}
|
onConfirmDeleteAllFolders={() => void confirmDeleteAllFolders()}
|
||||||
@@ -928,12 +1230,10 @@ function folderName(id: string | null | undefined): string {
|
|||||||
setRepromptPassword('');
|
setRepromptPassword('');
|
||||||
}}
|
}}
|
||||||
onRepromptPasswordChange={setRepromptPassword}
|
onRepromptPasswordChange={setRepromptPassword}
|
||||||
|
onConfirmDeletePasskey={confirmDeleteLoginPasskey}
|
||||||
|
onCancelDeletePasskey={() => setPendingDeletePasskeyIndex(null)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { CloudUpload, Save, Trash2 } from 'lucide-preact';
|
import { CloudUpload, Save, Trash2 } from 'lucide-preact';
|
||||||
import type {
|
import type {
|
||||||
BackupDestinationRecord,
|
BackupDestinationRecord,
|
||||||
E3BackupDestination,
|
|
||||||
RemoteBackupBrowserResponse,
|
RemoteBackupBrowserResponse,
|
||||||
|
S3BackupDestination,
|
||||||
WebDavBackupDestination,
|
WebDavBackupDestination,
|
||||||
} from '@/lib/api/backup';
|
} from '@/lib/api/backup';
|
||||||
import { COMMON_TIME_ZONES, getDestinationTypeLabel } from '@/lib/backup-center';
|
import { COMMON_TIME_ZONES, getDestinationTypeLabel } from '@/lib/backup-center';
|
||||||
@@ -134,6 +134,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
...COMMON_TIME_ZONES,
|
...COMMON_TIME_ZONES,
|
||||||
...props.availableTimeZones,
|
...props.availableTimeZones,
|
||||||
]));
|
]));
|
||||||
|
const selectedIntervalHours = props.selectedDestination?.schedule.intervalHours ?? 24;
|
||||||
|
|
||||||
if (props.selectedRecommendedProvider) {
|
if (props.selectedRecommendedProvider) {
|
||||||
return (
|
return (
|
||||||
@@ -216,7 +217,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
pattern="[0-9]*"
|
pattern="[0-9]*"
|
||||||
value={String(props.selectedDestination.schedule.intervalHours || 24)}
|
value={String(selectedIntervalHours)}
|
||||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
onInput={(event) => {
|
onInput={(event) => {
|
||||||
const raw = (event.currentTarget as HTMLInputElement).value.replace(/[^\d]/g, '');
|
const raw = (event.currentTarget as HTMLInputElement).value.replace(/[^\d]/g, '');
|
||||||
@@ -234,7 +235,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="backup-interval-presets" aria-label={t('txt_backup_interval_hours_presets')}>
|
<div className="backup-interval-presets" aria-label={t('txt_backup_interval_hours_presets')}>
|
||||||
{INTERVAL_HOUR_PRESETS.map((preset) => {
|
{INTERVAL_HOUR_PRESETS.map((preset) => {
|
||||||
const active = preset === props.selectedDestination.schedule.intervalHours;
|
const active = preset === selectedIntervalHours;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={preset}
|
key={preset}
|
||||||
@@ -256,6 +257,23 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_backup_start_time')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="time"
|
||||||
|
step={300}
|
||||||
|
value={props.selectedDestination.schedule.startTime || '03:00'}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
schedule: {
|
||||||
|
...destination.schedule,
|
||||||
|
startTime: (event.currentTarget as HTMLInputElement).value || '03:00',
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_backup_timezone')}</span>
|
<span>{t('txt_backup_timezone')}</span>
|
||||||
<select
|
<select
|
||||||
@@ -381,97 +399,97 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{props.selectedDestination.type === 'e3' ? (
|
{props.selectedDestination.type === 's3' ? (
|
||||||
<div className="field-grid">
|
<div className="field-grid">
|
||||||
<label className="field field-span-2">
|
<label className="field field-span-2">
|
||||||
<span>{t('txt_backup_e3_endpoint')}</span>
|
<span>{t('txt_backup_s3_endpoint')}</span>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
value={(props.selectedDestination.destination as E3BackupDestination).endpoint}
|
value={(props.selectedDestination.destination as S3BackupDestination).endpoint}
|
||||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
placeholder="https://s3.example.com"
|
placeholder="https://s3.example.com"
|
||||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
...destination,
|
...destination,
|
||||||
destination: {
|
destination: {
|
||||||
...(destination.destination as E3BackupDestination),
|
...(destination.destination as S3BackupDestination),
|
||||||
endpoint: (event.currentTarget as HTMLInputElement).value,
|
endpoint: (event.currentTarget as HTMLInputElement).value,
|
||||||
},
|
},
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_backup_e3_bucket')}</span>
|
<span>{t('txt_backup_s3_bucket')}</span>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
value={(props.selectedDestination.destination as E3BackupDestination).bucket}
|
value={(props.selectedDestination.destination as S3BackupDestination).bucket}
|
||||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
...destination,
|
...destination,
|
||||||
destination: {
|
destination: {
|
||||||
...(destination.destination as E3BackupDestination),
|
...(destination.destination as S3BackupDestination),
|
||||||
bucket: (event.currentTarget as HTMLInputElement).value,
|
bucket: (event.currentTarget as HTMLInputElement).value,
|
||||||
},
|
},
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_backup_e3_region')}</span>
|
<span>{t('txt_backup_s3_region')}</span>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
value={(props.selectedDestination.destination as E3BackupDestination).region}
|
value={(props.selectedDestination.destination as S3BackupDestination).region}
|
||||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
placeholder="auto"
|
placeholder="auto"
|
||||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
...destination,
|
...destination,
|
||||||
destination: {
|
destination: {
|
||||||
...(destination.destination as E3BackupDestination),
|
...(destination.destination as S3BackupDestination),
|
||||||
region: (event.currentTarget as HTMLInputElement).value,
|
region: (event.currentTarget as HTMLInputElement).value,
|
||||||
},
|
},
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_backup_e3_access_key')}</span>
|
<span>{t('txt_backup_s3_access_key')}</span>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
value={(props.selectedDestination.destination as E3BackupDestination).accessKeyId}
|
value={(props.selectedDestination.destination as S3BackupDestination).accessKeyId}
|
||||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
...destination,
|
...destination,
|
||||||
destination: {
|
destination: {
|
||||||
...(destination.destination as E3BackupDestination),
|
...(destination.destination as S3BackupDestination),
|
||||||
accessKeyId: (event.currentTarget as HTMLInputElement).value,
|
accessKeyId: (event.currentTarget as HTMLInputElement).value,
|
||||||
},
|
},
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_backup_e3_secret_key')}</span>
|
<span>{t('txt_backup_s3_secret_key')}</span>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
type="password"
|
type="password"
|
||||||
value={(props.selectedDestination.destination as E3BackupDestination).secretAccessKey}
|
value={(props.selectedDestination.destination as S3BackupDestination).secretAccessKey}
|
||||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
...destination,
|
...destination,
|
||||||
destination: {
|
destination: {
|
||||||
...(destination.destination as E3BackupDestination),
|
...(destination.destination as S3BackupDestination),
|
||||||
secretAccessKey: (event.currentTarget as HTMLInputElement).value,
|
secretAccessKey: (event.currentTarget as HTMLInputElement).value,
|
||||||
},
|
},
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field field-span-2">
|
<label className="field field-span-2">
|
||||||
<span>{t('txt_backup_e3_path')}</span>
|
<span>{t('txt_backup_s3_path')}</span>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
value={(props.selectedDestination.destination as E3BackupDestination).rootPath}
|
value={(props.selectedDestination.destination as S3BackupDestination).rootPath}
|
||||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
placeholder="nodewarden/backups"
|
placeholder="nodewarden/backups"
|
||||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
...destination,
|
...destination,
|
||||||
destination: {
|
destination: {
|
||||||
...(destination.destination as E3BackupDestination),
|
...(destination.destination as S3BackupDestination),
|
||||||
rootPath: (event.currentTarget as HTMLInputElement).value,
|
rootPath: (event.currentTarget as HTMLInputElement).value,
|
||||||
},
|
},
|
||||||
}))}
|
}))}
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ export function BackupDestinationSidebar(props: BackupDestinationSidebarProps) {
|
|||||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onAddDestination('webdav')}>
|
<button type="button" className="btn btn-secondary small" onClick={() => props.onAddDestination('webdav')}>
|
||||||
{t('txt_backup_protocol_webdav')}
|
{t('txt_backup_protocol_webdav')}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onAddDestination('e3')}>
|
<button type="button" className="btn btn-secondary small" onClick={() => props.onAddDestination('s3')}>
|
||||||
{t('txt_backup_protocol_e3')}
|
{t('txt_backup_protocol_s3')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState } from 'preact/hooks';
|
import { createPortal } from 'preact/compat';
|
||||||
import { Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, Trash2 } from 'lucide-preact';
|
import { useMemo, useState } from 'preact/hooks';
|
||||||
|
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact';
|
||||||
|
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||||
import type { Cipher } from '@/lib/types';
|
import type { Cipher } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import {
|
import {
|
||||||
@@ -31,11 +33,64 @@ interface VaultDetailViewProps {
|
|||||||
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => void;
|
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => void;
|
||||||
onStartEdit: () => void;
|
onStartEdit: () => void;
|
||||||
onDelete: (cipher: Cipher) => void;
|
onDelete: (cipher: Cipher) => void;
|
||||||
|
onArchive: (cipher: Cipher) => void | Promise<void>;
|
||||||
|
onUnarchive: (cipher: Cipher) => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PasswordHistoryDialog(props: {
|
||||||
|
open: boolean;
|
||||||
|
entries: Array<{ password: string; lastUsedDate: string | null }>;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
useDialogLifecycle(props.open, props.onClose);
|
||||||
|
|
||||||
|
if (!props.open || typeof document === 'undefined') return null;
|
||||||
|
return createPortal(
|
||||||
|
<div className="dialog-mask open" onClick={(event) => event.target === event.currentTarget && props.onClose()}>
|
||||||
|
<section className="dialog-card password-history-dialog open" role="dialog" aria-modal="true" aria-label={t('txt_password_history')}>
|
||||||
|
<div className="password-history-head">
|
||||||
|
<h3 className="dialog-title">{t('txt_password_history')}</h3>
|
||||||
|
<button type="button" className="password-history-close" aria-label={t('txt_close')} onClick={props.onClose}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="password-history-list">
|
||||||
|
{props.entries.map((entry, index) => (
|
||||||
|
<div key={`password-history-${index}-${entry.lastUsedDate || 'none'}`} className="password-history-item">
|
||||||
|
<div className="password-history-copy">
|
||||||
|
<button type="button" className="btn btn-secondary small password-history-copy-btn" onClick={() => copyToClipboard(entry.password)}>
|
||||||
|
<Clipboard size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="password-history-value">{entry.password}</div>
|
||||||
|
<div className="password-history-time">{formatHistoryTime(entry.lastUsedDate)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-primary dialog-btn" onClick={props.onClose}>
|
||||||
|
{t('txt_close')}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VaultDetailView(props: VaultDetailViewProps) {
|
export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||||
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
|
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
|
||||||
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
|
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
|
||||||
|
const [passwordHistoryOpen, setPasswordHistoryOpen] = useState(false);
|
||||||
|
const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt);
|
||||||
|
const passwordHistoryEntries = useMemo(
|
||||||
|
() =>
|
||||||
|
(props.selectedCipher.passwordHistory || [])
|
||||||
|
.map((entry) => ({
|
||||||
|
password: String(entry?.decPassword || entry?.password || ''),
|
||||||
|
lastUsedDate: entry?.lastUsedDate ?? null,
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.password.trim()),
|
||||||
|
[props.selectedCipher.passwordHistory]
|
||||||
|
);
|
||||||
const formatDownloadLabel = (attachmentId: string) => {
|
const formatDownloadLabel = (attachmentId: string) => {
|
||||||
const downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
|
const downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
|
||||||
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
|
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
|
||||||
@@ -50,7 +105,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<h4>{t('txt_master_password_reprompt_2')}</h4>
|
<h4>{t('txt_master_password_reprompt_2')}</h4>
|
||||||
<div className="detail-sub">{t('txt_this_item_requires_master_password_every_time_before_viewing_details')}</div>
|
<div className="detail-sub">{t('txt_this_item_requires_master_password_every_time_before_viewing_details')}</div>
|
||||||
<div className="actions" style={{ marginTop: '10px' }}>
|
<div className="actions detail-unlock-actions">
|
||||||
<button type="button" className="btn btn-primary" onClick={props.onOpenReprompt}>
|
<button type="button" className="btn btn-primary" onClick={props.onOpenReprompt}>
|
||||||
<Eye size={14} className="btn-icon" /> {t('txt_unlock_details')}
|
<Eye size={14} className="btn-icon" /> {t('txt_unlock_details')}
|
||||||
</button>
|
</button>
|
||||||
@@ -62,6 +117,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 className="detail-title">{props.selectedCipher.decName || t('txt_no_name')}</h3>
|
<h3 className="detail-title">{props.selectedCipher.decName || t('txt_no_name')}</h3>
|
||||||
<div className="detail-sub">{props.folderName(props.selectedCipher.folderId)}</div>
|
<div className="detail-sub">{props.folderName(props.selectedCipher.folderId)}</div>
|
||||||
|
{isArchived && <div className="list-badge archive-badge">{t('txt_archived')}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{props.selectedCipher.login && (
|
{props.selectedCipher.login && (
|
||||||
@@ -265,29 +321,36 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
if (fieldType === 2) {
|
if (fieldType === 2) {
|
||||||
const checked = toBooleanFieldValue(rawValue);
|
const checked = toBooleanFieldValue(rawValue);
|
||||||
return (
|
return (
|
||||||
<div key={`view-field-${index}`} className="kv-row custom-field-row">
|
<div key={`view-field-${index}`} className="custom-field-card">
|
||||||
<span className="kv-label" title={fieldName}>{fieldName}</span>
|
<div className="custom-field-label">{fieldName}</div>
|
||||||
<div className="kv-main boolean-main">
|
<div className="custom-field-body">
|
||||||
<label className="check-line cf-check view">
|
<div className="custom-field-value">
|
||||||
<input type="checkbox" checked={checked} disabled />
|
<label className="check-line cf-check view custom-field-check">
|
||||||
</label>
|
<input type="checkbox" checked={checked} disabled />
|
||||||
<span className="boolean-text value-ellipsis" title={checked ? t('txt_checked') : t('txt_unchecked')}>
|
<span className="boolean-text value-ellipsis" title={checked ? t('txt_checked') : t('txt_unchecked')}>
|
||||||
{checked ? t('txt_checked') : t('txt_unchecked')}
|
{checked ? t('txt_checked') : t('txt_unchecked')}
|
||||||
</span>
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="kv-actions">
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(rawValue)}>
|
||||||
|
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="kv-actions" />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div key={`view-field-${index}`} className="kv-row custom-field-row">
|
<div key={`view-field-${index}`} className="custom-field-card">
|
||||||
<span className="kv-label" title={fieldName}>{fieldName}</span>
|
<div className="custom-field-label" title={fieldName}>{fieldName}</div>
|
||||||
<div className="kv-main">
|
<div className="custom-field-body">
|
||||||
<strong className="value-ellipsis" title={fieldType === 1 && !isHiddenVisible ? '' : rawValue}>
|
<div className="custom-field-value">
|
||||||
{fieldType === 1 && !isHiddenVisible ? maskSecret(rawValue) : rawValue}
|
<strong className="value-ellipsis" title={fieldType === 1 && !isHiddenVisible ? '' : rawValue}>
|
||||||
</strong>
|
{fieldType === 1 && !isHiddenVisible ? maskSecret(rawValue) : rawValue}
|
||||||
</div>
|
</strong>
|
||||||
<div className="kv-actions">
|
</div>
|
||||||
|
<div className="kv-actions">
|
||||||
{fieldType === 1 && (
|
{fieldType === 1 && (
|
||||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onToggleHiddenField(index)}>
|
<button type="button" className="btn btn-secondary small" onClick={() => props.onToggleHiddenField(index)}>
|
||||||
{isHiddenVisible ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
|
{isHiddenVisible ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
|
||||||
@@ -297,6 +360,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(rawValue)}>
|
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(rawValue)}>
|
||||||
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -343,6 +407,14 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
<h4>{t('txt_item_history')}</h4>
|
<h4>{t('txt_item_history')}</h4>
|
||||||
<div className="detail-sub">{t('txt_last_edited_value', { value: formatHistoryTime(props.selectedCipher.revisionDate) })}</div>
|
<div className="detail-sub">{t('txt_last_edited_value', { value: formatHistoryTime(props.selectedCipher.revisionDate) })}</div>
|
||||||
<div className="detail-sub">{t('txt_created_value', { value: formatHistoryTime(props.selectedCipher.creationDate) })}</div>
|
<div className="detail-sub">{t('txt_created_value', { value: formatHistoryTime(props.selectedCipher.creationDate) })}</div>
|
||||||
|
{!!props.selectedCipher.login?.passwordRevisionDate && (
|
||||||
|
<div className="detail-sub">{t('txt_password_updated_value', { value: formatHistoryTime(props.selectedCipher.login.passwordRevisionDate) })}</div>
|
||||||
|
)}
|
||||||
|
{passwordHistoryEntries.length > 0 && (
|
||||||
|
<button type="button" className="password-history-link" onClick={() => setPasswordHistoryOpen(true)}>
|
||||||
|
{t('txt_password_history')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -351,6 +423,15 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
<button type="button" className="btn btn-secondary" onClick={props.onStartEdit}>
|
<button type="button" className="btn btn-secondary" onClick={props.onStartEdit}>
|
||||||
<Pencil size={14} className="btn-icon" /> {t('txt_edit')}
|
<Pencil size={14} className="btn-icon" /> {t('txt_edit')}
|
||||||
</button>
|
</button>
|
||||||
|
{isArchived ? (
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => void props.onUnarchive(props.selectedCipher)}>
|
||||||
|
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => void props.onArchive(props.selectedCipher)}>
|
||||||
|
<Archive size={14} className="btn-icon" /> {t('txt_archive')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-danger" onClick={() => props.onDelete(props.selectedCipher)}>
|
<button type="button" className="btn btn-danger" onClick={() => props.onDelete(props.selectedCipher)}>
|
||||||
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
|
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
|
||||||
@@ -358,6 +439,11 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<PasswordHistoryDialog
|
||||||
|
open={passwordHistoryOpen}
|
||||||
|
entries={passwordHistoryEntries}
|
||||||
|
onClose={() => setPasswordHistoryOpen(false)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
import type { CustomFieldType, Folder } from '@/lib/types';
|
import type { CustomFieldType, Folder } from '@/lib/types';
|
||||||
import { FIELD_TYPE_OPTIONS, toBooleanFieldValue } from '@/components/vault/vault-page-helpers';
|
import { getFieldTypeOptions, toBooleanFieldValue } from '@/components/vault/vault-page-helpers';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
interface VaultDialogsProps {
|
interface VaultDialogsProps {
|
||||||
|
busy: boolean;
|
||||||
fieldModalOpen: boolean;
|
fieldModalOpen: boolean;
|
||||||
fieldType: CustomFieldType;
|
fieldType: CustomFieldType;
|
||||||
fieldLabel: string;
|
fieldLabel: string;
|
||||||
fieldValue: string;
|
fieldValue: string;
|
||||||
|
archiveConfirmOpen: boolean;
|
||||||
|
bulkArchiveOpen: boolean;
|
||||||
pendingDeleteOpen: boolean;
|
pendingDeleteOpen: boolean;
|
||||||
bulkDeleteOpen: boolean;
|
bulkDeleteOpen: boolean;
|
||||||
sidebarTrashMode: boolean;
|
sidebarTrashMode: boolean;
|
||||||
@@ -17,15 +20,22 @@ interface VaultDialogsProps {
|
|||||||
folders: Folder[];
|
folders: Folder[];
|
||||||
createFolderOpen: boolean;
|
createFolderOpen: boolean;
|
||||||
newFolderName: string;
|
newFolderName: string;
|
||||||
|
renameFolderOpen: boolean;
|
||||||
|
renameFolderName: string;
|
||||||
pendingDeleteFolder: Folder | null;
|
pendingDeleteFolder: Folder | null;
|
||||||
deleteAllFoldersOpen: boolean;
|
deleteAllFoldersOpen: boolean;
|
||||||
repromptOpen: boolean;
|
repromptOpen: boolean;
|
||||||
repromptPassword: string;
|
repromptPassword: string;
|
||||||
|
deletePasskeyOpen: boolean;
|
||||||
onConfirmAddField: () => void;
|
onConfirmAddField: () => void;
|
||||||
onCancelFieldModal: () => void;
|
onCancelFieldModal: () => void;
|
||||||
onFieldTypeChange: (value: CustomFieldType) => void;
|
onFieldTypeChange: (value: CustomFieldType) => void;
|
||||||
onFieldLabelChange: (value: string) => void;
|
onFieldLabelChange: (value: string) => void;
|
||||||
onFieldValueChange: (value: string) => void;
|
onFieldValueChange: (value: string) => void;
|
||||||
|
onConfirmArchive: () => void;
|
||||||
|
onCancelArchive: () => void;
|
||||||
|
onConfirmBulkArchive: () => void;
|
||||||
|
onCancelBulkArchive: () => void;
|
||||||
onConfirmDelete: () => void;
|
onConfirmDelete: () => void;
|
||||||
onCancelDelete: () => void;
|
onCancelDelete: () => void;
|
||||||
onConfirmBulkDelete: () => void;
|
onConfirmBulkDelete: () => void;
|
||||||
@@ -36,6 +46,9 @@ interface VaultDialogsProps {
|
|||||||
onConfirmCreateFolder: () => void;
|
onConfirmCreateFolder: () => void;
|
||||||
onCancelCreateFolder: () => void;
|
onCancelCreateFolder: () => void;
|
||||||
onNewFolderNameChange: (value: string) => void;
|
onNewFolderNameChange: (value: string) => void;
|
||||||
|
onConfirmRenameFolder: () => void;
|
||||||
|
onCancelRenameFolder: () => void;
|
||||||
|
onRenameFolderNameChange: (value: string) => void;
|
||||||
onConfirmDeleteFolder: () => void;
|
onConfirmDeleteFolder: () => void;
|
||||||
onCancelDeleteFolder: () => void;
|
onCancelDeleteFolder: () => void;
|
||||||
onConfirmDeleteAllFolders: () => void;
|
onConfirmDeleteAllFolders: () => void;
|
||||||
@@ -43,9 +56,12 @@ interface VaultDialogsProps {
|
|||||||
onConfirmReprompt: () => void;
|
onConfirmReprompt: () => void;
|
||||||
onCancelReprompt: () => void;
|
onCancelReprompt: () => void;
|
||||||
onRepromptPasswordChange: (value: string) => void;
|
onRepromptPasswordChange: (value: string) => void;
|
||||||
|
onConfirmDeletePasskey: () => void;
|
||||||
|
onCancelDeletePasskey: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VaultDialogs(props: VaultDialogsProps) {
|
export default function VaultDialogs(props: VaultDialogsProps) {
|
||||||
|
const fieldTypeOptions = getFieldTypeOptions();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
@@ -60,7 +76,7 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
|||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_field_type')}</span>
|
<span>{t('txt_field_type')}</span>
|
||||||
<select className="input" value={props.fieldType} onInput={(e) => props.onFieldTypeChange(Number((e.currentTarget as HTMLSelectElement).value) as CustomFieldType)}>
|
<select className="input" value={props.fieldType} onInput={(e) => props.onFieldTypeChange(Number((e.currentTarget as HTMLSelectElement).value) as CustomFieldType)}>
|
||||||
{FIELD_TYPE_OPTIONS.map((option) => (
|
{fieldTypeOptions.map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option key={option.value} value={option.value}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
@@ -88,7 +104,40 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
|||||||
)}
|
)}
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|
||||||
<ConfirmDialog open={props.pendingDeleteOpen} title={t('txt_delete_item')} message={t('txt_are_you_sure_you_want_to_delete_this_item')} danger onConfirm={props.onConfirmDelete} onCancel={props.onCancelDelete} />
|
<ConfirmDialog
|
||||||
|
open={props.archiveConfirmOpen}
|
||||||
|
title={t('txt_archive_item')}
|
||||||
|
message={t('txt_archive_item_message')}
|
||||||
|
confirmText={t('txt_archive')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
|
onConfirm={props.onConfirmArchive}
|
||||||
|
onCancel={props.onCancelArchive}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={props.bulkArchiveOpen}
|
||||||
|
title={t('txt_archive_selected_items')}
|
||||||
|
message={t('txt_archive_selected_items_message', { count: props.selectedCount })}
|
||||||
|
confirmText={t('txt_archive')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
|
onConfirm={props.onConfirmBulkArchive}
|
||||||
|
onCancel={props.onCancelBulkArchive}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={props.pendingDeleteOpen}
|
||||||
|
title={t('txt_delete_item')}
|
||||||
|
message={t('txt_are_you_sure_you_want_to_delete_this_item')}
|
||||||
|
danger
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
|
onConfirm={props.onConfirmDelete}
|
||||||
|
onCancel={props.onCancelDelete}
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={props.bulkDeleteOpen}
|
open={props.bulkDeleteOpen}
|
||||||
@@ -99,11 +148,23 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
|||||||
: t('txt_are_you_sure_you_want_to_delete_count_selected_items', { count: props.selectedCount })
|
: t('txt_are_you_sure_you_want_to_delete_count_selected_items', { count: props.selectedCount })
|
||||||
}
|
}
|
||||||
danger
|
danger
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
onConfirm={props.onConfirmBulkDelete}
|
onConfirm={props.onConfirmBulkDelete}
|
||||||
onCancel={props.onCancelBulkDelete}
|
onCancel={props.onCancelBulkDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog open={props.moveOpen} title={t('txt_move_selected_items')} message={t('txt_choose_destination_folder')} confirmText={t('txt_move')} cancelText={t('txt_cancel')} onConfirm={props.onConfirmMove} onCancel={props.onCancelMove}>
|
<ConfirmDialog
|
||||||
|
open={props.moveOpen}
|
||||||
|
title={t('txt_move_selected_items')}
|
||||||
|
message={t('txt_choose_destination_folder')}
|
||||||
|
confirmText={t('txt_move')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
|
onConfirm={props.onConfirmMove}
|
||||||
|
onCancel={props.onCancelMove}
|
||||||
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_folder')}</span>
|
<span>{t('txt_folder')}</span>
|
||||||
<select className="input" value={props.moveFolderId} onInput={(e) => props.onMoveFolderIdChange((e.currentTarget as HTMLSelectElement).value)}>
|
<select className="input" value={props.moveFolderId} onInput={(e) => props.onMoveFolderIdChange((e.currentTarget as HTMLSelectElement).value)}>
|
||||||
@@ -117,13 +178,40 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
|||||||
</label>
|
</label>
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|
||||||
<ConfirmDialog open={props.createFolderOpen} title={t('txt_create_folder')} message={t('txt_enter_a_folder_name')} confirmText={t('txt_create')} cancelText={t('txt_cancel')} onConfirm={props.onConfirmCreateFolder} onCancel={props.onCancelCreateFolder}>
|
<ConfirmDialog
|
||||||
|
open={props.createFolderOpen}
|
||||||
|
title={t('txt_create_folder')}
|
||||||
|
message={t('txt_enter_a_folder_name')}
|
||||||
|
confirmText={t('txt_create')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
|
onConfirm={props.onConfirmCreateFolder}
|
||||||
|
onCancel={props.onCancelCreateFolder}
|
||||||
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_folder_name')}</span>
|
<span>{t('txt_folder_name')}</span>
|
||||||
<input className="input" value={props.newFolderName} onInput={(e) => props.onNewFolderNameChange((e.currentTarget as HTMLInputElement).value)} />
|
<input className="input" value={props.newFolderName} onInput={(e) => props.onNewFolderNameChange((e.currentTarget as HTMLInputElement).value)} />
|
||||||
</label>
|
</label>
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={props.renameFolderOpen}
|
||||||
|
title={t('txt_edit')}
|
||||||
|
message={t('txt_enter_a_folder_name')}
|
||||||
|
confirmText={t('txt_save')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
|
onConfirm={props.onConfirmRenameFolder}
|
||||||
|
onCancel={props.onCancelRenameFolder}
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_folder_name')}</span>
|
||||||
|
<input className="input" value={props.renameFolderName} onInput={(e) => props.onRenameFolderNameChange((e.currentTarget as HTMLInputElement).value)} />
|
||||||
|
</label>
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!props.pendingDeleteFolder}
|
open={!!props.pendingDeleteFolder}
|
||||||
title={t('txt_delete_folder')}
|
title={t('txt_delete_folder')}
|
||||||
@@ -131,18 +219,53 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
|||||||
confirmText={t('txt_delete')}
|
confirmText={t('txt_delete')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
danger
|
danger
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
onConfirm={props.onConfirmDeleteFolder}
|
onConfirm={props.onConfirmDeleteFolder}
|
||||||
onCancel={props.onCancelDeleteFolder}
|
onCancel={props.onCancelDeleteFolder}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog open={props.deleteAllFoldersOpen} title={t('txt_delete_all_folders')} message={t('txt_delete_all_folders_message')} confirmText={t('txt_delete')} cancelText={t('txt_cancel')} danger onConfirm={props.onConfirmDeleteAllFolders} onCancel={props.onCancelDeleteAllFolders} />
|
<ConfirmDialog
|
||||||
|
open={props.deleteAllFoldersOpen}
|
||||||
|
title={t('txt_delete_all_folders')}
|
||||||
|
message={t('txt_delete_all_folders_message')}
|
||||||
|
confirmText={t('txt_delete')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
danger
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
|
onConfirm={props.onConfirmDeleteAllFolders}
|
||||||
|
onCancel={props.onCancelDeleteAllFolders}
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog open={props.repromptOpen} title={t('txt_unlock_item')} message={t('txt_enter_master_password_to_view_this_item')} confirmText={t('txt_unlock')} cancelText={t('txt_cancel')} showIcon={false} onConfirm={props.onConfirmReprompt} onCancel={props.onCancelReprompt}>
|
<ConfirmDialog
|
||||||
|
open={props.repromptOpen}
|
||||||
|
title={t('txt_unlock_item')}
|
||||||
|
message={t('txt_enter_master_password_to_view_this_item')}
|
||||||
|
confirmText={t('txt_unlock')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
showIcon={false}
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
|
onConfirm={props.onConfirmReprompt}
|
||||||
|
onCancel={props.onCancelReprompt}
|
||||||
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_master_password')}</span>
|
<span>{t('txt_master_password')}</span>
|
||||||
<input className="input" type="password" value={props.repromptPassword} onInput={(e) => props.onRepromptPasswordChange((e.currentTarget as HTMLInputElement).value)} />
|
<input className="input" type="password" value={props.repromptPassword} onInput={(e) => props.onRepromptPasswordChange((e.currentTarget as HTMLInputElement).value)} />
|
||||||
</label>
|
</label>
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={props.deletePasskeyOpen}
|
||||||
|
title={t('txt_delete_passkey')}
|
||||||
|
message={t('txt_are_you_sure_you_want_to_delete_this_passkey')}
|
||||||
|
confirmText={t('txt_delete')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
danger
|
||||||
|
onConfirm={props.onConfirmDeletePasskey}
|
||||||
|
onCancel={props.onCancelDeletePasskey}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,34 @@
|
|||||||
import type { RefObject } from 'preact';
|
import type { JSX, RefObject } from 'preact';
|
||||||
import { CheckCheck, Download, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
|
import { CheckCheck, Download, GripVertical, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
|
||||||
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
type DragEndEvent,
|
||||||
|
type DragStartEvent,
|
||||||
|
PointerSensor,
|
||||||
|
TouchSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
arrayMove,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import { CREATE_TYPE_OPTIONS, cipherTypeLabel, formatAttachmentSize, toBooleanFieldValue } from '@/components/vault/vault-page-helpers';
|
import {
|
||||||
|
cipherTypeLabel,
|
||||||
|
createEmptyLoginUri,
|
||||||
|
formatAttachmentSize,
|
||||||
|
formatHistoryTime,
|
||||||
|
getCreateTypeOptions,
|
||||||
|
getWebsiteMatchOptions,
|
||||||
|
toBooleanFieldValue,
|
||||||
|
} from '@/components/vault/vault-page-helpers';
|
||||||
|
|
||||||
interface VaultEditorProps {
|
interface VaultEditorProps {
|
||||||
draft: VaultDraft;
|
draft: VaultDraft;
|
||||||
@@ -24,6 +50,9 @@ interface VaultEditorProps {
|
|||||||
onSeedSshDefaults: (force?: boolean) => void;
|
onSeedSshDefaults: (force?: boolean) => void;
|
||||||
onUpdateSshPublicKey: (value: string) => void;
|
onUpdateSshPublicKey: (value: string) => void;
|
||||||
onUpdateDraftLoginUri: (index: number, value: string) => void;
|
onUpdateDraftLoginUri: (index: number, value: string) => void;
|
||||||
|
onUpdateDraftLoginUriMatch: (index: number, value: number | null) => void;
|
||||||
|
onReorderDraftLoginUri: (fromIndex: number, toIndex: number) => void;
|
||||||
|
onRequestDeleteLoginPasskey: (index: number) => void;
|
||||||
onQueueAttachmentFiles: (list: FileList | null) => void;
|
onQueueAttachmentFiles: (list: FileList | null) => void;
|
||||||
onToggleExistingAttachmentRemoval: (attachmentId: string) => void;
|
onToggleExistingAttachmentRemoval: (attachmentId: string) => void;
|
||||||
onRemoveQueuedAttachment: (index: number) => void;
|
onRemoveQueuedAttachment: (index: number) => void;
|
||||||
@@ -36,7 +65,111 @@ interface VaultEditorProps {
|
|||||||
onDeleteSelected: () => void;
|
onDeleteSelected: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SortableWebsiteRowProps {
|
||||||
|
id: string;
|
||||||
|
uriEntry: VaultDraft['loginUris'][number];
|
||||||
|
index: number;
|
||||||
|
canRemove: boolean;
|
||||||
|
isDragging: boolean;
|
||||||
|
onUpdateUri: (index: number, value: string) => void;
|
||||||
|
onUpdateMatch: (index: number, value: number | null) => void;
|
||||||
|
onRemove: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableWebsiteRow(props: SortableWebsiteRowProps) {
|
||||||
|
const websiteMatchOptions = getWebsiteMatchOptions();
|
||||||
|
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: props.id,
|
||||||
|
});
|
||||||
|
const dragButtonAttributes = attributes as JSX.HTMLAttributes<HTMLButtonElement>;
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`website-row${isDragging || props.isDragging ? ' is-dragging' : ''}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={setActivatorNodeRef}
|
||||||
|
className="btn btn-secondary small website-drag-btn"
|
||||||
|
title={t('txt_drag_to_reorder')}
|
||||||
|
aria-label={t('txt_drag_to_reorder')}
|
||||||
|
{...dragButtonAttributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical size={14} className="btn-icon" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={props.uriEntry.uri}
|
||||||
|
onInput={(e) => props.onUpdateUri(props.index, (e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="input website-match-select"
|
||||||
|
value={props.uriEntry.match == null ? '' : String(props.uriEntry.match)}
|
||||||
|
onInput={(e) => {
|
||||||
|
const raw = (e.currentTarget as HTMLSelectElement).value;
|
||||||
|
props.onUpdateMatch(props.index, raw === '' ? null : Number(raw));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{websiteMatchOptions.map((option) => (
|
||||||
|
<option key={`website-match-${String(option.value)}`} value={option.value == null ? '' : String(option.value)}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{props.canRemove && (
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => props.onRemove(props.index)}>
|
||||||
|
<X size={14} className="btn-icon" />
|
||||||
|
{t('txt_remove')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function VaultEditor(props: VaultEditorProps) {
|
export default function VaultEditor(props: VaultEditorProps) {
|
||||||
|
const createTypeOptions = getCreateTypeOptions();
|
||||||
|
const uriIdSeedRef = useRef(0);
|
||||||
|
const [uriItemIds, setUriItemIds] = useState<string[]>([]);
|
||||||
|
const [activeUriId, setActiveUriId] = useState<string | null>(null);
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 6,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 120,
|
||||||
|
tolerance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const createUriId = () => `login-uri-${uriIdSeedRef.current++}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUriItemIds((prev) => {
|
||||||
|
if (prev.length === props.draft.loginUris.length) return prev;
|
||||||
|
if (prev.length < props.draft.loginUris.length) {
|
||||||
|
return [...prev, ...Array.from({ length: props.draft.loginUris.length - prev.length }, () => createUriId())];
|
||||||
|
}
|
||||||
|
return prev.slice(0, props.draft.loginUris.length);
|
||||||
|
});
|
||||||
|
}, [props.draft.loginUris.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUriItemIds(props.draft.loginUris.map(() => createUriId()));
|
||||||
|
setActiveUriId(null);
|
||||||
|
}, [props.draft.id, props.isCreating]);
|
||||||
|
|
||||||
const formatDownloadLabel = (attachmentId: string) => {
|
const formatDownloadLabel = (attachmentId: string) => {
|
||||||
const downloadKey = `${props.selectedCipher?.id || ''}:${attachmentId}`;
|
const downloadKey = `${props.selectedCipher?.id || ''}:${attachmentId}`;
|
||||||
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
|
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
|
||||||
@@ -52,6 +185,32 @@ export default function VaultEditor(props: VaultEditorProps) {
|
|||||||
percent: props.attachmentUploadPercent,
|
percent: props.attachmentUploadPercent,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const addLoginUri = () => {
|
||||||
|
setUriItemIds((prev) => [...prev, createUriId()]);
|
||||||
|
props.onUpdateDraft({ loginUris: [...props.draft.loginUris, createEmptyLoginUri()] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeLoginUri = (index: number) => {
|
||||||
|
setUriItemIds((prev) => prev.filter((_, itemIndex) => itemIndex !== index));
|
||||||
|
props.onUpdateDraft({ loginUris: props.draft.loginUris.filter((_, itemIndex) => itemIndex !== index) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWebsiteDragStart = (event: DragStartEvent) => {
|
||||||
|
setActiveUriId(String(event.active.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWebsiteDragEnd = (event: DragEndEvent) => {
|
||||||
|
const activeId = String(event.active.id);
|
||||||
|
const overId = event.over ? String(event.over.id) : null;
|
||||||
|
setActiveUriId(null);
|
||||||
|
if (!overId || activeId === overId) return;
|
||||||
|
const fromIndex = uriItemIds.indexOf(activeId);
|
||||||
|
const toIndex = uriItemIds.indexOf(overId);
|
||||||
|
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return;
|
||||||
|
setUriItemIds((prev) => arrayMove(prev, fromIndex, toIndex));
|
||||||
|
props.onReorderDraftLoginUri(fromIndex, toIndex);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -75,7 +234,7 @@ export default function VaultEditor(props: VaultEditorProps) {
|
|||||||
if (nextType === 5) props.onSeedSshDefaults();
|
if (nextType === 5) props.onSeedSshDefaults();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{CREATE_TYPE_OPTIONS.map((option) => (
|
{createTypeOptions.map((option) => (
|
||||||
<option key={option.type} value={option.type}>
|
<option key={option.type} value={option.type}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
@@ -119,21 +278,63 @@ export default function VaultEditor(props: VaultEditorProps) {
|
|||||||
</label>
|
</label>
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<h4>{t('txt_websites')}</h4>
|
<h4>{t('txt_websites')}</h4>
|
||||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onUpdateDraft({ loginUris: [...props.draft.loginUris, ''] })}>
|
<button type="button" className="btn btn-secondary small" onClick={addLoginUri}>
|
||||||
<Plus size={14} className="btn-icon" /> {t('txt_add_website')}
|
<Plus size={14} className="btn-icon" /> {t('txt_add_website')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{props.draft.loginUris.map((uri, index) => (
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragStart={handleWebsiteDragStart} onDragEnd={handleWebsiteDragEnd}>
|
||||||
<div key={`uri-${index}`} className="website-row">
|
<SortableContext items={uriItemIds} strategy={verticalListSortingStrategy}>
|
||||||
<input className="input" value={uri} onInput={(e) => props.onUpdateDraftLoginUri(index, (e.currentTarget as HTMLInputElement).value)} />
|
{props.draft.loginUris.map((uriEntry, index) => (
|
||||||
{props.draft.loginUris.length > 1 && (
|
<SortableWebsiteRow
|
||||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onUpdateDraft({ loginUris: props.draft.loginUris.filter((_, i) => i !== index) })}>
|
key={uriItemIds[index] ?? `uri-${index}`}
|
||||||
<X size={14} className="btn-icon" />
|
id={uriItemIds[index] ?? `uri-fallback-${index}`}
|
||||||
{t('txt_remove')}
|
uriEntry={uriEntry}
|
||||||
</button>
|
index={index}
|
||||||
)}
|
canRemove={props.draft.loginUris.length > 1}
|
||||||
</div>
|
isDragging={activeUriId === uriItemIds[index]}
|
||||||
))}
|
onUpdateUri={props.onUpdateDraftLoginUri}
|
||||||
|
onUpdateMatch={props.onUpdateDraftLoginUriMatch}
|
||||||
|
onRemove={removeLoginUri}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
{props.draft.loginFido2Credentials.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="section-head passkeys-section-head">
|
||||||
|
<h4>{t('txt_passkeys')}</h4>
|
||||||
|
</div>
|
||||||
|
<div className="attachment-list">
|
||||||
|
{props.draft.loginFido2Credentials.map((credential, index) => {
|
||||||
|
const createdAt = String(credential?.creationDate || '').trim();
|
||||||
|
const label = createdAt
|
||||||
|
? t('txt_passkey_created_at_value', { value: formatHistoryTime(createdAt) })
|
||||||
|
: t('txt_passkey');
|
||||||
|
return (
|
||||||
|
<div key={`login-passkey-${index}`} className="attachment-row">
|
||||||
|
<div className="attachment-main">
|
||||||
|
<div className="attachment-text">
|
||||||
|
<strong>{t('txt_passkey')}</strong>
|
||||||
|
<span>{label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="kv-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small"
|
||||||
|
disabled={props.busy}
|
||||||
|
onClick={() => props.onRequestDeleteLoginPasskey(index)}
|
||||||
|
>
|
||||||
|
<X size={14} className="btn-icon" />
|
||||||
|
{t('txt_remove')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -322,23 +523,31 @@ export default function VaultEditor(props: VaultEditorProps) {
|
|||||||
.map((field, originalIndex) => ({ field, originalIndex }))
|
.map((field, originalIndex) => ({ field, originalIndex }))
|
||||||
.filter((entry) => entry.field.type !== 3)
|
.filter((entry) => entry.field.type !== 3)
|
||||||
.map(({ field, originalIndex }) => (
|
.map(({ field, originalIndex }) => (
|
||||||
<div key={`field-${originalIndex}`} className="uri-row">
|
<div key={`field-${originalIndex}`} className="custom-field-card">
|
||||||
<input className="input" value={field.label} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { label: (e.currentTarget as HTMLInputElement).value })} />
|
<label className="field custom-field-label">
|
||||||
{field.type === 2 ? (
|
<span>{t('txt_field_label')}</span>
|
||||||
<label className="check-line cf-check">
|
<input className="input" value={field.label} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { label: (e.currentTarget as HTMLInputElement).value })} />
|
||||||
<input
|
</label>
|
||||||
type="checkbox"
|
<div className="custom-field-body">
|
||||||
checked={toBooleanFieldValue(field.value)}
|
<div className="custom-field-value">
|
||||||
onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).checked ? 'true' : 'false' })}
|
{field.type === 2 ? (
|
||||||
/>
|
<label className="check-line cf-check custom-field-check">
|
||||||
</label>
|
<input
|
||||||
) : (
|
type="checkbox"
|
||||||
<input className="input" value={field.value} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).value })} />
|
checked={toBooleanFieldValue(field.value)}
|
||||||
)}
|
onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).checked ? 'true' : 'false' })}
|
||||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onUpdateDraftCustomFields(props.draft.customFields.filter((_, i) => i !== originalIndex))}>
|
/>
|
||||||
<X size={14} className="btn-icon" />
|
<span>{toBooleanFieldValue(field.value) ? t('txt_checked') : t('txt_unchecked')}</span>
|
||||||
{t('txt_remove')}
|
</label>
|
||||||
</button>
|
) : (
|
||||||
|
<input className="input" value={field.value} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).value })} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-secondary small custom-field-remove" onClick={() => props.onUpdateDraftCustomFields(props.draft.customFields.filter((_, i) => i !== originalIndex))}>
|
||||||
|
<X size={14} className="btn-icon" />
|
||||||
|
{t('txt_remove')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import type { RefObject } from 'preact';
|
import type { RefObject } from 'preact';
|
||||||
import { ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, Trash2, X } from 'lucide-preact';
|
import { memo } from 'preact/compat';
|
||||||
|
import { createPortal } from 'preact/compat';
|
||||||
|
import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact';
|
||||||
|
import LoadingState from '@/components/LoadingState';
|
||||||
import type { Cipher } from '@/lib/types';
|
import type { Cipher } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import {
|
import {
|
||||||
CREATE_TYPE_OPTIONS,
|
|
||||||
CreateTypeIcon,
|
CreateTypeIcon,
|
||||||
VAULT_SORT_OPTIONS,
|
getCreateTypeOptions,
|
||||||
|
getVaultSortOptions,
|
||||||
VaultListIcon,
|
VaultListIcon,
|
||||||
type SidebarFilter,
|
type SidebarFilter,
|
||||||
type VaultSortMode,
|
type VaultSortMode,
|
||||||
@@ -32,11 +35,14 @@ interface VaultListPanelProps {
|
|||||||
selectedCipherId: string;
|
selectedCipherId: string;
|
||||||
selectedMap: Record<string, boolean>;
|
selectedMap: Record<string, boolean>;
|
||||||
sidebarFilter: SidebarFilter;
|
sidebarFilter: SidebarFilter;
|
||||||
|
isMobileLayout: boolean;
|
||||||
|
mobileFabVisible: boolean;
|
||||||
createMenuOpen: boolean;
|
createMenuOpen: boolean;
|
||||||
createMenuRef: RefObject<HTMLDivElement>;
|
createMenuRef: RefObject<HTMLDivElement>;
|
||||||
sortMenuRef: RefObject<HTMLDivElement>;
|
sortMenuRef: RefObject<HTMLDivElement>;
|
||||||
listPanelRef: RefObject<HTMLDivElement>;
|
listPanelRef: RefObject<HTMLDivElement>;
|
||||||
onSearchInput: (value: string) => void;
|
onSearchInput: (value: string) => void;
|
||||||
|
onClearSearch: () => void;
|
||||||
onSearchCompositionStart: () => void;
|
onSearchCompositionStart: () => void;
|
||||||
onSearchCompositionEnd: (value: string) => void;
|
onSearchCompositionEnd: (value: string) => void;
|
||||||
onToggleSortMenu: () => void;
|
onToggleSortMenu: () => void;
|
||||||
@@ -48,6 +54,8 @@ interface VaultListPanelProps {
|
|||||||
onToggleCreateMenu: () => void;
|
onToggleCreateMenu: () => void;
|
||||||
onStartCreate: (type: number) => void;
|
onStartCreate: (type: number) => void;
|
||||||
onBulkRestore: () => void;
|
onBulkRestore: () => void;
|
||||||
|
onBulkArchive: () => void;
|
||||||
|
onBulkUnarchive: () => void;
|
||||||
onOpenMove: () => void;
|
onOpenMove: () => void;
|
||||||
onClearSelection: () => void;
|
onClearSelection: () => void;
|
||||||
onScroll: (top: number) => void;
|
onScroll: (top: number) => void;
|
||||||
@@ -56,18 +64,103 @@ interface VaultListPanelProps {
|
|||||||
listSubtitle: (cipher: Cipher) => string;
|
listSubtitle: (cipher: Cipher) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CipherListItemProps {
|
||||||
|
cipher: Cipher;
|
||||||
|
selected: boolean;
|
||||||
|
checked: boolean;
|
||||||
|
subtitle: string;
|
||||||
|
onToggleSelected: (cipherId: string, checked: boolean) => void;
|
||||||
|
onSelectCipher: (cipherId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CipherListItem = memo(function CipherListItem(props: CipherListItemProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`list-item ${props.selected ? 'active' : ''}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target.closest('.row-check')) return;
|
||||||
|
props.onSelectCipher(props.cipher.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="row-check"
|
||||||
|
checked={props.checked}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onInput={(e) => props.onToggleSelected(props.cipher.id, (e.currentTarget as HTMLInputElement).checked)}
|
||||||
|
/>
|
||||||
|
<button type="button" className="row-main" onClick={() => props.onSelectCipher(props.cipher.id)}>
|
||||||
|
<div className="list-icon-wrap">
|
||||||
|
<VaultListIcon cipher={props.cipher} />
|
||||||
|
</div>
|
||||||
|
<div className="list-text">
|
||||||
|
<span className="list-title" title={props.cipher.decName || t('txt_no_name')}>
|
||||||
|
<span className="list-title-text">{props.cipher.decName || t('txt_no_name')}</span>
|
||||||
|
</span>
|
||||||
|
<span className="list-sub" title={props.subtitle}>{props.subtitle}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export default function VaultListPanel(props: VaultListPanelProps) {
|
export default function VaultListPanel(props: VaultListPanelProps) {
|
||||||
|
const createTypeOptions = getCreateTypeOptions();
|
||||||
|
const vaultSortOptions = getVaultSortOptions();
|
||||||
|
const createMenu = (
|
||||||
|
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary small mobile-fab-trigger"
|
||||||
|
aria-label={t('txt_add')}
|
||||||
|
title={t('txt_add')}
|
||||||
|
onClick={props.onToggleCreateMenu}
|
||||||
|
>
|
||||||
|
<Plus size={14} className="btn-icon" />
|
||||||
|
</button>
|
||||||
|
{props.createMenuOpen && (
|
||||||
|
<div className="create-menu">
|
||||||
|
{createTypeOptions.map((option) => (
|
||||||
|
<button key={option.type} type="button" className="create-menu-item" onClick={() => props.onStartCreate(option.type)}>
|
||||||
|
<CreateTypeIcon type={option.type} />
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="list-col">
|
<section className="list-col">
|
||||||
<div className="list-head">
|
<div className="list-head">
|
||||||
<input
|
<div className="search-input-wrap">
|
||||||
className="search-input"
|
<input
|
||||||
placeholder={t('txt_search_your_secure_vault')}
|
className="search-input"
|
||||||
value={props.searchInput}
|
placeholder={t('txt_search_your_secure_vault')}
|
||||||
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
|
value={props.searchInput}
|
||||||
onCompositionStart={props.onSearchCompositionStart}
|
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
|
||||||
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
|
onCompositionStart={props.onSearchCompositionStart}
|
||||||
/>
|
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== 'Escape' || !props.searchInput) return;
|
||||||
|
e.preventDefault();
|
||||||
|
props.onClearSearch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!!props.searchInput && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="search-clear-btn"
|
||||||
|
aria-label={t('txt_clear_search')}
|
||||||
|
title={t('txt_clear_search_esc')}
|
||||||
|
onClick={props.onClearSearch}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
|
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -80,7 +173,7 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
</button>
|
</button>
|
||||||
{props.sortMenuOpen && (
|
{props.sortMenuOpen && (
|
||||||
<div className="sort-menu">
|
<div className="sort-menu">
|
||||||
{VAULT_SORT_OPTIONS.map((option) => (
|
{vaultSortOptions.map((option) => (
|
||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -102,44 +195,27 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="toolbar actions">
|
<div className="toolbar actions">
|
||||||
<button type="button" className="btn btn-danger small" disabled={!props.selectedCount || props.busy} onClick={props.onOpenBulkDelete}>
|
|
||||||
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
|
|
||||||
</button>
|
|
||||||
{props.sidebarFilter.kind === 'duplicates' && (
|
{props.sidebarFilter.kind === 'duplicates' && (
|
||||||
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length || props.busy} onClick={props.onSelectDuplicates}>
|
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length || props.busy} onClick={props.onSelectDuplicates}>
|
||||||
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
|
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
|
|
||||||
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
|
||||||
</button>
|
|
||||||
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary small mobile-fab-trigger"
|
|
||||||
aria-label={t('txt_add')}
|
|
||||||
title={t('txt_add')}
|
|
||||||
onClick={props.onToggleCreateMenu}
|
|
||||||
>
|
|
||||||
<Plus size={14} className="btn-icon" />
|
|
||||||
</button>
|
|
||||||
{props.createMenuOpen && (
|
|
||||||
<div className="create-menu">
|
|
||||||
{CREATE_TYPE_OPTIONS.map((option) => (
|
|
||||||
<button key={option.type} type="button" className="create-menu-item" onClick={() => props.onStartCreate(option.type)}>
|
|
||||||
<CreateTypeIcon type={option.type} />
|
|
||||||
<span>{option.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{props.selectedCount > 0 && props.sidebarFilter.kind === 'trash' && (
|
{props.selectedCount > 0 && props.sidebarFilter.kind === 'trash' && (
|
||||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkRestore}>
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkRestore}>
|
||||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'duplicates' && (
|
{props.selectedCount > 0 && props.sidebarFilter.kind === 'archive' && (
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkUnarchive}>
|
||||||
|
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkArchive}>
|
||||||
|
<Archive size={14} className="btn-icon" /> {t('txt_archive_selected')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
|
||||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}>
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}>
|
||||||
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
|
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
|
||||||
</button>
|
</button>
|
||||||
@@ -149,35 +225,35 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
<X size={14} className="btn-icon" /> {t('txt_cancel')}
|
<X size={14} className="btn-icon" /> {t('txt_cancel')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button type="button" className="btn btn-danger small" disabled={!props.selectedCount || props.busy} onClick={props.onOpenBulkDelete}>
|
||||||
|
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
|
||||||
|
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
||||||
|
</button>
|
||||||
|
{props.isMobileLayout && typeof document !== 'undefined'
|
||||||
|
? props.mobileFabVisible ? createPortal(createMenu, document.body) : null
|
||||||
|
: createMenu}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
||||||
|
{props.loading && !props.filteredCiphers.length && <LoadingState lines={7} compact />}
|
||||||
{!!props.filteredCiphers.length && (
|
{!!props.filteredCiphers.length && (
|
||||||
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
|
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
|
||||||
{props.visibleCiphers.map((cipher) => (
|
{props.visibleCiphers.map((cipher) => (
|
||||||
<div key={cipher.id} className={`list-item ${props.selectedCipherId === cipher.id ? 'active' : ''}`}>
|
<CipherListItem
|
||||||
<input
|
key={cipher.id}
|
||||||
type="checkbox"
|
cipher={cipher}
|
||||||
className="row-check"
|
selected={props.selectedCipherId === cipher.id}
|
||||||
checked={!!props.selectedMap[cipher.id]}
|
checked={!!props.selectedMap[cipher.id]}
|
||||||
onInput={(e) => props.onToggleSelected(cipher.id, (e.currentTarget as HTMLInputElement).checked)}
|
subtitle={props.listSubtitle(cipher)}
|
||||||
/>
|
onToggleSelected={props.onToggleSelected}
|
||||||
<button type="button" className="row-main" onClick={() => props.onSelectCipher(cipher.id)}>
|
onSelectCipher={props.onSelectCipher}
|
||||||
<div className="list-icon-wrap">
|
/>
|
||||||
<VaultListIcon cipher={cipher} />
|
|
||||||
</div>
|
|
||||||
<div className="list-text">
|
|
||||||
<span className="list-title" title={cipher.decName || t('txt_no_name')}>
|
|
||||||
<span className="list-title-text">{cipher.decName || t('txt_no_name')}</span>
|
|
||||||
</span>
|
|
||||||
<span className="list-sub" title={props.listSubtitle(cipher)}>{props.listSubtitle(cipher)}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!props.filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>}
|
{!props.loading && !props.filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
|
import { useMemo } from 'preact/hooks';
|
||||||
|
import type { RefObject } from 'preact';
|
||||||
import {
|
import {
|
||||||
|
Archive,
|
||||||
|
ArrowUpDown,
|
||||||
|
Check,
|
||||||
Copy,
|
Copy,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Folder as FolderIcon,
|
Folder as FolderIcon,
|
||||||
@@ -7,6 +12,7 @@ import {
|
|||||||
Globe,
|
Globe,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
|
Pencil,
|
||||||
ShieldUser,
|
ShieldUser,
|
||||||
Star,
|
Star,
|
||||||
StickyNote,
|
StickyNote,
|
||||||
@@ -15,7 +21,7 @@ import {
|
|||||||
} from 'lucide-preact';
|
} from 'lucide-preact';
|
||||||
import type { Folder } from '@/lib/types';
|
import type { Folder } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import type { SidebarFilter } from '@/components/vault/vault-page-helpers';
|
import { getFolderSortOptions, type SidebarFilter, type VaultSortMode } from '@/components/vault/vault-page-helpers';
|
||||||
|
|
||||||
interface VaultSidebarProps {
|
interface VaultSidebarProps {
|
||||||
folders: Folder[];
|
folders: Folder[];
|
||||||
@@ -23,14 +29,58 @@ interface VaultSidebarProps {
|
|||||||
busy: boolean;
|
busy: boolean;
|
||||||
isMobileLayout: boolean;
|
isMobileLayout: boolean;
|
||||||
mobileSidebarOpen: boolean;
|
mobileSidebarOpen: boolean;
|
||||||
|
folderSortMode: VaultSortMode;
|
||||||
|
folderSortMenuOpen: boolean;
|
||||||
|
folderSortMenuRef: RefObject<HTMLDivElement>;
|
||||||
onCloseMobileSidebar: () => void;
|
onCloseMobileSidebar: () => void;
|
||||||
onChangeFilter: (filter: SidebarFilter) => void;
|
onChangeFilter: (filter: SidebarFilter) => void;
|
||||||
onOpenDeleteAllFolders: () => void;
|
onOpenDeleteAllFolders: () => void;
|
||||||
onOpenCreateFolder: () => void;
|
onOpenCreateFolder: () => void;
|
||||||
|
onOpenRenameFolder: (folder: Folder) => void;
|
||||||
onOpenDeleteFolder: (folder: Folder) => void;
|
onOpenDeleteFolder: (folder: Folder) => void;
|
||||||
|
onToggleFolderSortMenu: () => void;
|
||||||
|
onSelectFolderSortMode: (value: VaultSortMode) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VaultSidebar(props: VaultSidebarProps) {
|
export default function VaultSidebar(props: VaultSidebarProps) {
|
||||||
|
const folderSortOptions = getFolderSortOptions();
|
||||||
|
const nameCollator = useMemo(
|
||||||
|
() => new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const sortedFolders = useMemo(() => {
|
||||||
|
const sorted = [...props.folders];
|
||||||
|
sorted.sort((a, b) => {
|
||||||
|
if (props.folderSortMode === 'edited') {
|
||||||
|
const aTime = new Date(String(a.revisionDate || a.creationDate || '')).getTime();
|
||||||
|
const bTime = new Date(String(b.revisionDate || b.creationDate || '')).getTime();
|
||||||
|
const aValid = Number.isFinite(aTime);
|
||||||
|
const bValid = Number.isFinite(bTime);
|
||||||
|
if (aValid && bValid) {
|
||||||
|
const diff = bTime - aTime;
|
||||||
|
if (diff !== 0) return diff;
|
||||||
|
}
|
||||||
|
if (aValid !== bValid) return aValid ? -1 : 1;
|
||||||
|
} else if (props.folderSortMode === 'created') {
|
||||||
|
const aTime = new Date(String(a.creationDate || '')).getTime();
|
||||||
|
const bTime = new Date(String(b.creationDate || '')).getTime();
|
||||||
|
const aValid = Number.isFinite(aTime);
|
||||||
|
const bValid = Number.isFinite(bTime);
|
||||||
|
if (aValid && bValid) {
|
||||||
|
const diff = bTime - aTime;
|
||||||
|
if (diff !== 0) return diff;
|
||||||
|
}
|
||||||
|
if (aValid !== bValid) return aValid ? -1 : 1;
|
||||||
|
}
|
||||||
|
const nameDiff = nameCollator.compare(
|
||||||
|
String(a.decName || a.name || ''), String(b.decName || b.name || '')
|
||||||
|
);
|
||||||
|
if (nameDiff !== 0) return nameDiff;
|
||||||
|
return String(a.id || '').localeCompare(String(b.id || ''));
|
||||||
|
});
|
||||||
|
return sorted;
|
||||||
|
}, [props.folders, props.folderSortMode, nameCollator]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={`sidebar ${props.isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${props.isMobileLayout && props.mobileSidebarOpen ? 'open' : ''}`}>
|
<aside className={`sidebar ${props.isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${props.isMobileLayout && props.mobileSidebarOpen ? 'open' : ''}`}>
|
||||||
{props.isMobileLayout && (
|
{props.isMobileLayout && (
|
||||||
@@ -48,6 +98,9 @@ export default function VaultSidebar(props: VaultSidebarProps) {
|
|||||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'favorite' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'favorite' })}>
|
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'favorite' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'favorite' })}>
|
||||||
<Star size={14} className="tree-icon" /> <span className="tree-label">{t('txt_favorites')}</span>
|
<Star size={14} className="tree-icon" /> <span className="tree-label">{t('txt_favorites')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'archive' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'archive' })}>
|
||||||
|
<Archive size={14} className="tree-icon" /> <span className="tree-label">{t('txt_archive')}</span>
|
||||||
|
</button>
|
||||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'trash' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'trash' })}>
|
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'trash' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'trash' })}>
|
||||||
<Trash2 size={14} className="tree-icon" /> <span className="tree-label">{t('txt_trash')}</span>
|
<Trash2 size={14} className="tree-icon" /> <span className="tree-label">{t('txt_trash')}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -79,6 +132,32 @@ export default function VaultSidebar(props: VaultSidebarProps) {
|
|||||||
<div className="sidebar-title-row">
|
<div className="sidebar-title-row">
|
||||||
<div className="sidebar-title">{t('txt_folders')}</div>
|
<div className="sidebar-title">{t('txt_folders')}</div>
|
||||||
<div className="folder-title-actions">
|
<div className="folder-title-actions">
|
||||||
|
<div className="sort-menu-wrap" ref={props.folderSortMenuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`folder-sort-btn ${props.folderSortMenuOpen ? 'active' : ''}`}
|
||||||
|
title={t('txt_sort')}
|
||||||
|
aria-label={t('txt_sort')}
|
||||||
|
onClick={props.onToggleFolderSortMenu}
|
||||||
|
>
|
||||||
|
<ArrowUpDown size={13} />
|
||||||
|
</button>
|
||||||
|
{props.folderSortMenuOpen && (
|
||||||
|
<div className="sort-menu">
|
||||||
|
{folderSortOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`sort-menu-item ${props.folderSortMode === option.value ? 'active' : ''}`}
|
||||||
|
onClick={() => props.onSelectFolderSortMode(option.value)}
|
||||||
|
>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
{props.folderSortMode === option.value ? <Check size={14} /> : <span className="sort-menu-check-placeholder" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="folder-delete-btn"
|
className="folder-delete-btn"
|
||||||
@@ -97,7 +176,7 @@ export default function VaultSidebar(props: VaultSidebarProps) {
|
|||||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'folder' && props.sidebarFilter.folderId === null ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'folder', folderId: null })}>
|
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'folder' && props.sidebarFilter.folderId === null ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'folder', folderId: null })}>
|
||||||
<FolderX size={14} className="tree-icon" /> <span className="tree-label">{t('txt_no_folder')}</span>
|
<FolderX size={14} className="tree-icon" /> <span className="tree-label">{t('txt_no_folder')}</span>
|
||||||
</button>
|
</button>
|
||||||
{props.folders.map((folder) => (
|
{sortedFolders.map((folder) => (
|
||||||
<div key={folder.id} className="folder-row">
|
<div key={folder.id} className="folder-row">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -109,6 +188,20 @@ export default function VaultSidebar(props: VaultSidebarProps) {
|
|||||||
{folder.decName || folder.name || folder.id}
|
{folder.decName || folder.name || folder.id}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="folder-delete-btn folder-edit-btn"
|
||||||
|
title={t('txt_edit')}
|
||||||
|
aria-label={t('txt_edit')}
|
||||||
|
disabled={props.busy}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
props.onOpenRenameFolder(folder);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil size={12} />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="folder-delete-btn"
|
className="folder-delete-btn"
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
|
import type { ComponentChildren } from 'preact';
|
||||||
|
import { Globe } from 'lucide-preact';
|
||||||
|
import type { Cipher } from '@/lib/types';
|
||||||
|
import {
|
||||||
|
getWebsiteIconStatus,
|
||||||
|
markWebsiteIconErrored,
|
||||||
|
markWebsiteIconLoaded,
|
||||||
|
preloadWebsiteIcon,
|
||||||
|
subscribeWebsiteIconStatus,
|
||||||
|
} from '@/lib/website-icon-cache';
|
||||||
|
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
||||||
|
|
||||||
|
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
|
||||||
|
|
||||||
|
interface WebsiteIconProps {
|
||||||
|
cipher: Cipher;
|
||||||
|
fallback?: ComponentChildren;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WebsiteIcon(props: WebsiteIconProps) {
|
||||||
|
const host = useMemo(() => hostFromUri(firstCipherUri(props.cipher)), [props.cipher]);
|
||||||
|
const src = host ? websiteIconUrl(host) : '';
|
||||||
|
const nodeRef = useRef<HTMLSpanElement | null>(null);
|
||||||
|
const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
|
||||||
|
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!host) {
|
||||||
|
setShouldLoad(true);
|
||||||
|
setStatus('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextStatus = getWebsiteIconStatus(host);
|
||||||
|
setShouldLoad(nextStatus === 'loaded');
|
||||||
|
setStatus(nextStatus);
|
||||||
|
return subscribeWebsiteIconStatus(host, setStatus);
|
||||||
|
}, [host]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!host || shouldLoad || status === 'loaded' || status === 'error') return;
|
||||||
|
const node = nodeRef.current;
|
||||||
|
if (!node) return;
|
||||||
|
if (typeof IntersectionObserver !== 'function') {
|
||||||
|
setShouldLoad(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isIntersecting && entry.intersectionRatio <= 0) continue;
|
||||||
|
if (!cancelled) setShouldLoad(true);
|
||||||
|
observer.disconnect();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: ICON_LOAD_ROOT_MARGIN }
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(node);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [host, shouldLoad, status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!host || !src || !shouldLoad || status === 'loaded' || status === 'error') return;
|
||||||
|
let disposed = false;
|
||||||
|
void preloadWebsiteIcon(host, src).then((nextStatus) => {
|
||||||
|
if (!disposed) setStatus(nextStatus);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
};
|
||||||
|
}, [host, src, shouldLoad, status]);
|
||||||
|
|
||||||
|
if (!host || status === 'error') {
|
||||||
|
return <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="list-icon-stack" ref={nodeRef}>
|
||||||
|
{status !== 'loaded' && <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>}
|
||||||
|
{status === 'loaded' && (
|
||||||
|
<img
|
||||||
|
className="list-icon loaded"
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
onLoad={() => markWebsiteIconLoaded(host)}
|
||||||
|
onError={() => markWebsiteIconErrored(host)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'preact/hooks';
|
import { useMemo } from 'preact/hooks';
|
||||||
import {
|
import {
|
||||||
CreditCard,
|
CreditCard,
|
||||||
FileKey2,
|
FileKey2,
|
||||||
@@ -9,13 +9,15 @@ import {
|
|||||||
} from 'lucide-preact';
|
} from 'lucide-preact';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField } from '@/lib/types';
|
import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField, VaultDraftLoginUri } from '@/lib/types';
|
||||||
|
import WebsiteIcon from './WebsiteIcon';
|
||||||
|
|
||||||
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
||||||
export type VaultSortMode = 'edited' | 'created' | 'name';
|
export type VaultSortMode = 'edited' | 'created' | 'name';
|
||||||
export type SidebarFilter =
|
export type SidebarFilter =
|
||||||
| { kind: 'all' }
|
| { kind: 'all' }
|
||||||
| { kind: 'favorite' }
|
| { kind: 'favorite' }
|
||||||
|
| { kind: 'archive' }
|
||||||
| { kind: 'trash' }
|
| { kind: 'trash' }
|
||||||
| { kind: 'duplicates' }
|
| { kind: 'duplicates' }
|
||||||
| { kind: 'type'; value: TypeFilter }
|
| { kind: 'type'; value: TypeFilter }
|
||||||
@@ -26,29 +28,56 @@ interface TypeOption {
|
|||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CREATE_TYPE_OPTIONS: TypeOption[] = [
|
export function getCreateTypeOptions(): TypeOption[] {
|
||||||
{ type: 1, label: t('txt_login') },
|
return [
|
||||||
{ type: 3, label: t('txt_card') },
|
{ type: 1, label: t('txt_login') },
|
||||||
{ type: 4, label: t('txt_identity') },
|
{ type: 3, label: t('txt_card') },
|
||||||
{ type: 2, label: t('txt_note') },
|
{ type: 4, label: t('txt_identity') },
|
||||||
{ type: 5, label: t('txt_ssh_key') },
|
{ type: 2, label: t('txt_note') },
|
||||||
];
|
{ type: 5, label: t('txt_ssh_key') },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1';
|
export const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1';
|
||||||
export const MOBILE_LAYOUT_QUERY = '(max-width: 900px)';
|
export const FOLDER_SORT_STORAGE_KEY = 'nodewarden.folder-sort.v1';
|
||||||
export const VAULT_LIST_ROW_HEIGHT = 66;
|
export const MOBILE_LAYOUT_QUERY = '(max-width: 1180px)';
|
||||||
|
export const VAULT_LIST_ROW_HEIGHT = 74;
|
||||||
export const VAULT_LIST_OVERSCAN = 10;
|
export const VAULT_LIST_OVERSCAN = 10;
|
||||||
export const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [
|
export function getVaultSortOptions(): Array<{ value: VaultSortMode; label: string }> {
|
||||||
{ value: 'edited', label: t('txt_sort_last_edited') },
|
return [
|
||||||
{ value: 'created', label: t('txt_sort_created') },
|
{ value: 'edited', label: t('txt_sort_last_edited') },
|
||||||
{ value: 'name', label: t('txt_sort_name') },
|
{ value: 'created', label: t('txt_sort_created') },
|
||||||
];
|
{ value: 'name', label: t('txt_sort_name') },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export const FIELD_TYPE_OPTIONS: Array<{ value: CustomFieldType; label: string }> = [
|
export function getFolderSortOptions(): Array<{ value: VaultSortMode; label: string }> {
|
||||||
{ value: 0, label: t('txt_text') },
|
return [
|
||||||
{ value: 1, label: t('txt_hidden') },
|
{ value: 'edited', label: t('txt_sort_last_edited') },
|
||||||
{ value: 2, label: t('txt_boolean') },
|
{ value: 'created', label: t('txt_sort_created') },
|
||||||
];
|
{ value: 'name', label: t('txt_sort_name') },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFieldTypeOptions(): Array<{ value: CustomFieldType; label: string }> {
|
||||||
|
return [
|
||||||
|
{ value: 0, label: t('txt_text') },
|
||||||
|
{ value: 1, label: t('txt_hidden') },
|
||||||
|
{ value: 2, label: t('txt_boolean') },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWebsiteMatchOptions(): Array<{ value: number | null; label: string }> {
|
||||||
|
return [
|
||||||
|
{ value: null, label: t('txt_uri_match_default_base_domain') },
|
||||||
|
{ value: 0, label: t('txt_uri_match_base_domain') },
|
||||||
|
{ value: 1, label: t('txt_uri_match_host') },
|
||||||
|
{ value: 3, label: t('txt_uri_match_exact') },
|
||||||
|
{ value: 5, label: t('txt_uri_match_never') },
|
||||||
|
{ value: 2, label: t('txt_uri_match_starts_with') },
|
||||||
|
{ value: 4, label: t('txt_uri_match_regular_expression') },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export const TOTP_PERIOD_SECONDS = 30;
|
export const TOTP_PERIOD_SECONDS = 30;
|
||||||
export const TOTP_RING_RADIUS = 14;
|
export const TOTP_RING_RADIUS = 14;
|
||||||
@@ -71,6 +100,34 @@ export function cipherTypeKey(type: number): TypeFilter {
|
|||||||
return 'ssh';
|
return 'ssh';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cipherDeletedValue(cipher: Cipher): boolean {
|
||||||
|
return !!(cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cipherArchivedValue(cipher: Cipher): boolean {
|
||||||
|
return !!(cipher.archivedDate || (cipher as { archivedAt?: string | null }).archivedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCipherDeleted(cipher: Cipher): boolean {
|
||||||
|
return cipherDeletedValue(cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCipherArchived(cipher: Cipher): boolean {
|
||||||
|
return cipherArchivedValue(cipher) && !cipherDeletedValue(cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCipherVisibleInNormalVault(cipher: Cipher): boolean {
|
||||||
|
return !cipherDeletedValue(cipher) && !cipherArchivedValue(cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCipherVisibleInArchive(cipher: Cipher): boolean {
|
||||||
|
return !cipherDeletedValue(cipher) && cipherArchivedValue(cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCipherVisibleInTrash(cipher: Cipher): boolean {
|
||||||
|
return cipherDeletedValue(cipher);
|
||||||
|
}
|
||||||
|
|
||||||
export function cipherTypeLabel(type: number): string {
|
export function cipherTypeLabel(type: number): string {
|
||||||
if (type === 1) return t('txt_login');
|
if (type === 1) return t('txt_login');
|
||||||
if (type === 3) return t('txt_card');
|
if (type === 3) return t('txt_card');
|
||||||
@@ -102,27 +159,15 @@ export function toBooleanFieldValue(raw: string): boolean {
|
|||||||
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
|
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function firstCipherUri(cipher: Cipher): string {
|
export { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
||||||
const uris = cipher.login?.uris || [];
|
|
||||||
for (const uri of uris) {
|
export function createEmptyLoginUri(): VaultDraftLoginUri {
|
||||||
const raw = uri.decUri || uri.uri || '';
|
return { uri: '', match: null, originalUri: '', extra: {} };
|
||||||
if (raw.trim()) return raw.trim();
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hostFromUri(uri: string): string {
|
export function websiteMatchLabel(value: number | null | undefined): string {
|
||||||
if (!uri.trim()) return '';
|
const normalized = typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||||
try {
|
return getWebsiteMatchOptions().find((option) => option.value === normalized)?.label || t('txt_uri_match_default_base_domain');
|
||||||
const normalized = /^https?:\/\//i.test(uri) ? uri : `https://${uri}`;
|
|
||||||
return new URL(normalized).hostname || '';
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function websiteIconUrl(host: string): string {
|
|
||||||
return `/icons/${encodeURIComponent(host)}/icon.png`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function valueOrFallback(value: string | null | undefined): string {
|
function valueOrFallback(value: string | null | undefined): string {
|
||||||
@@ -216,7 +261,7 @@ export function createEmptyDraft(type: number): VaultDraft {
|
|||||||
loginUsername: '',
|
loginUsername: '',
|
||||||
loginPassword: '',
|
loginPassword: '',
|
||||||
loginTotp: '',
|
loginTotp: '',
|
||||||
loginUris: [''],
|
loginUris: [createEmptyLoginUri()],
|
||||||
loginFido2Credentials: [],
|
loginFido2Credentials: [],
|
||||||
cardholderName: '',
|
cardholderName: '',
|
||||||
cardNumber: '',
|
cardNumber: '',
|
||||||
@@ -262,11 +307,18 @@ export function draftFromCipher(cipher: Cipher): VaultDraft {
|
|||||||
draft.loginUsername = cipher.login.decUsername || '';
|
draft.loginUsername = cipher.login.decUsername || '';
|
||||||
draft.loginPassword = cipher.login.decPassword || '';
|
draft.loginPassword = cipher.login.decPassword || '';
|
||||||
draft.loginTotp = cipher.login.decTotp || '';
|
draft.loginTotp = cipher.login.decTotp || '';
|
||||||
draft.loginUris = (cipher.login.uris || []).map((x) => x.decUri || x.uri || '');
|
draft.loginUris = (cipher.login.uris || []).map((x) => ({
|
||||||
|
uri: x.decUri || x.uri || '',
|
||||||
|
match: x.match ?? null,
|
||||||
|
originalUri: x.decUri || x.uri || '',
|
||||||
|
extra: Object.fromEntries(
|
||||||
|
Object.entries(x as Record<string, unknown>).filter(([key]) => !['uri', 'match', 'decUri'].includes(key))
|
||||||
|
),
|
||||||
|
}));
|
||||||
draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials)
|
draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials)
|
||||||
? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
|
? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
|
||||||
: [];
|
: [];
|
||||||
if (!draft.loginUris.length) draft.loginUris = [''];
|
if (!draft.loginUris.length) draft.loginUris = [createEmptyLoginUri()];
|
||||||
}
|
}
|
||||||
if (cipher.card) {
|
if (cipher.card) {
|
||||||
draft.cardholderName = cipher.card.decCardholderName || '';
|
draft.cardholderName = cipher.card.decCardholderName || '';
|
||||||
@@ -372,32 +424,8 @@ export function firstPasskeyCreationTime(cipher: Cipher | null): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const failedIconHosts = new Set<string>();
|
|
||||||
|
|
||||||
export function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
export function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
||||||
const uri = firstCipherUri(cipher);
|
return <WebsiteIcon cipher={cipher} fallback={<TypeIcon type={Number(cipher.type || 1)} />} />;
|
||||||
const host = hostFromUri(uri);
|
|
||||||
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
|
|
||||||
if (host && !errored) {
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
className="list-icon"
|
|
||||||
src={websiteIconUrl(host)}
|
|
||||||
alt=""
|
|
||||||
loading="lazy"
|
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
onError={() => {
|
|
||||||
failedIconHosts.add(host);
|
|
||||||
setErrored(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className="list-icon-fallback">
|
|
||||||
<TypeIcon type={Number(cipher.type || 1)} />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function copyToClipboard(value: string): void {
|
export function copyToClipboard(value: string): void {
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import {
|
|||||||
deleteAuthorizedDevice,
|
deleteAuthorizedDevice,
|
||||||
deriveLoginHash,
|
deriveLoginHash,
|
||||||
getCurrentDeviceIdentifier,
|
getCurrentDeviceIdentifier,
|
||||||
|
getApiKey,
|
||||||
getTotpRecoveryCode,
|
getTotpRecoveryCode,
|
||||||
|
rotateApiKey,
|
||||||
revokeAuthorizedDeviceTrust,
|
revokeAuthorizedDeviceTrust,
|
||||||
revokeAllAuthorizedDeviceTrust,
|
revokeAllAuthorizedDeviceTrust,
|
||||||
setTotp,
|
setTotp,
|
||||||
|
updateAuthorizedDeviceName,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
} from '@/lib/api/auth';
|
} from '@/lib/api/auth';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
@@ -128,7 +131,6 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
|||||||
try {
|
try {
|
||||||
const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations);
|
const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations);
|
||||||
await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash });
|
await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash });
|
||||||
if (profile.id) localStorage.removeItem(`nodewarden.totp.secret.${profile.id}`);
|
|
||||||
clearDisableTotpDialog();
|
clearDisableTotpDialog();
|
||||||
await refetchTotpStatus();
|
await refetchTotpStatus();
|
||||||
onNotify('success', t('txt_totp_disabled'));
|
onNotify('success', t('txt_totp_disabled'));
|
||||||
@@ -147,10 +149,45 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
|||||||
return code;
|
return code;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getApiKey(masterPassword: string): Promise<string> {
|
||||||
|
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||||
|
const normalized = String(masterPassword || '');
|
||||||
|
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||||
|
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||||
|
const key = await getApiKey(authedFetch, derived.hash);
|
||||||
|
if (!key) throw new Error(t('txt_api_key_is_empty'));
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
|
||||||
|
async rotateApiKey(masterPassword: string): Promise<string> {
|
||||||
|
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||||
|
const normalized = String(masterPassword || '');
|
||||||
|
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||||
|
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||||
|
const key = await rotateApiKey(authedFetch, derived.hash);
|
||||||
|
if (!key) throw new Error(t('txt_api_key_is_empty'));
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
|
||||||
async refreshAuthorizedDevices() {
|
async refreshAuthorizedDevices() {
|
||||||
await refetchAuthorizedDevices();
|
await refetchAuthorizedDevices();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async renameAuthorizedDevice(device: AuthorizedDevice, name: string) {
|
||||||
|
const normalized = String(name || '').trim();
|
||||||
|
if (!normalized) {
|
||||||
|
onNotify('error', t('txt_device_note_required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await updateAuthorizedDeviceName(authedFetch, device.identifier, normalized);
|
||||||
|
await refetchAuthorizedDevices();
|
||||||
|
onNotify('success', t('txt_device_note_updated'));
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_update_device_note_failed'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
openRevokeDeviceTrust(device: AuthorizedDevice) {
|
openRevokeDeviceTrust(device: AuthorizedDevice) {
|
||||||
onSetConfirm({
|
onSetConfirm({
|
||||||
title: t('txt_revoke_device_authorization'),
|
title: t('txt_revoke_device_authorization'),
|
||||||
@@ -159,9 +196,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
|||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
onSetConfirm(null);
|
onSetConfirm(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await revokeAuthorizedDeviceTrust(authedFetch, device.identifier);
|
try {
|
||||||
await refetchAuthorizedDevices();
|
await revokeAuthorizedDeviceTrust(authedFetch, device.identifier);
|
||||||
onNotify('success', t('txt_device_authorization_revoked'));
|
await refetchAuthorizedDevices();
|
||||||
|
onNotify('success', t('txt_device_authorization_revoked'));
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_revoke_device_trust_failed'));
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -175,14 +216,18 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
|||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
onSetConfirm(null);
|
onSetConfirm(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await deleteAuthorizedDevice(authedFetch, device.identifier);
|
try {
|
||||||
if (device.identifier === getCurrentDeviceIdentifier()) {
|
await deleteAuthorizedDevice(authedFetch, device.identifier);
|
||||||
|
if (device.identifier === getCurrentDeviceIdentifier()) {
|
||||||
|
onNotify('success', t('txt_device_removed'));
|
||||||
|
onLogoutNow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await refetchAuthorizedDevices();
|
||||||
onNotify('success', t('txt_device_removed'));
|
onNotify('success', t('txt_device_removed'));
|
||||||
onLogoutNow();
|
} catch (error) {
|
||||||
return;
|
onNotify('error', error instanceof Error ? error.message : t('txt_remove_device_failed'));
|
||||||
}
|
}
|
||||||
await refetchAuthorizedDevices();
|
|
||||||
onNotify('success', t('txt_device_removed'));
|
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -196,9 +241,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
|||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
onSetConfirm(null);
|
onSetConfirm(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await revokeAllAuthorizedDeviceTrust(authedFetch);
|
try {
|
||||||
await refetchAuthorizedDevices();
|
await revokeAllAuthorizedDeviceTrust(authedFetch);
|
||||||
onNotify('success', t('txt_all_device_authorizations_revoked'));
|
await refetchAuthorizedDevices();
|
||||||
|
onNotify('success', t('txt_all_device_authorizations_revoked'));
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_revoke_all_device_trust_failed'));
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -212,9 +261,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
|||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
onSetConfirm(null);
|
onSetConfirm(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await deleteAllAuthorizedDevices(authedFetch);
|
try {
|
||||||
onNotify('success', t('txt_all_devices_removed'));
|
await deleteAllAuthorizedDevices(authedFetch);
|
||||||
onLogoutNow();
|
onNotify('success', t('txt_all_devices_removed'));
|
||||||
|
onLogoutNow();
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_remove_all_devices_failed'));
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { useMemo } from 'preact/hooks';
|
import { useMemo } from 'preact/hooks';
|
||||||
import {
|
import {
|
||||||
|
type BackupExportClientProgressEvent,
|
||||||
buildCompleteAdminBackupExport,
|
buildCompleteAdminBackupExport,
|
||||||
deleteRemoteBackup,
|
deleteRemoteBackup,
|
||||||
downloadRemoteBackup,
|
downloadRemoteBackup as fetchRemoteBackupPayload,
|
||||||
getAdminBackupSettings,
|
getAdminBackupSettings,
|
||||||
importAdminBackup,
|
importAdminBackup,
|
||||||
|
inspectRemoteBackupIntegrity,
|
||||||
listRemoteBackups,
|
listRemoteBackups,
|
||||||
restoreRemoteBackup,
|
restoreRemoteBackup as restoreRemoteBackupRequest,
|
||||||
runAdminBackupNow,
|
runAdminBackupNow,
|
||||||
saveAdminBackupSettings,
|
saveAdminBackupSettings,
|
||||||
} from '@/lib/api/backup';
|
} from '@/lib/api/backup';
|
||||||
import { downloadBytesAsFile } from '@/lib/download';
|
import { downloadBytesAsFile } from '@/lib/download';
|
||||||
|
import { dispatchBackupProgress } from '@/lib/backup-restore-progress';
|
||||||
import type { AuthedFetch } from '@/lib/api/shared';
|
import type { AuthedFetch } from '@/lib/api/shared';
|
||||||
|
|
||||||
interface UseBackupActionsOptions {
|
interface UseBackupActionsOptions {
|
||||||
@@ -25,8 +28,24 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
|
|||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
async exportBackup(includeAttachments: boolean = false) {
|
async exportBackup(includeAttachments: boolean = false) {
|
||||||
const payload = await buildCompleteAdminBackupExport(authedFetch, includeAttachments);
|
const payload = await buildCompleteAdminBackupExport(
|
||||||
|
authedFetch,
|
||||||
|
includeAttachments,
|
||||||
|
async (event: BackupExportClientProgressEvent) => {
|
||||||
|
dispatchBackupProgress(event);
|
||||||
|
}
|
||||||
|
);
|
||||||
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
|
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
|
||||||
|
dispatchBackupProgress({
|
||||||
|
operation: 'backup-export',
|
||||||
|
source: 'local',
|
||||||
|
step: 'export_complete',
|
||||||
|
fileName: payload.fileName,
|
||||||
|
stageTitle: 'txt_backup_export_progress_complete_title',
|
||||||
|
stageDetail: 'txt_backup_export_progress_complete_detail',
|
||||||
|
done: true,
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async importBackup(file: File, replaceExisting: boolean = false) {
|
async importBackup(file: File, replaceExisting: boolean = false) {
|
||||||
@@ -35,6 +54,12 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
|
|||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async importBackupAllowingChecksumMismatch(file: File, replaceExisting: boolean = false) {
|
||||||
|
const result = await importAdminBackup(authedFetch, file, replaceExisting, true);
|
||||||
|
onImported?.();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
async loadSettings() {
|
async loadSettings() {
|
||||||
return getAdminBackupSettings(authedFetch);
|
return getAdminBackupSettings(authedFetch);
|
||||||
},
|
},
|
||||||
@@ -52,16 +77,26 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async downloadRemoteBackup(destinationId: string, path: string, onProgress?: (percent: number | null) => void) {
|
async downloadRemoteBackup(destinationId: string, path: string, onProgress?: (percent: number | null) => void) {
|
||||||
const payload = await downloadRemoteBackup(authedFetch, destinationId, path, onProgress);
|
const payload = await fetchRemoteBackupPayload(authedFetch, destinationId, path, onProgress);
|
||||||
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
|
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async inspectRemoteBackup(destinationId: string, path: string) {
|
||||||
|
return inspectRemoteBackupIntegrity(authedFetch, destinationId, path);
|
||||||
|
},
|
||||||
|
|
||||||
async deleteRemoteBackup(destinationId: string, path: string) {
|
async deleteRemoteBackup(destinationId: string, path: string) {
|
||||||
await deleteRemoteBackup(authedFetch, destinationId, path);
|
await deleteRemoteBackup(authedFetch, destinationId, path);
|
||||||
},
|
},
|
||||||
|
|
||||||
async restoreRemoteBackup(destinationId: string, path: string, replaceExisting: boolean = false) {
|
async restoreRemoteBackup(destinationId: string, path: string, replaceExisting: boolean = false) {
|
||||||
const result = await restoreRemoteBackup(authedFetch, destinationId, path, replaceExisting);
|
const result = await restoreRemoteBackupRequest(authedFetch, destinationId, path, replaceExisting);
|
||||||
|
onRestored?.();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
async restoreRemoteBackupAllowingChecksumMismatch(destinationId: string, path: string, replaceExisting: boolean = false) {
|
||||||
|
const result = await restoreRemoteBackupRequest(authedFetch, destinationId, path, replaceExisting, true);
|
||||||
onRestored?.();
|
onRestored?.();
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
encryptZipBytesWithPassword,
|
encryptZipBytesWithPassword,
|
||||||
} from '@/lib/export-formats';
|
} from '@/lib/export-formats';
|
||||||
import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr } from '@/lib/crypto';
|
import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr } from '@/lib/crypto';
|
||||||
|
import { decryptSingleCipher } from '@/lib/decrypt-cipher';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import {
|
import {
|
||||||
buildPublicSendUrl,
|
buildPublicSendUrl,
|
||||||
@@ -22,12 +23,15 @@ import {
|
|||||||
} from '@/lib/app-support';
|
} from '@/lib/app-support';
|
||||||
import { buildSendShareKey, bulkDeleteSends, createSend, deleteSend, updateSend } from '@/lib/api/send';
|
import { buildSendShareKey, bulkDeleteSends, createSend, deleteSend, updateSend } from '@/lib/api/send';
|
||||||
import {
|
import {
|
||||||
|
archiveCipher,
|
||||||
buildCipherImportPayload,
|
buildCipherImportPayload,
|
||||||
|
bulkArchiveCiphers,
|
||||||
bulkDeleteCiphers,
|
bulkDeleteCiphers,
|
||||||
bulkDeleteFolders,
|
bulkDeleteFolders,
|
||||||
bulkMoveCiphers,
|
bulkMoveCiphers,
|
||||||
bulkPermanentDeleteCiphers,
|
bulkPermanentDeleteCiphers,
|
||||||
bulkRestoreCiphers,
|
bulkRestoreCiphers,
|
||||||
|
bulkUnarchiveCiphers,
|
||||||
createCipher,
|
createCipher,
|
||||||
createFolder,
|
createFolder,
|
||||||
deleteCipher,
|
deleteCipher,
|
||||||
@@ -40,6 +44,8 @@ import {
|
|||||||
type CiphersImportPayload,
|
type CiphersImportPayload,
|
||||||
type ImportedCipherMapEntry,
|
type ImportedCipherMapEntry,
|
||||||
updateCipher,
|
updateCipher,
|
||||||
|
updateFolder,
|
||||||
|
unarchiveCipher,
|
||||||
uploadCipherAttachment,
|
uploadCipherAttachment,
|
||||||
} from '@/lib/api/vault';
|
} from '@/lib/api/vault';
|
||||||
import { deriveLoginHash, getPreloginKdfConfig, verifyMasterPassword } from '@/lib/api/auth';
|
import { deriveLoginHash, getPreloginKdfConfig, verifyMasterPassword } from '@/lib/api/auth';
|
||||||
@@ -61,6 +67,8 @@ interface UseVaultSendActionsOptions {
|
|||||||
refetchFolders: () => Promise<{ data?: VaultFolder[] | undefined } | unknown>;
|
refetchFolders: () => Promise<{ data?: VaultFolder[] | undefined } | unknown>;
|
||||||
refetchSends: () => Promise<unknown>;
|
refetchSends: () => Promise<unknown>;
|
||||||
onNotify: Notify;
|
onNotify: Notify;
|
||||||
|
patchDecryptedCiphers: (updater: (prev: Cipher[]) => Cipher[]) => void;
|
||||||
|
patchDecryptedFolders: (updater: (prev: VaultFolder[]) => VaultFolder[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractImportIdMaps(cipherMap: ImportedCipherMapEntry[] | null) {
|
function extractImportIdMaps(cipherMap: ImportedCipherMapEntry[] | null) {
|
||||||
@@ -77,6 +85,144 @@ function extractImportIdMaps(cipherMap: ImportedCipherMapEntry[] | null) {
|
|||||||
return { byIndex, bySourceId };
|
return { byIndex, bySourceId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createOptimisticCipherId(): string {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return `optimistic:${crypto.randomUUID()}`;
|
||||||
|
}
|
||||||
|
return `optimistic:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optimisticCipherFromDraft(draft: VaultDraft, current?: Cipher | null): Cipher {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const type = Number(draft.type || current?.type || 1) || 1;
|
||||||
|
const next: Cipher = {
|
||||||
|
...(current || {}),
|
||||||
|
id: current?.id || createOptimisticCipherId(),
|
||||||
|
type,
|
||||||
|
folderId: draft.folderId || null,
|
||||||
|
favorite: !!draft.favorite,
|
||||||
|
reprompt: draft.reprompt ? 1 : 0,
|
||||||
|
name: draft.name || '',
|
||||||
|
notes: draft.notes || '',
|
||||||
|
decName: draft.name || '',
|
||||||
|
decNotes: draft.notes || '',
|
||||||
|
creationDate: current?.creationDate || now,
|
||||||
|
revisionDate: now,
|
||||||
|
deletedDate: current?.deletedDate || null,
|
||||||
|
archivedDate: current?.archivedDate || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type === 1) {
|
||||||
|
next.login = {
|
||||||
|
...(current?.login || {}),
|
||||||
|
username: draft.loginUsername || '',
|
||||||
|
password: draft.loginPassword || '',
|
||||||
|
totp: draft.loginTotp || '',
|
||||||
|
decUsername: draft.loginUsername || '',
|
||||||
|
decPassword: draft.loginPassword || '',
|
||||||
|
decTotp: draft.loginTotp || '',
|
||||||
|
uris: draft.loginUris.map((uri) => ({
|
||||||
|
...(uri.extra || {}),
|
||||||
|
uri: uri.uri || '',
|
||||||
|
decUri: uri.uri || '',
|
||||||
|
match: uri.match ?? null,
|
||||||
|
})),
|
||||||
|
fido2Credentials: draft.loginFido2Credentials.map((credential) => ({ ...credential })),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
next.login = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 3) {
|
||||||
|
next.card = {
|
||||||
|
...(current?.card || {}),
|
||||||
|
cardholderName: draft.cardholderName || '',
|
||||||
|
number: draft.cardNumber || '',
|
||||||
|
brand: draft.cardBrand || '',
|
||||||
|
expMonth: draft.cardExpMonth || '',
|
||||||
|
expYear: draft.cardExpYear || '',
|
||||||
|
code: draft.cardCode || '',
|
||||||
|
decCardholderName: draft.cardholderName || '',
|
||||||
|
decNumber: draft.cardNumber || '',
|
||||||
|
decBrand: draft.cardBrand || '',
|
||||||
|
decExpMonth: draft.cardExpMonth || '',
|
||||||
|
decExpYear: draft.cardExpYear || '',
|
||||||
|
decCode: draft.cardCode || '',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
next.card = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 4) {
|
||||||
|
next.identity = {
|
||||||
|
...(current?.identity || {}),
|
||||||
|
title: draft.identTitle || '',
|
||||||
|
firstName: draft.identFirstName || '',
|
||||||
|
middleName: draft.identMiddleName || '',
|
||||||
|
lastName: draft.identLastName || '',
|
||||||
|
username: draft.identUsername || '',
|
||||||
|
company: draft.identCompany || '',
|
||||||
|
ssn: draft.identSsn || '',
|
||||||
|
passportNumber: draft.identPassportNumber || '',
|
||||||
|
licenseNumber: draft.identLicenseNumber || '',
|
||||||
|
email: draft.identEmail || '',
|
||||||
|
phone: draft.identPhone || '',
|
||||||
|
address1: draft.identAddress1 || '',
|
||||||
|
address2: draft.identAddress2 || '',
|
||||||
|
address3: draft.identAddress3 || '',
|
||||||
|
city: draft.identCity || '',
|
||||||
|
state: draft.identState || '',
|
||||||
|
postalCode: draft.identPostalCode || '',
|
||||||
|
country: draft.identCountry || '',
|
||||||
|
decTitle: draft.identTitle || '',
|
||||||
|
decFirstName: draft.identFirstName || '',
|
||||||
|
decMiddleName: draft.identMiddleName || '',
|
||||||
|
decLastName: draft.identLastName || '',
|
||||||
|
decUsername: draft.identUsername || '',
|
||||||
|
decCompany: draft.identCompany || '',
|
||||||
|
decSsn: draft.identSsn || '',
|
||||||
|
decPassportNumber: draft.identPassportNumber || '',
|
||||||
|
decLicenseNumber: draft.identLicenseNumber || '',
|
||||||
|
decEmail: draft.identEmail || '',
|
||||||
|
decPhone: draft.identPhone || '',
|
||||||
|
decAddress1: draft.identAddress1 || '',
|
||||||
|
decAddress2: draft.identAddress2 || '',
|
||||||
|
decAddress3: draft.identAddress3 || '',
|
||||||
|
decCity: draft.identCity || '',
|
||||||
|
decState: draft.identState || '',
|
||||||
|
decPostalCode: draft.identPostalCode || '',
|
||||||
|
decCountry: draft.identCountry || '',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
next.identity = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 5) {
|
||||||
|
next.sshKey = {
|
||||||
|
...(current?.sshKey || {}),
|
||||||
|
privateKey: draft.sshPrivateKey || '',
|
||||||
|
publicKey: draft.sshPublicKey || '',
|
||||||
|
keyFingerprint: draft.sshFingerprint || '',
|
||||||
|
fingerprint: draft.sshFingerprint || '',
|
||||||
|
decPrivateKey: draft.sshPrivateKey || '',
|
||||||
|
decPublicKey: draft.sshPublicKey || '',
|
||||||
|
decFingerprint: draft.sshFingerprint || '',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
next.sshKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
next.fields = draft.customFields.map((field) => ({
|
||||||
|
type: field.type,
|
||||||
|
name: field.label,
|
||||||
|
value: field.value,
|
||||||
|
decName: field.label,
|
||||||
|
decValue: field.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
export default function useVaultSendActions(options: UseVaultSendActionsOptions) {
|
export default function useVaultSendActions(options: UseVaultSendActionsOptions) {
|
||||||
const {
|
const {
|
||||||
authedFetch,
|
authedFetch,
|
||||||
@@ -90,6 +236,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
refetchFolders,
|
refetchFolders,
|
||||||
refetchSends,
|
refetchSends,
|
||||||
onNotify,
|
onNotify,
|
||||||
|
patchDecryptedCiphers,
|
||||||
|
patchDecryptedFolders,
|
||||||
} = options;
|
} = options;
|
||||||
const [downloadingAttachmentKey, setDownloadingAttachmentKey] = useState('');
|
const [downloadingAttachmentKey, setDownloadingAttachmentKey] = useState('');
|
||||||
const [attachmentDownloadPercent, setAttachmentDownloadPercent] = useState<number | null>(null);
|
const [attachmentDownloadPercent, setAttachmentDownloadPercent] = useState<number | null>(null);
|
||||||
@@ -103,6 +251,91 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
await Promise.all([refetchCiphers(), refetchFolders(), refetchSends()]);
|
await Promise.all([refetchCiphers(), refetchFolders(), refetchSends()]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const syncVaultCoreInBackground = (options?: { includeFolders?: boolean }) => {
|
||||||
|
const tasks: Promise<unknown>[] = [Promise.resolve(refetchCiphers())];
|
||||||
|
if (options?.includeFolders) {
|
||||||
|
tasks.push(Promise.resolve(refetchFolders()));
|
||||||
|
}
|
||||||
|
void Promise.all(tasks).catch((err) => {
|
||||||
|
console.warn('Background vault sync failed:', err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
async function decryptAndPatch(encrypted: Cipher) {
|
||||||
|
if (!session?.symEncKey || !session?.symMacKey) {
|
||||||
|
await refetchCiphers();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const encKey = base64ToBytes(session.symEncKey);
|
||||||
|
const macKey = base64ToBytes(session.symMacKey);
|
||||||
|
const decrypted = await decryptSingleCipher(encrypted, encKey, macKey);
|
||||||
|
patchDecryptedCiphers((prev) => {
|
||||||
|
const idx = prev.findIndex((c) => c.id === decrypted.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const next = [...prev];
|
||||||
|
next[idx] = decrypted;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return [decrypted, ...prev];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptAndReplaceOptimistic(optimisticId: string, encrypted: Cipher) {
|
||||||
|
if (!session?.symEncKey || !session?.symMacKey) {
|
||||||
|
await refetchCiphers();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const encKey = base64ToBytes(session.symEncKey);
|
||||||
|
const macKey = base64ToBytes(session.symMacKey);
|
||||||
|
const decrypted = await decryptSingleCipher(encrypted, encKey, macKey);
|
||||||
|
patchDecryptedCiphers((prev) => {
|
||||||
|
const next = prev.filter((cipher) => cipher.id !== optimisticId && cipher.id !== decrypted.id);
|
||||||
|
return [decrypted, ...next];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCipherFromState(id: string) {
|
||||||
|
patchDecryptedCiphers((prev) => prev.filter((c) => c.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchCipherBatch(ids: string[], updater: (cipher: Cipher) => Cipher | null) {
|
||||||
|
const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean));
|
||||||
|
if (!idSet.size) return;
|
||||||
|
patchDecryptedCiphers((prev) => {
|
||||||
|
let changed = false;
|
||||||
|
const next: Cipher[] = [];
|
||||||
|
for (const cipher of prev) {
|
||||||
|
if (!idSet.has(cipher.id)) {
|
||||||
|
next.push(cipher);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const updated = updater(cipher);
|
||||||
|
changed = true;
|
||||||
|
if (updated) next.push(updated);
|
||||||
|
}
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchFolderBatch(ids: string[], updater: (folder: VaultFolder) => VaultFolder | null) {
|
||||||
|
const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean));
|
||||||
|
if (!idSet.size) return;
|
||||||
|
patchDecryptedFolders((prev) => {
|
||||||
|
let changed = false;
|
||||||
|
const next: VaultFolder[] = [];
|
||||||
|
for (const folder of prev) {
|
||||||
|
if (!idSet.has(folder.id)) {
|
||||||
|
next.push(folder);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const updated = updater(folder);
|
||||||
|
changed = true;
|
||||||
|
if (updated) next.push(updated);
|
||||||
|
}
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const uploadImportedAttachments = async (
|
const uploadImportedAttachments = async (
|
||||||
attachments: ImportAttachmentFile[],
|
attachments: ImportAttachmentFile[],
|
||||||
idMaps: { byIndex: Map<number, string>; bySourceId: Map<string, string> }
|
idMaps: { byIndex: Map<number, string>; bySourceId: Map<string, string> }
|
||||||
@@ -163,6 +396,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
|
|
||||||
async createVaultItem(draft: VaultDraft, attachments: File[] = []) {
|
async createVaultItem(draft: VaultDraft, attachments: File[] = []) {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
const optimistic = optimisticCipherFromDraft(draft, null);
|
||||||
|
patchDecryptedCiphers((prev) => [optimistic, ...prev.filter((cipher) => cipher.id !== optimistic.id)]);
|
||||||
try {
|
try {
|
||||||
const created = await createCipher(authedFetch, session, draft);
|
const created = await createCipher(authedFetch, session, draft);
|
||||||
for (const file of attachments) {
|
for (const file of attachments) {
|
||||||
@@ -170,9 +405,11 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
setAttachmentUploadPercent(0);
|
setAttachmentUploadPercent(0);
|
||||||
await uploadCipherAttachment(authedFetch, session, created.id, file, undefined, setAttachmentUploadPercent);
|
await uploadCipherAttachment(authedFetch, session, created.id, file, undefined, setAttachmentUploadPercent);
|
||||||
}
|
}
|
||||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
await decryptAndReplaceOptimistic(optimistic.id, created);
|
||||||
|
syncVaultCoreInBackground({ includeFolders: !!draft.folderId || attachments.length > 0 });
|
||||||
onNotify('success', t('txt_item_created'));
|
onNotify('success', t('txt_item_created'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
patchDecryptedCiphers((prev) => prev.filter((cipher) => cipher.id !== optimistic.id));
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_create_item_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_create_item_failed'));
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -185,8 +422,26 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
if (!session) return;
|
if (!session) return;
|
||||||
const addFiles = Array.isArray(options?.addFiles) ? options.addFiles : [];
|
const addFiles = Array.isArray(options?.addFiles) ? options.addFiles : [];
|
||||||
const removeAttachmentIds = Array.isArray(options?.removeAttachmentIds) ? options.removeAttachmentIds : [];
|
const removeAttachmentIds = Array.isArray(options?.removeAttachmentIds) ? options.removeAttachmentIds : [];
|
||||||
|
const previousCipher: Cipher = {
|
||||||
|
...cipher,
|
||||||
|
login: cipher.login ? { ...cipher.login, uris: cipher.login.uris ? [...cipher.login.uris] : cipher.login.uris } : cipher.login,
|
||||||
|
card: cipher.card ? { ...cipher.card } : cipher.card,
|
||||||
|
identity: cipher.identity ? { ...cipher.identity } : cipher.identity,
|
||||||
|
sshKey: cipher.sshKey ? { ...cipher.sshKey } : cipher.sshKey,
|
||||||
|
fields: cipher.fields ? cipher.fields.map((field) => ({ ...field })) : cipher.fields,
|
||||||
|
attachments: cipher.attachments ? cipher.attachments.map((attachment) => ({ ...attachment })) : cipher.attachments,
|
||||||
|
passwordHistory: cipher.passwordHistory ? cipher.passwordHistory.map((entry) => ({ ...entry })) : cipher.passwordHistory,
|
||||||
|
};
|
||||||
|
const optimistic = optimisticCipherFromDraft(draft, cipher);
|
||||||
|
if (removeAttachmentIds.length || addFiles.length) {
|
||||||
|
const removedSet = new Set(removeAttachmentIds.map((id) => String(id || '').trim()).filter(Boolean));
|
||||||
|
optimistic.attachments = (cipher.attachments || [])
|
||||||
|
.filter((attachment) => !removedSet.has(String(attachment?.id || '').trim()))
|
||||||
|
.map((attachment) => ({ ...attachment }));
|
||||||
|
}
|
||||||
|
patchCipherBatch([cipher.id], () => optimistic);
|
||||||
try {
|
try {
|
||||||
await updateCipher(authedFetch, session, cipher, draft);
|
const updated = await updateCipher(authedFetch, session, cipher, draft);
|
||||||
for (const attachmentId of removeAttachmentIds) {
|
for (const attachmentId of removeAttachmentIds) {
|
||||||
const id = String(attachmentId || '').trim();
|
const id = String(attachmentId || '').trim();
|
||||||
if (!id) continue;
|
if (!id) continue;
|
||||||
@@ -197,9 +452,16 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
setAttachmentUploadPercent(0);
|
setAttachmentUploadPercent(0);
|
||||||
await uploadCipherAttachment(authedFetch, session, cipher.id, file, cipher, setAttachmentUploadPercent);
|
await uploadCipherAttachment(authedFetch, session, cipher.id, file, cipher, setAttachmentUploadPercent);
|
||||||
}
|
}
|
||||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
await decryptAndPatch(updated);
|
||||||
|
syncVaultCoreInBackground({
|
||||||
|
includeFolders:
|
||||||
|
draft.folderId !== (cipher.folderId || '')
|
||||||
|
|| addFiles.length > 0
|
||||||
|
|| removeAttachmentIds.length > 0,
|
||||||
|
});
|
||||||
onNotify('success', t('txt_item_updated'));
|
onNotify('success', t('txt_item_updated'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
patchCipherBatch([cipher.id], () => previousCipher);
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_update_item_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_update_item_failed'));
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -227,20 +489,59 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
},
|
},
|
||||||
|
|
||||||
async deleteVaultItem(cipher: Cipher) {
|
async deleteVaultItem(cipher: Cipher) {
|
||||||
|
const previousCipher = { ...cipher };
|
||||||
|
const deletedDate = new Date().toISOString();
|
||||||
|
patchCipherBatch([cipher.id], (current) => ({ ...current, deletedDate, archivedDate: null, revisionDate: deletedDate }));
|
||||||
try {
|
try {
|
||||||
await deleteCipher(authedFetch, cipher.id);
|
const deleted = await deleteCipher(authedFetch, cipher.id);
|
||||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
await decryptAndPatch(deleted);
|
||||||
|
syncVaultCoreInBackground({ includeFolders: true });
|
||||||
onNotify('success', t('txt_item_deleted'));
|
onNotify('success', t('txt_item_deleted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
patchCipherBatch([cipher.id], () => previousCipher);
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_delete_item_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_delete_item_failed'));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async archiveVaultItem(cipher: Cipher) {
|
||||||
|
const previousCipher = { ...cipher };
|
||||||
|
const archivedDate = new Date().toISOString();
|
||||||
|
patchCipherBatch([cipher.id], (current) => ({ ...current, archivedDate, deletedDate: null, revisionDate: archivedDate }));
|
||||||
|
try {
|
||||||
|
const archived = await archiveCipher(authedFetch, cipher.id);
|
||||||
|
await decryptAndPatch(archived);
|
||||||
|
syncVaultCoreInBackground({ includeFolders: true });
|
||||||
|
onNotify('success', t('txt_item_archived'));
|
||||||
|
} catch (error) {
|
||||||
|
patchCipherBatch([cipher.id], () => previousCipher);
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_archive_item_failed'));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async unarchiveVaultItem(cipher: Cipher) {
|
||||||
|
const previousCipher = { ...cipher };
|
||||||
|
const revisionDate = new Date().toISOString();
|
||||||
|
patchCipherBatch([cipher.id], (current) => ({ ...current, archivedDate: null, revisionDate }));
|
||||||
|
try {
|
||||||
|
const unarchived = await unarchiveCipher(authedFetch, cipher.id);
|
||||||
|
await decryptAndPatch(unarchived);
|
||||||
|
syncVaultCoreInBackground({ includeFolders: true });
|
||||||
|
onNotify('success', t('txt_item_unarchived'));
|
||||||
|
} catch (error) {
|
||||||
|
patchCipherBatch([cipher.id], () => previousCipher);
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_unarchive_item_failed'));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async bulkDeleteVaultItems(ids: string[]) {
|
async bulkDeleteVaultItems(ids: string[]) {
|
||||||
try {
|
try {
|
||||||
await bulkDeleteCiphers(authedFetch, ids);
|
await bulkDeleteCiphers(authedFetch, ids);
|
||||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
const deletedDate = new Date().toISOString();
|
||||||
|
patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate, archivedDate: null }));
|
||||||
|
syncVaultCoreInBackground({ includeFolders: true });
|
||||||
onNotify('success', t('txt_deleted_selected_items'));
|
onNotify('success', t('txt_deleted_selected_items'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_delete_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_delete_failed'));
|
||||||
@@ -248,10 +549,36 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async bulkArchiveVaultItems(ids: string[]) {
|
||||||
|
try {
|
||||||
|
await bulkArchiveCiphers(authedFetch, ids);
|
||||||
|
const archivedDate = new Date().toISOString();
|
||||||
|
patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate, deletedDate: null }));
|
||||||
|
syncVaultCoreInBackground({ includeFolders: true });
|
||||||
|
onNotify('success', t('txt_archived_selected_items'));
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_archive_failed'));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async bulkUnarchiveVaultItems(ids: string[]) {
|
||||||
|
try {
|
||||||
|
await bulkUnarchiveCiphers(authedFetch, ids);
|
||||||
|
patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate: null }));
|
||||||
|
syncVaultCoreInBackground({ includeFolders: true });
|
||||||
|
onNotify('success', t('txt_unarchived_selected_items'));
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_unarchive_failed'));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async bulkMoveVaultItems(ids: string[], folderId: string | null) {
|
async bulkMoveVaultItems(ids: string[], folderId: string | null) {
|
||||||
try {
|
try {
|
||||||
await bulkMoveCiphers(authedFetch, ids, folderId);
|
await bulkMoveCiphers(authedFetch, ids, folderId);
|
||||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
patchCipherBatch(ids, (cipher) => ({ ...cipher, folderId }));
|
||||||
|
syncVaultCoreInBackground({ includeFolders: true });
|
||||||
onNotify('success', t('txt_moved_selected_items'));
|
onNotify('success', t('txt_moved_selected_items'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_move_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_move_failed'));
|
||||||
@@ -267,8 +594,16 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
||||||
await createFolder(authedFetch, session, folderName);
|
const created = await createFolder(authedFetch, session, folderName);
|
||||||
await refetchFolders();
|
patchDecryptedFolders((prev) => [
|
||||||
|
{
|
||||||
|
id: created.id,
|
||||||
|
name: created.name || folderName,
|
||||||
|
decName: folderName,
|
||||||
|
},
|
||||||
|
...prev,
|
||||||
|
]);
|
||||||
|
syncVaultCoreInBackground({ includeFolders: true });
|
||||||
onNotify('success', t('txt_folder_created'));
|
onNotify('success', t('txt_folder_created'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_create_folder_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_create_folder_failed'));
|
||||||
@@ -284,7 +619,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await deleteFolder(authedFetch, id);
|
await deleteFolder(authedFetch, id);
|
||||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
patchFolderBatch([id], () => null);
|
||||||
|
patchDecryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId === id ? { ...cipher, folderId: null } : cipher)));
|
||||||
|
syncVaultCoreInBackground({ includeFolders: true });
|
||||||
onNotify('success', t('txt_folder_deleted'));
|
onNotify('success', t('txt_folder_deleted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_delete_folder_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_delete_folder_failed'));
|
||||||
@@ -292,10 +629,34 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async renameFolder(folderId: string, name: string) {
|
||||||
|
const id = String(folderId || '').trim();
|
||||||
|
const nextName = String(name || '').trim();
|
||||||
|
if (!id) {
|
||||||
|
onNotify('error', t('txt_folder_not_found'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!nextName) {
|
||||||
|
onNotify('error', t('txt_folder_name_is_required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
||||||
|
await updateFolder(authedFetch, session, id, nextName);
|
||||||
|
patchFolderBatch([id], (folder) => ({ ...folder, decName: nextName }));
|
||||||
|
syncVaultCoreInBackground({ includeFolders: true });
|
||||||
|
onNotify('success', t('txt_folder_updated'));
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_update_folder_failed'));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async bulkRestoreVaultItems(ids: string[]) {
|
async bulkRestoreVaultItems(ids: string[]) {
|
||||||
try {
|
try {
|
||||||
await bulkRestoreCiphers(authedFetch, ids);
|
await bulkRestoreCiphers(authedFetch, ids);
|
||||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate: null }));
|
||||||
|
syncVaultCoreInBackground({ includeFolders: true });
|
||||||
onNotify('success', t('txt_restored_selected_items'));
|
onNotify('success', t('txt_restored_selected_items'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_restore_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_restore_failed'));
|
||||||
@@ -306,7 +667,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
async bulkPermanentDeleteVaultItems(ids: string[]) {
|
async bulkPermanentDeleteVaultItems(ids: string[]) {
|
||||||
try {
|
try {
|
||||||
await bulkPermanentDeleteCiphers(authedFetch, ids);
|
await bulkPermanentDeleteCiphers(authedFetch, ids);
|
||||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
patchCipherBatch(ids, () => null);
|
||||||
|
syncVaultCoreInBackground({ includeFolders: true });
|
||||||
onNotify('success', t('txt_deleted_selected_items_permanently'));
|
onNotify('success', t('txt_deleted_selected_items_permanently'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_permanent_delete_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_permanent_delete_failed'));
|
||||||
@@ -319,7 +681,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
try {
|
try {
|
||||||
await bulkDeleteFolders(authedFetch, ids);
|
await bulkDeleteFolders(authedFetch, ids);
|
||||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
const removedIds = new Set(ids);
|
||||||
|
patchDecryptedFolders((prev) => prev.filter((folder) => !removedIds.has(folder.id)));
|
||||||
|
patchDecryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId && removedIds.has(cipher.folderId) ? { ...cipher, folderId: null } : cipher)));
|
||||||
|
syncVaultCoreInBackground({ includeFolders: true });
|
||||||
onNotify('success', t('txt_folders_deleted'));
|
onNotify('success', t('txt_folders_deleted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_delete_all_folders_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_delete_all_folders_failed'));
|
||||||
@@ -453,7 +818,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
for (let i = 0; i < payload.ciphers.length; i++) {
|
for (let i = 0; i < payload.ciphers.length; i++) {
|
||||||
const raw = (payload.ciphers[i] || {}) as Record<string, unknown>;
|
const raw = (payload.ciphers[i] || {}) as Record<string, unknown>;
|
||||||
const draft = importCipherToDraft(raw, mode === 'target' ? targetFolderId : null);
|
const draft = importCipherToDraft(raw, mode === 'target' ? targetFolderId : null);
|
||||||
nextPayload.ciphers.push(await buildCipherImportPayload(session, draft));
|
const cipherPayload = await buildCipherImportPayload(session, draft);
|
||||||
|
const sourceId = String(raw.id || '').trim();
|
||||||
|
if (sourceId) cipherPayload.id = sourceId;
|
||||||
|
nextPayload.ciphers.push(cipherPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
const importedCipherMap = await importCiphers(importAuthedFetch, nextPayload, {
|
const importedCipherMap = await importCiphers(importAuthedFetch, nextPayload, {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { base64ToBytes, decryptBw } from './crypto';
|
import { base64ToBytes, decryptBw, toBufferSource } from './crypto';
|
||||||
import type { AdminBackupSettings, BackupSettingsPortablePayload } from './api/backup';
|
import type { AdminBackupSettings, BackupSettingsPortablePayload } from './api/backup';
|
||||||
import type { Profile, SessionState } from './types';
|
import type { Profile, SessionState } from './types';
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ const AES_GCM_ALGORITHM = 'AES-GCM';
|
|||||||
async function importPortablePrivateKey(pkcs8: Uint8Array): Promise<CryptoKey> {
|
async function importPortablePrivateKey(pkcs8: Uint8Array): Promise<CryptoKey> {
|
||||||
return crypto.subtle.importKey(
|
return crypto.subtle.importKey(
|
||||||
'pkcs8',
|
'pkcs8',
|
||||||
pkcs8,
|
toBufferSource(pkcs8),
|
||||||
{ name: PORTABLE_ALGORITHM, hash: PORTABLE_HASH },
|
{ name: PORTABLE_ALGORITHM, hash: PORTABLE_HASH },
|
||||||
false,
|
false,
|
||||||
['decrypt']
|
['decrypt']
|
||||||
@@ -17,7 +17,7 @@ async function importPortablePrivateKey(pkcs8: Uint8Array): Promise<CryptoKey> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function importPortableAesKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
async function importPortableAesKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
||||||
return crypto.subtle.importKey('raw', keyBytes, { name: AES_GCM_ALGORITHM }, false, ['decrypt']);
|
return crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: AES_GCM_ALGORITHM }, false, ['decrypt']);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decryptPortableBackupSettings(
|
export async function decryptPortableBackupSettings(
|
||||||
@@ -50,15 +50,15 @@ export async function decryptPortableBackupSettings(
|
|||||||
await crypto.subtle.decrypt(
|
await crypto.subtle.decrypt(
|
||||||
{ name: PORTABLE_ALGORITHM },
|
{ name: PORTABLE_ALGORITHM },
|
||||||
privateKey,
|
privateKey,
|
||||||
base64ToBytes(wrap.wrappedKey)
|
toBufferSource(base64ToBytes(wrap.wrappedKey))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const aesKey = await importPortableAesKey(portableDek);
|
const aesKey = await importPortableAesKey(portableDek);
|
||||||
const plaintext = new Uint8Array(
|
const plaintext = new Uint8Array(
|
||||||
await crypto.subtle.decrypt(
|
await crypto.subtle.decrypt(
|
||||||
{ name: AES_GCM_ALGORITHM, iv: base64ToBytes(portable.iv) },
|
{ name: AES_GCM_ALGORITHM, iv: toBufferSource(base64ToBytes(portable.iv)) },
|
||||||
aesKey,
|
aesKey,
|
||||||
base64ToBytes(portable.ciphertext)
|
toBufferSource(base64ToBytes(portable.ciphertext))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
return JSON.parse(new TextDecoder().decode(plaintext)) as AdminBackupSettings;
|
return JSON.parse(new TextDecoder().decode(plaintext)) as AdminBackupSettings;
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import type {
|
|||||||
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
|
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
|
||||||
|
|
||||||
const SESSION_KEY = 'nodewarden.web.session.v4';
|
const SESSION_KEY = 'nodewarden.web.session.v4';
|
||||||
|
const PROFILE_SNAPSHOT_KEY = 'nodewarden.web.profile-snapshot.v1';
|
||||||
const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1';
|
const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1';
|
||||||
const TOTP_REMEMBER_TOKEN_KEY = 'nodewarden.web.totp.remember-token.v1';
|
const TOTP_REMEMBER_TOKEN_KEY = 'nodewarden.web.totp.remember-token.v1';
|
||||||
|
const WEB_SESSION_HEADER = 'X-NodeWarden-Web-Session';
|
||||||
|
|
||||||
export interface PreloginResult {
|
export interface PreloginResult {
|
||||||
hash: string;
|
hash: string;
|
||||||
@@ -26,6 +28,24 @@ export interface PreloginKdfConfig {
|
|||||||
kdfParallelism: number | null;
|
kdfParallelism: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PersistedSessionState {
|
||||||
|
email: string;
|
||||||
|
authMode: 'token' | 'web-cookie';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RefreshFailure {
|
||||||
|
ok: false;
|
||||||
|
transient: boolean;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RefreshSuccess {
|
||||||
|
ok: true;
|
||||||
|
token: TokenSuccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RefreshResult = RefreshFailure | RefreshSuccess;
|
||||||
|
|
||||||
function randomHex(length: number): string {
|
function randomHex(length: number): string {
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
|
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
|
||||||
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
|
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
|
||||||
@@ -66,12 +86,19 @@ export function loadSession(): SessionState | null {
|
|||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(SESSION_KEY);
|
const raw = localStorage.getItem(SESSION_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const parsed = JSON.parse(raw) as SessionState;
|
const parsed = JSON.parse(raw) as Partial<SessionState> & Partial<PersistedSessionState>;
|
||||||
if (!parsed.accessToken || !parsed.refreshToken) return null;
|
if (parsed.authMode === 'web-cookie' && parsed.email) {
|
||||||
|
return {
|
||||||
|
email: parsed.email,
|
||||||
|
authMode: 'web-cookie',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!parsed.accessToken || !parsed.refreshToken || !parsed.email) return null;
|
||||||
return {
|
return {
|
||||||
accessToken: parsed.accessToken,
|
accessToken: parsed.accessToken,
|
||||||
refreshToken: parsed.refreshToken,
|
refreshToken: parsed.refreshToken,
|
||||||
email: parsed.email,
|
email: parsed.email,
|
||||||
|
authMode: 'token',
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -83,14 +110,72 @@ export function saveSession(session: SessionState | null): void {
|
|||||||
localStorage.removeItem(SESSION_KEY);
|
localStorage.removeItem(SESSION_KEY);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const persisted: SessionState = {
|
const persisted: PersistedSessionState = {
|
||||||
accessToken: session.accessToken,
|
|
||||||
refreshToken: session.refreshToken,
|
|
||||||
email: session.email,
|
email: session.email,
|
||||||
|
authMode: session.authMode === 'token' ? 'token' : 'web-cookie',
|
||||||
};
|
};
|
||||||
localStorage.setItem(SESSION_KEY, JSON.stringify(persisted));
|
localStorage.setItem(SESSION_KEY, JSON.stringify(persisted));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function loadProfileSnapshot(email?: string | null): Profile | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PROFILE_SNAPSHOT_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as Profile;
|
||||||
|
if (!parsed?.email) return null;
|
||||||
|
if (email && parsed.email !== email) return null;
|
||||||
|
const snapshot = stripProfileSecrets(parsed);
|
||||||
|
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(snapshot));
|
||||||
|
return snapshot;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveProfileSnapshot(profile: Profile | null): void {
|
||||||
|
if (!profile) return;
|
||||||
|
const nextSnapshot = stripProfileSecrets(profile);
|
||||||
|
try {
|
||||||
|
const rawExisting = localStorage.getItem(PROFILE_SNAPSHOT_KEY);
|
||||||
|
if (rawExisting) {
|
||||||
|
const existing = stripProfileSecrets(JSON.parse(rawExisting) as Profile);
|
||||||
|
if (
|
||||||
|
existing
|
||||||
|
&& existing.email === nextSnapshot?.email
|
||||||
|
&& existing.role === 'admin'
|
||||||
|
&& nextSnapshot?.role !== 'admin'
|
||||||
|
) {
|
||||||
|
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify({
|
||||||
|
...nextSnapshot,
|
||||||
|
role: 'admin',
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to writing the normalized snapshot below.
|
||||||
|
}
|
||||||
|
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(nextSnapshot));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearProfileSnapshot(): void {
|
||||||
|
localStorage.removeItem(PROFILE_SNAPSHOT_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripProfileSecrets(profile: Profile | null): Profile | null {
|
||||||
|
if (!profile) return null;
|
||||||
|
return {
|
||||||
|
id: String(profile.id || ''),
|
||||||
|
email: String(profile.email || ''),
|
||||||
|
name: String(profile.name || ''),
|
||||||
|
role: profile.role === 'admin' ? 'admin' : 'user',
|
||||||
|
masterPasswordHint: profile.masterPasswordHint ?? null,
|
||||||
|
publicKey: profile.publicKey ?? null,
|
||||||
|
key: '',
|
||||||
|
privateKey: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function getCurrentDeviceIdentifier(): string {
|
export function getCurrentDeviceIdentifier(): string {
|
||||||
return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim();
|
return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim();
|
||||||
}
|
}
|
||||||
@@ -170,7 +255,10 @@ export async function loginWithPassword(
|
|||||||
}
|
}
|
||||||
const resp = await fetch('/identity/connect/token', {
|
const resp = await fetch('/identity/connect/token', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
[WEB_SESSION_HEADER]: '1',
|
||||||
|
},
|
||||||
body: body.toString(),
|
body: body.toString(),
|
||||||
});
|
});
|
||||||
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
|
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
|
||||||
@@ -183,18 +271,60 @@ export async function loginWithPassword(
|
|||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshAccessToken(refreshToken: string): Promise<TokenSuccess | null> {
|
function isTransientRefreshStatus(status: number): boolean {
|
||||||
|
return status === 0 || status === 429 || status >= 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshAccessToken(session: SessionState): Promise<RefreshResult> {
|
||||||
const body = new URLSearchParams();
|
const body = new URLSearchParams();
|
||||||
body.set('grant_type', 'refresh_token');
|
body.set('grant_type', 'refresh_token');
|
||||||
body.set('refresh_token', refreshToken);
|
if (session.authMode !== 'web-cookie' && session.refreshToken) {
|
||||||
const resp = await fetch('/identity/connect/token', {
|
body.set('refresh_token', session.refreshToken);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/identity/connect/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
...(session.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
|
||||||
|
},
|
||||||
|
body: body.toString(),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const json = await parseJson<TokenError>(resp);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
transient: isTransientRefreshStatus(resp.status),
|
||||||
|
error: json?.error_description || json?.error || 'Session refresh failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const json = await parseJson<TokenSuccess>(resp);
|
||||||
|
if (!json?.access_token) {
|
||||||
|
return { ok: false, transient: false, error: 'Session refresh failed' };
|
||||||
|
}
|
||||||
|
return { ok: true, token: json };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
transient: true,
|
||||||
|
error: error instanceof Error ? error.message : 'Network error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeCurrentSession(session: SessionState | null): Promise<void> {
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
if (session?.authMode !== 'web-cookie' && session?.refreshToken) {
|
||||||
|
body.set('token', session.refreshToken);
|
||||||
|
}
|
||||||
|
await fetch('/identity/connect/revocation', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
...(session?.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
|
||||||
|
},
|
||||||
body: body.toString(),
|
body: body.toString(),
|
||||||
});
|
}).catch(() => undefined);
|
||||||
if (!resp.ok) return null;
|
|
||||||
const json = await parseJson<TokenSuccess>(resp);
|
|
||||||
return json || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerAccount(args: {
|
export async function registerAccount(args: {
|
||||||
@@ -273,31 +403,60 @@ export async function getPasswordHint(email: string): Promise<{ masterPasswordHi
|
|||||||
|
|
||||||
export function createAuthedFetch(getSession: () => SessionState | null, setSession: SessionSetter) {
|
export function createAuthedFetch(getSession: () => SessionState | null, setSession: SessionSetter) {
|
||||||
return async function authedFetch(input: string, init: RequestInit = {}): Promise<Response> {
|
return async function authedFetch(input: string, init: RequestInit = {}): Promise<Response> {
|
||||||
|
const retryableRequest = async (headers: Headers): Promise<Response> => {
|
||||||
|
const maxAttempts = 3;
|
||||||
|
let lastError: unknown;
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(input, { ...init, headers });
|
||||||
|
if (response.status !== 429 && (response.status < 500 || response.status >= 600)) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
lastError = new Error(`HTTP ${response.status}`);
|
||||||
|
if (attempt === maxAttempts - 1) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (attempt === maxAttempts - 1) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const delayMs = 250 * (2 ** attempt) + Math.floor(Math.random() * 120);
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
||||||
|
}
|
||||||
|
throw lastError instanceof Error ? lastError : new Error('Request failed');
|
||||||
|
};
|
||||||
|
|
||||||
const session = getSession();
|
const session = getSession();
|
||||||
if (!session?.accessToken) throw new Error('Unauthorized');
|
if (!session?.accessToken) throw new Error('Unauthorized');
|
||||||
const headers = new Headers(init.headers || {});
|
const headers = new Headers(init.headers || {});
|
||||||
headers.set('Authorization', `Bearer ${session.accessToken}`);
|
headers.set('Authorization', `Bearer ${session.accessToken}`);
|
||||||
|
|
||||||
let resp = await fetch(input, { ...init, headers });
|
let resp = await retryableRequest(headers);
|
||||||
if (resp.status !== 401 || !session.refreshToken) return resp;
|
if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp;
|
||||||
|
|
||||||
const refreshed = await refreshAccessToken(session.refreshToken);
|
const refreshed = await refreshAccessToken(session);
|
||||||
if (!refreshed?.access_token) {
|
if (!refreshed.ok) {
|
||||||
|
if (refreshed.transient) {
|
||||||
|
throw new Error(refreshed.error || 'Session refresh temporarily unavailable');
|
||||||
|
}
|
||||||
setSession(null);
|
setSession(null);
|
||||||
throw new Error('Session expired');
|
throw new Error('Session expired');
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextSession: SessionState = {
|
const nextSession: SessionState = {
|
||||||
...session,
|
...session,
|
||||||
accessToken: refreshed.access_token,
|
accessToken: refreshed.token.access_token,
|
||||||
refreshToken: refreshed.refresh_token || session.refreshToken,
|
refreshToken: refreshed.token.refresh_token || session.refreshToken,
|
||||||
|
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
|
||||||
};
|
};
|
||||||
setSession(nextSession);
|
setSession(nextSession);
|
||||||
saveSession(nextSession);
|
saveSession(nextSession);
|
||||||
|
|
||||||
const retryHeaders = new Headers(init.headers || {});
|
const retryHeaders = new Headers(init.headers || {});
|
||||||
retryHeaders.set('Authorization', `Bearer ${nextSession.accessToken}`);
|
retryHeaders.set('Authorization', `Bearer ${nextSession.accessToken}`);
|
||||||
resp = await fetch(input, { ...init, headers: retryHeaders });
|
resp = await retryableRequest(retryHeaders);
|
||||||
return resp;
|
return resp;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -405,6 +564,19 @@ export async function verifyMasterPassword(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getVaultRevisionDate(authedFetch: AuthedFetch): Promise<number> {
|
||||||
|
const resp = await authedFetch('/api/accounts/revision-date');
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error('Failed to load revision date');
|
||||||
|
}
|
||||||
|
const body = await parseJson<number>(resp);
|
||||||
|
const stamp = Number(body);
|
||||||
|
if (!Number.isFinite(stamp) || stamp <= 0) {
|
||||||
|
throw new Error('Invalid revision date');
|
||||||
|
}
|
||||||
|
return stamp;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getTotpStatus(authedFetch: AuthedFetch): Promise<{ enabled: boolean }> {
|
export async function getTotpStatus(authedFetch: AuthedFetch): Promise<{ enabled: boolean }> {
|
||||||
const resp = await authedFetch('/api/accounts/totp');
|
const resp = await authedFetch('/api/accounts/totp');
|
||||||
if (!resp.ok) throw new Error('Failed to load TOTP status');
|
if (!resp.ok) throw new Error('Failed to load TOTP status');
|
||||||
@@ -478,7 +650,50 @@ export async function deleteAuthorizedDevice(
|
|||||||
if (!resp.ok) throw new Error(t('txt_remove_device_failed'));
|
if (!resp.ok) throw new Error(t('txt_remove_device_failed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateAuthorizedDeviceName(
|
||||||
|
authedFetch: AuthedFetch,
|
||||||
|
deviceIdentifier: string,
|
||||||
|
name: string
|
||||||
|
): Promise<void> {
|
||||||
|
const normalized = String(name || '').trim();
|
||||||
|
if (!normalized) throw new Error(t('txt_device_note_required'));
|
||||||
|
const resp = await authedFetch(`/api/devices/${encodeURIComponent(deviceIdentifier)}/name`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: normalized }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(t('txt_update_device_note_failed'));
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Promise<void> {
|
export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Promise<void> {
|
||||||
const resp = await authedFetch('/api/devices', { method: 'DELETE' });
|
const resp = await authedFetch('/api/devices', { method: 'DELETE' });
|
||||||
if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed'));
|
if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<string> {
|
||||||
|
const resp = await authedFetch('/api/accounts/api-key', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ masterPasswordHash }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const body = await parseJson<TokenError>(resp);
|
||||||
|
throw new Error(body?.error_description || body?.error || 'Failed to get API key');
|
||||||
|
}
|
||||||
|
const body = (await parseJson<{ apiKey?: string }>(resp)) || {};
|
||||||
|
return String(body.apiKey || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rotateApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<string> {
|
||||||
|
const resp = await authedFetch('/api/accounts/rotate-api-key', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ masterPasswordHash }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const body = await parseJson<TokenError>(resp);
|
||||||
|
throw new Error(body?.error_description || body?.error || 'Failed to rotate API key');
|
||||||
|
}
|
||||||
|
const body = (await parseJson<{ apiKey?: string }>(resp)) || {};
|
||||||
|
return String(body.apiKey || '');
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
BackupRuntimeState,
|
BackupRuntimeState,
|
||||||
BackupScheduleConfig,
|
BackupScheduleConfig,
|
||||||
BackupSettings as AdminBackupSettings,
|
BackupSettings as AdminBackupSettings,
|
||||||
E3BackupDestination,
|
S3BackupDestination,
|
||||||
WebDavBackupDestination,
|
WebDavBackupDestination,
|
||||||
} from '@shared/backup-schema';
|
} from '@shared/backup-schema';
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type AuthedFetch,
|
type AuthedFetch,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
import { readResponseBytesWithProgress } from '../download';
|
import { readResponseBytesWithProgress } from '../download';
|
||||||
|
import { toBufferSource } from '../crypto';
|
||||||
import { unzipSync, zipSync } from 'fflate';
|
import { unzipSync, zipSync } from 'fflate';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
@@ -25,7 +26,7 @@ export type {
|
|||||||
BackupRuntimeState,
|
BackupRuntimeState,
|
||||||
BackupScheduleConfig,
|
BackupScheduleConfig,
|
||||||
AdminBackupSettings,
|
AdminBackupSettings,
|
||||||
E3BackupDestination,
|
S3BackupDestination,
|
||||||
WebDavBackupDestination,
|
WebDavBackupDestination,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,6 +58,21 @@ export interface AdminBackupRunResponse {
|
|||||||
settings: AdminBackupSettings;
|
settings: AdminBackupSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BackupFileIntegrityCheckResult {
|
||||||
|
hasChecksumPrefix: boolean;
|
||||||
|
expectedPrefix: string | null;
|
||||||
|
actualPrefix: string;
|
||||||
|
matches: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteBackupIntegrityResponse {
|
||||||
|
object: 'backup-remote-integrity';
|
||||||
|
destinationId: string;
|
||||||
|
path: string;
|
||||||
|
fileName: string;
|
||||||
|
integrity: BackupFileIntegrityCheckResult;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RemoteBackupItem {
|
export interface RemoteBackupItem {
|
||||||
path: string;
|
path: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -109,6 +125,18 @@ export interface AdminBackupExportPayload {
|
|||||||
bytes: Uint8Array;
|
bytes: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BackupExportClientProgressEvent {
|
||||||
|
operation: 'backup-export';
|
||||||
|
source: 'local';
|
||||||
|
step: string;
|
||||||
|
fileName: string;
|
||||||
|
stageTitle: string;
|
||||||
|
stageDetail: string;
|
||||||
|
done?: boolean;
|
||||||
|
ok?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface BackupExportManifestAttachmentBlob {
|
interface BackupExportManifestAttachmentBlob {
|
||||||
cipherId: string;
|
cipherId: string;
|
||||||
attachmentId: string;
|
attachmentId: string;
|
||||||
@@ -119,6 +147,25 @@ interface BackupExportManifest {
|
|||||||
attachmentBlobs?: BackupExportManifestAttachmentBlob[];
|
attachmentBlobs?: BackupExportManifestAttachmentBlob[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
|
||||||
|
|
||||||
|
function extractBackupTimestampFromFileName(fileName: string): string | null {
|
||||||
|
const match = String(fileName || '').match(/nodewarden_backup_(\d{8})_(\d{6})(?:_[0-9a-f]{5})?\.zip$/i);
|
||||||
|
if (!match) return null;
|
||||||
|
return `${match[1]}_${match[2]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBackupFileName(timestamp: string, checksumPrefix: string): string {
|
||||||
|
return `nodewarden_backup_${timestamp}_${checksumPrefix}.zip`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyBackupFileIntegrityName(fileName: string, bytes: Uint8Array): Promise<string> {
|
||||||
|
const integrity = await verifyBackupFileIntegrity(bytes, fileName);
|
||||||
|
const timestamp = extractBackupTimestampFromFileName(fileName);
|
||||||
|
if (!timestamp) return fileName;
|
||||||
|
return buildBackupFileName(timestamp, integrity.actualPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
export async function exportAdminBackup(
|
export async function exportAdminBackup(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
includeAttachments: boolean = false
|
includeAttachments: boolean = false
|
||||||
@@ -149,10 +196,21 @@ export async function downloadAdminBackupAttachmentBlob(
|
|||||||
|
|
||||||
export async function buildCompleteAdminBackupExport(
|
export async function buildCompleteAdminBackupExport(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
includeAttachments: boolean = false
|
includeAttachments: boolean = false,
|
||||||
|
onProgress?: (event: BackupExportClientProgressEvent) => void | Promise<void>
|
||||||
): Promise<AdminBackupExportPayload> {
|
): Promise<AdminBackupExportPayload> {
|
||||||
const payload = await exportAdminBackup(authedFetch, includeAttachments);
|
const payload = await exportAdminBackup(authedFetch, includeAttachments);
|
||||||
if (!includeAttachments) return payload;
|
if (!includeAttachments) {
|
||||||
|
await onProgress?.({
|
||||||
|
operation: 'backup-export',
|
||||||
|
source: 'local',
|
||||||
|
step: 'export_client_save',
|
||||||
|
fileName: payload.fileName,
|
||||||
|
stageTitle: 'txt_backup_export_progress_save_title',
|
||||||
|
stageDetail: 'txt_backup_export_progress_save_detail',
|
||||||
|
});
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
const zipped = unzipSync(payload.bytes);
|
const zipped = unzipSync(payload.bytes);
|
||||||
const manifestBytes = zipped['manifest.json'];
|
const manifestBytes = zipped['manifest.json'];
|
||||||
@@ -167,14 +225,41 @@ export async function buildCompleteAdminBackupExport(
|
|||||||
throw new Error(t('txt_backup_export_failed'));
|
throw new Error(t('txt_backup_export_failed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await onProgress?.({
|
||||||
|
operation: 'backup-export',
|
||||||
|
source: 'local',
|
||||||
|
step: 'export_client_fetch_attachments',
|
||||||
|
fileName: payload.fileName,
|
||||||
|
stageTitle: 'txt_backup_export_progress_fetch_attachments_title',
|
||||||
|
stageDetail: 'txt_backup_export_progress_fetch_attachments_detail',
|
||||||
|
});
|
||||||
for (const attachment of manifest.attachmentBlobs || []) {
|
for (const attachment of manifest.attachmentBlobs || []) {
|
||||||
const bytes = await downloadAdminBackupAttachmentBlob(authedFetch, attachment.blobName);
|
const bytes = await downloadAdminBackupAttachmentBlob(authedFetch, attachment.blobName);
|
||||||
zipped[`attachments/${attachment.cipherId}/${attachment.attachmentId}.bin`] = bytes;
|
zipped[`attachments/${attachment.cipherId}/${attachment.attachmentId}.bin`] = bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await onProgress?.({
|
||||||
|
operation: 'backup-export',
|
||||||
|
source: 'local',
|
||||||
|
step: 'export_client_rebuild',
|
||||||
|
fileName: payload.fileName,
|
||||||
|
stageTitle: 'txt_backup_export_progress_rebuild_title',
|
||||||
|
stageDetail: 'txt_backup_export_progress_rebuild_detail',
|
||||||
|
});
|
||||||
|
const rebuiltBytes = zipSync(zipped, { level: 0 });
|
||||||
|
const rebuiltFileName = await applyBackupFileIntegrityName(payload.fileName, rebuiltBytes);
|
||||||
|
await onProgress?.({
|
||||||
|
operation: 'backup-export',
|
||||||
|
source: 'local',
|
||||||
|
step: 'export_client_save',
|
||||||
|
fileName: rebuiltFileName,
|
||||||
|
stageTitle: 'txt_backup_export_progress_save_title',
|
||||||
|
stageDetail: 'txt_backup_export_progress_save_detail',
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
...payload,
|
...payload,
|
||||||
bytes: zipSync(zipped, { level: 0 }),
|
bytes: rebuiltBytes,
|
||||||
|
fileName: rebuiltFileName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +361,29 @@ export async function downloadRemoteBackup(
|
|||||||
return { fileName, mimeType, bytes };
|
return { fileName, mimeType, bytes };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extractBackupFileChecksumPrefix(fileName: string): string | null {
|
||||||
|
const normalized = String(fileName || '').trim();
|
||||||
|
const match = normalized.match(/_([0-9a-f]{5})\.zip$/i);
|
||||||
|
return match ? match[1].toLowerCase() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', toBufferSource(bytes));
|
||||||
|
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyBackupFileIntegrity(bytes: Uint8Array, fileName: string): Promise<BackupFileIntegrityCheckResult> {
|
||||||
|
const expectedPrefix = extractBackupFileChecksumPrefix(fileName);
|
||||||
|
const actualHash = await sha256Hex(bytes);
|
||||||
|
const actualPrefix = actualHash.slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
|
||||||
|
return {
|
||||||
|
hasChecksumPrefix: !!expectedPrefix,
|
||||||
|
expectedPrefix,
|
||||||
|
actualPrefix,
|
||||||
|
matches: !expectedPrefix || expectedPrefix === actualPrefix,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteRemoteBackup(
|
export async function deleteRemoteBackup(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
destinationId: string,
|
destinationId: string,
|
||||||
@@ -288,16 +396,32 @@ export async function deleteRemoteBackup(
|
|||||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_delete_failed')));
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_delete_failed')));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function inspectRemoteBackupIntegrity(
|
||||||
|
authedFetch: AuthedFetch,
|
||||||
|
destinationId: string,
|
||||||
|
path: string
|
||||||
|
): Promise<RemoteBackupIntegrityResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('destinationId', destinationId);
|
||||||
|
params.set('path', path);
|
||||||
|
const resp = await authedFetch(`/api/admin/backup/remote/integrity?${params.toString()}`, { method: 'GET' });
|
||||||
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed')));
|
||||||
|
const body = await parseJson<RemoteBackupIntegrityResponse>(resp);
|
||||||
|
if (!body?.integrity || !body?.fileName) throw new Error(t('txt_backup_remote_invalid_response'));
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
export async function restoreRemoteBackup(
|
export async function restoreRemoteBackup(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
destinationId: string,
|
destinationId: string,
|
||||||
path: string,
|
path: string,
|
||||||
replaceExisting: boolean = false
|
replaceExisting: boolean = false,
|
||||||
|
allowChecksumMismatch: boolean = false
|
||||||
): Promise<AdminBackupImportResponse> {
|
): Promise<AdminBackupImportResponse> {
|
||||||
const resp = await authedFetch('/api/admin/backup/remote/restore', {
|
const resp = await authedFetch('/api/admin/backup/remote/restore', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ destinationId, path, replaceExisting }),
|
body: JSON.stringify({ destinationId, path, replaceExisting, allowChecksumMismatch }),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_restore_failed')));
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_restore_failed')));
|
||||||
const body = await parseJson<AdminBackupImportResponse>(resp);
|
const body = await parseJson<AdminBackupImportResponse>(resp);
|
||||||
@@ -308,13 +432,17 @@ export async function restoreRemoteBackup(
|
|||||||
export async function importAdminBackup(
|
export async function importAdminBackup(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
file: File,
|
file: File,
|
||||||
replaceExisting: boolean = false
|
replaceExisting: boolean = false,
|
||||||
|
allowChecksumMismatch: boolean = false
|
||||||
): Promise<AdminBackupImportResponse> {
|
): Promise<AdminBackupImportResponse> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.set('file', file, file.name || 'nodewarden_backup.zip');
|
formData.set('file', file, file.name || 'nodewarden_backup.zip');
|
||||||
if (replaceExisting) {
|
if (replaceExisting) {
|
||||||
formData.set('replaceExisting', '1');
|
formData.set('replaceExisting', '1');
|
||||||
}
|
}
|
||||||
|
if (allowChecksumMismatch) {
|
||||||
|
formData.set('allowChecksumMismatch', '1');
|
||||||
|
}
|
||||||
|
|
||||||
const resp = await authedFetch('/api/admin/backup/import', {
|
const resp = await authedFetch('/api/admin/backup/import', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ function parseMaxAccessCountRaw(value: string): number | null {
|
|||||||
export async function getSends(authedFetch: AuthedFetch): Promise<Send[]> {
|
export async function getSends(authedFetch: AuthedFetch): Promise<Send[]> {
|
||||||
const resp = await authedFetch('/api/sends');
|
const resp = await authedFetch('/api/sends');
|
||||||
if (!resp.ok) throw new Error('Failed to load sends');
|
if (!resp.ok) throw new Error('Failed to load sends');
|
||||||
const body = await parseJson<{ object: 'list'; data: Send[] }>(resp);
|
const body = await parseJson<{ data?: Send[] }>(resp);
|
||||||
return body?.data || [];
|
return body?.data || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,10 +152,13 @@ export async function createSend(
|
|||||||
const uploadInfo = await parseJson<{ url?: string; sendResponse?: Send; fileUploadType?: number }>(fileResp);
|
const uploadInfo = await parseJson<{ url?: string; sendResponse?: Send; fileUploadType?: number }>(fileResp);
|
||||||
const uploadUrl = uploadInfo?.url;
|
const uploadUrl = uploadInfo?.url;
|
||||||
if (!uploadUrl) throw new Error('Create file send failed: missing upload URL');
|
if (!uploadUrl) throw new Error('Create file send failed: missing upload URL');
|
||||||
|
if (!session.accessToken) throw new Error('Unauthorized');
|
||||||
|
const payload = new ArrayBuffer(encryptedFileBytes.byteLength);
|
||||||
|
new Uint8Array(payload).set(encryptedFileBytes);
|
||||||
const uploadResp = await uploadDirectEncryptedPayload({
|
const uploadResp = await uploadDirectEncryptedPayload({
|
||||||
accessToken: session.accessToken,
|
accessToken: session.accessToken,
|
||||||
uploadUrl,
|
uploadUrl,
|
||||||
payload: encryptedFileBytes,
|
payload,
|
||||||
fileUploadType: uploadInfo?.fileUploadType,
|
fileUploadType: uploadInfo?.fileUploadType,
|
||||||
unsupportedMessage: 'Unsupported send upload type',
|
unsupportedMessage: 'Unsupported send upload type',
|
||||||
onProgress,
|
onProgress,
|
||||||
@@ -258,18 +261,24 @@ async function buildPublicSendAccessPayload(password?: string, keyPart?: string
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function accessPublicSend(accessId: string, keyPart?: string | null, password?: string): Promise<any> {
|
export async function accessPublicSend(
|
||||||
|
accessId: string,
|
||||||
|
keyPart?: string | null,
|
||||||
|
password?: string,
|
||||||
|
options?: { signal?: AbortSignal }
|
||||||
|
): Promise<unknown> {
|
||||||
const payload = await buildPublicSendAccessPayload(password, keyPart);
|
const payload = await buildPublicSendAccessPayload(password, keyPart);
|
||||||
const resp = await fetch(`/api/sends/access/${encodeURIComponent(accessId)}`, {
|
const resp = await fetch(`/api/sends/access/${encodeURIComponent(accessId)}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
|
signal: options?.signal,
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const message = await parseErrorMessage(resp, 'Failed to access send');
|
const message = await parseErrorMessage(resp, 'Failed to access send');
|
||||||
throw createApiError(message, resp.status);
|
throw createApiError(message, resp.status);
|
||||||
}
|
}
|
||||||
return (await parseJson<any>(resp)) || null;
|
return (await parseJson<unknown>(resp)) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function accessPublicSendFile(sendId: string, fileId: string, keyPart?: string | null, password?: string): Promise<string> {
|
export async function accessPublicSendFile(sendId: string, fileId: string, keyPart?: string | null, password?: string): Promise<string> {
|
||||||
@@ -288,19 +297,22 @@ export async function accessPublicSendFile(sendId: string, fileId: string, keyPa
|
|||||||
return body.url;
|
return body.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decryptPublicSend(accessData: any, urlSafeKey: string): Promise<any> {
|
export async function decryptPublicSend(accessData: unknown, urlSafeKey: string): Promise<unknown> {
|
||||||
const sendKeyMaterial = base64UrlToBytes(urlSafeKey);
|
const sendKeyMaterial = base64UrlToBytes(urlSafeKey);
|
||||||
const sendKey = await toSendKeyParts(sendKeyMaterial);
|
const sendKey = await toSendKeyParts(sendKeyMaterial);
|
||||||
const out: any = { ...accessData };
|
const source = accessData && typeof accessData === 'object' ? accessData as Record<string, unknown> : {};
|
||||||
out.decName = await decryptStr(accessData?.name || '', sendKey.enc, sendKey.mac);
|
const text = source.text && typeof source.text === 'object' ? source.text as Record<string, unknown> : null;
|
||||||
if (accessData?.text?.text) {
|
const file = source.file && typeof source.file === 'object' ? source.file as Record<string, unknown> : null;
|
||||||
out.decText = await decryptStr(accessData.text.text, sendKey.enc, sendKey.mac);
|
const out: Record<string, unknown> = { ...source };
|
||||||
|
out.decName = await decryptStr(String(source.name || ''), sendKey.enc, sendKey.mac);
|
||||||
|
if (text?.text) {
|
||||||
|
out.decText = await decryptStr(String(text.text), sendKey.enc, sendKey.mac);
|
||||||
}
|
}
|
||||||
if (accessData?.file?.fileName) {
|
if (file?.fileName) {
|
||||||
try {
|
try {
|
||||||
out.decFileName = await decryptStr(accessData.file.fileName, sendKey.enc, sendKey.mac);
|
out.decFileName = await decryptStr(String(file.fileName), sendKey.enc, sendKey.mac);
|
||||||
} catch {
|
} catch {
|
||||||
out.decFileName = String(accessData.file.fileName);
|
out.decFileName = String(file.fileName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -63,14 +63,14 @@ interface UploadWithProgressOptions {
|
|||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
method?: string;
|
method?: string;
|
||||||
headers?: HeadersInit;
|
headers?: HeadersInit;
|
||||||
body?: Document | XMLHttpRequestBodyInit | null;
|
body?: XMLHttpRequestBodyInit | null;
|
||||||
onProgress?: (percent: number | null) => void;
|
onProgress?: (percent: number | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DirectEncryptedUploadOptions {
|
interface DirectEncryptedUploadOptions {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
uploadUrl: string;
|
uploadUrl: string;
|
||||||
payload: ArrayBuffer | Uint8Array;
|
payload: XMLHttpRequestBodyInit;
|
||||||
fileUploadType: number | null | undefined;
|
fileUploadType: number | null | undefined;
|
||||||
unsupportedMessage: string;
|
unsupportedMessage: string;
|
||||||
onProgress?: (percent: number | null) => void;
|
onProgress?: (percent: number | null) => void;
|
||||||
|
|||||||