Compare commits
376 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cae5cb218 | |||
| d96ad9bb1c | |||
| 92d1f07998 | |||
| a8432ab94b | |||
| 2230f75d8a | |||
| a982a5a57b | |||
| 4d7ee2164a | |||
| 34d4851981 | |||
| 4827a4958e | |||
| 70463d3fc7 | |||
| 681705ee13 | |||
| 5bf7c79ada | |||
| c516194d54 | |||
| 53231a4878 | |||
| c9e7417825 | |||
| 76623d7201 | |||
| 90a7731351 | |||
| f4adeb8ec9 | |||
| bb0b82f838 | |||
| be82c953d6 | |||
| edd2ba2e44 | |||
| 0f6da7d147 | |||
| 1184cb8d9a | |||
| 882fa2e8c8 | |||
| b6b7e46f79 | |||
| 144d3d9406 | |||
| 10707cf902 | |||
| 3bd4f6a9fe | |||
| 3d4e95ef66 | |||
| 2a7879efaa | |||
| bd8e26d2ab | |||
| 783fcbbe4b | |||
| 9e892e85a2 | |||
| 3e5a80e498 | |||
| 89308fc8a6 | |||
| fe0bd80f43 | |||
| 0062fd6c48 | |||
| 7373eeb501 | |||
| 8b07cd4409 | |||
| 0fc7bd7985 | |||
| 58c029beba | |||
| ac79cbd8bd | |||
| 96fc3ae485 | |||
| cb4632cd04 | |||
| f7b5534cd0 | |||
| b50673f7d9 | |||
| 98e94e766f | |||
| a17ed646a0 | |||
| c2b920532d | |||
| fba2aa9746 | |||
| cbf1e86881 | |||
| 3d38424d77 | |||
| 5ff322d809 | |||
| facd0ea5f7 | |||
| 8bc43b8f0c | |||
| bb3fe41330 | |||
| 3204eeb9ab | |||
| 9280f6916e | |||
| 3f7ca52983 | |||
| 011fe15aae | |||
| 98a653efb6 | |||
| b5d58f1aa8 | |||
| 010cda972c | |||
| 911cec337e | |||
| 40fe9223ac | |||
| 3791f89a5c | |||
| 0ba85229a9 | |||
| b5f8ef28cc | |||
| c16f9881d3 | |||
| 99f5bc735e | |||
| 623ad1acda | |||
| 43ec591414 | |||
| 2ebd0b60f7 | |||
| 4de8643360 | |||
| 2f448964f2 | |||
| 9fcd700dc4 | |||
| 3cb2ef1015 | |||
| 557f4bfbbd | |||
| c42a52f889 | |||
| 3d33f78a0c | |||
| 4b8cad6d00 | |||
| fc2667501c | |||
| 9820c2ed44 | |||
| a4b45c1b59 | |||
| 171f3c5d71 | |||
| 588408ff96 | |||
| 722d3db0e9 | |||
| ca74e55979 | |||
| f0ace28bf2 | |||
| 1cef45e373 | |||
| 1fcfeb91d1 | |||
| f749bbf7fd | |||
| 5faf1bdee1 | |||
| 8755b64f56 | |||
| b1c6ec50da | |||
| 05f1b2f9a8 | |||
| 51d0e60cf1 | |||
| 33323439cd | |||
| cc522ec40f | |||
| 96b076b113 | |||
| 246a743822 | |||
| 73e90f7860 | |||
| 37cbb2f2c7 | |||
| b10e6032d4 | |||
| 0bb1baf768 | |||
| a994214e4a | |||
| 3eb517a92f | |||
| f51468b7b9 | |||
| ad764a9c5b | |||
| 94cb6177f2 | |||
| 9b26feb310 | |||
| 80d6315148 | |||
| f4d2e7932a | |||
| 7c64453c1a | |||
| 810edfe8a6 | |||
| d1aee25905 | |||
| 3b0ccf2a77 | |||
| cf815805e9 | |||
| bc5efbf2fd | |||
| 616d6273bb | |||
| 1285f6296e | |||
| cb137fe0c7 | |||
| 899f1004a3 | |||
| f0c57a7f9c | |||
| 54cf1ff718 | |||
| e0d53b4683 | |||
| c34c44ce5b | |||
| d48e6b6ce5 | |||
| 1062725b46 | |||
| 61dac98a12 | |||
| c8194a04c7 | |||
| 219f569969 | |||
| a372b99fc9 | |||
| f556782c86 | |||
| 68583821fe | |||
| ed678a070e | |||
| 0e1152a0b9 | |||
| 5fee320eee | |||
| eeb477b84c | |||
| 01f01e5903 | |||
| 206b0be566 | |||
| 5c2c6cfb6c | |||
| eec27f3a40 | |||
| ec57897a5f | |||
| d828f145db | |||
| 3f7af954c7 | |||
| e7d2c85de9 | |||
| 1b242b8404 | |||
| 49c71039a4 | |||
| 4cec39cfe2 | |||
| ca194da822 | |||
| e931307c8f | |||
| 23c78b3408 | |||
| 0fcdc61843 | |||
| 1aa29dda11 | |||
| be572746a3 | |||
| bf066fc68b | |||
| 40a3105b82 | |||
| 03b793b14a | |||
| 5f386c80c5 | |||
| 54466160af | |||
| 257928a317 | |||
| fdf266111b | |||
| 39ec5da861 | |||
| 5d636e4977 | |||
| 57aa7457ae | |||
| 773453b7cc | |||
| c54740517c | |||
| d054d76afe | |||
| dc7d80ddfc | |||
| dab0961a63 | |||
| 1e34a96c57 | |||
| e12ab2b334 | |||
| 380cd34474 | |||
| 7b5f6163cf | |||
| 56235cb94d | |||
| 55c5573544 | |||
| 49af3e7099 | |||
| 9db92d13ab | |||
| c39654ab3c | |||
| 12024203be | |||
| f5684145f9 | |||
| a2654dcde3 | |||
| 8c35d89519 | |||
| cb662b7d70 | |||
| 4d5f207ce7 | |||
| 1ac063909f | |||
| 3f62a03181 | |||
| 35dc239c25 | |||
| 7ace10e7cc | |||
| c99a558b5e | |||
| 8df3221078 | |||
| 819734ce5c | |||
| 36f398b728 | |||
| 7b4733d4c4 | |||
| 6ca1fa739f | |||
| af56236dba | |||
| 7193df7f11 | |||
| 3622c58680 | |||
| 0d36aa9139 | |||
| b5284e669a | |||
| d63755f67d | |||
| 4da5525a1a | |||
| 6dcc18e2e9 | |||
| 16a7bcace9 | |||
| f230e5c8c2 | |||
| f59e81de3a | |||
| 8ac2ab0699 | |||
| 227d43194d | |||
| f9030d5dbb | |||
| 3341a9ef74 | |||
| 41221998c9 | |||
| d0c97ee573 | |||
| fab6d9da67 | |||
| 5dab96f40e | |||
| 01154947ef | |||
| dc12a73ab3 | |||
| 82131bd892 | |||
| 9c9c76d82e | |||
| ddf5901730 | |||
| a1d38b76c6 | |||
| 65b57b00e2 | |||
| 705a716a80 | |||
| 15eb72a4b3 | |||
| 1a1b334f6c | |||
| 30884d7184 | |||
| 8d6835b665 | |||
| 1ab8e1baa7 | |||
| 189a7b9285 | |||
| d3d4755505 | |||
| 23a45913e0 | |||
| a0b9f970c1 | |||
| ace9f4f5ac | |||
| f20a71e8a8 | |||
| c0683016c3 | |||
| 7d5681665f | |||
| e9ace523e6 | |||
| 1a94f8dd44 | |||
| 4390251c1e | |||
| 66f995d981 | |||
| aef0c2f688 | |||
| 234e3a5e96 | |||
| 594ca0c7ea | |||
| d3b515fd99 | |||
| 26447cd9b4 | |||
| 68f66cf4e6 | |||
| f5a2523f91 | |||
| 9061ab52b6 | |||
| bbf4094943 | |||
| 1d170baaaf | |||
| 9f14bca99a | |||
| bacf27b936 | |||
| 8641df3cff | |||
| 1810e0aa7a | |||
| 8852127743 | |||
| 3a650740a1 | |||
| 053ce887f9 | |||
| 9b490016aa | |||
| 2fbe29a0d9 | |||
| 0db5f957c8 | |||
| 15b87025ad | |||
| 8481e2756e | |||
| 0e823e80a6 | |||
| b7dfd1b3ad | |||
| bb50617b16 | |||
| 9c1c5e2c26 | |||
| be3b68956b | |||
| 15e0a29bb1 | |||
| 0f132f4f43 | |||
| 205ccdad8b | |||
| 32c695c81f | |||
| 389872d491 | |||
| 651eb69bd6 | |||
| d7c41edad4 | |||
| 0cf8028087 | |||
| 5509492563 | |||
| 3494471cad | |||
| 7c7d32de30 | |||
| 59566f88e3 | |||
| 4831a0915c | |||
| 172f6626c0 | |||
| 930f4f86cc | |||
| 829008db7f | |||
| ceb4bef9e4 | |||
| 363aec1652 | |||
| c4c25efc50 | |||
| b8c4bcef0c | |||
| bda0cba1c6 | |||
| d0c8516021 | |||
| b10ce83ca0 | |||
| 1f4933c5d5 | |||
| ee784d18db | |||
| 4a37d742eb | |||
| ec9be40d6c | |||
| 6bbc7554c1 | |||
| b21b031120 | |||
| d80821edeb | |||
| 90da97c945 | |||
| 6e95d7a235 | |||
| 39fbdc7e0e | |||
| f9b084d09d | |||
| 9359ce2a2c | |||
| 4f82cf9d43 | |||
| 026aea03dc | |||
| bc0fd65b6b | |||
| 6621738b02 | |||
| 08114762bc | |||
| 431cc0d5d7 | |||
| 1dfa96611a | |||
| 2226bdd9ef | |||
| 36715645c6 | |||
| f7a5966104 | |||
| 3873d347aa | |||
| 747cad35f5 | |||
| 874d31e86b | |||
| c44436a5fd | |||
| cd7b5a361c | |||
| a3f074f38a | |||
| 9eddb91237 | |||
| 8106364650 | |||
| b2e8d3e00b | |||
| 2934ebd36d | |||
| a83e0d259e | |||
| 177d34ba54 | |||
| b6f2882cdf | |||
| 622a4ec506 | |||
| aaf5078c8a | |||
| 3f8a6d78d5 | |||
| 76d766d5d6 | |||
| 269055867b | |||
| cdbe87aac2 | |||
| 363a029618 | |||
| d1a43f2e95 | |||
| 2b6852fb7f | |||
| 8d6bcc327d | |||
| e452dde3dc | |||
| d1e6ec8b8d | |||
| 6b8ee28e54 | |||
| 3e56d05283 | |||
| 2f7dbc78d3 | |||
| 870149c771 | |||
| 1a22b108ca | |||
| 9771df8777 | |||
| 40549147bd | |||
| 0be3b91dd7 | |||
| c0a390baa5 | |||
| 645a2f8e95 | |||
| 7cdccde684 | |||
| f63b5d6cf4 | |||
| 9edaa647c4 | |||
| 081dc64093 | |||
| ba9710cdf0 | |||
| 3ec1ecf464 | |||
| 69f4fde5a2 | |||
| b6d4113e21 | |||
| 2a747c996d | |||
| c53819e178 | |||
| e1f1c6f865 | |||
| 73db6c518b | |||
| fff2b149e9 | |||
| 1d1cbd2c8e | |||
| 50ee2e6b64 | |||
| 72ec21415b | |||
| 309cd98edc | |||
| 649f54f923 | |||
| 3fff6c0277 | |||
| beefe2227e | |||
| ced0a183b3 | |||
| 326e13adf0 | |||
| 4939df7fa2 | |||
| 6e1a8e7b5c | |||
| 6c3fbbe78c | |||
| c5d3052080 | |||
| 719024d0fd | |||
| ff7b44e501 | |||
| 4772c17e44 |
@@ -0,0 +1,70 @@
|
|||||||
|
name: "Bug Report"
|
||||||
|
description: "Report a reproducible bug / 反馈可复现问题"
|
||||||
|
title: "[Bug] "
|
||||||
|
labels: ["bug", "needs-triage"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for reporting. Please provide enough detail so maintainers can reproduce quickly.
|
||||||
|
感谢反馈,请尽量提供可复现信息,方便快速定位。
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Pre-check / 提交前确认
|
||||||
|
options:
|
||||||
|
- label: I have searched existing issues and did not find a duplicate. / 我已搜索现有 issue,确认不是重复问题。
|
||||||
|
required: true
|
||||||
|
- label: I have read README and Project Wiki / 我已阅读 README 与 项目 Wiki。
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Version / 版本
|
||||||
|
description: "Which version of NodeWarden are you using? Please provide the exact version or commit hash."
|
||||||
|
placeholder: "1.0.0"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: reproduce_steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to Reproduce / 复现步骤
|
||||||
|
placeholder: |
|
||||||
|
1. Start service with ...
|
||||||
|
2. Open ...
|
||||||
|
3. Click ...
|
||||||
|
4. Observe ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: Expected Behavior / 预期行为
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: actual
|
||||||
|
attributes:
|
||||||
|
label: Actual Behavior / 实际行为
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Logs and Screenshots / 日志与截图
|
||||||
|
description: "Please paste key logs (docker logs / browser console / network errors)."
|
||||||
|
render: shell
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: extra
|
||||||
|
attributes:
|
||||||
|
label: Additional Context / 补充信息
|
||||||
|
description: "Any workaround, frequency, impact scope, etc."
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Project Wiki/ 项目文档
|
||||||
|
url: https://github.com/shuaiplus/nodewarden/wiki
|
||||||
|
about: |
|
||||||
|
Please check the documentation for common questions and troubleshooting steps.
|
||||||
|
请先查看文档,常见问题和排查步骤可能已经覆盖了你的问题。
|
||||||
|
- name: Project Discussions / 讨论区
|
||||||
|
url: https://github.com/shuaiplus/nodewarden/discussions
|
||||||
|
about: |
|
||||||
|
For general questions, feature discussions, or if you're not sure which template to use, please post in the Discussions section.
|
||||||
|
如果你有一般性问题、功能讨论,或者不确定使用哪个模板,请在讨论区发帖。
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
name: "Feature Request"
|
||||||
|
description: "Suggest an improvement / 功能建议"
|
||||||
|
title: "[Feature] "
|
||||||
|
labels: ["enhancement", "needs-triage"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Proposals with clear use-case and expected value are easier to evaluate.
|
||||||
|
说明清晰的使用场景和价值,有助于快速评估。
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Pre-check / 提交前确认
|
||||||
|
options:
|
||||||
|
- label: I have searched existing issues and this request is not duplicated. / 我已搜索现有 issue,确认不是重复建议。
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Problem Statement / 现存问题
|
||||||
|
description: "What is difficult or missing today?"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: proposal
|
||||||
|
attributes:
|
||||||
|
label: Proposed Solution / 建议方案
|
||||||
|
description: "Describe your expected behavior, UI flow, API changes, etc."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives Considered / 备选方案
|
||||||
|
description: "Any alternatives or workarounds you've considered."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: impact
|
||||||
|
attributes:
|
||||||
|
label: Expected Impact / 预期价值
|
||||||
|
description: "Who benefits? Any performance/security/maintenance concerns?"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: scope
|
||||||
|
attributes:
|
||||||
|
label: Scope (Optional) / 影响范围(可选)
|
||||||
|
placeholder: "frontend / backend / docs / deployment"
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: extra
|
||||||
|
attributes:
|
||||||
|
label: Additional Context / 补充信息
|
||||||
|
description: "Mockups, references, related links, etc."
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security Report Generator (Node.js)
|
||||||
|
* Better, faster, and more maintainable than Bash.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class SecurityReport {
|
||||||
|
constructor() {
|
||||||
|
this.results = {
|
||||||
|
codeql: { status: 'PASS', findings: [], alertCount: 0, rulesCount: 0 },
|
||||||
|
snyk: { status: 'PASS', findings: [], vulnCount: 0 },
|
||||||
|
gitleaks: { status: 'PASS', findings: [], leaksCount: 0 },
|
||||||
|
trivy: { status: 'PASS', findings: [], misconfigCount: 0 },
|
||||||
|
coverage: { actions: 0, js: 0, ts: 0 },
|
||||||
|
artifactUris: []
|
||||||
|
};
|
||||||
|
this.auditTime = new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
|
||||||
|
this.runId = process.env.GITHUB_RUN_ID || '0';
|
||||||
|
this.repository = process.env.GITHUB_REPOSITORY || 'unknown/repo';
|
||||||
|
this.runUrl = `https://github.com/${this.repository}/actions/runs/${this.runId}`;
|
||||||
|
|
||||||
|
this.locales = {
|
||||||
|
zh: {
|
||||||
|
filename: 'security-report-cn.md',
|
||||||
|
switcher: '[English](security-report.md) | 中文',
|
||||||
|
title: '🛡️ 安全审计与透明度报告',
|
||||||
|
grade: '安全评级',
|
||||||
|
important: '> [!IMPORTANT]\n> 本报告由 **GitHub Actions** 自动生成。为确保数据主权的绝对透明度,所有核心模块的安全扫描结果均实时公开。',
|
||||||
|
auditTime: '📅 审计时间',
|
||||||
|
runId: '📝 运行 ID',
|
||||||
|
env: '🛠️ 环境',
|
||||||
|
dashboard: '📉 实时安全仪表盘',
|
||||||
|
tool: '工具',
|
||||||
|
status: '状态',
|
||||||
|
findings: '发现项',
|
||||||
|
leaks: '泄露',
|
||||||
|
vulns: '漏洞',
|
||||||
|
alerts: '告警',
|
||||||
|
coverageTitle: '🔍 扫描覆盖范围',
|
||||||
|
module: '模块',
|
||||||
|
auditedFiles: '已审计文件',
|
||||||
|
coverage: '覆盖率',
|
||||||
|
detailedFindings: '🔍 详细发现项',
|
||||||
|
gitleaksTitle: '🔑 凭据泄露检查 (Gitleaks)',
|
||||||
|
gitleaksDesc: '`检测代码历史记录中硬编码的 API 密钥、密码或其他敏感令牌。`',
|
||||||
|
gitleaksSafe: '✅ **安全**:未发现硬编码的敏感凭据。',
|
||||||
|
gitleaksScope: '`扫描范围:所有代码更改和 Git 历史记录 (Gitleaks 全量扫描)`',
|
||||||
|
snykTitle: '📦 第三方依赖',
|
||||||
|
snykSafe: '✅ **安全**:在依赖项中未发现已知漏洞。',
|
||||||
|
package: '软件包',
|
||||||
|
severity: '严重程度',
|
||||||
|
description: '描述',
|
||||||
|
fixPlan: '修复方案',
|
||||||
|
codeqlTitle: '💻 代码质量与安全 (CodeQL)',
|
||||||
|
codeqlSummary: '#### 摘要',
|
||||||
|
rulesChecked: '已检查规则',
|
||||||
|
totalAlerts: '告警总数',
|
||||||
|
codeqlSafe: '✅ **安全**:CodeQL 扫描清洁,未检测到问题。',
|
||||||
|
ruleId: '规则 ID',
|
||||||
|
level: '级别',
|
||||||
|
location: '位置',
|
||||||
|
auditedList: '📂 已审计文件列表',
|
||||||
|
guideTitle: '⚠️ 操作指南',
|
||||||
|
guideDesc: '如果您看到 **FAIL** 状态或严重的代码问题:',
|
||||||
|
guideStep1: '1. **开发人员**:使用上方表格中的 **位置** 列找到确切的文件和行号。',
|
||||||
|
guideStep2: '2. **纠正**:遵循为每个规则提供的文档链接以提交修复。',
|
||||||
|
guideStep3: '3. **可追溯性**:完整的原始 `.sarif` 数据已附加到此分支。下载并将其导入您的 IDE(例如 VS Code SARIF 查看器)进行本地分析。',
|
||||||
|
footer: '💡 *由 Antigravity AI 安全引擎生成。透明度是我们的承诺。*',
|
||||||
|
auditedIcon: '✅ **已审计**',
|
||||||
|
noFiles: '未检索到文件。',
|
||||||
|
trivyTitle: '🛡️ 容器配置安全 (Trivy)',
|
||||||
|
trivyDesc: '`检测 Dockerfile 和容器配置中的安全风险与最佳实践。`',
|
||||||
|
trivySafe: '✅ **安全**:未发现容器配置缺陷。'
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
filename: 'security-report.md',
|
||||||
|
switcher: 'English | [中文](security-report-cn.md)',
|
||||||
|
title: '🛡️ Security Audit & Transparency Report',
|
||||||
|
grade: 'Security Grade',
|
||||||
|
important: '> [!IMPORTANT]\n> This report is automatically generated by **GitHub Actions**. To ensure absolute transparency of data sovereignty, all core module security scan results are made public in real-time.',
|
||||||
|
auditTime: '📅 Audit Time',
|
||||||
|
runId: '📝 Run ID',
|
||||||
|
env: '🛠️ Environment',
|
||||||
|
dashboard: '📉 Real-time Security Dashboard',
|
||||||
|
tool: 'Tool',
|
||||||
|
status: 'Status',
|
||||||
|
findings: 'Findings',
|
||||||
|
leaks: 'Leaks',
|
||||||
|
vulns: 'Vulns',
|
||||||
|
alerts: 'Alerts',
|
||||||
|
coverageTitle: '🔍 Scan Coverage',
|
||||||
|
module: 'Module',
|
||||||
|
auditedFiles: 'Audited Files',
|
||||||
|
coverage: 'Coverage',
|
||||||
|
detailedFindings: '🔍 Detailed Findings',
|
||||||
|
gitleaksTitle: '🔑 Credential Leak Check (Gitleaks)',
|
||||||
|
gitleaksDesc: '`This section detects hardcoded API Keys, passwords, or other sensitive tokens in the code history.`',
|
||||||
|
gitleaksSafe: '✅ **SAFE**: No hardcoded sensitive credentials found.',
|
||||||
|
gitleaksScope: '`Scan Scope: All code changes and Git history (Gitleaks Full Scan)`',
|
||||||
|
snykTitle: '📦 Third-party Dependencies',
|
||||||
|
snykSafe: '✅ **SAFE**: No known vulnerabilities found in dependencies.',
|
||||||
|
package: 'Package',
|
||||||
|
severity: 'Severity',
|
||||||
|
description: 'Description',
|
||||||
|
fixPlan: 'Fix Plan',
|
||||||
|
codeqlTitle: '💻 Code Quality & Safety (CodeQL)',
|
||||||
|
codeqlSummary: '#### Summary',
|
||||||
|
rulesChecked: 'Rules Checked',
|
||||||
|
totalAlerts: 'Total Alerts',
|
||||||
|
codeqlSafe: '✅ **SAFE**: CodeQL clean. No issues detected.',
|
||||||
|
ruleId: 'Rule ID',
|
||||||
|
level: 'Level',
|
||||||
|
location: 'Location',
|
||||||
|
auditedList: '📂 Audited File List',
|
||||||
|
guideTitle: '⚠️ Action Guide',
|
||||||
|
guideDesc: 'If you see a **FAIL** status or serious code issues:',
|
||||||
|
guideStep1: '1. **Developers**: Use the **Location** column in the tables above to find the exact file and line number.',
|
||||||
|
guideStep2: '2. **Remediate**: Follow the documentation links provided for each rule to submit a fix.',
|
||||||
|
guideStep3: '3. **Traceability**: Full raw `.sarif` data is attached to this branch. Download and import it into your IDE (e.g., VS Code SARIF Viewer) for local analysis.',
|
||||||
|
footer: '💡 *Generated by Antigravity AI Security Engine. Transparency is our commitment.*',
|
||||||
|
auditedIcon: '✅ **Audited**',
|
||||||
|
noFiles: 'No files found.',
|
||||||
|
trivyTitle: '🛡️ Container Config Security (Trivy)',
|
||||||
|
trivyDesc: '`This section detects security risks and best practices in Dockerfile and container configurations.`',
|
||||||
|
trivySafe: '✅ **SAFE**: No container configuration defects found.'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Data Parsers ---
|
||||||
|
|
||||||
|
async parseCodeQL() {
|
||||||
|
const sarifPath = 'sarif-results';
|
||||||
|
if (!fs.existsSync(sarifPath)) return;
|
||||||
|
|
||||||
|
const files = this.globFiles(sarifPath, '.sarif');
|
||||||
|
let totalAlerts = 0;
|
||||||
|
let rulesSet = new Set();
|
||||||
|
let findings = [];
|
||||||
|
let artifactUris = new Set();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
for (const run of data.runs || []) {
|
||||||
|
// Collect Rules
|
||||||
|
(run.tool.driver.rules || []).forEach(r => rulesSet.add(r.id));
|
||||||
|
(run.tool.extensions || []).forEach(ext => {
|
||||||
|
(ext.rules || []).forEach(r => rulesSet.add(r.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collect Results
|
||||||
|
for (const res of run.results || []) {
|
||||||
|
totalAlerts++;
|
||||||
|
const loc = (res.locations && res.locations[0]?.physicalLocation) || {};
|
||||||
|
findings.push({
|
||||||
|
id: res.ruleId,
|
||||||
|
level: res.level || 'warning',
|
||||||
|
path: loc.artifactLocation?.uri || 'Global',
|
||||||
|
line: loc.region?.startLine || '-',
|
||||||
|
message: res.message?.text || 'No description'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track Coverage (Deduplicated)
|
||||||
|
(run.artifacts || []).forEach(art => {
|
||||||
|
const uri = art.location?.uri || '';
|
||||||
|
if (uri) artifactUris.add(uri);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.results.artifactUris = Array.from(artifactUris).sort();
|
||||||
|
this.results.coverage.actions = this.results.artifactUris.filter(u => u.startsWith('.github/workflows/')).length;
|
||||||
|
this.results.coverage.js = this.results.artifactUris.filter(u => u.endsWith('.js')).length;
|
||||||
|
this.results.coverage.ts = this.results.artifactUris.filter(u => u.endsWith('.ts')).length;
|
||||||
|
|
||||||
|
this.results.codeql.alertCount = totalAlerts;
|
||||||
|
this.results.codeql.rulesCount = rulesSet.size;
|
||||||
|
this.results.codeql.findings = findings;
|
||||||
|
if (totalAlerts > 0) this.results.codeql.status = 'INFO';
|
||||||
|
}
|
||||||
|
|
||||||
|
async parseSnyk() {
|
||||||
|
const jsonPath = 'snyk_result.json';
|
||||||
|
if (!fs.existsSync(jsonPath)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
||||||
|
const projects = Array.isArray(data) ? data : [data];
|
||||||
|
let vulnTotal = 0;
|
||||||
|
let findings = [];
|
||||||
|
|
||||||
|
for (const proj of projects) {
|
||||||
|
const vulns = proj.vulnerabilities || [];
|
||||||
|
vulnTotal += vulns.length;
|
||||||
|
vulns.forEach(v => {
|
||||||
|
findings.push({
|
||||||
|
pkg: `${v.packageName}@${v.version}`,
|
||||||
|
severity: v.severity,
|
||||||
|
title: v.title,
|
||||||
|
url: v.url,
|
||||||
|
fixedIn: Array.isArray(v.fixedIn) ? v.fixedIn.join(', ') : (v.fixedIn || 'N/A')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.results.snyk.vulnCount = vulnTotal;
|
||||||
|
this.results.snyk.findings = findings;
|
||||||
|
if (vulnTotal > 0) this.results.snyk.status = 'WARN';
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing Snyk JSON:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async parseGitleaks() {
|
||||||
|
const files = this.globFiles('.', 'results.sarif');
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(fs.readFileSync(files[0], 'utf8'));
|
||||||
|
let leaks = 0;
|
||||||
|
let findings = [];
|
||||||
|
for (const run of data.runs || []) {
|
||||||
|
for (const res of run.results || []) {
|
||||||
|
leaks++;
|
||||||
|
findings.push({
|
||||||
|
id: res.ruleId,
|
||||||
|
message: res.message.text,
|
||||||
|
path: res.locations[0]?.physicalLocation?.artifactLocation?.uri || 'Unknown'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.results.gitleaks.leaksCount = leaks;
|
||||||
|
this.results.gitleaks.findings = findings;
|
||||||
|
if (leaks > 0) this.results.gitleaks.status = 'FAIL';
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing Gitleaks SARIF:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async parseTrivy() {
|
||||||
|
const jsonPath = 'trivy_result.json';
|
||||||
|
if (!fs.existsSync(jsonPath)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
||||||
|
let misconfigs = 0;
|
||||||
|
let findings = [];
|
||||||
|
|
||||||
|
(data.Results || []).forEach(res => {
|
||||||
|
(res.Misconfigurations || []).forEach(m => {
|
||||||
|
misconfigs++;
|
||||||
|
findings.push({
|
||||||
|
id: m.ID,
|
||||||
|
severity: m.Severity,
|
||||||
|
title: m.Title,
|
||||||
|
message: m.Message,
|
||||||
|
status: m.Status,
|
||||||
|
target: res.Target
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.results.trivy.misconfigCount = misconfigs;
|
||||||
|
this.results.trivy.findings = findings;
|
||||||
|
if (misconfigs > 0) this.results.trivy.status = 'WARN';
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing Trivy JSON:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generateTable(type, t) {
|
||||||
|
let files = [];
|
||||||
|
if (type === 'actions') files = this.results.artifactUris.filter(u => u.startsWith('.github/workflows/'));
|
||||||
|
else if (type === 'js') files = this.results.artifactUris.filter(u => u.endsWith('.js'));
|
||||||
|
else if (type === 'ts') files = this.results.artifactUris.filter(u => u.endsWith('.ts'));
|
||||||
|
|
||||||
|
if (files.length === 0) return `> ${t.noFiles}\n`;
|
||||||
|
|
||||||
|
let table = `| ${t.module} | ${t.location} | ${t.status} |\n| :--- | :--- | :--- |\n`;
|
||||||
|
files.forEach(f => {
|
||||||
|
const filename = path.basename(f);
|
||||||
|
table += `| \`${filename}\` | \`${f}\` | ${t.auditedIcon} |\n`;
|
||||||
|
});
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Renderers ---
|
||||||
|
|
||||||
|
generateMarkdown(localeKey) {
|
||||||
|
const { codeql, snyk, gitleaks, coverage } = this.results;
|
||||||
|
const t = this.locales[localeKey];
|
||||||
|
|
||||||
|
// Calculate Grade
|
||||||
|
let grade = 'A+';
|
||||||
|
let gradeColor = 'success';
|
||||||
|
if (gitleaks.status === 'FAIL') { grade = 'D'; gradeColor = 'red'; }
|
||||||
|
else if (snyk.vulnCount > 10 || this.results.trivy.misconfigCount > 5) { grade = 'C'; gradeColor = 'orange'; }
|
||||||
|
else if (snyk.vulnCount > 0 || codeql.alertCount > 0 || this.results.trivy.misconfigCount > 0) { grade = 'B'; gradeColor = 'blue'; }
|
||||||
|
|
||||||
|
const badge = (label, value, color) => `}-${value}-${color}?style=for-the-badge)`;
|
||||||
|
|
||||||
|
let md = `# ${t.title}\n\n`;
|
||||||
|
md += `${t.switcher}\n\n`;
|
||||||
|
md += `${badge(t.grade.replace(/ /g, '_'), grade, gradeColor)}\n\n`;
|
||||||
|
md += `${t.important}\n\n`;
|
||||||
|
|
||||||
|
md += `| ${t.auditTime} | ${t.runId} | ${t.env} |\n`;
|
||||||
|
md += `| :--- | :--- | :--- |\n`;
|
||||||
|
md += `| \`${this.auditTime}\` | [#${this.runId}](${this.runUrl}) | \`GitHub CI/CD\` |\n\n`;
|
||||||
|
|
||||||
|
md += `---\n\n## ${t.dashboard}\n\n`;
|
||||||
|
md += `| ${t.tool} | ${t.status} | ${t.findings} |\n`;
|
||||||
|
md += `| :--- | :--- | :--- |\n`;
|
||||||
|
md += `| **Credential Leak (Gitleaks)** | ${this.getBadge(gitleaks.status)} | \`${gitleaks.leaksCount}\` ${t.leaks} |\n`;
|
||||||
|
md += `| **Dependency Scan (Snyk)** | ${this.getBadge(snyk.status)} | \`${snyk.vulnCount}\` ${t.vulns} |\n`;
|
||||||
|
md += `| **Static Analysis (CodeQL)** | ${this.getBadge(codeql.status)} | \`${codeql.alertCount}\` ${t.alerts} |\n`;
|
||||||
|
md += `| **Container Scan (Trivy)** | ${this.getBadge(this.results.trivy.status)} | \`${this.results.trivy.misconfigCount}\` ${t.findings} |\n\n`;
|
||||||
|
|
||||||
|
md += `---\n\n## ${t.coverageTitle}\n\n`;
|
||||||
|
md += `| ${t.module} | ${t.auditedFiles} | ${t.coverage} |\n`;
|
||||||
|
md += `| :--- | :---: | :---: |\n`;
|
||||||
|
md += `| **GitHub Actions** | \`${coverage.actions}\` | ✨ **100%** |\n`;
|
||||||
|
md += `| **JavaScript (Frontend)** | \`${coverage.js}\` | ✨ **100%** |\n`;
|
||||||
|
md += `| **TypeScript (Backend)** | \`${coverage.ts}\` | ✨ **100%** |\n\n`;
|
||||||
|
|
||||||
|
md += `---\n\n## ${t.detailedFindings}\n\n`;
|
||||||
|
|
||||||
|
// Gitleaks Section
|
||||||
|
md += `### ${t.gitleaksTitle}\n`;
|
||||||
|
md += `${t.gitleaksDesc} ${t.gitleaksScope}\n\n`;
|
||||||
|
if (gitleaks.findings.length > 0) {
|
||||||
|
md += `| ${t.ruleId} | ${t.location} | ${t.description} |\n`;
|
||||||
|
md += `| :--- | :--- | :--- |\n`;
|
||||||
|
gitleaks.findings.forEach(f => {
|
||||||
|
md += `| \`${f.id}\` | \`${f.path}\` | ${f.message} |\n`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
md += `${t.gitleaksSafe}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trivy Section
|
||||||
|
md += `\n### ${t.trivyTitle}\n`;
|
||||||
|
md += `${t.trivyDesc}\n\n`;
|
||||||
|
if (this.results.trivy.findings.length > 0) {
|
||||||
|
md += `| ${t.ruleId} | ${t.severity} | ${t.location} | ${t.description} |\n`;
|
||||||
|
md += `| :--- | :---: | :--- | :--- |\n`;
|
||||||
|
this.results.trivy.findings.forEach(f => {
|
||||||
|
const icon = f.severity === 'CRITICAL' ? '🔴' : (f.severity === 'HIGH' ? '🟠' : '🟡');
|
||||||
|
md += `| \`${f.id}\` | ${icon} ${f.severity} | \`${f.target}\` | ${f.title}: ${f.message} |\n`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
md += `${t.trivySafe}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snyk Section
|
||||||
|
md += `\n### ${t.snykTitle}\n`;
|
||||||
|
if (snyk.findings.length > 0) {
|
||||||
|
md += `| ${t.package} | ${t.severity} | ${t.description} | ${t.fixPlan} |\n`;
|
||||||
|
md += `| :--- | :---: | :--- | :--- |\n`;
|
||||||
|
snyk.findings.forEach(f => {
|
||||||
|
const icon = f.severity === 'critical' ? '🔴' : (f.severity === 'high' ? '🟠' : '🟡');
|
||||||
|
md += `| \`${f.pkg}\` | ${icon} ${f.severity} | [${f.title}](${f.url}) | ${f.fixedIn === 'N/A' ? 'No fix' : `Upgrade to \`${f.fixedIn}\``} |\n`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
md += `${t.snykSafe}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CodeQL Section
|
||||||
|
md += `\n### ${t.codeqlTitle}\n`;
|
||||||
|
if (codeql.findings.length > 0) {
|
||||||
|
md += `${t.codeqlSummary}\n- **${t.rulesChecked}**: \`${codeql.rulesCount}\`\n- **${t.totalAlerts}**: \`${codeql.alertCount}\`\n\n`;
|
||||||
|
md += `| ${t.ruleId} | ${t.level} | ${t.location} | ${t.description} |\n`;
|
||||||
|
md += `| :--- | :---: | :--- | :--- |\n`;
|
||||||
|
codeql.findings.forEach(f => {
|
||||||
|
const icon = f.level === 'error' ? '🔴' : (f.level === 'warning' ? '🟠' : '🔵');
|
||||||
|
const prefix = f.id.split('/')[0];
|
||||||
|
const langMap = {
|
||||||
|
'js': 'javascript',
|
||||||
|
'actions': 'github-actions',
|
||||||
|
'cpp': 'cpp',
|
||||||
|
'cs': 'csharp',
|
||||||
|
'go': 'go',
|
||||||
|
'java': 'java',
|
||||||
|
'py': 'python',
|
||||||
|
'rb': 'ruby',
|
||||||
|
'swift': 'swift'
|
||||||
|
};
|
||||||
|
const langPath = langMap[prefix] || 'javascript';
|
||||||
|
md += `| [${f.id}](https://codeql.github.com/codeql-query-help/${langPath}/${f.id.replace(/\//g, '-')}/) | ${icon} ${f.level} | \`${f.path}:${f.line}\` | ${f.message} |\n`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
md += `${t.codeqlSafe}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audited Files List
|
||||||
|
md += `\n### ${t.auditedList}\n`;
|
||||||
|
md += `<details>\n<summary><b>GitHub Actions (${this.results.coverage.actions})</b></summary>\n\n`;
|
||||||
|
md += this.generateTable('actions', t);
|
||||||
|
md += `\n</details>\n\n`;
|
||||||
|
|
||||||
|
md += `<details>\n<summary><b>JavaScript (${this.results.coverage.js})</b></summary>\n\n`;
|
||||||
|
md += this.generateTable('js', t);
|
||||||
|
md += `\n</details>\n\n`;
|
||||||
|
|
||||||
|
md += `<details>\n<summary><b>TypeScript (${this.results.coverage.ts})</b></summary>\n\n`;
|
||||||
|
md += this.generateTable('ts', t);
|
||||||
|
md += `\n</details>\n\n`;
|
||||||
|
|
||||||
|
// Action Guide
|
||||||
|
md += `--- \n\n## ${t.guideTitle}\n\n`;
|
||||||
|
md += `${t.guideDesc}\n`;
|
||||||
|
md += `${t.guideStep1}\n`;
|
||||||
|
md += `${t.guideStep2}\n`;
|
||||||
|
md += `${t.guideStep3}\n\n`;
|
||||||
|
|
||||||
|
md += `--- \n\n${t.footer}`;
|
||||||
|
|
||||||
|
return md;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
getBadge(status) {
|
||||||
|
if (status === 'PASS') return '';
|
||||||
|
if (status === 'WARN' || status === 'INFO') return '';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
globFiles(dir, ext) {
|
||||||
|
let results = [];
|
||||||
|
const list = fs.readdirSync(dir);
|
||||||
|
for (const file of list) {
|
||||||
|
const fullPath = path.join(dir, file);
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
|
if (stat && stat.isDirectory()) {
|
||||||
|
results = results.concat(this.globFiles(fullPath, ext));
|
||||||
|
} else if (file.endsWith(ext)) {
|
||||||
|
results.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
console.log('--- Security Report Generation Started ---');
|
||||||
|
await this.parseCodeQL();
|
||||||
|
await this.parseSnyk();
|
||||||
|
await this.parseGitleaks();
|
||||||
|
await this.parseTrivy();
|
||||||
|
|
||||||
|
for (const localeKey of Object.keys(this.locales)) {
|
||||||
|
const locale = this.locales[localeKey];
|
||||||
|
const markdown = this.generateMarkdown(localeKey);
|
||||||
|
fs.writeFileSync(locale.filename, markdown);
|
||||||
|
console.log(`Report generated successfully at ${locale.filename}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
new SecurityReport().run().catch(err => {
|
||||||
|
console.error('Report generation failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
name: Security Scan
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
scan:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
actions: read
|
||||||
|
env:
|
||||||
|
SECURITY_SNYK_TOKEN: ${{ secrets.SECURITY_SNYK_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
if: env.ACT != 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: github/codeql-action/init@v4
|
||||||
|
with:
|
||||||
|
languages: javascript-typescript, actions
|
||||||
|
build-mode: none
|
||||||
|
queries: security-extended,security-and-quality
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
if: env.ACT != 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: github/codeql-action/analyze@v4
|
||||||
|
with:
|
||||||
|
upload: true
|
||||||
|
output: sarif-results
|
||||||
|
|
||||||
|
- name: Install Gitleaks
|
||||||
|
if: env.ACT != 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
GITLEAKS_VERSION="8.28.0"
|
||||||
|
curl -sSL -o gitleaks.tar.gz "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
|
||||||
|
tar -xzf gitleaks.tar.gz gitleaks
|
||||||
|
chmod +x gitleaks
|
||||||
|
sudo mv gitleaks /usr/local/bin/gitleaks
|
||||||
|
|
||||||
|
- name: Secret Detection
|
||||||
|
if: env.ACT != 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
gitleaks git . --report-format sarif --report-path results.sarif --no-banner || true
|
||||||
|
|
||||||
|
- name: Install Project Dependencies
|
||||||
|
if: env.SECURITY_SNYK_TOKEN != ''
|
||||||
|
env:
|
||||||
|
SECURITY_PACKAGE: ${{ vars.SECURITY_PACKAGE || '' }}
|
||||||
|
run: |
|
||||||
|
echo "Preparing dependency lock files for security scanning..."
|
||||||
|
if [ -z "$SECURITY_PACKAGE" ]; then
|
||||||
|
echo "SECURITY_PACKAGE is empty, installing in root..."
|
||||||
|
npm install --package-lock-only
|
||||||
|
else
|
||||||
|
echo "SECURITY_PACKAGE is set to: $SECURITY_PACKAGE"
|
||||||
|
# Split by comma and install
|
||||||
|
IFS=',' read -ra PACKAGES <<< "$SECURITY_PACKAGE"
|
||||||
|
for pkg in "${PACKAGES[@]}"; do
|
||||||
|
if [ -d "$pkg" ]; then
|
||||||
|
echo "Installing in "$pkg"..."
|
||||||
|
npm install --prefix "$pkg" --package-lock-only
|
||||||
|
else
|
||||||
|
echo "Warning: Directory $pkg not found, skipping."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Dependency Scan
|
||||||
|
id: snyk
|
||||||
|
if: env.SECURITY_SNYK_TOKEN != ''
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
npm install -g snyk
|
||||||
|
snyk auth ${{ secrets.SECURITY_SNYK_TOKEN }}
|
||||||
|
snyk test --all-projects --json-file-output=snyk_result.json > snyk_result.txt || true
|
||||||
|
env:
|
||||||
|
SECURITY_SNYK_TOKEN: ${{ secrets.SECURITY_SNYK_TOKEN }}
|
||||||
|
|
||||||
|
- name: Check for Dockerfile
|
||||||
|
id: check_docker
|
||||||
|
run: |
|
||||||
|
if [ -f "Dockerfile" ]; then
|
||||||
|
echo "exists=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "exists=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Container Security Scan (Trivy)
|
||||||
|
if: steps.check_docker.outputs.exists == 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
VERSION="0.56.1"
|
||||||
|
echo "Installing Trivy $VERSION..."
|
||||||
|
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin "v$VERSION"
|
||||||
|
trivy config . --format json --output trivy_result.json --severity CRITICAL,HIGH || true
|
||||||
|
|
||||||
|
- name: Generate Security Report
|
||||||
|
run: |
|
||||||
|
# Gitleaks typically produces results.sarif if configured or by default in some versions
|
||||||
|
# We'll ensure it exists for our reporter
|
||||||
|
node .github/scripts/security.cjs
|
||||||
|
|
||||||
|
# Also append to step summary for immediate visibility in GHA UI
|
||||||
|
cat security-report.md >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo -e "\n---\n" >> $GITHUB_STEP_SUMMARY
|
||||||
|
cat security-report-cn.md >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
- name: Upload Gitleaks Results to GitHub Security
|
||||||
|
uses: github/codeql-action/upload-sarif@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
sarif_file: results.sarif
|
||||||
|
category: gitleaks
|
||||||
|
|
||||||
|
- name: Upload Security Report Artifacts
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: security-report
|
||||||
|
if-no-files-found: ignore
|
||||||
|
path: |
|
||||||
|
security-report.md
|
||||||
|
security-report-cn.md
|
||||||
|
snyk_result.txt
|
||||||
|
snyk_result.json
|
||||||
|
trivy_result.json
|
||||||
|
results.sarif
|
||||||
|
sarif-results/*.sarif
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
name: Sync upstream
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 3 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
target_commit:
|
||||||
|
description: 'Commit hash (leave blank to use latest commit)'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Configure git
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
- name: Add upstream
|
||||||
|
run: |
|
||||||
|
git remote add upstream https://github.com/shuaiplus/NodeWarden.git || true
|
||||||
|
git fetch upstream --tags
|
||||||
|
|
||||||
|
- name: Resolve target commit
|
||||||
|
id: resolve
|
||||||
|
run: |
|
||||||
|
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
|
||||||
@@ -6,6 +6,8 @@ node_modules/
|
|||||||
.dev.vars
|
.dev.vars
|
||||||
wrangler.my.toml
|
wrangler.my.toml
|
||||||
RELEASE_NOTES.md
|
RELEASE_NOTES.md
|
||||||
|
tests/selfcheck.ts
|
||||||
|
problem.md
|
||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
dist/
|
dist/
|
||||||
@@ -35,3 +37,8 @@ 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/
|
||||||
|
|
||||||
|
nodewarden.wiki/
|
||||||
|
|||||||
|
After Width: | Height: | Size: 122 KiB |
@@ -1,108 +1,155 @@
|
|||||||
# NodeWarden
|
<p align="center">
|
||||||
中文文档:[`README_ZH.md`](./README_ZH.md)
|
<img src="./NodeWarden.png" alt="NodeWarden Logo" />
|
||||||
|
</p>
|
||||||
|
|
||||||
A **Bitwarden-compatible** server that runs on **Cloudflare Workers**.
|
<p align="center">
|
||||||
|
运行在 Cloudflare Workers 上的第三方 Bitwarden 兼容服务端。
|
||||||
|
</p>
|
||||||
|
|
||||||
- Simple deploy (no VPS)
|
[](https://workers.cloudflare.com/)
|
||||||
- Focused feature set
|
[](./LICENSE)
|
||||||
- Low maintenance
|
[](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||||
|
[](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
|
||||||
|
|
||||||
|
[更新日志](./RELEASE_NOTES.md) | [提交问题](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [最新发布](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||||
|
|
||||||
> Disclaimer
|
[文档首页](./nodewarden.wiki/Home.md) | [快速开始](./nodewarden.wiki/快速开始.md)
|
||||||
> - This project is **not affiliated** with Bitwarden.
|
|
||||||
> - Use at your own risk. Keep regular backups of your vault.
|
[Telegram 频道](https://t.me/NodeWarden_News) | [Telegram 群组](https://t.me/NodeWarden_Official)
|
||||||
|
|
||||||
|
English: [`README_EN.md`](./README_EN.md)
|
||||||
|
|
||||||
|
> **免责声明**
|
||||||
|
> 本项目仅供学习与交流使用,请定期备份你的密码库。
|
||||||
|
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## 与 Bitwarden 官方服务端能力对比
|
||||||
|
|
||||||
- ✅ **Free to use. No server to manage.**
|
| 能力 | Bitwarden | NodeWarden | 说明 |
|
||||||
- ✅ Full support for logins, notes, cards, and identities
|
|---|---|---|---|
|
||||||
- ✅ Folders and favorites
|
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
|
||||||
- ✅ Attachments (Cloudflare R2)
|
| 全量同步 `/api/sync` | ✅ | ✅ | 已针对官方客户端做兼容优化 |
|
||||||
- ✅ Import / export
|
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
|
||||||
- ✅ Website icons
|
| Send | ✅ | ✅ | 支持文本与文件 Send |
|
||||||
- ✅ End-to-end encryption (the server can’t see plaintext)
|
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
|
||||||
- ✅ Compatible with common Bitwarden official clients
|
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / E3 定时备份** |
|
||||||
|
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
|
||||||
## Tested clients / platforms
|
| TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 |
|
||||||
|
| 多用户 | ✅ | ✅ | 支持邀请码注册 |
|
||||||
- ✅ Windows desktop client (v2026.1.0)
|
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
|
||||||
- ✅ Android app (v2026.1.0)
|
| 登录 2FA | ✅ | ⚠️ 部分支持 | 当前仅支持用户级 TOTP |
|
||||||
- ✅ Browser extension (v2026.1.0)
|
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
|
||||||
- ⬜ macOS desktop client (not tested)
|
|
||||||
- ⬜ Linux desktop client (not tested)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Quick start
|
## 已测试客户端
|
||||||
|
|
||||||
### One-click deploy
|
- ✅ Windows 桌面端
|
||||||
|
- ✅ 手机 App
|
||||||
|
- ✅ 浏览器扩展
|
||||||
|
- ✅ Linux 桌面端
|
||||||
|
- ⚠️ macOS 桌面端尚未完整验证
|
||||||
|
|
||||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
|
---
|
||||||
|
|
||||||
**Deploy steps:**
|
## 网页部署
|
||||||
|
|
||||||
1. Sign in with GitHub and authorize
|
1. Fork `NodeWarden` 仓库到自己的 GitHub 账号
|
||||||
2. Sign in to Cloudflare
|
2. 进入 [Cloudflare Workers 创建页面](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
||||||
3. **Important**: set `JWT_SECRET` to a strong random string (recommended: `openssl rand -hex 32`)
|
3. 选择 `Continue with GitHub`
|
||||||
4. D1 database and R2 bucket will be created automatically
|
4. 选择你刚刚 Fork 的仓库
|
||||||
5. Click **Deploy** and wait for it to finish
|
5. 保持默认配置继续部署
|
||||||
6. After deploy, open the Cloudflare-provided Workers URL (your service URL), and register on the web page
|
6. 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
|
||||||
|
7. 等部署完成后,打开生成的 Workers 域名
|
||||||
|
8. 根据页面提示设置`JWT_SECRET` ,不建议临时乱填。这个值直接关系到令牌签发安全,正式环境至少使用 32 个字符以上的随机字符串。
|
||||||
|
|
||||||
> ⚠️ **Reminder**: always use a strong random `JWT_SECRET`. Weak secrets may put your account at risk.
|
> [!TIP]
|
||||||
|
> 默认R2与可选KV的区别:
|
||||||
|
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|
||||||
|
> |---|---|---|---|
|
||||||
|
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
|
||||||
|
> | KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
||||||
|
|
||||||
### Configure your client
|
|
||||||
|
|
||||||
In any Bitwarden client:
|
## 更新方法:
|
||||||
|
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork` ➜ `Update branch`
|
||||||
|
- 自动:进入你的 Fork 仓库 ➜ `Actions` ➜ `Sync upstream` ➜ `Enable workflow`,会在每天凌晨 3 点自动同步上游。
|
||||||
|
|
||||||
1. Open **Settings**
|
|
||||||
2. Choose **Self-hosted environment**
|
|
||||||
3. Set **Server URL** to your Worker URL (for example: `https://your-project.your-subdomain.workers.dev`)
|
|
||||||
4. Save, then go back to the login screen
|
|
||||||
|
|
||||||
## 🧑💻 Local development
|
|
||||||
|
|
||||||
This repo is a Cloudflare Workers TypeScript project (Wrangler).
|
## CLI 部署
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||||
|
cd NodeWarden
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
npm install
|
||||||
|
npx wrangler login
|
||||||
|
|
||||||
|
# 默认:R2 模式
|
||||||
|
npm run deploy
|
||||||
|
|
||||||
|
# 可选:KV 模式
|
||||||
|
npm run deploy:kv
|
||||||
|
|
||||||
|
# 本地开发
|
||||||
npm run dev
|
npm run dev
|
||||||
|
npm run dev:kv
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tech stack
|
## 云端备份说明
|
||||||
|
|
||||||
- **Runtime**: Cloudflare Workers
|
- 远程备份支持 **WebDAV** 与 **E3**
|
||||||
- **Data storage**: Cloudflare D1 (SQLite)
|
- 勾选“包含附件”后:
|
||||||
- **File storage**: Cloudflare R2
|
- ZIP 内仍只包含 `db.json` 与 `manifest.json`
|
||||||
- **Language**: TypeScript
|
- 真实附件单独存放在 `attachments/`
|
||||||
- **Crypto**: Client-side AES-256-CBC, JWT uses HS256
|
- 后续备份会按稳定 blob 名复用已有附件,不会每次全量重传
|
||||||
|
- 远程还原时:
|
||||||
|
- 会从 `attachments/` 目录按需读取附件
|
||||||
|
- 缺失的附件会被安全跳过
|
||||||
|
- 被跳过的附件不会在恢复后的数据库中留下脏记录
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## FAQ
|
## 导入 / 导出
|
||||||
|
|
||||||
**Q: How do I back up my data?**
|
当前支持的导入来源包括:
|
||||||
A: Use **Export vault** in your client and save the JSON file.
|
|
||||||
|
|
||||||
**Q: What if I forget the master password?**
|
- Bitwarden JSON
|
||||||
A: It can’t be recovered (end-to-end encryption). Keep it safe.
|
- Bitwarden CSV
|
||||||
|
- Bitwarden 密码库 + 附件 ZIP
|
||||||
|
- NodeWarden JSON
|
||||||
|
- 网页导入器里可见的多种浏览器 / 密码管理器格式
|
||||||
|
|
||||||
**Q: Can multiple people use it?**
|
当前支持的导出方式包括:
|
||||||
A: Not recommended. This project is designed for single-user usage.
|
|
||||||
|
- Bitwarden JSON
|
||||||
|
- Bitwarden 加密 JSON
|
||||||
|
- 带附件的 ZIP 导出
|
||||||
|
- NodeWarden JSON 系列
|
||||||
|
- 备份中心中的实例级完整手动导出
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## License
|
|
||||||
|
## 开源协议
|
||||||
|
|
||||||
LGPL-3.0 License
|
LGPL-3.0 License
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Credits
|
## 致谢
|
||||||
|
|
||||||
- [Bitwarden](https://bitwarden.com/) - original design and clients
|
- [Bitwarden](https://bitwarden.com/) - 原始设计与客户端
|
||||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - server implementation reference
|
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务端实现参考
|
||||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - serverless platform
|
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="./NodeWarden.png" alt="NodeWarden Logo" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
A third-party Bitwarden-compatible server running on Cloudflare Workers.
|
||||||
|
</p>
|
||||||
|
[](https://workers.cloudflare.com/)
|
||||||
|
[](./LICENSE)
|
||||||
|
[](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||||
|
[](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
|
||||||
|
[Release Notes](./RELEASE_NOTES.md) | [Report an Issue](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [Latest Release](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||||
|
[Telegram Channel](https://t.me/NodeWarden_News) | [Telegram Group](https://t.me/NodeWarden_Official)
|
||||||
|
中文说明:[`README.md`](./README.md)
|
||||||
|
|
||||||
|
> **Disclaimer**
|
||||||
|
>
|
||||||
|
> This project is for learning and discussion purposes only. Please back up your vault regularly.
|
||||||
|
>
|
||||||
|
> This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Comparison with the Official Bitwarden Server
|
||||||
|
|
||||||
|
| Capability | Bitwarden | NodeWarden | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Web Vault | ✅ | ✅ | **Original Web Vault interface** |
|
||||||
|
| Full sync `/api/sync` | ✅ | ✅ | Compatibility optimized for official clients |
|
||||||
|
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
|
||||||
|
| Send | ✅ | ✅ | Supports both text and file Sends |
|
||||||
|
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
|
||||||
|
| **Cloud Backup Center** | ❌ | ✅ | **Scheduled backup to WebDAV / E3** |
|
||||||
|
| Password hint (web) | ⚠️ Limited | ✅ | **No email required** |
|
||||||
|
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
|
||||||
|
| Multi-user | ✅ | ✅ | Invite-based registration |
|
||||||
|
| Organizations / Collections / Member roles | ✅ | ❌ | Not implemented |
|
||||||
|
| Login 2FA | ✅ | ⚠️ Partial | Currently only user-level TOTP |
|
||||||
|
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not implemented |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tested Clients
|
||||||
|
|
||||||
|
- ✅ Windows desktop client
|
||||||
|
- ✅ Mobile app
|
||||||
|
- ✅ Browser extension
|
||||||
|
- ✅ Linux desktop client
|
||||||
|
- ⚠️ macOS desktop client has not been fully verified yet
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Web Deploy
|
||||||
|
|
||||||
|
1. Fork this repository. If this project helps you, consider giving it a Star.
|
||||||
|
2. Open [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) -> `Continue with GitHub` -> select your forked repository (`NodeWarden`) -> continue.
|
||||||
|
3. R2 is used by default. If R2 is not enabled on your account, you can use KV instead by changing the **deploy command** to `npm run deploy:kv`.
|
||||||
|
4. Deploy and open the generated URL.
|
||||||
|
|
||||||
|
| Storage | Card required | Single attachment / Send file limit | Free tier |
|
||||||
|
|---|---|---|---|
|
||||||
|
| R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
|
||||||
|
| KV | No | 25 MiB (Cloudflare limit) | 1 GB |
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> How to keep your fork updated:
|
||||||
|
> - Manual: open your fork on GitHub, click `Sync fork`, then `Update branch`
|
||||||
|
> - Automatic: go to your fork -> `Actions` -> `Sync upstream` -> `Enable workflow`; it will sync upstream automatically every day at 3 AM
|
||||||
|
|
||||||
|
## CLI Deploy
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||||
|
cd NodeWarden
|
||||||
|
npm install
|
||||||
|
npx wrangler login
|
||||||
|
|
||||||
|
# Default: R2 mode
|
||||||
|
npm run deploy
|
||||||
|
|
||||||
|
# Optional: KV mode
|
||||||
|
npm run deploy:kv
|
||||||
|
|
||||||
|
# Local development
|
||||||
|
npm run dev
|
||||||
|
npm run dev:kv
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cloud Backup Notes
|
||||||
|
|
||||||
|
- Remote backup supports **WebDAV** and **E3**
|
||||||
|
- When `Include attachments` is enabled:
|
||||||
|
- the ZIP still contains only `db.json` and `manifest.json`
|
||||||
|
- actual attachment files are stored separately under `attachments/`
|
||||||
|
- later backups reuse existing attachments by stable blob name instead of re-uploading everything every time
|
||||||
|
- During remote restore:
|
||||||
|
- required attachment files are loaded from `attachments/` on demand
|
||||||
|
- missing attachments are skipped safely
|
||||||
|
- skipped attachments do not leave broken rows in the restored database
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Import / Export
|
||||||
|
|
||||||
|
Current supported import sources include:
|
||||||
|
|
||||||
|
- Bitwarden JSON
|
||||||
|
- Bitwarden CSV
|
||||||
|
- Bitwarden vault + attachments ZIP
|
||||||
|
- NodeWarden JSON
|
||||||
|
- Multiple browser / password-manager formats available in the web import selector
|
||||||
|
|
||||||
|
Current supported export formats include:
|
||||||
|
|
||||||
|
- Bitwarden JSON
|
||||||
|
- Bitwarden encrypted JSON
|
||||||
|
- ZIP export with attachments
|
||||||
|
- NodeWarden JSON variants
|
||||||
|
- Full manual instance export from the backup center
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
LGPL-3.0 License
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
- [Bitwarden](https://bitwarden.com/) - Original design and clients
|
||||||
|
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - Server implementation reference
|
||||||
|
- [Cloudflare Workers](https://workers.cloudflare.com/) - Serverless platform
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
|
|
||||||
# NodeWarden
|
|
||||||
English:[`README.md`](./README.md)
|
|
||||||
|
|
||||||
一个运行在 **Cloudflare Workers** 上的 **Bitwarden 兼容**服务端实现。
|
|
||||||
|
|
||||||
- 部署简单(不需要 VPS)
|
|
||||||
- 功能聚焦
|
|
||||||
- 维护成本低
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
> **免责声明**
|
|
||||||
> 本项目仅供学习交流使用。我们不对任何数据丢失负责,强烈建议定期备份您的密码库。
|
|
||||||
> 本项目与 Bitwarden 官方无关,请勿向 Bitwarden 官方反馈问题。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 特性
|
|
||||||
- ✅ **完全免费,不需要在服务器上部署,再次感谢大善人!**
|
|
||||||
- ✅ 完整的密码、笔记、卡片、身份信息管理
|
|
||||||
- ✅ 文件夹和收藏功能
|
|
||||||
- ✅ 文件附件支持(基于 R2 存储)
|
|
||||||
- ✅ 导入/导出功能
|
|
||||||
- ✅ 网站图标获取
|
|
||||||
- ✅ 端到端加密(服务器无法查看明文)
|
|
||||||
- ✅ 兼容常见的 Bitwarden 官方客户端
|
|
||||||
|
|
||||||
## 测试情况:
|
|
||||||
- ✅ Windows 客户端(v2026.1.0)
|
|
||||||
- ✅ Android App(v2026.1.0)
|
|
||||||
- ✅ 浏览器扩展(v2026.1.0)
|
|
||||||
- ⬜ macOS 客户端(未测试)
|
|
||||||
- ⬜ Linux 客户端(未测试)
|
|
||||||
---
|
|
||||||
|
|
||||||
# 快速开始
|
|
||||||
|
|
||||||
### 一键部署
|
|
||||||
|
|
||||||
点击下方按钮部署到 Cloudflare Workers:
|
|
||||||
|
|
||||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
|
|
||||||
|
|
||||||
**部署步骤:**
|
|
||||||
|
|
||||||
1. 使用 GitHub 登录并授权
|
|
||||||
2. 登录 Cloudflare 账户
|
|
||||||
3. **重要**:设置 `JWT_SECRET` 为强随机字符串(推荐使用 `openssl rand -hex 32` 生成)
|
|
||||||
4. D1 数据库和 R2 存储桶将自动创建
|
|
||||||
5. 点击 Deploy 等待部署完成
|
|
||||||
6. 部署完成后,先打开 Cloudflare 给你的 Workers 链接(也就是你的服务地址),在网页上填写信息完成注册。
|
|
||||||
|
|
||||||
> ⚠️ **再次提醒**:请务必使用强随机的 `JWT_SECRET`,使用默认或弱密钥可能导致账户被入侵,**后果自负!**
|
|
||||||
|
|
||||||
### 配置客户端
|
|
||||||
|
|
||||||
部署完成后,在任意 Bitwarden 客户端中:
|
|
||||||
|
|
||||||
1. 打开设置(⚙️)
|
|
||||||
2. 选择「自托管环境」
|
|
||||||
3. 服务器 URL 填入:`https://你的项目名`
|
|
||||||
4. 保存并返回登录页面
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 本地开发
|
|
||||||
|
|
||||||
这是一个 Cloudflare Workers 的 TypeScript 项目(Wrangler)。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
## 技术栈
|
|
||||||
|
|
||||||
- **运行环境**:Cloudflare Workers
|
|
||||||
- **数据存储**:Cloudflare D1(SQLite)
|
|
||||||
- **文件存储**:Cloudflare R2
|
|
||||||
- **开发语言**:TypeScript
|
|
||||||
- **加密算法**:客户端 AES-256-CBC,JWT 使用 HS256
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
**Q: 如何备份数据?**
|
|
||||||
A: 在客户端中选择「导出密码库」,保存 JSON 文件。
|
|
||||||
|
|
||||||
**Q: 忘记主密码怎么办?**
|
|
||||||
A: 无法恢复,这是端到端加密的特性。建议妥善保管主密码。
|
|
||||||
|
|
||||||
**Q: 可以多人使用吗?**
|
|
||||||
A: 不建议。本项目为单用户设计,多人使用请选择 Vaultwarden。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 开源协议
|
|
||||||
|
|
||||||
LGPL-3.0 License
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 致谢
|
|
||||||
|
|
||||||
- [Bitwarden](https://bitwarden.com/) - 原始设计和客户端
|
|
||||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务器实现参考
|
|
||||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
|
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
PRAGMA foreign_keys = ON;
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
-- IMPORTANT:
|
||||||
|
-- Keep this file in sync with src/services/storage.ts (SCHEMA_STATEMENTS).
|
||||||
|
-- Any new table/column/index must be added to both places together.
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS config (
|
CREATE TABLE IF NOT EXISTS config (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
@@ -9,6 +13,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
email TEXT NOT NULL UNIQUE,
|
email TEXT NOT NULL UNIQUE,
|
||||||
name TEXT,
|
name TEXT,
|
||||||
|
master_password_hint TEXT,
|
||||||
master_password_hash TEXT NOT NULL,
|
master_password_hash TEXT NOT NULL,
|
||||||
key TEXT NOT NULL,
|
key TEXT NOT NULL,
|
||||||
private_key TEXT,
|
private_key TEXT,
|
||||||
@@ -18,6 +23,11 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
kdf_memory INTEGER,
|
kdf_memory INTEGER,
|
||||||
kdf_parallelism INTEGER,
|
kdf_parallelism INTEGER,
|
||||||
security_stamp TEXT NOT NULL,
|
security_stamp TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
verify_devices INTEGER NOT NULL DEFAULT 1,
|
||||||
|
totp_secret TEXT,
|
||||||
|
totp_recovery_code TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
@@ -42,11 +52,14 @@ 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 TABLE IF NOT EXISTS folders (
|
CREATE TABLE IF NOT EXISTS folders (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -69,6 +82,33 @@ CREATE TABLE IF NOT EXISTS attachments (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id);
|
CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sends (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
type INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
password_hash TEXT,
|
||||||
|
password_salt TEXT,
|
||||||
|
password_iterations INTEGER,
|
||||||
|
auth_type INTEGER NOT NULL DEFAULT 2,
|
||||||
|
emails TEXT,
|
||||||
|
max_access_count INTEGER,
|
||||||
|
access_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
disabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
hide_email INTEGER,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
expiration_date TEXT,
|
||||||
|
deletion_date TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sends_user_updated_id ON sends(user_id, updated_at, id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
token TEXT PRIMARY KEY,
|
token TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
@@ -77,9 +117,62 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS invites (
|
||||||
|
code TEXT PRIMARY KEY,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
used_by TEXT,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invites_status_expires ON invites(status, expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
actor_user_id TEXT,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
target_type TEXT,
|
||||||
|
target_id TEXT,
|
||||||
|
metadata TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS devices (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
device_identifier TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type INTEGER NOT NULL,
|
||||||
|
session_stamp TEXT,
|
||||||
|
encrypted_user_key TEXT,
|
||||||
|
encrypted_public_key TEXT,
|
||||||
|
encrypted_private_key TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, device_identifier),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at);
|
||||||
|
|
||||||
|
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,
|
||||||
|
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);
|
||||||
|
|
||||||
-- Rate limiting
|
-- Rate limiting
|
||||||
CREATE TABLE IF NOT EXISTS login_attempts (
|
CREATE TABLE IF NOT EXISTS login_attempts_ip (
|
||||||
email TEXT PRIMARY KEY,
|
ip TEXT PRIMARY KEY,
|
||||||
attempts INTEGER NOT NULL,
|
attempts INTEGER NOT NULL,
|
||||||
locked_until INTEGER,
|
locked_until INTEGER,
|
||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
@@ -92,3 +185,8 @@ CREATE TABLE IF NOT EXISTS api_rate_limits (
|
|||||||
PRIMARY KEY (identifier, window_start)
|
PRIMARY KEY (identifier, window_start)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(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 (
|
||||||
|
jti TEXT PRIMARY KEY,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "0.2.0",
|
"version": "1.4.3",
|
||||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||||
"author": "shuaiplus",
|
"author": "shuaiplus",
|
||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
@@ -8,8 +8,10 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "wrangler dev -c wrangler.toml",
|
"dev": "wrangler dev -c wrangler.toml",
|
||||||
"deploymy": "wrangler deploy -c wrangler.my.toml",
|
"dev:kv": "wrangler dev -c wrangler.kv.toml",
|
||||||
"deploy": "wrangler deploy "
|
"build": "vite build --config webapp/vite.config.ts",
|
||||||
|
"deploy": "wrangler deploy",
|
||||||
|
"deploy:kv": "wrangler deploy -c wrangler.kv.toml"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"bitwarden",
|
"bitwarden",
|
||||||
@@ -21,20 +23,39 @@
|
|||||||
"cloudflare": {
|
"cloudflare": {
|
||||||
"bindings": {
|
"bindings": {
|
||||||
"JWT_SECRET": {
|
"JWT_SECRET": {
|
||||||
"description": "Secret used to sign JWTs. Use a strong random string (32+ characters recommended)"
|
"description": "Use a strong random string (32+ characters recommended)"
|
||||||
},
|
},
|
||||||
"DB": {
|
"DB": {
|
||||||
"description": "D1 database for storing vault data"
|
"description": "D1 database for storing vault data"
|
||||||
},
|
},
|
||||||
"ATTACHMENTS": {
|
"ATTACHMENTS": {
|
||||||
"description": "R2 bucket for storing file attachments"
|
"description": "R2 bucket for storing file attachments"
|
||||||
|
},
|
||||||
|
"ATTACHMENTS_KV": {
|
||||||
|
"description": "Optional KV namespace fallback for attachment/send-file storage"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/workers-types": "^4.20260131.0",
|
"@cloudflare/workers-types": "^4.20260131.0",
|
||||||
|
"@preact/preset-vite": "^2.10.3",
|
||||||
|
"@types/node": "^25.2.3",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"wrangler": "^4.61.1"
|
"vite": "^7.3.1",
|
||||||
|
"wrangler": "^4.71.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@noble/hashes": "^2.0.1",
|
||||||
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"@zip.js/zip.js": "^2.8.22",
|
||||||
|
"fflate": "^0.8.2",
|
||||||
|
"lucide-preact": "^0.575.0",
|
||||||
|
"preact": "^10.28.4",
|
||||||
|
"qrcode-generator": "^2.0.4",
|
||||||
|
"wouter": "^3.9.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export const APP_VERSION = '1.4.3';
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
|
||||||
|
export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
|
||||||
|
export const BACKUP_DEFAULT_E3_REGION = 'auto';
|
||||||
|
export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden';
|
||||||
|
export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
|
||||||
|
export const BACKUP_DEFAULT_START_TIME = '03:00';
|
||||||
|
|
||||||
|
export type BackupDestinationType = 'e3' | 'webdav';
|
||||||
|
|
||||||
|
export interface E3BackupDestination {
|
||||||
|
endpoint: string;
|
||||||
|
bucket: string;
|
||||||
|
region: string;
|
||||||
|
accessKeyId: string;
|
||||||
|
secretAccessKey: string;
|
||||||
|
rootPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebDavBackupDestination {
|
||||||
|
baseUrl: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
remotePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BackupDestinationConfig =
|
||||||
|
| E3BackupDestination
|
||||||
|
| WebDavBackupDestination;
|
||||||
|
|
||||||
|
export interface BackupRuntimeState {
|
||||||
|
lastAttemptAt: string | null;
|
||||||
|
lastAttemptLocalDate: string | null;
|
||||||
|
lastSuccessAt: string | null;
|
||||||
|
lastErrorAt: string | null;
|
||||||
|
lastErrorMessage: string | null;
|
||||||
|
lastUploadedFileName: string | null;
|
||||||
|
lastUploadedSizeBytes: number | null;
|
||||||
|
lastUploadedDestination: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupScheduleConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
intervalHours: number;
|
||||||
|
startTime: string;
|
||||||
|
timezone: string;
|
||||||
|
retentionCount: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupDestinationRecord {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: BackupDestinationType;
|
||||||
|
includeAttachments: boolean;
|
||||||
|
destination: BackupDestinationConfig;
|
||||||
|
schedule: BackupScheduleConfig;
|
||||||
|
runtime: BackupRuntimeState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupSettings {
|
||||||
|
destinations: BackupDestinationRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBackupRandomId(): string {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `backup-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultBackupRuntimeState(): BackupRuntimeState {
|
||||||
|
return {
|
||||||
|
lastAttemptAt: null,
|
||||||
|
lastAttemptLocalDate: null,
|
||||||
|
lastSuccessAt: null,
|
||||||
|
lastErrorAt: null,
|
||||||
|
lastErrorMessage: null,
|
||||||
|
lastUploadedFileName: null,
|
||||||
|
lastUploadedSizeBytes: null,
|
||||||
|
lastUploadedDestination: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultBackupScheduleConfig(timezone: string = BACKUP_DEFAULT_TIMEZONE): BackupScheduleConfig {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
intervalHours: BACKUP_DEFAULT_INTERVAL_HOURS,
|
||||||
|
startTime: BACKUP_DEFAULT_START_TIME,
|
||||||
|
timezone,
|
||||||
|
retentionCount: BACKUP_DEFAULT_RETENTION_COUNT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultBackupDestinationConfig(type: BackupDestinationType): BackupDestinationConfig {
|
||||||
|
if (type === 'e3') {
|
||||||
|
return {
|
||||||
|
endpoint: '',
|
||||||
|
bucket: '',
|
||||||
|
region: BACKUP_DEFAULT_E3_REGION,
|
||||||
|
accessKeyId: '',
|
||||||
|
secretAccessKey: '',
|
||||||
|
rootPath: BACKUP_DEFAULT_REMOTE_PATH,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
baseUrl: '',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
remotePath: BACKUP_DEFAULT_REMOTE_PATH,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultBackupDestinationName(type: BackupDestinationType, index: number): string {
|
||||||
|
if (type === 'e3') return `E3 ${index}`;
|
||||||
|
return `WebDAV ${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateBackupDestinationRecordOptions {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
timezone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBackupDestinationRecord(
|
||||||
|
type: BackupDestinationType,
|
||||||
|
index: number,
|
||||||
|
options: CreateBackupDestinationRecordOptions = {}
|
||||||
|
): BackupDestinationRecord {
|
||||||
|
return {
|
||||||
|
id: options.id || createBackupRandomId(),
|
||||||
|
name: options.name || createDefaultBackupDestinationName(type, index),
|
||||||
|
type,
|
||||||
|
includeAttachments: false,
|
||||||
|
destination: createDefaultBackupDestinationConfig(type),
|
||||||
|
schedule: createDefaultBackupScheduleConfig(options.timezone || BACKUP_DEFAULT_TIMEZONE),
|
||||||
|
runtime: createDefaultBackupRuntimeState(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultBackupSettings(
|
||||||
|
timezone: string = BACKUP_DEFAULT_TIMEZONE,
|
||||||
|
options: { destinationName?: string } = {}
|
||||||
|
): BackupSettings {
|
||||||
|
return {
|
||||||
|
destinations: [
|
||||||
|
createBackupDestinationRecord('webdav', 1, {
|
||||||
|
timezone,
|
||||||
|
name: options.destinationName,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
export const LIMITS = {
|
||||||
|
auth: {
|
||||||
|
// Access token lifetime in seconds.
|
||||||
|
// 访问令牌有效期(秒)。
|
||||||
|
accessTokenTtlSeconds: 7200,
|
||||||
|
// Refresh token lifetime in milliseconds.
|
||||||
|
// 刷新令牌有效期(毫秒)。
|
||||||
|
refreshTokenTtlMs: 30 * 24 * 60 * 60 * 1000,
|
||||||
|
// Grace window for previous refresh token after rotation (ms).
|
||||||
|
// 刷新令牌轮换后的旧令牌宽限窗口(毫秒)。
|
||||||
|
refreshTokenOverlapGraceMs: 60 * 1000,
|
||||||
|
// Refresh token random byte length.
|
||||||
|
// 刷新令牌随机字节长度。
|
||||||
|
refreshTokenRandomBytes: 32,
|
||||||
|
// Attachment download token lifetime in seconds.
|
||||||
|
// 附件下载令牌有效期(秒)。
|
||||||
|
fileDownloadTokenTtlSeconds: 300,
|
||||||
|
// Send access token lifetime in seconds.
|
||||||
|
// Send 访问令牌有效期(秒)。
|
||||||
|
sendAccessTokenTtlSeconds: 300,
|
||||||
|
// Minimum required JWT secret length.
|
||||||
|
// JWT 密钥最小长度要求。
|
||||||
|
jwtSecretMinLength: 32,
|
||||||
|
// Default PBKDF2 iterations for account creation/prelogin fallback.
|
||||||
|
// 账户创建与预登录回退使用的默认 PBKDF2 迭代次数。
|
||||||
|
defaultKdfIterations: 600000,
|
||||||
|
},
|
||||||
|
rateLimit: {
|
||||||
|
// Max failed login attempts before temporary lock.
|
||||||
|
// 触发临时锁定前允许的最大登录失败次数。
|
||||||
|
loginMaxAttempts: 10,
|
||||||
|
// Login lock duration in minutes.
|
||||||
|
// 登录锁定时长(分钟)。
|
||||||
|
loginLockoutMinutes: 2,
|
||||||
|
// Authenticated API request budget per user per minute (all reads & writes combined).
|
||||||
|
// 认证 API 每用户每分钟请求配额(读写合计)。
|
||||||
|
apiRequestsPerMinute: 200,
|
||||||
|
// Public (unauthenticated) request budget per IP per minute.
|
||||||
|
// 公开(未认证)接口每 IP 每分钟请求配额。
|
||||||
|
publicRequestsPerMinute: 60,
|
||||||
|
// Public read-only request budget per IP per minute.
|
||||||
|
// 公开只读接口每 IP 每分钟请求配额。
|
||||||
|
publicReadRequestsPerMinute: 120,
|
||||||
|
// Sensitive public/auth request budget per IP per minute.
|
||||||
|
// 敏感公开/认证接口每 IP 每分钟请求配额。
|
||||||
|
sensitivePublicRequestsPerMinute: 30,
|
||||||
|
// Password hint lookup budget per IP per minute.
|
||||||
|
// 密码提示查询接口每 IP 每分钟请求配额。
|
||||||
|
passwordHintRequestsPerMinute: 1,
|
||||||
|
// Password hint lookup budget per IP per hour.
|
||||||
|
// 密码提示查询接口每 IP 每小时请求配额。
|
||||||
|
passwordHintRequestsPerHour: 3,
|
||||||
|
// Register endpoint budget per IP per minute.
|
||||||
|
// 注册接口每 IP 每分钟请求配额。
|
||||||
|
registerRequestsPerMinute: 5,
|
||||||
|
// Refresh-token grant budget per IP per minute.
|
||||||
|
// refresh_token 授权每 IP 每分钟请求配额。
|
||||||
|
refreshTokenRequestsPerMinute: 30,
|
||||||
|
// Fixed window size for API rate limiting in seconds.
|
||||||
|
// API 限流固定窗口大小(秒)。
|
||||||
|
apiWindowSeconds: 60,
|
||||||
|
// Probability to run low-frequency cleanup on request path.
|
||||||
|
// 在请求路径中触发低频清理的概率。
|
||||||
|
cleanupProbability: 0.05,
|
||||||
|
// Minimum interval between login-attempt cleanup runs.
|
||||||
|
// 登录尝试表清理的最小间隔。
|
||||||
|
loginIpCleanupIntervalMs: 10 * 60 * 1000,
|
||||||
|
// Retention window for login IP records.
|
||||||
|
// 登录 IP 记录保留时长。
|
||||||
|
loginIpRetentionMs: 30 * 24 * 60 * 60 * 1000,
|
||||||
|
},
|
||||||
|
cleanup: {
|
||||||
|
// Minimum interval between refresh-token cleanup runs.
|
||||||
|
// refresh_token 表清理最小间隔。
|
||||||
|
refreshTokenCleanupIntervalMs: 30 * 60 * 1000,
|
||||||
|
// Minimum interval between used attachment token cleanup runs.
|
||||||
|
// 已使用附件令牌表清理最小间隔。
|
||||||
|
attachmentTokenCleanupIntervalMs: 10 * 60 * 1000,
|
||||||
|
// Probability to trigger cleanup during requests.
|
||||||
|
// 请求过程中触发清理的概率。
|
||||||
|
cleanupProbability: 0.05,
|
||||||
|
},
|
||||||
|
attachment: {
|
||||||
|
// Max attachment upload size in bytes.
|
||||||
|
// 附件上传大小上限(字节)。
|
||||||
|
maxFileSizeBytes: 100 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
send: {
|
||||||
|
// Max file size allowed for Send file uploads.
|
||||||
|
// Send 文件上传大小上限。
|
||||||
|
maxFileSizeBytes: 100 * 1024 * 1024,
|
||||||
|
// Max days allowed between now and deletion date.
|
||||||
|
// 允许的最远删除日期(距当前天数)。
|
||||||
|
maxDeletionDays: 31,
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
// Default page size when client does not specify pageSize.
|
||||||
|
// 客户端未传 pageSize 时的默认分页大小。
|
||||||
|
defaultPageSize: 100,
|
||||||
|
// Hard maximum page size accepted by server.
|
||||||
|
// 服务端允许的最大分页大小。
|
||||||
|
maxPageSize: 500,
|
||||||
|
},
|
||||||
|
cors: {
|
||||||
|
// Browser preflight cache max age in seconds.
|
||||||
|
// 浏览器预检请求缓存时长(秒)。
|
||||||
|
preflightMaxAgeSeconds: 86400,
|
||||||
|
},
|
||||||
|
cache: {
|
||||||
|
// Icon proxy cache TTL in seconds.
|
||||||
|
// 图标代理缓存时长(秒)。
|
||||||
|
iconTtlSeconds: 604800,
|
||||||
|
// In-memory /api/sync response cache TTL (milliseconds).
|
||||||
|
// /api/sync 内存缓存有效期(毫秒)。
|
||||||
|
syncResponseTtlMs: 30 * 1000,
|
||||||
|
// Max size of a single cached /api/sync body in bytes.
|
||||||
|
// 单个 /api/sync 缓存响应允许的最大字节数。
|
||||||
|
syncResponseMaxBodyBytes: 512 * 1024,
|
||||||
|
// Max total in-memory bytes used by /api/sync cache per isolate.
|
||||||
|
// 每个 isolate 中 /api/sync 缓存允许占用的最大总字节数。
|
||||||
|
syncResponseMaxTotalBytes: 2 * 1024 * 1024,
|
||||||
|
// Max in-memory /api/sync cache entries per isolate.
|
||||||
|
// 每个 isolate 的 /api/sync 最大缓存条目数。
|
||||||
|
syncResponseMaxEntries: 64,
|
||||||
|
},
|
||||||
|
performance: {
|
||||||
|
// Max IDs per SQL batch when moving ciphers in bulk.
|
||||||
|
// 批量移动密码项时每批 SQL 的最大 ID 数量。
|
||||||
|
bulkMoveChunkSize: 200,
|
||||||
|
// Max total items (folders + ciphers) allowed in a single import.
|
||||||
|
// 单次导入允许的最大条目数(文件夹 + 密码项合计)。
|
||||||
|
importItemLimit: 5000,
|
||||||
|
// Small fixed concurrency for blob/attachment batch cleanup work.
|
||||||
|
// 附件 / blob 批量清理时的保守并发数。
|
||||||
|
attachmentDeleteConcurrency: 4,
|
||||||
|
},
|
||||||
|
request: {
|
||||||
|
// Hard body size limit for JSON API endpoints (bytes). File upload paths are exempt.
|
||||||
|
// JSON 接口请求 body 大小上限(字节),文件上传接口除外。
|
||||||
|
maxBodyBytes: 25 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
compatibility: {
|
||||||
|
// Single source of truth for /config.version and /api/version.
|
||||||
|
// /config.version 与 /api/version 的统一版本号来源。
|
||||||
|
bitwardenServerVersion: '2026.1.0',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
import { DurableObject } from 'cloudflare:workers';
|
||||||
|
import type { Env } from '../types';
|
||||||
|
|
||||||
|
const SIGNALR_RECORD_SEPARATOR = 0x1e;
|
||||||
|
const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARATOR]);
|
||||||
|
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
||||||
|
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
||||||
|
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
|
||||||
|
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
|
||||||
|
|
||||||
|
type HubProtocol = 'json' | 'messagepack';
|
||||||
|
|
||||||
|
interface WsAttachment {
|
||||||
|
userId: string;
|
||||||
|
handshakeComplete: boolean;
|
||||||
|
protocol: HubProtocol;
|
||||||
|
deviceIdentifier: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function concatBytes(chunks: Uint8Array[]): Uint8Array {
|
||||||
|
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||||
|
const out = new Uint8Array(total);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
out.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeUtf8(value: string): Uint8Array {
|
||||||
|
return new TextEncoder().encode(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeIncomingMessage(data: string | ArrayBuffer | ArrayBufferView): string {
|
||||||
|
if (typeof data === 'string') return data;
|
||||||
|
if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data));
|
||||||
|
return new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeMsgPackInteger(value: number): Uint8Array {
|
||||||
|
const normalized = Math.trunc(value);
|
||||||
|
if (normalized >= 0 && normalized <= 0x7f) {
|
||||||
|
return new Uint8Array([normalized]);
|
||||||
|
}
|
||||||
|
if (normalized >= 0 && normalized <= 0xff) {
|
||||||
|
return new Uint8Array([0xcc, normalized]);
|
||||||
|
}
|
||||||
|
if (normalized >= 0 && normalized <= 0xffff) {
|
||||||
|
return new Uint8Array([0xcd, normalized >> 8, normalized & 0xff]);
|
||||||
|
}
|
||||||
|
const safe = normalized >>> 0;
|
||||||
|
return new Uint8Array([
|
||||||
|
0xce,
|
||||||
|
(safe >>> 24) & 0xff,
|
||||||
|
(safe >>> 16) & 0xff,
|
||||||
|
(safe >>> 8) & 0xff,
|
||||||
|
safe & 0xff,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeMsgPackString(value: string): Uint8Array {
|
||||||
|
const bytes = encodeUtf8(value);
|
||||||
|
const len = bytes.length;
|
||||||
|
if (len < 32) {
|
||||||
|
return concatBytes([new Uint8Array([0xa0 | len]), bytes]);
|
||||||
|
}
|
||||||
|
if (len <= 0xff) {
|
||||||
|
return concatBytes([new Uint8Array([0xd9, len]), bytes]);
|
||||||
|
}
|
||||||
|
return concatBytes([new Uint8Array([0xda, (len >> 8) & 0xff, len & 0xff]), bytes]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeMsgPackTimestamp(date: Date): Uint8Array {
|
||||||
|
const seconds = BigInt(Math.floor(date.getTime() / 1000));
|
||||||
|
const nanos = BigInt(date.getMilliseconds()) * 1000000n;
|
||||||
|
const timestamp = (nanos << 34n) | seconds;
|
||||||
|
const payload = new Uint8Array(8);
|
||||||
|
for (let i = 7; i >= 0; i--) {
|
||||||
|
payload[i] = Number((timestamp >> BigInt((7 - i) * 8)) & 0xffn);
|
||||||
|
}
|
||||||
|
return concatBytes([new Uint8Array([0xc7, 0x08, 0xff]), payload]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeMsgPackArray(values: unknown[]): Uint8Array {
|
||||||
|
const items = values.map(encodeMsgPack);
|
||||||
|
const len = items.length;
|
||||||
|
const header =
|
||||||
|
len < 16
|
||||||
|
? new Uint8Array([0x90 | len])
|
||||||
|
: new Uint8Array([0xdc, (len >> 8) & 0xff, len & 0xff]);
|
||||||
|
return concatBytes([header, ...items]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeMsgPackMap(value: Record<string, unknown>): Uint8Array {
|
||||||
|
const entries = Object.entries(value);
|
||||||
|
const len = entries.length;
|
||||||
|
const header =
|
||||||
|
len < 16
|
||||||
|
? new Uint8Array([0x80 | len])
|
||||||
|
: new Uint8Array([0xde, (len >> 8) & 0xff, len & 0xff]);
|
||||||
|
const chunks: Uint8Array[] = [header];
|
||||||
|
for (const [key, entryValue] of entries) {
|
||||||
|
chunks.push(encodeMsgPackString(key), encodeMsgPack(entryValue));
|
||||||
|
}
|
||||||
|
return concatBytes(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeMsgPack(value: unknown): Uint8Array {
|
||||||
|
if (value === null || value === undefined) return new Uint8Array([0xc0]);
|
||||||
|
if (value instanceof Date) return encodeMsgPackTimestamp(value);
|
||||||
|
if (typeof value === 'string') return encodeMsgPackString(value);
|
||||||
|
if (typeof value === 'number') return encodeMsgPackInteger(value);
|
||||||
|
if (typeof value === 'boolean') return new Uint8Array([value ? 0xc3 : 0xc2]);
|
||||||
|
if (Array.isArray(value)) return encodeMsgPackArray(value);
|
||||||
|
if (value instanceof Uint8Array) {
|
||||||
|
const len = value.length;
|
||||||
|
if (len <= 0xff) return concatBytes([new Uint8Array([0xc4, len]), value]);
|
||||||
|
return concatBytes([new Uint8Array([0xc5, (len >> 8) & 0xff, len & 0xff]), value]);
|
||||||
|
}
|
||||||
|
return encodeMsgPackMap(value as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
function frameSignalRBinary(payload: Uint8Array): Uint8Array {
|
||||||
|
const len = payload.length;
|
||||||
|
const prefix: number[] = [];
|
||||||
|
let value = len;
|
||||||
|
do {
|
||||||
|
let current = value & 0x7f;
|
||||||
|
value >>>= 7;
|
||||||
|
if (value > 0) current |= 0x80;
|
||||||
|
prefix.push(current);
|
||||||
|
} while (value > 0);
|
||||||
|
return concatBytes([new Uint8Array(prefix), payload]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSignalRJsonInvocation(
|
||||||
|
updateType: number,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
contextId: string | null
|
||||||
|
): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 1,
|
||||||
|
target: 'ReceiveMessage',
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
ContextId: contextId,
|
||||||
|
Type: updateType,
|
||||||
|
Payload: payload,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSignalRMessagePackInvocation(
|
||||||
|
updateType: number,
|
||||||
|
messagePayload: Record<string, unknown>,
|
||||||
|
contextId: string | null
|
||||||
|
): Uint8Array {
|
||||||
|
// SignalR MessagePack hub protocol uses an array-based invocation shape:
|
||||||
|
// [type, headers, invocationId, target, arguments]
|
||||||
|
const encodedPayload = encodeMsgPack([
|
||||||
|
1,
|
||||||
|
{},
|
||||||
|
null,
|
||||||
|
'ReceiveMessage',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
ContextId: contextId,
|
||||||
|
Type: updateType,
|
||||||
|
Payload: messagePayload,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
return frameSignalRBinary(encodedPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotificationsHub extends DurableObject<Env> {
|
||||||
|
constructor(ctx: DurableObjectState, env: Env) {
|
||||||
|
super(ctx, env);
|
||||||
|
this.ctx.setWebSocketAutoResponse(
|
||||||
|
new WebSocketRequestResponsePair(
|
||||||
|
JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR),
|
||||||
|
JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetch(request: Request): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (url.pathname === '/internal/notify' && request.method === 'POST') {
|
||||||
|
const body = (await request.json().catch(() => null)) as {
|
||||||
|
revisionDate?: string;
|
||||||
|
userId?: string;
|
||||||
|
contextId?: string | null;
|
||||||
|
updateType?: number;
|
||||||
|
targetDeviceIdentifier?: string | null;
|
||||||
|
payload?: Record<string, unknown> | null;
|
||||||
|
} | null;
|
||||||
|
const revisionDate = String(body?.revisionDate || '').trim() || new Date().toISOString();
|
||||||
|
const userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || '').trim();
|
||||||
|
const contextId = String(body?.contextId || '').trim() || null;
|
||||||
|
const updateType = Number(body?.updateType || SIGNALR_UPDATE_TYPE_SYNC_VAULT) || SIGNALR_UPDATE_TYPE_SYNC_VAULT;
|
||||||
|
const targetDeviceIdentifier = String(body?.targetDeviceIdentifier || '').trim() || null;
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/internal/online' && request.method === 'GET') {
|
||||||
|
return new Response(JSON.stringify({ deviceIdentifiers: this.getOnlineDeviceIdentifiers() }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname !== '/notifications/hub') {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
|
||||||
|
return new Response('Expected websocket', { status: 426 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestUserId = String(url.searchParams.get('nw_uid') || '').trim();
|
||||||
|
const requestDeviceIdentifier = String(url.searchParams.get('nw_did') || '').trim() || null;
|
||||||
|
|
||||||
|
if (!requestUserId) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pair = new WebSocketPair();
|
||||||
|
const client = pair[0];
|
||||||
|
const server = pair[1];
|
||||||
|
|
||||||
|
const tags: string[] = [];
|
||||||
|
if (requestDeviceIdentifier) {
|
||||||
|
tags.push(`device:${requestDeviceIdentifier}`);
|
||||||
|
}
|
||||||
|
this.ctx.acceptWebSocket(server, tags);
|
||||||
|
|
||||||
|
server.serializeAttachment({
|
||||||
|
userId: requestUserId,
|
||||||
|
handshakeComplete: false,
|
||||||
|
protocol: 'messagepack',
|
||||||
|
deviceIdentifier: requestDeviceIdentifier,
|
||||||
|
} satisfies WsAttachment);
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 101,
|
||||||
|
webSocket: client,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer | ArrayBufferView): Promise<void> {
|
||||||
|
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||||
|
if (!attachment) return;
|
||||||
|
|
||||||
|
if (!attachment.handshakeComplete) {
|
||||||
|
const text = decodeIncomingMessage(message);
|
||||||
|
const frames = text.split(String.fromCharCode(SIGNALR_RECORD_SEPARATOR)).filter(Boolean);
|
||||||
|
for (const frame of frames) {
|
||||||
|
try {
|
||||||
|
const handshake = JSON.parse(frame) as { protocol?: string };
|
||||||
|
attachment.protocol = handshake.protocol === 'json' ? 'json' : 'messagepack';
|
||||||
|
attachment.handshakeComplete = true;
|
||||||
|
ws.serializeAttachment(attachment);
|
||||||
|
ws.send(SIGNALR_HANDSHAKE_ACK);
|
||||||
|
this.broadcastDeviceStatus(attachment.userId);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed pre-handshake payloads.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof message !== 'string') {
|
||||||
|
try {
|
||||||
|
ws.send(message);
|
||||||
|
} catch {
|
||||||
|
// ignore send errors on echo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
|
||||||
|
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||||
|
const shouldBroadcast = !!attachment?.handshakeComplete;
|
||||||
|
if (shouldBroadcast && attachment?.userId) {
|
||||||
|
this.broadcastDeviceStatus(attachment.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
|
||||||
|
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||||
|
const shouldBroadcast = !!attachment?.handshakeComplete;
|
||||||
|
if (shouldBroadcast && attachment?.userId) {
|
||||||
|
this.broadcastDeviceStatus(attachment.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOnlineDeviceIdentifiers(): string[] {
|
||||||
|
const out = new Set<string>();
|
||||||
|
for (const ws of this.ctx.getWebSockets()) {
|
||||||
|
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||||
|
if (!attachment?.handshakeComplete || !attachment.deviceIdentifier) continue;
|
||||||
|
out.add(attachment.deviceIdentifier);
|
||||||
|
}
|
||||||
|
return Array.from(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcastMessage(
|
||||||
|
updateType: number,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
contextId: string | null,
|
||||||
|
targetDeviceIdentifier: string | null
|
||||||
|
): void {
|
||||||
|
const sockets = targetDeviceIdentifier
|
||||||
|
? this.ctx.getWebSockets(`device:${targetDeviceIdentifier}`)
|
||||||
|
: this.ctx.getWebSockets();
|
||||||
|
|
||||||
|
if (sockets.length === 0) return;
|
||||||
|
|
||||||
|
for (const ws of sockets) {
|
||||||
|
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||||
|
if (!attachment?.handshakeComplete) continue;
|
||||||
|
try {
|
||||||
|
if (attachment.protocol === 'json') {
|
||||||
|
ws.send(buildSignalRJsonInvocation(updateType, payload, contextId));
|
||||||
|
} else {
|
||||||
|
ws.send(buildSignalRMessagePackInvocation(updateType, payload, contextId));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
ws.close(1011, 'Notification send failed');
|
||||||
|
} catch {
|
||||||
|
// ignore close races
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcastDeviceStatus(userId: string): void {
|
||||||
|
this.broadcastMessage(
|
||||||
|
SIGNALR_UPDATE_TYPE_DEVICE_STATUS,
|
||||||
|
{
|
||||||
|
UserId: userId,
|
||||||
|
Date: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function notifyUserVaultSync(
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
revisionDate: string,
|
||||||
|
contextId?: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
return notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_VAULT, revisionDate, contextId ?? null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function notifyUserLogout(
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
targetDeviceIdentifier?: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
return notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_LOG_OUT, new Date().toISOString(), null, targetDeviceIdentifier ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOnlineUserDevices(env: Env, userId: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
|
||||||
|
const stub = env.NOTIFICATIONS_HUB.get(id);
|
||||||
|
const response = await stub.fetch('https://notifications/internal/online');
|
||||||
|
if (!response.ok) return [];
|
||||||
|
const body = (await response.json().catch(() => null)) as { deviceIdentifiers?: string[] } | null;
|
||||||
|
return Array.isArray(body?.deviceIdentifiers) ? body.deviceIdentifiers.filter((value) => !!String(value || '').trim()) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyUserUpdate(
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
updateType: number,
|
||||||
|
revisionDate: string,
|
||||||
|
contextId: string | null,
|
||||||
|
targetDeviceIdentifier: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
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: contextId || null,
|
||||||
|
updateType,
|
||||||
|
targetDeviceIdentifier: targetDeviceIdentifier || null,
|
||||||
|
payload: {
|
||||||
|
UserId: userId,
|
||||||
|
Date: revisionDate,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to broadcast realtime notification:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function notifyUserBackupProgress(
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
progress: {
|
||||||
|
operation: 'backup-restore' | 'backup-export' | 'backup-remote-run';
|
||||||
|
source?: 'local' | 'remote';
|
||||||
|
step: string;
|
||||||
|
fileName: string;
|
||||||
|
stageTitle?: string;
|
||||||
|
stageDetail?: string;
|
||||||
|
replaceExisting?: boolean;
|
||||||
|
done?: boolean;
|
||||||
|
ok?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
timestamp?: string;
|
||||||
|
},
|
||||||
|
targetDeviceIdentifier?: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
const revisionDate = progress.timestamp || new Date().toISOString();
|
||||||
|
try {
|
||||||
|
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
|
||||||
|
const stub = env.NOTIFICATIONS_HUB.get(id);
|
||||||
|
await stub.fetch('https://notifications/internal/notify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-NodeWarden-UserId': userId,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
revisionDate,
|
||||||
|
contextId: null,
|
||||||
|
updateType: SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS,
|
||||||
|
targetDeviceIdentifier: targetDeviceIdentifier || null,
|
||||||
|
payload: {
|
||||||
|
UserId: userId,
|
||||||
|
Date: revisionDate,
|
||||||
|
...progress,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to broadcast backup progress:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function notifyUserBackupRestoreProgress(
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
progress: {
|
||||||
|
operation: 'backup-restore';
|
||||||
|
source: 'local' | 'remote';
|
||||||
|
step: string;
|
||||||
|
fileName: string;
|
||||||
|
stageTitle?: string;
|
||||||
|
stageDetail?: string;
|
||||||
|
replaceExisting?: boolean;
|
||||||
|
done?: boolean;
|
||||||
|
ok?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
timestamp?: string;
|
||||||
|
},
|
||||||
|
targetDeviceIdentifier?: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
return notifyUserBackupProgress(env, userId, progress, targetDeviceIdentifier);
|
||||||
|
}
|
||||||
@@ -1,22 +1,127 @@
|
|||||||
import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types';
|
import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { AuthService } from '../services/auth';
|
import { AuthService } from '../services/auth';
|
||||||
|
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
import { LIMITS } from '../config/limits';
|
||||||
|
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
||||||
|
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
||||||
|
import { buildAccountKeys } from '../utils/user-decryption';
|
||||||
|
|
||||||
|
function looksLikeEncString(value: string): boolean {
|
||||||
|
if (!value) return false;
|
||||||
|
const firstDot = value.indexOf('.');
|
||||||
|
if (firstDot <= 0 || firstDot === value.length - 1) return false;
|
||||||
|
const payload = value.slice(firstDot + 1);
|
||||||
|
const parts = payload.split('|');
|
||||||
|
// Bitwarden encrypted payloads should have at least IV + ciphertext.
|
||||||
|
return parts.length >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate KDF parameters according to Bitwarden minimum requirements.
|
||||||
|
* Returns an error message if invalid, or null if OK.
|
||||||
|
*/
|
||||||
|
function validateKdfParams(kdfType: number | undefined, kdfIterations: number | undefined, kdfMemory?: number | undefined, kdfParallelism?: number | undefined): string | null {
|
||||||
|
const type = kdfType ?? 0;
|
||||||
|
if (type === 0) {
|
||||||
|
// PBKDF2-SHA256: minimum 100 000 iterations
|
||||||
|
if (typeof kdfIterations === 'number' && kdfIterations < 100_000) {
|
||||||
|
return 'PBKDF2 iterations must be at least 100000';
|
||||||
|
}
|
||||||
|
} else if (type === 1) {
|
||||||
|
// Argon2id: iterations >= 2, memory >= 16 MiB, parallelism >= 1
|
||||||
|
if (typeof kdfIterations === 'number' && kdfIterations < 2) {
|
||||||
|
return 'Argon2id iterations must be at least 2';
|
||||||
|
}
|
||||||
|
if (typeof kdfMemory === 'number' && kdfMemory < 16) {
|
||||||
|
return 'Argon2id memory must be at least 16 MiB';
|
||||||
|
}
|
||||||
|
if (typeof kdfParallelism === 'number' && kdfParallelism < 1) {
|
||||||
|
return 'Argon2id parallelism must be at least 1';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTotpSecret(input: string): string {
|
||||||
|
const raw = String(input || '').toUpperCase();
|
||||||
|
let out = '';
|
||||||
|
for (const char of raw) {
|
||||||
|
if (char === ' ' || char === '\t' || char === '\n' || char === '\r' || char === '-') continue;
|
||||||
|
out += char;
|
||||||
|
}
|
||||||
|
while (out.endsWith('=')) {
|
||||||
|
out = out.slice(0, -1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRecoveryCodeInput(input: string): string {
|
||||||
|
return String(input || '').toUpperCase().replace(/[^A-Z2-7]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMasterPasswordHint(input: string | null | undefined): string | null {
|
||||||
|
const normalized = String(input || '').trim();
|
||||||
|
return normalized ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
|
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
|
||||||
const secret = (env.JWT_SECRET || '').trim();
|
const secret = (env.JWT_SECRET || '').trim();
|
||||||
if (!secret) return 'missing';
|
if (!secret) return 'missing';
|
||||||
if (secret === DEFAULT_DEV_SECRET) return 'default';
|
if (secret === DEFAULT_DEV_SECRET) return 'default';
|
||||||
if (secret.length < 32) return 'too_short';
|
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/accounts/register (only used from setup page, not client)
|
async function verifyUserSecret(
|
||||||
|
auth: AuthService,
|
||||||
|
user: User,
|
||||||
|
secret: string | null | undefined
|
||||||
|
): Promise<boolean> {
|
||||||
|
const normalized = String(secret || '').trim();
|
||||||
|
if (!normalized) return false;
|
||||||
|
return auth.verifyPassword(normalized, user.masterPasswordHash, user.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toProfile(user: User, env: Env): ProfileResponse {
|
||||||
|
void env;
|
||||||
|
const accountKeys = buildAccountKeys(user);
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
emailVerified: true,
|
||||||
|
premium: true,
|
||||||
|
premiumFromOrganization: false,
|
||||||
|
usesKeyConnector: false,
|
||||||
|
masterPasswordHint: user.masterPasswordHint,
|
||||||
|
culture: 'en-US',
|
||||||
|
twoFactorEnabled: !!user.totpSecret,
|
||||||
|
key: user.key,
|
||||||
|
privateKey: user.privateKey,
|
||||||
|
accountKeys,
|
||||||
|
securityStamp: user.securityStamp || user.id,
|
||||||
|
organizations: [],
|
||||||
|
providers: [],
|
||||||
|
providerOrganizations: [],
|
||||||
|
forcePasswordReset: false,
|
||||||
|
avatarColor: null,
|
||||||
|
creationDate: user.createdAt,
|
||||||
|
verifyDevices: user.verifyDevices,
|
||||||
|
role: user.role,
|
||||||
|
status: user.status,
|
||||||
|
object: 'profile',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/accounts/register
|
||||||
|
// - First user becomes admin.
|
||||||
|
// - Any subsequent user must provide a valid inviteCode.
|
||||||
export async function handleRegister(request: Request, env: Env): Promise<Response> {
|
export async function handleRegister(request: Request, env: Env): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
// Enforce safe JWT_SECRET before allowing first registration.
|
|
||||||
const unsafe = jwtSecretUnsafeReason(env);
|
const unsafe = jwtSecretUnsafeReason(env);
|
||||||
if (unsafe) {
|
if (unsafe) {
|
||||||
const message = unsafe === 'missing'
|
const message = unsafe === 'missing'
|
||||||
@@ -27,22 +132,17 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
|||||||
return errorResponse(message, 400);
|
return errorResponse(message, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if already registered
|
|
||||||
const isRegistered = await storage.isRegistered();
|
|
||||||
if (isRegistered) {
|
|
||||||
return errorResponse('Registration is closed', 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
let body: {
|
let body: {
|
||||||
email?: string;
|
email?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
masterPasswordHash?: string;
|
masterPasswordHash?: string;
|
||||||
masterPasswordHint?: string;
|
|
||||||
key?: string;
|
key?: string;
|
||||||
kdf?: number;
|
kdf?: number;
|
||||||
kdfIterations?: number;
|
kdfIterations?: number;
|
||||||
kdfMemory?: number;
|
kdfMemory?: number;
|
||||||
kdfParallelism?: number;
|
kdfParallelism?: number;
|
||||||
|
inviteCode?: string;
|
||||||
|
masterPasswordHint?: string;
|
||||||
keys?: {
|
keys?: {
|
||||||
publicKey?: string;
|
publicKey?: string;
|
||||||
encryptedPrivateKey?: string;
|
encryptedPrivateKey?: string;
|
||||||
@@ -55,110 +155,265 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
|||||||
return errorResponse('Invalid JSON', 400);
|
return errorResponse('Invalid JSON', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const email = body.email?.toLowerCase();
|
const email = body.email?.toLowerCase().trim();
|
||||||
const name = body.name || email;
|
const name = body.name?.trim() || email;
|
||||||
const masterPasswordHash = body.masterPasswordHash;
|
const masterPasswordHash = body.masterPasswordHash;
|
||||||
const key = body.key;
|
const key = body.key;
|
||||||
const privateKey = body.keys?.encryptedPrivateKey;
|
const privateKey = body.keys?.encryptedPrivateKey;
|
||||||
const publicKey = body.keys?.publicKey;
|
const publicKey = body.keys?.publicKey;
|
||||||
|
const inviteCode = (body.inviteCode || '').trim();
|
||||||
|
const masterPasswordHint = normalizeMasterPasswordHint(body.masterPasswordHint);
|
||||||
|
|
||||||
if (!email || !masterPasswordHash || !key) {
|
if (!email || !masterPasswordHash || !key) {
|
||||||
return errorResponse('Email, masterPasswordHash, and key are required', 400);
|
return errorResponse('Email, masterPasswordHash, and key are required', 400);
|
||||||
}
|
}
|
||||||
|
if (!email.includes('@') || email.length < 3) {
|
||||||
|
return errorResponse('Invalid email address', 400);
|
||||||
|
}
|
||||||
if (!privateKey || !publicKey) {
|
if (!privateKey || !publicKey) {
|
||||||
return errorResponse('Private key and public key are required', 400);
|
return errorResponse('Private key and public key are required', 400);
|
||||||
}
|
}
|
||||||
|
if (!looksLikeEncString(key)) {
|
||||||
|
return errorResponse('key is not a valid encrypted string', 400);
|
||||||
|
}
|
||||||
|
if (!looksLikeEncString(privateKey)) {
|
||||||
|
return errorResponse('encryptedPrivateKey is not a valid encrypted string', 400);
|
||||||
|
}
|
||||||
|
if (masterPasswordHint && masterPasswordHint.length > 120) {
|
||||||
|
return errorResponse('masterPasswordHint must be 120 characters or fewer', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const kdfErr = validateKdfParams(body.kdf, body.kdfIterations, body.kdfMemory, body.kdfParallelism);
|
||||||
|
if (kdfErr) return errorResponse(kdfErr, 400);
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const auth = new AuthService(env);
|
||||||
|
const serverHash = await auth.hashPasswordServer(masterPasswordHash, email);
|
||||||
|
|
||||||
// Create user
|
|
||||||
const user: User = {
|
const user: User = {
|
||||||
id: generateUUID(),
|
id: generateUUID(),
|
||||||
email: email,
|
email,
|
||||||
name: name || email,
|
name: name || email,
|
||||||
masterPasswordHash: masterPasswordHash,
|
masterPasswordHint,
|
||||||
key: key,
|
masterPasswordHash: serverHash,
|
||||||
privateKey: privateKey,
|
key,
|
||||||
publicKey: publicKey,
|
privateKey,
|
||||||
|
publicKey,
|
||||||
kdfType: body.kdf ?? 0,
|
kdfType: body.kdf ?? 0,
|
||||||
kdfIterations: body.kdfIterations ?? 600000,
|
kdfIterations: body.kdfIterations ?? LIMITS.auth.defaultKdfIterations,
|
||||||
kdfMemory: body.kdfMemory,
|
kdfMemory: body.kdfMemory,
|
||||||
kdfParallelism: body.kdfParallelism,
|
kdfParallelism: body.kdfParallelism,
|
||||||
securityStamp: generateUUID(),
|
securityStamp: generateUUID(),
|
||||||
createdAt: new Date().toISOString(),
|
role: 'user',
|
||||||
updatedAt: new Date().toISOString(),
|
status: 'active',
|
||||||
|
verifyDevices: true,
|
||||||
|
totpSecret: null,
|
||||||
|
totpRecoveryCode: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
await storage.saveUser(user);
|
const userCount = await storage.getUserCount();
|
||||||
|
if (userCount === 0) {
|
||||||
|
user.role = 'admin';
|
||||||
|
const created = await storage.createFirstUser(user);
|
||||||
|
if (!created) {
|
||||||
|
return errorResponse('Registration is temporarily unavailable, retry once', 409);
|
||||||
|
}
|
||||||
await storage.setRegistered();
|
await storage.setRegistered();
|
||||||
|
await storage.createAuditLog({
|
||||||
return jsonResponse({ success: true }, 200);
|
id: generateUUID(),
|
||||||
|
actorUserId: user.id,
|
||||||
|
action: 'user.register.first_admin',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
metadata: JSON.stringify({ email: user.email }),
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
return jsonResponse({ success: true, role: user.role }, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/accounts/profile
|
if (!inviteCode) {
|
||||||
export async function handleGetProfile(request: Request, env: Env, userId: string): Promise<Response> {
|
return errorResponse('Invite code is required', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await storage.createUser(user);
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
||||||
|
if (msg.includes('unique') || msg.includes('constraint')) {
|
||||||
|
return errorResponse('Email already registered', 409);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id);
|
||||||
|
if (!inviteMarked) {
|
||||||
|
await storage.deleteUserById(user.id);
|
||||||
|
return errorResponse('Invite code is invalid or expired', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.createAuditLog({
|
||||||
|
id: generateUUID(),
|
||||||
|
actorUserId: user.id,
|
||||||
|
action: 'user.register.invite',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
metadata: JSON.stringify({ email: user.email, inviteCode }),
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse({ success: true, role: user.role }, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/accounts/password-hint
|
||||||
|
export async function handleGetPasswordHint(request: Request, env: Env): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const user = await storage.getUserById(userId);
|
const clientIdentifier = getClientIdentifier(request);
|
||||||
|
if (!clientIdentifier) {
|
||||||
if (!user) {
|
return errorResponse('Client IP is required', 403);
|
||||||
return errorResponse('User not found', 404);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const profile: ProfileResponse = {
|
let body: { email?: string };
|
||||||
id: user.id,
|
|
||||||
name: user.name,
|
|
||||||
email: user.email,
|
|
||||||
emailVerified: true,
|
|
||||||
premium: true,
|
|
||||||
premiumFromOrganization: false,
|
|
||||||
usesKeyConnector: false,
|
|
||||||
masterPasswordHint: null,
|
|
||||||
culture: 'en-US',
|
|
||||||
twoFactorEnabled: false,
|
|
||||||
key: user.key,
|
|
||||||
privateKey: user.privateKey,
|
|
||||||
accountKeys: null,
|
|
||||||
securityStamp: user.securityStamp || user.id,
|
|
||||||
organizations: [],
|
|
||||||
providers: [],
|
|
||||||
providerOrganizations: [],
|
|
||||||
forcePasswordReset: false,
|
|
||||||
avatarColor: null,
|
|
||||||
creationDate: user.createdAt,
|
|
||||||
object: 'profile',
|
|
||||||
};
|
|
||||||
|
|
||||||
return jsonResponse(profile);
|
|
||||||
}
|
|
||||||
|
|
||||||
// PUT /api/accounts/profile
|
|
||||||
export async function handleUpdateProfile(request: Request, env: Env, userId: string): Promise<Response> {
|
|
||||||
const storage = new StorageService(env.DB);
|
|
||||||
const user = await storage.getUserById(userId);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return errorResponse('User not found', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
let body: { name?: string; masterPasswordHint?: string };
|
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
} catch {
|
} catch {
|
||||||
return errorResponse('Invalid JSON', 400);
|
return errorResponse('Invalid JSON', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.name) {
|
const email = String(body.email || '').trim().toLowerCase();
|
||||||
user.name = body.name;
|
if (!email) {
|
||||||
|
return errorResponse('Email is required', 400);
|
||||||
}
|
}
|
||||||
user.updatedAt = new Date().toISOString();
|
|
||||||
|
|
||||||
|
const rateLimit = new RateLimitService(env.DB);
|
||||||
|
const minuteBudget = await rateLimit.consumeBudgetWithWindow(
|
||||||
|
`${clientIdentifier}:password-hint`,
|
||||||
|
LIMITS.rateLimit.passwordHintRequestsPerMinute,
|
||||||
|
60
|
||||||
|
);
|
||||||
|
if (!minuteBudget.allowed) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Too many requests',
|
||||||
|
error_description: `Rate limit exceeded. Try again in ${minuteBudget.retryAfterSeconds || 60} seconds.`,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Retry-After': String(minuteBudget.retryAfterSeconds || 60),
|
||||||
|
'X-RateLimit-Remaining': '0',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hourlyBudget = await rateLimit.consumeBudgetWithWindow(
|
||||||
|
`${clientIdentifier}:password-hint-hour`,
|
||||||
|
LIMITS.rateLimit.passwordHintRequestsPerHour,
|
||||||
|
60 * 60
|
||||||
|
);
|
||||||
|
if (!hourlyBudget.allowed) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Too many requests',
|
||||||
|
error_description: `Rate limit exceeded. Try again in ${hourlyBudget.retryAfterSeconds || 3600} seconds.`,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Retry-After': String(hourlyBudget.retryAfterSeconds || 3600),
|
||||||
|
'X-RateLimit-Remaining': '0',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await storage.getUser(email);
|
||||||
|
const hint = user?.status === 'active' ? normalizeMasterPasswordHint(user.masterPasswordHint) : null;
|
||||||
|
return jsonResponse({
|
||||||
|
object: 'passwordHint',
|
||||||
|
hasHint: !!hint,
|
||||||
|
masterPasswordHint: hint,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/accounts/profile
|
||||||
|
export async function handleGetProfile(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const user = await storage.getUserById(userId);
|
||||||
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
return jsonResponse(toProfile(user, env));
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/accounts/profile
|
||||||
|
export async function handleUpdateProfile(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const user = await storage.getUserById(userId);
|
||||||
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
|
let body: {
|
||||||
|
masterPasswordHint?: string | null;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const masterPasswordHint = normalizeMasterPasswordHint(body.masterPasswordHint);
|
||||||
|
if (masterPasswordHint && masterPasswordHint.length > 120) {
|
||||||
|
return errorResponse('masterPasswordHint must be 120 characters or fewer', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.masterPasswordHint = masterPasswordHint;
|
||||||
|
user.updatedAt = new Date().toISOString();
|
||||||
await storage.saveUser(user);
|
await storage.saveUser(user);
|
||||||
|
|
||||||
return handleGetProfile(request, env, userId);
|
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);
|
||||||
|
const auth = new AuthService(env);
|
||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -166,6 +421,7 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
|
|||||||
}
|
}
|
||||||
|
|
||||||
let body: {
|
let body: {
|
||||||
|
masterPasswordHash?: string;
|
||||||
key?: string;
|
key?: string;
|
||||||
encryptedPrivateKey?: string;
|
encryptedPrivateKey?: string;
|
||||||
publicKey?: string;
|
publicKey?: string;
|
||||||
@@ -177,6 +433,22 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
|
|||||||
return errorResponse('Invalid JSON', 400);
|
return errorResponse('Invalid JSON', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Require password verification before allowing key replacement.
|
||||||
|
if (!body.masterPasswordHash) {
|
||||||
|
return errorResponse('masterPasswordHash is required', 400);
|
||||||
|
}
|
||||||
|
const passwordValid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash, user.email);
|
||||||
|
if (!passwordValid) {
|
||||||
|
return errorResponse('Invalid password', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.key && !looksLikeEncString(body.key)) {
|
||||||
|
return errorResponse('key is not a valid encrypted string', 400);
|
||||||
|
}
|
||||||
|
if (body.encryptedPrivateKey && !looksLikeEncString(body.encryptedPrivateKey)) {
|
||||||
|
return errorResponse('encryptedPrivateKey is not a valid encrypted string', 400);
|
||||||
|
}
|
||||||
|
|
||||||
if (body.key) user.key = body.key;
|
if (body.key) user.key = body.key;
|
||||||
if (body.encryptedPrivateKey) user.privateKey = body.encryptedPrivateKey;
|
if (body.encryptedPrivateKey) user.privateKey = body.encryptedPrivateKey;
|
||||||
if (body.publicKey) user.publicKey = body.publicKey;
|
if (body.publicKey) user.publicKey = body.publicKey;
|
||||||
@@ -187,8 +459,262 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
|
|||||||
return handleGetProfile(request, env, userId);
|
return handleGetProfile(request, env, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST/PUT /api/accounts/password
|
||||||
|
export async function handleChangePassword(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: {
|
||||||
|
masterPasswordHash?: string;
|
||||||
|
currentPasswordHash?: string;
|
||||||
|
newMasterPasswordHash?: string;
|
||||||
|
key?: string;
|
||||||
|
newKey?: string;
|
||||||
|
encryptedPrivateKey?: string;
|
||||||
|
newEncryptedPrivateKey?: string;
|
||||||
|
publicKey?: string;
|
||||||
|
newPublicKey?: string;
|
||||||
|
kdf?: number;
|
||||||
|
kdfIterations?: number;
|
||||||
|
kdfMemory?: number;
|
||||||
|
kdfParallelism?: number;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentHash = body.currentPasswordHash || body.masterPasswordHash;
|
||||||
|
if (!currentHash) return errorResponse('Current password hash is required', 400);
|
||||||
|
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
|
||||||
|
if (!valid) return errorResponse('Invalid password', 400);
|
||||||
|
|
||||||
|
if (!body.newMasterPasswordHash) {
|
||||||
|
return errorResponse('newMasterPasswordHash is required', 400);
|
||||||
|
}
|
||||||
|
const nextKey = body.newKey || body.key;
|
||||||
|
const nextPrivateKey = body.newEncryptedPrivateKey || body.encryptedPrivateKey;
|
||||||
|
const nextPublicKey = body.newPublicKey || body.publicKey;
|
||||||
|
if (nextKey && !looksLikeEncString(nextKey)) {
|
||||||
|
return errorResponse('new key is not a valid encrypted string', 400);
|
||||||
|
}
|
||||||
|
if (nextPrivateKey && !looksLikeEncString(nextPrivateKey)) {
|
||||||
|
return errorResponse('new encryptedPrivateKey is not a valid encrypted string', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const kdfErr = validateKdfParams(body.kdf ?? user.kdfType, body.kdfIterations, body.kdfMemory, body.kdfParallelism);
|
||||||
|
if (kdfErr) return errorResponse(kdfErr, 400);
|
||||||
|
|
||||||
|
user.masterPasswordHash = await auth.hashPasswordServer(body.newMasterPasswordHash, user.email);
|
||||||
|
if (nextKey) user.key = nextKey;
|
||||||
|
if (nextPrivateKey) user.privateKey = nextPrivateKey;
|
||||||
|
if (nextPublicKey) user.publicKey = nextPublicKey;
|
||||||
|
if (typeof body.kdf === 'number') user.kdfType = body.kdf;
|
||||||
|
if (typeof body.kdfIterations === 'number') user.kdfIterations = body.kdfIterations;
|
||||||
|
if (typeof body.kdfMemory === 'number') user.kdfMemory = body.kdfMemory;
|
||||||
|
if (typeof body.kdfParallelism === 'number') user.kdfParallelism = body.kdfParallelism;
|
||||||
|
user.securityStamp = generateUUID();
|
||||||
|
user.updatedAt = new Date().toISOString();
|
||||||
|
await storage.saveUser(user);
|
||||||
|
await storage.deleteRefreshTokensByUserId(user.id);
|
||||||
|
await storage.createAuditLog({
|
||||||
|
id: generateUUID(),
|
||||||
|
actorUserId: user.id,
|
||||||
|
action: 'user.password.change',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
metadata: JSON.stringify({ email: user.email }),
|
||||||
|
createdAt: user.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/accounts/totp
|
||||||
|
export async function handleGetTotpStatus(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const user = await storage.getUserById(userId);
|
||||||
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
enabled: !!user.totpSecret,
|
||||||
|
object: 'twoFactor',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/accounts/totp
|
||||||
|
// enable: { enabled: true, secret: "...", token: "123456" }
|
||||||
|
// disable: { enabled: false, masterPasswordHash: "..." }
|
||||||
|
export async function handleSetTotpStatus(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: { enabled?: boolean; secret?: string; token?: string; masterPasswordHash?: string };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.enabled === true) {
|
||||||
|
const normalizedSecret = normalizeTotpSecret(body.secret || '');
|
||||||
|
if (!isTotpEnabled(normalizedSecret)) {
|
||||||
|
return errorResponse('Invalid TOTP secret', 400);
|
||||||
|
}
|
||||||
|
if (!body.token) {
|
||||||
|
return errorResponse('TOTP token is required', 400);
|
||||||
|
}
|
||||||
|
const verified = await verifyTotpToken(normalizedSecret, body.token);
|
||||||
|
if (!verified) {
|
||||||
|
return errorResponse('Invalid TOTP token', 400);
|
||||||
|
}
|
||||||
|
user.totpSecret = normalizedSecret;
|
||||||
|
if (!user.totpRecoveryCode) {
|
||||||
|
user.totpRecoveryCode = createRecoveryCode();
|
||||||
|
}
|
||||||
|
user.updatedAt = new Date().toISOString();
|
||||||
|
await storage.saveUser(user);
|
||||||
|
await storage.deleteRefreshTokensByUserId(user.id);
|
||||||
|
return jsonResponse({ enabled: true, recoveryCode: user.totpRecoveryCode, object: 'twoFactor' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.enabled === false) {
|
||||||
|
if (!body.masterPasswordHash) {
|
||||||
|
return errorResponse('masterPasswordHash is required to disable TOTP', 400);
|
||||||
|
}
|
||||||
|
const valid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash, user.email);
|
||||||
|
if (!valid) return errorResponse('Invalid password', 400);
|
||||||
|
|
||||||
|
user.totpSecret = null;
|
||||||
|
user.updatedAt = new Date().toISOString();
|
||||||
|
await storage.saveUser(user);
|
||||||
|
await storage.deleteRefreshTokensByUserId(user.id);
|
||||||
|
return jsonResponse({ enabled: false, object: 'twoFactor' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return errorResponse('enabled must be true or false', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/accounts/totp/recovery-code
|
||||||
|
export async function handleGetTotpRecoveryCode(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: 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 (!user.totpRecoveryCode) {
|
||||||
|
user.totpRecoveryCode = createRecoveryCode();
|
||||||
|
user.updatedAt = new Date().toISOString();
|
||||||
|
await storage.saveUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
code: user.totpRecoveryCode,
|
||||||
|
object: 'twoFactorRecover',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /identity/accounts/recover-2fa
|
||||||
|
// Disable TOTP by recovery code + password, then rotate recovery code.
|
||||||
|
export async function handleRecoverTwoFactor(request: Request, env: Env): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const auth = new AuthService(env);
|
||||||
|
const rateLimit = new RateLimitService(env.DB);
|
||||||
|
|
||||||
|
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 email = String(body.email || body.username || '').trim().toLowerCase();
|
||||||
|
const masterPasswordHash = String(body.masterPasswordHash || body.password || '').trim();
|
||||||
|
const recoveryCode = normalizeRecoveryCodeInput(String(body.recoveryCode || body.twoFactorToken || body.recovery_code || ''));
|
||||||
|
const clientIdentifier = getClientIdentifier(request);
|
||||||
|
if (!clientIdentifier) {
|
||||||
|
return errorResponse('Client IP is required', 403);
|
||||||
|
}
|
||||||
|
const recoverLimitKey = `${clientIdentifier}:recover-2fa:${email || 'unknown'}`;
|
||||||
|
|
||||||
|
const recoverAttemptCheck = await rateLimit.checkLoginAttempt(recoverLimitKey);
|
||||||
|
if (!recoverAttemptCheck.allowed) {
|
||||||
|
return errorResponse(
|
||||||
|
`Too many failed recovery attempts. Try again in ${Math.ceil((recoverAttemptCheck.retryAfterSeconds || 60) / 60)} minutes.`,
|
||||||
|
429
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email || !masterPasswordHash || !recoveryCode) {
|
||||||
|
return errorResponse('Email, masterPasswordHash and recoveryCode are required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await storage.getUser(email);
|
||||||
|
if (!user || user.status !== 'active') {
|
||||||
|
await rateLimit.recordFailedLogin(recoverLimitKey);
|
||||||
|
return errorResponse('Invalid credentials or recovery code', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validPassword = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
|
||||||
|
if (!validPassword) {
|
||||||
|
await rateLimit.recordFailedLogin(recoverLimitKey);
|
||||||
|
return errorResponse('Invalid credentials or recovery code', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!recoveryCodeEquals(recoveryCode, user.totpRecoveryCode)) {
|
||||||
|
await rateLimit.recordFailedLogin(recoverLimitKey);
|
||||||
|
return errorResponse('Invalid credentials or recovery code', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.totpSecret = null;
|
||||||
|
user.totpRecoveryCode = createRecoveryCode();
|
||||||
|
user.securityStamp = generateUUID();
|
||||||
|
user.updatedAt = new Date().toISOString();
|
||||||
|
await storage.saveUser(user);
|
||||||
|
await storage.deleteRefreshTokensByUserId(user.id);
|
||||||
|
await rateLimit.clearLoginAttempts(recoverLimitKey);
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
success: true,
|
||||||
|
twoFactorEnabled: false,
|
||||||
|
newRecoveryCode: user.totpRecoveryCode,
|
||||||
|
object: 'twoFactorRecovery',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/accounts/revision-date
|
// GET /api/accounts/revision-date
|
||||||
export async function handleGetRevisionDate(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleGetRevisionDate(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
void request;
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const revisionDate = await storage.getRevisionDate(userId);
|
const revisionDate = await storage.getRevisionDate(userId);
|
||||||
|
|
||||||
@@ -200,6 +726,7 @@ export async function handleGetRevisionDate(request: Request, env: Env, userId:
|
|||||||
// POST /api/accounts/verify-password
|
// POST /api/accounts/verify-password
|
||||||
export async function handleVerifyPassword(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleVerifyPassword(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
|
const auth = new AuthService(env);
|
||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -217,7 +744,8 @@ export async function handleVerifyPassword(request: Request, env: Env, userId: s
|
|||||||
return errorResponse('masterPasswordHash is required', 400);
|
return errorResponse('masterPasswordHash is required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.masterPasswordHash !== user.masterPasswordHash) {
|
const valid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash, user.email);
|
||||||
|
if (!valid) {
|
||||||
return errorResponse('Invalid password', 400);
|
return errorResponse('Invalid password', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import { Env, User, Invite } from '../types';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
import { deleteBlobObject, getAttachmentObjectKey, getSendFileObjectKey } from '../services/blob-store';
|
||||||
|
|
||||||
|
function isAdmin(user: User): boolean {
|
||||||
|
return user.role === 'admin' && user.status === 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomHex(bytes: number): string {
|
||||||
|
const data = crypto.getRandomValues(new Uint8Array(bytes));
|
||||||
|
return Array.from(data).map(v => v.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInviteLink(request: Request, code: string): string {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
return `${url.origin}/?invite=${encodeURIComponent(code)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeAuditLog(
|
||||||
|
storage: StorageService,
|
||||||
|
actorUserId: string | null,
|
||||||
|
action: string,
|
||||||
|
targetType: string | null,
|
||||||
|
targetId: string | null,
|
||||||
|
metadata: Record<string, unknown> | null
|
||||||
|
): Promise<void> {
|
||||||
|
await storage.createAuditLog({
|
||||||
|
id: generateUUID(),
|
||||||
|
actorUserId,
|
||||||
|
action,
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
metadata: metadata ? JSON.stringify(metadata) : null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toInviteResponse(request: Request, invite: Invite): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
code: invite.code,
|
||||||
|
status: invite.status,
|
||||||
|
createdBy: invite.createdBy,
|
||||||
|
usedBy: invite.usedBy,
|
||||||
|
createdAt: invite.createdAt,
|
||||||
|
updatedAt: invite.updatedAt,
|
||||||
|
expiresAt: invite.expiresAt,
|
||||||
|
inviteLink: buildInviteLink(request, invite.code),
|
||||||
|
object: 'invite',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/admin/users
|
||||||
|
export async function handleAdminListUsers(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
actorUser: User
|
||||||
|
): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
if (!isAdmin(actorUser)) {
|
||||||
|
return errorResponse('Forbidden', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const users = await storage.getAllUsers();
|
||||||
|
return jsonResponse({
|
||||||
|
data: users.map(user => ({
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
status: user.status,
|
||||||
|
twoFactorEnabled: !!user.totpSecret,
|
||||||
|
creationDate: user.createdAt,
|
||||||
|
revisionDate: user.updatedAt,
|
||||||
|
object: 'user',
|
||||||
|
})),
|
||||||
|
object: 'list',
|
||||||
|
continuationToken: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/admin/invites
|
||||||
|
export async function handleAdminCreateInvite(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
actorUser: User
|
||||||
|
): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) {
|
||||||
|
return errorResponse('Forbidden', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
let body: { expiresInHours?: number } = {};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
body = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresInHours = Number.isFinite(body.expiresInHours)
|
||||||
|
? Math.max(1, Math.min(24 * 30, Math.floor(Number(body.expiresInHours))))
|
||||||
|
: 24 * 7;
|
||||||
|
const now = new Date();
|
||||||
|
const expiresAt = new Date(now.getTime() + expiresInHours * 60 * 60 * 1000);
|
||||||
|
const invite: Invite = {
|
||||||
|
code: randomHex(20),
|
||||||
|
createdBy: actorUser.id,
|
||||||
|
usedBy: null,
|
||||||
|
expiresAt: expiresAt.toISOString(),
|
||||||
|
status: 'active',
|
||||||
|
createdAt: now.toISOString(),
|
||||||
|
updatedAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await storage.createInvite(invite);
|
||||||
|
await writeAuditLog(storage, actorUser.id, 'admin.invite.create', 'invite', invite.code, {
|
||||||
|
expiresInHours,
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse(toInviteResponse(request, invite), 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/admin/invites
|
||||||
|
export async function handleAdminListInvites(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
actorUser: User
|
||||||
|
): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) {
|
||||||
|
return errorResponse('Forbidden', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const includeInactive = url.searchParams.get('includeInactive') === 'true';
|
||||||
|
const invites = await storage.listInvites(includeInactive);
|
||||||
|
return jsonResponse({
|
||||||
|
data: invites.map(invite => toInviteResponse(request, invite)),
|
||||||
|
object: 'list',
|
||||||
|
continuationToken: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/admin/invites/:code
|
||||||
|
export async function handleAdminRevokeInvite(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
actorUser: User,
|
||||||
|
code: string
|
||||||
|
): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) {
|
||||||
|
return errorResponse('Forbidden', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const revoked = await storage.revokeInvite(code);
|
||||||
|
if (!revoked) {
|
||||||
|
return errorResponse('Invite not found or already inactive', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeAuditLog(storage, actorUser.id, 'admin.invite.revoke', 'invite', code, null);
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/admin/invites
|
||||||
|
export async function handleAdminDeleteAllInvites(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
actorUser: User
|
||||||
|
): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
if (!isAdmin(actorUser)) {
|
||||||
|
return errorResponse('Forbidden', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const deleted = await storage.deleteAllInvites();
|
||||||
|
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_all', 'invite', null, {
|
||||||
|
deleted,
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse({ deleted }, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/admin/users/:id/status
|
||||||
|
export async function handleAdminSetUserStatus(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
actorUser: User,
|
||||||
|
targetUserId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) {
|
||||||
|
return errorResponse('Forbidden', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { status?: string };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStatus = body.status === 'banned' ? 'banned' : body.status === 'active' ? 'active' : null;
|
||||||
|
if (!nextStatus) {
|
||||||
|
return errorResponse('status must be active or banned', 400);
|
||||||
|
}
|
||||||
|
if (targetUserId === actorUser.id && nextStatus !== 'active') {
|
||||||
|
return errorResponse('You cannot ban yourself', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const target = await storage.getUserById(targetUserId);
|
||||||
|
if (!target) {
|
||||||
|
return errorResponse('User not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
target.status = nextStatus;
|
||||||
|
target.updatedAt = new Date().toISOString();
|
||||||
|
await storage.saveUser(target);
|
||||||
|
if (nextStatus === 'banned') {
|
||||||
|
await storage.deleteRefreshTokensByUserId(target.id);
|
||||||
|
}
|
||||||
|
await writeAuditLog(storage, actorUser.id, 'admin.user.status', 'user', target.id, {
|
||||||
|
status: nextStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
id: target.id,
|
||||||
|
email: target.email,
|
||||||
|
role: target.role,
|
||||||
|
status: target.status,
|
||||||
|
object: 'user',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/admin/users/:id
|
||||||
|
export async function handleAdminDeleteUser(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
actorUser: User,
|
||||||
|
targetUserId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
if (!isAdmin(actorUser)) {
|
||||||
|
return errorResponse('Forbidden', 403);
|
||||||
|
}
|
||||||
|
if (targetUserId === actorUser.id) {
|
||||||
|
return errorResponse('You cannot delete yourself', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const target = await storage.getUserById(targetUserId);
|
||||||
|
if (!target) {
|
||||||
|
return errorResponse('User not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up R2 files before DB cascade deletes the metadata rows.
|
||||||
|
// 1. Attachment files (keyed by cipherId/attachmentId)
|
||||||
|
const attachmentMap = await storage.getAttachmentsByUserId(target.id);
|
||||||
|
for (const [cipherId, attachments] of attachmentMap) {
|
||||||
|
for (const att of attachments) {
|
||||||
|
await deleteBlobObject(env, getAttachmentObjectKey(cipherId, att.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. Send files (keyed by sends/sendId/fileId)
|
||||||
|
const sends = await storage.getAllSends(target.id);
|
||||||
|
for (const send of sends) {
|
||||||
|
if (send.type === 1) { // SendType.File
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(send.data) as Record<string, unknown>;
|
||||||
|
const fileId = typeof parsed.id === 'string' ? parsed.id : null;
|
||||||
|
if (fileId) {
|
||||||
|
await deleteBlobObject(env, getSendFileObjectKey(send.id, fileId));
|
||||||
|
}
|
||||||
|
} catch { /* non-file send or bad data, skip */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.deleteRefreshTokensByUserId(target.id);
|
||||||
|
await storage.deleteUserById(target.id);
|
||||||
|
await writeAuditLog(storage, actorUser.id, 'admin.user.delete', 'user', target.id, {
|
||||||
|
email: target.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
@@ -1,9 +1,34 @@
|
|||||||
import { Env, Attachment } from '../types';
|
import { Env, Attachment, DEFAULT_DEV_SECRET } from '../types';
|
||||||
|
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
|
import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt';
|
import {
|
||||||
|
createAttachmentUploadToken,
|
||||||
|
createFileDownloadToken,
|
||||||
|
verifyAttachmentUploadToken,
|
||||||
|
verifyFileDownloadToken,
|
||||||
|
} from '../utils/jwt';
|
||||||
import { cipherToResponse } from './ciphers';
|
import { cipherToResponse } from './ciphers';
|
||||||
|
import { LIMITS } from '../config/limits';
|
||||||
|
import { readActingDeviceIdentifier } from '../utils/device';
|
||||||
|
import {
|
||||||
|
deleteBlobObject,
|
||||||
|
getAttachmentObjectKey,
|
||||||
|
getBlobObject,
|
||||||
|
getBlobStorageMaxBytes,
|
||||||
|
putBlobObject,
|
||||||
|
} from '../services/blob-store';
|
||||||
|
|
||||||
|
async function notifyVaultSyncForRequest(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
revisionDate: string
|
||||||
|
): Promise<void> {
|
||||||
|
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||||
|
}
|
||||||
|
|
||||||
// Format file size to human readable
|
// Format file size to human readable
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
@@ -13,9 +38,65 @@ function formatSize(bytes: number): string {
|
|||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get R2 object path for attachment
|
async function runWithConcurrency<T>(
|
||||||
function getAttachmentPath(cipherId: string, attachmentId: string): string {
|
items: T[],
|
||||||
return `${cipherId}/${attachmentId}`;
|
concurrency: number,
|
||||||
|
worker: (item: T) => Promise<void>
|
||||||
|
): Promise<void> {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
const limit = Math.max(1, concurrency);
|
||||||
|
for (let index = 0; index < items.length; index += limit) {
|
||||||
|
await Promise.all(items.slice(index, index + limit).map(worker));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processAttachmentUpload(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
attachment: Attachment,
|
||||||
|
cipherId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.attachment.maxFileSizeBytes);
|
||||||
|
const upload = await parseDirectUploadPayload(request, {
|
||||||
|
expectedSize: Number(attachment.size) || 0,
|
||||||
|
maxFileSize,
|
||||||
|
tooLargeMessage: `File too large. Maximum size is ${Math.floor(maxFileSize / (1024 * 1024))}MB`,
|
||||||
|
});
|
||||||
|
if (upload instanceof Response) {
|
||||||
|
return upload;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = getAttachmentObjectKey(cipherId, attachment.id);
|
||||||
|
try {
|
||||||
|
await putBlobObject(env, path, upload.body, {
|
||||||
|
size: upload.size,
|
||||||
|
contentType: upload.contentType,
|
||||||
|
customMetadata: {
|
||||||
|
cipherId,
|
||||||
|
attachmentId: attachment.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (message.includes('KV object too large')) {
|
||||||
|
return errorResponse(`File too large. Maximum size is ${Math.floor(maxFileSize / (1024 * 1024))}MB`, 413);
|
||||||
|
}
|
||||||
|
return errorResponse('Attachment storage is not configured', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upload.size !== attachment.size) {
|
||||||
|
attachment.size = upload.size;
|
||||||
|
attachment.sizeName = formatSize(upload.size);
|
||||||
|
await storage.saveAttachment(attachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||||
|
if (revisionInfo) {
|
||||||
|
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/ciphers/{cipherId}/attachment/v2
|
// POST /api/ciphers/{cipherId}/attachment/v2
|
||||||
@@ -70,24 +151,29 @@ export async function handleCreateAttachment(
|
|||||||
await storage.addAttachmentToCipher(cipherId, attachmentId);
|
await storage.addAttachmentToCipher(cipherId, attachmentId);
|
||||||
|
|
||||||
// Update cipher revision date
|
// Update cipher revision date
|
||||||
await storage.updateCipherRevisionDate(cipherId);
|
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||||
|
if (revisionInfo) {
|
||||||
|
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
// Get updated cipher for response
|
// Get updated cipher for response
|
||||||
const updatedCipher = await storage.getCipher(cipherId);
|
const updatedCipher = await storage.getCipher(cipherId);
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||||
|
const jwtSecret = getSafeJwtSecret(env);
|
||||||
|
if (!jwtSecret) {
|
||||||
|
return errorResponse('Server configuration error', 500);
|
||||||
|
}
|
||||||
|
const uploadToken = await createAttachmentUploadToken(userId, cipherId, attachmentId, jwtSecret);
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
object: 'attachment-fileUpload',
|
object: 'attachment-fileUpload',
|
||||||
attachmentId: attachmentId,
|
attachmentId: attachmentId,
|
||||||
url: `/api/ciphers/${cipherId}/attachment/${attachmentId}`,
|
url: buildDirectUploadUrl(request, `/api/ciphers/${cipherId}/attachment/${attachmentId}`, uploadToken),
|
||||||
fileUploadType: 0, // Direct upload
|
fileUploadType: 1,
|
||||||
cipherResponse: cipherToResponse(updatedCipher!, attachments),
|
cipherResponse: cipherToResponse(updatedCipher!, attachments),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maximum file size: 100MB
|
|
||||||
const MAX_FILE_SIZE = 100 * 1024 * 1024;
|
|
||||||
|
|
||||||
// POST /api/ciphers/{cipherId}/attachment/{attachmentId}
|
// POST /api/ciphers/{cipherId}/attachment/{attachmentId}
|
||||||
// Upload attachment file content
|
// Upload attachment file content
|
||||||
export async function handleUploadAttachment(
|
export async function handleUploadAttachment(
|
||||||
@@ -111,54 +197,45 @@ export async function handleUploadAttachment(
|
|||||||
return errorResponse('Attachment not found', 404);
|
return errorResponse('Attachment not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check content-length header for size limit
|
return processAttachmentUpload(request, env, attachment, cipherId);
|
||||||
const contentLength = request.headers.get('content-length');
|
|
||||||
if (contentLength && parseInt(contentLength) > MAX_FILE_SIZE) {
|
|
||||||
return errorResponse('File too large. Maximum size is 100MB', 413);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the file from multipart form data
|
export async function handlePublicUploadAttachment(
|
||||||
const contentType = request.headers.get('content-type') || '';
|
request: Request,
|
||||||
if (!contentType.includes('multipart/form-data')) {
|
env: Env,
|
||||||
return errorResponse('Content-Type must be multipart/form-data', 400);
|
cipherId: string,
|
||||||
|
attachmentId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const jwtSecret = getSafeJwtSecret(env);
|
||||||
|
if (!jwtSecret) {
|
||||||
|
return errorResponse('Server configuration error', 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
const token = new URL(request.url).searchParams.get('token');
|
||||||
const file = formData.get('data') as File | null;
|
if (!token) {
|
||||||
|
return errorResponse('Token required', 401);
|
||||||
if (!file) {
|
|
||||||
return errorResponse('No file uploaded', 400);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check actual file size
|
const claims = await verifyAttachmentUploadToken(token, jwtSecret);
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
if (!claims) {
|
||||||
return errorResponse('File too large. Maximum size is 100MB', 413);
|
return errorResponse('Invalid or expired token', 401);
|
||||||
|
}
|
||||||
|
if (claims.cipherId !== cipherId || claims.attachmentId !== attachmentId) {
|
||||||
|
return errorResponse('Token mismatch', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store file in R2
|
const storage = new StorageService(env.DB);
|
||||||
const path = getAttachmentPath(cipherId, attachmentId);
|
const cipher = await storage.getCipher(cipherId);
|
||||||
await env.ATTACHMENTS.put(path, file.stream(), {
|
if (!cipher || cipher.userId !== claims.userId) {
|
||||||
httpMetadata: {
|
return errorResponse('Cipher not found', 404);
|
||||||
contentType: 'application/octet-stream',
|
|
||||||
},
|
|
||||||
customMetadata: {
|
|
||||||
cipherId: cipherId,
|
|
||||||
attachmentId: attachmentId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update attachment size if different
|
|
||||||
const actualSize = file.size;
|
|
||||||
if (actualSize !== attachment.size) {
|
|
||||||
attachment.size = actualSize;
|
|
||||||
attachment.sizeName = formatSize(actualSize);
|
|
||||||
await storage.saveAttachment(attachment);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update cipher revision date
|
const attachment = await storage.getAttachment(attachmentId);
|
||||||
await storage.updateCipherRevisionDate(cipherId);
|
if (!attachment || attachment.cipherId !== cipherId) {
|
||||||
|
return errorResponse('Attachment not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 200 });
|
return processAttachmentUpload(request, env, attachment, cipherId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/ciphers/{cipherId}/attachment/{attachmentId}
|
// GET /api/ciphers/{cipherId}/attachment/{attachmentId}
|
||||||
@@ -197,7 +274,7 @@ export async function handleGetAttachment(
|
|||||||
url: downloadUrl,
|
url: downloadUrl,
|
||||||
fileName: attachment.fileName,
|
fileName: attachment.fileName,
|
||||||
key: attachment.key,
|
key: attachment.key,
|
||||||
size: Number(attachment.size) || 0,
|
size: String(Number(attachment.size) || 0),
|
||||||
sizeName: attachment.sizeName,
|
sizeName: attachment.sizeName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -210,6 +287,11 @@ export async function handlePublicDownloadAttachment(
|
|||||||
cipherId: string,
|
cipherId: string,
|
||||||
attachmentId: string
|
attachmentId: string
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
|
const secret = (env.JWT_SECRET || '').trim();
|
||||||
|
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
|
||||||
|
return errorResponse('Server configuration error', 500);
|
||||||
|
}
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const token = url.searchParams.get('token');
|
const token = url.searchParams.get('token');
|
||||||
|
|
||||||
@@ -230,27 +312,29 @@ export async function handlePublicDownloadAttachment(
|
|||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
|
|
||||||
// Verify attachment exists
|
// Verify attachment exists
|
||||||
const attachment = await storage.getAttachment(attachmentId);
|
const attachment = await storage.getAttachment(attachmentId);
|
||||||
if (!attachment || attachment.cipherId !== cipherId) {
|
if (!attachment || attachment.cipherId !== cipherId) {
|
||||||
return errorResponse('Attachment not found', 404);
|
return errorResponse('Attachment not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get file from R2
|
const path = getAttachmentObjectKey(cipherId, attachmentId);
|
||||||
const path = getAttachmentPath(cipherId, attachmentId);
|
const object = await getBlobObject(env, path);
|
||||||
const object = await env.ATTACHMENTS.get(path);
|
|
||||||
|
|
||||||
if (!object) {
|
if (!object) {
|
||||||
return errorResponse('Attachment file not found', 404);
|
return errorResponse('Attachment file not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const firstUse = await storage.consumeAttachmentDownloadToken(claims.jti, claims.exp);
|
||||||
|
if (!firstUse) {
|
||||||
|
return errorResponse('Invalid or expired token', 401);
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(object.body, {
|
return new Response(object.body, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/octet-stream',
|
'Content-Type': object.contentType || 'application/octet-stream',
|
||||||
'Content-Length': String(object.size),
|
'Content-Length': String(object.size),
|
||||||
'Cache-Control': 'private, no-cache',
|
'Cache-Control': 'private, no-cache',
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -278,9 +362,8 @@ export async function handleDeleteAttachment(
|
|||||||
return errorResponse('Attachment not found', 404);
|
return errorResponse('Attachment not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete file from R2
|
const path = getAttachmentObjectKey(cipherId, attachmentId);
|
||||||
const path = getAttachmentPath(cipherId, attachmentId);
|
await deleteBlobObject(env, path);
|
||||||
await env.ATTACHMENTS.delete(path);
|
|
||||||
|
|
||||||
// Delete attachment metadata
|
// Delete attachment metadata
|
||||||
await storage.deleteAttachment(attachmentId);
|
await storage.deleteAttachment(attachmentId);
|
||||||
@@ -289,7 +372,10 @@ export async function handleDeleteAttachment(
|
|||||||
await storage.removeAttachmentFromCipher(cipherId, attachmentId);
|
await storage.removeAttachmentFromCipher(cipherId, attachmentId);
|
||||||
|
|
||||||
// Update cipher revision date
|
// Update cipher revision date
|
||||||
await storage.updateCipherRevisionDate(cipherId);
|
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||||
|
if (revisionInfo) {
|
||||||
|
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
// Get updated cipher for response
|
// Get updated cipher for response
|
||||||
const updatedCipher = await storage.getCipher(cipherId);
|
const updatedCipher = await storage.getCipher(cipherId);
|
||||||
@@ -307,10 +393,9 @@ export async function deleteAllAttachmentsForCipher(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||||
|
await runWithConcurrency(attachments, LIMITS.performance.attachmentDeleteConcurrency, async (attachment) => {
|
||||||
for (const attachment of attachments) {
|
const path = getAttachmentObjectKey(cipherId, attachment.id);
|
||||||
const path = getAttachmentPath(cipherId, attachment.id);
|
await deleteBlobObject(env, path);
|
||||||
await env.ATTACHMENTS.delete(path);
|
|
||||||
await storage.deleteAttachment(attachment.id);
|
await storage.deleteAttachment(attachment.id);
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,878 @@
|
|||||||
|
import type { Env, User } from '../types';
|
||||||
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
import {
|
||||||
|
type BackupArchiveBundle,
|
||||||
|
buildBackupArchive,
|
||||||
|
inspectBackupArchiveFileNameChecksum,
|
||||||
|
verifyBackupArchiveFileNameChecksum,
|
||||||
|
} from '../services/backup-archive';
|
||||||
|
import {
|
||||||
|
type BackupDestinationRecord,
|
||||||
|
type BackupSettingsInput,
|
||||||
|
BACKUP_SCHEDULER_WINDOW_MINUTES,
|
||||||
|
getBackupLocalDateKey,
|
||||||
|
getDefaultBackupSettings,
|
||||||
|
getBackupSettingsRepairState,
|
||||||
|
isBackupDueNow,
|
||||||
|
loadBackupSettings,
|
||||||
|
normalizeBackupSettingsInput,
|
||||||
|
normalizeImportedBackupSettings,
|
||||||
|
repairBackupSettings,
|
||||||
|
requireBackupDestination,
|
||||||
|
saveBackupSettings,
|
||||||
|
} from '../services/backup-config';
|
||||||
|
import {
|
||||||
|
type BackupImportExecutionResult,
|
||||||
|
type BackupRestoreProgressReporter,
|
||||||
|
importBackupArchiveBytes,
|
||||||
|
importRemoteBackupArchiveBytes,
|
||||||
|
} from '../services/backup-import';
|
||||||
|
import {
|
||||||
|
type RemoteBackupTransferSession,
|
||||||
|
createRemoteBackupTransferSession,
|
||||||
|
deleteRemoteBackupFile,
|
||||||
|
downloadRemoteBackupFile,
|
||||||
|
ensureRemoteRestoreCandidate,
|
||||||
|
listRemoteBackupEntries,
|
||||||
|
pruneRemoteBackupArchives,
|
||||||
|
uploadBackupArchive,
|
||||||
|
} from '../services/backup-uploader';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import { getBlobObject } from '../services/blob-store';
|
||||||
|
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
|
||||||
|
|
||||||
|
function isAdmin(user: User): boolean {
|
||||||
|
return user.role === 'admin' && user.status === 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeAuditLog(
|
||||||
|
storage: StorageService,
|
||||||
|
actorUserId: string | null,
|
||||||
|
action: string,
|
||||||
|
targetType: string | null,
|
||||||
|
targetId: string | null,
|
||||||
|
metadata: Record<string, unknown> | null
|
||||||
|
): Promise<void> {
|
||||||
|
await storage.createAuditLog({
|
||||||
|
id: generateUUID(),
|
||||||
|
actorUserId,
|
||||||
|
action,
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
metadata: metadata ? JSON.stringify(metadata) : null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackupDestinationSummary(destination: BackupDestinationRecord | null): Record<string, unknown> {
|
||||||
|
if (!destination) {
|
||||||
|
return {
|
||||||
|
destinationId: null,
|
||||||
|
destinationName: null,
|
||||||
|
destinationType: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
destinationId: destination.id,
|
||||||
|
destinationName: destination.name,
|
||||||
|
destinationType: destination.type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureBackupBlobName(value: string): string {
|
||||||
|
const normalized = String(value || '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error('Backup attachment blob is required');
|
||||||
|
}
|
||||||
|
const parts = normalized.split('/').filter(Boolean);
|
||||||
|
if (!parts.length || parts.some((part) => part === '.' || part === '..')) {
|
||||||
|
throw new Error('Backup attachment blob is invalid');
|
||||||
|
}
|
||||||
|
return parts.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const REMOTE_ATTACHMENT_INDEX_PATH = 'attachments/.nodewarden-attachment-index.v1.json';
|
||||||
|
|
||||||
|
interface RemoteAttachmentIndexPayload {
|
||||||
|
version: 1;
|
||||||
|
blobs: Record<string, { sizeBytes: number; updatedAt: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRemoteAttachmentIndex(session: RemoteBackupTransferSession): Promise<Map<string, number>> {
|
||||||
|
try {
|
||||||
|
const file = await session.download(REMOTE_ATTACHMENT_INDEX_PATH);
|
||||||
|
const payload = JSON.parse(new TextDecoder().decode(file.bytes)) as RemoteAttachmentIndexPayload;
|
||||||
|
if (payload?.version !== 1 || !payload.blobs || typeof payload.blobs !== 'object') {
|
||||||
|
return new Map<string, number>();
|
||||||
|
}
|
||||||
|
return new Map(
|
||||||
|
Object.entries(payload.blobs)
|
||||||
|
.filter(([key, value]) => !!String(key || '').trim() && Number.isFinite(Number(value?.sizeBytes || 0)))
|
||||||
|
.map(([key, value]) => [key, Number(value.sizeBytes || 0)])
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
const normalized = message.toLowerCase();
|
||||||
|
// Some WebDAV providers return non-standard codes such as 530 when the
|
||||||
|
// attachment index does not exist yet. Treat these "missing file" style
|
||||||
|
// responses as an empty index so first-time incremental backups can proceed.
|
||||||
|
if (
|
||||||
|
normalized.includes('404')
|
||||||
|
|| normalized.includes('403')
|
||||||
|
|| normalized.includes('530')
|
||||||
|
|| normalized.includes('not found')
|
||||||
|
|| normalized.includes('file not found')
|
||||||
|
|| normalized.includes('does not exist')
|
||||||
|
|| normalized.includes('please select a backup file')
|
||||||
|
) {
|
||||||
|
return new Map<string, number>();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRemoteAttachmentIndex(
|
||||||
|
session: RemoteBackupTransferSession,
|
||||||
|
index: Map<string, number>
|
||||||
|
): Promise<void> {
|
||||||
|
const payload: RemoteAttachmentIndexPayload = {
|
||||||
|
version: 1,
|
||||||
|
blobs: Object.fromEntries(
|
||||||
|
Array.from(index.entries()).map(([blobName, sizeBytes]) => [
|
||||||
|
blobName,
|
||||||
|
{
|
||||||
|
sizeBytes,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const bytes = new TextEncoder().encode(JSON.stringify(payload));
|
||||||
|
await session.putFile(REMOTE_ATTACHMENT_INDEX_PATH, bytes, {
|
||||||
|
contentType: 'application/json; charset=utf-8',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeConfiguredBackup(
|
||||||
|
env: Env,
|
||||||
|
storage: StorageService,
|
||||||
|
actorUserId: string | null,
|
||||||
|
trigger: 'manual' | 'scheduled',
|
||||||
|
destinationId?: string | null,
|
||||||
|
progress?: ((event: {
|
||||||
|
operation: 'backup-remote-run';
|
||||||
|
step: string;
|
||||||
|
fileName: string;
|
||||||
|
stageTitle: string;
|
||||||
|
stageDetail: string;
|
||||||
|
done?: boolean;
|
||||||
|
ok?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
}) => Promise<void>) | null
|
||||||
|
): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> {
|
||||||
|
const maxArchiveUploadAttempts = 3;
|
||||||
|
const currentSettings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
|
const destination = requireBackupDestination(currentSettings, destinationId);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
destination.runtime.lastAttemptAt = now.toISOString();
|
||||||
|
destination.runtime.lastAttemptLocalDate = getBackupLocalDateKey(now, destination.schedule.timezone);
|
||||||
|
destination.runtime.lastErrorAt = null;
|
||||||
|
destination.runtime.lastErrorMessage = null;
|
||||||
|
await saveBackupSettings(storage, env, currentSettings);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await progress?.({
|
||||||
|
operation: 'backup-remote-run',
|
||||||
|
step: 'remote_run_prepare',
|
||||||
|
fileName: '',
|
||||||
|
stageTitle: 'txt_backup_remote_run_progress_prepare_title',
|
||||||
|
stageDetail: 'txt_backup_remote_run_progress_prepare_detail',
|
||||||
|
});
|
||||||
|
const archive = await buildBackupArchive(env, now, {
|
||||||
|
includeAttachments: destination.includeAttachments,
|
||||||
|
timeZone: destination.schedule.timezone,
|
||||||
|
progress: progress
|
||||||
|
? async (event) => {
|
||||||
|
if (event.step === 'archive_ready') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await progress({
|
||||||
|
operation: 'backup-remote-run',
|
||||||
|
step: `remote_run_${event.step}`,
|
||||||
|
fileName: event.fileName || '',
|
||||||
|
stageTitle: event.stageTitle,
|
||||||
|
stageDetail: event.stageDetail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
await progress?.({
|
||||||
|
operation: 'backup-remote-run',
|
||||||
|
step: 'remote_run_sync_attachments',
|
||||||
|
fileName: archive.fileName,
|
||||||
|
stageTitle: 'txt_backup_remote_run_progress_sync_attachments_title',
|
||||||
|
stageDetail: destination.includeAttachments
|
||||||
|
? 'txt_backup_remote_run_progress_sync_attachments_detail'
|
||||||
|
: 'txt_backup_remote_run_progress_sync_attachments_skipped_detail',
|
||||||
|
});
|
||||||
|
const remoteSession = createRemoteBackupTransferSession(destination);
|
||||||
|
const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession);
|
||||||
|
let attachmentIndexChanged = false;
|
||||||
|
for (const attachment of archive.manifest.attachmentBlobs || []) {
|
||||||
|
if (remoteAttachmentIndex.get(attachment.blobName) === attachment.sizeBytes) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const remotePath = `attachments/${attachment.blobName}`;
|
||||||
|
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 saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex);
|
||||||
|
}
|
||||||
|
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
|
||||||
|
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
|
||||||
|
await progress?.({
|
||||||
|
operation: 'backup-remote-run',
|
||||||
|
step: 'remote_run_upload_archive',
|
||||||
|
fileName: archive.fileName,
|
||||||
|
stageTitle: 'txt_backup_remote_run_progress_upload_title',
|
||||||
|
stageDetail: 'txt_backup_remote_run_progress_upload_detail',
|
||||||
|
});
|
||||||
|
upload = await remoteSession.uploadArchive(archive.bytes, archive.fileName);
|
||||||
|
try {
|
||||||
|
await progress?.({
|
||||||
|
operation: 'backup-remote-run',
|
||||||
|
step: 'remote_run_verify_archive',
|
||||||
|
fileName: archive.fileName,
|
||||||
|
stageTitle: 'txt_backup_remote_run_progress_verify_title',
|
||||||
|
stageDetail: 'txt_backup_remote_run_progress_verify_detail',
|
||||||
|
});
|
||||||
|
const remoteFile = await remoteSession.download(archive.fileName);
|
||||||
|
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, archive.fileName);
|
||||||
|
if (!checksumOk) {
|
||||||
|
throw new Error('Remote backup ZIP checksum verification failed');
|
||||||
|
}
|
||||||
|
if (remoteFile.bytes.byteLength !== archive.bytes.byteLength) {
|
||||||
|
throw new Error('Remote backup ZIP size verification failed');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
await remoteSession.deleteFile(archive.fileName).catch(() => undefined);
|
||||||
|
if (attempt === maxArchiveUploadAttempts) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Remote backup ZIP verification failed';
|
||||||
|
throw new Error(`Backup archive upload verification failed after ${maxArchiveUploadAttempts} attempts: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!upload) {
|
||||||
|
throw new Error('Backup archive upload failed');
|
||||||
|
}
|
||||||
|
let prunedFileCount = 0;
|
||||||
|
let pruneErrorMessage: string | null = null;
|
||||||
|
try {
|
||||||
|
await progress?.({
|
||||||
|
operation: 'backup-remote-run',
|
||||||
|
step: 'remote_run_cleanup',
|
||||||
|
fileName: archive.fileName,
|
||||||
|
stageTitle: 'txt_backup_remote_run_progress_cleanup_title',
|
||||||
|
stageDetail: 'txt_backup_remote_run_progress_cleanup_detail',
|
||||||
|
});
|
||||||
|
prunedFileCount = await pruneRemoteBackupArchives(destination, destination.schedule.retentionCount, archive.fileName);
|
||||||
|
} catch (error) {
|
||||||
|
pruneErrorMessage = error instanceof Error ? error.message : 'Old backup cleanup failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
destination.runtime.lastSuccessAt = new Date().toISOString();
|
||||||
|
destination.runtime.lastErrorAt = null;
|
||||||
|
destination.runtime.lastErrorMessage = null;
|
||||||
|
destination.runtime.lastUploadedFileName = archive.fileName;
|
||||||
|
destination.runtime.lastUploadedSizeBytes = archive.bytes.byteLength;
|
||||||
|
destination.runtime.lastUploadedDestination = upload.remotePath;
|
||||||
|
await saveBackupSettings(storage, env, currentSettings);
|
||||||
|
|
||||||
|
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, {
|
||||||
|
...getBackupDestinationSummary(destination),
|
||||||
|
provider: upload.provider,
|
||||||
|
remotePath: upload.remotePath,
|
||||||
|
fileName: archive.fileName,
|
||||||
|
fileBytes: archive.bytes.byteLength,
|
||||||
|
uploadVerificationAttempts: maxArchiveUploadAttempts,
|
||||||
|
prunedFileCount,
|
||||||
|
pruneError: pruneErrorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
await progress?.({
|
||||||
|
operation: 'backup-remote-run',
|
||||||
|
step: 'remote_run_complete',
|
||||||
|
fileName: archive.fileName,
|
||||||
|
stageTitle: 'txt_backup_remote_run_progress_complete_title',
|
||||||
|
stageDetail: 'txt_backup_remote_run_progress_complete_detail',
|
||||||
|
done: true,
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileName: archive.fileName,
|
||||||
|
fileSize: archive.bytes.byteLength,
|
||||||
|
remotePath: upload.remotePath,
|
||||||
|
provider: upload.provider,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
destination.runtime.lastErrorAt = new Date().toISOString();
|
||||||
|
destination.runtime.lastErrorMessage = error instanceof Error ? error.message : 'Backup upload failed';
|
||||||
|
await saveBackupSettings(storage, env, currentSettings);
|
||||||
|
|
||||||
|
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, {
|
||||||
|
...getBackupDestinationSummary(destination),
|
||||||
|
error: destination.runtime.lastErrorMessage,
|
||||||
|
});
|
||||||
|
await progress?.({
|
||||||
|
operation: 'backup-remote-run',
|
||||||
|
step: 'remote_run_failed',
|
||||||
|
fileName: '',
|
||||||
|
stageTitle: 'txt_backup_remote_run_progress_failed_title',
|
||||||
|
stageDetail: 'txt_backup_remote_run_progress_failed_detail',
|
||||||
|
done: true,
|
||||||
|
ok: false,
|
||||||
|
error: destination.runtime.lastErrorMessage,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toImportStatusCode(message: string): number {
|
||||||
|
const lower = message.toLowerCase();
|
||||||
|
if (lower.includes('invalid backup') || lower.includes('invalid json')) return 400;
|
||||||
|
if (lower.includes('fresh instance')) return 409;
|
||||||
|
if (lower.includes('not configured') || lower.includes('kv')) return 409;
|
||||||
|
return 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runImportAndAudit(
|
||||||
|
env: Env,
|
||||||
|
request: Request,
|
||||||
|
actorUser: User,
|
||||||
|
archiveBytes: Uint8Array,
|
||||||
|
fileName: string,
|
||||||
|
replaceExisting: boolean,
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
): Promise<BackupImportExecutionResult> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
||||||
|
const progress: BackupRestoreProgressReporter = async (event) => {
|
||||||
|
await notifyUserBackupRestoreProgress(
|
||||||
|
env,
|
||||||
|
actorUser.id,
|
||||||
|
{
|
||||||
|
operation: 'backup-restore',
|
||||||
|
...event,
|
||||||
|
},
|
||||||
|
targetDeviceIdentifier
|
||||||
|
);
|
||||||
|
};
|
||||||
|
await progress({
|
||||||
|
source: 'local',
|
||||||
|
step: 'local_upload_received',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_local_upload_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_local_upload_detail',
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
const imported = await importBackupArchiveBytes(archiveBytes, env, actorUser.id, replaceExisting, progress, fileName);
|
||||||
|
await writeAuditLog(storage, imported.auditActorUserId, 'admin.backup.import', 'backup', null, {
|
||||||
|
users: imported.result.imported.users,
|
||||||
|
ciphers: imported.result.imported.ciphers,
|
||||||
|
attachments: imported.result.imported.attachmentFiles,
|
||||||
|
skippedAttachments: imported.result.skipped.attachments,
|
||||||
|
skippedReason: imported.result.skipped.reason,
|
||||||
|
replaceExisting,
|
||||||
|
...metadata,
|
||||||
|
});
|
||||||
|
return imported;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runScheduledBackupIfDue(env: Env): Promise<void> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
|
const now = new Date();
|
||||||
|
for (const destination of settings.destinations) {
|
||||||
|
if (!isBackupDueNow(destination, now, BACKUP_SCHEDULER_WINDOW_MINUTES)) continue;
|
||||||
|
await executeConfiguredBackup(env, storage, null, 'scheduled', destination.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
try {
|
||||||
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
|
return jsonResponse(settings);
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error instanceof Error ? error.message : 'Backup settings could not be loaded', 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleUpdateAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
let body: BackupSettingsInput;
|
||||||
|
try {
|
||||||
|
body = await request.json<BackupSettingsInput>();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Backup settings payload is invalid', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
let previous;
|
||||||
|
try {
|
||||||
|
previous = await loadBackupSettings(storage, env, 'UTC');
|
||||||
|
} catch {
|
||||||
|
previous = getDefaultBackupSettings('UTC');
|
||||||
|
}
|
||||||
|
|
||||||
|
let next;
|
||||||
|
try {
|
||||||
|
next = normalizeBackupSettingsInput(body, previous);
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error instanceof Error ? error.message : 'Backup settings are invalid', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveBackupSettings(storage, env, next);
|
||||||
|
await writeAuditLog(storage, actorUser.id, 'admin.backup.settings.update', 'backup', null, {
|
||||||
|
destinationCount: next.destinations.length,
|
||||||
|
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
|
||||||
|
});
|
||||||
|
return jsonResponse(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetAdminBackupSettingsRepairState(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
try {
|
||||||
|
const state = await getBackupSettingsRepairState(storage, env, 'UTC');
|
||||||
|
return jsonResponse({
|
||||||
|
object: 'backup-settings-repair',
|
||||||
|
needsRepair: state.needsRepair,
|
||||||
|
portable: state.portable,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error instanceof Error ? error.message : 'Backup settings repair state could not be loaded', 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleRepairAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
let body: BackupSettingsInput;
|
||||||
|
try {
|
||||||
|
body = await request.json<BackupSettingsInput>();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Backup settings repair payload is invalid', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
let previous;
|
||||||
|
try {
|
||||||
|
previous = await loadBackupSettings(storage, env, 'UTC');
|
||||||
|
} catch {
|
||||||
|
previous = getDefaultBackupSettings('UTC');
|
||||||
|
}
|
||||||
|
|
||||||
|
let next;
|
||||||
|
try {
|
||||||
|
next = normalizeBackupSettingsInput(body, previous);
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error instanceof Error ? error.message : 'Backup settings repair payload is invalid', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await repairBackupSettings(storage, env, next);
|
||||||
|
await writeAuditLog(storage, actorUser.id, 'admin.backup.settings.repair', 'backup', null, {
|
||||||
|
destinationCount: next.destinations.length,
|
||||||
|
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
|
||||||
|
});
|
||||||
|
return jsonResponse(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleRunAdminConfiguredBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
try {
|
||||||
|
let body: { destinationId?: string } | null = null;
|
||||||
|
try {
|
||||||
|
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
|
||||||
|
body = await request.json<{ destinationId?: string }>();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Backup run payload is invalid', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
||||||
|
const progress = async (event: {
|
||||||
|
operation: 'backup-remote-run';
|
||||||
|
step: string;
|
||||||
|
fileName: string;
|
||||||
|
stageTitle: string;
|
||||||
|
stageDetail: string;
|
||||||
|
done?: boolean;
|
||||||
|
ok?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
}) => {
|
||||||
|
await notifyUserBackupProgress(env, actorUser.id, event, targetDeviceIdentifier);
|
||||||
|
};
|
||||||
|
const result = await executeConfiguredBackup(env, storage, actorUser.id, 'manual', body?.destinationId || null, progress);
|
||||||
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
|
return jsonResponse({
|
||||||
|
object: 'backup-run',
|
||||||
|
result: {
|
||||||
|
fileName: result.fileName,
|
||||||
|
fileSize: result.fileSize,
|
||||||
|
provider: result.provider,
|
||||||
|
remotePath: result.remotePath,
|
||||||
|
},
|
||||||
|
settings,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error instanceof Error ? error.message : 'Backup run failed', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleListAdminRemoteBackups(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 destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
|
||||||
|
const listing = await listRemoteBackupEntries(destination, url.searchParams.get('path') || '');
|
||||||
|
return jsonResponse({
|
||||||
|
object: 'backup-remote-browser',
|
||||||
|
destinationId: destination.id,
|
||||||
|
destinationName: destination.name,
|
||||||
|
...listing,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error instanceof Error ? error.message : 'Remote backup listing failed', 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleDownloadAdminRemoteBackup(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);
|
||||||
|
return new Response(remoteFile.bytes, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': remoteFile.contentType || 'application/zip',
|
||||||
|
'Content-Disposition': `attachment; filename="${remoteFile.fileName}"`,
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error instanceof Error ? error.message : 'Remote backup download failed', 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleInspectAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
try {
|
||||||
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
|
||||||
|
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
|
||||||
|
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
||||||
|
const integrity = await inspectBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
|
||||||
|
return jsonResponse({
|
||||||
|
object: 'backup-remote-integrity',
|
||||||
|
destinationId: destination.id,
|
||||||
|
path,
|
||||||
|
fileName: remoteFile.fileName || path.split('/').pop() || path,
|
||||||
|
integrity,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error instanceof Error ? error.message : 'Remote backup integrity inspection failed', 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleDeleteAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
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);
|
||||||
|
await deleteRemoteBackupFile(destination, path);
|
||||||
|
await writeAuditLog(storage, actorUser.id, 'admin.backup.remote.delete', 'backup', null, {
|
||||||
|
...getBackupDestinationSummary(destination),
|
||||||
|
remotePath: path,
|
||||||
|
});
|
||||||
|
return jsonResponse({ object: 'backup-remote-delete', deleted: true, path });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error instanceof Error ? error.message : 'Remote backup delete failed', 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
let body: { destinationId?: string; path?: string; replaceExisting?: boolean; allowChecksumMismatch?: boolean };
|
||||||
|
try {
|
||||||
|
body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Remote restore payload is invalid', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
try {
|
||||||
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
|
const destination = requireBackupDestination(settings, body.destinationId || null);
|
||||||
|
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
||||||
|
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
||||||
|
const restoreFileNameFromPath = path.split('/').pop() || path;
|
||||||
|
await notifyUserBackupRestoreProgress(
|
||||||
|
env,
|
||||||
|
actorUser.id,
|
||||||
|
{
|
||||||
|
operation: 'backup-restore',
|
||||||
|
source: 'remote',
|
||||||
|
step: 'remote_fetch_archive',
|
||||||
|
fileName: restoreFileNameFromPath,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_remote_fetch_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_remote_fetch_detail',
|
||||||
|
replaceExisting: !!body.replaceExisting,
|
||||||
|
},
|
||||||
|
targetDeviceIdentifier
|
||||||
|
);
|
||||||
|
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
||||||
|
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
|
||||||
|
if (!checksumOk && !body.allowChecksumMismatch) {
|
||||||
|
return errorResponse('Remote backup file checksum does not match its filename', 400);
|
||||||
|
}
|
||||||
|
const restoreFileName = remoteFile.fileName || path.split('/').pop() || path;
|
||||||
|
const progress: BackupRestoreProgressReporter = async (event) => {
|
||||||
|
await notifyUserBackupRestoreProgress(
|
||||||
|
env,
|
||||||
|
actorUser.id,
|
||||||
|
{
|
||||||
|
operation: 'backup-restore',
|
||||||
|
...event,
|
||||||
|
},
|
||||||
|
targetDeviceIdentifier
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const imported = await (async () => {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const result = await importRemoteBackupArchiveBytes(
|
||||||
|
remoteFile.bytes,
|
||||||
|
env,
|
||||||
|
actorUser.id,
|
||||||
|
!!body.replaceExisting,
|
||||||
|
{
|
||||||
|
loadAttachment: async (blobName) => {
|
||||||
|
const file = await downloadRemoteBackupFile(destination, `attachments/${blobName}`).catch(() => null);
|
||||||
|
return file?.bytes || null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
progress,
|
||||||
|
restoreFileName
|
||||||
|
);
|
||||||
|
await writeAuditLog(storage, result.auditActorUserId, 'admin.backup.import', 'backup', null, {
|
||||||
|
users: result.result.imported.users,
|
||||||
|
ciphers: result.result.imported.ciphers,
|
||||||
|
attachments: result.result.imported.attachmentFiles,
|
||||||
|
skippedAttachments: result.result.skipped.attachments,
|
||||||
|
skippedReason: result.result.skipped.reason,
|
||||||
|
replaceExisting: !!body.replaceExisting,
|
||||||
|
...getBackupDestinationSummary(destination),
|
||||||
|
remotePath: path,
|
||||||
|
bytes: remoteFile.bytes.byteLength,
|
||||||
|
trigger: 'remote',
|
||||||
|
checksumMismatchAccepted: !checksumOk,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
})();
|
||||||
|
return jsonResponse(imported.result);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Remote backup restore failed';
|
||||||
|
return errorResponse(message, toImportStatusCode(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAdminExportBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
||||||
|
let body: { includeAttachments?: boolean } | null = null;
|
||||||
|
try {
|
||||||
|
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
|
||||||
|
body = await request.json<{ includeAttachments?: boolean }>();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Backup export payload is invalid', 400);
|
||||||
|
}
|
||||||
|
let archive: BackupArchiveBundle;
|
||||||
|
try {
|
||||||
|
const progress = async (event: {
|
||||||
|
step: string;
|
||||||
|
fileName?: string;
|
||||||
|
stageTitle: string;
|
||||||
|
stageDetail: string;
|
||||||
|
includeAttachments: boolean;
|
||||||
|
}) => {
|
||||||
|
await notifyUserBackupProgress(
|
||||||
|
env,
|
||||||
|
actorUser.id,
|
||||||
|
{
|
||||||
|
operation: 'backup-export',
|
||||||
|
source: 'local',
|
||||||
|
step: `export_${event.step}`,
|
||||||
|
fileName: event.fileName || '',
|
||||||
|
stageTitle: event.stageTitle,
|
||||||
|
stageDetail: event.stageDetail,
|
||||||
|
},
|
||||||
|
targetDeviceIdentifier
|
||||||
|
);
|
||||||
|
};
|
||||||
|
archive = await buildBackupArchive(env, new Date(), {
|
||||||
|
includeAttachments: !!body?.includeAttachments,
|
||||||
|
progress,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Backup export failed';
|
||||||
|
await notifyUserBackupProgress(
|
||||||
|
env,
|
||||||
|
actorUser.id,
|
||||||
|
{
|
||||||
|
operation: 'backup-export',
|
||||||
|
source: 'local',
|
||||||
|
step: 'export_failed',
|
||||||
|
fileName: '',
|
||||||
|
stageTitle: 'txt_backup_export_progress_failed_title',
|
||||||
|
stageDetail: 'txt_backup_export_progress_failed_detail',
|
||||||
|
done: true,
|
||||||
|
ok: false,
|
||||||
|
error: message,
|
||||||
|
},
|
||||||
|
targetDeviceIdentifier
|
||||||
|
);
|
||||||
|
return errorResponse(message, message.includes('blob missing') ? 409 : 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeAuditLog(storage, actorUser.id, 'admin.backup.export', 'backup', null, {
|
||||||
|
users: archive.manifest.tableCounts.users,
|
||||||
|
ciphers: archive.manifest.tableCounts.ciphers,
|
||||||
|
attachments: archive.manifest.tableCounts.attachments,
|
||||||
|
compressedBytes: archive.bytes.byteLength,
|
||||||
|
includesAttachments: archive.manifest.includes.attachments,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(archive.bytes, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/zip',
|
||||||
|
'Content-Disposition': `attachment; filename="${archive.fileName}"`,
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleDownloadAdminBackupAttachment(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const blobName = ensureBackupBlobName(url.searchParams.get('blobName') || '');
|
||||||
|
const object = await getBlobObject(env, blobName);
|
||||||
|
if (!object) {
|
||||||
|
return errorResponse('Backup attachment blob not found', 404);
|
||||||
|
}
|
||||||
|
return new Response(object.body, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': object.contentType || 'application/octet-stream',
|
||||||
|
'Content-Length': String(object.size),
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error instanceof Error ? error.message : 'Backup attachment download failed', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAdminImportBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
let formData: FormData;
|
||||||
|
try {
|
||||||
|
formData = await request.formData();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Content-Type must be multipart/form-data', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = formData.get('file');
|
||||||
|
if (!file || typeof file !== 'object' || !('arrayBuffer' in file)) {
|
||||||
|
return errorResponse('Backup file is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1';
|
||||||
|
const allowChecksumMismatch = String(formData.get('allowChecksumMismatch') || '').trim() === '1';
|
||||||
|
let archiveBytes: Uint8Array;
|
||||||
|
try {
|
||||||
|
archiveBytes = new Uint8Array(await (file as { arrayBuffer(): Promise<ArrayBuffer> }).arrayBuffer());
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Unable to read backup file', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileName = 'name' in file ? String((file as File).name || '') : '';
|
||||||
|
const checksumOk = await verifyBackupArchiveFileNameChecksum(archiveBytes, fileName);
|
||||||
|
if (!checksumOk && !allowChecksumMismatch) {
|
||||||
|
return errorResponse('Backup file checksum does not match its filename', 400);
|
||||||
|
}
|
||||||
|
const imported = await runImportAndAudit(env, request, actorUser, archiveBytes, fileName || 'nodewarden_backup.zip', replaceExisting, {
|
||||||
|
trigger: 'local',
|
||||||
|
bytes: archiveBytes.byteLength,
|
||||||
|
checksumMismatchAccepted: !checksumOk,
|
||||||
|
});
|
||||||
|
return jsonResponse(imported.result);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Backup import failed';
|
||||||
|
return errorResponse(message, toImportStatusCode(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedDefaultBackupSettings(env: Env): Promise<void> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const current = await storage.getConfigValue('backup.settings.v1');
|
||||||
|
if (current) {
|
||||||
|
await normalizeImportedBackupSettings(storage, env, 'UTC');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await saveBackupSettings(storage, env, getDefaultBackupSettings('UTC'));
|
||||||
|
}
|
||||||
@@ -1,8 +1,121 @@
|
|||||||
import { Env, Cipher, CipherResponse, Attachment } from '../types';
|
import { Env, Cipher, CipherResponse, Attachment } from '../types';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
|
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 } from './attachments';
|
||||||
|
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
||||||
|
import { readActingDeviceIdentifier } from '../utils/device';
|
||||||
|
|
||||||
|
function normalizeOptionalId(value: unknown): string | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
return normalized ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeCipherNestedObject<T>(
|
||||||
|
existingValue: T | null | undefined,
|
||||||
|
incomingValue: unknown
|
||||||
|
): T | null {
|
||||||
|
if (incomingValue === undefined) {
|
||||||
|
return (existingValue ?? null) as T | null;
|
||||||
|
}
|
||||||
|
if (incomingValue === null || typeof incomingValue !== 'object' || Array.isArray(incomingValue)) {
|
||||||
|
return incomingValue as T | null;
|
||||||
|
}
|
||||||
|
const existingObject =
|
||||||
|
existingValue && typeof existingValue === 'object' && !Array.isArray(existingValue)
|
||||||
|
? (existingValue as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
return {
|
||||||
|
...existingObject,
|
||||||
|
...(incomingValue as Record<string, unknown>),
|
||||||
|
} as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyVaultSyncForRequest(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
revisionDate: string
|
||||||
|
): Promise<void> {
|
||||||
|
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAliasedProp(source: any, aliases: string[]): { present: boolean; value: any } {
|
||||||
|
if (!source || typeof source !== 'object') return { present: false, value: undefined };
|
||||||
|
for (const key of aliases) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||||
|
return { present: true, value: source[key] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { present: false, value: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCipherTimestamp(value: unknown): string | null {
|
||||||
|
if (value == null || value === '') return null;
|
||||||
|
const parsed = new Date(String(value));
|
||||||
|
if (Number.isNaN(parsed.getTime())) return null;
|
||||||
|
return parsed.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCipherArchivedAt(source: any, fallback: string | null = null): string | null {
|
||||||
|
const archived = getAliasedProp(source, ['archivedAt', 'ArchivedAt', 'archivedDate', 'ArchivedDate']);
|
||||||
|
return archived.present ? normalizeCipherTimestamp(archived.value) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCipherComputedAliases(cipher: Cipher): Cipher {
|
||||||
|
cipher.archivedDate = cipher.archivedAt ?? null;
|
||||||
|
cipher.deletedDate = cipher.deletedAt ?? null;
|
||||||
|
return cipher;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCipherForStorage(cipher: Cipher): Cipher {
|
||||||
|
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||||
|
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
|
||||||
|
cipher.folderId = normalizeOptionalId(cipher.folderId);
|
||||||
|
const hasArchivedAt = Object.prototype.hasOwnProperty.call(cipher as object, 'archivedAt');
|
||||||
|
cipher.archivedAt = hasArchivedAt
|
||||||
|
? normalizeCipherTimestamp(cipher.archivedAt) ?? null
|
||||||
|
: normalizeCipherTimestamp(cipher.archivedDate) ?? null;
|
||||||
|
return syncCipherComputedAliases(cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCipherLoginForStorage(login: any): any {
|
||||||
|
if (!login || typeof login !== 'object') return login ?? null;
|
||||||
|
return {
|
||||||
|
...login,
|
||||||
|
fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCipherLoginForCompatibility(login: any): any {
|
||||||
|
const normalized = normalizeCipherLoginForStorage(login);
|
||||||
|
if (!normalized || typeof normalized !== 'object') return normalized ?? null;
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android 2026.2.0 requires sshKey.keyFingerprint in sync payloads.
|
||||||
|
// Keep legacy alias "fingerprint" in parallel for older web payloads.
|
||||||
|
export function normalizeCipherSshKeyForCompatibility(sshKey: any): any {
|
||||||
|
if (!sshKey || typeof sshKey !== 'object') return sshKey ?? null;
|
||||||
|
|
||||||
|
const candidate =
|
||||||
|
sshKey.keyFingerprint !== undefined && sshKey.keyFingerprint !== null
|
||||||
|
? sshKey.keyFingerprint
|
||||||
|
: sshKey.fingerprint;
|
||||||
|
|
||||||
|
const normalizedFingerprint =
|
||||||
|
candidate === undefined || candidate === null
|
||||||
|
? ''
|
||||||
|
: String(candidate);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...sshKey,
|
||||||
|
keyFingerprint: normalizedFingerprint,
|
||||||
|
fingerprint: normalizedFingerprint,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Format attachments for API response
|
// Format attachments for API response
|
||||||
export function formatAttachments(attachments: Attachment[]): any[] | null {
|
export function formatAttachments(attachments: Attachment[]): any[] | null {
|
||||||
@@ -10,7 +123,8 @@ export function formatAttachments(attachments: Attachment[]): any[] | null {
|
|||||||
return attachments.map(a => ({
|
return attachments.map(a => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
fileName: a.fileName,
|
fileName: a.fileName,
|
||||||
size: Number(a.size) || 0, // Android expects Int, not String
|
// Bitwarden clients decode attachment size as string in cipher payloads.
|
||||||
|
size: String(Number(a.size) || 0),
|
||||||
sizeName: a.sizeName,
|
sizeName: a.sizeName,
|
||||||
key: a.key,
|
key: a.key,
|
||||||
url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`, // Android requires non-null url!
|
url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`, // Android requires non-null url!
|
||||||
@@ -18,29 +132,31 @@ export function formatAttachments(attachments: Attachment[]): any[] | null {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert internal cipher to API response format
|
// Convert internal cipher to API response format.
|
||||||
export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): CipherResponse {
|
// Uses opaque passthrough: spreads ALL stored fields (including unknown/future ones),
|
||||||
|
// then overlays server-computed fields. This ensures new Bitwarden client fields
|
||||||
|
// survive a round-trip without code changes.
|
||||||
|
export function cipherToResponse(
|
||||||
|
cipher: Cipher,
|
||||||
|
attachments: Attachment[] = []
|
||||||
|
): CipherResponse {
|
||||||
|
// Strip internal-only fields that must not appear in the API response
|
||||||
|
const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher;
|
||||||
|
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null);
|
||||||
|
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: cipher.id,
|
// Pass through ALL stored cipher fields (known + unknown)
|
||||||
organizationId: null,
|
...passthrough,
|
||||||
folderId: cipher.folderId,
|
// Server-computed / enforced fields (always override)
|
||||||
|
folderId: normalizeOptionalId(cipher.folderId),
|
||||||
type: Number(cipher.type) || 1,
|
type: Number(cipher.type) || 1,
|
||||||
name: cipher.name,
|
organizationId: null,
|
||||||
notes: cipher.notes,
|
|
||||||
favorite: cipher.favorite,
|
|
||||||
login: cipher.login,
|
|
||||||
card: cipher.card,
|
|
||||||
identity: cipher.identity,
|
|
||||||
secureNote: cipher.secureNote,
|
|
||||||
sshKey: cipher.sshKey,
|
|
||||||
fields: cipher.fields,
|
|
||||||
passwordHistory: cipher.passwordHistory,
|
|
||||||
reprompt: cipher.reprompt,
|
|
||||||
organizationUseTotp: false,
|
organizationUseTotp: false,
|
||||||
creationDate: cipher.createdAt,
|
creationDate: createdAt,
|
||||||
revisionDate: cipher.updatedAt,
|
revisionDate: updatedAt,
|
||||||
deletedDate: cipher.deletedAt,
|
deletedDate: deletedAt,
|
||||||
archivedDate: null,
|
archivedDate: archivedAt ?? null,
|
||||||
edit: true,
|
edit: true,
|
||||||
viewPassword: true,
|
viewPassword: true,
|
||||||
permissions: {
|
permissions: {
|
||||||
@@ -50,7 +166,8 @@ export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = [])
|
|||||||
object: 'cipher',
|
object: 'cipher',
|
||||||
collectionIds: [],
|
collectionIds: [],
|
||||||
attachments: formatAttachments(attachments),
|
attachments: formatAttachments(attachments),
|
||||||
key: cipher.key,
|
login: normalizedLogin,
|
||||||
|
sshKey: normalizedSshKey,
|
||||||
encryptedFor: null,
|
encryptedFor: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -58,27 +175,44 @@ export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = [])
|
|||||||
// GET /api/ciphers
|
// GET /api/ciphers
|
||||||
export async function handleGetCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleGetCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const ciphers = await storage.getAllCiphers(userId);
|
|
||||||
|
|
||||||
// Filter out soft-deleted ciphers unless specifically requested
|
|
||||||
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 filteredCiphers = includeDeleted
|
let filteredCiphers: Cipher[];
|
||||||
|
let continuationToken: string | null = null;
|
||||||
|
if (pagination) {
|
||||||
|
const pageRows = await storage.getCiphersPage(
|
||||||
|
userId,
|
||||||
|
includeDeleted,
|
||||||
|
pagination.limit + 1,
|
||||||
|
pagination.offset
|
||||||
|
);
|
||||||
|
const hasNext = pageRows.length > pagination.limit;
|
||||||
|
filteredCiphers = hasNext ? pageRows.slice(0, pagination.limit) : pageRows;
|
||||||
|
continuationToken = hasNext ? encodeContinuationToken(pagination.offset + filteredCiphers.length) : null;
|
||||||
|
} else {
|
||||||
|
const ciphers = await storage.getAllCiphers(userId);
|
||||||
|
filteredCiphers = includeDeleted
|
||||||
? ciphers
|
? ciphers
|
||||||
: ciphers.filter(c => !c.deletedAt);
|
: ciphers.filter(c => !c.deletedAt);
|
||||||
|
}
|
||||||
|
|
||||||
// Get attachments for all ciphers
|
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(
|
||||||
const cipherResponses = [];
|
filteredCiphers.map((cipher) => cipher.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build responses only for the current page to keep pagination cheap.
|
||||||
|
const cipherResponses: CipherResponse[] = [];
|
||||||
for (const cipher of filteredCiphers) {
|
for (const cipher of filteredCiphers) {
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||||
cipherResponses.push(cipherToResponse(cipher, attachments));
|
cipherResponses.push(cipherToResponse(cipher, attachments));
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
data: cipherResponses,
|
data: cipherResponses,
|
||||||
object: 'list',
|
object: 'list',
|
||||||
continuationToken: null,
|
continuationToken: continuationToken,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +226,15 @@ 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(cipherToResponse(cipher, attachments));
|
return jsonResponse(
|
||||||
|
cipherToResponse(cipher, attachments)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyFolderOwnership(storage: StorageService, folderId: string | null | undefined, userId: string): Promise<boolean> {
|
||||||
|
if (!folderId) return true;
|
||||||
|
const folder = await storage.getFolder(folderId);
|
||||||
|
return !!(folder && folder.userId === userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/ciphers
|
// POST /api/ciphers
|
||||||
@@ -111,32 +253,39 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
|||||||
const cipherData = body.Cipher || body.cipher || body;
|
const cipherData = body.Cipher || body.cipher || body;
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
// Opaque passthrough: spread ALL client fields to preserve unknown/future ones,
|
||||||
|
// then override only server-controlled fields.
|
||||||
const cipher: Cipher = {
|
const cipher: Cipher = {
|
||||||
|
...cipherData,
|
||||||
|
// Server-controlled fields (always override client values)
|
||||||
id: generateUUID(),
|
id: generateUUID(),
|
||||||
userId: userId,
|
userId: userId,
|
||||||
type: Number(cipherData.type) || 1,
|
type: Number(cipherData.type) || 1,
|
||||||
folderId: cipherData.folderId || null,
|
favorite: !!cipherData.favorite,
|
||||||
name: cipherData.name || null,
|
|
||||||
notes: cipherData.notes || null,
|
|
||||||
favorite: cipherData.favorite || false,
|
|
||||||
login: cipherData.login || null,
|
|
||||||
card: cipherData.card || null,
|
|
||||||
identity: cipherData.identity || null,
|
|
||||||
secureNote: cipherData.secureNote || null,
|
|
||||||
sshKey: cipherData.sshKey || null,
|
|
||||||
fields: cipherData.fields || null,
|
|
||||||
passwordHistory: cipherData.passwordHistory || null,
|
|
||||||
reprompt: cipherData.reprompt || 0,
|
reprompt: cipherData.reprompt || 0,
|
||||||
key: cipherData.key || null,
|
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
archivedAt: readCipherArchivedAt(cipherData, null),
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
};
|
};
|
||||||
|
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
|
||||||
|
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
|
||||||
|
normalizeCipherForStorage(cipher);
|
||||||
|
|
||||||
|
// Prevent referencing a folder owned by another user.
|
||||||
|
if (cipher.folderId) {
|
||||||
|
const folderOk = await verifyFolderOwnership(storage, cipher.folderId, userId);
|
||||||
|
if (!folderOk) return errorResponse('Folder not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(cipherToResponse(cipher), 200);
|
return jsonResponse(
|
||||||
|
cipherToResponse(cipher, []),
|
||||||
|
200
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /api/ciphers/:id
|
// PUT /api/ciphers/:id
|
||||||
@@ -159,29 +308,53 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
// 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;
|
||||||
|
|
||||||
|
// Opaque passthrough: merge existing stored data with ALL incoming client fields.
|
||||||
|
// Unknown/future fields from the client are preserved; server-controlled fields are protected.
|
||||||
const cipher: Cipher = {
|
const cipher: Cipher = {
|
||||||
...existingCipher,
|
...existingCipher, // start with all existing stored data (including unknowns)
|
||||||
|
...cipherData, // overlay all client data (including new/unknown fields)
|
||||||
|
// Server-controlled fields (never from client)
|
||||||
|
id: existingCipher.id,
|
||||||
|
userId: existingCipher.userId,
|
||||||
type: Number(cipherData.type) || existingCipher.type,
|
type: Number(cipherData.type) || existingCipher.type,
|
||||||
folderId: cipherData.folderId !== undefined ? cipherData.folderId : existingCipher.folderId,
|
|
||||||
name: cipherData.name ?? existingCipher.name,
|
|
||||||
notes: cipherData.notes !== undefined ? cipherData.notes : existingCipher.notes,
|
|
||||||
favorite: cipherData.favorite ?? existingCipher.favorite,
|
favorite: cipherData.favorite ?? existingCipher.favorite,
|
||||||
login: cipherData.login !== undefined ? cipherData.login : existingCipher.login,
|
|
||||||
card: cipherData.card !== undefined ? cipherData.card : existingCipher.card,
|
|
||||||
identity: cipherData.identity !== undefined ? cipherData.identity : existingCipher.identity,
|
|
||||||
secureNote: cipherData.secureNote !== undefined ? cipherData.secureNote : existingCipher.secureNote,
|
|
||||||
sshKey: cipherData.sshKey !== undefined ? cipherData.sshKey : existingCipher.sshKey,
|
|
||||||
fields: cipherData.fields !== undefined ? cipherData.fields : existingCipher.fields,
|
|
||||||
passwordHistory: cipherData.passwordHistory !== undefined ? cipherData.passwordHistory : existingCipher.passwordHistory,
|
|
||||||
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
|
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
|
||||||
key: cipherData.key !== undefined ? cipherData.key : existingCipher.key,
|
createdAt: existingCipher.createdAt,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
|
||||||
|
deletedAt: existingCipher.deletedAt,
|
||||||
};
|
};
|
||||||
|
cipher.login = mergeCipherNestedObject(existingCipher.login, cipherData.login);
|
||||||
|
cipher.card = mergeCipherNestedObject(existingCipher.card, cipherData.card);
|
||||||
|
cipher.identity = mergeCipherNestedObject(existingCipher.identity, cipherData.identity);
|
||||||
|
cipher.secureNote = mergeCipherNestedObject(existingCipher.secureNote, cipherData.secureNote);
|
||||||
|
cipher.sshKey = mergeCipherNestedObject(existingCipher.sshKey, cipherData.sshKey);
|
||||||
|
|
||||||
|
// Custom fields deletion compatibility:
|
||||||
|
// - Accept both camelCase "fields" and PascalCase "Fields".
|
||||||
|
// - For full update (PUT/POST on this endpoint), missing fields means cleared fields.
|
||||||
|
// This prevents stale custom fields from being resurrected by merge fallback.
|
||||||
|
const incomingFields = getAliasedProp(cipherData, ['fields', 'Fields']);
|
||||||
|
if (incomingFields.present) {
|
||||||
|
cipher.fields = incomingFields.value ?? null;
|
||||||
|
} else if (request.method === 'PUT' || request.method === 'POST') {
|
||||||
|
cipher.fields = null;
|
||||||
|
}
|
||||||
|
normalizeCipherForStorage(cipher);
|
||||||
|
|
||||||
|
// Prevent referencing a folder owned by another user.
|
||||||
|
if (cipher.folderId) {
|
||||||
|
const folderOk = await verifyFolderOwnership(storage, cipher.folderId, userId);
|
||||||
|
if (!folderOk) return errorResponse('Folder not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(cipherToResponse(cipher));
|
return jsonResponse(
|
||||||
|
cipherToResponse(cipher, [])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/ciphers/:id
|
// DELETE /api/ciphers/:id
|
||||||
@@ -196,10 +369,38 @@ 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);
|
||||||
await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(cipherToResponse(cipher));
|
return jsonResponse(
|
||||||
|
cipherToResponse(cipher, [])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/ciphers/:id (compat mode)
|
||||||
|
// Bitwarden clients may call DELETE on a trashed item to purge it permanently.
|
||||||
|
// For compatibility:
|
||||||
|
// - If item is active -> soft delete.
|
||||||
|
// - If item is already soft-deleted -> hard delete.
|
||||||
|
export async function handleDeleteCipherCompat(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) {
|
||||||
|
await deleteAllAttachmentsForCipher(env, id);
|
||||||
|
await storage.deleteCipher(id, userId);
|
||||||
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return handleDeleteCipher(request, env, userId, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/ciphers/:id (permanent)
|
// DELETE /api/ciphers/:id (permanent)
|
||||||
@@ -215,7 +416,8 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
|
|||||||
await deleteAllAttachmentsForCipher(env, id);
|
await deleteAllAttachmentsForCipher(env, id);
|
||||||
|
|
||||||
await storage.deleteCipher(id, userId);
|
await storage.deleteCipher(id, userId);
|
||||||
await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
@@ -231,10 +433,14 @@ 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);
|
||||||
await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(cipherToResponse(cipher));
|
return jsonResponse(
|
||||||
|
cipherToResponse(cipher, [])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /api/ciphers/:id/partial - Update only favorite/folderId
|
// PUT /api/ciphers/:id/partial - Update only favorite/folderId
|
||||||
@@ -254,17 +460,26 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (body.folderId !== undefined) {
|
if (body.folderId !== undefined) {
|
||||||
cipher.folderId = body.folderId;
|
const folderId = normalizeOptionalId(body.folderId);
|
||||||
|
if (folderId) {
|
||||||
|
const folderOk = await verifyFolderOwnership(storage, folderId, userId);
|
||||||
|
if (!folderOk) return errorResponse('Folder not found', 404);
|
||||||
|
}
|
||||||
|
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);
|
||||||
await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(cipherToResponse(cipher));
|
return jsonResponse(
|
||||||
|
cipherToResponse(cipher, [])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST/PUT /api/ciphers/move - Bulk move to folder
|
// POST/PUT /api/ciphers/move - Bulk move to folder
|
||||||
@@ -282,7 +497,212 @@ export async function handleBulkMoveCiphers(request: Request, env: Env, userId:
|
|||||||
return errorResponse('ids array is required', 400);
|
return errorResponse('ids array is required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
await storage.bulkMoveCiphers(body.ids, body.folderId || null, userId);
|
const folderId = normalizeOptionalId(body.folderId);
|
||||||
|
if (folderId) {
|
||||||
|
const folderOk = await verifyFolderOwnership(storage, folderId, userId);
|
||||||
|
if (!folderOk) return errorResponse('Folder not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisionDate = await storage.bulkMoveCiphers(body.ids, folderId, userId);
|
||||||
|
if (revisionDate) {
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
await 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);
|
||||||
|
await 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) {
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildCipherListResponse(request, storage, userId, ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT/POST /api/ciphers/unarchive
|
||||||
|
export async function handleBulkUnarchiveCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
|
let body: { ids?: unknown };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = parseCipherIdList(body);
|
||||||
|
if (!ids) {
|
||||||
|
return errorResponse('ids array is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisionDate = await storage.bulkUnarchiveCiphers(ids, userId);
|
||||||
|
if (revisionDate) {
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildCipherListResponse(request, storage, userId, ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/ciphers/delete - Bulk soft delete
|
||||||
|
export async function handleBulkDeleteCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
|
let body: { ids?: string[] };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.ids || !Array.isArray(body.ids)) {
|
||||||
|
return errorResponse('ids array is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId);
|
||||||
|
if (revisionDate) {
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/ciphers/restore - Bulk restore
|
||||||
|
export async function handleBulkRestoreCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
|
let body: { ids?: string[] };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.ids || !Array.isArray(body.ids)) {
|
||||||
|
return errorResponse('ids array is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisionDate = await storage.bulkRestoreCiphers(body.ids, userId);
|
||||||
|
if (revisionDate) {
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/ciphers/delete-permanent - Bulk permanent delete
|
||||||
|
export async function handleBulkPermanentDeleteCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
|
let body: { ids?: string[] };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.ids || !Array.isArray(body.ids)) {
|
||||||
|
return errorResponse('ids array is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = Array.from(new Set(body.ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||||
|
if (!ids.length) {
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
await deleteAllAttachmentsForCipher(env, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisionDate = await storage.bulkDeleteCiphers(ids, userId);
|
||||||
|
if (revisionDate) {
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,466 @@
|
|||||||
|
import type { Device, DevicePendingAuthRequest, DeviceResponse, ProtectedDeviceResponse as ProtectedDeviceWireResponse } from '../types';
|
||||||
|
import { Env } from '../types';
|
||||||
|
import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
|
import { readKnownDeviceProbe } from '../utils/device';
|
||||||
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
|
||||||
|
function normalizeIdentifier(value: string | null | undefined): string {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDevicePendingAuthRequest(value?: { id?: string | null; creationDate?: string | null } | null): DevicePendingAuthRequest | null {
|
||||||
|
if (!value?.id || !value.creationDate) return null;
|
||||||
|
return {
|
||||||
|
id: String(value.id),
|
||||||
|
creationDate: String(value.creationDate),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTrustedDevice(device: Pick<Device, 'encryptedUserKey' | 'encryptedPublicKey'>): boolean {
|
||||||
|
return !!(device.encryptedUserKey && device.encryptedPublicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDeviceResponse(device: Device): DeviceResponse {
|
||||||
|
const response = {
|
||||||
|
Id: device.deviceIdentifier,
|
||||||
|
id: device.deviceIdentifier,
|
||||||
|
UserId: device.userId,
|
||||||
|
userId: device.userId,
|
||||||
|
Name: device.name,
|
||||||
|
name: device.name,
|
||||||
|
Identifier: device.deviceIdentifier,
|
||||||
|
identifier: device.deviceIdentifier,
|
||||||
|
Type: device.type,
|
||||||
|
type: device.type,
|
||||||
|
CreationDate: device.createdAt,
|
||||||
|
creationDate: device.createdAt,
|
||||||
|
RevisionDate: device.updatedAt,
|
||||||
|
revisionDate: device.updatedAt,
|
||||||
|
IsTrusted: isTrustedDevice(device),
|
||||||
|
isTrusted: isTrustedDevice(device),
|
||||||
|
EncryptedUserKey: device.encryptedUserKey,
|
||||||
|
encryptedUserKey: device.encryptedUserKey,
|
||||||
|
EncryptedPublicKey: device.encryptedPublicKey,
|
||||||
|
encryptedPublicKey: device.encryptedPublicKey,
|
||||||
|
DevicePendingAuthRequest: buildDevicePendingAuthRequest(device.devicePendingAuthRequest),
|
||||||
|
devicePendingAuthRequest: buildDevicePendingAuthRequest(device.devicePendingAuthRequest),
|
||||||
|
object: 'device',
|
||||||
|
};
|
||||||
|
return response as DeviceResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProtectedDeviceResponse(device: Device): ProtectedDeviceWireResponse {
|
||||||
|
const response = {
|
||||||
|
Id: device.deviceIdentifier,
|
||||||
|
id: device.deviceIdentifier,
|
||||||
|
Name: device.name,
|
||||||
|
name: device.name,
|
||||||
|
Identifier: device.deviceIdentifier,
|
||||||
|
identifier: device.deviceIdentifier,
|
||||||
|
Type: device.type,
|
||||||
|
type: device.type,
|
||||||
|
CreationDate: device.createdAt,
|
||||||
|
creationDate: device.createdAt,
|
||||||
|
EncryptedUserKey: device.encryptedUserKey,
|
||||||
|
encryptedUserKey: device.encryptedUserKey,
|
||||||
|
EncryptedPublicKey: device.encryptedPublicKey,
|
||||||
|
encryptedPublicKey: device.encryptedPublicKey,
|
||||||
|
object: 'protectedDevice',
|
||||||
|
};
|
||||||
|
return response as ProtectedDeviceWireResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseKeysBody(body: any, fallback?: Device): {
|
||||||
|
encryptedUserKey?: string | null;
|
||||||
|
encryptedPublicKey?: string | null;
|
||||||
|
encryptedPrivateKey?: string | null;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
encryptedUserKey:
|
||||||
|
Object.prototype.hasOwnProperty.call(body || {}, 'encryptedUserKey')
|
||||||
|
? body?.encryptedUserKey ?? null
|
||||||
|
: fallback?.encryptedUserKey ?? null,
|
||||||
|
encryptedPublicKey:
|
||||||
|
Object.prototype.hasOwnProperty.call(body || {}, 'encryptedPublicKey')
|
||||||
|
? body?.encryptedPublicKey ?? null
|
||||||
|
: fallback?.encryptedPublicKey ?? null,
|
||||||
|
encryptedPrivateKey:
|
||||||
|
Object.prototype.hasOwnProperty.call(body || {}, 'encryptedPrivateKey')
|
||||||
|
? body?.encryptedPrivateKey ?? null
|
||||||
|
: fallback?.encryptedPrivateKey ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonBody(request: Request): Promise<any> {
|
||||||
|
try {
|
||||||
|
return await request.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/devices/knowndevice
|
||||||
|
// Compatible with Bitwarden/Vaultwarden behavior:
|
||||||
|
// - X-Request-Email: base64url(email) without padding
|
||||||
|
// - X-Device-Identifier: client device identifier
|
||||||
|
export async function handleKnownDevice(request: Request, env: Env): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const { email, deviceIdentifier } = readKnownDeviceProbe(request);
|
||||||
|
|
||||||
|
if (!email || !deviceIdentifier) {
|
||||||
|
return jsonResponse(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const known = await storage.isKnownDeviceByEmail(email, deviceIdentifier);
|
||||||
|
return jsonResponse(known);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/devices
|
||||||
|
export async function handleGetDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const devices = await storage.getDevicesByUserId(userId);
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
data: devices.map((device) => buildDeviceResponse(device)),
|
||||||
|
object: 'list',
|
||||||
|
continuationToken: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/devices/identifier/:deviceIdentifier
|
||||||
|
export async function handleGetDeviceByIdentifier(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const normalized = normalizeIdentifier(deviceIdentifier);
|
||||||
|
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const device = await storage.getDevice(userId, normalized);
|
||||||
|
if (!device) {
|
||||||
|
return errorResponse('Device not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(buildDeviceResponse(device));
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/devices/:deviceIdentifier
|
||||||
|
export async function handleGetDevice(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<Response> {
|
||||||
|
return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/devices/authorized
|
||||||
|
// Returns known devices together with active 2FA remember-token expiry.
|
||||||
|
export async function handleGetAuthorizedDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const [devices, trusted, onlineDeviceIdentifiers] = await Promise.all([
|
||||||
|
storage.getDevicesByUserId(userId),
|
||||||
|
storage.getTrustedDeviceTokenSummariesByUserId(userId),
|
||||||
|
getOnlineUserDevices(env, userId),
|
||||||
|
]);
|
||||||
|
const onlineSet = new Set(onlineDeviceIdentifiers);
|
||||||
|
|
||||||
|
const trustedByIdentifier = new Map<string, { expiresAt: number; tokenCount: number }>();
|
||||||
|
for (const row of trusted) {
|
||||||
|
trustedByIdentifier.set(row.deviceIdentifier, { expiresAt: row.expiresAt, tokenCount: row.tokenCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
const knownIdentifiers = new Set<string>();
|
||||||
|
const data = devices.map(device => {
|
||||||
|
knownIdentifiers.add(device.deviceIdentifier);
|
||||||
|
const trustedInfo = trustedByIdentifier.get(device.deviceIdentifier);
|
||||||
|
return {
|
||||||
|
...buildDeviceResponse(device),
|
||||||
|
online: onlineSet.has(device.deviceIdentifier),
|
||||||
|
trusted: !!trustedInfo,
|
||||||
|
trustedTokenCount: trustedInfo?.tokenCount || 0,
|
||||||
|
trustedUntil: trustedInfo?.expiresAt ? new Date(trustedInfo.expiresAt).toISOString() : null,
|
||||||
|
object: 'device',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const row of trusted) {
|
||||||
|
if (knownIdentifiers.has(row.deviceIdentifier)) continue;
|
||||||
|
const placeholderDevice: Device = {
|
||||||
|
userId,
|
||||||
|
deviceIdentifier: row.deviceIdentifier,
|
||||||
|
name: 'Unknown device',
|
||||||
|
type: 14,
|
||||||
|
sessionStamp: '',
|
||||||
|
encryptedUserKey: null,
|
||||||
|
encryptedPublicKey: null,
|
||||||
|
encryptedPrivateKey: null,
|
||||||
|
devicePendingAuthRequest: null,
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: '',
|
||||||
|
};
|
||||||
|
data.push({
|
||||||
|
...buildDeviceResponse(placeholderDevice),
|
||||||
|
isTrusted: true,
|
||||||
|
online: onlineSet.has(row.deviceIdentifier),
|
||||||
|
trusted: true,
|
||||||
|
trustedTokenCount: row.tokenCount,
|
||||||
|
trustedUntil: row.expiresAt ? new Date(row.expiresAt).toISOString() : null,
|
||||||
|
object: 'device',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
data,
|
||||||
|
object: 'list',
|
||||||
|
continuationToken: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/devices/authorized
|
||||||
|
export async function handleRevokeAllTrustedDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const removed = await storage.deleteTrustedTwoFactorTokensByUserId(userId);
|
||||||
|
return jsonResponse({ success: true, removed });
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/devices/authorized/:deviceIdentifier
|
||||||
|
export async function handleRevokeTrustedDevice(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const normalized = String(deviceIdentifier || '').trim();
|
||||||
|
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const removed = await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
||||||
|
return jsonResponse({ success: true, removed });
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/devices/:deviceIdentifier
|
||||||
|
export async function handleDeleteDevice(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const normalized = String(deviceIdentifier || '').trim();
|
||||||
|
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
||||||
|
await storage.deleteRefreshTokensByDevice(userId, normalized);
|
||||||
|
const deleted = await storage.deleteDevice(userId, normalized);
|
||||||
|
if (deleted) {
|
||||||
|
await notifyUserLogout(env, userId, normalized);
|
||||||
|
}
|
||||||
|
return jsonResponse({ success: deleted });
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/devices
|
||||||
|
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const user = await storage.getUserById(userId);
|
||||||
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
|
const [removedTrusted, removedSessions, removedDevices] = await Promise.all([
|
||||||
|
storage.deleteTrustedTwoFactorTokensByUserId(userId),
|
||||||
|
storage.deleteRefreshTokensByUserId(userId),
|
||||||
|
storage.deleteDevicesByUserId(userId),
|
||||||
|
]);
|
||||||
|
user.securityStamp = generateUUID();
|
||||||
|
user.updatedAt = new Date().toISOString();
|
||||||
|
await storage.saveUser(user);
|
||||||
|
await notifyUserLogout(env, userId, null);
|
||||||
|
return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices });
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT/POST /api/devices/identifier/:deviceIdentifier/keys
|
||||||
|
export async function handleUpdateDeviceKeys(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const normalized = normalizeIdentifier(deviceIdentifier);
|
||||||
|
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const device = await storage.getDevice(userId, normalized);
|
||||||
|
if (!device) {
|
||||||
|
return errorResponse('Device not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await storage.updateDeviceKeys(userId, normalized, parseKeysBody(body, device));
|
||||||
|
if (!updated) {
|
||||||
|
return errorResponse('Device not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextDevice = await storage.getDevice(userId, normalized);
|
||||||
|
return jsonResponse(buildDeviceResponse(nextDevice || device));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/devices/update-trust
|
||||||
|
export async function handleUpdateDeviceTrust(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const currentDeviceIdentifier =
|
||||||
|
normalizeIdentifier(request.headers.get('Device-Identifier')) ||
|
||||||
|
normalizeIdentifier(request.headers.get('X-Device-Identifier'));
|
||||||
|
|
||||||
|
const updates: Array<{
|
||||||
|
deviceIdentifier: string;
|
||||||
|
keys: {
|
||||||
|
encryptedUserKey?: string | null;
|
||||||
|
encryptedPublicKey?: string | null;
|
||||||
|
encryptedPrivateKey?: string | null;
|
||||||
|
};
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
if (currentDeviceIdentifier && body?.currentDevice) {
|
||||||
|
updates.push({
|
||||||
|
deviceIdentifier: currentDeviceIdentifier,
|
||||||
|
keys: parseKeysBody(body.currentDevice, await storage.getDevice(userId, currentDeviceIdentifier) || undefined),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(body?.otherDevices)) {
|
||||||
|
for (const item of body.otherDevices) {
|
||||||
|
const deviceIdentifier = normalizeIdentifier(item?.deviceId);
|
||||||
|
if (!deviceIdentifier) continue;
|
||||||
|
updates.push({
|
||||||
|
deviceIdentifier,
|
||||||
|
keys: parseKeysBody(item, await storage.getDevice(userId, deviceIdentifier) || undefined),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let updatedCount = 0;
|
||||||
|
for (const update of updates) {
|
||||||
|
const ok = await storage.updateDeviceKeys(userId, update.deviceIdentifier, update.keys);
|
||||||
|
if (ok) updatedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({ success: true, updated: updatedCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/devices/untrust
|
||||||
|
export async function handleUntrustDevices(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const devices = Array.isArray(body?.devices) ? body.devices.map((id: unknown) => normalizeIdentifier(String(id))) : [];
|
||||||
|
const removed = await storage.clearDeviceKeys(userId, devices);
|
||||||
|
for (const deviceIdentifier of devices) {
|
||||||
|
if (!deviceIdentifier) continue;
|
||||||
|
await storage.deleteTrustedTwoFactorTokensByDevice(userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
return jsonResponse({ success: true, removed });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/devices/:deviceIdentifier/retrieve-keys
|
||||||
|
export async function handleRetrieveDeviceKeys(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const normalized = normalizeIdentifier(deviceIdentifier);
|
||||||
|
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const device = await storage.getDevice(userId, normalized);
|
||||||
|
if (!device) {
|
||||||
|
return errorResponse('Device not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(buildProtectedDeviceResponse(device));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/devices/:id/deactivate
|
||||||
|
export async function handleDeactivateDevice(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const normalized = normalizeIdentifier(deviceIdentifier);
|
||||||
|
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
||||||
|
await storage.deleteRefreshTokensByDevice(userId, normalized);
|
||||||
|
const deleted = await storage.deleteDevice(userId, normalized);
|
||||||
|
if (deleted) {
|
||||||
|
await notifyUserLogout(env, userId, normalized);
|
||||||
|
}
|
||||||
|
return jsonResponse({ success: deleted });
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/devices/identifier/{deviceIdentifier}/token
|
||||||
|
// Bitwarden mobile reports push token updates to this endpoint.
|
||||||
|
// NodeWarden does not implement push notifications, so accept and no-op.
|
||||||
|
export async function handleUpdateDeviceToken(
|
||||||
|
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/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 });
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,7 +1,19 @@
|
|||||||
import { Env, Folder, FolderResponse } from '../types';
|
import { Env, Folder, FolderResponse } from '../types';
|
||||||
|
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
|
import { readActingDeviceIdentifier } from '../utils/device';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
||||||
|
|
||||||
|
async function notifyVaultSyncForRequest(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
revisionDate: string
|
||||||
|
): Promise<void> {
|
||||||
|
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||||
|
}
|
||||||
|
|
||||||
// Convert internal folder to API response format
|
// Convert internal folder to API response format
|
||||||
function folderToResponse(folder: Folder): FolderResponse {
|
function folderToResponse(folder: Folder): FolderResponse {
|
||||||
@@ -16,12 +28,24 @@ function folderToResponse(folder: Folder): FolderResponse {
|
|||||||
// GET /api/folders
|
// GET /api/folders
|
||||||
export async function handleGetFolders(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleGetFolders(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const folders = await storage.getAllFolders(userId);
|
const url = new URL(request.url);
|
||||||
|
const pagination = parsePagination(url);
|
||||||
|
|
||||||
|
let folders: Folder[];
|
||||||
|
let continuationToken: string | null = null;
|
||||||
|
if (pagination) {
|
||||||
|
const pageRows = await storage.getFoldersPage(userId, pagination.limit + 1, pagination.offset);
|
||||||
|
const hasNext = pageRows.length > pagination.limit;
|
||||||
|
folders = hasNext ? pageRows.slice(0, pagination.limit) : pageRows;
|
||||||
|
continuationToken = hasNext ? encodeContinuationToken(pagination.offset + folders.length) : null;
|
||||||
|
} else {
|
||||||
|
folders = await storage.getAllFolders(userId);
|
||||||
|
}
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
data: folders.map(folderToResponse),
|
data: folders.map(folderToResponse),
|
||||||
object: 'list',
|
object: 'list',
|
||||||
continuationToken: null,
|
continuationToken: continuationToken,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +86,8 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
|
|||||||
};
|
};
|
||||||
|
|
||||||
await storage.saveFolder(folder);
|
await storage.saveFolder(folder);
|
||||||
await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(folderToResponse(folder), 200);
|
return jsonResponse(folderToResponse(folder), 200);
|
||||||
}
|
}
|
||||||
@@ -89,7 +114,8 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
|
|||||||
folder.updatedAt = new Date().toISOString();
|
folder.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
await storage.saveFolder(folder);
|
await storage.saveFolder(folder);
|
||||||
await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(folderToResponse(folder));
|
return jsonResponse(folderToResponse(folder));
|
||||||
}
|
}
|
||||||
@@ -103,8 +129,34 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str
|
|||||||
return errorResponse('Folder not found', 404);
|
return errorResponse('Folder not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await storage.clearFolderFromCiphers(userId, id);
|
||||||
await storage.deleteFolder(id, userId);
|
await storage.deleteFolder(id, userId);
|
||||||
await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/folders/delete
|
||||||
|
export async function handleBulkDeleteFolders(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
|
let body: { ids?: string[] };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = Array.isArray(body.ids) ? body.ids.map((id) => String(id || '').trim()).filter(Boolean) : [];
|
||||||
|
if (!ids.length) {
|
||||||
|
return errorResponse('Folder ids are required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisionDate = await storage.bulkDeleteFolders(ids, userId);
|
||||||
|
if (revisionDate) {
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,175 @@
|
|||||||
import { Env, TokenResponse } from '../types';
|
import { Env, TokenResponse } from '../types';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { AuthService } from '../services/auth';
|
import { AuthService } from '../services/auth';
|
||||||
import { RateLimitService } from '../services/ratelimit';
|
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||||
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
|
||||||
|
import { LIMITS } from '../config/limits';
|
||||||
|
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
||||||
|
import { createRefreshToken } from '../utils/jwt';
|
||||||
|
import { readAuthRequestDeviceInfo } from '../utils/device';
|
||||||
|
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
||||||
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
import { issueSendAccessToken } from './sends';
|
||||||
|
import {
|
||||||
|
buildAccountKeys,
|
||||||
|
buildUserDecryptionOptions,
|
||||||
|
} from '../utils/user-decryption';
|
||||||
|
|
||||||
|
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||||
|
const TWO_FACTOR_PROVIDER_REMEMBER = 5;
|
||||||
|
const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh';
|
||||||
|
// Android client (2026.2.x) deserializes TwoFactorProviders2 keys with -1 for recovery code.
|
||||||
|
// Keep request parsing backward-compatible with historical provider values (8 / 100).
|
||||||
|
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
|
||||||
|
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_LEGACY = 8;
|
||||||
|
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100;
|
||||||
|
|
||||||
|
function resolveTotpSecret(userSecret: string | null): string | null {
|
||||||
|
if (userSecret && isTotpEnabled(userSecret)) {
|
||||||
|
return userSecret;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseWebSession(request: Request): boolean {
|
||||||
|
return String(request.headers.get('X-NodeWarden-Web-Session') || '').trim() === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCookieValue(request: Request, name: string): string | null {
|
||||||
|
const rawCookie = String(request.headers.get('Cookie') || '').trim();
|
||||||
|
if (!rawCookie) return null;
|
||||||
|
for (const part of rawCookie.split(';')) {
|
||||||
|
const [key, ...rest] = part.trim().split('=');
|
||||||
|
if (key !== name) continue;
|
||||||
|
const value = rest.join('=').trim();
|
||||||
|
return value ? decodeURIComponent(value) : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string {
|
||||||
|
const isHttps = new URL(request.url).protocol === 'https:';
|
||||||
|
const parts = [
|
||||||
|
`${WEB_REFRESH_COOKIE}=${encodeURIComponent(refreshToken)}`,
|
||||||
|
'Path=/identity/connect',
|
||||||
|
'HttpOnly',
|
||||||
|
'SameSite=Strict',
|
||||||
|
`Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
|
||||||
|
];
|
||||||
|
if (isHttps) parts.push('Secure');
|
||||||
|
return parts.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClearedRefreshCookie(request: Request): string {
|
||||||
|
return buildRefreshCookie(request, '', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function withWebRefreshCookie(request: Request, response: Response, refreshToken: string | null): Response {
|
||||||
|
const headers = new Headers(response.headers);
|
||||||
|
headers.append(
|
||||||
|
'Set-Cookie',
|
||||||
|
refreshToken
|
||||||
|
? buildRefreshCookie(request, refreshToken, Math.floor(LIMITS.auth.refreshTokenTtlMs / 1000))
|
||||||
|
: buildClearedRefreshCookie(request)
|
||||||
|
);
|
||||||
|
return new Response(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPreloginResponse(
|
||||||
|
email: string,
|
||||||
|
kdfType: number,
|
||||||
|
kdfIterations: number,
|
||||||
|
kdfMemory: number | null,
|
||||||
|
kdfParallelism: number | null
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
kdf: kdfType,
|
||||||
|
kdfIterations,
|
||||||
|
kdfMemory,
|
||||||
|
kdfParallelism,
|
||||||
|
KdfSettings: {
|
||||||
|
KdfType: kdfType,
|
||||||
|
Iterations: kdfIterations,
|
||||||
|
Memory: kdfMemory,
|
||||||
|
Parallelism: kdfParallelism,
|
||||||
|
},
|
||||||
|
Salt: email.toLowerCase(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function twoFactorRequiredResponse(message: string = 'Two factor required.', includeRecoveryCode: boolean = false): Response {
|
||||||
|
const providers = includeRecoveryCode
|
||||||
|
? [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR), TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE]
|
||||||
|
: [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)];
|
||||||
|
const providers2: Record<string, null> = {};
|
||||||
|
for (const provider of providers) providers2[provider] = null;
|
||||||
|
const customResponse = {
|
||||||
|
TwoFactorProviders: providers,
|
||||||
|
TwoFactorProviders2: providers2,
|
||||||
|
SsoEmail2faSessionToken: null,
|
||||||
|
MasterPasswordPolicy: {
|
||||||
|
Object: 'masterPasswordPolicy',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bitwarden clients rely on these fields to trigger the 2FA UI flow.
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
error: 'invalid_grant',
|
||||||
|
error_description: message,
|
||||||
|
Error: 'invalid_grant',
|
||||||
|
ErrorDescription: message,
|
||||||
|
ErrorMessage: message,
|
||||||
|
TwoFactorProviders: customResponse.TwoFactorProviders,
|
||||||
|
TwoFactorProviders2: customResponse.TwoFactorProviders2,
|
||||||
|
// Required by current Android parser (nullable value is acceptable).
|
||||||
|
SsoEmail2faSessionToken: customResponse.SsoEmail2faSessionToken,
|
||||||
|
MasterPasswordPolicy: customResponse.MasterPasswordPolicy,
|
||||||
|
CustomResponse: customResponse,
|
||||||
|
ErrorModel: {
|
||||||
|
Message: message,
|
||||||
|
Object: 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordFailedLoginAndBuildResponse(
|
||||||
|
rateLimit: RateLimitService,
|
||||||
|
loginIdentifier: string,
|
||||||
|
message: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const result = await rateLimit.recordFailedLogin(loginIdentifier);
|
||||||
|
if (result.locked) {
|
||||||
|
return identityErrorResponse(
|
||||||
|
`Too many failed login attempts. Account locked for ${Math.ceil(result.retryAfterSeconds! / 60)} minutes.`,
|
||||||
|
'TooManyRequests',
|
||||||
|
429
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return identityErrorResponse(message, 'invalid_grant', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordFailedTwoFactorAndBuildResponse(
|
||||||
|
rateLimit: RateLimitService,
|
||||||
|
loginIdentifier: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const failed = await rateLimit.recordFailedLogin(loginIdentifier);
|
||||||
|
if (failed.locked) {
|
||||||
|
return identityErrorResponse(
|
||||||
|
`Too many failed login attempts. Account locked for ${Math.ceil(failed.retryAfterSeconds! / 60)} minutes.`,
|
||||||
|
'TooManyRequests',
|
||||||
|
429
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return identityErrorResponse('Two-step token is invalid. Try again.', 'invalid_grant', 400);
|
||||||
|
}
|
||||||
|
|
||||||
// POST /identity/connect/token
|
// POST /identity/connect/token
|
||||||
export async function handleToken(request: Request, env: Env): Promise<Response> {
|
export async function handleToken(request: Request, env: Env): Promise<Response> {
|
||||||
@@ -12,33 +179,40 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
|
|
||||||
let body: Record<string, string>;
|
let body: Record<string, string>;
|
||||||
const contentType = request.headers.get('content-type') || '';
|
const contentType = request.headers.get('content-type') || '';
|
||||||
|
try {
|
||||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
body = Object.fromEntries(formData.entries()) as Record<string, string>;
|
body = Object.fromEntries(formData.entries()) as Record<string, string>;
|
||||||
} else {
|
} else {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
return identityErrorResponse('Invalid request payload', 'invalid_request', 400);
|
||||||
|
}
|
||||||
|
|
||||||
const grantType = body.grant_type;
|
const grantType = body.grant_type;
|
||||||
|
const clientIdentifier = getClientIdentifier(request);
|
||||||
|
if (!clientIdentifier) {
|
||||||
|
return identityErrorResponse('Client IP is required', 'invalid_request', 403);
|
||||||
|
}
|
||||||
|
|
||||||
if (grantType === 'password') {
|
if (grantType === 'password') {
|
||||||
// Login with password
|
// Login with password
|
||||||
const email = body.username?.toLowerCase();
|
const email = body.username?.toLowerCase();
|
||||||
const passwordHash = body.password;
|
const passwordHash = body.password;
|
||||||
|
const twoFactorToken = body.twoFactorToken;
|
||||||
|
const twoFactorProvider = body.twoFactorProvider;
|
||||||
|
const twoFactorRemember = body.twoFactorRemember;
|
||||||
|
const loginIdentifier = `${clientIdentifier}:${email}`;
|
||||||
|
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
||||||
|
|
||||||
if (!email || !passwordHash) {
|
if (!email || !passwordHash) {
|
||||||
// Bitwarden clients expect OAuth-style error fields.
|
// Bitwarden clients expect OAuth-style error fields.
|
||||||
return identityErrorResponse('Email and password are required', 'invalid_request', 400);
|
return identityErrorResponse('Email and password are required', 'invalid_request', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await storage.getUser(email);
|
// Check login lockout before user lookup to reduce user-enumeration signal
|
||||||
if (!user) {
|
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
|
||||||
return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if login is rate limited (only after confirming user exists)
|
|
||||||
const loginCheck = await rateLimit.checkLoginAttempt(email);
|
|
||||||
if (!loginCheck.allowed) {
|
if (!loginCheck.allowed) {
|
||||||
return identityErrorResponse(
|
return identityErrorResponse(
|
||||||
`Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`,
|
`Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`,
|
||||||
@@ -47,116 +221,271 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash);
|
const user = await storage.getUser(email);
|
||||||
|
if (!user) {
|
||||||
|
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||||
|
return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400);
|
||||||
|
}
|
||||||
|
if (user.status !== 'active') {
|
||||||
|
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||||
|
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
// Record failed login attempt
|
return recordFailedLoginAndBuildResponse(
|
||||||
const result = await rateLimit.recordFailedLogin(email);
|
rateLimit,
|
||||||
if (result.locked) {
|
loginIdentifier,
|
||||||
|
'Username or password is incorrect. Try again'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional 2FA: enabled only by per-user secret.
|
||||||
|
let trustedTwoFactorTokenToReturn: string | undefined;
|
||||||
|
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret);
|
||||||
|
if (effectiveTotpSecret) {
|
||||||
|
const canUseRecoveryCode = !!user.totpRecoveryCode;
|
||||||
|
const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim();
|
||||||
|
const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim();
|
||||||
|
let rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim());
|
||||||
|
const hasProvider = normalizedTwoFactorProvider.length > 0;
|
||||||
|
const hasToken = normalizedTwoFactorToken.length > 0;
|
||||||
|
|
||||||
|
// Upstream-compatible behavior: if 2FA is required and either provider or token is missing,
|
||||||
|
// respond with a 2FA challenge payload.
|
||||||
|
if (!hasProvider || !hasToken) {
|
||||||
|
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
let passedByRememberToken = false;
|
||||||
|
if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_REMEMBER)) {
|
||||||
|
if (deviceInfo.deviceIdentifier) {
|
||||||
|
const trustedUserId = await storage.getTrustedTwoFactorDeviceTokenUserId(
|
||||||
|
normalizedTwoFactorToken,
|
||||||
|
deviceInfo.deviceIdentifier
|
||||||
|
);
|
||||||
|
passedByRememberToken = trustedUserId === user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remember token missing/invalid/expired should re-enter the 2FA challenge flow.
|
||||||
|
if (!passedByRememberToken) {
|
||||||
|
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
|
||||||
|
}
|
||||||
|
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
|
||||||
|
const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken);
|
||||||
|
if (!totpOk) {
|
||||||
|
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
normalizedTwoFactorProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE ||
|
||||||
|
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE_LEGACY) ||
|
||||||
|
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST)
|
||||||
|
) {
|
||||||
|
if (!recoveryCodeEquals(normalizedTwoFactorToken, user.totpRecoveryCode)) {
|
||||||
|
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||||
|
}
|
||||||
|
user.totpSecret = null;
|
||||||
|
user.totpRecoveryCode = createRecoveryCode();
|
||||||
|
user.updatedAt = new Date().toISOString();
|
||||||
|
await storage.saveUser(user);
|
||||||
|
await storage.deleteRefreshTokensByUserId(user.id);
|
||||||
|
rememberRequested = false;
|
||||||
|
} else {
|
||||||
|
// Unsupported provider for this server profile behaves as an invalid 2FA attempt.
|
||||||
|
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upstream behavior: do not issue a new remember token when auth itself used remember provider.
|
||||||
|
if (rememberRequested && !passedByRememberToken && deviceInfo.deviceIdentifier) {
|
||||||
|
trustedTwoFactorTokenToReturn = createRefreshToken();
|
||||||
|
await storage.saveTrustedTwoFactorDeviceToken(
|
||||||
|
trustedTwoFactorTokenToReturn,
|
||||||
|
user.id,
|
||||||
|
deviceInfo.deviceIdentifier,
|
||||||
|
Date.now() + TWO_FACTOR_REMEMBER_TTL_MS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist device only after successful password + (optional) 2FA 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 }),
|
||||||
|
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
||||||
|
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') {
|
||||||
|
const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
|
||||||
|
if (!sendAccessLimit.allowed) {
|
||||||
return identityErrorResponse(
|
return identityErrorResponse(
|
||||||
`Too many failed login attempts. Account locked for ${Math.ceil(result.retryAfterSeconds! / 60)} minutes.`,
|
`Rate limit exceeded. Try again in ${sendAccessLimit.retryAfterSeconds} seconds.`,
|
||||||
'TooManyRequests',
|
'TooManyRequests',
|
||||||
429
|
429
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400);
|
|
||||||
|
const sendId = String(body.send_id || body.sendId || '').trim();
|
||||||
|
if (!sendId) {
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
error: 'invalid_request',
|
||||||
|
error_description: 'send_id is required',
|
||||||
|
send_access_error_type: 'invalid_send_id',
|
||||||
|
ErrorModel: {
|
||||||
|
Message: 'send_id is required',
|
||||||
|
Object: 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Successful login - clear failed attempts
|
const passwordHashB64 = String(
|
||||||
await rateLimit.clearLoginAttempts(email);
|
body.password_hash_b64 || body.passwordHashB64 || body.passwordHash || body.password_hash || ''
|
||||||
|
).trim() || null;
|
||||||
|
const password = String(body.password || '').trim() || null;
|
||||||
|
|
||||||
const accessToken = await auth.generateAccessToken(user);
|
const result = await issueSendAccessToken(
|
||||||
const refreshToken = await auth.generateRefreshToken(user.id);
|
env,
|
||||||
|
sendId,
|
||||||
|
passwordHashB64,
|
||||||
|
password,
|
||||||
|
rateLimit,
|
||||||
|
`${clientIdentifier}:send-password`
|
||||||
|
);
|
||||||
|
if ('error' in result) {
|
||||||
|
return result.error;
|
||||||
|
}
|
||||||
|
|
||||||
const response: TokenResponse = {
|
return jsonResponse({
|
||||||
access_token: accessToken,
|
access_token: result.token,
|
||||||
expires_in: 7200,
|
expires_in: LIMITS.auth.sendAccessTokenTtlSeconds,
|
||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
refresh_token: refreshToken,
|
scope: 'api.send',
|
||||||
Key: user.key,
|
|
||||||
PrivateKey: user.privateKey,
|
|
||||||
Kdf: user.kdfType,
|
|
||||||
KdfIterations: user.kdfIterations,
|
|
||||||
KdfMemory: user.kdfMemory,
|
|
||||||
KdfParallelism: user.kdfParallelism,
|
|
||||||
ForcePasswordReset: false,
|
|
||||||
ResetMasterPassword: false,
|
|
||||||
scope: 'api offline_access',
|
|
||||||
unofficialServer: true,
|
unofficialServer: true,
|
||||||
UserDecryptionOptions: {
|
});
|
||||||
HasMasterPassword: true,
|
|
||||||
Object: 'userDecryptionOptions',
|
|
||||||
MasterPasswordUnlock: {
|
|
||||||
Kdf: {
|
|
||||||
KdfType: user.kdfType,
|
|
||||||
Iterations: user.kdfIterations,
|
|
||||||
Memory: user.kdfMemory || null,
|
|
||||||
Parallelism: user.kdfParallelism || null,
|
|
||||||
},
|
|
||||||
MasterKeyEncryptedUserKey: user.key,
|
|
||||||
MasterKeyWrappedUserKey: user.key,
|
|
||||||
Salt: email, // email is already lowercased above
|
|
||||||
Object: 'masterPasswordUnlock',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return jsonResponse(response);
|
|
||||||
|
|
||||||
} else if (grantType === 'refresh_token') {
|
} else if (grantType === 'refresh_token') {
|
||||||
|
const refreshLimit = await rateLimit.consumeBudget(
|
||||||
|
`${clientIdentifier}:identity-refresh`,
|
||||||
|
LIMITS.rateLimit.refreshTokenRequestsPerMinute
|
||||||
|
);
|
||||||
|
if (!refreshLimit.allowed) {
|
||||||
|
return identityErrorResponse(
|
||||||
|
`Rate limit exceeded. Try again in ${refreshLimit.retryAfterSeconds} seconds.`,
|
||||||
|
'TooManyRequests',
|
||||||
|
429
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 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 errorResponse('Refresh token is required', 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 errorResponse('Invalid refresh token', 401);
|
const invalidResponse = identityErrorResponse('Invalid refresh token', 'invalid_grant', 400);
|
||||||
|
return shouldUseWebSession(request)
|
||||||
|
? withWebRefreshCookie(request, invalidResponse, null)
|
||||||
|
: invalidResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Revoke old refresh token (prevent reuse)
|
// Keep a short overlap window for old refresh token to absorb
|
||||||
await storage.deleteRefreshToken(refreshToken);
|
// concurrent refresh requests from multiple client contexts.
|
||||||
|
await storage.constrainRefreshTokenExpiry(
|
||||||
|
refreshToken,
|
||||||
|
Date.now() + LIMITS.auth.refreshTokenOverlapGraceMs
|
||||||
|
);
|
||||||
|
|
||||||
const { accessToken, user } = result;
|
const { accessToken, user, device } = result;
|
||||||
const newRefreshToken = await auth.generateRefreshToken(user.id);
|
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: 7200,
|
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: accountKeys,
|
||||||
|
accountKeys: accountKeys,
|
||||||
Kdf: user.kdfType,
|
Kdf: user.kdfType,
|
||||||
KdfIterations: user.kdfIterations,
|
KdfIterations: user.kdfIterations,
|
||||||
KdfMemory: user.kdfMemory,
|
KdfMemory: user.kdfMemory,
|
||||||
KdfParallelism: user.kdfParallelism,
|
KdfParallelism: user.kdfParallelism,
|
||||||
ForcePasswordReset: false,
|
ForcePasswordReset: false,
|
||||||
ResetMasterPassword: false,
|
ResetMasterPassword: false,
|
||||||
|
MasterPasswordPolicy: {
|
||||||
|
Object: 'masterPasswordPolicy',
|
||||||
|
},
|
||||||
|
ApiUseKeyConnector: false,
|
||||||
scope: 'api offline_access',
|
scope: 'api offline_access',
|
||||||
unofficialServer: true,
|
unofficialServer: true,
|
||||||
UserDecryptionOptions: {
|
UserDecryptionOptions: userDecryptionOptions,
|
||||||
HasMasterPassword: true,
|
userDecryptionOptions: userDecryptionOptions,
|
||||||
Object: 'userDecryptionOptions',
|
|
||||||
MasterPasswordUnlock: {
|
|
||||||
Kdf: {
|
|
||||||
KdfType: user.kdfType,
|
|
||||||
Iterations: user.kdfIterations,
|
|
||||||
Memory: user.kdfMemory || null,
|
|
||||||
Parallelism: user.kdfParallelism || null,
|
|
||||||
},
|
|
||||||
MasterKeyEncryptedUserKey: user.key,
|
|
||||||
MasterKeyWrappedUserKey: user.key,
|
|
||||||
Salt: user.email.toLowerCase(),
|
|
||||||
Object: 'masterPasswordUnlock',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return jsonResponse(response);
|
const baseResponse = jsonResponse(response);
|
||||||
|
return shouldUseWebSession(request)
|
||||||
|
? withWebRefreshCookie(request, baseResponse, newRefreshToken)
|
||||||
|
: baseResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
return errorResponse('Unsupported grant type', 400);
|
return identityErrorResponse('Unsupported grant type', 'unsupported_grant_type', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /identity/accounts/prelogin
|
// POST /identity/accounts/prelogin
|
||||||
@@ -179,14 +508,45 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
|
|||||||
|
|
||||||
// Return default KDF settings even if user doesn't exist (to prevent user enumeration)
|
// Return default KDF settings even if user doesn't exist (to prevent user enumeration)
|
||||||
const kdfType = user?.kdfType ?? 0;
|
const kdfType = user?.kdfType ?? 0;
|
||||||
const kdfIterations = user?.kdfIterations ?? 600000;
|
const kdfIterations = user?.kdfIterations ?? LIMITS.auth.defaultKdfIterations;
|
||||||
const kdfMemory = user?.kdfMemory;
|
// Use ?? null so non-existent users return null (not undefined/omitted) for these fields,
|
||||||
const kdfParallelism = user?.kdfParallelism;
|
// matching the response shape of real PBKDF2 users and reducing enumeration signal.
|
||||||
|
const kdfMemory = user?.kdfMemory ?? null;
|
||||||
|
const kdfParallelism = user?.kdfParallelism ?? null;
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse(buildPreloginResponse(email, kdfType, kdfIterations, kdfMemory, kdfParallelism));
|
||||||
kdf: kdfType,
|
}
|
||||||
kdfIterations: kdfIterations,
|
|
||||||
kdfMemory: kdfMemory,
|
// POST /identity/connect/revocation
|
||||||
kdfParallelism: kdfParallelism,
|
// Best-effort OAuth token revocation endpoint.
|
||||||
});
|
// RFC 7009 allows returning 200 even if token is unknown.
|
||||||
|
export async function handleRevocation(request: Request, env: Env): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
|
let body: Record<string, string>;
|
||||||
|
const contentType = request.headers.get('content-type') || '';
|
||||||
|
try {
|
||||||
|
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 new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = String(body.token || '').trim() || (
|
||||||
|
shouldUseWebSession(request)
|
||||||
|
? (parseCookieValue(request, WEB_REFRESH_COOKIE) || '')
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
if (token) {
|
||||||
|
await storage.deleteRefreshToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseResponse = new Response(null, { status: 200 });
|
||||||
|
return shouldUseWebSession(request)
|
||||||
|
? withWebRefreshCookie(request, baseResponse, null)
|
||||||
|
: baseResponse;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,32 @@
|
|||||||
import { Env, Cipher, Folder, CipherType } from '../types';
|
import { Env, Cipher, Folder, CipherType } from '../types';
|
||||||
|
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { errorResponse } from '../utils/response';
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
|
import { readActingDeviceIdentifier } from '../utils/device';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
import { LIMITS } from '../config/limits';
|
||||||
|
import { normalizeCipherLoginForStorage, normalizeCipherSshKeyForCompatibility } from './ciphers';
|
||||||
|
|
||||||
// Bitwarden client import request format
|
// Bitwarden client import request format
|
||||||
interface CiphersImportRequest {
|
interface CiphersImportRequest {
|
||||||
ciphers: Array<{
|
ciphers: Array<{
|
||||||
|
id?: string | null;
|
||||||
type: number;
|
type: number;
|
||||||
name: string;
|
name?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
favorite?: boolean;
|
favorite?: boolean;
|
||||||
reprompt?: number;
|
reprompt?: number;
|
||||||
|
sshKey?: any | null;
|
||||||
|
key?: string | null;
|
||||||
login?: {
|
login?: {
|
||||||
uris?: Array<{ uri: string | null; match?: number | null }> | null;
|
uris?: Array<{ uri: string | null; match?: number | null }> | null;
|
||||||
username?: string | null;
|
username?: string | null;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
totp?: string | null;
|
totp?: string | null;
|
||||||
|
autofillOnPageLoad?: boolean | null;
|
||||||
|
uri?: string | null;
|
||||||
|
passwordRevisionDate?: string | null;
|
||||||
|
[key: string]: any;
|
||||||
} | null;
|
} | null;
|
||||||
card?: {
|
card?: {
|
||||||
cardholderName?: string | null;
|
cardholderName?: string | null;
|
||||||
@@ -56,6 +67,7 @@ interface CiphersImportRequest {
|
|||||||
password: string;
|
password: string;
|
||||||
lastUsedDate: string;
|
lastUsedDate: string;
|
||||||
}> | null;
|
}> | null;
|
||||||
|
[key: string]: any;
|
||||||
}>;
|
}>;
|
||||||
folders: Array<{
|
folders: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
@@ -66,9 +78,22 @@ interface CiphersImportRequest {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bindNull(v: any): any {
|
||||||
|
return v === undefined ? null : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> {
|
||||||
|
for (let i = 0; i < statements.length; i += chunkSize) {
|
||||||
|
const chunk = statements.slice(i, i + chunkSize);
|
||||||
|
await db.batch(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// POST /api/ciphers/import - Bitwarden client import endpoint
|
// POST /api/ciphers/import - Bitwarden client import endpoint
|
||||||
export async function handleCiphersImport(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleCiphersImport(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const returnCipherMap = url.searchParams.get('returnCipherMap') === '1';
|
||||||
|
|
||||||
let importData: CiphersImportRequest;
|
let importData: CiphersImportRequest;
|
||||||
try {
|
try {
|
||||||
@@ -81,10 +106,16 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
const ciphers = importData.ciphers || [];
|
const ciphers = importData.ciphers || [];
|
||||||
const folderRelationships = importData.folderRelationships || [];
|
const folderRelationships = importData.folderRelationships || [];
|
||||||
|
|
||||||
|
if (folders.length + ciphers.length > LIMITS.performance.importItemLimit) {
|
||||||
|
return errorResponse(`Import exceeds maximum of ${LIMITS.performance.importItemLimit} items`, 400);
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
const batchChunkSize = LIMITS.performance.bulkMoveChunkSize;
|
||||||
|
|
||||||
// Create folders and build index -> id mapping
|
// Create folders and build index -> id mapping
|
||||||
const folderIdMap = new Map<number, string>();
|
const folderIdMap = new Map<number, string>();
|
||||||
|
const folderRows: Folder[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < folders.length; i++) {
|
for (let i = 0; i < folders.length; i++) {
|
||||||
const folderId = generateUUID();
|
const folderId = generateUUID();
|
||||||
@@ -98,7 +129,19 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
await storage.saveFolder(folder);
|
folderRows.push(folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (folderRows.length > 0) {
|
||||||
|
const folderStatements = folderRows.map(folder =>
|
||||||
|
env.DB
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO folders(id, user_id, name, created_at, updated_at) VALUES(?, ?, ?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(id) DO UPDATE SET user_id=excluded.user_id, name=excluded.name, updated_at=excluded.updated_at'
|
||||||
|
)
|
||||||
|
.bind(folder.id, folder.userId, folder.name, folder.createdAt, folder.updatedAt)
|
||||||
|
);
|
||||||
|
await runBatchInChunks(env.DB, folderStatements, batchChunkSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build cipher index -> folder id mapping from relationships
|
// Build cipher index -> folder id mapping from relationships
|
||||||
@@ -111,81 +154,132 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create ciphers
|
// Create ciphers
|
||||||
|
const cipherRows: Cipher[] = [];
|
||||||
|
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) || c.folderId || null;
|
||||||
|
const sourceIdRaw = String(c?.id ?? '').trim();
|
||||||
|
const sourceId = sourceIdRaw || null;
|
||||||
|
|
||||||
const cipher: Cipher = {
|
const cipher: Cipher = {
|
||||||
|
...c,
|
||||||
id: generateUUID(),
|
id: generateUUID(),
|
||||||
userId: userId,
|
userId: userId,
|
||||||
type: c.type as CipherType,
|
type: c.type as CipherType,
|
||||||
folderId: folderId,
|
folderId: folderId,
|
||||||
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: c.login ? {
|
||||||
username: c.login.username || null,
|
...c.login,
|
||||||
password: c.login.password || null,
|
username: c.login.username ?? null,
|
||||||
|
password: c.login.password ?? null,
|
||||||
uris: c.login.uris?.map(u => ({
|
uris: c.login.uris?.map(u => ({
|
||||||
uri: u.uri || null,
|
...u,
|
||||||
|
uri: u.uri ?? null,
|
||||||
uriChecksum: null,
|
uriChecksum: null,
|
||||||
match: u.match ?? null,
|
match: u.match ?? null,
|
||||||
})) || null,
|
})) || null,
|
||||||
totp: c.login.totp || null,
|
totp: c.login.totp ?? null,
|
||||||
autofillOnPageLoad: null,
|
autofillOnPageLoad: c.login.autofillOnPageLoad ?? null,
|
||||||
fido2Credentials: null,
|
fido2Credentials: Array.isArray(c.login.fido2Credentials) ? c.login.fido2Credentials : null,
|
||||||
uri: null,
|
uri: c.login.uri ?? null,
|
||||||
passwordRevisionDate: null,
|
passwordRevisionDate: c.login.passwordRevisionDate ?? null,
|
||||||
} : null,
|
} : null,
|
||||||
card: c.card ? {
|
card: c.card ? {
|
||||||
cardholderName: c.card.cardholderName || null,
|
...c.card,
|
||||||
brand: c.card.brand || null,
|
cardholderName: c.card.cardholderName ?? null,
|
||||||
number: c.card.number || null,
|
brand: c.card.brand ?? null,
|
||||||
expMonth: c.card.expMonth || null,
|
number: c.card.number ?? null,
|
||||||
expYear: c.card.expYear || null,
|
expMonth: c.card.expMonth ?? null,
|
||||||
code: c.card.code || null,
|
expYear: c.card.expYear ?? null,
|
||||||
|
code: c.card.code ?? null,
|
||||||
} : null,
|
} : null,
|
||||||
identity: c.identity ? {
|
identity: c.identity ? {
|
||||||
title: c.identity.title || null,
|
...c.identity,
|
||||||
firstName: c.identity.firstName || null,
|
title: c.identity.title ?? null,
|
||||||
middleName: c.identity.middleName || null,
|
firstName: c.identity.firstName ?? null,
|
||||||
lastName: c.identity.lastName || null,
|
middleName: c.identity.middleName ?? null,
|
||||||
address1: c.identity.address1 || null,
|
lastName: c.identity.lastName ?? null,
|
||||||
address2: c.identity.address2 || null,
|
address1: c.identity.address1 ?? null,
|
||||||
address3: c.identity.address3 || null,
|
address2: c.identity.address2 ?? null,
|
||||||
city: c.identity.city || null,
|
address3: c.identity.address3 ?? null,
|
||||||
state: c.identity.state || null,
|
city: c.identity.city ?? null,
|
||||||
postalCode: c.identity.postalCode || null,
|
state: c.identity.state ?? null,
|
||||||
country: c.identity.country || null,
|
postalCode: c.identity.postalCode ?? null,
|
||||||
company: c.identity.company || null,
|
country: c.identity.country ?? null,
|
||||||
email: c.identity.email || null,
|
company: c.identity.company ?? null,
|
||||||
phone: c.identity.phone || null,
|
email: c.identity.email ?? null,
|
||||||
ssn: c.identity.ssn || null,
|
phone: c.identity.phone ?? null,
|
||||||
username: c.identity.username || null,
|
ssn: c.identity.ssn ?? null,
|
||||||
passportNumber: c.identity.passportNumber || null,
|
username: c.identity.username ?? null,
|
||||||
licenseNumber: c.identity.licenseNumber || null,
|
passportNumber: c.identity.passportNumber ?? null,
|
||||||
|
licenseNumber: c.identity.licenseNumber ?? null,
|
||||||
} : null,
|
} : null,
|
||||||
secureNote: c.secureNote || null,
|
secureNote: c.secureNote ?? null,
|
||||||
fields: c.fields?.map(f => ({
|
fields: c.fields?.map(f => ({
|
||||||
name: f.name || null,
|
...f,
|
||||||
value: f.value || null,
|
name: f.name ?? 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: c.passwordHistory ?? null,
|
||||||
reprompt: c.reprompt || 0,
|
reprompt: c.reprompt ?? 0,
|
||||||
sshKey: null,
|
sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null),
|
||||||
key: null,
|
key: (c as any).key ?? null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
archivedAt: null,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
};
|
};
|
||||||
|
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||||
|
|
||||||
await storage.saveCipher(cipher);
|
cipherRows.push(cipher);
|
||||||
|
cipherMapRows.push({ index: i, sourceId, id: cipher.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cipherRows.length > 0) {
|
||||||
|
const cipherStatements = cipherRows.map(cipher => {
|
||||||
|
const data = JSON.stringify(cipher);
|
||||||
|
return env.DB
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
|
||||||
|
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||||
|
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at'
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
cipher.id,
|
||||||
|
cipher.userId,
|
||||||
|
Number(cipher.type) || 1,
|
||||||
|
bindNull(cipher.folderId),
|
||||||
|
bindNull(cipher.name),
|
||||||
|
bindNull(cipher.notes),
|
||||||
|
cipher.favorite ? 1 : 0,
|
||||||
|
data,
|
||||||
|
bindNull(cipher.reprompt ?? 0),
|
||||||
|
bindNull(cipher.key),
|
||||||
|
cipher.createdAt,
|
||||||
|
cipher.updatedAt,
|
||||||
|
bindNull(cipher.archivedAt),
|
||||||
|
bindNull(cipher.deletedAt)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await runBatchInChunks(env.DB, cipherStatements, batchChunkSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update revision date
|
// Update revision date
|
||||||
await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||||
|
|
||||||
|
if (returnCipherMap) {
|
||||||
|
return jsonResponse({
|
||||||
|
object: 'import-result',
|
||||||
|
cipherMap: cipherMapRows,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { AuthService } from '../services/auth';
|
||||||
|
import type { Env, JWTPayload } from '../types';
|
||||||
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
|
||||||
|
function extractAccessToken(request: Request): string | null {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const queryToken = String(url.searchParams.get('access_token') || '').trim();
|
||||||
|
if (queryToken) return queryToken;
|
||||||
|
|
||||||
|
const authHeader = String(request.headers.get('Authorization') || '').trim();
|
||||||
|
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||||
|
return match?.[1]?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticateNotificationsRequest(request: Request, env: Env): Promise<JWTPayload | null> {
|
||||||
|
const accessToken = extractAccessToken(request);
|
||||||
|
if (!accessToken) return null;
|
||||||
|
|
||||||
|
const auth = new AuthService(env);
|
||||||
|
return auth.verifyAccessToken(`Bearer ${accessToken}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleNotificationsNegotiate(request: Request, env: Env): Promise<Response> {
|
||||||
|
const payload = await authenticateNotificationsRequest(request, env);
|
||||||
|
if (!payload?.sub) return errorResponse('Unauthorized', 401);
|
||||||
|
|
||||||
|
const connectionId = generateUUID();
|
||||||
|
return jsonResponse({
|
||||||
|
connectionId,
|
||||||
|
connectionToken: connectionId,
|
||||||
|
negotiateVersion: 1,
|
||||||
|
availableTransports: [
|
||||||
|
{
|
||||||
|
transport: 'WebSockets',
|
||||||
|
transferFormats: ['Text', 'Binary'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleNotificationsHub(request: Request, env: Env): Promise<Response> {
|
||||||
|
const payload = await authenticateNotificationsRequest(request, env);
|
||||||
|
if (!payload?.sub) return errorResponse('Unauthorized', 401);
|
||||||
|
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
|
||||||
|
return errorResponse('Expected websocket', 426);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = payload.sub;
|
||||||
|
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
|
||||||
|
const stub = env.NOTIFICATIONS_HUB.get(id);
|
||||||
|
const forwardedUrl = new URL(request.url);
|
||||||
|
forwardedUrl.searchParams.set('nw_uid', userId);
|
||||||
|
if (payload.did) {
|
||||||
|
forwardedUrl.searchParams.set('nw_did', payload.did);
|
||||||
|
}
|
||||||
|
return stub.fetch(new Request(forwardedUrl.toString(), request));
|
||||||
|
}
|
||||||
@@ -0,0 +1,692 @@
|
|||||||
|
import { Env, Send, SendAuthType, SendType } from '../types';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
|
import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload';
|
||||||
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
||||||
|
import { LIMITS } from '../config/limits';
|
||||||
|
import {
|
||||||
|
getBlobStorageMaxBytes,
|
||||||
|
getSendFileObjectKey,
|
||||||
|
putBlobObject,
|
||||||
|
deleteBlobObject,
|
||||||
|
} from '../services/blob-store';
|
||||||
|
import { createSendFileUploadToken, verifySendFileUploadToken } from '../utils/jwt';
|
||||||
|
import {
|
||||||
|
formatSize,
|
||||||
|
getAliasedProp,
|
||||||
|
normalizeEmails,
|
||||||
|
notifyVaultSyncForRequest,
|
||||||
|
parseDate,
|
||||||
|
parseFileLength,
|
||||||
|
parseInteger,
|
||||||
|
parseMaxAccessCount,
|
||||||
|
parseSendAuthType,
|
||||||
|
parseSendType,
|
||||||
|
parseStoredSendData,
|
||||||
|
sanitizeSendData,
|
||||||
|
sendToResponse,
|
||||||
|
setSendPassword,
|
||||||
|
validateDeletionDate,
|
||||||
|
} from './sends-shared';
|
||||||
|
|
||||||
|
async function processSendFileUpload(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
send: Send,
|
||||||
|
fileId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.send.maxFileSizeBytes);
|
||||||
|
const sendData = parseStoredSendData(send);
|
||||||
|
const expectedFileId = typeof sendData.id === 'string' ? sendData.id : null;
|
||||||
|
if (!expectedFileId || expectedFileId !== fileId) {
|
||||||
|
return errorResponse('Send file does not match send data.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedFileName = typeof sendData.fileName === 'string' ? sendData.fileName : null;
|
||||||
|
const expectedSize = parseInteger(sendData.size);
|
||||||
|
const upload = await parseDirectUploadPayload(request, {
|
||||||
|
expectedSize,
|
||||||
|
expectedFileName,
|
||||||
|
maxFileSize,
|
||||||
|
tooLargeMessage: 'Send storage limit exceeded with this file',
|
||||||
|
sizeMismatchMessage: 'Send file size does not match.',
|
||||||
|
fileNameMismatchMessage: 'Send file name does not match.',
|
||||||
|
});
|
||||||
|
if (upload instanceof Response) {
|
||||||
|
return upload;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await putBlobObject(env, getSendFileObjectKey(send.id, fileId), upload.body, {
|
||||||
|
size: upload.size,
|
||||||
|
contentType: upload.contentType,
|
||||||
|
customMetadata: {
|
||||||
|
sendId: send.id,
|
||||||
|
fileId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (message.includes('KV object too large')) {
|
||||||
|
return errorResponse('Send storage limit exceeded with this file', 413);
|
||||||
|
}
|
||||||
|
return errorResponse('Attachment storage is not configured', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||||
|
|
||||||
|
return new Response(null, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetSends(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const pagination = parsePagination(url);
|
||||||
|
|
||||||
|
let sends: Send[];
|
||||||
|
let continuationToken: string | null = null;
|
||||||
|
if (pagination) {
|
||||||
|
const pageRows = await storage.getSendsPage(userId, pagination.limit + 1, pagination.offset);
|
||||||
|
const hasNext = pageRows.length > pagination.limit;
|
||||||
|
sends = hasNext ? pageRows.slice(0, pagination.limit) : pageRows;
|
||||||
|
continuationToken = hasNext ? encodeContinuationToken(pagination.offset + sends.length) : null;
|
||||||
|
} else {
|
||||||
|
sends = await storage.getAllSends(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendResponses = sends.map(sendToResponse);
|
||||||
|
return jsonResponse({
|
||||||
|
data: sendResponses,
|
||||||
|
object: 'list',
|
||||||
|
continuationToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const send = await storage.getSend(sendId);
|
||||||
|
|
||||||
|
if (!send || send.userId !== userId) {
|
||||||
|
return errorResponse('Send not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(sendToResponse(send));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCreateSend(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeRaw = getAliasedProp(body, ['type', 'Type']);
|
||||||
|
const sendType = parseSendType(typeRaw.value);
|
||||||
|
if (sendType === null) {
|
||||||
|
return errorResponse('Invalid Send type', 400);
|
||||||
|
}
|
||||||
|
if (sendType === SendType.File) {
|
||||||
|
return errorResponse('File sends should use /api/sends/file/v2', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameRaw = getAliasedProp(body, ['name', 'Name']);
|
||||||
|
const keyRaw = getAliasedProp(body, ['key', 'Key']);
|
||||||
|
const deletionDateRaw = getAliasedProp(body, ['deletionDate', 'DeletionDate']);
|
||||||
|
const textRaw = getAliasedProp(body, ['text', 'Text']);
|
||||||
|
|
||||||
|
if (typeof nameRaw.value !== 'string' || !nameRaw.value.trim()) {
|
||||||
|
return errorResponse('Name is required', 400);
|
||||||
|
}
|
||||||
|
if (typeof keyRaw.value !== 'string' || !keyRaw.value.trim()) {
|
||||||
|
return errorResponse('Key is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletionDate = parseDate(deletionDateRaw.value);
|
||||||
|
if (!deletionDate) {
|
||||||
|
return errorResponse('Invalid deletionDate', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletionValidation = validateDeletionDate(deletionDate);
|
||||||
|
if (deletionValidation) return deletionValidation;
|
||||||
|
|
||||||
|
const sendData = sanitizeSendData(textRaw.value);
|
||||||
|
if (!sendData) {
|
||||||
|
return errorResponse('Send data not provided', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxAccessRaw = getAliasedProp(body, ['maxAccessCount', 'MaxAccessCount']);
|
||||||
|
const maxAccess = parseMaxAccessCount(maxAccessRaw.value);
|
||||||
|
if (!maxAccess.ok) return maxAccess.response;
|
||||||
|
|
||||||
|
const expirationRaw = getAliasedProp(body, ['expirationDate', 'ExpirationDate']);
|
||||||
|
const expirationDate = expirationRaw.value === null || expirationRaw.value === undefined
|
||||||
|
? null
|
||||||
|
: parseDate(expirationRaw.value);
|
||||||
|
if (expirationRaw.value !== null && expirationRaw.value !== undefined && !expirationDate) {
|
||||||
|
return errorResponse('Invalid expirationDate', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabledRaw = getAliasedProp(body, ['disabled', 'Disabled']);
|
||||||
|
const hideEmailRaw = getAliasedProp(body, ['hideEmail', 'HideEmail']);
|
||||||
|
const notesRaw = getAliasedProp(body, ['notes', 'Notes']);
|
||||||
|
const passwordRaw = getAliasedProp(body, ['password', 'Password']);
|
||||||
|
const authTypeRaw = getAliasedProp(body, ['authType', 'AuthType']);
|
||||||
|
const emailsRaw = getAliasedProp(body, ['emails', 'Emails']);
|
||||||
|
|
||||||
|
const requestedAuthType = parseSendAuthType(authTypeRaw.value);
|
||||||
|
if (authTypeRaw.present && requestedAuthType === null) {
|
||||||
|
return errorResponse('Invalid authType', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEmails = normalizeEmails(emailsRaw.value);
|
||||||
|
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
|
||||||
|
return errorResponse('Invalid emails', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const send: Send = {
|
||||||
|
id: generateUUID(),
|
||||||
|
userId,
|
||||||
|
type: sendType,
|
||||||
|
name: nameRaw.value.trim(),
|
||||||
|
notes: typeof notesRaw.value === 'string' ? notesRaw.value : null,
|
||||||
|
data: JSON.stringify(sendData),
|
||||||
|
key: keyRaw.value,
|
||||||
|
passwordHash: null,
|
||||||
|
passwordSalt: null,
|
||||||
|
passwordIterations: null,
|
||||||
|
authType: requestedAuthType ?? SendAuthType.None,
|
||||||
|
emails: normalizedEmails,
|
||||||
|
maxAccessCount: maxAccess.value,
|
||||||
|
accessCount: 0,
|
||||||
|
disabled: typeof disabledRaw.value === 'boolean' ? disabledRaw.value : false,
|
||||||
|
hideEmail: typeof hideEmailRaw.value === 'boolean' ? hideEmailRaw.value : null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
expirationDate: expirationDate ? expirationDate.toISOString() : null,
|
||||||
|
deletionDate: deletionDate.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof passwordRaw.value === 'string' && passwordRaw.value.length > 0) {
|
||||||
|
await setSendPassword(send, passwordRaw.value);
|
||||||
|
} else if (send.authType === SendAuthType.Password) {
|
||||||
|
return errorResponse('Password is required for password auth', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (send.authType !== SendAuthType.Email) {
|
||||||
|
send.emails = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.saveSend(send);
|
||||||
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
|
return jsonResponse(sendToResponse(send));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCreateFileSendV2(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.send.maxFileSizeBytes);
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeRaw = getAliasedProp(body, ['type', 'Type']);
|
||||||
|
const sendType = parseSendType(typeRaw.value);
|
||||||
|
if (sendType !== SendType.File) {
|
||||||
|
return errorResponse('Send content is not a file', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileLengthRaw = getAliasedProp(body, ['fileLength', 'FileLength']);
|
||||||
|
const fileLengthParsed = parseFileLength(fileLengthRaw.value);
|
||||||
|
if (!fileLengthParsed.ok) return fileLengthParsed.response;
|
||||||
|
if (fileLengthParsed.value > maxFileSize) {
|
||||||
|
return errorResponse('Send storage limit exceeded with this file', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameRaw = getAliasedProp(body, ['name', 'Name']);
|
||||||
|
const keyRaw = getAliasedProp(body, ['key', 'Key']);
|
||||||
|
const deletionDateRaw = getAliasedProp(body, ['deletionDate', 'DeletionDate']);
|
||||||
|
const fileRaw = getAliasedProp(body, ['file', 'File']);
|
||||||
|
|
||||||
|
if (typeof nameRaw.value !== 'string' || !nameRaw.value.trim()) {
|
||||||
|
return errorResponse('Name is required', 400);
|
||||||
|
}
|
||||||
|
if (typeof keyRaw.value !== 'string' || !keyRaw.value.trim()) {
|
||||||
|
return errorResponse('Key is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletionDate = parseDate(deletionDateRaw.value);
|
||||||
|
if (!deletionDate) {
|
||||||
|
return errorResponse('Invalid deletionDate', 400);
|
||||||
|
}
|
||||||
|
const deletionValidation = validateDeletionDate(deletionDate);
|
||||||
|
if (deletionValidation) return deletionValidation;
|
||||||
|
|
||||||
|
const fileData = sanitizeSendData(fileRaw.value);
|
||||||
|
if (!fileData) {
|
||||||
|
return errorResponse('Send data not provided', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileId = generateUUID();
|
||||||
|
fileData.id = fileId;
|
||||||
|
fileData.size = fileLengthParsed.value;
|
||||||
|
fileData.sizeName = formatSize(fileLengthParsed.value);
|
||||||
|
|
||||||
|
const maxAccessRaw = getAliasedProp(body, ['maxAccessCount', 'MaxAccessCount']);
|
||||||
|
const maxAccess = parseMaxAccessCount(maxAccessRaw.value);
|
||||||
|
if (!maxAccess.ok) return maxAccess.response;
|
||||||
|
|
||||||
|
const expirationRaw = getAliasedProp(body, ['expirationDate', 'ExpirationDate']);
|
||||||
|
const expirationDate = expirationRaw.value === null || expirationRaw.value === undefined
|
||||||
|
? null
|
||||||
|
: parseDate(expirationRaw.value);
|
||||||
|
if (expirationRaw.value !== null && expirationRaw.value !== undefined && !expirationDate) {
|
||||||
|
return errorResponse('Invalid expirationDate', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabledRaw = getAliasedProp(body, ['disabled', 'Disabled']);
|
||||||
|
const hideEmailRaw = getAliasedProp(body, ['hideEmail', 'HideEmail']);
|
||||||
|
const notesRaw = getAliasedProp(body, ['notes', 'Notes']);
|
||||||
|
const passwordRaw = getAliasedProp(body, ['password', 'Password']);
|
||||||
|
const authTypeRaw = getAliasedProp(body, ['authType', 'AuthType']);
|
||||||
|
const emailsRaw = getAliasedProp(body, ['emails', 'Emails']);
|
||||||
|
|
||||||
|
const requestedAuthType = parseSendAuthType(authTypeRaw.value);
|
||||||
|
if (authTypeRaw.present && requestedAuthType === null) {
|
||||||
|
return errorResponse('Invalid authType', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEmails = normalizeEmails(emailsRaw.value);
|
||||||
|
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
|
||||||
|
return errorResponse('Invalid emails', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const send: Send = {
|
||||||
|
id: generateUUID(),
|
||||||
|
userId,
|
||||||
|
type: sendType,
|
||||||
|
name: nameRaw.value.trim(),
|
||||||
|
notes: typeof notesRaw.value === 'string' ? notesRaw.value : null,
|
||||||
|
data: JSON.stringify(fileData),
|
||||||
|
key: keyRaw.value,
|
||||||
|
passwordHash: null,
|
||||||
|
passwordSalt: null,
|
||||||
|
passwordIterations: null,
|
||||||
|
authType: requestedAuthType ?? SendAuthType.None,
|
||||||
|
emails: normalizedEmails,
|
||||||
|
maxAccessCount: maxAccess.value,
|
||||||
|
accessCount: 0,
|
||||||
|
disabled: typeof disabledRaw.value === 'boolean' ? disabledRaw.value : false,
|
||||||
|
hideEmail: typeof hideEmailRaw.value === 'boolean' ? hideEmailRaw.value : null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
expirationDate: expirationDate ? expirationDate.toISOString() : null,
|
||||||
|
deletionDate: deletionDate.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof passwordRaw.value === 'string' && passwordRaw.value.length > 0) {
|
||||||
|
await setSendPassword(send, passwordRaw.value);
|
||||||
|
} else if (send.authType === SendAuthType.Password) {
|
||||||
|
return errorResponse('Password is required for password auth', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (send.authType !== SendAuthType.Email) {
|
||||||
|
send.emails = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.saveSend(send);
|
||||||
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
const jwtSecret = getSafeJwtSecret(env);
|
||||||
|
if (!jwtSecret) {
|
||||||
|
return errorResponse('Server configuration error', 500);
|
||||||
|
}
|
||||||
|
const uploadToken = await createSendFileUploadToken(userId, send.id, fileId, jwtSecret);
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
fileUploadType: 1,
|
||||||
|
object: 'send-fileUpload',
|
||||||
|
url: buildDirectUploadUrl(request, `/api/sends/${send.id}/file/${fileId}`, uploadToken),
|
||||||
|
sendResponse: sendToResponse(send),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetSendFileUpload(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
sendId: string,
|
||||||
|
fileId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const send = await storage.getSend(sendId);
|
||||||
|
if (!send || send.userId !== userId) {
|
||||||
|
return errorResponse('Send not found', 404);
|
||||||
|
}
|
||||||
|
if (send.type !== SendType.File) {
|
||||||
|
return errorResponse('Send is not a file type send.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendData = parseStoredSendData(send);
|
||||||
|
const expectedFileId = typeof sendData.id === 'string' ? sendData.id : null;
|
||||||
|
if (!expectedFileId || expectedFileId !== fileId) {
|
||||||
|
return errorResponse('Send file does not match send data.', 400);
|
||||||
|
}
|
||||||
|
const jwtSecret = getSafeJwtSecret(env);
|
||||||
|
if (!jwtSecret) {
|
||||||
|
return errorResponse('Server configuration error', 500);
|
||||||
|
}
|
||||||
|
const uploadToken = await createSendFileUploadToken(userId, send.id, fileId, jwtSecret);
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
fileUploadType: 1,
|
||||||
|
object: 'send-fileUpload',
|
||||||
|
url: buildDirectUploadUrl(request, `/api/sends/${send.id}/file/${fileId}`, uploadToken),
|
||||||
|
sendResponse: sendToResponse(send),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleUploadSendFile(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
sendId: string,
|
||||||
|
fileId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const send = await storage.getSend(sendId);
|
||||||
|
if (!send || send.userId !== userId) {
|
||||||
|
return errorResponse('Send not found. Unable to save the file.', 404);
|
||||||
|
}
|
||||||
|
if (send.type !== SendType.File) {
|
||||||
|
return errorResponse('Send is not a file type send.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return processSendFileUpload(request, env, send, fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handlePublicUploadSendFile(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
sendId: string,
|
||||||
|
fileId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const jwtSecret = getSafeJwtSecret(env);
|
||||||
|
if (!jwtSecret) {
|
||||||
|
return errorResponse('Server configuration error', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = new URL(request.url).searchParams.get('token');
|
||||||
|
if (!token) {
|
||||||
|
return errorResponse('Token required', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims = await verifySendFileUploadToken(token, jwtSecret);
|
||||||
|
if (!claims) {
|
||||||
|
return errorResponse('Invalid or expired token', 401);
|
||||||
|
}
|
||||||
|
if (claims.sendId !== sendId || claims.fileId !== fileId) {
|
||||||
|
return errorResponse('Token mismatch', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const send = await storage.getSend(sendId);
|
||||||
|
if (!send || send.userId !== claims.userId) {
|
||||||
|
return errorResponse('Send not found. Unable to save the file.', 404);
|
||||||
|
}
|
||||||
|
if (send.type !== SendType.File) {
|
||||||
|
return errorResponse('Send is not a file type send.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return processSendFileUpload(request, env, send, fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleUpdateSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const send = await storage.getSend(sendId);
|
||||||
|
if (!send || send.userId !== userId) {
|
||||||
|
return errorResponse('Send not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeRaw = getAliasedProp(body, ['type', 'Type']);
|
||||||
|
if (typeRaw.present) {
|
||||||
|
const incomingType = parseSendType(typeRaw.value);
|
||||||
|
if (incomingType === null) {
|
||||||
|
return errorResponse('Invalid Send type', 400);
|
||||||
|
}
|
||||||
|
if (incomingType !== send.type) {
|
||||||
|
return errorResponse("Sends can't change type", 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletionRaw = getAliasedProp(body, ['deletionDate', 'DeletionDate']);
|
||||||
|
if (deletionRaw.present) {
|
||||||
|
const deletionDate = parseDate(deletionRaw.value);
|
||||||
|
if (!deletionDate) return errorResponse('Invalid deletionDate', 400);
|
||||||
|
const deletionValidation = validateDeletionDate(deletionDate);
|
||||||
|
if (deletionValidation) return deletionValidation;
|
||||||
|
send.deletionDate = deletionDate.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const expirationRaw = getAliasedProp(body, ['expirationDate', 'ExpirationDate']);
|
||||||
|
if (expirationRaw.present) {
|
||||||
|
if (expirationRaw.value === null || expirationRaw.value === '') {
|
||||||
|
send.expirationDate = null;
|
||||||
|
} else {
|
||||||
|
const expiration = parseDate(expirationRaw.value);
|
||||||
|
if (!expiration) return errorResponse('Invalid expirationDate', 400);
|
||||||
|
send.expirationDate = expiration.toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameRaw = getAliasedProp(body, ['name', 'Name']);
|
||||||
|
if (nameRaw.present) {
|
||||||
|
if (typeof nameRaw.value !== 'string' || !nameRaw.value.trim()) {
|
||||||
|
return errorResponse('Name is required', 400);
|
||||||
|
}
|
||||||
|
send.name = nameRaw.value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyRaw = getAliasedProp(body, ['key', 'Key']);
|
||||||
|
if (keyRaw.present) {
|
||||||
|
if (typeof keyRaw.value !== 'string' || !keyRaw.value.trim()) {
|
||||||
|
return errorResponse('Key is required', 400);
|
||||||
|
}
|
||||||
|
send.key = keyRaw.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notesRaw = getAliasedProp(body, ['notes', 'Notes']);
|
||||||
|
if (notesRaw.present) {
|
||||||
|
send.notes = typeof notesRaw.value === 'string' ? notesRaw.value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabledRaw = getAliasedProp(body, ['disabled', 'Disabled']);
|
||||||
|
if (disabledRaw.present) {
|
||||||
|
if (typeof disabledRaw.value !== 'boolean') {
|
||||||
|
return errorResponse('Invalid disabled', 400);
|
||||||
|
}
|
||||||
|
send.disabled = disabledRaw.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideEmailRaw = getAliasedProp(body, ['hideEmail', 'HideEmail']);
|
||||||
|
if (hideEmailRaw.present) {
|
||||||
|
if (hideEmailRaw.value === null) {
|
||||||
|
send.hideEmail = null;
|
||||||
|
} else if (typeof hideEmailRaw.value === 'boolean') {
|
||||||
|
send.hideEmail = hideEmailRaw.value;
|
||||||
|
} else {
|
||||||
|
return errorResponse('Invalid hideEmail', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxAccessRaw = getAliasedProp(body, ['maxAccessCount', 'MaxAccessCount']);
|
||||||
|
if (maxAccessRaw.present) {
|
||||||
|
const parsedMax = parseMaxAccessCount(maxAccessRaw.value);
|
||||||
|
if (!parsedMax.ok) return parsedMax.response;
|
||||||
|
send.maxAccessCount = parsedMax.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (send.type === SendType.Text) {
|
||||||
|
const textRaw = getAliasedProp(body, ['text', 'Text']);
|
||||||
|
if (textRaw.present) {
|
||||||
|
const textData = sanitizeSendData(textRaw.value);
|
||||||
|
if (!textData) {
|
||||||
|
return errorResponse('Send data not provided', 400);
|
||||||
|
}
|
||||||
|
send.data = JSON.stringify(textData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authTypeRaw = getAliasedProp(body, ['authType', 'AuthType']);
|
||||||
|
if (authTypeRaw.present) {
|
||||||
|
const parsedAuthType = parseSendAuthType(authTypeRaw.value);
|
||||||
|
if (parsedAuthType === null) {
|
||||||
|
return errorResponse('Invalid authType', 400);
|
||||||
|
}
|
||||||
|
send.authType = parsedAuthType;
|
||||||
|
if (parsedAuthType !== SendAuthType.Email) {
|
||||||
|
send.emails = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailsRaw = getAliasedProp(body, ['emails', 'Emails']);
|
||||||
|
if (emailsRaw.present) {
|
||||||
|
const normalizedEmails = normalizeEmails(emailsRaw.value);
|
||||||
|
if (emailsRaw.value !== null && normalizedEmails === null) {
|
||||||
|
return errorResponse('Invalid emails', 400);
|
||||||
|
}
|
||||||
|
send.emails = normalizedEmails;
|
||||||
|
if (send.emails) {
|
||||||
|
send.authType = SendAuthType.Email;
|
||||||
|
} else if (send.authType === SendAuthType.Email) {
|
||||||
|
send.authType = SendAuthType.None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordRaw = getAliasedProp(body, ['password', 'Password']);
|
||||||
|
if (passwordRaw.present && typeof passwordRaw.value === 'string') {
|
||||||
|
await setSendPassword(send, passwordRaw.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (send.authType === SendAuthType.Password && !send.passwordHash) {
|
||||||
|
return errorResponse('Password is required for password auth', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
send.updatedAt = new Date().toISOString();
|
||||||
|
await storage.saveSend(send);
|
||||||
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
|
return jsonResponse(sendToResponse(send));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const send = await storage.getSend(sendId);
|
||||||
|
if (!send || send.userId !== userId) {
|
||||||
|
return errorResponse('Send not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (send.type === SendType.File) {
|
||||||
|
const data = parseStoredSendData(send);
|
||||||
|
const fileId = typeof data.id === 'string' ? data.id : null;
|
||||||
|
if (fileId) {
|
||||||
|
await deleteBlobObject(env, getSendFileObjectKey(send.id, fileId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.deleteSend(sendId, userId);
|
||||||
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleBulkDeleteSends(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
|
let body: { ids?: string[] };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.ids || !Array.isArray(body.ids)) {
|
||||||
|
return errorResponse('ids array is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sends = await storage.getSendsByIds(body.ids, userId);
|
||||||
|
for (const send of sends) {
|
||||||
|
if (send.type !== SendType.File) continue;
|
||||||
|
const data = parseStoredSendData(send);
|
||||||
|
const fileId = typeof data.id === 'string' ? data.id : null;
|
||||||
|
if (fileId) {
|
||||||
|
await deleteBlobObject(env, getSendFileObjectKey(send.id, fileId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisionDate = await storage.bulkDeleteSends(body.ids, userId);
|
||||||
|
if (revisionDate) {
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const send = await storage.getSend(sendId);
|
||||||
|
if (!send || send.userId !== userId) {
|
||||||
|
return errorResponse('Send not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await setSendPassword(send, null);
|
||||||
|
send.updatedAt = new Date().toISOString();
|
||||||
|
await storage.saveSend(send);
|
||||||
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
|
return jsonResponse(sendToResponse(send));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const send = await storage.getSend(sendId);
|
||||||
|
if (!send || send.userId !== userId) {
|
||||||
|
return errorResponse('Send not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
send.authType = SendAuthType.None;
|
||||||
|
send.emails = null;
|
||||||
|
send.updatedAt = new Date().toISOString();
|
||||||
|
await storage.saveSend(send);
|
||||||
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
|
return jsonResponse(sendToResponse(send));
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
import { Env, SendType } from '../types';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||||
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
|
import { LIMITS } from '../config/limits';
|
||||||
|
import {
|
||||||
|
createSendAccessToken,
|
||||||
|
createSendFileDownloadToken,
|
||||||
|
verifySendAccessToken,
|
||||||
|
verifySendFileDownloadToken,
|
||||||
|
} from '../utils/jwt';
|
||||||
|
import {
|
||||||
|
getBlobObject,
|
||||||
|
getSendFileObjectKey,
|
||||||
|
} from '../services/blob-store';
|
||||||
|
import {
|
||||||
|
SEND_INACCESSIBLE_MSG,
|
||||||
|
extractBearerToken,
|
||||||
|
fromAccessId,
|
||||||
|
getCreatorIdentifier,
|
||||||
|
getSafeJwtSecret,
|
||||||
|
hasEmailAuth,
|
||||||
|
isSendAvailable,
|
||||||
|
notifyVaultSyncForRequest,
|
||||||
|
parseStoredSendData,
|
||||||
|
resolveSendFromIdOrAccessId,
|
||||||
|
sendPasswordLimitKey,
|
||||||
|
sendPasswordLockedErrorResponse,
|
||||||
|
sendPasswordLockedOAuthResponse,
|
||||||
|
sendToAccessResponse,
|
||||||
|
validatePublicSendAccess,
|
||||||
|
verifySendPassword,
|
||||||
|
verifySendPasswordHashB64,
|
||||||
|
} from './sends-shared';
|
||||||
|
|
||||||
|
export async function handleAccessSend(request: Request, env: Env, accessId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const sendId = fromAccessId(accessId);
|
||||||
|
if (!sendId) {
|
||||||
|
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const send = await storage.getSend(sendId);
|
||||||
|
if (!send || !isSendAvailable(send)) {
|
||||||
|
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown = {};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
body = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
let sendPasswordLimitIpKey: string | null = null;
|
||||||
|
let sendPasswordRateLimit: RateLimitService | null = null;
|
||||||
|
if (send.passwordHash) {
|
||||||
|
const clientIdentifier = getClientIdentifier(request);
|
||||||
|
if (!clientIdentifier) {
|
||||||
|
return errorResponse('Client IP is required', 403);
|
||||||
|
}
|
||||||
|
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier);
|
||||||
|
sendPasswordRateLimit = new RateLimitService(env.DB);
|
||||||
|
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
|
||||||
|
if (!sendPasswordCheck.allowed) {
|
||||||
|
return sendPasswordLockedErrorResponse(sendPasswordCheck.retryAfterSeconds || 60);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = await validatePublicSendAccess(send, body);
|
||||||
|
if (!validation.ok) {
|
||||||
|
if (validation.reason === 'invalid_password' && sendPasswordRateLimit && sendPasswordLimitIpKey) {
|
||||||
|
const failed = await sendPasswordRateLimit.recordFailedLogin(sendPasswordLimitIpKey);
|
||||||
|
if (failed.locked) {
|
||||||
|
return sendPasswordLockedErrorResponse(failed.retryAfterSeconds || 60);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return validation.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (send.passwordHash && sendPasswordRateLimit && sendPasswordLimitIpKey) {
|
||||||
|
await sendPasswordRateLimit.clearLoginAttempts(sendPasswordLimitIpKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (send.type === SendType.Text) {
|
||||||
|
const updated = await storage.incrementSendAccessCount(send.id);
|
||||||
|
if (!updated) {
|
||||||
|
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||||
|
}
|
||||||
|
send.accessCount += 1;
|
||||||
|
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const creatorIdentifier = await getCreatorIdentifier(storage, send);
|
||||||
|
return jsonResponse(sendToAccessResponse(send, creatorIdentifier));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAccessSendFile(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
idOrAccessId: string,
|
||||||
|
fileId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const secret = (env.JWT_SECRET || '').trim();
|
||||||
|
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) {
|
||||||
|
return errorResponse('Server configuration error', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const send = await resolveSendFromIdOrAccessId(storage, idOrAccessId);
|
||||||
|
if (!send || !isSendAvailable(send) || send.type !== SendType.File) {
|
||||||
|
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parseStoredSendData(send);
|
||||||
|
const expectedFileId = typeof data.id === 'string' ? data.id : null;
|
||||||
|
if (!expectedFileId || expectedFileId !== fileId) {
|
||||||
|
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown = {};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
body = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
let sendPasswordLimitIpKey: string | null = null;
|
||||||
|
let sendPasswordRateLimit: RateLimitService | null = null;
|
||||||
|
if (send.passwordHash) {
|
||||||
|
const clientIdentifier = getClientIdentifier(request);
|
||||||
|
if (!clientIdentifier) {
|
||||||
|
return errorResponse('Client IP is required', 403);
|
||||||
|
}
|
||||||
|
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier);
|
||||||
|
sendPasswordRateLimit = new RateLimitService(env.DB);
|
||||||
|
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
|
||||||
|
if (!sendPasswordCheck.allowed) {
|
||||||
|
return sendPasswordLockedErrorResponse(sendPasswordCheck.retryAfterSeconds || 60);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = await validatePublicSendAccess(send, body);
|
||||||
|
if (!validation.ok) {
|
||||||
|
if (validation.reason === 'invalid_password' && sendPasswordRateLimit && sendPasswordLimitIpKey) {
|
||||||
|
const failed = await sendPasswordRateLimit.recordFailedLogin(sendPasswordLimitIpKey);
|
||||||
|
if (failed.locked) {
|
||||||
|
return sendPasswordLockedErrorResponse(failed.retryAfterSeconds || 60);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return validation.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (send.passwordHash && sendPasswordRateLimit && sendPasswordLimitIpKey) {
|
||||||
|
await sendPasswordRateLimit.clearLoginAttempts(sendPasswordLimitIpKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await storage.incrementSendAccessCount(send.id);
|
||||||
|
if (!updated) {
|
||||||
|
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||||
|
}
|
||||||
|
send.accessCount += 1;
|
||||||
|
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||||
|
|
||||||
|
const token = await createSendFileDownloadToken(send.id, fileId, secret);
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const downloadUrl = `${url.origin}/api/sends/${send.id}/${fileId}?t=${token}`;
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
object: 'send-fileDownload',
|
||||||
|
id: fileId,
|
||||||
|
url: downloadUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAccessSendV2(request: Request, env: Env): Promise<Response> {
|
||||||
|
const jwt = getSafeJwtSecret(env);
|
||||||
|
if (!jwt.ok) return jwt.response;
|
||||||
|
|
||||||
|
const token = extractBearerToken(request);
|
||||||
|
if (!token) {
|
||||||
|
return errorResponse('Unauthorized', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims = await verifySendAccessToken(token, jwt.secret);
|
||||||
|
if (!claims) {
|
||||||
|
return errorResponse('Unauthorized', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const send = await storage.getSend(claims.sub);
|
||||||
|
if (!send || !isSendAvailable(send)) {
|
||||||
|
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (send.type === SendType.Text) {
|
||||||
|
const updated = await storage.incrementSendAccessCount(send.id);
|
||||||
|
if (!updated) {
|
||||||
|
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||||
|
}
|
||||||
|
send.accessCount += 1;
|
||||||
|
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const creatorIdentifier = await getCreatorIdentifier(storage, send);
|
||||||
|
return jsonResponse(sendToAccessResponse(send, creatorIdentifier));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAccessSendFileV2(request: Request, env: Env, fileId: string): Promise<Response> {
|
||||||
|
const jwt = getSafeJwtSecret(env);
|
||||||
|
if (!jwt.ok) return jwt.response;
|
||||||
|
|
||||||
|
const token = extractBearerToken(request);
|
||||||
|
if (!token) {
|
||||||
|
return errorResponse('Unauthorized', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims = await verifySendAccessToken(token, jwt.secret);
|
||||||
|
if (!claims) {
|
||||||
|
return errorResponse('Unauthorized', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const send = await storage.getSend(claims.sub);
|
||||||
|
if (!send || !isSendAvailable(send) || send.type !== SendType.File) {
|
||||||
|
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parseStoredSendData(send);
|
||||||
|
const expectedFileId = typeof data.id === 'string' ? data.id : null;
|
||||||
|
if (!expectedFileId || expectedFileId !== fileId) {
|
||||||
|
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await storage.incrementSendAccessCount(send.id);
|
||||||
|
if (!updated) {
|
||||||
|
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||||
|
}
|
||||||
|
send.accessCount += 1;
|
||||||
|
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||||
|
|
||||||
|
const downloadToken = await createSendFileDownloadToken(send.id, fileId, jwt.secret);
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const downloadUrl = `${url.origin}/api/sends/${send.id}/${fileId}?t=${downloadToken}`;
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
object: 'send-fileDownload',
|
||||||
|
id: fileId,
|
||||||
|
url: downloadUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleDownloadSendFile(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
sendId: string,
|
||||||
|
fileId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const jwt = getSafeJwtSecret(env);
|
||||||
|
if (!jwt.ok) return jwt.response;
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const token = url.searchParams.get('t') || url.searchParams.get('token');
|
||||||
|
if (!token) {
|
||||||
|
return errorResponse('Token required', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims = await verifySendFileDownloadToken(token, jwt.secret);
|
||||||
|
if (!claims) {
|
||||||
|
return errorResponse('Invalid or expired token', 401);
|
||||||
|
}
|
||||||
|
if (claims.sendId !== sendId || claims.fileId !== fileId) {
|
||||||
|
return errorResponse('Token mismatch', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId));
|
||||||
|
if (!object) {
|
||||||
|
return errorResponse('Send file not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp);
|
||||||
|
if (!firstUse) {
|
||||||
|
return errorResponse('Invalid or expired token', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(object.body, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': object.contentType || 'application/octet-stream',
|
||||||
|
'Content-Length': String(object.size),
|
||||||
|
'Cache-Control': 'private, no-cache',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function issueSendAccessToken(
|
||||||
|
env: Env,
|
||||||
|
sendIdOrAccessId: string,
|
||||||
|
passwordHashB64?: string | null,
|
||||||
|
password?: string | null,
|
||||||
|
rateLimit?: RateLimitService,
|
||||||
|
sendPasswordLimitIpKey?: string
|
||||||
|
): Promise<{ token: string } | { error: Response }> {
|
||||||
|
const jwt = getSafeJwtSecret(env);
|
||||||
|
if (!jwt.ok) {
|
||||||
|
return { error: jwt.response };
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const send = await resolveSendFromIdOrAccessId(storage, sendIdOrAccessId);
|
||||||
|
|
||||||
|
if (!send || !isSendAvailable(send)) {
|
||||||
|
return {
|
||||||
|
error: jsonResponse(
|
||||||
|
{
|
||||||
|
error: 'invalid_grant',
|
||||||
|
error_description: SEND_INACCESSIBLE_MSG,
|
||||||
|
send_access_error_type: 'send_not_available',
|
||||||
|
ErrorModel: {
|
||||||
|
Message: SEND_INACCESSIBLE_MSG,
|
||||||
|
Object: 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasEmailAuth(send)) {
|
||||||
|
const message = 'Email verification for this Send is not supported by this server.';
|
||||||
|
return {
|
||||||
|
error: jsonResponse(
|
||||||
|
{
|
||||||
|
error: 'invalid_grant',
|
||||||
|
error_description: message,
|
||||||
|
send_access_error_type: 'email_verification_not_supported',
|
||||||
|
ErrorModel: {
|
||||||
|
Message: message,
|
||||||
|
Object: 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (send.passwordHash) {
|
||||||
|
if (rateLimit && sendPasswordLimitIpKey) {
|
||||||
|
const sendPasswordCheck = await rateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
|
||||||
|
if (!sendPasswordCheck.allowed) {
|
||||||
|
return {
|
||||||
|
error: sendPasswordLockedOAuthResponse(sendPasswordCheck.retryAfterSeconds || 60),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ok = false;
|
||||||
|
if (passwordHashB64) {
|
||||||
|
ok = verifySendPasswordHashB64(send, passwordHashB64);
|
||||||
|
} else if (password) {
|
||||||
|
ok = await verifySendPassword(send, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
|
if (rateLimit && sendPasswordLimitIpKey) {
|
||||||
|
const failed = await rateLimit.recordFailedLogin(sendPasswordLimitIpKey);
|
||||||
|
if (failed.locked) {
|
||||||
|
return {
|
||||||
|
error: sendPasswordLockedOAuthResponse(failed.retryAfterSeconds || 60),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
error: jsonResponse(
|
||||||
|
{
|
||||||
|
error: 'invalid_grant',
|
||||||
|
error_description: 'Invalid password.',
|
||||||
|
send_access_error_type: 'invalid_password',
|
||||||
|
ErrorModel: {
|
||||||
|
Message: 'Invalid password.',
|
||||||
|
Object: 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rateLimit && sendPasswordLimitIpKey) {
|
||||||
|
await rateLimit.clearLoginAttempts(sendPasswordLimitIpKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await createSendAccessToken(send.id, jwt.secret);
|
||||||
|
return { token };
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types';
|
||||||
|
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
|
import { readActingDeviceIdentifier } from '../utils/device';
|
||||||
|
import { LIMITS } from '../config/limits';
|
||||||
|
|
||||||
|
export const SEND_INACCESSIBLE_MSG = 'Send does not exist or is no longer available';
|
||||||
|
const SEND_PASSWORD_ITERATIONS = 100_000;
|
||||||
|
export const SEND_PASSWORD_LIMIT_SCOPE = 'send-password';
|
||||||
|
|
||||||
|
export async function notifyVaultSyncForRequest(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
revisionDate: string
|
||||||
|
): Promise<void> {
|
||||||
|
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAliasedProp(source: unknown, aliases: string[]): { present: boolean; value: unknown } {
|
||||||
|
if (!source || typeof source !== 'object') return { present: false, value: undefined };
|
||||||
|
for (const key of aliases) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||||
|
const value = (source as Record<string, unknown>)[key];
|
||||||
|
return { present: true, value };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { present: false, value: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function base64UrlEncode(data: Uint8Array): string {
|
||||||
|
const base64 = btoa(String.fromCharCode(...data));
|
||||||
|
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function base64UrlDecode(input: string): Uint8Array | null {
|
||||||
|
try {
|
||||||
|
let normalized = input.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
while (normalized.length % 4) normalized += '=';
|
||||||
|
const raw = atob(normalized);
|
||||||
|
const out = new Uint8Array(raw.length);
|
||||||
|
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uuidToBytes(uuid: string): Uint8Array | null {
|
||||||
|
const hex = uuid.replace(/-/g, '').toLowerCase();
|
||||||
|
if (!/^[0-9a-f]{32}$/.test(hex)) return null;
|
||||||
|
const bytes = new Uint8Array(16);
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToUuid(bytes: Uint8Array): string | null {
|
||||||
|
if (bytes.length !== 16) return null;
|
||||||
|
const hex = Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
return [
|
||||||
|
hex.slice(0, 8),
|
||||||
|
hex.slice(8, 12),
|
||||||
|
hex.slice(12, 16),
|
||||||
|
hex.slice(16, 20),
|
||||||
|
hex.slice(20, 32),
|
||||||
|
].join('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAccessId(sendId: string): string {
|
||||||
|
const bytes = uuidToBytes(sendId);
|
||||||
|
if (!bytes) return '';
|
||||||
|
return base64UrlEncode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromAccessId(accessId: string): string | null {
|
||||||
|
const bytes = base64UrlDecode(accessId);
|
||||||
|
if (!bytes || bytes.length !== 16) return null;
|
||||||
|
return bytesToUuid(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyUuid(value: string): boolean {
|
||||||
|
return /^[a-f0-9-]{36}$/i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveSendFromIdOrAccessId(storage: StorageService, idOrAccessId: string): Promise<Send | null> {
|
||||||
|
if (isLikelyUuid(idOrAccessId)) {
|
||||||
|
const send = await storage.getSend(idOrAccessId);
|
||||||
|
if (send) return send;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendId = fromAccessId(idOrAccessId);
|
||||||
|
if (!sendId) return null;
|
||||||
|
return storage.getSend(sendId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} Bytes`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDate(raw: unknown): Date | null {
|
||||||
|
if (typeof raw !== 'string' || !raw.trim()) return null;
|
||||||
|
const date = new Date(raw);
|
||||||
|
if (Number.isNaN(date.getTime())) return null;
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseInteger(raw: unknown): number | null {
|
||||||
|
if (raw === null || raw === undefined || raw === '') return null;
|
||||||
|
const value = typeof raw === 'string' ? Number(raw) : raw;
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) return null;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeSendData(raw: unknown): Record<string, unknown> | null {
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||||
|
const data = { ...(raw as Record<string, unknown>) };
|
||||||
|
delete data.response;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseStoredSendData(send: Send): Record<string, unknown> {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(send.data) as unknown;
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
return { ...(parsed as Record<string, unknown>) };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSendDataSizeField(data: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const normalized = { ...data };
|
||||||
|
if (typeof normalized.size === 'number' && Number.isFinite(normalized.size)) {
|
||||||
|
normalized.size = String(Math.trunc(normalized.size));
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSendAvailable(send: Send): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (send.maxAccessCount !== null && send.accessCount >= send.maxAccessCount) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (send.expirationDate) {
|
||||||
|
const expirationMs = new Date(send.expirationDate).getTime();
|
||||||
|
if (!Number.isNaN(expirationMs) && now >= expirationMs) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletionMs = new Date(send.deletionDate).getTime();
|
||||||
|
if (!Number.isNaN(deletionMs) && now >= deletionMs) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (send.disabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deriveSendPasswordHash(password: string, salt: Uint8Array, iterations: number): Promise<Uint8Array> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const key = await crypto.subtle.importKey('raw', encoder.encode(password), { name: 'PBKDF2' }, false, ['deriveBits']);
|
||||||
|
const bits = await crypto.subtle.deriveBits(
|
||||||
|
{
|
||||||
|
name: 'PBKDF2',
|
||||||
|
salt,
|
||||||
|
iterations,
|
||||||
|
hash: 'SHA-256',
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
256
|
||||||
|
);
|
||||||
|
return new Uint8Array(bits);
|
||||||
|
}
|
||||||
|
|
||||||
|
function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
diff |= a[i] ^ b[i];
|
||||||
|
}
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyHashB64(value: string): boolean {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw) return false;
|
||||||
|
if (!/^[A-Za-z0-9+/_=-]+$/.test(raw)) return false;
|
||||||
|
const decoded = base64UrlDecode(raw);
|
||||||
|
return !!decoded && decoded.length === 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSendPassword(send: Send, password: string | null): Promise<void> {
|
||||||
|
if (!password) {
|
||||||
|
send.passwordHash = null;
|
||||||
|
send.passwordSalt = null;
|
||||||
|
send.passwordIterations = null;
|
||||||
|
if (send.authType === SendAuthType.Password) {
|
||||||
|
send.authType = SendAuthType.None;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLikelyHashB64(password)) {
|
||||||
|
send.passwordHash = password.trim();
|
||||||
|
send.passwordSalt = null;
|
||||||
|
send.passwordIterations = null;
|
||||||
|
send.authType = SendAuthType.Password;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const salt = crypto.getRandomValues(new Uint8Array(64));
|
||||||
|
const hash = await deriveSendPasswordHash(password, salt, SEND_PASSWORD_ITERATIONS);
|
||||||
|
|
||||||
|
send.passwordSalt = base64UrlEncode(salt);
|
||||||
|
send.passwordHash = base64UrlEncode(hash);
|
||||||
|
send.passwordIterations = SEND_PASSWORD_ITERATIONS;
|
||||||
|
send.authType = SendAuthType.Password;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifySendPassword(send: Send, password: string): Promise<boolean> {
|
||||||
|
if (!send.passwordHash) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!send.passwordSalt || !send.passwordIterations) {
|
||||||
|
return verifySendPasswordHashB64(send, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
const salt = base64UrlDecode(send.passwordSalt);
|
||||||
|
const expected = base64UrlDecode(send.passwordHash);
|
||||||
|
if (!salt || !expected) return false;
|
||||||
|
|
||||||
|
const actual = await deriveSendPasswordHash(password, salt, send.passwordIterations);
|
||||||
|
return constantTimeEqual(actual, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifySendPasswordHashB64(send: Send, passwordHashB64: string): boolean {
|
||||||
|
if (!send.passwordHash || !passwordHashB64) return false;
|
||||||
|
const expected = base64UrlDecode(send.passwordHash);
|
||||||
|
const provided = base64UrlDecode(passwordHashB64);
|
||||||
|
if (!expected || !provided) return false;
|
||||||
|
return constantTimeEqual(expected, provided);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateDeletionDate(date: Date): Response | null {
|
||||||
|
const maxMs = Date.now() + LIMITS.send.maxDeletionDays * 24 * 60 * 60 * 1000;
|
||||||
|
if (date.getTime() > maxMs) {
|
||||||
|
return errorResponse(
|
||||||
|
'You cannot have a Send with a deletion date that far into the future. Adjust the Deletion Date to a value less than 31 days from now and try again.',
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMaxAccessCount(value: unknown): { ok: true; value: number | null } | { ok: false; response: Response } {
|
||||||
|
const parsed = parseInteger(value);
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return { ok: true, value: null };
|
||||||
|
}
|
||||||
|
if (parsed === null || parsed < 0) {
|
||||||
|
return { ok: false, response: errorResponse('Invalid maxAccessCount', 400) };
|
||||||
|
}
|
||||||
|
return { ok: true, value: parsed };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFileLength(value: unknown): { ok: true; value: number } | { ok: false; response: Response } {
|
||||||
|
const parsed = parseInteger(value);
|
||||||
|
if (parsed === null) {
|
||||||
|
return { ok: false, response: errorResponse('Invalid send length', 400) };
|
||||||
|
}
|
||||||
|
if (parsed < 0) {
|
||||||
|
return { ok: false, response: errorResponse("Send size can't be negative", 400) };
|
||||||
|
}
|
||||||
|
return { ok: true, value: parsed };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSendType(value: unknown): SendType | null {
|
||||||
|
const type = parseInteger(value);
|
||||||
|
if (type === SendType.Text || type === SendType.File) return type;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSendAuthType(value: unknown): SendAuthType | null {
|
||||||
|
if (value === undefined || value === null || value === '') return null;
|
||||||
|
const parsed = parseInteger(value);
|
||||||
|
if (parsed === SendAuthType.Email || parsed === SendAuthType.Password || parsed === SendAuthType.None) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEmails(value: unknown): string | null {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const strings = value.filter((v) => typeof v === 'string').map((v) => String(v));
|
||||||
|
if (strings.length === 0) return null;
|
||||||
|
return strings.join(',');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasEmailAuth(send: Send): boolean {
|
||||||
|
return send.authType === SendAuthType.Email;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSafeJwtSecret(env: Env): { ok: true; secret: string } | { ok: false; response: Response } {
|
||||||
|
const secret = (env.JWT_SECRET || '').trim();
|
||||||
|
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
|
||||||
|
return { ok: false, response: errorResponse('Server configuration error', 500) };
|
||||||
|
}
|
||||||
|
return { ok: true, secret };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractBearerToken(request: Request): string | null {
|
||||||
|
const authHeader = request.headers.get('Authorization');
|
||||||
|
if (!authHeader) return null;
|
||||||
|
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||||
|
return match ? match[1].trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendToResponse(send: Send): SendResponse {
|
||||||
|
const data = normalizeSendDataSizeField(parseStoredSendData(send));
|
||||||
|
return {
|
||||||
|
id: send.id,
|
||||||
|
accessId: toAccessId(send.id),
|
||||||
|
type: Number(send.type) || 0,
|
||||||
|
name: send.name,
|
||||||
|
notes: send.notes,
|
||||||
|
text: send.type === SendType.Text ? data : null,
|
||||||
|
file: send.type === SendType.File ? data : null,
|
||||||
|
key: send.key,
|
||||||
|
maxAccessCount: send.maxAccessCount,
|
||||||
|
accessCount: send.accessCount,
|
||||||
|
password: send.passwordHash,
|
||||||
|
emails: send.emails,
|
||||||
|
authType: send.authType,
|
||||||
|
disabled: send.disabled,
|
||||||
|
hideEmail: send.hideEmail,
|
||||||
|
revisionDate: send.updatedAt,
|
||||||
|
expirationDate: send.expirationDate,
|
||||||
|
deletionDate: send.deletionDate,
|
||||||
|
object: 'send',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendToAccessResponse(send: Send, creatorIdentifier: string | null): Record<string, unknown> {
|
||||||
|
const data = normalizeSendDataSizeField(parseStoredSendData(send));
|
||||||
|
return {
|
||||||
|
id: send.id,
|
||||||
|
type: Number(send.type) || 0,
|
||||||
|
name: send.name,
|
||||||
|
text: send.type === SendType.Text ? data : null,
|
||||||
|
file: send.type === SendType.File ? data : null,
|
||||||
|
expirationDate: send.expirationDate,
|
||||||
|
deletionDate: send.deletionDate,
|
||||||
|
creatorIdentifier,
|
||||||
|
object: 'send-access',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCreatorIdentifier(storage: StorageService, send: Send): Promise<string | null> {
|
||||||
|
if (send.hideEmail) return null;
|
||||||
|
const owner = await storage.getUserById(send.userId);
|
||||||
|
return owner?.email ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PublicSendAccessValidationResult =
|
||||||
|
| { ok: true }
|
||||||
|
| { ok: false; response: Response; reason: 'email_auth_unsupported' | 'password_missing' | 'invalid_password' };
|
||||||
|
|
||||||
|
export function sendPasswordLimitKey(clientIdentifier: string): string {
|
||||||
|
return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendPasswordLockMessage(retryAfterSeconds: number): string {
|
||||||
|
return `Too many failed send password attempts. Try again in ${Math.ceil(retryAfterSeconds / 60)} minutes.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendPasswordLockedErrorResponse(retryAfterSeconds: number): Response {
|
||||||
|
return errorResponse(sendPasswordLockMessage(retryAfterSeconds), 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendPasswordLockedOAuthResponse(retryAfterSeconds: number): Response {
|
||||||
|
const message = sendPasswordLockMessage(retryAfterSeconds);
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
error: 'invalid_grant',
|
||||||
|
error_description: message,
|
||||||
|
send_access_error_type: 'too_many_password_attempts',
|
||||||
|
ErrorModel: {
|
||||||
|
Message: message,
|
||||||
|
Object: 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
429
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validatePublicSendAccess(send: Send, body: unknown): Promise<PublicSendAccessValidationResult> {
|
||||||
|
if (hasEmailAuth(send)) {
|
||||||
|
return { ok: false, response: errorResponse(SEND_INACCESSIBLE_MSG, 404), reason: 'email_auth_unsupported' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!send.passwordHash) return { ok: true };
|
||||||
|
|
||||||
|
const passwordRaw = getAliasedProp(body, ['password', 'Password']);
|
||||||
|
const passwordHashB64Raw = getAliasedProp(body, [
|
||||||
|
'password_hash_b64',
|
||||||
|
'passwordHashB64',
|
||||||
|
'passwordHash',
|
||||||
|
'password_hash',
|
||||||
|
]);
|
||||||
|
|
||||||
|
let validPassword = false;
|
||||||
|
if (send.passwordSalt && send.passwordIterations) {
|
||||||
|
if (typeof passwordRaw.value !== 'string') {
|
||||||
|
return { ok: false, response: errorResponse('Password not provided', 401), reason: 'password_missing' };
|
||||||
|
}
|
||||||
|
validPassword = await verifySendPassword(send, passwordRaw.value);
|
||||||
|
} else {
|
||||||
|
const candidate =
|
||||||
|
typeof passwordHashB64Raw.value === 'string'
|
||||||
|
? passwordHashB64Raw.value
|
||||||
|
: typeof passwordRaw.value === 'string'
|
||||||
|
? passwordRaw.value
|
||||||
|
: '';
|
||||||
|
if (!candidate) return { ok: false, response: errorResponse('Password not provided', 401), reason: 'password_missing' };
|
||||||
|
validPassword = verifySendPasswordHashB64(send, candidate);
|
||||||
|
}
|
||||||
|
if (!validPassword) {
|
||||||
|
return { ok: false, response: errorResponse('Invalid password', 400), reason: 'invalid_password' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './sends-shared';
|
||||||
|
export * from './sends-private';
|
||||||
|
export * from './sends-public';
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { Env, DEFAULT_DEV_SECRET } from '../types';
|
|
||||||
import { StorageService } from '../services/storage';
|
|
||||||
import { jsonResponse, htmlResponse, errorResponse } from '../utils/response';
|
|
||||||
import { renderJwtSecretWarningPage, JwtSecretState } from './setupPages';
|
|
||||||
import { handleRegisterPage } from './setupRegisterPage';
|
|
||||||
|
|
||||||
function getJwtSecretState(env: Env): JwtSecretState | null {
|
|
||||||
const secret = (env.JWT_SECRET || '').trim();
|
|
||||||
if (!secret) return 'missing';
|
|
||||||
// Block common "forgot to change" sample value (matches .dev.vars.example)
|
|
||||||
if (secret === DEFAULT_DEV_SECRET) return 'default';
|
|
||||||
if (secret.length < 32) return 'too_short';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET / - Setup page
|
|
||||||
export async function handleSetupPage(request: Request, env: Env): Promise<Response> {
|
|
||||||
const storage = new StorageService(env.DB);
|
|
||||||
const disabled = await storage.isSetupDisabled();
|
|
||||||
if (disabled) {
|
|
||||||
return new Response(null, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guard: require a strong JWT_SECRET before allowing setup/registration.
|
|
||||||
const jwtState = getJwtSecretState(env);
|
|
||||||
if (jwtState) {
|
|
||||||
return htmlResponse(renderJwtSecretWarningPage(request, jwtState), 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serve the registration/setup UI (split into a dedicated module).
|
|
||||||
return handleRegisterPage(request, env);
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /setup/status
|
|
||||||
export async function handleSetupStatus(request: Request, env: Env): Promise<Response> {
|
|
||||||
const storage = new StorageService(env.DB);
|
|
||||||
const registered = await storage.isRegistered();
|
|
||||||
const disabled = await storage.isSetupDisabled();
|
|
||||||
return jsonResponse({ registered, disabled });
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /setup/disable
|
|
||||||
export async function handleDisableSetup(request: Request, env: Env): Promise<Response> {
|
|
||||||
const storage = new StorageService(env.DB);
|
|
||||||
const registered = await storage.isRegistered();
|
|
||||||
if (!registered) {
|
|
||||||
return errorResponse('Registration required', 403);
|
|
||||||
}
|
|
||||||
await storage.setSetupDisabled();
|
|
||||||
return jsonResponse({ success: true });
|
|
||||||
}
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
import { Env } from '../types';
|
|
||||||
|
|
||||||
// NOTE: Kept as a single file with inline HTML/CSS to avoid external assets.
|
|
||||||
// This file splits the old monolithic setup page into reusable page generators.
|
|
||||||
|
|
||||||
type Lang = 'zh' | 'en';
|
|
||||||
|
|
||||||
function isChineseFromRequest(request: Request): boolean {
|
|
||||||
const acceptLang = (request.headers.get('accept-language') || '').toLowerCase();
|
|
||||||
return acceptLang.includes('zh');
|
|
||||||
}
|
|
||||||
|
|
||||||
function t(lang: Lang, key: string): string {
|
|
||||||
const zh: Record<string, string> = {
|
|
||||||
app: 'NodeWarden',
|
|
||||||
tag: '部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端。',
|
|
||||||
|
|
||||||
// Config warning page
|
|
||||||
cfgTitle: '需要配置 JWT_SECRET',
|
|
||||||
cfgDescMissing: '当前服务没有配置 JWT_SECRET(用于签名登录令牌)。为了安全起见,必须先配置后才能注册/使用。',
|
|
||||||
cfgDescDefault: '检测到你正在使用示例/默认 JWT_SECRET。为了安全起见,请先修改为随机强密钥后再注册/使用。',
|
|
||||||
cfgDescTooShort: '检测到 JWT_SECRET 长度不足 32 个字符。为了安全起见,请使用至少 32 位的随机字符串。',
|
|
||||||
cfgStepsTitle: '如何在 Cloudflare 修改 JWT_SECRET',
|
|
||||||
cfgSteps: '打开 Cloudflare 控制台 → Workers 和 Pages → 选择 nodewarden → 设置 → 变量和机密 → 添加变量。\n类型:密钥\n名称:JWT_SECRET\n值:粘贴你生成的随机密钥\n保存后,等待重新部署生效。',
|
|
||||||
cfgGenTitle: '随机密钥生成器',
|
|
||||||
cfgGenHint: '建议长度:至少 32 字符(推荐 64+)。点击刷新生成新的随机值。',
|
|
||||||
cfgCopy: '复制',
|
|
||||||
cfgRefresh: '刷新',
|
|
||||||
|
|
||||||
// Shared
|
|
||||||
by: '作者',
|
|
||||||
github: 'GitHub',
|
|
||||||
};
|
|
||||||
|
|
||||||
const en: Record<string, string> = {
|
|
||||||
app: 'NodeWarden',
|
|
||||||
tag: 'Minimal Bitwarden-compatible server on Cloudflare Workers.',
|
|
||||||
|
|
||||||
// Config warning page
|
|
||||||
cfgTitle: 'JWT_SECRET is required',
|
|
||||||
cfgDescMissing: 'This server has no JWT_SECRET configured (used to sign login tokens). For safety, you must configure it before registration/usage.',
|
|
||||||
cfgDescDefault: 'You are using the sample/default JWT_SECRET. For safety, please change it to a strong random secret before registration/usage.',
|
|
||||||
cfgDescTooShort: 'JWT_SECRET is shorter than 32 characters. For safety, use a random string with at least 32 characters.',
|
|
||||||
cfgStepsTitle: 'How to set JWT_SECRET in Cloudflare',
|
|
||||||
cfgSteps: 'Open Cloudflare Dashboard → Workers & Pages → select nodewarden → Settings → Variables and Secrets → Add variable.\nType: Secret\nName: JWT_SECRET\nValue: paste a random secret\nSave, and wait for redeploy to take effect.',
|
|
||||||
cfgGenTitle: 'Random secret generator',
|
|
||||||
cfgGenHint: 'Recommended length: 32+ characters (64+ preferred). Click refresh to generate a new one.',
|
|
||||||
cfgCopy: 'Copy',
|
|
||||||
cfgRefresh: 'Refresh',
|
|
||||||
|
|
||||||
// Shared
|
|
||||||
by: 'By',
|
|
||||||
github: 'GitHub',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (lang === 'zh' ? zh : en)[key] ?? key;
|
|
||||||
}
|
|
||||||
|
|
||||||
function baseStyles(): string {
|
|
||||||
// Keep consistent with existing setup page look & feel.
|
|
||||||
return `
|
|
||||||
:root {
|
|
||||||
color-scheme: light;
|
|
||||||
--bg0: #0b0b0f;
|
|
||||||
--bg1: #0f1020;
|
|
||||||
--card: rgba(255, 255, 255, 0.08);
|
|
||||||
--card2: rgba(255, 255, 255, 0.06);
|
|
||||||
--border: rgba(255, 255, 255, 0.14);
|
|
||||||
--text: rgba(255, 255, 255, 0.92);
|
|
||||||
--muted: rgba(255, 255, 255, 0.62);
|
|
||||||
--muted2: rgba(255, 255, 255, 0.52);
|
|
||||||
--accent: #0a84ff;
|
|
||||||
--accent2: #64d2ff;
|
|
||||||
--danger: #ff453a;
|
|
||||||
--ok: #32d74b;
|
|
||||||
--shadow: 0 16px 60px rgba(0, 0, 0, 0.50);
|
|
||||||
--radius: 18px;
|
|
||||||
--radius2: 14px;
|
|
||||||
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
html, body { height: 100%; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
||||||
background:
|
|
||||||
radial-gradient(900px 600px at 15% 10%, rgba(100, 210, 255, 0.25), transparent 60%),
|
|
||||||
radial-gradient(900px 600px at 85% 20%, rgba(10, 132, 255, 0.22), transparent 60%),
|
|
||||||
radial-gradient(900px 600px at 50% 90%, rgba(50, 215, 75, 0.10), transparent 60%),
|
|
||||||
linear-gradient(180deg, var(--bg0), var(--bg1));
|
|
||||||
color: var(--text);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
.shell { width: max(500px); }
|
|
||||||
.panel {
|
|
||||||
padding: 22px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: rgba(255,255,255,0.06);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
-webkit-backdrop-filter: blur(16px);
|
|
||||||
}
|
|
||||||
.top {
|
|
||||||
display: flex;
|
|
||||||
gap: 14px;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
.mark {
|
|
||||||
width: 46px;
|
|
||||||
height: 46px;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: linear-gradient(135deg, rgba(10,132,255,0.85), rgba(100,210,255,0.55));
|
|
||||||
border: 1px solid rgba(255,255,255,0.20);
|
|
||||||
box-shadow: 0 10px 40px rgba(10, 132, 255, 0.30);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
color: rgba(255,255,255,0.96);
|
|
||||||
text-transform: uppercase;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.title { display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.title h1 { font-size: 22px; margin: 0; letter-spacing: -0.3px; }
|
|
||||||
.title p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.5; }
|
|
||||||
|
|
||||||
h2 { font-size: 16px; margin: 14px 0 10px 0; letter-spacing: -0.2px; }
|
|
||||||
.lead { font-size: 13px; line-height: 1.7; color: rgba(255,255,255,0.86); }
|
|
||||||
|
|
||||||
.kv {
|
|
||||||
border-radius: var(--radius2);
|
|
||||||
border: 1px solid rgba(255,255,255,0.14);
|
|
||||||
background: rgba(255,255,255,0.05);
|
|
||||||
padding: 14px;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
.kv h3 { margin: 0 0 8px 0; font-size: 13px; color: rgba(255,255,255,0.86); }
|
|
||||||
.kv p { margin: 0; font-size: 12px; line-height: 1.55; color: var(--muted); white-space: pre-line; }
|
|
||||||
|
|
||||||
.server {
|
|
||||||
margin-top: 10px;
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: rgba(0,0,0,0.25);
|
|
||||||
border: 1px solid rgba(255,255,255,0.12);
|
|
||||||
word-break: break-all;
|
|
||||||
color: rgba(255,255,255,0.90);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
|
||||||
.btn {
|
|
||||||
height: 38px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid rgba(255,255,255,0.18);
|
|
||||||
background: rgba(0,0,0,0.18);
|
|
||||||
color: rgba(255,255,255,0.92);
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.btn.primary {
|
|
||||||
background: linear-gradient(135deg, rgba(10,132,255,0.95), rgba(100,210,255,0.60));
|
|
||||||
}
|
|
||||||
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
|
||||||
|
|
||||||
a { color: rgba(100, 210, 255, 0.92); text-decoration: none; }
|
|
||||||
a:hover { text-decoration: underline; }
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
margin-top: 18px;
|
|
||||||
padding-top: 14px;
|
|
||||||
border-top: 1px solid rgba(255,255,255,0.10);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
font-size: 12px;
|
|
||||||
color: rgba(255,255,255,0.55);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type JwtSecretState = 'missing' | 'default' | 'too_short';
|
|
||||||
|
|
||||||
export function renderJwtSecretWarningPage(request: Request, state: JwtSecretState): string {
|
|
||||||
const lang: Lang = isChineseFromRequest(request) ? 'zh' : 'en';
|
|
||||||
|
|
||||||
const descKey = state === 'missing' ? 'cfgDescMissing' : state === 'default' ? 'cfgDescDefault' : 'cfgDescTooShort';
|
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
|
||||||
<html lang="${lang === 'zh' ? 'zh-CN' : 'en'}">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>NodeWarden</title>
|
|
||||||
<style>${baseStyles()}</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="shell">
|
|
||||||
<aside class="panel">
|
|
||||||
<div class="top">
|
|
||||||
<div class="mark" aria-label="NodeWarden">NW</div>
|
|
||||||
<div class="title">
|
|
||||||
<h1>${t(lang, 'app')}</h1>
|
|
||||||
<p>${t(lang, 'tag')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>${t(lang, 'cfgTitle')}</h2>
|
|
||||||
<div class="lead">${t(lang, descKey)}</div>
|
|
||||||
|
|
||||||
<div class="kv">
|
|
||||||
<h3>${t(lang, 'cfgStepsTitle')}</h3>
|
|
||||||
<p>${t(lang, 'cfgSteps')
|
|
||||||
.replace(/^类型:密钥/m, '<b>类型:密钥</b>')
|
|
||||||
.replace(/^名称:JWT_SECRET/m, '<b>名称:JWT_SECRET</b>')
|
|
||||||
.replace(/^Type: Secret/m, '<b>Type: Secret</b>')
|
|
||||||
.replace(/^Name: JWT_SECRET/m, '<b>Name: JWT_SECRET</b>')
|
|
||||||
}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="kv">
|
|
||||||
<h3>${t(lang, 'cfgGenTitle')}</h3>
|
|
||||||
<p>${t(lang, 'cfgGenHint')}</p>
|
|
||||||
<div class="server" id="secret"></div>
|
|
||||||
<div style="height: 10px"></div>
|
|
||||||
<div class="row">
|
|
||||||
<button class="btn primary" type="button" onclick="refreshSecret()">${t(lang, 'cfgRefresh')}</button>
|
|
||||||
<button class="btn" type="button" onclick="copySecret()">${t(lang, 'cfgCopy')}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer">
|
|
||||||
<div>
|
|
||||||
<span>${t(lang, 'by')} </span>
|
|
||||||
<a href="https://shuai.plus" target="_blank" rel="noreferrer">shuaiplus</a>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<a href="https://github.com/shuaiplus/nodewarden" target="_blank" rel="noreferrer">${t(lang, 'github')}</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Generate a URL-safe random secret (default length: 64)
|
|
||||||
function genSecret(len) {
|
|
||||||
len = len || 50;
|
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
|
||||||
const bytes = new Uint8Array(len);
|
|
||||||
crypto.getRandomValues(bytes);
|
|
||||||
let out = '';
|
|
||||||
for (let i = 0; i < bytes.length; i++) {
|
|
||||||
out += chars[bytes[i] % chars.length];
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshSecret() {
|
|
||||||
const s = genSecret(50);
|
|
||||||
document.getElementById('secret').textContent = s;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copySecret() {
|
|
||||||
const s = document.getElementById('secret').textContent || '';
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(s);
|
|
||||||
} catch {
|
|
||||||
const ta = document.createElement('textarea');
|
|
||||||
ta.value = s;
|
|
||||||
document.body.appendChild(ta);
|
|
||||||
ta.select();
|
|
||||||
document.execCommand('copy');
|
|
||||||
ta.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshSecret();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
@@ -1,668 +0,0 @@
|
|||||||
import { Env } from '../types';
|
|
||||||
import { StorageService } from '../services/storage';
|
|
||||||
import { htmlResponse } from '../utils/response';
|
|
||||||
|
|
||||||
// Registration/setup page HTML (single-file, no external assets)
|
|
||||||
// Split out from the old monolithic `setup.ts` as requested.
|
|
||||||
const registerPageHTML = `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>NodeWarden</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
color-scheme: light;
|
|
||||||
--bg0: #0b0b0f;
|
|
||||||
--bg1: #0f1020;
|
|
||||||
--card: rgba(255, 255, 255, 0.08);
|
|
||||||
--card2: rgba(255, 255, 255, 0.06);
|
|
||||||
--border: rgba(255, 255, 255, 0.14);
|
|
||||||
--text: rgba(255, 255, 255, 0.92);
|
|
||||||
--muted: rgba(255, 255, 255, 0.62);
|
|
||||||
--muted2: rgba(255, 255, 255, 0.52);
|
|
||||||
--accent: #0a84ff;
|
|
||||||
--accent2: #64d2ff;
|
|
||||||
--danger: #ff453a;
|
|
||||||
--ok: #32d74b;
|
|
||||||
--shadow: 0 16px 60px rgba(0, 0, 0, 0.50);
|
|
||||||
--radius: 18px;
|
|
||||||
--radius2: 14px;
|
|
||||||
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
html, body { height: 100%; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
||||||
background:
|
|
||||||
radial-gradient(900px 600px at 15% 10%, rgba(100, 210, 255, 0.25), transparent 60%),
|
|
||||||
radial-gradient(900px 600px at 85% 20%, rgba(10, 132, 255, 0.22), transparent 60%),
|
|
||||||
radial-gradient(900px 600px at 50% 90%, rgba(50, 215, 75, 0.10), transparent 60%),
|
|
||||||
linear-gradient(180deg, var(--bg0), var(--bg1));
|
|
||||||
color: var(--text);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
.shell { width: max(500px); }
|
|
||||||
.panel {
|
|
||||||
padding: 22px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: rgba(255,255,255,0.06);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
-webkit-backdrop-filter: blur(16px);
|
|
||||||
}
|
|
||||||
.top {
|
|
||||||
display: flex;
|
|
||||||
gap: 14px;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
.mark {
|
|
||||||
width: 46px;
|
|
||||||
height: 46px;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: linear-gradient(135deg, rgba(10,132,255,0.85), rgba(100,210,255,0.55));
|
|
||||||
border: 1px solid rgba(255,255,255,0.20);
|
|
||||||
box-shadow: 0 10px 40px rgba(10, 132, 255, 0.30);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
color: rgba(255,255,255,0.96);
|
|
||||||
text-transform: uppercase;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.title { display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.title h1 { font-size: 22px; margin: 0; letter-spacing: -0.3px; }
|
|
||||||
.title p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.5; }
|
|
||||||
|
|
||||||
h2 { font-size: 16px; margin: 14px 0 10px 0; letter-spacing: -0.2px; }
|
|
||||||
|
|
||||||
.message {
|
|
||||||
display: none;
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 12px 12px;
|
|
||||||
margin: 0 0 12px 0;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.45;
|
|
||||||
border: 1px solid rgba(255,255,255,0.14);
|
|
||||||
background: rgba(255,255,255,0.06);
|
|
||||||
}
|
|
||||||
.message.error {
|
|
||||||
display: block;
|
|
||||||
border-color: rgba(255, 69, 58, 0.40);
|
|
||||||
background: rgba(255, 69, 58, 0.10);
|
|
||||||
color: rgba(255, 255, 255, 0.92);
|
|
||||||
}
|
|
||||||
.message.success {
|
|
||||||
display: block;
|
|
||||||
border-color: rgba(50, 215, 75, 0.35);
|
|
||||||
background: rgba(50, 215, 75, 0.10);
|
|
||||||
color: rgba(255, 255, 255, 0.92);
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
||||||
@media (max-width: 540px) { .grid { grid-template-columns: 1fr; } }
|
|
||||||
|
|
||||||
.field { display: flex; flex-direction: column; gap: 7px; }
|
|
||||||
label { font-size: 12px; color: var(--muted); letter-spacing: 0.2px; }
|
|
||||||
input {
|
|
||||||
height: 42px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid rgba(255,255,255,0.18);
|
|
||||||
background: rgba(0,0,0,0.18);
|
|
||||||
color: rgba(255,255,255,0.92);
|
|
||||||
outline: none;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
|
||||||
}
|
|
||||||
input::placeholder { color: rgba(255,255,255,0.35); }
|
|
||||||
input:focus {
|
|
||||||
border-color: rgba(10, 132, 255, 0.55);
|
|
||||||
box-shadow: 0 0 0 6px rgba(10, 132, 255, 0.12);
|
|
||||||
}
|
|
||||||
.hint { margin: 0; color: var(--muted2); font-size: 12px; line-height: 1.55; }
|
|
||||||
|
|
||||||
.actions { margin-top: 12px; display: flex; gap: 10px; align-items: center; }
|
|
||||||
.primary {
|
|
||||||
width: 100%;
|
|
||||||
height: 44px;
|
|
||||||
border-radius: 14px;
|
|
||||||
border: 1px solid rgba(255,255,255,0.18);
|
|
||||||
background: linear-gradient(135deg, rgba(10,132,255,0.95), rgba(100,210,255,0.60));
|
|
||||||
color: rgba(255,255,255,0.96);
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.2px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 120ms ease, filter 120ms ease;
|
|
||||||
}
|
|
||||||
.primary:hover { filter: brightness(1.03); }
|
|
||||||
.primary:active { transform: translateY(1px) scale(0.99); }
|
|
||||||
.primary:disabled { opacity: 0.55; cursor: not-allowed; transform: none; }
|
|
||||||
|
|
||||||
.sideCard { display: flex; flex-direction: column; gap: 12px; }
|
|
||||||
.kv {
|
|
||||||
border-radius: var(--radius2);
|
|
||||||
border: 1px solid rgba(255,255,255,0.14);
|
|
||||||
background: rgba(255,255,255,0.05);
|
|
||||||
padding: 14px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.kv h3 { margin: 0 0 8px 0; font-size: 13px; color: rgba(255,255,255,0.86); }
|
|
||||||
.kv p { margin: 0; font-size: 12px; line-height: 1.55; color: var(--muted); }
|
|
||||||
|
|
||||||
.server {
|
|
||||||
margin-top: 10px;
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: rgba(0,0,0,0.25);
|
|
||||||
border: 1px solid rgba(255,255,255,0.12);
|
|
||||||
word-break: break-all;
|
|
||||||
color: rgba(255,255,255,0.90);
|
|
||||||
}
|
|
||||||
a { color: rgba(100, 210, 255, 0.92); text-decoration: none; }
|
|
||||||
a:hover { text-decoration: underline; }
|
|
||||||
.footer {
|
|
||||||
margin-top: 18px;
|
|
||||||
padding-top: 14px;
|
|
||||||
border-top: 1px solid rgba(255,255,255,0.10);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
font-size: 12px;
|
|
||||||
color: rgba(255,255,255,0.55);
|
|
||||||
}
|
|
||||||
.muted { color: var(--muted); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="shell">
|
|
||||||
<aside class="panel">
|
|
||||||
<div class="top">
|
|
||||||
<div class="mark" aria-label="NodeWarden">NW</div>
|
|
||||||
<div class="title">
|
|
||||||
<h1 id="t_app">NodeWarden</h1>
|
|
||||||
<p id="t_tag">Minimal Bitwarden-compatible server on Cloudflare Workers.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="muted" id="t_intro" style="font-size: 13px; line-height: 1.7;">
|
|
||||||
Create your first account to finish setup. Then use any official Bitwarden client to sign in.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="height: 14px"></div>
|
|
||||||
<h2 id="t_setup">Setup</h2>
|
|
||||||
|
|
||||||
<div id="message" class="message"></div>
|
|
||||||
|
|
||||||
<div id="setup-form">
|
|
||||||
<form id="form" onsubmit="handleSubmit(event)">
|
|
||||||
<div class="grid">
|
|
||||||
<div class="field">
|
|
||||||
<label for="name" id="t_name_label">Name</label>
|
|
||||||
<input type="text" id="name" name="name" required placeholder="Your name">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="email" id="t_email_label">Email</label>
|
|
||||||
<input type="email" id="email" name="email" required placeholder="you@example.com" autocomplete="email">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="height: 10px"></div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="password" id="t_pw_label">Master password</label>
|
|
||||||
<input type="password" id="password" name="password" required minlength="12" placeholder="At least 12 characters" autocomplete="new-password">
|
|
||||||
<p class="hint" id="t_pw_hint">Choose a strong password you can remember. The server cannot recover it.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="height: 10px"></div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="confirmPassword" id="t_pw2_label">Confirm password</label>
|
|
||||||
<input type="password" id="confirmPassword" name="confirmPassword" required placeholder="Confirm password" autocomplete="new-password">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<button type="submit" id="submitBtn" class="primary">Create account</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="registered-view" class="sideCard" style="display: none;">
|
|
||||||
<div class="kv">
|
|
||||||
<h3 id="t_done_title">Setup complete</h3>
|
|
||||||
<p id="t_done_desc">Your server is ready. Configure your Bitwarden client with this server URL:</p>
|
|
||||||
<div class="server" id="serverUrl"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="kv">
|
|
||||||
<h3 id="t_important">Important</h3>
|
|
||||||
<p id="t_limitations">
|
|
||||||
This project is designed for a single user. You cannot add new users. Changing the master password is not supported.
|
|
||||||
If you forget it, you must redeploy and register again.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="kv">
|
|
||||||
<h3 id="t_hide_title">Hide setup page</h3>
|
|
||||||
<p id="t_hide_desc">After hiding, this setup page will return 404 for everyone. Your vault will keep working.</p>
|
|
||||||
<div class="actions">
|
|
||||||
<button type="button" id="hideBtn" class="primary" onclick="disableSetupPage()">Hide setup page</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer">
|
|
||||||
<div>
|
|
||||||
<span class="muted" id="t_by">By</span>
|
|
||||||
<a href="https://shuai.plus" target="_blank" rel="noreferrer">shuaiplus</a>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<a href="https://github.com/shuaiplus/nodewarden" target="_blank" rel="noreferrer">GitHub</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let isRegistered = false;
|
|
||||||
|
|
||||||
function isChinese() {
|
|
||||||
const lang = (navigator.language || '').toLowerCase();
|
|
||||||
return lang.startsWith('zh');
|
|
||||||
}
|
|
||||||
|
|
||||||
function t(key) {
|
|
||||||
const zh = {
|
|
||||||
app: 'NodeWarden',
|
|
||||||
tag: '部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端。',
|
|
||||||
intro: '创建第一个账号完成初始化,然后用任意 Bitwarden 官方客户端登录。',
|
|
||||||
by: '作者',
|
|
||||||
setup: '初始化',
|
|
||||||
nameLabel: '昵称',
|
|
||||||
emailLabel: '邮箱',
|
|
||||||
pwLabel: '主密码',
|
|
||||||
pwHint: '请选择你能记住的强密码。服务器无法找回主密码。',
|
|
||||||
pw2Label: '确认主密码',
|
|
||||||
create: '创建账号',
|
|
||||||
creating: '正在创建…',
|
|
||||||
doneTitle: '初始化完成',
|
|
||||||
doneDesc: '服务已就绪。在 Bitwarden 客户端中填入以下服务器地址:',
|
|
||||||
important: '重要提示',
|
|
||||||
limitations: '本项目仅支持单用户:不能添加新用户;不支持修改主密码;如果忘记主密码,只能重新部署并重新注册。',
|
|
||||||
hideTitle: '隐藏初始化页',
|
|
||||||
hideDesc: '隐藏后,初始化页对任何人都会直接返回 404。你的密码库仍可正常使用。',
|
|
||||||
hideBtn: '隐藏初始化页',
|
|
||||||
hideWorking: '正在隐藏…',
|
|
||||||
hideDone: '已隐藏,此页面将返回 404。',
|
|
||||||
hideFailed: '隐藏失败',
|
|
||||||
hideConfirm: '确认隐藏初始化页?隐藏后页面将不可访问,但你的密码库不会受影响。',
|
|
||||||
errPwNotMatch: '两次输入的密码不一致',
|
|
||||||
errPwTooShort: '密码长度至少 12 位',
|
|
||||||
errGeneric: '发生错误:',
|
|
||||||
errRegisterFailed: '注册失败',
|
|
||||||
};
|
|
||||||
const en = {
|
|
||||||
app: 'NodeWarden',
|
|
||||||
tag: 'Minimal Bitwarden-compatible server on Cloudflare Workers.',
|
|
||||||
intro: 'Create your first account to finish setup. Then use any official Bitwarden client to sign in.',
|
|
||||||
by: 'By',
|
|
||||||
setup: 'Setup',
|
|
||||||
nameLabel: 'Name',
|
|
||||||
emailLabel: 'Email',
|
|
||||||
pwLabel: 'Master password',
|
|
||||||
pwHint: 'Choose a strong password you can remember. The server cannot recover it.',
|
|
||||||
pw2Label: 'Confirm password',
|
|
||||||
create: 'Create account',
|
|
||||||
creating: 'Creating…',
|
|
||||||
doneTitle: 'Setup complete',
|
|
||||||
doneDesc: 'Your server is ready. Configure your Bitwarden client with this server URL:',
|
|
||||||
important: 'Important',
|
|
||||||
limitations: 'Single user only: you cannot add new users. Changing the master password is not supported. If you forget it, redeploy and register again.',
|
|
||||||
hideTitle: 'Hide setup page',
|
|
||||||
hideDesc: 'After hiding, this setup page will return 404 for everyone. Your vault will keep working.',
|
|
||||||
hideBtn: 'Hide setup page',
|
|
||||||
hideWorking: 'Hiding…',
|
|
||||||
hideDone: 'Hidden. This page will now return 404.',
|
|
||||||
hideFailed: 'Failed to hide setup page',
|
|
||||||
hideConfirm: 'Hide the setup page? It will no longer be accessible, but your vault will keep working.',
|
|
||||||
errPwNotMatch: 'Passwords do not match',
|
|
||||||
errPwTooShort: 'Password must be at least 12 characters',
|
|
||||||
errGeneric: 'An error occurred: ',
|
|
||||||
errRegisterFailed: 'Registration failed',
|
|
||||||
};
|
|
||||||
return (isChinese() ? zh : en)[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyI18n() {
|
|
||||||
document.documentElement.lang = isChinese() ? 'zh-CN' : 'en';
|
|
||||||
|
|
||||||
document.getElementById('t_app').textContent = t('app');
|
|
||||||
document.getElementById('t_tag').textContent = t('tag');
|
|
||||||
document.getElementById('t_intro').textContent = t('intro');
|
|
||||||
document.getElementById('t_by').textContent = t('by');
|
|
||||||
document.getElementById('t_setup').textContent = t('setup');
|
|
||||||
|
|
||||||
document.getElementById('t_name_label').textContent = t('nameLabel');
|
|
||||||
document.getElementById('t_email_label').textContent = t('emailLabel');
|
|
||||||
document.getElementById('t_pw_label').textContent = t('pwLabel');
|
|
||||||
document.getElementById('t_pw_hint').textContent = t('pwHint');
|
|
||||||
document.getElementById('t_pw2_label').textContent = t('pw2Label');
|
|
||||||
document.getElementById('submitBtn').textContent = t('create');
|
|
||||||
|
|
||||||
document.getElementById('t_done_title').textContent = t('doneTitle');
|
|
||||||
document.getElementById('t_done_desc').textContent = t('doneDesc');
|
|
||||||
document.getElementById('t_important').textContent = t('important');
|
|
||||||
document.getElementById('t_limitations').textContent = t('limitations');
|
|
||||||
document.getElementById('t_hide_title').textContent = t('hideTitle');
|
|
||||||
document.getElementById('t_hide_desc').textContent = t('hideDesc');
|
|
||||||
document.getElementById('hideBtn').textContent = t('hideBtn');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkStatus() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/setup/status');
|
|
||||||
const data = await res.json();
|
|
||||||
isRegistered = !!data.registered;
|
|
||||||
if (data.registered) {
|
|
||||||
showRegisteredView();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to check status:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showRegisteredView() {
|
|
||||||
isRegistered = true;
|
|
||||||
document.getElementById('setup-form').style.display = 'none';
|
|
||||||
document.getElementById('registered-view').style.display = 'block';
|
|
||||||
document.getElementById('serverUrl').textContent = window.location.origin;
|
|
||||||
showMessage(t('doneTitle'), 'success');
|
|
||||||
const form = document.getElementById('form');
|
|
||||||
if (form) {
|
|
||||||
const fields = form.querySelectorAll('input, button');
|
|
||||||
fields.forEach((el) => {
|
|
||||||
el.disabled = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function disableSetupPage() {
|
|
||||||
if (!isRegistered) return;
|
|
||||||
if (!confirm(t('hideConfirm'))) return;
|
|
||||||
|
|
||||||
const btn = document.getElementById('hideBtn');
|
|
||||||
if (btn) {
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = t('hideWorking');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/setup/disable', { method: 'POST' });
|
|
||||||
const data = await res.json();
|
|
||||||
if (res.ok && data.success) {
|
|
||||||
showMessage(t('hideDone'), 'success');
|
|
||||||
setTimeout(() => window.location.reload(), 600);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showMessage(data.error || t('hideFailed'), 'error');
|
|
||||||
} catch (e) {
|
|
||||||
showMessage(t('hideFailed'), 'error');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (btn) {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = t('hideBtn');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showMessage(text, type) {
|
|
||||||
const msg = document.getElementById('message');
|
|
||||||
msg.textContent = text;
|
|
||||||
msg.className = 'message ' + type;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pbkdf2(password, salt, iterations, keyLen) {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const passwordBytes = (password instanceof Uint8Array)
|
|
||||||
? password
|
|
||||||
: encoder.encode(password);
|
|
||||||
const saltBytes = (salt instanceof Uint8Array)
|
|
||||||
? salt
|
|
||||||
: encoder.encode(salt);
|
|
||||||
|
|
||||||
const keyMaterial = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
passwordBytes,
|
|
||||||
'PBKDF2',
|
|
||||||
false,
|
|
||||||
['deriveBits']
|
|
||||||
);
|
|
||||||
|
|
||||||
const derivedBits = await crypto.subtle.deriveBits(
|
|
||||||
{
|
|
||||||
name: 'PBKDF2',
|
|
||||||
salt: saltBytes,
|
|
||||||
iterations: iterations,
|
|
||||||
hash: 'SHA-256'
|
|
||||||
},
|
|
||||||
keyMaterial,
|
|
||||||
keyLen * 8
|
|
||||||
);
|
|
||||||
|
|
||||||
return new Uint8Array(derivedBits);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hkdfExpand(prk, info, length) {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const key = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
prk,
|
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['sign']
|
|
||||||
);
|
|
||||||
|
|
||||||
const infoBytes = encoder.encode(info);
|
|
||||||
const result = new Uint8Array(length);
|
|
||||||
let prev = new Uint8Array(0);
|
|
||||||
let offset = 0;
|
|
||||||
let counter = 1;
|
|
||||||
|
|
||||||
while (offset < length) {
|
|
||||||
const input = new Uint8Array(prev.length + infoBytes.length + 1);
|
|
||||||
input.set(prev);
|
|
||||||
input.set(infoBytes, prev.length);
|
|
||||||
input[input.length - 1] = counter;
|
|
||||||
|
|
||||||
const signature = await crypto.subtle.sign('HMAC', key, input);
|
|
||||||
prev = new Uint8Array(signature);
|
|
||||||
|
|
||||||
const toCopy = Math.min(prev.length, length - offset);
|
|
||||||
result.set(prev.slice(0, toCopy), offset);
|
|
||||||
offset += toCopy;
|
|
||||||
counter++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateSymmetricKey() {
|
|
||||||
return crypto.getRandomValues(new Uint8Array(64));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function encryptAesCbc(data, key, iv) {
|
|
||||||
const cryptoKey = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
key,
|
|
||||||
{ name: 'AES-CBC' },
|
|
||||||
false,
|
|
||||||
['encrypt']
|
|
||||||
);
|
|
||||||
|
|
||||||
const encrypted = await crypto.subtle.encrypt(
|
|
||||||
{ name: 'AES-CBC', iv: iv },
|
|
||||||
cryptoKey,
|
|
||||||
data
|
|
||||||
);
|
|
||||||
|
|
||||||
return new Uint8Array(encrypted);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hmacSha256(key, data) {
|
|
||||||
const cryptoKey = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
key,
|
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['sign']
|
|
||||||
);
|
|
||||||
|
|
||||||
const signature = await crypto.subtle.sign('HMAC', cryptoKey, data);
|
|
||||||
return new Uint8Array(signature);
|
|
||||||
}
|
|
||||||
|
|
||||||
function base64Encode(bytes) {
|
|
||||||
return btoa(String.fromCharCode.apply(null, bytes));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function encryptToBitwardenFormat(data, encKey, macKey) {
|
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(16));
|
|
||||||
const encrypted = await encryptAesCbc(data, encKey, iv);
|
|
||||||
|
|
||||||
const macData = new Uint8Array(iv.length + encrypted.length);
|
|
||||||
macData.set(iv);
|
|
||||||
macData.set(encrypted, iv.length);
|
|
||||||
const mac = await hmacSha256(macKey, macData);
|
|
||||||
|
|
||||||
return '2.' + base64Encode(iv) + '|' + base64Encode(encrypted) + '|' + base64Encode(mac);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateRsaKeyPair() {
|
|
||||||
const keyPair = await crypto.subtle.generateKey(
|
|
||||||
{
|
|
||||||
name: 'RSA-OAEP',
|
|
||||||
modulusLength: 2048,
|
|
||||||
publicExponent: new Uint8Array([1, 0, 1]),
|
|
||||||
hash: 'SHA-1'
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
['encrypt', 'decrypt']
|
|
||||||
);
|
|
||||||
|
|
||||||
const publicKeySpki = await crypto.subtle.exportKey('spki', keyPair.publicKey);
|
|
||||||
const publicKeyB64 = base64Encode(new Uint8Array(publicKeySpki));
|
|
||||||
|
|
||||||
const privateKeyPkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
|
||||||
const privateKeyBytes = new Uint8Array(privateKeyPkcs8);
|
|
||||||
|
|
||||||
return {
|
|
||||||
publicKey: publicKeyB64,
|
|
||||||
privateKey: privateKeyBytes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
if (isRegistered) {
|
|
||||||
showMessage(t('doneTitle'), 'success');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = document.getElementById('name').value;
|
|
||||||
const email = document.getElementById('email').value.toLowerCase();
|
|
||||||
const password = document.getElementById('password').value;
|
|
||||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
|
||||||
showMessage(t('errPwNotMatch'), 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password.length < 12) {
|
|
||||||
showMessage(t('errPwTooShort'), 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const btn = document.getElementById('submitBtn');
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = t('creating');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const iterations = 600000;
|
|
||||||
const masterKey = await pbkdf2(password, email, iterations, 32);
|
|
||||||
|
|
||||||
const masterPasswordHash = await pbkdf2(masterKey, password, 1, 32);
|
|
||||||
const masterPasswordHashB64 = base64Encode(masterPasswordHash);
|
|
||||||
|
|
||||||
const stretchedKey = await hkdfExpand(masterKey, 'enc', 32);
|
|
||||||
const stretchedMacKey = await hkdfExpand(masterKey, 'mac', 32);
|
|
||||||
|
|
||||||
const symmetricKey = generateSymmetricKey();
|
|
||||||
|
|
||||||
const encryptedKey = await encryptToBitwardenFormat(symmetricKey, stretchedKey, stretchedMacKey);
|
|
||||||
|
|
||||||
const rsaKeys = await generateRsaKeyPair();
|
|
||||||
|
|
||||||
const encryptedPrivateKey = await encryptToBitwardenFormat(rsaKeys.privateKey, symmetricKey.slice(0, 32), symmetricKey.slice(32, 64));
|
|
||||||
|
|
||||||
const response = await fetch('/api/accounts/register', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
email: email,
|
|
||||||
name: name,
|
|
||||||
masterPasswordHash: masterPasswordHashB64,
|
|
||||||
key: encryptedKey,
|
|
||||||
kdf: 0,
|
|
||||||
kdfIterations: iterations,
|
|
||||||
keys: {
|
|
||||||
publicKey: rsaKeys.publicKey,
|
|
||||||
encryptedPrivateKey: encryptedPrivateKey
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && result.success) {
|
|
||||||
showRegisteredView();
|
|
||||||
} else {
|
|
||||||
showMessage(result.error || result.ErrorModel?.Message || t('errRegisterFailed'), 'error');
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = t('create');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Registration error:', error);
|
|
||||||
showMessage(t('errGeneric') + (error && error.message ? error.message : String(error)), 'error');
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = t('create');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
applyI18n();
|
|
||||||
checkStatus();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
|
|
||||||
export async function handleRegisterPage(request: Request, env: Env): Promise<Response> {
|
|
||||||
const storage = new StorageService(env.DB);
|
|
||||||
const disabled = await storage.isSetupDisabled();
|
|
||||||
if (disabled) {
|
|
||||||
return new Response(null, { status: 404 });
|
|
||||||
}
|
|
||||||
return htmlResponse(registerPageHTML);
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,62 @@
|
|||||||
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 { jsonResponse, errorResponse } from '../utils/response';
|
import { errorResponse } from '../utils/response';
|
||||||
import { cipherToResponse } from './ciphers';
|
import { cipherToResponse } from './ciphers';
|
||||||
|
import { sendToResponse } from './sends';
|
||||||
|
import { LIMITS } from '../config/limits';
|
||||||
|
import {
|
||||||
|
buildAccountKeys,
|
||||||
|
buildUserDecryptionCompat,
|
||||||
|
buildUserDecryptionOptions,
|
||||||
|
} from '../utils/user-decryption';
|
||||||
|
|
||||||
|
function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean): Request {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const cacheUrl = new URL(
|
||||||
|
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${excludeDomains ? '1' : '0'}`,
|
||||||
|
url.origin
|
||||||
|
);
|
||||||
|
return new Request(cacheUrl.toString(), { method: 'GET' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSyncCache(cacheRequest: Request): Promise<Response | null> {
|
||||||
|
const hit = await caches.default.match(cacheRequest);
|
||||||
|
if (!hit) return null;
|
||||||
|
return new Response(hit.body, hit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeSyncCache(cacheRequest: Request, response: Response): Promise<void> {
|
||||||
|
await caches.default.put(cacheRequest, response.clone());
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/sync
|
// GET /api/sync
|
||||||
export async function handleSync(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleSync(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const excludeDomainsParam = url.searchParams.get('excludeDomains');
|
||||||
|
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
||||||
|
|
||||||
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 ciphers = await storage.getAllCiphers(userId);
|
const revisionDate = await storage.getRevisionDate(userId);
|
||||||
const folders = await storage.getAllFolders(userId);
|
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains);
|
||||||
|
const cachedResponse = await readSyncCache(cacheRequest);
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [ciphers, folders, sends, attachmentsByCipher] = await Promise.all([
|
||||||
|
storage.getAllCiphers(userId),
|
||||||
|
storage.getAllFolders(userId),
|
||||||
|
storage.getAllSends(userId),
|
||||||
|
storage.getAttachmentsByUserId(userId),
|
||||||
|
]);
|
||||||
|
const accountKeys = buildAccountKeys(user);
|
||||||
|
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||||
|
|
||||||
// Build profile response
|
|
||||||
const profile: ProfileResponse = {
|
const profile: ProfileResponse = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
@@ -24,12 +65,12 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
premium: true,
|
premium: true,
|
||||||
premiumFromOrganization: false,
|
premiumFromOrganization: false,
|
||||||
usesKeyConnector: false,
|
usesKeyConnector: false,
|
||||||
masterPasswordHint: null,
|
masterPasswordHint: user.masterPasswordHint,
|
||||||
culture: 'en-US',
|
culture: 'en-US',
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: !!user.totpSecret,
|
||||||
key: user.key,
|
key: user.key,
|
||||||
privateKey: user.privateKey,
|
privateKey: user.privateKey,
|
||||||
accountKeys: null,
|
accountKeys,
|
||||||
securityStamp: user.securityStamp || user.id,
|
securityStamp: user.securityStamp || user.id,
|
||||||
organizations: [],
|
organizations: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
@@ -37,69 +78,58 @@ 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 = await storage.getAttachmentsByCipher(cipher.id);
|
cipherResponses.push(cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []));
|
||||||
cipherResponses.push(cipherToResponse(cipher, attachments));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build folder responses
|
const folderResponses: FolderResponse[] = [];
|
||||||
const folderResponses: FolderResponse[] = folders.map(folder => ({
|
for (const folder of folders) {
|
||||||
|
folderResponses.push({
|
||||||
id: folder.id,
|
id: folder.id,
|
||||||
name: folder.name,
|
name: folder.name,
|
||||||
revisionDate: folder.updatedAt,
|
revisionDate: folder.updatedAt,
|
||||||
object: 'folder',
|
object: 'folder',
|
||||||
}));
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendResponses = sends.map(sendToResponse);
|
||||||
const syncResponse: SyncResponse = {
|
const syncResponse: SyncResponse = {
|
||||||
profile: profile,
|
profile,
|
||||||
folders: folderResponses,
|
folders: folderResponses,
|
||||||
collections: [],
|
collections: [],
|
||||||
ciphers: cipherResponses,
|
ciphers: cipherResponses,
|
||||||
domains: {
|
domains: excludeDomains
|
||||||
|
? null
|
||||||
|
: {
|
||||||
equivalentDomains: [],
|
equivalentDomains: [],
|
||||||
globalEquivalentDomains: [],
|
globalEquivalentDomains: [],
|
||||||
object: 'domains',
|
object: 'domains',
|
||||||
},
|
},
|
||||||
policies: [],
|
policies: [],
|
||||||
sends: [],
|
sends: sendResponses,
|
||||||
// PascalCase for desktop/browser clients
|
UserDecryption: {
|
||||||
UserDecryptionOptions: {
|
MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock,
|
||||||
HasMasterPassword: true,
|
TrustedDeviceOption: null,
|
||||||
Object: 'userDecryptionOptions',
|
KeyConnectorOption: null,
|
||||||
MasterPasswordUnlock: {
|
Object: 'userDecryption',
|
||||||
Kdf: {
|
|
||||||
KdfType: user.kdfType,
|
|
||||||
Iterations: user.kdfIterations,
|
|
||||||
Memory: user.kdfMemory || null,
|
|
||||||
Parallelism: user.kdfParallelism || null,
|
|
||||||
},
|
|
||||||
MasterKeyEncryptedUserKey: user.key,
|
|
||||||
MasterKeyWrappedUserKey: user.key,
|
|
||||||
Salt: user.email,
|
|
||||||
Object: 'masterPasswordUnlock',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
|
||||||
userDecryption: {
|
|
||||||
masterPasswordUnlock: {
|
|
||||||
kdf: {
|
|
||||||
kdfType: user.kdfType,
|
|
||||||
iterations: user.kdfIterations,
|
|
||||||
memory: user.kdfMemory || null,
|
|
||||||
parallelism: user.kdfParallelism || null,
|
|
||||||
},
|
|
||||||
masterKeyWrappedUserKey: user.key,
|
|
||||||
masterKeyEncryptedUserKey: user.key,
|
|
||||||
salt: user.email,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
UserDecryptionOptions: userDecryptionOptions,
|
||||||
|
userDecryption: buildUserDecryptionCompat(user) as SyncResponse['userDecryption'],
|
||||||
object: 'sync',
|
object: 'sync',
|
||||||
};
|
};
|
||||||
|
|
||||||
return jsonResponse(syncResponse);
|
const response = new Response(JSON.stringify(syncResponse), {
|
||||||
|
status: 200,
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,109 @@
|
|||||||
import { Env } from './types';
|
import { Env } from './types';
|
||||||
|
import { NotificationsHub } from './durable/notifications-hub';
|
||||||
import { handleRequest } from './router';
|
import { handleRequest } from './router';
|
||||||
import { StorageService } from './services/storage';
|
import { StorageService } from './services/storage';
|
||||||
|
import { applyCors, jsonResponse } from './utils/response';
|
||||||
|
import { runScheduledBackupIfDue } from './handlers/backup';
|
||||||
|
|
||||||
// Per-isolate flag. Each Worker isolate may have its own copy of this flag,
|
|
||||||
// but initializeDatabase() is idempotent (uses CREATE TABLE IF NOT EXISTS),
|
|
||||||
// so redundant calls are harmless and fast (single SELECT check).
|
|
||||||
let dbInitialized = false;
|
let dbInitialized = false;
|
||||||
|
let dbInitError: string | null = null;
|
||||||
|
let dbInitPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
export default {
|
function normalizeRequestUrl(request: Request): Request {
|
||||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
const url = new URL(request.url);
|
||||||
// Auto-initialize database on first request
|
const normalizedPathname = url.pathname.length <= 1 ? url.pathname : url.pathname.replace(/\/+$/, '');
|
||||||
if (!dbInitialized) {
|
if (normalizedPathname === url.pathname) return request;
|
||||||
try {
|
|
||||||
|
url.pathname = normalizedPathname;
|
||||||
|
return new Request(url.toString(), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWorkerHandledPath(path: string): boolean {
|
||||||
|
return (
|
||||||
|
path.startsWith('/api/') ||
|
||||||
|
path.startsWith('/identity/') ||
|
||||||
|
path.startsWith('/icons/') ||
|
||||||
|
path.startsWith('/notifications/') ||
|
||||||
|
path.startsWith('/.well-known/') ||
|
||||||
|
path === '/config' ||
|
||||||
|
path === '/api/config' ||
|
||||||
|
path === '/api/version'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybeServeAsset(request: Request, env: Env): Promise<Response | null> {
|
||||||
|
if (!env.ASSETS) return null;
|
||||||
|
if (request.method !== 'GET' && request.method !== 'HEAD') return null;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (isWorkerHandledPath(url.pathname)) return null;
|
||||||
|
|
||||||
|
return env.ASSETS.fetch(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDatabaseInitialized(env: Env): Promise<void> {
|
||||||
|
if (dbInitialized) return;
|
||||||
|
|
||||||
|
if (!dbInitPromise) {
|
||||||
|
dbInitPromise = (async () => {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
await storage.initializeDatabase();
|
await storage.initializeDatabase();
|
||||||
dbInitialized = true;
|
dbInitialized = true;
|
||||||
} catch (error) {
|
dbInitError = null;
|
||||||
|
})()
|
||||||
|
.catch((error: unknown) => {
|
||||||
console.error('Failed to initialize database:', error);
|
console.error('Failed to initialize database:', error);
|
||||||
// Continue anyway - the error will surface when actual DB operations are attempted
|
dbInitError = error instanceof Error ? error.message : 'Unknown database initialization error';
|
||||||
}
|
})
|
||||||
|
.finally(() => {
|
||||||
|
dbInitPromise = null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return handleRequest(request, env);
|
await dbInitPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||||
|
void ctx;
|
||||||
|
const normalizedRequest = normalizeRequestUrl(request);
|
||||||
|
const assetResponse = await maybeServeAsset(normalizedRequest, env);
|
||||||
|
if (assetResponse) {
|
||||||
|
return applyCors(normalizedRequest, assetResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureDatabaseInitialized(env);
|
||||||
|
if (dbInitError) {
|
||||||
|
// Log full error server-side, return generic message to client.
|
||||||
|
console.error('DB init error (not forwarded to client):', dbInitError);
|
||||||
|
const resp = jsonResponse(
|
||||||
|
{
|
||||||
|
error: 'Database not initialized',
|
||||||
|
error_description: 'Database initialization failed. Check server logs for details.',
|
||||||
|
ErrorModel: {
|
||||||
|
Message: 'Service temporarily unavailable',
|
||||||
|
Object: 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
500
|
||||||
|
);
|
||||||
|
return applyCors(normalizedRequest, resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await handleRequest(normalizedRequest, env);
|
||||||
|
return applyCors(normalizedRequest, resp);
|
||||||
|
},
|
||||||
|
|
||||||
|
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
||||||
|
void controller;
|
||||||
|
await ensureDatabaseInitialized(env);
|
||||||
|
if (dbInitError) {
|
||||||
|
console.error('Skipping scheduled backup because DB init failed:', dbInitError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.waitUntil(runScheduledBackupIfDue(env).catch((error) => {
|
||||||
|
console.error('Scheduled backup failed:', error);
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { NotificationsHub };
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { Env, User } from './types';
|
||||||
|
import {
|
||||||
|
handleAdminExportBackup,
|
||||||
|
handleDownloadAdminRemoteBackup,
|
||||||
|
handleDeleteAdminRemoteBackup,
|
||||||
|
handleDownloadAdminBackupAttachment,
|
||||||
|
handleGetAdminBackupSettings,
|
||||||
|
handleGetAdminBackupSettingsRepairState,
|
||||||
|
handleInspectAdminRemoteBackup,
|
||||||
|
handleAdminImportBackup,
|
||||||
|
handleListAdminRemoteBackups,
|
||||||
|
handleRepairAdminBackupSettings,
|
||||||
|
handleRestoreAdminRemoteBackup,
|
||||||
|
handleRunAdminConfiguredBackup,
|
||||||
|
handleUpdateAdminBackupSettings,
|
||||||
|
} from './handlers/backup';
|
||||||
|
|
||||||
|
export async function handleAdminBackupRoute(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
actorUser: User,
|
||||||
|
path: string,
|
||||||
|
method: string
|
||||||
|
): Promise<Response | null> {
|
||||||
|
if (path === '/api/admin/backup/export' && method === 'POST') {
|
||||||
|
return handleAdminExportBackup(request, env, actorUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/blob' && method === 'GET') {
|
||||||
|
return handleDownloadAdminBackupAttachment(request, env, actorUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/settings') {
|
||||||
|
if (method === 'GET') return handleGetAdminBackupSettings(request, env, actorUser);
|
||||||
|
if (method === 'PUT') return handleUpdateAdminBackupSettings(request, env, actorUser);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/settings/repair') {
|
||||||
|
if (method === 'GET') return handleGetAdminBackupSettingsRepairState(request, env, actorUser);
|
||||||
|
if (method === 'POST') return handleRepairAdminBackupSettings(request, env, actorUser);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/run' && method === 'POST') {
|
||||||
|
return handleRunAdminConfiguredBackup(request, env, actorUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/remote' && method === 'GET') {
|
||||||
|
return handleListAdminRemoteBackups(request, env, actorUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/remote/download' && method === 'GET') {
|
||||||
|
return handleDownloadAdminRemoteBackup(request, env, actorUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/remote/integrity' && method === 'GET') {
|
||||||
|
return handleInspectAdminRemoteBackup(request, env, actorUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/remote/file' && method === 'DELETE') {
|
||||||
|
return handleDeleteAdminRemoteBackup(request, env, actorUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/remote/restore' && method === 'POST') {
|
||||||
|
return handleRestoreAdminRemoteBackup(request, env, actorUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/import' && method === 'POST') {
|
||||||
|
return handleAdminImportBackup(request, env, actorUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { Env, User } from './types';
|
||||||
|
import {
|
||||||
|
handleAdminListUsers,
|
||||||
|
handleAdminCreateInvite,
|
||||||
|
handleAdminListInvites,
|
||||||
|
handleAdminDeleteAllInvites,
|
||||||
|
handleAdminRevokeInvite,
|
||||||
|
handleAdminSetUserStatus,
|
||||||
|
handleAdminDeleteUser,
|
||||||
|
} from './handlers/admin';
|
||||||
|
import { handleAdminBackupRoute } from './router-admin-backup';
|
||||||
|
|
||||||
|
export async function handleAdminRoute(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
actorUser: User,
|
||||||
|
path: string,
|
||||||
|
method: string
|
||||||
|
): Promise<Response | null> {
|
||||||
|
if (path === '/api/admin/users' && method === 'GET') {
|
||||||
|
return handleAdminListUsers(request, env, actorUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminBackupResponse = await handleAdminBackupRoute(request, env, actorUser, path, method);
|
||||||
|
if (adminBackupResponse) return adminBackupResponse;
|
||||||
|
|
||||||
|
if (path === '/api/admin/invites') {
|
||||||
|
if (method === 'GET') return handleAdminListInvites(request, env, actorUser);
|
||||||
|
if (method === 'POST') return handleAdminCreateInvite(request, env, actorUser);
|
||||||
|
if (method === 'DELETE') return handleAdminDeleteAllInvites(request, env, actorUser);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminInviteMatch = path.match(/^\/api\/admin\/invites\/([^/]+)$/i);
|
||||||
|
if (adminInviteMatch && method === 'DELETE') {
|
||||||
|
const inviteCode = decodeURIComponent(adminInviteMatch[1]);
|
||||||
|
return handleAdminRevokeInvite(request, env, actorUser, inviteCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminUserStatusMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)\/status$/i);
|
||||||
|
if (adminUserStatusMatch && (method === 'PUT' || method === 'POST')) {
|
||||||
|
return handleAdminSetUserStatus(request, env, actorUser, adminUserStatusMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminUserDeleteMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)$/i);
|
||||||
|
if (adminUserDeleteMatch && method === 'DELETE') {
|
||||||
|
return handleAdminDeleteUser(request, env, actorUser, adminUserDeleteMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import type { Env, User } from './types';
|
||||||
|
import { errorResponse, jsonResponse } from './utils/response';
|
||||||
|
import {
|
||||||
|
handleGetProfile,
|
||||||
|
handleUpdateProfile,
|
||||||
|
handleSetKeys,
|
||||||
|
handleGetRevisionDate,
|
||||||
|
handleVerifyPassword,
|
||||||
|
handleChangePassword,
|
||||||
|
handleSetVerifyDevices,
|
||||||
|
handleGetTotpStatus,
|
||||||
|
handleSetTotpStatus,
|
||||||
|
handleGetTotpRecoveryCode,
|
||||||
|
} from './handlers/accounts';
|
||||||
|
import {
|
||||||
|
handleGetCiphers,
|
||||||
|
handleGetCipher,
|
||||||
|
handleCreateCipher,
|
||||||
|
handleUpdateCipher,
|
||||||
|
handleDeleteCipher,
|
||||||
|
handleDeleteCipherCompat,
|
||||||
|
handlePermanentDeleteCipher,
|
||||||
|
handleRestoreCipher,
|
||||||
|
handleBulkArchiveCiphers,
|
||||||
|
handlePartialUpdateCipher,
|
||||||
|
handleBulkUnarchiveCiphers,
|
||||||
|
handleBulkMoveCiphers,
|
||||||
|
handleBulkDeleteCiphers,
|
||||||
|
handleBulkPermanentDeleteCiphers,
|
||||||
|
handleBulkRestoreCiphers,
|
||||||
|
handleArchiveCipher,
|
||||||
|
handleUnarchiveCipher,
|
||||||
|
} from './handlers/ciphers';
|
||||||
|
import {
|
||||||
|
handleGetFolders,
|
||||||
|
handleGetFolder,
|
||||||
|
handleCreateFolder,
|
||||||
|
handleUpdateFolder,
|
||||||
|
handleDeleteFolder,
|
||||||
|
handleBulkDeleteFolders,
|
||||||
|
} from './handlers/folders';
|
||||||
|
import {
|
||||||
|
handleGetSends,
|
||||||
|
handleGetSend,
|
||||||
|
handleCreateSend,
|
||||||
|
handleCreateFileSendV2,
|
||||||
|
handleGetSendFileUpload,
|
||||||
|
handleUploadSendFile,
|
||||||
|
handleUpdateSend,
|
||||||
|
handleDeleteSend,
|
||||||
|
handleBulkDeleteSends,
|
||||||
|
handleRemoveSendPassword,
|
||||||
|
handleRemoveSendAuth,
|
||||||
|
} from './handlers/sends';
|
||||||
|
import { handleSync } from './handlers/sync';
|
||||||
|
import { handleCiphersImport } from './handlers/import';
|
||||||
|
import {
|
||||||
|
handleCreateAttachment,
|
||||||
|
handleUploadAttachment,
|
||||||
|
handleGetAttachment,
|
||||||
|
handleDeleteAttachment,
|
||||||
|
} from './handlers/attachments';
|
||||||
|
import { handleAuthenticatedDeviceRoute } from './router-devices';
|
||||||
|
import { handleAdminRoute } from './router-admin';
|
||||||
|
|
||||||
|
export async function handleAuthenticatedRoute(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
currentUser: User,
|
||||||
|
path: string,
|
||||||
|
method: string
|
||||||
|
): Promise<Response | null> {
|
||||||
|
if (method === 'POST' || method === 'PUT' || method === 'DELETE') {
|
||||||
|
const blockedAccountPaths = new Set([
|
||||||
|
'/api/accounts/set-password',
|
||||||
|
'/api/accounts/delete',
|
||||||
|
'/api/accounts/delete-account',
|
||||||
|
'/api/accounts/delete-vault',
|
||||||
|
]);
|
||||||
|
if (blockedAccountPaths.has(path)) {
|
||||||
|
return errorResponse('Not implemented', 501);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/accounts/profile') {
|
||||||
|
if (method === 'GET') return handleGetProfile(request, env, userId);
|
||||||
|
if (method === 'PUT') return handleUpdateProfile(request, env, userId);
|
||||||
|
return errorResponse('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((path === '/api/accounts/password' || path === '/api/accounts/change-password') && (method === 'POST' || method === 'PUT')) {
|
||||||
|
return handleChangePassword(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/accounts/keys' && method === 'POST') {
|
||||||
|
return handleSetKeys(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/accounts/totp') {
|
||||||
|
if (method === 'GET') return handleGetTotpStatus(request, env, userId);
|
||||||
|
if (method === 'PUT' || method === 'POST') return handleSetTotpStatus(request, env, userId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((path === '/api/accounts/totp/recovery-code' || path === '/api/two-factor/get-recover') && method === 'POST') {
|
||||||
|
return handleGetTotpRecoveryCode(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/accounts/revision-date' && method === 'GET') {
|
||||||
|
return handleGetRevisionDate(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/accounts/verify-password' && method === 'POST') {
|
||||||
|
return handleVerifyPassword(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/accounts/verify-devices' && (method === 'PUT' || method === 'POST')) {
|
||||||
|
return handleSetVerifyDevices(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/sync' && method === 'GET') {
|
||||||
|
return handleSync(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith('/notifications/')) {
|
||||||
|
return errorResponse('Not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/ciphers' || path === '/api/ciphers/create') {
|
||||||
|
if (method === 'GET') return handleGetCiphers(request, env, userId);
|
||||||
|
if (method === 'POST') return handleCreateCipher(request, env, userId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/ciphers/import' && method === 'POST') {
|
||||||
|
return handleCiphersImport(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/ciphers/delete' && method === 'POST') {
|
||||||
|
return handleBulkDeleteCiphers(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/ciphers/delete-permanent' && method === 'POST') {
|
||||||
|
return handleBulkPermanentDeleteCiphers(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/ciphers/restore' && method === 'POST') {
|
||||||
|
return handleBulkRestoreCiphers(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/ciphers/archive' && (method === 'PUT' || method === 'POST')) {
|
||||||
|
return handleBulkArchiveCiphers(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/ciphers/unarchive' && (method === 'PUT' || method === 'POST')) {
|
||||||
|
return handleBulkUnarchiveCiphers(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/ciphers/move' && (method === 'POST' || method === 'PUT')) {
|
||||||
|
return handleBulkMoveCiphers(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cipherMatch = path.match(/^\/api\/ciphers\/([a-f0-9-]+)(\/.*)?$/i);
|
||||||
|
if (cipherMatch) {
|
||||||
|
const cipherId = cipherMatch[1];
|
||||||
|
const subPath = cipherMatch[2] || '';
|
||||||
|
|
||||||
|
if (subPath === '' || subPath === '/') {
|
||||||
|
if (method === 'GET') return handleGetCipher(request, env, userId, cipherId);
|
||||||
|
if (method === 'PUT' || method === 'POST') return handleUpdateCipher(request, env, userId, cipherId);
|
||||||
|
if (method === 'DELETE') return handleDeleteCipherCompat(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 === '/restore' && method === 'PUT') return handleRestoreCipher(request, env, userId, cipherId);
|
||||||
|
if (subPath === '/archive' && (method === 'PUT' || method === 'POST')) return handleArchiveCipher(request, env, userId, cipherId);
|
||||||
|
if (subPath === '/unarchive' && (method === 'PUT' || method === 'POST')) return handleUnarchiveCipher(request, env, userId, cipherId);
|
||||||
|
if (subPath === '/partial' && (method === 'PUT' || method === 'POST')) return handlePartialUpdateCipher(request, env, userId, cipherId);
|
||||||
|
if (subPath === '/share' && method === 'POST') return handleGetCipher(request, env, userId, cipherId);
|
||||||
|
if (subPath === '/details' && method === 'GET') return handleGetCipher(request, env, userId, cipherId);
|
||||||
|
if (subPath === '/attachment/v2' && method === 'POST') return handleCreateAttachment(request, env, userId, cipherId);
|
||||||
|
if (subPath === '/attachment' && method === 'POST') return handleCreateAttachment(request, env, userId, cipherId);
|
||||||
|
|
||||||
|
const attachmentMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)$/i);
|
||||||
|
if (attachmentMatch) {
|
||||||
|
const attachmentId = attachmentMatch[1];
|
||||||
|
if (method === 'POST' || method === 'PUT') return handleUploadAttachment(request, env, userId, cipherId, attachmentId);
|
||||||
|
if (method === 'GET') return handleGetAttachment(request, env, userId, cipherId, attachmentId);
|
||||||
|
if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i);
|
||||||
|
if (attachmentDeleteMatch && method === 'POST') {
|
||||||
|
return handleDeleteAttachment(request, env, userId, cipherId, attachmentDeleteMatch[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/folders') {
|
||||||
|
if (method === 'GET') return handleGetFolders(request, env, userId);
|
||||||
|
if (method === 'POST') return handleCreateFolder(request, env, userId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/folders/delete' && method === 'POST') {
|
||||||
|
return handleBulkDeleteFolders(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderMatch = path.match(/^\/api\/folders\/([a-f0-9-]+)$/i);
|
||||||
|
if (folderMatch) {
|
||||||
|
const folderId = folderMatch[1];
|
||||||
|
if (method === 'GET') return handleGetFolder(request, env, userId, folderId);
|
||||||
|
if (method === 'PUT') return handleUpdateFolder(request, env, userId, folderId);
|
||||||
|
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith('/api/auth-requests')) {
|
||||||
|
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/collections' || path.startsWith('/api/collections/')) {
|
||||||
|
if (method === 'GET') {
|
||||||
|
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/organizations' || path.startsWith('/api/organizations/')) {
|
||||||
|
if (method === 'GET') {
|
||||||
|
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/sends') {
|
||||||
|
if (method === 'GET') return handleGetSends(request, env, userId);
|
||||||
|
if (method === 'POST') return handleCreateSend(request, env, userId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/sends/file/v2' && method === 'POST') {
|
||||||
|
return handleCreateFileSendV2(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/sends/delete' && method === 'POST') {
|
||||||
|
return handleBulkDeleteSends(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendMatch = path.match(/^\/api\/sends\/([^/]+)(\/.*)?$/i);
|
||||||
|
if (sendMatch) {
|
||||||
|
const sendId = sendMatch[1];
|
||||||
|
const subPath = sendMatch[2] || '';
|
||||||
|
|
||||||
|
if (subPath === '' || subPath === '/') {
|
||||||
|
if (method === 'GET') return handleGetSend(request, env, userId, sendId);
|
||||||
|
if (method === 'PUT') return handleUpdateSend(request, env, userId, sendId);
|
||||||
|
if (method === 'DELETE') return handleDeleteSend(request, env, userId, sendId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subPath === '/remove-password' && (method === 'PUT' || method === 'POST')) {
|
||||||
|
return handleRemoveSendPassword(request, env, userId, sendId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subPath === '/remove-auth' && (method === 'PUT' || method === 'POST')) {
|
||||||
|
return handleRemoveSendAuth(request, env, userId, sendId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendFileUploadMatch = subPath.match(/^\/file\/([^/]+)\/?$/i);
|
||||||
|
if (sendFileUploadMatch) {
|
||||||
|
const fileId = sendFileUploadMatch[1];
|
||||||
|
if (method === 'GET') return handleGetSendFileUpload(request, env, userId, sendId, fileId);
|
||||||
|
if (method === 'POST' || method === 'PUT') return handleUploadSendFile(request, env, userId, sendId, fileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/policies' || path.startsWith('/api/policies/')) {
|
||||||
|
if (method === 'GET') {
|
||||||
|
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/settings/domains') {
|
||||||
|
if (method === 'GET' || method === 'PUT' || method === 'POST') {
|
||||||
|
return jsonResponse({
|
||||||
|
equivalentDomains: [],
|
||||||
|
globalEquivalentDomains: [],
|
||||||
|
object: 'domains',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authenticatedDeviceResponse = await handleAuthenticatedDeviceRoute(request, env, userId, path, method);
|
||||||
|
if (authenticatedDeviceResponse) return authenticatedDeviceResponse;
|
||||||
|
|
||||||
|
const adminResponse = await handleAdminRoute(request, env, currentUser, path, method);
|
||||||
|
if (adminResponse) return adminResponse;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import type { Env } from './types';
|
||||||
|
import {
|
||||||
|
handleGetAuthorizedDevices,
|
||||||
|
handleGetDevice,
|
||||||
|
handleGetDevices,
|
||||||
|
handleGetDeviceByIdentifier,
|
||||||
|
handleUpdateDeviceKeys,
|
||||||
|
handleUpdateDeviceTrust,
|
||||||
|
handleUntrustDevices,
|
||||||
|
handleRetrieveDeviceKeys,
|
||||||
|
handleDeactivateDevice,
|
||||||
|
handleRevokeAllTrustedDevices,
|
||||||
|
handleRevokeTrustedDevice,
|
||||||
|
handleDeleteAllDevices,
|
||||||
|
handleDeleteDevice,
|
||||||
|
handleUpdateDeviceToken,
|
||||||
|
handleUpdateDeviceWebPushAuth,
|
||||||
|
handleClearDeviceToken,
|
||||||
|
} from './handlers/devices';
|
||||||
|
|
||||||
|
export async function handleAuthenticatedDeviceRoute(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
path: string,
|
||||||
|
method: string
|
||||||
|
): Promise<Response | null> {
|
||||||
|
if (path === '/api/devices') {
|
||||||
|
if (method === 'GET') return handleGetDevices(request, env, userId);
|
||||||
|
if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/devices/authorized') {
|
||||||
|
if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId);
|
||||||
|
if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)$/i);
|
||||||
|
if (authorizedDeviceMatch && method === 'DELETE') {
|
||||||
|
const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]);
|
||||||
|
return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i);
|
||||||
|
if (deleteDeviceMatch && method === 'GET') {
|
||||||
|
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
|
||||||
|
return handleGetDevice(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
if (deleteDeviceMatch && method === 'DELETE') {
|
||||||
|
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
|
||||||
|
return handleDeleteDevice(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i);
|
||||||
|
if (identifierMatch && method === 'GET') {
|
||||||
|
const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
|
||||||
|
return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/keys$/i) || path.match(/^\/api\/devices\/identifier\/([^/]+)\/keys$/i);
|
||||||
|
if (deviceKeysMatch && (method === 'PUT' || method === 'POST')) {
|
||||||
|
const deviceIdentifier = decodeURIComponent(deviceKeysMatch[1]);
|
||||||
|
return handleUpdateDeviceKeys(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifierTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/token$/i);
|
||||||
|
if (identifierTokenMatch && (method === 'PUT' || method === 'POST')) {
|
||||||
|
const deviceIdentifier = decodeURIComponent(identifierTokenMatch[1]);
|
||||||
|
return handleUpdateDeviceToken(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifierWebPushMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/web-push-auth$/i);
|
||||||
|
if (identifierWebPushMatch && (method === 'PUT' || method === 'POST')) {
|
||||||
|
const deviceIdentifier = decodeURIComponent(identifierWebPushMatch[1]);
|
||||||
|
return handleUpdateDeviceWebPushAuth(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifierClearTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/clear-token$/i);
|
||||||
|
if (identifierClearTokenMatch && (method === 'PUT' || method === 'POST')) {
|
||||||
|
const deviceIdentifier = decodeURIComponent(identifierClearTokenMatch[1]);
|
||||||
|
return handleClearDeviceToken(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifierRetrieveKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/retrieve-keys$/i);
|
||||||
|
if (identifierRetrieveKeysMatch && method === 'POST') {
|
||||||
|
const deviceIdentifier = decodeURIComponent(identifierRetrieveKeysMatch[1]);
|
||||||
|
return handleRetrieveDeviceKeys(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifierDeactivateMatch = path.match(/^\/api\/devices\/([^/]+)\/deactivate$/i);
|
||||||
|
if (identifierDeactivateMatch && (method === 'POST' || method === 'DELETE')) {
|
||||||
|
const deviceIdentifier = decodeURIComponent(identifierDeactivateMatch[1]);
|
||||||
|
return handleDeactivateDevice(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/devices/update-trust' && method === 'POST') {
|
||||||
|
return handleUpdateDeviceTrust(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/devices/untrust' && method === 'POST') {
|
||||||
|
return handleUntrustDevices(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import { LIMITS } from './config/limits';
|
||||||
|
import { DEFAULT_DEV_SECRET } from './types';
|
||||||
|
import {
|
||||||
|
handleAccessSend,
|
||||||
|
handleAccessSendFile,
|
||||||
|
handleAccessSendV2,
|
||||||
|
handleAccessSendFileV2,
|
||||||
|
handleDownloadSendFile,
|
||||||
|
} from './handlers/sends';
|
||||||
|
import { handleKnownDevice } from './handlers/devices';
|
||||||
|
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
|
||||||
|
import {
|
||||||
|
handleRegister,
|
||||||
|
handleGetPasswordHint,
|
||||||
|
handleRecoverTwoFactor,
|
||||||
|
} from './handlers/accounts';
|
||||||
|
import { handlePublicDownloadAttachment } from './handlers/attachments';
|
||||||
|
import { handlePublicUploadAttachment } from './handlers/attachments';
|
||||||
|
import {
|
||||||
|
handleNotificationsHub,
|
||||||
|
handleNotificationsNegotiate,
|
||||||
|
} from './handlers/notifications';
|
||||||
|
import { handlePublicUploadSendFile } from './handlers/sends';
|
||||||
|
import { jsonResponse } from './utils/response';
|
||||||
|
import type { Env } from './types';
|
||||||
|
|
||||||
|
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
|
||||||
|
type JwtUnsafeReason = 'missing' | 'default' | 'too_short' | null;
|
||||||
|
|
||||||
|
export interface WebBootstrapResponse {
|
||||||
|
defaultKdfIterations: number;
|
||||||
|
jwtUnsafeReason: JwtUnsafeReason;
|
||||||
|
jwtSecretMinLength: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameOriginWriteRequest(request: Request): boolean {
|
||||||
|
const targetOrigin = new URL(request.url).origin;
|
||||||
|
const origin = request.headers.get('Origin');
|
||||||
|
if (origin) {
|
||||||
|
return origin === targetOrigin;
|
||||||
|
}
|
||||||
|
|
||||||
|
const referer = request.headers.get('Referer');
|
||||||
|
if (referer) {
|
||||||
|
try {
|
||||||
|
return new URL(referer).origin === targetOrigin;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNwIconSvg(): 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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNwFavicon(): Response {
|
||||||
|
return new Response(getNwIconSvg(), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'image/svg+xml; charset=utf-8',
|
||||||
|
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIconServiceBase(origin: string): string {
|
||||||
|
return `${origin}/icons`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIconServiceTemplate(origin: string): string {
|
||||||
|
return `${buildIconServiceBase(origin)}/{}/icon.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIconServiceCsp(origin: string): string {
|
||||||
|
return `img-src 'self' data: ${origin}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConfigResponse(origin: string) {
|
||||||
|
return {
|
||||||
|
version: LIMITS.compatibility.bitwardenServerVersion,
|
||||||
|
gitHash: 'nodewarden',
|
||||||
|
server: null,
|
||||||
|
environment: {
|
||||||
|
cloudRegion: 'self-hosted',
|
||||||
|
vault: origin,
|
||||||
|
api: origin + '/api',
|
||||||
|
identity: origin + '/identity',
|
||||||
|
notifications: origin + '/notifications',
|
||||||
|
icons: origin,
|
||||||
|
sso: '',
|
||||||
|
fillAssistRules: null,
|
||||||
|
},
|
||||||
|
push: {
|
||||||
|
pushTechnology: 0,
|
||||||
|
vapidPublicKey: null,
|
||||||
|
},
|
||||||
|
communication: null,
|
||||||
|
settings: {
|
||||||
|
disableUserRegistration: false,
|
||||||
|
},
|
||||||
|
_icon_service_url: buildIconServiceTemplate(origin),
|
||||||
|
_icon_service_csp: buildIconServiceCsp(origin),
|
||||||
|
featureStates: {
|
||||||
|
'duo-redirect': true,
|
||||||
|
'email-verification': true,
|
||||||
|
'pm-19051-send-email-verification': false,
|
||||||
|
'pm-19148-innovation-archive': true,
|
||||||
|
'unauth-ui-refresh': true,
|
||||||
|
'web-push': false,
|
||||||
|
},
|
||||||
|
object: 'config',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIconHost(rawHost: string): string | null {
|
||||||
|
const decoded = decodeURIComponent(String(rawHost || '').trim()).toLowerCase().replace(/\.+$/, '');
|
||||||
|
if (!decoded || decoded.includes('/') || decoded.includes('\\')) return null;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(`https://${decoded}`);
|
||||||
|
return parsed.hostname === decoded ? decoded : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleWebsiteIcon(host: string): Promise<Response> {
|
||||||
|
const normalizedHost = normalizeIconHost(host);
|
||||||
|
if (!normalizedHost) return handleNwFavicon();
|
||||||
|
|
||||||
|
const encodedHost = encodeURIComponent(normalizedHost);
|
||||||
|
const requestHeaders = { 'User-Agent': 'NodeWarden/1.0' };
|
||||||
|
const upstreamSources: Array<{ url: string; headers?: HeadersInit }> = [
|
||||||
|
{
|
||||||
|
url: `https://icons.bitwarden.net/${encodedHost}/icon.png`,
|
||||||
|
headers: requestHeaders,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `https://favicon.im/${encodedHost}`,
|
||||||
|
headers: requestHeaders,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `https://icons.duckduckgo.com/ip3/${encodedHost}.ico`,
|
||||||
|
headers: requestHeaders,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const source of upstreamSources) {
|
||||||
|
const resp = await fetch(source.url, {
|
||||||
|
headers: source.headers,
|
||||||
|
redirect: 'follow',
|
||||||
|
cf: {
|
||||||
|
cacheEverything: true,
|
||||||
|
cacheTtl: LIMITS.cache.iconTtlSeconds,
|
||||||
|
},
|
||||||
|
} as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } });
|
||||||
|
|
||||||
|
if (!resp.ok) continue;
|
||||||
|
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
|
||||||
|
if (!contentType.startsWith('image/')) continue;
|
||||||
|
|
||||||
|
return new Response(resp.body, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
|
||||||
|
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return handleNwFavicon();
|
||||||
|
} catch {
|
||||||
|
return handleNwFavicon();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWebBootstrapResponse(env: Env): WebBootstrapResponse {
|
||||||
|
const secret = (env.JWT_SECRET || '').trim();
|
||||||
|
const jwtUnsafeReason =
|
||||||
|
!secret
|
||||||
|
? 'missing'
|
||||||
|
: secret === DEFAULT_DEV_SECRET
|
||||||
|
? 'default'
|
||||||
|
: secret.length < LIMITS.auth.jwtSecretMinLength
|
||||||
|
? 'too_short'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
|
||||||
|
jwtUnsafeReason,
|
||||||
|
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handlePublicRoute(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
path: string,
|
||||||
|
method: string,
|
||||||
|
enforcePublicRateLimit: PublicRateLimiter
|
||||||
|
): Promise<Response | null> {
|
||||||
|
if (path === '/.well-known/appspecific/com.chrome.devtools.json' && method === 'GET') {
|
||||||
|
return new Response('{}', {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((path === '/api/web-bootstrap' || path === '/web-bootstrap') && method === 'GET') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return jsonResponse(buildWebBootstrapResponse(env));
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
||||||
|
if (iconMatch && method === 'GET') {
|
||||||
|
return handleWebsiteIcon(iconMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
||||||
|
if (publicAttachmentMatch && method === 'GET') {
|
||||||
|
return handlePublicDownloadAttachment(request, env, publicAttachmentMatch[1], publicAttachmentMatch[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicAttachmentUploadMatch = path.match(/^\/api\/ciphers\/([a-f0-9-]+)\/attachment\/([a-f0-9-]+)$/i);
|
||||||
|
if (publicAttachmentUploadMatch && (method === 'POST' || method === 'PUT') && new URL(request.url).searchParams.has('token')) {
|
||||||
|
return handlePublicUploadAttachment(request, env, publicAttachmentUploadMatch[1], publicAttachmentUploadMatch[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicSendUploadMatch = path.match(/^\/api\/sends\/([^/]+)\/file\/([^/]+)\/?$/i);
|
||||||
|
if (publicSendUploadMatch && (method === 'POST' || method === 'PUT') && new URL(request.url).searchParams.has('token')) {
|
||||||
|
return handlePublicUploadSendFile(request, env, publicSendUploadMatch[1], publicSendUploadMatch[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendAccessMatch = path.match(/^\/api\/sends\/access\/([^/]+)$/i);
|
||||||
|
if (sendAccessMatch && method === 'POST') {
|
||||||
|
const blocked = await enforcePublicRateLimit();
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handleAccessSend(request, env, sendAccessMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/sends/access' && method === 'POST') {
|
||||||
|
const blocked = await enforcePublicRateLimit();
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handleAccessSendV2(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendAccessFileV2Match = path.match(/^\/api\/sends\/access\/file\/([^/]+)\/?$/i);
|
||||||
|
if (sendAccessFileV2Match && method === 'POST') {
|
||||||
|
const blocked = await enforcePublicRateLimit();
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handleAccessSendFileV2(request, env, sendAccessFileV2Match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendAccessFileMatch = path.match(/^\/api\/sends\/([^/]+)\/access\/file\/([^/]+)\/?$/i);
|
||||||
|
if (sendAccessFileMatch && method === 'POST') {
|
||||||
|
const blocked = await enforcePublicRateLimit();
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handleAccessSendFile(request, env, sendAccessFileMatch[1], sendAccessFileMatch[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendDownloadMatch = path.match(/^\/api\/sends\/([^/]+)\/([^/]+)\/?$/i);
|
||||||
|
if (sendDownloadMatch && method === 'GET') {
|
||||||
|
return handleDownloadSendFile(request, env, sendDownloadMatch[1], sendDownloadMatch[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/identity/connect/token' && method === 'POST') {
|
||||||
|
return handleToken(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/devices/knowndevice' && method === 'GET') {
|
||||||
|
const blocked = await enforcePublicRateLimit();
|
||||||
|
if (blocked) return jsonResponse(false);
|
||||||
|
return handleKnownDevice(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearDeviceTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/clear-token$/i);
|
||||||
|
if (clearDeviceTokenMatch && (method === 'PUT' || method === 'POST')) {
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((path === '/identity/connect/revocation' || path === '/identity/connect/revoke') && method === 'POST') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handleRevocation(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/identity/accounts/prelogin' && method === 'POST') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handlePrelogin(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/identity/accounts/prelogin/password' && method === 'POST') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handlePrelogin(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
|
||||||
|
return handleRecoverTwoFactor(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/accounts/password-hint' && method === 'POST') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
if (!isSameOriginWriteRequest(request)) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Forbidden origin' }), {
|
||||||
|
status: 403,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return handleGetPasswordHint(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((path === '/config' || path === '/api/config') && method === 'GET') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
const origin = new URL(request.url).origin;
|
||||||
|
return jsonResponse(buildConfigResponse(origin));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/version' && method === 'GET') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return jsonResponse(LIMITS.compatibility.bitwardenServerVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/accounts/register' && method === 'POST') {
|
||||||
|
const blocked = await enforcePublicRateLimit('register', LIMITS.rateLimit.registerRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
if (!isSameOriginWriteRequest(request)) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Forbidden origin' }), {
|
||||||
|
status: 403,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return handleRegister(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/notifications/hub/negotiate' && method === 'POST') {
|
||||||
|
return handleNotificationsNegotiate(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/notifications/hub' && method === 'GET') {
|
||||||
|
return handleNotificationsHub(request, env);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,424 +1,143 @@
|
|||||||
import { Env } from './types';
|
import { DEFAULT_DEV_SECRET, Env } from './types';
|
||||||
import { AuthService } from './services/auth';
|
import { AuthService } from './services/auth';
|
||||||
import { RateLimitService, getClientIdentifier } from './services/ratelimit';
|
import { RateLimitService, getClientIdentifier } from './services/ratelimit';
|
||||||
import { handleCors, errorResponse, jsonResponse } from './utils/response';
|
import { handleCors, errorResponse } from './utils/response';
|
||||||
|
import { LIMITS } from './config/limits';
|
||||||
|
import { handleAuthenticatedRoute } from './router-authenticated';
|
||||||
|
import { handlePublicRoute } from './router-public';
|
||||||
|
|
||||||
// Identity handlers
|
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
|
||||||
import { handleToken, handlePrelogin } from './handlers/identity';
|
const secret = (env.JWT_SECRET || '').trim();
|
||||||
|
if (!secret) return 'missing';
|
||||||
// Account handlers
|
if (secret === DEFAULT_DEV_SECRET) return 'default';
|
||||||
import { handleRegister, handleGetProfile, handleUpdateProfile, handleSetKeys, handleGetRevisionDate, handleVerifyPassword } from './handlers/accounts';
|
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
|
||||||
|
return null;
|
||||||
// Cipher handlers
|
|
||||||
import {
|
|
||||||
handleGetCiphers,
|
|
||||||
handleGetCipher,
|
|
||||||
handleCreateCipher,
|
|
||||||
handleUpdateCipher,
|
|
||||||
handleDeleteCipher,
|
|
||||||
handlePermanentDeleteCipher,
|
|
||||||
handleRestoreCipher,
|
|
||||||
handlePartialUpdateCipher,
|
|
||||||
handleBulkMoveCiphers,
|
|
||||||
} from './handlers/ciphers';
|
|
||||||
|
|
||||||
// Folder handlers
|
|
||||||
import {
|
|
||||||
handleGetFolders,
|
|
||||||
handleGetFolder,
|
|
||||||
handleCreateFolder,
|
|
||||||
handleUpdateFolder,
|
|
||||||
handleDeleteFolder
|
|
||||||
} from './handlers/folders';
|
|
||||||
|
|
||||||
// Sync handler
|
|
||||||
import { handleSync } from './handlers/sync';
|
|
||||||
|
|
||||||
// Setup handlers
|
|
||||||
import { handleSetupPage, handleSetupStatus, handleDisableSetup } from './handlers/setup';
|
|
||||||
|
|
||||||
// Import handler
|
|
||||||
import { handleCiphersImport } from './handlers/import';
|
|
||||||
|
|
||||||
// Attachment handlers
|
|
||||||
import {
|
|
||||||
handleCreateAttachment,
|
|
||||||
handleUploadAttachment,
|
|
||||||
handleGetAttachment,
|
|
||||||
handleDeleteAttachment,
|
|
||||||
handlePublicDownloadAttachment,
|
|
||||||
} from './handlers/attachments';
|
|
||||||
|
|
||||||
// Icons handler - proxy to Bitwarden's official icon service
|
|
||||||
async function handleGetIcon(request: Request, env: Env, hostname: string): Promise<Response> {
|
|
||||||
try {
|
|
||||||
// Use Bitwarden's official icon service
|
|
||||||
const iconUrl = `https://icons.bitwarden.net/${hostname}/icon.png`;
|
|
||||||
const resp = await fetch(iconUrl, {
|
|
||||||
headers: { 'User-Agent': 'NodeWarden/1.0' },
|
|
||||||
redirect: 'follow',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (resp.ok) {
|
|
||||||
const body = await resp.arrayBuffer();
|
|
||||||
return new Response(body, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
|
|
||||||
'Cache-Control': 'public, max-age=604800', // 7 days
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
function isImportBypassRequest(request: Request, path: string, method: string): boolean {
|
||||||
} catch {
|
if (request.headers.get('X-NodeWarden-Import') !== '1') return false;
|
||||||
return new Response(null, { status: 204 });
|
|
||||||
|
if (method === 'POST') {
|
||||||
|
if (path === '/api/ciphers/import') return true;
|
||||||
|
if (/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/v2$/i.test(path)) return true;
|
||||||
|
if (/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path)) return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleRequest(request: Request, env: Env): Promise<Response> {
|
export async function handleRequest(request: Request, env: Env): Promise<Response> {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const path = url.pathname;
|
const path = url.pathname;
|
||||||
const method = request.method;
|
const method = request.method;
|
||||||
|
|
||||||
// Handle CORS preflight
|
|
||||||
if (method === 'OPTIONS') {
|
|
||||||
return handleCors();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Route matching
|
|
||||||
try {
|
|
||||||
|
|
||||||
// Setup page (root)
|
|
||||||
if (path === '/' && method === 'GET') {
|
|
||||||
return handleSetupPage(request, env);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup status
|
|
||||||
if (path === '/setup/status' && method === 'GET') {
|
|
||||||
return handleSetupStatus(request, env);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disable setup page (one-way)
|
|
||||||
if (path === '/setup/disable' && method === 'POST') {
|
|
||||||
return handleDisableSetup(request, env);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Favicon - return empty
|
|
||||||
if (path === '/favicon.ico') {
|
|
||||||
return new Response(null, { status: 204 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Icon endpoint - proxy to Bitwarden's icon service (no auth required)
|
|
||||||
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
|
||||||
if (iconMatch) {
|
|
||||||
const hostname = iconMatch[1];
|
|
||||||
return handleGetIcon(request, env, hostname);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public attachment download (no auth header, uses token in query string)
|
|
||||||
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
|
||||||
if (publicAttachmentMatch && method === 'GET') {
|
|
||||||
const cipherId = publicAttachmentMatch[1];
|
|
||||||
const attachmentId = publicAttachmentMatch[2];
|
|
||||||
return handlePublicDownloadAttachment(request, env, cipherId, attachmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notifications hub (stub - no auth required, return 200 for connection)
|
|
||||||
if (path.startsWith('/notifications/')) {
|
|
||||||
return new Response(null, { status: 200 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Known device check (no auth required) - returns plain string "true" or "false"
|
|
||||||
if (path.startsWith('/api/devices/knowndevice')) {
|
|
||||||
return new Response('true', {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'text/plain',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Identity endpoints (no auth required)
|
|
||||||
if (path === '/identity/connect/token' && method === 'POST') {
|
|
||||||
return handleToken(request, env);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/identity/accounts/prelogin' && method === 'POST') {
|
|
||||||
return handlePrelogin(request, env);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Config endpoint (no auth required for basic config)
|
|
||||||
// Bitwarden clients call GET "/config" (relative to the API base URL).
|
|
||||||
// They also tolerate different casing, but their response models use PascalCase.
|
|
||||||
const isConfigRequest = (path === '/config' || path === '/api/config') && method === 'GET';
|
|
||||||
if (isConfigRequest) {
|
|
||||||
const origin = url.origin;
|
|
||||||
return jsonResponse({
|
|
||||||
version: '2025.12.0',
|
|
||||||
gitHash: 'nodewarden',
|
|
||||||
server: null,
|
|
||||||
environment: {
|
|
||||||
vault: origin,
|
|
||||||
api: origin + '/api',
|
|
||||||
identity: origin + '/identity',
|
|
||||||
notifications: origin + '/notifications',
|
|
||||||
sso: '',
|
|
||||||
},
|
|
||||||
featureStates: {
|
|
||||||
'duo-redirect': true,
|
|
||||||
},
|
|
||||||
object: 'config',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Version endpoint (some clients probe this to validate the server)
|
|
||||||
if (path === '/api/version' && method === 'GET') {
|
|
||||||
return jsonResponse('2025.12.0');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Registration endpoint (no auth required, but only works once)
|
|
||||||
if (path === '/api/accounts/register' && method === 'POST') {
|
|
||||||
return handleRegister(request, env);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If JWT_SECRET is not safely configured, block any other endpoints.
|
|
||||||
const secret = (env.JWT_SECRET || '').trim();
|
|
||||||
if (!secret || secret.length < 32) {
|
|
||||||
return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// All other API endpoints require authentication
|
|
||||||
const auth = new AuthService(env);
|
|
||||||
const authHeader = request.headers.get('Authorization');
|
|
||||||
const payload = await auth.verifyAccessToken(authHeader);
|
|
||||||
|
|
||||||
if (!payload) {
|
|
||||||
return errorResponse('Unauthorized', 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = payload.sub;
|
|
||||||
|
|
||||||
// API rate limiting for authenticated requests
|
|
||||||
const rateLimit = new RateLimitService(env.DB);
|
|
||||||
const clientId = getClientIdentifier(request);
|
const clientId = getClientIdentifier(request);
|
||||||
const rateLimitCheck = await rateLimit.checkApiRateLimit(userId + ':' + clientId);
|
|
||||||
|
|
||||||
if (!rateLimitCheck.allowed) {
|
async function enforcePublicRateLimit(
|
||||||
return new Response(JSON.stringify({
|
category: string = 'public',
|
||||||
|
maxRequests: number = LIMITS.rateLimit.publicRequestsPerMinute
|
||||||
|
): Promise<Response | null> {
|
||||||
|
if (!clientId) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Forbidden',
|
||||||
|
error_description: 'Client IP is required',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateLimit = new RateLimitService(env.DB);
|
||||||
|
const check = await rateLimit.consumeBudget(`${clientId}:${category}`, maxRequests);
|
||||||
|
if (check.allowed) return null;
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
error: 'Too many requests',
|
error: 'Too many requests',
|
||||||
error_description: `Rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`,
|
error_description: `Rate limit exceeded. Try again in ${check.retryAfterSeconds} seconds.`,
|
||||||
}), {
|
}),
|
||||||
|
{
|
||||||
status: 429,
|
status: 429,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Retry-After': rateLimitCheck.retryAfterSeconds!.toString(),
|
'Retry-After': String(check.retryAfterSeconds || 60),
|
||||||
'X-RateLimit-Remaining': '0',
|
'X-RateLimit-Remaining': '0',
|
||||||
},
|
},
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment rate limit counter
|
if (method === 'OPTIONS') {
|
||||||
await rateLimit.incrementApiCount(userId + ':' + clientId);
|
return handleCors(request);
|
||||||
|
}
|
||||||
|
|
||||||
// Block account operations that could change password or delete user
|
try {
|
||||||
if (method === 'POST' || method === 'PUT' || method === 'DELETE') {
|
const isLargeUploadPath =
|
||||||
const blockedAccountPaths = new Set([
|
/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path) ||
|
||||||
'/api/accounts/password',
|
/^\/api\/sends\/[a-f0-9-]+\/file\/[a-f0-9-]+$/i.test(path) ||
|
||||||
'/api/accounts/change-password',
|
path === '/api/admin/backup/import';
|
||||||
'/api/accounts/set-password',
|
if (!isLargeUploadPath) {
|
||||||
'/api/accounts/master-password',
|
const contentLength = parseInt(request.headers.get('Content-Length') || '0', 10);
|
||||||
'/api/accounts/delete',
|
if (contentLength > LIMITS.request.maxBodyBytes) {
|
||||||
'/api/accounts/delete-account',
|
return errorResponse('Request body too large', 413);
|
||||||
'/api/accounts/delete-vault',
|
|
||||||
]);
|
|
||||||
if (blockedAccountPaths.has(path)) {
|
|
||||||
return errorResponse('This operation is disabled', 403);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Account endpoints
|
const publicResponse = await handlePublicRoute(request, env, path, method, enforcePublicRateLimit);
|
||||||
if (path === '/api/accounts/profile') {
|
if (publicResponse) return publicResponse;
|
||||||
if (method === 'GET') return handleGetProfile(request, env, userId);
|
|
||||||
if (method === 'PUT') return handleUpdateProfile(request, env, userId);
|
const secretIssue = jwtSecretUnsafeReason(env);
|
||||||
|
if (secretIssue) {
|
||||||
|
return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/accounts/keys' && method === 'POST') {
|
const auth = new AuthService(env);
|
||||||
return handleSetKeys(request, env, userId);
|
const authHeader = request.headers.get('Authorization');
|
||||||
|
const verified = await auth.verifyAccessTokenWithUser(authHeader);
|
||||||
|
if (!verified) {
|
||||||
|
return errorResponse('Unauthorized', 401);
|
||||||
|
}
|
||||||
|
const { payload, user: currentUser } = verified;
|
||||||
|
|
||||||
|
const actingDeviceId = String(payload.did || '').trim();
|
||||||
|
if (actingDeviceId) {
|
||||||
|
const nextHeaders = new Headers(request.headers);
|
||||||
|
nextHeaders.set('X-NodeWarden-Acting-Device-Id', actingDeviceId);
|
||||||
|
request = new Request(request, { headers: nextHeaders });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Revision date endpoint
|
const userId = payload.sub;
|
||||||
if (path === '/api/accounts/revision-date' && method === 'GET') {
|
if (currentUser.status !== 'active') {
|
||||||
return handleGetRevisionDate(request, env, userId);
|
return errorResponse('Account is disabled', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify password endpoint
|
if (!isImportBypassRequest(request, path, method)) {
|
||||||
if (path === '/api/accounts/verify-password' && method === 'POST') {
|
const rateLimit = new RateLimitService(env.DB);
|
||||||
return handleVerifyPassword(request, env, userId);
|
const rateLimitCheck = await rateLimit.consumeBudget(`${userId}:api`, LIMITS.rateLimit.apiRequestsPerMinute);
|
||||||
|
if (!rateLimitCheck.allowed) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Too many requests',
|
||||||
|
error_description: `Rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Retry-After': String(rateLimitCheck.retryAfterSeconds || 60),
|
||||||
|
'X-RateLimit-Remaining': '0',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
);
|
||||||
// Sync endpoint
|
|
||||||
if (path === '/api/sync' && method === 'GET') {
|
|
||||||
return handleSync(request, env, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cipher endpoints
|
|
||||||
if (path === '/api/ciphers' || path === '/api/ciphers/create') {
|
|
||||||
if (method === 'GET') return handleGetCiphers(request, env, userId);
|
|
||||||
if (method === 'POST') return handleCreateCipher(request, env, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ciphers import endpoint (Bitwarden client format)
|
|
||||||
if (path === '/api/ciphers/import' && method === 'POST') {
|
|
||||||
return handleCiphersImport(request, env, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bulk cipher operations (only move is allowed)
|
|
||||||
if (path === '/api/ciphers/move') {
|
|
||||||
if (method === 'POST' || method === 'PUT') {
|
|
||||||
return handleBulkMoveCiphers(request, env, userId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match /api/ciphers/:id patterns
|
const authenticatedResponse = await handleAuthenticatedRoute(request, env, userId, currentUser, path, method);
|
||||||
const cipherMatch = path.match(/^\/api\/ciphers\/([a-f0-9-]+)(\/.*)?$/i);
|
if (authenticatedResponse) return authenticatedResponse;
|
||||||
if (cipherMatch) {
|
|
||||||
const cipherId = cipherMatch[1];
|
|
||||||
const subPath = cipherMatch[2] || '';
|
|
||||||
|
|
||||||
if (subPath === '' || subPath === '/') {
|
|
||||||
if (method === 'GET') return handleGetCipher(request, env, userId, cipherId);
|
|
||||||
if (method === 'PUT' || method === 'POST') return handleUpdateCipher(request, env, userId, cipherId);
|
|
||||||
if (method === 'DELETE') 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 === '/restore' && method === 'PUT') {
|
|
||||||
return handleRestoreCipher(request, env, userId, cipherId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subPath === '/partial' && (method === 'PUT' || method === 'POST')) {
|
|
||||||
return handlePartialUpdateCipher(request, env, userId, cipherId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Share endpoint - just return the cipher (single user mode)
|
|
||||||
if (subPath === '/share' && method === 'POST') {
|
|
||||||
return handleGetCipher(request, env, userId, cipherId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subPath === '/details' && method === 'GET') {
|
|
||||||
return handleGetCipher(request, env, userId, cipherId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attachment endpoints
|
|
||||||
// POST /api/ciphers/{id}/attachment/v2 - Create attachment metadata
|
|
||||||
if (subPath === '/attachment/v2' && method === 'POST') {
|
|
||||||
return handleCreateAttachment(request, env, userId, cipherId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy attachment endpoint - also goes to v2 flow
|
|
||||||
if (subPath === '/attachment' && method === 'POST') {
|
|
||||||
return handleCreateAttachment(request, env, userId, cipherId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match /api/ciphers/{id}/attachment/{attachmentId}
|
|
||||||
const attachmentMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)$/i);
|
|
||||||
if (attachmentMatch) {
|
|
||||||
const attachmentId = attachmentMatch[1];
|
|
||||||
if (method === 'POST') return handleUploadAttachment(request, env, userId, cipherId, attachmentId);
|
|
||||||
if (method === 'GET') return handleGetAttachment(request, env, userId, cipherId, attachmentId);
|
|
||||||
if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE via POST (legacy)
|
|
||||||
const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i);
|
|
||||||
if (attachmentDeleteMatch && method === 'POST') {
|
|
||||||
const attachmentId = attachmentDeleteMatch[1];
|
|
||||||
return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Folder endpoints
|
|
||||||
if (path === '/api/folders') {
|
|
||||||
if (method === 'GET') return handleGetFolders(request, env, userId);
|
|
||||||
if (method === 'POST') return handleCreateFolder(request, env, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match /api/folders/:id patterns
|
|
||||||
const folderMatch = path.match(/^\/api\/folders\/([a-f0-9-]+)$/i);
|
|
||||||
if (folderMatch) {
|
|
||||||
const folderId = folderMatch[1];
|
|
||||||
if (method === 'GET') return handleGetFolder(request, env, userId, folderId);
|
|
||||||
if (method === 'PUT') return handleUpdateFolder(request, env, userId, folderId);
|
|
||||||
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auth requests endpoint (stub - we don't support passwordless login)
|
|
||||||
if (path.startsWith('/api/auth-requests')) {
|
|
||||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collections endpoint (stub - no organization support)
|
|
||||||
if (path === '/api/collections' || path.startsWith('/api/collections/')) {
|
|
||||||
if (method === 'GET') {
|
|
||||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Organizations endpoint (stub - no organization support)
|
|
||||||
if (path === '/api/organizations' || path.startsWith('/api/organizations/')) {
|
|
||||||
if (method === 'GET') {
|
|
||||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sends endpoint (stub - not implemented)
|
|
||||||
if (path === '/api/sends' || path.startsWith('/api/sends/')) {
|
|
||||||
if (method === 'GET') {
|
|
||||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Policies endpoint (stub - not implemented)
|
|
||||||
if (path === '/api/policies' || path.startsWith('/api/policies/')) {
|
|
||||||
if (method === 'GET') {
|
|
||||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings domains endpoint (stub)
|
|
||||||
if (path === '/api/settings/domains') {
|
|
||||||
if (method === 'GET') {
|
|
||||||
return jsonResponse({
|
|
||||||
equivalentDomains: [],
|
|
||||||
globalEquivalentDomains: [],
|
|
||||||
object: 'domains',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (method === 'PUT' || method === 'POST') {
|
|
||||||
return jsonResponse({
|
|
||||||
equivalentDomains: [],
|
|
||||||
globalEquivalentDomains: [],
|
|
||||||
object: 'domains',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Devices endpoint (stub) - for authenticated requests
|
|
||||||
if (path === '/api/devices' && method === 'GET') {
|
|
||||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not found
|
|
||||||
return errorResponse('Not found', 404);
|
return errorResponse('Not found', 404);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Request error:', error);
|
console.error('Request error:', error);
|
||||||
return errorResponse('Internal server error', 500);
|
return errorResponse('Internal server error', 500);
|
||||||
|
|||||||
@@ -2,6 +2,16 @@ import { Env, JWTPayload, User } from '../types';
|
|||||||
import { verifyJWT, createJWT, createRefreshToken } from '../utils/jwt';
|
import { verifyJWT, createJWT, createRefreshToken } from '../utils/jwt';
|
||||||
import { StorageService } from './storage';
|
import { StorageService } from './storage';
|
||||||
|
|
||||||
|
// Server-side iterations for second-layer hashing.
|
||||||
|
// The client already does heavy PBKDF2 (600k iterations).
|
||||||
|
// This second layer only needs to be non-trivial, not expensive.
|
||||||
|
const SERVER_HASH_ITERATIONS = 100_000;
|
||||||
|
|
||||||
|
export interface VerifiedAccessContext {
|
||||||
|
payload: JWTPayload;
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private storage: StorageService;
|
private storage: StorageService;
|
||||||
|
|
||||||
@@ -9,35 +19,74 @@ export class AuthService {
|
|||||||
this.storage = new StorageService(env.DB);
|
this.storage = new StorageService(env.DB);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify password hash (compare with stored hash)
|
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
|
||||||
async verifyPassword(inputHash: string, storedHash: string): Promise<boolean> {
|
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
|
||||||
// In Bitwarden, the client sends the password hash directly
|
// Result is prefixed with "$s$" to distinguish from legacy raw client hashes.
|
||||||
// We compare the hashes
|
async hashPasswordServer(clientHash: string, email: string): Promise<string> {
|
||||||
return inputHash === storedHash;
|
const keyMaterial = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
new TextEncoder().encode(clientHash),
|
||||||
|
'PBKDF2',
|
||||||
|
false,
|
||||||
|
['deriveBits']
|
||||||
|
);
|
||||||
|
const salt = new TextEncoder().encode(email.toLowerCase().trim());
|
||||||
|
const bits = await crypto.subtle.deriveBits(
|
||||||
|
{ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: SERVER_HASH_ITERATIONS },
|
||||||
|
keyMaterial,
|
||||||
|
256
|
||||||
|
);
|
||||||
|
const bytes = new Uint8Array(bits);
|
||||||
|
let binary = '';
|
||||||
|
for (const b of bytes) binary += String.fromCharCode(b);
|
||||||
|
return '$s$' + btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password: hash the input the same way, then constant-time compare.
|
||||||
|
async verifyPassword(inputHash: string, storedHash: string, email?: string): Promise<boolean> {
|
||||||
|
// New server-hashed passwords are prefixed with "$s$".
|
||||||
|
// Legacy accounts (created before the upgrade) store raw client hashes without prefix.
|
||||||
|
if (email && storedHash.startsWith('$s$')) {
|
||||||
|
const serverHash = await this.hashPasswordServer(inputHash, email);
|
||||||
|
return this.constantTimeEquals(serverHash, storedHash);
|
||||||
|
}
|
||||||
|
// Legacy path: direct constant-time comparison of raw client hashes.
|
||||||
|
return this.constantTimeEquals(inputHash, storedHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
private 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate access token
|
// Generate access token
|
||||||
async generateAccessToken(user: User): Promise<string> {
|
async generateAccessToken(user: User, device?: { identifier: string; sessionStamp: string } | null): Promise<string> {
|
||||||
return createJWT(
|
return createJWT(
|
||||||
{
|
{
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
sstamp: user.securityStamp,
|
sstamp: user.securityStamp,
|
||||||
|
...(device?.identifier ? { did: device.identifier, dstamp: device.sessionStamp } : {}),
|
||||||
},
|
},
|
||||||
this.env.JWT_SECRET
|
this.env.JWT_SECRET
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate refresh token
|
// Generate refresh token
|
||||||
async generateRefreshToken(userId: string): Promise<string> {
|
async generateRefreshToken(userId: string, device?: { identifier: string; sessionStamp: string } | null): Promise<string> {
|
||||||
const token = createRefreshToken();
|
const token = createRefreshToken();
|
||||||
await this.storage.saveRefreshToken(token, userId);
|
await this.storage.saveRefreshToken(token, userId, undefined, device?.identifier ?? null, device?.sessionStamp ?? null);
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify access token from Authorization header
|
async verifyAccessTokenWithUser(authHeader: string | null): Promise<VerifiedAccessContext | null> {
|
||||||
async verifyAccessToken(authHeader: string | null): Promise<JWTPayload | null> {
|
|
||||||
if (!authHeader) return null;
|
if (!authHeader) return null;
|
||||||
|
|
||||||
const parts = authHeader.split(' ');
|
const parts = authHeader.split(' ');
|
||||||
@@ -48,26 +97,57 @@ 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;
|
||||||
|
|
||||||
// Verify security stamp - ensures token is invalidated after password change
|
|
||||||
const user = await this.storage.getUserById(payload.sub);
|
const user = await this.storage.getUserById(payload.sub);
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
if (payload.sstamp !== user.securityStamp) {
|
if (payload.sstamp !== user.securityStamp) {
|
||||||
return null; // Token was issued before password change
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload;
|
if (payload.did) {
|
||||||
|
const device = await this.storage.getDevice(user.id, payload.did);
|
||||||
|
if (!device) return null;
|
||||||
|
if (!payload.dstamp || payload.dstamp !== device.sessionStamp) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { payload, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify access token from Authorization header
|
||||||
|
async verifyAccessToken(authHeader: string | null): Promise<JWTPayload | null> {
|
||||||
|
const verified = await this.verifyAccessTokenWithUser(authHeader);
|
||||||
|
return verified?.payload ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh access token
|
// Refresh access token
|
||||||
async refreshAccessToken(refreshToken: string): Promise<{ accessToken: string; user: User } | null> {
|
async refreshAccessToken(
|
||||||
const userId = await this.storage.getRefreshTokenUserId(refreshToken);
|
refreshToken: string
|
||||||
if (!userId) return null;
|
): Promise<{ accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } | null> {
|
||||||
|
const record = await this.storage.getRefreshTokenRecord(refreshToken);
|
||||||
|
if (!record?.userId) return null;
|
||||||
|
|
||||||
const user = await this.storage.getUserById(userId);
|
const user = await this.storage.getUserById(record.userId);
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
if (user.status !== 'active') {
|
||||||
|
await this.storage.deleteRefreshToken(refreshToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const accessToken = await this.generateAccessToken(user);
|
let device: { identifier: string; sessionStamp: string } | null = null;
|
||||||
return { accessToken, user };
|
if (record.deviceIdentifier) {
|
||||||
|
const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier);
|
||||||
|
if (!boundDevice) {
|
||||||
|
await this.storage.deleteRefreshToken(refreshToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!record.deviceSessionStamp || boundDevice.sessionStamp !== record.deviceSessionStamp) {
|
||||||
|
await this.storage.deleteRefreshToken(refreshToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp };
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = await this.generateAccessToken(user, device);
|
||||||
|
return { accessToken, user, device };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,430 @@
|
|||||||
|
import { zipSync, unzipSync } from 'fflate';
|
||||||
|
import type { Env } from '../types';
|
||||||
|
import { APP_VERSION } from '../../shared/app-version';
|
||||||
|
import {
|
||||||
|
getAttachmentObjectKey,
|
||||||
|
getBlobStorageKind,
|
||||||
|
} from './blob-store';
|
||||||
|
|
||||||
|
type SqlRow = Record<string, string | number | null>;
|
||||||
|
|
||||||
|
const BACKUP_FORMAT_VERSION = 1;
|
||||||
|
const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
|
||||||
|
// Worker-side backup export must stay well below Cloudflare CPU limits.
|
||||||
|
// Prefer store-only ZIP entries over heavier compression to keep exports reliable.
|
||||||
|
const BACKUP_TEXT_COMPRESSION_LEVEL = 0;
|
||||||
|
const BACKUP_JSON_INDENT = 2;
|
||||||
|
const MAX_BACKUP_ARCHIVE_BYTES = 64 * 1024 * 1024;
|
||||||
|
const MAX_BACKUP_ARCHIVE_ENTRY_COUNT = 10_000;
|
||||||
|
const MAX_BACKUP_EXTRACTED_BYTES = 64 * 1024 * 1024;
|
||||||
|
const MAX_BACKUP_DB_JSON_BYTES = 32 * 1024 * 1024;
|
||||||
|
|
||||||
|
export interface BackupManifest {
|
||||||
|
formatVersion: 1;
|
||||||
|
exportedAt: string;
|
||||||
|
appVersion: string;
|
||||||
|
storageKind: 'r2' | 'kv' | null;
|
||||||
|
tableCounts: Record<string, number>;
|
||||||
|
includes: {
|
||||||
|
attachments: boolean;
|
||||||
|
};
|
||||||
|
blobSummary: {
|
||||||
|
attachmentFiles: number;
|
||||||
|
totalBytes: number;
|
||||||
|
largestObjectBytes: number;
|
||||||
|
};
|
||||||
|
attachmentBlobs?: BackupManifestAttachmentBlob[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupManifestAttachmentBlob {
|
||||||
|
cipherId: string;
|
||||||
|
attachmentId: string;
|
||||||
|
blobName: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupPayload {
|
||||||
|
manifest: BackupManifest;
|
||||||
|
db: {
|
||||||
|
config: SqlRow[];
|
||||||
|
users: SqlRow[];
|
||||||
|
user_revisions: SqlRow[];
|
||||||
|
folders: SqlRow[];
|
||||||
|
ciphers: SqlRow[];
|
||||||
|
attachments: SqlRow[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupArchiveBundle {
|
||||||
|
bytes: Uint8Array;
|
||||||
|
fileName: string;
|
||||||
|
manifest: BackupManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupFileIntegrityCheckResult {
|
||||||
|
hasChecksumPrefix: boolean;
|
||||||
|
expectedPrefix: string | null;
|
||||||
|
actualPrefix: string;
|
||||||
|
matches: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildBackupArchiveOptions {
|
||||||
|
includeAttachments?: boolean;
|
||||||
|
progress?: BackupArchiveBuildProgressReporter;
|
||||||
|
timeZone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupArchiveBuildProgressEvent {
|
||||||
|
step: string;
|
||||||
|
fileName?: string;
|
||||||
|
stageTitle: string;
|
||||||
|
stageDetail: string;
|
||||||
|
includeAttachments: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BackupArchiveBuildProgressReporter = (event: BackupArchiveBuildProgressEvent) => Promise<void>;
|
||||||
|
|
||||||
|
async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Promise<SqlRow[]> {
|
||||||
|
const result = await db.prepare(sql).bind(...values).all<SqlRow>();
|
||||||
|
return (result.results || []).map((row) => ({ ...row }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||||
|
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateParts(date: Date, timeZone: string): string {
|
||||||
|
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hourCycle: 'h23',
|
||||||
|
});
|
||||||
|
const parts = formatter.formatToParts(date);
|
||||||
|
const pick = (type: string): string => parts.find((part) => part.type === type)?.value || '';
|
||||||
|
return `${pick('year')}${pick('month')}${pick('day')}_${pick('hour')}${pick('minute')}${pick('second')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBackupFileNameInTimeZone(
|
||||||
|
date: Date = new Date(),
|
||||||
|
checksumPrefix: string | null = null,
|
||||||
|
timeZone: string = 'UTC'
|
||||||
|
): string {
|
||||||
|
const parts = getDateParts(date, timeZone);
|
||||||
|
const suffix = checksumPrefix ? `_${checksumPrefix}` : '';
|
||||||
|
return `nodewarden_backup_${parts}${suffix}.zip`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractBackupFileChecksumPrefix(fileName: string): string | null {
|
||||||
|
const normalized = String(fileName || '').trim();
|
||||||
|
const match = normalized.match(/_([0-9a-f]{5})\.zip$/i);
|
||||||
|
return match ? match[1].toLowerCase() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function inspectBackupArchiveFileNameChecksum(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
fileName: string
|
||||||
|
): Promise<BackupFileIntegrityCheckResult> {
|
||||||
|
const expectedPrefix = extractBackupFileChecksumPrefix(fileName);
|
||||||
|
const actualHash = await sha256Hex(bytes);
|
||||||
|
const actualPrefix = actualHash.slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
|
||||||
|
return {
|
||||||
|
hasChecksumPrefix: !!expectedPrefix,
|
||||||
|
expectedPrefix,
|
||||||
|
actualPrefix,
|
||||||
|
matches: !expectedPrefix || actualPrefix === expectedPrefix,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyBackupArchiveFileNameChecksum(bytes: Uint8Array, fileName: string): Promise<boolean> {
|
||||||
|
const result = await inspectBackupArchiveFileNameChecksum(bytes, fileName);
|
||||||
|
return result.matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateArchiveSize(bytes: Uint8Array): void {
|
||||||
|
if (bytes.byteLength > MAX_BACKUP_ARCHIVE_BYTES) {
|
||||||
|
throw new Error(`Backup archive is too large. The current restore limit is ${Math.floor(MAX_BACKUP_ARCHIVE_BYTES / (1024 * 1024))} MiB`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequiredZipEntries(db: BackupPayload['db']): string[] {
|
||||||
|
const entries: string[] = [];
|
||||||
|
for (const row of db.attachments) {
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
if (!cipherId || !attachmentId) continue;
|
||||||
|
entries.push(`attachments/${cipherId}/${attachmentId}.bin`);
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRowArray(value: unknown, table: string): SqlRow[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
throw new Error(`Backup archive table ${table} is invalid`);
|
||||||
|
}
|
||||||
|
return value as SqlRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createZipEntries(files: Record<string, Uint8Array>): Record<string, Uint8Array | [Uint8Array, { level: 0 | 1 | 6 }]> {
|
||||||
|
const entries: Record<string, Uint8Array | [Uint8Array, { level: 0 | 1 | 6 }]> = {};
|
||||||
|
for (const [path, bytes] of Object.entries(files)) {
|
||||||
|
entries[path] = [bytes, { level: BACKUP_TEXT_COMPRESSION_LEVEL }];
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParseBackupArchiveOptions {
|
||||||
|
allowExternalAttachmentBlobs?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBackupArchive(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
options: ParseBackupArchiveOptions = {}
|
||||||
|
): { payload: BackupPayload; files: Record<string, Uint8Array> } {
|
||||||
|
validateArchiveSize(bytes);
|
||||||
|
let zipped: Record<string, Uint8Array>;
|
||||||
|
try {
|
||||||
|
zipped = unzipSync(bytes);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid backup archive');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryNames = Object.keys(zipped);
|
||||||
|
if (entryNames.length > MAX_BACKUP_ARCHIVE_ENTRY_COUNT) {
|
||||||
|
throw new Error('Backup archive contains too many files');
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalExtractedBytes = 0;
|
||||||
|
for (const entry of entryNames) {
|
||||||
|
const entryBytes = zipped[entry];
|
||||||
|
totalExtractedBytes += entryBytes.byteLength;
|
||||||
|
if (entry === 'db.json' && entryBytes.byteLength > MAX_BACKUP_DB_JSON_BYTES) {
|
||||||
|
throw new Error('Backup archive database payload is too large');
|
||||||
|
}
|
||||||
|
if (totalExtractedBytes > MAX_BACKUP_EXTRACTED_BYTES) {
|
||||||
|
throw new Error('Backup archive expands beyond the current restore limit');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestBytes = zipped['manifest.json'];
|
||||||
|
const dbBytes = zipped['db.json'];
|
||||||
|
if (!manifestBytes || !dbBytes) {
|
||||||
|
throw new Error('Backup archive is missing manifest.json or db.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let manifest: BackupManifest;
|
||||||
|
let db: BackupPayload['db'];
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(decoder.decode(manifestBytes)) as BackupManifest;
|
||||||
|
db = JSON.parse(decoder.decode(dbBytes)) as BackupPayload['db'];
|
||||||
|
} catch {
|
||||||
|
throw new Error('Backup archive contains invalid JSON metadata');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest?.formatVersion !== BACKUP_FORMAT_VERSION) {
|
||||||
|
throw new Error('Unsupported backup format version');
|
||||||
|
}
|
||||||
|
if (!db || typeof db !== 'object') {
|
||||||
|
throw new Error('Backup archive database payload is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const externalAttachmentKeys = new Set<string>(
|
||||||
|
options.allowExternalAttachmentBlobs
|
||||||
|
? (manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`)
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
const requiredEntries = getRequiredZipEntries(db).filter((entry) => !externalAttachmentKeys.has(entry));
|
||||||
|
for (const entry of requiredEntries) {
|
||||||
|
if (!zipped[entry]) {
|
||||||
|
throw new Error(`Backup archive is missing required file: ${entry}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
payload: { manifest, db },
|
||||||
|
files: zipped,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidateBackupPayloadOptions {
|
||||||
|
allowExternalAttachmentBlobs?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateBackupPayloadContents(
|
||||||
|
payload: BackupPayload,
|
||||||
|
files: Record<string, Uint8Array>,
|
||||||
|
options: ValidateBackupPayloadOptions = {}
|
||||||
|
): void {
|
||||||
|
const configRows = ensureRowArray(payload.db.config, 'config');
|
||||||
|
const userRows = ensureRowArray(payload.db.users, 'users');
|
||||||
|
const revisionRows = ensureRowArray(payload.db.user_revisions, 'user_revisions');
|
||||||
|
const folderRows = ensureRowArray(payload.db.folders, 'folders');
|
||||||
|
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
|
||||||
|
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
|
||||||
|
const externalAttachmentKeys = new Set<string>(
|
||||||
|
options.allowExternalAttachmentBlobs
|
||||||
|
? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`)
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
|
||||||
|
const userIds = new Set<string>();
|
||||||
|
for (const row of userRows) {
|
||||||
|
const id = String(row.id || '').trim();
|
||||||
|
const email = String(row.email || '').trim();
|
||||||
|
if (!id || !email) throw new Error('Backup archive contains an invalid user row');
|
||||||
|
if (userIds.has(id)) throw new Error(`Backup archive contains duplicate user id: ${id}`);
|
||||||
|
userIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of configRows) {
|
||||||
|
const key = String(row.key || '').trim();
|
||||||
|
if (!key) throw new Error('Backup archive contains an invalid config row');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of revisionRows) {
|
||||||
|
const userId = String(row.user_id || '').trim();
|
||||||
|
if (!userId || !userIds.has(userId)) {
|
||||||
|
throw new Error(`Backup archive contains a revision for an unknown user: ${userId || '(empty)'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderIds = new Set<string>();
|
||||||
|
for (const row of folderRows) {
|
||||||
|
const id = String(row.id || '').trim();
|
||||||
|
const userId = String(row.user_id || '').trim();
|
||||||
|
if (!id || !userIds.has(userId)) throw new Error('Backup archive contains an invalid folder row');
|
||||||
|
if (folderIds.has(id)) throw new Error(`Backup archive contains duplicate folder id: ${id}`);
|
||||||
|
folderIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cipherIds = new Set<string>();
|
||||||
|
for (const row of cipherRows) {
|
||||||
|
const id = String(row.id || '').trim();
|
||||||
|
const userId = String(row.user_id || '').trim();
|
||||||
|
const folderId = String(row.folder_id || '').trim();
|
||||||
|
if (!id || !userIds.has(userId)) throw new Error('Backup archive contains an invalid cipher row');
|
||||||
|
if (folderId && !folderIds.has(folderId)) {
|
||||||
|
throw new Error(`Backup archive contains a cipher for an unknown folder: ${folderId}`);
|
||||||
|
}
|
||||||
|
if (cipherIds.has(id)) throw new Error(`Backup archive contains duplicate cipher id: ${id}`);
|
||||||
|
cipherIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of attachmentRows) {
|
||||||
|
const id = String(row.id || '').trim();
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
if (!id || !cipherId || !cipherIds.has(cipherId)) {
|
||||||
|
throw new Error('Backup archive contains an invalid attachment row');
|
||||||
|
}
|
||||||
|
const attachmentPath = `attachments/${cipherId}/${id}.bin`;
|
||||||
|
if (!files[attachmentPath] && !externalAttachmentKeys.has(attachmentPath)) {
|
||||||
|
throw new Error(`Backup archive is missing required file: attachments/${cipherId}/${id}.bin`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildBackupArchive(
|
||||||
|
env: Env,
|
||||||
|
date: Date = new Date(),
|
||||||
|
options: BuildBackupArchiveOptions = {}
|
||||||
|
): Promise<BackupArchiveBundle> {
|
||||||
|
const includeAttachments = options.includeAttachments !== false;
|
||||||
|
await options.progress?.({
|
||||||
|
step: 'collect_data',
|
||||||
|
fileName: '',
|
||||||
|
stageTitle: 'txt_backup_archive_progress_collect_title',
|
||||||
|
stageDetail: includeAttachments
|
||||||
|
? 'txt_backup_archive_progress_collect_with_attachments_detail'
|
||||||
|
: 'txt_backup_archive_progress_collect_detail',
|
||||||
|
includeAttachments,
|
||||||
|
});
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows] = await Promise.all([
|
||||||
|
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
|
||||||
|
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
||||||
|
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
|
||||||
|
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
|
||||||
|
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
||||||
|
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
|
||||||
|
]);
|
||||||
|
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
|
||||||
|
const attachmentBlobs: BackupManifestAttachmentBlob[] = exportedAttachmentRows.map((row) => {
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
return {
|
||||||
|
cipherId,
|
||||||
|
attachmentId,
|
||||||
|
blobName: getAttachmentObjectKey(cipherId, attachmentId),
|
||||||
|
sizeBytes: Number(row.size || 0) || 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const manifestBase = {
|
||||||
|
formatVersion: BACKUP_FORMAT_VERSION,
|
||||||
|
exportedAt: date.toISOString(),
|
||||||
|
appVersion: APP_VERSION,
|
||||||
|
storageKind: getBlobStorageKind(env),
|
||||||
|
tableCounts: {
|
||||||
|
config: configRows.length,
|
||||||
|
users: userRows.length,
|
||||||
|
user_revisions: revisionRows.length,
|
||||||
|
folders: folderRows.length,
|
||||||
|
ciphers: cipherRows.length,
|
||||||
|
attachments: exportedAttachmentRows.length,
|
||||||
|
},
|
||||||
|
includes: {
|
||||||
|
attachments: includeAttachments,
|
||||||
|
},
|
||||||
|
blobSummary: {
|
||||||
|
attachmentFiles: attachmentBlobs.length,
|
||||||
|
totalBytes: attachmentBlobs.reduce((sum, item) => sum + item.sizeBytes, 0),
|
||||||
|
largestObjectBytes: attachmentBlobs.reduce((max, item) => Math.max(max, item.sizeBytes), 0),
|
||||||
|
},
|
||||||
|
attachmentBlobs: includeAttachments ? attachmentBlobs : [],
|
||||||
|
} satisfies BackupManifest;
|
||||||
|
|
||||||
|
const files: Record<string, Uint8Array> = {
|
||||||
|
'manifest.json': encoder.encode(JSON.stringify(manifestBase, null, BACKUP_JSON_INDENT)),
|
||||||
|
'db.json': encoder.encode(JSON.stringify({
|
||||||
|
config: configRows,
|
||||||
|
users: userRows,
|
||||||
|
user_revisions: revisionRows,
|
||||||
|
folders: folderRows,
|
||||||
|
ciphers: cipherRows,
|
||||||
|
attachments: exportedAttachmentRows,
|
||||||
|
}, null, BACKUP_JSON_INDENT)),
|
||||||
|
};
|
||||||
|
|
||||||
|
await options.progress?.({
|
||||||
|
step: 'package_archive',
|
||||||
|
fileName: '',
|
||||||
|
stageTitle: 'txt_backup_archive_progress_package_title',
|
||||||
|
stageDetail: includeAttachments
|
||||||
|
? 'txt_backup_archive_progress_package_with_attachments_detail'
|
||||||
|
: 'txt_backup_archive_progress_package_detail',
|
||||||
|
includeAttachments,
|
||||||
|
});
|
||||||
|
const bytes = zipSync(createZipEntries(files));
|
||||||
|
const fileHashPrefix = (await sha256Hex(bytes)).slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
|
||||||
|
const backupTimeZone = options.timeZone || 'UTC';
|
||||||
|
const fileName = buildBackupFileNameInTimeZone(date, fileHashPrefix, backupTimeZone);
|
||||||
|
await options.progress?.({
|
||||||
|
step: 'archive_ready',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_archive_progress_ready_title',
|
||||||
|
stageDetail: 'txt_backup_archive_progress_ready_detail',
|
||||||
|
includeAttachments,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
bytes,
|
||||||
|
fileName,
|
||||||
|
manifest: manifestBase,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,627 @@
|
|||||||
|
import type { Env, User } from '../types';
|
||||||
|
import { StorageService } from './storage';
|
||||||
|
import {
|
||||||
|
type BackupSettingsPortableEnvelope,
|
||||||
|
decryptBackupSettingsRuntime,
|
||||||
|
encryptBackupSettingsEnvelope,
|
||||||
|
parseBackupSettingsEnvelope,
|
||||||
|
} from './backup-settings-crypto';
|
||||||
|
import {
|
||||||
|
BACKUP_DEFAULT_INTERVAL_HOURS,
|
||||||
|
BACKUP_DEFAULT_START_TIME,
|
||||||
|
BACKUP_DEFAULT_TIMEZONE,
|
||||||
|
type BackupDestinationConfig,
|
||||||
|
type BackupDestinationRecord,
|
||||||
|
type BackupDestinationType,
|
||||||
|
type BackupRuntimeState,
|
||||||
|
type BackupScheduleConfig,
|
||||||
|
type BackupSettings,
|
||||||
|
type E3BackupDestination,
|
||||||
|
type WebDavBackupDestination,
|
||||||
|
createBackupRandomId,
|
||||||
|
createDefaultBackupDestinationName,
|
||||||
|
createDefaultBackupScheduleConfig,
|
||||||
|
createDefaultBackupSettings as createSharedDefaultBackupSettings,
|
||||||
|
} from '../../shared/backup-schema';
|
||||||
|
|
||||||
|
export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1';
|
||||||
|
export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5;
|
||||||
|
const MAX_BACKUP_DESTINATIONS = 24;
|
||||||
|
|
||||||
|
export type {
|
||||||
|
BackupDestinationConfig,
|
||||||
|
BackupDestinationRecord,
|
||||||
|
BackupDestinationType,
|
||||||
|
BackupRuntimeState,
|
||||||
|
BackupScheduleConfig,
|
||||||
|
BackupSettings,
|
||||||
|
E3BackupDestination,
|
||||||
|
WebDavBackupDestination,
|
||||||
|
} from '../../shared/backup-schema';
|
||||||
|
|
||||||
|
export interface BackupSettingsInput {
|
||||||
|
destinations?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupSettingsRepairState {
|
||||||
|
needsRepair: boolean;
|
||||||
|
portable: BackupSettingsPortableEnvelope | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultScheduleConfig(timezone: string = 'UTC'): BackupScheduleConfig {
|
||||||
|
return { ...createDefaultBackupScheduleConfig(assertValidTimeZone(timezone)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asTrimmedString(value: unknown): string {
|
||||||
|
return String(value ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(value: unknown): string {
|
||||||
|
return asTrimmedString(value).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidTimeZone(timezone: string): string {
|
||||||
|
try {
|
||||||
|
new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(new Date());
|
||||||
|
return timezone;
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid backup timezone');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRetentionCount(value: unknown, fallback: number | null = 30): number | null {
|
||||||
|
if (value === undefined) return fallback;
|
||||||
|
if (value === null || String(value).trim() === '') return null;
|
||||||
|
const count = Number(value);
|
||||||
|
if (!Number.isInteger(count) || count < 1 || count > 1000) {
|
||||||
|
throw new Error('Backup retention count must be between 1 and 1000');
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIntervalHours(value: unknown, fallback: number = BACKUP_DEFAULT_INTERVAL_HOURS): number {
|
||||||
|
const raw = value === undefined || value === null || value === '' ? fallback : Number(value);
|
||||||
|
if (!Number.isInteger(raw) || raw < 1 || raw > 99) {
|
||||||
|
throw new Error('Backup interval hours must be between 1 and 99');
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStartTime(value: unknown, fallback: string = BACKUP_DEFAULT_START_TIME): string {
|
||||||
|
const raw = asTrimmedString(value) || fallback;
|
||||||
|
const match = raw.match(/^(\d{1,2})(?::(\d{1,2}))?$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error('Backup start time must be in HH:mm format');
|
||||||
|
}
|
||||||
|
const hour = Number(match[1]);
|
||||||
|
const minute = Number(match[2] ?? '0');
|
||||||
|
if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||||
|
throw new Error('Backup start time must be in HH:mm format');
|
||||||
|
}
|
||||||
|
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeE3Destination(value: unknown, allowIncomplete = false): E3BackupDestination {
|
||||||
|
const source = isPlainObject(value) ? value : {};
|
||||||
|
const endpoint = asTrimmedString(source.endpoint);
|
||||||
|
const bucket = asTrimmedString(source.bucket);
|
||||||
|
const accessKeyId = asTrimmedString(source.accessKeyId);
|
||||||
|
const secretAccessKey = asTrimmedString(source.secretAccessKey);
|
||||||
|
const region = asTrimmedString(source.region) || 'auto';
|
||||||
|
const rootPath = normalizePath(source.rootPath);
|
||||||
|
|
||||||
|
if (!allowIncomplete || endpoint) {
|
||||||
|
if (!endpoint) throw new Error('E3 endpoint is required');
|
||||||
|
if (!/^https?:\/\//i.test(endpoint)) throw new Error('E3 endpoint must start with http:// or https://');
|
||||||
|
}
|
||||||
|
if (!allowIncomplete || bucket) {
|
||||||
|
if (!bucket) throw new Error('E3 bucket is required');
|
||||||
|
}
|
||||||
|
if (!allowIncomplete || accessKeyId) {
|
||||||
|
if (!accessKeyId) throw new Error('E3 access key is required');
|
||||||
|
}
|
||||||
|
if (!allowIncomplete || secretAccessKey) {
|
||||||
|
if (!secretAccessKey) throw new Error('E3 secret key is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
|
||||||
|
bucket,
|
||||||
|
region,
|
||||||
|
accessKeyId,
|
||||||
|
secretAccessKey,
|
||||||
|
rootPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWebDavDestination(value: unknown, allowIncomplete = false): WebDavBackupDestination {
|
||||||
|
const source = isPlainObject(value) ? value : {};
|
||||||
|
const baseUrl = asTrimmedString(source.baseUrl);
|
||||||
|
const username = asTrimmedString(source.username);
|
||||||
|
const password = String(source.password ?? '');
|
||||||
|
const remotePath = normalizePath(source.remotePath);
|
||||||
|
|
||||||
|
if (!allowIncomplete || baseUrl) {
|
||||||
|
if (!baseUrl) throw new Error('WebDAV server URL is required');
|
||||||
|
if (!/^https?:\/\//i.test(baseUrl)) throw new Error('WebDAV server URL must start with http:// or https://');
|
||||||
|
}
|
||||||
|
if (!allowIncomplete || username) {
|
||||||
|
if (!username) throw new Error('WebDAV username is required');
|
||||||
|
}
|
||||||
|
if (!allowIncomplete || password) {
|
||||||
|
if (!password) throw new Error('WebDAV password is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl: baseUrl ? baseUrl.replace(/\/+$/, '') : '',
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
remotePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDestination(
|
||||||
|
destinationType: BackupDestinationType,
|
||||||
|
destination: unknown,
|
||||||
|
allowIncomplete = false
|
||||||
|
): BackupDestinationConfig {
|
||||||
|
if (destinationType === 'e3') return normalizeE3Destination(destination, allowIncomplete);
|
||||||
|
return normalizeWebDavDestination(destination, allowIncomplete);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRuntime(value: unknown): BackupRuntimeState {
|
||||||
|
const source = isPlainObject(value) ? value : {};
|
||||||
|
const asIso = (input: unknown): string | null => {
|
||||||
|
const raw = asTrimmedString(input);
|
||||||
|
if (!raw) return null;
|
||||||
|
const date = new Date(raw);
|
||||||
|
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
||||||
|
};
|
||||||
|
const asMaybeNumber = (input: unknown): number | null => {
|
||||||
|
if (input === null || input === undefined || input === '') return null;
|
||||||
|
const n = Number(input);
|
||||||
|
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
lastAttemptAt: asIso(source.lastAttemptAt),
|
||||||
|
lastAttemptLocalDate: asTrimmedString(source.lastAttemptLocalDate) || null,
|
||||||
|
lastSuccessAt: asIso(source.lastSuccessAt),
|
||||||
|
lastErrorAt: asIso(source.lastErrorAt),
|
||||||
|
lastErrorMessage: asTrimmedString(source.lastErrorMessage) || null,
|
||||||
|
lastUploadedFileName: asTrimmedString(source.lastUploadedFileName) || null,
|
||||||
|
lastUploadedSizeBytes: asMaybeNumber(source.lastUploadedSizeBytes),
|
||||||
|
lastUploadedDestination: asTrimmedString(source.lastUploadedDestination) || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultDestinationName(type: BackupDestinationType, index: number): string {
|
||||||
|
return createDefaultBackupDestinationName(type, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDestinationType(raw: unknown): BackupDestinationType {
|
||||||
|
const value = asTrimmedString(raw);
|
||||||
|
if (value === 'e3' || value === 'webdav') return value;
|
||||||
|
throw new Error('Backup destination type is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDestinationRecord(
|
||||||
|
input: unknown,
|
||||||
|
previousById: Map<string, BackupDestinationRecord>,
|
||||||
|
index: number,
|
||||||
|
fallbackTimezone: string
|
||||||
|
): BackupDestinationRecord {
|
||||||
|
if (!isPlainObject(input)) {
|
||||||
|
throw new Error('Backup destination is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = asTrimmedString(input.id) || createBackupRandomId();
|
||||||
|
const type = getDestinationType(input.type);
|
||||||
|
const previous = previousById.get(id);
|
||||||
|
const runtime = previous?.runtime ? normalizeRuntime(previous.runtime) : normalizeRuntime(input.runtime);
|
||||||
|
const name = asTrimmedString(input.name) || previous?.name || defaultDestinationName(type, index + 1);
|
||||||
|
const scheduleSource = isPlainObject(input.schedule) ? input.schedule : {};
|
||||||
|
const previousSchedule = previous?.schedule || defaultScheduleConfig(fallbackTimezone);
|
||||||
|
const retentionSource = Object.prototype.hasOwnProperty.call(scheduleSource, 'retentionCount')
|
||||||
|
? scheduleSource.retentionCount
|
||||||
|
: previousSchedule.retentionCount;
|
||||||
|
const schedule: BackupScheduleConfig = {
|
||||||
|
enabled: !!(scheduleSource.enabled ?? previousSchedule.enabled),
|
||||||
|
intervalHours: normalizeIntervalHours(
|
||||||
|
scheduleSource.intervalHours ?? previousSchedule.intervalHours,
|
||||||
|
previousSchedule.intervalHours || BACKUP_DEFAULT_INTERVAL_HOURS
|
||||||
|
),
|
||||||
|
startTime: normalizeStartTime(
|
||||||
|
scheduleSource.startTime ?? previousSchedule.startTime,
|
||||||
|
previousSchedule.startTime || BACKUP_DEFAULT_START_TIME
|
||||||
|
),
|
||||||
|
timezone: assertValidTimeZone(asTrimmedString(scheduleSource.timezone ?? previousSchedule.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
|
||||||
|
retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount),
|
||||||
|
};
|
||||||
|
|
||||||
|
const destination = normalizeDestination(type, input.destination, !schedule.enabled);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
includeAttachments: typeof input.includeAttachments === 'boolean'
|
||||||
|
? input.includeAttachments
|
||||||
|
: previous?.includeAttachments ?? false,
|
||||||
|
destination,
|
||||||
|
schedule,
|
||||||
|
runtime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLegacyBackupSettings(rawValue: Record<string, unknown>, fallbackTimezone: string): BackupSettings {
|
||||||
|
const legacyFrequency = asTrimmedString(rawValue.frequency).toLowerCase();
|
||||||
|
const intervalHours = legacyFrequency === 'weekly'
|
||||||
|
? 24 * 7
|
||||||
|
: legacyFrequency === 'monthly'
|
||||||
|
? 24 * 30
|
||||||
|
: BACKUP_DEFAULT_INTERVAL_HOURS;
|
||||||
|
const destinationTypeRaw = asTrimmedString(rawValue.destinationType);
|
||||||
|
const destinationType: BackupDestinationType =
|
||||||
|
destinationTypeRaw === 'e3' || destinationTypeRaw === 'webdav'
|
||||||
|
? destinationTypeRaw
|
||||||
|
: 'webdav';
|
||||||
|
const destination = {
|
||||||
|
id: createBackupRandomId(),
|
||||||
|
name: defaultDestinationName(destinationType, 1),
|
||||||
|
type: destinationType,
|
||||||
|
includeAttachments: false,
|
||||||
|
destination: normalizeDestination(destinationType, rawValue.destination),
|
||||||
|
schedule: {
|
||||||
|
enabled: !!rawValue.enabled,
|
||||||
|
intervalHours,
|
||||||
|
startTime: BACKUP_DEFAULT_START_TIME,
|
||||||
|
timezone: assertValidTimeZone(asTrimmedString(rawValue.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
|
||||||
|
retentionCount: 30,
|
||||||
|
},
|
||||||
|
runtime: normalizeRuntime(rawValue.runtime),
|
||||||
|
} satisfies BackupDestinationRecord;
|
||||||
|
|
||||||
|
return {
|
||||||
|
destinations: [destination],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDestinations(
|
||||||
|
rawDestinations: unknown,
|
||||||
|
previousById: Map<string, BackupDestinationRecord>,
|
||||||
|
fallbackTimezone: string
|
||||||
|
): BackupDestinationRecord[] {
|
||||||
|
if (!Array.isArray(rawDestinations)) {
|
||||||
|
throw new Error('Backup destinations are invalid');
|
||||||
|
}
|
||||||
|
if (rawDestinations.length > MAX_BACKUP_DESTINATIONS) {
|
||||||
|
throw new Error(`You can save up to ${MAX_BACKUP_DESTINATIONS} backup destinations`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const destinations = rawDestinations.map((entry, index) => normalizeDestinationRecord(entry, previousById, index, fallbackTimezone));
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const destination of destinations) {
|
||||||
|
if (ids.has(destination.id)) {
|
||||||
|
throw new Error('Backup destination ids must be unique');
|
||||||
|
}
|
||||||
|
ids.add(destination.id);
|
||||||
|
}
|
||||||
|
return destinations;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDestinationsById(destinations: BackupDestinationRecord[]): Map<string, BackupDestinationRecord> {
|
||||||
|
return new Map(destinations.map((destination) => [destination.id, destination]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultBackupSettings(timezone: string = 'UTC'): BackupSettings {
|
||||||
|
return createSharedDefaultBackupSettings(assertValidTimeZone(timezone));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBackupSettings(raw: string | null, fallbackTimezone: string = 'UTC'): BackupSettings {
|
||||||
|
if (!raw) return getDefaultBackupSettings(fallbackTimezone);
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
if (Array.isArray(parsed.destinations)) {
|
||||||
|
const globalTimezone = assertValidTimeZone(asTrimmedString(parsed.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE);
|
||||||
|
const globalEnabled = !!parsed.enabled;
|
||||||
|
const activeDestinationIdRaw = asTrimmedString(parsed.activeDestinationId);
|
||||||
|
const globalFrequency = asTrimmedString(parsed.frequency).toLowerCase();
|
||||||
|
const globalIntervalHours = globalFrequency === 'weekly'
|
||||||
|
? 24 * 7
|
||||||
|
: globalFrequency === 'monthly'
|
||||||
|
? 24 * 30
|
||||||
|
: BACKUP_DEFAULT_INTERVAL_HOURS;
|
||||||
|
const previousById = new Map<string, BackupDestinationRecord>();
|
||||||
|
const normalizedEntries = (parsed.destinations as unknown[]).map((entry) => {
|
||||||
|
if (!isPlainObject(entry)) return entry;
|
||||||
|
if (isPlainObject(entry.schedule)) return entry;
|
||||||
|
const entryId = asTrimmedString(entry.id);
|
||||||
|
const scheduleEnabled = globalEnabled && (!activeDestinationIdRaw || entryId === activeDestinationIdRaw);
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
schedule: {
|
||||||
|
enabled: scheduleEnabled,
|
||||||
|
intervalHours: globalIntervalHours,
|
||||||
|
startTime: BACKUP_DEFAULT_START_TIME,
|
||||||
|
timezone: globalTimezone,
|
||||||
|
retentionCount: 30,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
destinations: parseDestinations(normalizedEntries, previousById, fallbackTimezone),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return parseLegacyBackupSettings(parsed, fallbackTimezone);
|
||||||
|
} catch {
|
||||||
|
return getDefaultBackupSettings(fallbackTimezone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBackupSettingsInput(
|
||||||
|
input: BackupSettingsInput,
|
||||||
|
previous: BackupSettings
|
||||||
|
): BackupSettings {
|
||||||
|
if (!isPlainObject(input)) {
|
||||||
|
throw new Error('Backup settings payload is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousById = mapDestinationsById(previous.destinations);
|
||||||
|
const rawDestinations = input.destinations ?? previous.destinations;
|
||||||
|
const destinations = parseDestinations(rawDestinations, previousById, BACKUP_DEFAULT_TIMEZONE);
|
||||||
|
|
||||||
|
return {
|
||||||
|
destinations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeBackupSettings(settings: BackupSettings): string {
|
||||||
|
return JSON.stringify(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettings> {
|
||||||
|
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
const settings = getDefaultBackupSettings(fallbackTimezone);
|
||||||
|
await saveBackupSettings(storage, env, settings);
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope = parseBackupSettingsEnvelope(raw);
|
||||||
|
if (!envelope) {
|
||||||
|
const settings = parseBackupSettings(raw, fallbackTimezone);
|
||||||
|
await saveBackupSettings(storage, env, settings);
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decrypted = await decryptBackupSettingsRuntime(raw, env);
|
||||||
|
return parseBackupSettings(decrypted, fallbackTimezone);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Backup settings need administrator reactivation after restore');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveBackupSettings(storage: StorageService, env: Env, settings: BackupSettings): Promise<void> {
|
||||||
|
const users = await storage.getAllUsers();
|
||||||
|
const hasPortableAdmins = users.some(
|
||||||
|
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
|
||||||
|
);
|
||||||
|
if (!hasPortableAdmins) {
|
||||||
|
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, serializeBackupSettings(settings));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
||||||
|
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function normalizeImportedBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<void> {
|
||||||
|
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
const users = await storage.getAllUsers();
|
||||||
|
const normalized = await normalizeImportedBackupSettingsValue(raw, env, users, fallbackTimezone);
|
||||||
|
if (normalized !== null) {
|
||||||
|
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function normalizeImportedBackupSettingsValue(
|
||||||
|
raw: string | null,
|
||||||
|
env: Env,
|
||||||
|
users: Pick<User, 'id' | 'publicKey' | 'role' | 'status'>[],
|
||||||
|
fallbackTimezone: string = 'UTC'
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!raw) return null;
|
||||||
|
const envelope = parseBackupSettingsEnvelope(raw);
|
||||||
|
if (envelope) {
|
||||||
|
try {
|
||||||
|
const decrypted = await decryptBackupSettingsRuntime(raw, env);
|
||||||
|
const settings = parseBackupSettings(decrypted, fallbackTimezone);
|
||||||
|
const hasPortableAdmins = users.some(
|
||||||
|
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
|
||||||
|
);
|
||||||
|
if (!hasPortableAdmins) {
|
||||||
|
return serializeBackupSettings(settings);
|
||||||
|
}
|
||||||
|
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
||||||
|
} catch {
|
||||||
|
// Keep imported portable recovery data intact until an admin signs in and repairs it.
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const settings = parseBackupSettings(raw, fallbackTimezone);
|
||||||
|
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> {
|
||||||
|
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
const settings = getDefaultBackupSettings(fallbackTimezone);
|
||||||
|
await saveBackupSettings(storage, env, settings);
|
||||||
|
return { needsRepair: false, portable: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope = parseBackupSettingsEnvelope(raw);
|
||||||
|
if (!envelope) {
|
||||||
|
const settings = parseBackupSettings(raw, fallbackTimezone);
|
||||||
|
await saveBackupSettings(storage, env, settings);
|
||||||
|
return { needsRepair: false, portable: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await decryptBackupSettingsRuntime(raw, env);
|
||||||
|
return { needsRepair: false, portable: null };
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
needsRepair: true,
|
||||||
|
portable: envelope.portable,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repairBackupSettings(storage: StorageService, env: Env, settings: BackupSettings): Promise<void> {
|
||||||
|
await saveBackupSettings(storage, env, settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findBackupDestination(
|
||||||
|
settings: BackupSettings,
|
||||||
|
destinationId: string | null | undefined
|
||||||
|
): BackupDestinationRecord | null {
|
||||||
|
const normalizedId = asTrimmedString(destinationId);
|
||||||
|
if (!normalizedId) return null;
|
||||||
|
return settings.destinations.find((destination) => destination.id === normalizedId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireBackupDestination(settings: BackupSettings, destinationId?: string | null): BackupDestinationRecord {
|
||||||
|
const destination = destinationId ? findBackupDestination(settings, destinationId) : settings.destinations[0] || null;
|
||||||
|
if (!destination) {
|
||||||
|
throw new Error('Backup destination not found');
|
||||||
|
}
|
||||||
|
return destination;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateTimeParts(date: Date, timezone: string): { year: string; month: string; day: string; hour: string; minute: string } {
|
||||||
|
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: timezone,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hourCycle: 'h23',
|
||||||
|
});
|
||||||
|
const parts = formatter.formatToParts(date);
|
||||||
|
const pick = (type: string): string => parts.find((part) => part.type === type)?.value || '';
|
||||||
|
return {
|
||||||
|
year: pick('year'),
|
||||||
|
month: pick('month'),
|
||||||
|
day: pick('day'),
|
||||||
|
hour: pick('hour'),
|
||||||
|
minute: pick('minute'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBackupLocalDateKey(date: Date, timezone: string): string {
|
||||||
|
const parts = getDateTimeParts(date, timezone);
|
||||||
|
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBackupLocalTime(date: Date, timezone: string): string {
|
||||||
|
const parts = getDateTimeParts(date, timezone);
|
||||||
|
return `${parts.hour}:${parts.minute}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLocalDateKey(dateKey: string): { year: number; month: number; day: number } | null {
|
||||||
|
const match = String(dateKey || '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const year = Number(match[1]);
|
||||||
|
const month = Number(match[2]);
|
||||||
|
const day = Number(match[3]);
|
||||||
|
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return null;
|
||||||
|
return { year, month, day };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUtcDateForLocalTime(timezone: string, year: number, month: number, day: number, hour: number, minute: number): Date {
|
||||||
|
const utcGuess = Date.UTC(year, month - 1, day, hour, minute, 0, 0);
|
||||||
|
const actual = getDateTimeParts(new Date(utcGuess), timezone);
|
||||||
|
const actualUtc = Date.UTC(
|
||||||
|
Number(actual.year),
|
||||||
|
Number(actual.month) - 1,
|
||||||
|
Number(actual.day),
|
||||||
|
Number(actual.hour),
|
||||||
|
Number(actual.minute),
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const desiredUtc = Date.UTC(year, month - 1, day, hour, minute, 0, 0);
|
||||||
|
return new Date(utcGuess - (actualUtc - desiredUtc));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackupSlotStartsForLocalDay(
|
||||||
|
dateKey: string,
|
||||||
|
timezone: string,
|
||||||
|
startTime: string,
|
||||||
|
intervalHours: number
|
||||||
|
): Date[] {
|
||||||
|
const parsedDate = parseLocalDateKey(dateKey);
|
||||||
|
const parsedTime = normalizeStartTime(startTime).split(':').map((value) => Number(value));
|
||||||
|
if (!parsedDate || parsedTime.length !== 2) return [];
|
||||||
|
|
||||||
|
const [hour, minute] = parsedTime;
|
||||||
|
const firstSlot = getUtcDateForLocalTime(timezone, parsedDate.year, parsedDate.month, parsedDate.day, hour, minute);
|
||||||
|
const nextLocalDay = new Date(Date.UTC(parsedDate.year, parsedDate.month - 1, parsedDate.day, 0, 0, 0, 0));
|
||||||
|
nextLocalDay.setUTCDate(nextLocalDay.getUTCDate() + 1);
|
||||||
|
const nextDay = getUtcDateForLocalTime(
|
||||||
|
timezone,
|
||||||
|
nextLocalDay.getUTCFullYear(),
|
||||||
|
nextLocalDay.getUTCMonth() + 1,
|
||||||
|
nextLocalDay.getUTCDate(),
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const intervalMs = intervalHours * 60 * 60 * 1000;
|
||||||
|
const slots: Date[] = [];
|
||||||
|
|
||||||
|
for (let slotMs = firstSlot.getTime(); slotMs < nextDay.getTime(); slotMs += intervalMs) {
|
||||||
|
slots.push(new Date(slotMs));
|
||||||
|
}
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBackupDueNow(
|
||||||
|
destination: BackupDestinationRecord,
|
||||||
|
now: Date,
|
||||||
|
windowMinutes: number = BACKUP_SCHEDULER_WINDOW_MINUTES
|
||||||
|
): boolean {
|
||||||
|
if (!destination.schedule.enabled) return false;
|
||||||
|
const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000;
|
||||||
|
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
|
||||||
|
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime())
|
||||||
|
? lastAttemptAt.getTime()
|
||||||
|
: Number.NEGATIVE_INFINITY;
|
||||||
|
const localDateKey = getBackupLocalDateKey(now, destination.schedule.timezone);
|
||||||
|
const slotStarts = getBackupSlotStartsForLocalDay(
|
||||||
|
localDateKey,
|
||||||
|
destination.schedule.timezone,
|
||||||
|
destination.schedule.startTime,
|
||||||
|
destination.schedule.intervalHours
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const slotStart of slotStarts) {
|
||||||
|
const slotStartMs = slotStart.getTime();
|
||||||
|
if (now.getTime() < slotStartMs || now.getTime() >= slotStartMs + toleranceMs) continue;
|
||||||
|
if (lastAttemptMs >= slotStartMs) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,901 @@
|
|||||||
|
import type { Env, User } from '../types';
|
||||||
|
import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobStorageKind, putBlobObject } from './blob-store';
|
||||||
|
import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from './backup-config';
|
||||||
|
import {
|
||||||
|
type BackupManifestAttachmentBlob,
|
||||||
|
type BackupPayload,
|
||||||
|
parseBackupArchive,
|
||||||
|
validateBackupPayloadContents,
|
||||||
|
} from './backup-archive';
|
||||||
|
|
||||||
|
type SqlRow = Record<string, string | number | null>;
|
||||||
|
type BackupTableName =
|
||||||
|
| 'config'
|
||||||
|
| 'users'
|
||||||
|
| 'user_revisions'
|
||||||
|
| 'folders'
|
||||||
|
| 'ciphers'
|
||||||
|
| 'attachments';
|
||||||
|
|
||||||
|
const BACKUP_TABLES: BackupTableName[] = [
|
||||||
|
'config',
|
||||||
|
'users',
|
||||||
|
'user_revisions',
|
||||||
|
'folders',
|
||||||
|
'ciphers',
|
||||||
|
'attachments',
|
||||||
|
];
|
||||||
|
|
||||||
|
function shadowTableName(table: BackupTableName): string {
|
||||||
|
return `${table}__restore`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupImportResultBody {
|
||||||
|
object: 'instance-backup-import';
|
||||||
|
imported: {
|
||||||
|
config: number;
|
||||||
|
users: number;
|
||||||
|
userRevisions: number;
|
||||||
|
folders: number;
|
||||||
|
ciphers: number;
|
||||||
|
attachments: number;
|
||||||
|
attachmentFiles: number;
|
||||||
|
};
|
||||||
|
skipped: {
|
||||||
|
reason: string | null;
|
||||||
|
attachments: number;
|
||||||
|
items: Array<{
|
||||||
|
kind: 'attachment';
|
||||||
|
path: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupImportExecutionResult {
|
||||||
|
result: BackupImportResultBody;
|
||||||
|
auditActorUserId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Promise<SqlRow[]> {
|
||||||
|
const response = await db.prepare(sql).bind(...values).all<SqlRow>();
|
||||||
|
return (response.results || []).map((row) => ({ ...row }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTableCreateSql(db: D1Database, table: BackupTableName): Promise<string> {
|
||||||
|
const row = await db
|
||||||
|
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||||
|
.bind(table)
|
||||||
|
.first<{ sql: string | null }>();
|
||||||
|
const sql = String(row?.sql || '').trim();
|
||||||
|
if (!sql) {
|
||||||
|
throw new Error(`Restore shadow schema is missing table definition for ${table}`);
|
||||||
|
}
|
||||||
|
return sql;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildShadowTableCreateSql(createSql: string, table: BackupTableName): string {
|
||||||
|
const tablePattern = new RegExp(`^CREATE TABLE(?:\\s+IF NOT EXISTS)?\\s+(?:\"${table}\"|${table})(?=\\s*\\()`, 'i');
|
||||||
|
let next = createSql.replace(tablePattern, `CREATE TABLE "${shadowTableName(table)}"`);
|
||||||
|
if (next === createSql) {
|
||||||
|
throw new Error(`Restore shadow schema could not rewrite CREATE TABLE statement for ${table}`);
|
||||||
|
}
|
||||||
|
for (const currentTable of BACKUP_TABLES) {
|
||||||
|
const referencePattern = new RegExp(`\\bREFERENCES\\s+(?:\"${currentTable}\"|${currentTable})(?=\\s*\\()`, 'gi');
|
||||||
|
next = next.replace(
|
||||||
|
referencePattern,
|
||||||
|
`REFERENCES "${shadowTableName(currentTable)}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetRestoreArtifacts(db: D1Database): Promise<void> {
|
||||||
|
const dropStatements = BACKUP_TABLES
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((table) => db.prepare(`DROP TABLE IF EXISTS ${shadowTableName(table)}`));
|
||||||
|
if (dropStatements.length) {
|
||||||
|
await db.batch(dropStatements);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createShadowTables(db: D1Database): Promise<void> {
|
||||||
|
const createStatements: D1PreparedStatement[] = [];
|
||||||
|
for (const table of BACKUP_TABLES) {
|
||||||
|
const createSql = await getTableCreateSql(db, table);
|
||||||
|
createStatements.push(db.prepare(buildShadowTableCreateSql(createSql, table)));
|
||||||
|
}
|
||||||
|
await db.batch(createStatements);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateShadowTableCounts(
|
||||||
|
db: D1Database,
|
||||||
|
expectedCounts: Partial<Record<BackupTableName, number>>
|
||||||
|
): Promise<void> {
|
||||||
|
await Promise.all(BACKUP_TABLES.map(async (table) => {
|
||||||
|
const expected = expectedCounts[table] ?? 0;
|
||||||
|
const row = await db.prepare(`SELECT COUNT(*) AS count FROM ${shadowTableName(table)}`).first<{ count: number }>();
|
||||||
|
const actual = Number(row?.count || 0);
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(`Restore shadow validation failed for ${table}: expected ${expected}, received ${actual}`);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function swapShadowTablesIntoPlace(db: D1Database): Promise<void> {
|
||||||
|
const statements: D1PreparedStatement[] = [];
|
||||||
|
// Commit by replacing live table contents from validated shadow tables.
|
||||||
|
// This avoids D1 schema-rename edge cases while keeping current data intact
|
||||||
|
// until the final batch succeeds.
|
||||||
|
for (const sql of buildResetImportTargetStatements(db)) {
|
||||||
|
statements.push(sql);
|
||||||
|
}
|
||||||
|
for (const table of BACKUP_TABLES) {
|
||||||
|
statements.push(db.prepare(`INSERT INTO ${table} SELECT * FROM ${shadowTableName(table)}`));
|
||||||
|
}
|
||||||
|
await db.batch(statements);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureImportTargetIsFresh(db: D1Database): Promise<void> {
|
||||||
|
const counts = await Promise.all([
|
||||||
|
db.prepare('SELECT COUNT(*) AS count FROM ciphers').first<{ count: number }>(),
|
||||||
|
db.prepare('SELECT COUNT(*) AS count FROM folders').first<{ count: number }>(),
|
||||||
|
db.prepare('SELECT COUNT(*) AS count FROM attachments').first<{ count: number }>(),
|
||||||
|
db.prepare('SELECT COUNT(*) AS count FROM sends').first<{ count: number }>(),
|
||||||
|
]);
|
||||||
|
const total = counts.reduce((sum, row) => sum + Number(row?.count || 0), 0);
|
||||||
|
if (total > 0) {
|
||||||
|
throw new Error('Backup import requires a fresh instance with no vault or send data');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[] {
|
||||||
|
return [
|
||||||
|
'DELETE FROM attachments',
|
||||||
|
'DELETE FROM ciphers',
|
||||||
|
'DELETE FROM folders',
|
||||||
|
'DELETE FROM user_revisions',
|
||||||
|
'DELETE FROM users',
|
||||||
|
'DELETE FROM config',
|
||||||
|
].map((sql) => db.prepare(sql));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectCurrentBlobKeys(db: D1Database): Promise<Set<string>> {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
const attachmentRows = await queryRows(
|
||||||
|
db,
|
||||||
|
`SELECT a.id, a.cipher_id
|
||||||
|
FROM attachments a
|
||||||
|
INNER JOIN ciphers c ON c.id = a.cipher_id`
|
||||||
|
);
|
||||||
|
for (const row of attachmentRows) {
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
if (!cipherId || !attachmentId) continue;
|
||||||
|
keys.add(getAttachmentObjectKey(cipherId, attachmentId));
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KV_BLOB_SKIP_REASON = 'Cloudflare KV object size limit (25 MB)';
|
||||||
|
const BLOB_STORAGE_UNAVAILABLE_SKIP_REASON = 'Attachment storage is not configured';
|
||||||
|
const ATTACHMENT_RESTORE_FAILED_REASON = 'Some attachments could not be restored and were skipped';
|
||||||
|
|
||||||
|
interface BackupImportSkipSummary {
|
||||||
|
reason: string | null;
|
||||||
|
attachments: number;
|
||||||
|
items: Array<{
|
||||||
|
kind: 'attachment';
|
||||||
|
path: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreparedBackupImportPayload {
|
||||||
|
payload: BackupPayload;
|
||||||
|
skipped: BackupImportSkipSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AttachmentRestoreResult {
|
||||||
|
imported: number;
|
||||||
|
restoredAttachments: SqlRow[];
|
||||||
|
skipped: BackupImportSkipSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RemoteAttachmentSource {
|
||||||
|
loadAttachment(blobName: string): Promise<Uint8Array | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupRestoreProgressEvent {
|
||||||
|
source: 'local' | 'remote';
|
||||||
|
step: string;
|
||||||
|
fileName: string;
|
||||||
|
stageTitle: string;
|
||||||
|
stageDetail: string;
|
||||||
|
replaceExisting: boolean;
|
||||||
|
done?: boolean;
|
||||||
|
ok?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BackupRestoreProgressReporter = (event: BackupRestoreProgressEvent) => Promise<void> | void;
|
||||||
|
|
||||||
|
function attachmentRowKey(row: SqlRow): string {
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
return `${cipherId}/${attachmentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneRows(rows: SqlRow[]): SqlRow[] {
|
||||||
|
return rows.map((row) => ({ ...row }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertConfigRow(rows: SqlRow[], key: string, value: string): SqlRow[] {
|
||||||
|
let replaced = false;
|
||||||
|
const nextRows = rows.map((row) => {
|
||||||
|
if (String(row.key || '').trim() !== key) return { ...row };
|
||||||
|
replaced = true;
|
||||||
|
return { ...row, key, value };
|
||||||
|
});
|
||||||
|
if (!replaced) {
|
||||||
|
nextRows.push({ key, value });
|
||||||
|
}
|
||||||
|
return nextRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareImportedConfigRows(
|
||||||
|
env: Env,
|
||||||
|
configRows: SqlRow[],
|
||||||
|
userRows: SqlRow[]
|
||||||
|
): Promise<SqlRow[]> {
|
||||||
|
let nextConfigRows = cloneRows(configRows || []);
|
||||||
|
const rawBackupSettings = nextConfigRows.find((row) => String(row.key || '').trim() === BACKUP_SETTINGS_CONFIG_KEY);
|
||||||
|
const normalizedBackupSettings = await normalizeImportedBackupSettingsValue(
|
||||||
|
typeof rawBackupSettings?.value === 'string' ? rawBackupSettings.value : null,
|
||||||
|
env,
|
||||||
|
userRows.map((row) => ({
|
||||||
|
id: String(row.id || '').trim(),
|
||||||
|
publicKey: typeof row.public_key === 'string' ? row.public_key : null,
|
||||||
|
role: String(row.role || '').trim() as User['role'],
|
||||||
|
status: String(row.status || '').trim() as User['status'],
|
||||||
|
})),
|
||||||
|
'UTC'
|
||||||
|
);
|
||||||
|
if (normalizedBackupSettings !== null) {
|
||||||
|
nextConfigRows = upsertConfigRow(nextConfigRows, BACKUP_SETTINGS_CONFIG_KEY, normalizedBackupSettings);
|
||||||
|
}
|
||||||
|
nextConfigRows = upsertConfigRow(nextConfigRows, 'registered', 'true');
|
||||||
|
return nextConfigRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['db'], env: Env): Promise<BackupPayload['db']> {
|
||||||
|
const preparedDb: BackupPayload['db'] = {
|
||||||
|
config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []),
|
||||||
|
users: cloneRows(payload.users || []).map((row) => ({
|
||||||
|
...row,
|
||||||
|
verify_devices: row.verify_devices ?? 1,
|
||||||
|
})),
|
||||||
|
user_revisions: cloneRows(payload.user_revisions || []),
|
||||||
|
folders: cloneRows(payload.folders || []),
|
||||||
|
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
||||||
|
...row,
|
||||||
|
archived_at: row.archived_at ?? null,
|
||||||
|
})),
|
||||||
|
attachments: cloneRows(payload.attachments || []),
|
||||||
|
};
|
||||||
|
await importBackupRows(db, preparedDb, true);
|
||||||
|
return preparedDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files: Record<string, Uint8Array>): PreparedBackupImportPayload {
|
||||||
|
const storageKind = getBlobStorageKind(env);
|
||||||
|
if (storageKind === 'r2') {
|
||||||
|
return {
|
||||||
|
payload,
|
||||||
|
skipped: {
|
||||||
|
reason: null,
|
||||||
|
attachments: 0,
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storageKind === null) {
|
||||||
|
const skippedItems = (payload.db.attachments || []).map((row) => {
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
return {
|
||||||
|
kind: 'attachment' as const,
|
||||||
|
path: `attachments/${cipherId}/${attachmentId}.bin`,
|
||||||
|
sizeBytes: Number(row.size || 0) || 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
payload: {
|
||||||
|
...payload,
|
||||||
|
db: {
|
||||||
|
...payload.db,
|
||||||
|
attachments: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
skipped: {
|
||||||
|
reason: skippedItems.length ? BLOB_STORAGE_UNAVAILABLE_SKIP_REASON : null,
|
||||||
|
attachments: skippedItems.length,
|
||||||
|
items: skippedItems,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oversizedAttachmentPaths = new Set<string>();
|
||||||
|
const skippedItems: BackupImportSkipSummary['items'] = [];
|
||||||
|
|
||||||
|
for (const entry of Object.keys(files)) {
|
||||||
|
if (!entry.endsWith('.bin')) continue;
|
||||||
|
const sizeBytes = files[entry].byteLength;
|
||||||
|
if (sizeBytes <= KV_MAX_OBJECT_BYTES) continue;
|
||||||
|
if (entry.startsWith('attachments/')) {
|
||||||
|
oversizedAttachmentPaths.add(entry);
|
||||||
|
skippedItems.push({ kind: 'attachment', path: entry, sizeBytes });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAttachments = (payload.db.attachments || []).filter((row) => {
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
if (!cipherId || !attachmentId) return false;
|
||||||
|
return !oversizedAttachmentPaths.has(`attachments/${cipherId}/${attachmentId}.bin`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextPayload: BackupPayload = {
|
||||||
|
...payload,
|
||||||
|
db: {
|
||||||
|
...payload.db,
|
||||||
|
attachments: nextAttachments,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const needsKvBlobStorage = nextAttachments.length > 0;
|
||||||
|
|
||||||
|
if (needsKvBlobStorage && !env.ATTACHMENTS_KV) {
|
||||||
|
throw new Error('Backup restore requires ATTACHMENTS_KV when using KV blob storage');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
payload: nextPayload,
|
||||||
|
skipped: {
|
||||||
|
reason: skippedItems.length ? KV_BLOB_SKIP_REASON : null,
|
||||||
|
attachments: skippedItems.length,
|
||||||
|
items: skippedItems,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInsertStatements(db: D1Database, table: string, columns: string[], rows: SqlRow[], upsert = false): D1PreparedStatement[] {
|
||||||
|
if (!rows.length) return [];
|
||||||
|
const placeholders = `(${columns.map(() => '?').join(', ')})`;
|
||||||
|
const sql = `INSERT ${upsert ? 'OR REPLACE ' : ''}INTO ${table} (${columns.join(', ')}) VALUES ${placeholders}`;
|
||||||
|
return rows.map((row) => db.prepare(sql).bind(...columns.map((column) => row[column] ?? null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runInsertBatch(db: D1Database, table: string, statements: D1PreparedStatement[]): Promise<void> {
|
||||||
|
if (!statements.length) return;
|
||||||
|
try {
|
||||||
|
await db.batch(statements);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(`Restore insert failed for ${table}: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record<string, Uint8Array>): Promise<AttachmentRestoreResult> {
|
||||||
|
const restoredAttachments: SqlRow[] = [];
|
||||||
|
const skippedItems: BackupImportSkipSummary['items'] = [];
|
||||||
|
|
||||||
|
for (const row of db.attachments || []) {
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
if (!cipherId || !attachmentId) continue;
|
||||||
|
const key = `attachments/${cipherId}/${attachmentId}.bin`;
|
||||||
|
const bytes = files[key];
|
||||||
|
if (!bytes) {
|
||||||
|
skippedItems.push({
|
||||||
|
kind: 'attachment',
|
||||||
|
path: key,
|
||||||
|
sizeBytes: Number(row.size || 0) || 0,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await putBlobObject(env, getAttachmentObjectKey(cipherId, attachmentId), bytes, {
|
||||||
|
size: bytes.byteLength,
|
||||||
|
contentType: 'application/octet-stream',
|
||||||
|
});
|
||||||
|
restoredAttachments.push(row);
|
||||||
|
} catch {
|
||||||
|
skippedItems.push({
|
||||||
|
kind: 'attachment',
|
||||||
|
path: key,
|
||||||
|
sizeBytes: bytes.byteLength,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
imported: restoredAttachments.length,
|
||||||
|
restoredAttachments,
|
||||||
|
skipped: {
|
||||||
|
reason: skippedItems.length ? ATTACHMENT_RESTORE_FAILED_REASON : null,
|
||||||
|
attachments: skippedItems.length,
|
||||||
|
items: skippedItems,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAttachmentBlobLookup(manifest: BackupPayload['manifest']): Map<string, BackupManifestAttachmentBlob> {
|
||||||
|
return new Map(
|
||||||
|
(manifest.attachmentBlobs || []).map((item) => [`${item.cipherId}/${item.attachmentId}`, item])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareRemoteAttachmentPayload(
|
||||||
|
env: Env,
|
||||||
|
payload: BackupPayload,
|
||||||
|
files: Record<string, Uint8Array>,
|
||||||
|
source: RemoteAttachmentSource
|
||||||
|
): Promise<PreparedBackupImportPayload> {
|
||||||
|
const manifestLookup = buildAttachmentBlobLookup(payload.manifest);
|
||||||
|
const storageKind = getBlobStorageKind(env);
|
||||||
|
const nextAttachments: SqlRow[] = [];
|
||||||
|
const skippedItems: BackupImportSkipSummary['items'] = [];
|
||||||
|
|
||||||
|
for (const row of payload.db.attachments || []) {
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
const lookupKey = `${cipherId}/${attachmentId}`;
|
||||||
|
const ref = manifestLookup.get(lookupKey);
|
||||||
|
const sizeBytes = ref?.sizeBytes || Number(row.size || 0) || 0;
|
||||||
|
const path = ref ? `attachments/${ref.blobName}` : `attachments/${lookupKey}`;
|
||||||
|
const inlinePath = `attachments/${cipherId}/${attachmentId}.bin`;
|
||||||
|
|
||||||
|
if (files[inlinePath]) {
|
||||||
|
nextAttachments.push(row);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!ref) {
|
||||||
|
skippedItems.push({ kind: 'attachment', path, sizeBytes });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (storageKind === 'kv' && sizeBytes > KV_MAX_OBJECT_BYTES) {
|
||||||
|
skippedItems.push({ kind: 'attachment', path, sizeBytes });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (storageKind === null) {
|
||||||
|
skippedItems.push({ kind: 'attachment', path, sizeBytes });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
nextAttachments.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
payload: {
|
||||||
|
...payload,
|
||||||
|
db: {
|
||||||
|
...payload.db,
|
||||||
|
attachments: nextAttachments,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
skipped: {
|
||||||
|
reason: skippedItems.length ? 'Some remote attachments were unavailable and were skipped' : null,
|
||||||
|
attachments: skippedItems.length,
|
||||||
|
items: skippedItems,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAttachmentRows(db: D1Database, attachmentRows: SqlRow[], useShadowTable: boolean = false): Promise<void> {
|
||||||
|
if (!attachmentRows.length) return;
|
||||||
|
const tableName = useShadowTable ? shadowTableName('attachments') : 'attachments';
|
||||||
|
const statements = attachmentRows
|
||||||
|
.map((row) => {
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
if (!attachmentId || !cipherId) return null;
|
||||||
|
return db.prepare(`DELETE FROM ${tableName} WHERE id = ? AND cipher_id = ?`).bind(attachmentId, cipherId);
|
||||||
|
})
|
||||||
|
.filter((statement): statement is D1PreparedStatement => !!statement);
|
||||||
|
if (!statements.length) return;
|
||||||
|
await db.batch(statements);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreRemoteAttachmentFiles(
|
||||||
|
env: Env,
|
||||||
|
payload: BackupPayload,
|
||||||
|
files: Record<string, Uint8Array>,
|
||||||
|
source: RemoteAttachmentSource
|
||||||
|
): Promise<{
|
||||||
|
imported: number;
|
||||||
|
skipped: BackupImportSkipSummary;
|
||||||
|
restoredAttachments: SqlRow[];
|
||||||
|
}> {
|
||||||
|
const manifestLookup = buildAttachmentBlobLookup(payload.manifest);
|
||||||
|
const restoredAttachments: SqlRow[] = [];
|
||||||
|
const skippedItems: BackupImportSkipSummary['items'] = [];
|
||||||
|
|
||||||
|
for (const row of payload.db.attachments || []) {
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
const inlinePath = `attachments/${cipherId}/${attachmentId}.bin`;
|
||||||
|
const ref = manifestLookup.get(`${cipherId}/${attachmentId}`);
|
||||||
|
if (!ref && !files[inlinePath]) {
|
||||||
|
skippedItems.push({
|
||||||
|
kind: 'attachment',
|
||||||
|
path: `attachments/${cipherId}/${attachmentId}`,
|
||||||
|
sizeBytes: Number(row.size || 0) || 0,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const bytes = files[inlinePath] || (ref ? await source.loadAttachment(ref.blobName) : null);
|
||||||
|
if (!bytes) {
|
||||||
|
skippedItems.push({
|
||||||
|
kind: 'attachment',
|
||||||
|
path: ref ? `attachments/${ref.blobName}` : inlinePath,
|
||||||
|
sizeBytes: ref?.sizeBytes || Number(row.size || 0) || 0,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await putBlobObject(env, getAttachmentObjectKey(cipherId, attachmentId), bytes, {
|
||||||
|
size: bytes.byteLength,
|
||||||
|
contentType: 'application/octet-stream',
|
||||||
|
});
|
||||||
|
restoredAttachments.push(row);
|
||||||
|
} catch {
|
||||||
|
skippedItems.push({
|
||||||
|
kind: 'attachment',
|
||||||
|
path: ref ? `attachments/${ref.blobName}` : inlinePath,
|
||||||
|
sizeBytes: bytes.byteLength,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
imported: restoredAttachments.length,
|
||||||
|
restoredAttachments,
|
||||||
|
skipped: {
|
||||||
|
reason: skippedItems.length ? ATTACHMENT_RESTORE_FAILED_REASON : null,
|
||||||
|
attachments: skippedItems.length,
|
||||||
|
items: skippedItems,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupOrphanedBlobFiles(env: Env, beforeKeys: Set<string>, afterKeys: Set<string>): Promise<void> {
|
||||||
|
const staleKeys = Array.from(beforeKeys).filter((key) => !afterKeys.has(key));
|
||||||
|
for (const key of staleKeys) {
|
||||||
|
await deleteBlobObject(env, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importBackupRows(db: D1Database, payload: BackupPayload['db'], useShadowTables: boolean = false): Promise<void> {
|
||||||
|
const tableName = (table: BackupTableName): string => (useShadowTables ? shadowTableName(table) : table);
|
||||||
|
await runInsertBatch(
|
||||||
|
db,
|
||||||
|
tableName('config'),
|
||||||
|
buildInsertStatements(db, tableName('config'), ['key', 'value'], payload.config || [], true)
|
||||||
|
);
|
||||||
|
await runInsertBatch(
|
||||||
|
db,
|
||||||
|
tableName('users'),
|
||||||
|
buildInsertStatements(
|
||||||
|
db,
|
||||||
|
tableName('users'),
|
||||||
|
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'],
|
||||||
|
payload.users || []
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await runInsertBatch(
|
||||||
|
db,
|
||||||
|
tableName('user_revisions'),
|
||||||
|
buildInsertStatements(db, tableName('user_revisions'), ['user_id', 'revision_date'], payload.user_revisions || [], true)
|
||||||
|
);
|
||||||
|
await runInsertBatch(
|
||||||
|
db,
|
||||||
|
tableName('folders'),
|
||||||
|
buildInsertStatements(db, tableName('folders'), ['id', 'user_id', 'name', 'created_at', 'updated_at'], payload.folders || [])
|
||||||
|
);
|
||||||
|
await runInsertBatch(
|
||||||
|
db,
|
||||||
|
tableName('ciphers'),
|
||||||
|
buildInsertStatements(
|
||||||
|
db,
|
||||||
|
tableName('ciphers'),
|
||||||
|
['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'archived_at', 'deleted_at'],
|
||||||
|
payload.ciphers || []
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await runInsertBatch(
|
||||||
|
db,
|
||||||
|
tableName('attachments'),
|
||||||
|
buildInsertStatements(db, tableName('attachments'), ['id', 'cipher_id', 'file_name', 'size', 'size_name', 'key'], payload.attachments || [])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importBackupArchiveBytes(
|
||||||
|
archiveBytes: Uint8Array,
|
||||||
|
env: Env,
|
||||||
|
actorUserId: string,
|
||||||
|
replaceExisting: boolean,
|
||||||
|
progress?: BackupRestoreProgressReporter,
|
||||||
|
fileName: string = 'nodewarden_backup.zip'
|
||||||
|
): Promise<BackupImportExecutionResult> {
|
||||||
|
const parsed = parseBackupArchive(archiveBytes);
|
||||||
|
validateBackupPayloadContents(parsed.payload, parsed.files);
|
||||||
|
const prepared = prepareImportPayloadForTarget(env, parsed.payload, parsed.files);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureImportTargetIsFresh(env.DB);
|
||||||
|
} catch (error) {
|
||||||
|
if (!replaceExisting) {
|
||||||
|
throw error instanceof Error ? error : new Error('Backup import requires a fresh instance');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await resetRestoreArtifacts(env.DB);
|
||||||
|
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
|
||||||
|
try {
|
||||||
|
await progress?.({
|
||||||
|
source: 'local',
|
||||||
|
step: 'local_create_shadow',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_local_shadow_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_local_shadow_detail',
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
await createShadowTables(env.DB);
|
||||||
|
await progress?.({
|
||||||
|
source: 'local',
|
||||||
|
step: 'local_import_data',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_local_data_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_local_data_detail',
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
const db = await importPreparedBackupRows(env.DB, prepared.payload.db, env);
|
||||||
|
await validateShadowTableCounts(env.DB, {
|
||||||
|
config: (db.config || []).length,
|
||||||
|
users: (db.users || []).length,
|
||||||
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
folders: (db.folders || []).length,
|
||||||
|
ciphers: (db.ciphers || []).length,
|
||||||
|
attachments: (db.attachments || []).length,
|
||||||
|
});
|
||||||
|
|
||||||
|
await progress?.({
|
||||||
|
source: 'local',
|
||||||
|
step: 'local_restore_files',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_local_files_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_local_files_detail',
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
const restored = await restoreBlobFiles(env, db, parsed.files);
|
||||||
|
const restoredAttachmentKeys = new Set((restored.restoredAttachments || []).map(attachmentRowKey));
|
||||||
|
const failedRestoreRows = (db.attachments || []).filter((row) => !restoredAttachmentKeys.has(attachmentRowKey(row)));
|
||||||
|
await removeAttachmentRows(env.DB, failedRestoreRows, true).catch(() => undefined);
|
||||||
|
await validateShadowTableCounts(env.DB, {
|
||||||
|
config: (db.config || []).length,
|
||||||
|
users: (db.users || []).length,
|
||||||
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
folders: (db.folders || []).length,
|
||||||
|
ciphers: (db.ciphers || []).length,
|
||||||
|
attachments: restored.restoredAttachments.length,
|
||||||
|
});
|
||||||
|
await progress?.({
|
||||||
|
source: 'local',
|
||||||
|
step: 'local_finalize',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
await swapShadowTablesIntoPlace(env.DB);
|
||||||
|
await resetRestoreArtifacts(env.DB).catch(() => undefined);
|
||||||
|
if (replaceExisting && previousBlobKeys.size) {
|
||||||
|
const nextBlobKeys = await collectCurrentBlobKeys(env.DB).catch(() => null);
|
||||||
|
if (nextBlobKeys) {
|
||||||
|
await cleanupOrphanedBlobFiles(env, previousBlobKeys, nextBlobKeys).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await progress?.({
|
||||||
|
source: 'local',
|
||||||
|
step: 'local_complete',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
|
||||||
|
replaceExisting,
|
||||||
|
done: true,
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
auditActorUserId: (db.users || []).some((row) => String(row.id || '').trim() === actorUserId) ? actorUserId : null,
|
||||||
|
result: {
|
||||||
|
object: 'instance-backup-import',
|
||||||
|
imported: {
|
||||||
|
config: (db.config || []).length,
|
||||||
|
users: (db.users || []).length,
|
||||||
|
userRevisions: (db.user_revisions || []).length,
|
||||||
|
folders: (db.folders || []).length,
|
||||||
|
ciphers: (db.ciphers || []).length,
|
||||||
|
attachments: restored.restoredAttachments.length,
|
||||||
|
attachmentFiles: restored.imported,
|
||||||
|
},
|
||||||
|
skipped: {
|
||||||
|
reason: restored.skipped.reason || prepared.skipped.reason,
|
||||||
|
attachments: prepared.skipped.attachments + restored.skipped.attachments,
|
||||||
|
items: [...prepared.skipped.items, ...restored.skipped.items],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await progress?.({
|
||||||
|
source: 'local',
|
||||||
|
step: 'local_failed',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
|
||||||
|
replaceExisting,
|
||||||
|
done: true,
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
await resetRestoreArtifacts(env.DB).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importRemoteBackupArchiveBytes(
|
||||||
|
archiveBytes: Uint8Array,
|
||||||
|
env: Env,
|
||||||
|
actorUserId: string,
|
||||||
|
replaceExisting: boolean,
|
||||||
|
source: RemoteAttachmentSource,
|
||||||
|
progress?: BackupRestoreProgressReporter,
|
||||||
|
fileName: string = 'nodewarden_backup.zip'
|
||||||
|
): Promise<BackupImportExecutionResult> {
|
||||||
|
const parsed = parseBackupArchive(archiveBytes, { allowExternalAttachmentBlobs: true });
|
||||||
|
const preparedRemote = await prepareRemoteAttachmentPayload(env, parsed.payload, parsed.files, source);
|
||||||
|
validateBackupPayloadContents(preparedRemote.payload, parsed.files, { allowExternalAttachmentBlobs: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureImportTargetIsFresh(env.DB);
|
||||||
|
} catch (error) {
|
||||||
|
if (!replaceExisting) {
|
||||||
|
throw error instanceof Error ? error : new Error('Backup import requires a fresh instance');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await resetRestoreArtifacts(env.DB);
|
||||||
|
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
|
||||||
|
try {
|
||||||
|
await progress?.({
|
||||||
|
source: 'remote',
|
||||||
|
step: 'remote_create_shadow',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_remote_shadow_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_remote_shadow_detail',
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
await createShadowTables(env.DB);
|
||||||
|
await progress?.({
|
||||||
|
source: 'remote',
|
||||||
|
step: 'remote_import_data',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_remote_data_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_remote_data_detail',
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
const db = await importPreparedBackupRows(env.DB, preparedRemote.payload.db, env);
|
||||||
|
await validateShadowTableCounts(env.DB, {
|
||||||
|
config: (db.config || []).length,
|
||||||
|
users: (db.users || []).length,
|
||||||
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
folders: (db.folders || []).length,
|
||||||
|
ciphers: (db.ciphers || []).length,
|
||||||
|
attachments: (db.attachments || []).length,
|
||||||
|
});
|
||||||
|
|
||||||
|
await progress?.({
|
||||||
|
source: 'remote',
|
||||||
|
step: 'remote_restore_files',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_remote_files_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_remote_files_detail',
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
const restored = await restoreRemoteAttachmentFiles(env, preparedRemote.payload, parsed.files, source);
|
||||||
|
const restoredAttachmentKeys = new Set((restored.restoredAttachments || []).map(attachmentRowKey));
|
||||||
|
const failedRestoreRows = (db.attachments || []).filter((row) => !restoredAttachmentKeys.has(attachmentRowKey(row)));
|
||||||
|
await removeAttachmentRows(env.DB, failedRestoreRows, true).catch(() => undefined);
|
||||||
|
await validateShadowTableCounts(env.DB, {
|
||||||
|
config: (db.config || []).length,
|
||||||
|
users: (db.users || []).length,
|
||||||
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
folders: (db.folders || []).length,
|
||||||
|
ciphers: (db.ciphers || []).length,
|
||||||
|
attachments: restored.restoredAttachments.length,
|
||||||
|
});
|
||||||
|
await progress?.({
|
||||||
|
source: 'remote',
|
||||||
|
step: 'remote_finalize',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_remote_finalize_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_remote_finalize_detail',
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
await swapShadowTablesIntoPlace(env.DB);
|
||||||
|
await resetRestoreArtifacts(env.DB).catch(() => undefined);
|
||||||
|
|
||||||
|
if (replaceExisting && previousBlobKeys.size) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import type { Env, User } from '../types';
|
||||||
|
|
||||||
|
const RUNTIME_SALT = 'nodewarden.backup-settings.runtime.v2';
|
||||||
|
const RUNTIME_INFO = 'runtime';
|
||||||
|
const PORTABLE_ALGORITHM = 'RSA-OAEP';
|
||||||
|
const PORTABLE_HASH = 'SHA-1';
|
||||||
|
const AES_GCM_ALGORITHM = 'AES-GCM';
|
||||||
|
const AES_GCM_IV_BYTES = 12;
|
||||||
|
const PORTABLE_DEK_BYTES = 32;
|
||||||
|
|
||||||
|
export interface BackupSettingsRuntimeEnvelope {
|
||||||
|
iv: string;
|
||||||
|
ciphertext: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupSettingsPortableWrap {
|
||||||
|
userId: string;
|
||||||
|
wrappedKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupSettingsPortableEnvelope {
|
||||||
|
iv: string;
|
||||||
|
ciphertext: string;
|
||||||
|
wraps: BackupSettingsPortableWrap[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupSettingsEnvelopeV2 {
|
||||||
|
version: 2;
|
||||||
|
runtime: BackupSettingsRuntimeEnvelope;
|
||||||
|
portable: BackupSettingsPortableEnvelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToBase64(bytes: Uint8Array): string {
|
||||||
|
let text = '';
|
||||||
|
for (let index = 0; index < bytes.length; index += 1) {
|
||||||
|
text += String.fromCharCode(bytes[index]);
|
||||||
|
}
|
||||||
|
return btoa(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToBytes(value: string): Uint8Array {
|
||||||
|
const normalized = String(value || '').trim();
|
||||||
|
const binary = atob(normalized);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let index = 0; index < binary.length; index += 1) {
|
||||||
|
bytes[index] = binary.charCodeAt(index);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deriveRuntimeKey(secret: string): Promise<CryptoKey> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const keyMaterial = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(secret),
|
||||||
|
'HKDF',
|
||||||
|
false,
|
||||||
|
['deriveBits']
|
||||||
|
);
|
||||||
|
const bits = await crypto.subtle.deriveBits(
|
||||||
|
{
|
||||||
|
name: 'HKDF',
|
||||||
|
hash: 'SHA-256',
|
||||||
|
salt: encoder.encode(RUNTIME_SALT),
|
||||||
|
info: encoder.encode(RUNTIME_INFO),
|
||||||
|
},
|
||||||
|
keyMaterial,
|
||||||
|
256
|
||||||
|
);
|
||||||
|
return crypto.subtle.importKey('raw', bits, { name: AES_GCM_ALGORITHM }, false, ['encrypt', 'decrypt']);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function encryptAesGcm(plaintext: Uint8Array, key: CryptoKey): Promise<{ iv: Uint8Array; ciphertext: Uint8Array }> {
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_BYTES));
|
||||||
|
const ciphertext = new Uint8Array(
|
||||||
|
await crypto.subtle.encrypt(
|
||||||
|
{ name: AES_GCM_ALGORITHM, iv },
|
||||||
|
key,
|
||||||
|
plaintext
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return { iv, ciphertext };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptAesGcm(ciphertext: Uint8Array, iv: Uint8Array, key: CryptoKey): Promise<Uint8Array> {
|
||||||
|
return new Uint8Array(
|
||||||
|
await crypto.subtle.decrypt(
|
||||||
|
{ name: AES_GCM_ALGORITHM, iv },
|
||||||
|
key,
|
||||||
|
ciphertext
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importPortablePublicKey(publicKeyBase64: string): Promise<CryptoKey> {
|
||||||
|
return crypto.subtle.importKey(
|
||||||
|
'spki',
|
||||||
|
base64ToBytes(publicKeyBase64),
|
||||||
|
{ name: PORTABLE_ALGORITHM, hash: PORTABLE_HASH },
|
||||||
|
false,
|
||||||
|
['encrypt']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEligiblePortableUsers(users: Pick<User, 'id' | 'publicKey' | 'role' | 'status'>[]): Array<Pick<User, 'id' | 'publicKey'>> {
|
||||||
|
return users
|
||||||
|
.filter(
|
||||||
|
(user) =>
|
||||||
|
user.role === 'admin' &&
|
||||||
|
user.status === 'active' &&
|
||||||
|
typeof user.publicKey === 'string' &&
|
||||||
|
user.publicKey.trim().length > 0
|
||||||
|
)
|
||||||
|
.map((user) => ({
|
||||||
|
id: user.id,
|
||||||
|
publicKey: user.publicKey!,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBackupSettingsEnvelope(raw: string | null): BackupSettingsEnvelopeV2 | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
if (!isPlainObject(parsed) || Number(parsed.version) !== 2) return null;
|
||||||
|
const runtime = parsed.runtime;
|
||||||
|
const portable = parsed.portable;
|
||||||
|
if (!isPlainObject(runtime) || !isPlainObject(portable)) return null;
|
||||||
|
if (!Array.isArray(portable.wraps)) return null;
|
||||||
|
if (typeof runtime.iv !== 'string' || typeof runtime.ciphertext !== 'string') return null;
|
||||||
|
if (typeof portable.iv !== 'string' || typeof portable.ciphertext !== 'string') return null;
|
||||||
|
return {
|
||||||
|
version: 2,
|
||||||
|
runtime: {
|
||||||
|
iv: runtime.iv,
|
||||||
|
ciphertext: runtime.ciphertext,
|
||||||
|
},
|
||||||
|
portable: {
|
||||||
|
iv: portable.iv,
|
||||||
|
ciphertext: portable.ciphertext,
|
||||||
|
wraps: portable.wraps
|
||||||
|
.filter((entry): entry is Record<string, unknown> => isPlainObject(entry))
|
||||||
|
.map((entry) => ({
|
||||||
|
userId: String(entry.userId || '').trim(),
|
||||||
|
wrappedKey: String(entry.wrappedKey || '').trim(),
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.userId && entry.wrappedKey),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptBackupSettingsEnvelope(
|
||||||
|
plaintext: string,
|
||||||
|
env: Env,
|
||||||
|
users: Pick<User, 'id' | 'publicKey' | 'role' | 'status'>[]
|
||||||
|
): Promise<string> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const eligibleUsers = getEligiblePortableUsers(users);
|
||||||
|
if (!eligibleUsers.length) {
|
||||||
|
throw new Error('No active administrator public keys are available for backup settings recovery');
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeKey = await deriveRuntimeKey(env.JWT_SECRET);
|
||||||
|
const runtime = await encryptAesGcm(encoder.encode(plaintext), runtimeKey);
|
||||||
|
|
||||||
|
const portableDek = crypto.getRandomValues(new Uint8Array(PORTABLE_DEK_BYTES));
|
||||||
|
const portableKey = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
portableDek,
|
||||||
|
{ name: AES_GCM_ALGORITHM },
|
||||||
|
false,
|
||||||
|
['encrypt']
|
||||||
|
);
|
||||||
|
const portableCipher = await encryptAesGcm(encoder.encode(plaintext), portableKey);
|
||||||
|
|
||||||
|
const wraps: BackupSettingsPortableWrap[] = [];
|
||||||
|
for (const user of eligibleUsers) {
|
||||||
|
const publicKey = await importPortablePublicKey(user.publicKey!);
|
||||||
|
const wrappedKey = new Uint8Array(
|
||||||
|
await crypto.subtle.encrypt(
|
||||||
|
{ name: PORTABLE_ALGORITHM },
|
||||||
|
publicKey,
|
||||||
|
portableDek
|
||||||
|
)
|
||||||
|
);
|
||||||
|
wraps.push({
|
||||||
|
userId: user.id,
|
||||||
|
wrappedKey: bytesToBase64(wrappedKey),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope: BackupSettingsEnvelopeV2 = {
|
||||||
|
version: 2,
|
||||||
|
runtime: {
|
||||||
|
iv: bytesToBase64(runtime.iv),
|
||||||
|
ciphertext: bytesToBase64(runtime.ciphertext),
|
||||||
|
},
|
||||||
|
portable: {
|
||||||
|
iv: bytesToBase64(portableCipher.iv),
|
||||||
|
ciphertext: bytesToBase64(portableCipher.ciphertext),
|
||||||
|
wraps,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return JSON.stringify(envelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptBackupSettingsRuntime(raw: string, env: Env): Promise<string> {
|
||||||
|
const envelope = parseBackupSettingsEnvelope(raw);
|
||||||
|
if (!envelope) {
|
||||||
|
throw new Error('Backup settings envelope is invalid');
|
||||||
|
}
|
||||||
|
const runtimeKey = await deriveRuntimeKey(env.JWT_SECRET);
|
||||||
|
const plaintext = await decryptAesGcm(
|
||||||
|
base64ToBytes(envelope.runtime.ciphertext),
|
||||||
|
base64ToBytes(envelope.runtime.iv),
|
||||||
|
runtimeKey
|
||||||
|
);
|
||||||
|
return new TextDecoder().decode(plaintext);
|
||||||
|
}
|
||||||
@@ -0,0 +1,789 @@
|
|||||||
|
import {
|
||||||
|
BackupDestinationRecord,
|
||||||
|
BackupDestinationType,
|
||||||
|
E3BackupDestination,
|
||||||
|
WebDavBackupDestination,
|
||||||
|
} from './backup-config';
|
||||||
|
|
||||||
|
export interface BackupUploadResult {
|
||||||
|
provider: BackupDestinationType;
|
||||||
|
remotePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteBackupItem {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
isDirectory: boolean;
|
||||||
|
size: number | null;
|
||||||
|
modifiedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteBackupListResult {
|
||||||
|
provider: BackupDestinationType;
|
||||||
|
currentPath: string;
|
||||||
|
parentPath: string | null;
|
||||||
|
items: RemoteBackupItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteBackupFile {
|
||||||
|
provider: BackupDestinationType;
|
||||||
|
remotePath: string;
|
||||||
|
fileName: string;
|
||||||
|
contentType: string;
|
||||||
|
bytes: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteBackupFilePutOptions {
|
||||||
|
contentType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBackupArchiveName(name: string): boolean {
|
||||||
|
return /\.zip$/i.test(String(name || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodePathSegments(path: string): string {
|
||||||
|
return path
|
||||||
|
.split('/')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((segment) => encodeURIComponent(segment))
|
||||||
|
.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimSlashes(value: string): string {
|
||||||
|
let next = String(value || '');
|
||||||
|
while (next.startsWith('/')) next = next.slice(1);
|
||||||
|
while (next.endsWith('/')) next = next.slice(0, -1);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildJoinedPath(...segments: string[]): string {
|
||||||
|
return segments.map(trimSlashes).filter(Boolean).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRelativePath(path: string): string {
|
||||||
|
const normalized = trimSlashes(path).replace(/\\/g, '/');
|
||||||
|
if (!normalized) return '';
|
||||||
|
const parts = normalized.split('/').filter(Boolean);
|
||||||
|
if (parts.some((part) => part === '.' || part === '..')) {
|
||||||
|
throw new Error('Invalid remote backup path');
|
||||||
|
}
|
||||||
|
return parts.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function basename(path: string): string {
|
||||||
|
const normalized = trimSlashes(path);
|
||||||
|
if (!normalized) return '';
|
||||||
|
const parts = normalized.split('/').filter(Boolean);
|
||||||
|
return parts[parts.length - 1] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parentPath(path: string): string | null {
|
||||||
|
const normalized = normalizeRelativePath(path);
|
||||||
|
if (!normalized) return null;
|
||||||
|
const parts = normalized.split('/');
|
||||||
|
parts.pop();
|
||||||
|
return parts.length ? parts.join('/') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortRemoteItems(items: RemoteBackupItem[]): RemoteBackupItem[] {
|
||||||
|
return items.slice().sort((a, b) => {
|
||||||
|
const aIsAttachmentsDir = a.isDirectory && a.name === 'attachments';
|
||||||
|
const bIsAttachmentsDir = b.isDirectory && b.name === 'attachments';
|
||||||
|
if (aIsAttachmentsDir !== bIsAttachmentsDir) return aIsAttachmentsDir ? -1 : 1;
|
||||||
|
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
||||||
|
return a.name.localeCompare(b.name, 'en');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeXmlText(value: string): string {
|
||||||
|
return value.replace(/&(amp|lt|gt|quot|#39);/g, (_match, entity) => {
|
||||||
|
switch (entity) {
|
||||||
|
case 'amp':
|
||||||
|
return '&';
|
||||||
|
case 'lt':
|
||||||
|
return '<';
|
||||||
|
case 'gt':
|
||||||
|
return '>';
|
||||||
|
case 'quot':
|
||||||
|
return '"';
|
||||||
|
case '#39':
|
||||||
|
return "'";
|
||||||
|
default:
|
||||||
|
return _match;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHttpDate(value: string): string | null {
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractXmlBlocks(xml: string, tagName: string): string[] {
|
||||||
|
const pattern = new RegExp(`<(?:[^:>]+:)?${tagName}\\b[^>]*>([\\s\\S]*?)</(?:[^:>]+:)?${tagName}>`, 'gi');
|
||||||
|
const blocks: string[] = [];
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = pattern.exec(xml))) {
|
||||||
|
blocks.push(match[1]);
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractXmlFirst(xml: string, tagName: string): string | null {
|
||||||
|
const pattern = new RegExp(`<(?:[^:>]+:)?${tagName}\\b[^>]*>([\\s\\S]*?)</(?:[^:>]+:)?${tagName}>`, 'i');
|
||||||
|
const match = xml.match(pattern);
|
||||||
|
return match?.[1] ? decodeXmlText(match[1].trim()) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256Hex(value: Uint8Array | string): Promise<string> {
|
||||||
|
const bytes = typeof value === 'string' ? new TextEncoder().encode(value) : value;
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||||
|
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hmacSha256Raw(keyBytes: Uint8Array, message: string): Promise<Uint8Array> {
|
||||||
|
const key = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||||||
|
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message));
|
||||||
|
return new Uint8Array(signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBasicAuthHeader(username: string, password: string): string {
|
||||||
|
const token = btoa(`${username}:${password}`);
|
||||||
|
return `Basic ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCanonicalQueryString(url: URL): string {
|
||||||
|
const params = Array.from(url.searchParams.entries()).sort(([aKey, aValue], [bKey, bValue]) => {
|
||||||
|
if (aKey === bKey) return aValue.localeCompare(bValue);
|
||||||
|
return aKey.localeCompare(bKey);
|
||||||
|
});
|
||||||
|
return params
|
||||||
|
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||||
|
.join('&');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildAwsV4Authorization(
|
||||||
|
method: string,
|
||||||
|
url: URL,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
payloadHashHex: string,
|
||||||
|
accessKeyId: string,
|
||||||
|
secretAccessKey: string,
|
||||||
|
region: string
|
||||||
|
): Promise<string> {
|
||||||
|
const amzDate = headers['x-amz-date'];
|
||||||
|
const shortDate = amzDate.slice(0, 8);
|
||||||
|
const headerEntries = Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value] as const).sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
const canonicalHeaders = headerEntries
|
||||||
|
.map(([name, value]) => `${name}:${String(value).trim().replace(/\s+/g, ' ')}`)
|
||||||
|
.join('\n');
|
||||||
|
const signedHeaders = headerEntries.map(([name]) => name).join(';');
|
||||||
|
const canonicalRequest = [
|
||||||
|
method.toUpperCase(),
|
||||||
|
url.pathname || '/',
|
||||||
|
buildCanonicalQueryString(url),
|
||||||
|
`${canonicalHeaders}\n`,
|
||||||
|
signedHeaders,
|
||||||
|
payloadHashHex,
|
||||||
|
].join('\n');
|
||||||
|
const credentialScope = `${shortDate}/${region}/s3/aws4_request`;
|
||||||
|
const stringToSign = [
|
||||||
|
'AWS4-HMAC-SHA256',
|
||||||
|
amzDate,
|
||||||
|
credentialScope,
|
||||||
|
await sha256Hex(canonicalRequest),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const kDate = await hmacSha256Raw(new TextEncoder().encode(`AWS4${secretAccessKey}`), shortDate);
|
||||||
|
const kRegion = await hmacSha256Raw(kDate, region);
|
||||||
|
const kService = await hmacSha256Raw(kRegion, 's3');
|
||||||
|
const kSigning = await hmacSha256Raw(kService, 'aws4_request');
|
||||||
|
const signatureBytes = await hmacSha256Raw(kSigning, stringToSign);
|
||||||
|
const signature = Array.from(signatureBytes).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
|
|
||||||
|
return `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDestinationConfigReady(destination: BackupDestinationRecord): void {
|
||||||
|
if (destination.type === 'webdav') {
|
||||||
|
const config = destination.destination as WebDavBackupDestination;
|
||||||
|
if (!String(config.baseUrl || '').trim()) throw new Error('WebDAV server URL is required');
|
||||||
|
if (!/^https?:\/\//i.test(String(config.baseUrl || '').trim())) throw new Error('WebDAV server URL must start with http:// or https://');
|
||||||
|
if (!String(config.username || '').trim()) throw new Error('WebDAV username is required');
|
||||||
|
if (!String(config.password || '')) throw new Error('WebDAV password is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (destination.type === 'e3') {
|
||||||
|
const config = destination.destination as E3BackupDestination;
|
||||||
|
if (!String(config.endpoint || '').trim()) throw new Error('E3 endpoint is required');
|
||||||
|
if (!/^https?:\/\//i.test(String(config.endpoint || '').trim())) throw new Error('E3 endpoint must start with http:// or https://');
|
||||||
|
if (!String(config.bucket || '').trim()) throw new Error('E3 bucket is required');
|
||||||
|
if (!String(config.accessKeyId || '').trim()) throw new Error('E3 access key is required');
|
||||||
|
if (!String(config.secretAccessKey || '')) throw new Error('E3 secret key is required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWebDavUrl(baseUrl: string, relativePath: string): string {
|
||||||
|
const trimmedBase = baseUrl.replace(/\/+$/, '');
|
||||||
|
const normalized = normalizeRelativePath(relativePath);
|
||||||
|
return normalized ? `${trimmedBase}/${encodePathSegments(normalized)}` : trimmedBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
function webDavFullPath(config: WebDavBackupDestination, relativePath: string): string {
|
||||||
|
return buildJoinedPath(config.remotePath, normalizeRelativePath(relativePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureWebDavDirectory(baseUrl: string, directoryPath: string, authHeader: string): Promise<void> {
|
||||||
|
const segments = trimSlashes(directoryPath).split('/').filter(Boolean);
|
||||||
|
let current = '';
|
||||||
|
for (const segment of segments) {
|
||||||
|
current = buildJoinedPath(current, segment);
|
||||||
|
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)) continue;
|
||||||
|
throw new Error(`WebDAV directory creation failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureWebDavDirectoryCached(
|
||||||
|
baseUrl: string,
|
||||||
|
directoryPath: string,
|
||||||
|
authHeader: string,
|
||||||
|
ensuredDirectories: Set<string>
|
||||||
|
): Promise<void> {
|
||||||
|
const segments = trimSlashes(directoryPath).split('/').filter(Boolean);
|
||||||
|
let current = '';
|
||||||
|
for (const segment of segments) {
|
||||||
|
current = buildJoinedPath(current, segment);
|
||||||
|
if (ensuredDirectories.has(current)) continue;
|
||||||
|
const url = buildWebDavUrl(baseUrl, current);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'MKCOL',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if ([200, 201, 204, 301, 302, 405].includes(response.status)) {
|
||||||
|
ensuredDirectories.add(current);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(`WebDAV directory creation failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putToWebDav(
|
||||||
|
config: WebDavBackupDestination,
|
||||||
|
relativePath: string,
|
||||||
|
bytes: Uint8Array,
|
||||||
|
options: RemoteBackupFilePutOptions = {},
|
||||||
|
ensuredDirectories?: Set<string>
|
||||||
|
): Promise<void> {
|
||||||
|
const authHeader = toBasicAuthHeader(config.username, config.password);
|
||||||
|
const remoteFilePath = buildJoinedPath(config.remotePath, relativePath);
|
||||||
|
const remoteDir = parentPath(remoteFilePath);
|
||||||
|
|
||||||
|
if (remoteDir) {
|
||||||
|
if (ensuredDirectories) {
|
||||||
|
await ensureWebDavDirectoryCached(config.baseUrl, remoteDir, authHeader, ensuredDirectories);
|
||||||
|
} else {
|
||||||
|
await ensureWebDavDirectory(config.baseUrl, remoteDir, authHeader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(buildWebDavUrl(config.baseUrl, remoteFilePath), {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
'Content-Type': options.contentType || 'application/octet-stream',
|
||||||
|
'Content-Length': String(bytes.byteLength),
|
||||||
|
},
|
||||||
|
body: bytes,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`WebDAV upload failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadToWebDav(config: WebDavBackupDestination, archive: Uint8Array, fileName: string): Promise<BackupUploadResult> {
|
||||||
|
await putToWebDav(config, fileName, archive, { contentType: 'application/zip' });
|
||||||
|
return {
|
||||||
|
provider: 'webdav',
|
||||||
|
remotePath: buildJoinedPath(config.remotePath, fileName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWebDavResponsePath(baseUrl: string, href: string): string {
|
||||||
|
const base = new URL(baseUrl);
|
||||||
|
const target = new URL(href, base);
|
||||||
|
const basePath = trimSlashes(decodeURIComponent(base.pathname));
|
||||||
|
const entryPath = trimSlashes(decodeURIComponent(target.pathname));
|
||||||
|
if (!basePath) return entryPath;
|
||||||
|
if (entryPath === basePath) return '';
|
||||||
|
return entryPath.startsWith(`${basePath}/`) ? entryPath.slice(basePath.length + 1) : entryPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listWebDavEntries(config: WebDavBackupDestination, relativePath: string): Promise<RemoteBackupListResult> {
|
||||||
|
const currentPath = normalizeRelativePath(relativePath);
|
||||||
|
const targetFullPath = webDavFullPath(config, currentPath);
|
||||||
|
const authHeader = toBasicAuthHeader(config.username, config.password);
|
||||||
|
const response = await fetch(buildWebDavUrl(config.baseUrl, targetFullPath), {
|
||||||
|
method: 'PROPFIND',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
Depth: '1',
|
||||||
|
'Content-Type': 'application/xml; charset=utf-8',
|
||||||
|
},
|
||||||
|
body: `<?xml version="1.0" encoding="utf-8"?><propfind xmlns="DAV:"><prop><resourcetype/><getcontentlength/><getlastmodified/></prop></propfind>`,
|
||||||
|
});
|
||||||
|
if (response.status === 404) {
|
||||||
|
return {
|
||||||
|
provider: 'webdav',
|
||||||
|
currentPath,
|
||||||
|
parentPath: parentPath(currentPath),
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`WebDAV listing failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const xml = await response.text();
|
||||||
|
const rootFullPath = trimSlashes(config.remotePath);
|
||||||
|
const items: RemoteBackupItem[] = [];
|
||||||
|
for (const block of extractXmlBlocks(xml, 'response')) {
|
||||||
|
const href = extractXmlFirst(block, 'href');
|
||||||
|
if (!href) continue;
|
||||||
|
const fullPath = trimSlashes(parseWebDavResponsePath(config.baseUrl, href));
|
||||||
|
if (!fullPath) continue;
|
||||||
|
if (fullPath === targetFullPath) continue;
|
||||||
|
if (rootFullPath && !(fullPath === rootFullPath || fullPath.startsWith(`${rootFullPath}/`))) continue;
|
||||||
|
const relative = rootFullPath
|
||||||
|
? fullPath === rootFullPath
|
||||||
|
? ''
|
||||||
|
: fullPath.slice(rootFullPath.length + 1)
|
||||||
|
: fullPath;
|
||||||
|
if (!relative) continue;
|
||||||
|
const directParent = parentPath(relative);
|
||||||
|
if ((directParent || '') !== currentPath) continue;
|
||||||
|
|
||||||
|
const resourceTypeBlock = extractXmlFirst(block, 'resourcetype') || '';
|
||||||
|
const isDirectory = /<(?:[^:>]+:)?collection\b/i.test(resourceTypeBlock);
|
||||||
|
const sizeRaw = extractXmlFirst(block, 'getcontentlength');
|
||||||
|
const modifiedAtRaw = extractXmlFirst(block, 'getlastmodified');
|
||||||
|
items.push({
|
||||||
|
path: relative,
|
||||||
|
name: basename(relative) || relative,
|
||||||
|
isDirectory,
|
||||||
|
size: !isDirectory && sizeRaw && Number.isFinite(Number(sizeRaw)) ? Number(sizeRaw) : null,
|
||||||
|
modifiedAt: modifiedAtRaw ? parseHttpDate(modifiedAtRaw) : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: 'webdav',
|
||||||
|
currentPath,
|
||||||
|
parentPath: parentPath(currentPath),
|
||||||
|
items: sortRemoteItems(items),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFromWebDav(config: WebDavBackupDestination, relativePath: string): Promise<RemoteBackupFile> {
|
||||||
|
const normalized = normalizeRelativePath(relativePath);
|
||||||
|
if (!normalized || normalized.endsWith('/')) {
|
||||||
|
throw new Error('Please select a backup file');
|
||||||
|
}
|
||||||
|
const authHeader = toBasicAuthHeader(config.username, config.password);
|
||||||
|
const remotePath = webDavFullPath(config, normalized);
|
||||||
|
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`WebDAV download failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
provider: 'webdav',
|
||||||
|
remotePath: normalized,
|
||||||
|
fileName: basename(normalized) || 'backup.zip',
|
||||||
|
contentType: String(response.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip',
|
||||||
|
bytes: new Uint8Array(await response.arrayBuffer()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFromWebDav(config: WebDavBackupDestination, relativePath: string): Promise<void> {
|
||||||
|
const authHeader = toBasicAuthHeader(config.username, config.password);
|
||||||
|
const remotePath = webDavFullPath(config, relativePath);
|
||||||
|
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok && response.status !== 404) {
|
||||||
|
throw new Error(`WebDAV delete failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function existsInWebDav(config: WebDavBackupDestination, relativePath: string): Promise<boolean> {
|
||||||
|
const authHeader = toBasicAuthHeader(config.username, config.password);
|
||||||
|
const remotePath = webDavFullPath(config, relativePath);
|
||||||
|
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
|
||||||
|
method: 'HEAD',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (response.status === 404) return false;
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`WebDAV existence check failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function e3BucketBaseUrl(config: E3BackupDestination): URL {
|
||||||
|
return new URL(`${config.endpoint.replace(/\/+$/, '')}/${encodeURIComponent(config.bucket)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeE3ObjectKey(config: E3BackupDestination, relativePath: string): string {
|
||||||
|
return buildJoinedPath(config.rootPath, normalizeRelativePath(relativePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signedE3Request(
|
||||||
|
config: E3BackupDestination,
|
||||||
|
method: 'GET' | 'PUT' | 'DELETE' | 'HEAD',
|
||||||
|
url: URL,
|
||||||
|
body?: Uint8Array,
|
||||||
|
contentType?: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const payloadHashHex = await sha256Hex(body || new Uint8Array());
|
||||||
|
const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
host: url.host,
|
||||||
|
'x-amz-content-sha256': payloadHashHex,
|
||||||
|
'x-amz-date': amzDate,
|
||||||
|
};
|
||||||
|
if (method === 'PUT') headers['content-type'] = contentType || 'application/octet-stream';
|
||||||
|
|
||||||
|
const authorization = await buildAwsV4Authorization(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
headers,
|
||||||
|
payloadHashHex,
|
||||||
|
config.accessKeyId,
|
||||||
|
config.secretAccessKey,
|
||||||
|
config.region || 'auto'
|
||||||
|
);
|
||||||
|
|
||||||
|
return fetch(url.toString(), {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Authorization: authorization,
|
||||||
|
'X-Amz-Content-Sha256': headers['x-amz-content-sha256'],
|
||||||
|
'X-Amz-Date': headers['x-amz-date'],
|
||||||
|
...(method === 'PUT' ? { 'Content-Type': headers['content-type'] } : {}),
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putToE3(
|
||||||
|
config: E3BackupDestination,
|
||||||
|
relativePath: string,
|
||||||
|
bytes: Uint8Array,
|
||||||
|
options: RemoteBackupFilePutOptions = {}
|
||||||
|
): Promise<void> {
|
||||||
|
const objectKey = normalizeE3ObjectKey(config, relativePath);
|
||||||
|
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||||
|
const response = await signedE3Request(config, 'PUT', url, bytes, options.contentType);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`E3 upload failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadToE3(config: E3BackupDestination, archive: Uint8Array, fileName: string): Promise<BackupUploadResult> {
|
||||||
|
await putToE3(config, fileName, archive, { contentType: 'application/zip' });
|
||||||
|
return {
|
||||||
|
provider: 'e3',
|
||||||
|
remotePath: normalizeE3ObjectKey(config, fileName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listE3Entries(config: E3BackupDestination, relativePath: string): Promise<RemoteBackupListResult> {
|
||||||
|
const currentPath = normalizeRelativePath(relativePath);
|
||||||
|
const targetPrefixBase = normalizeE3ObjectKey(config, currentPath);
|
||||||
|
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
|
||||||
|
const url = e3BucketBaseUrl(config);
|
||||||
|
url.searchParams.set('list-type', '2');
|
||||||
|
url.searchParams.set('delimiter', '/');
|
||||||
|
if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
|
||||||
|
|
||||||
|
const response = await signedE3Request(config, 'GET', url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`E3 listing failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const xml = await response.text();
|
||||||
|
const rootPrefix = trimSlashes(config.rootPath);
|
||||||
|
const items: RemoteBackupItem[] = [];
|
||||||
|
|
||||||
|
for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) {
|
||||||
|
const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || '');
|
||||||
|
if (!fullPrefix) continue;
|
||||||
|
const relative = rootPrefix
|
||||||
|
? fullPrefix === rootPrefix
|
||||||
|
? ''
|
||||||
|
: fullPrefix.startsWith(`${rootPrefix}/`)
|
||||||
|
? fullPrefix.slice(rootPrefix.length + 1)
|
||||||
|
: ''
|
||||||
|
: fullPrefix;
|
||||||
|
const normalizedRelative = trimSlashes(relative);
|
||||||
|
if (!normalizedRelative) continue;
|
||||||
|
const itemPath = normalizedRelative.replace(/\/+$/, '');
|
||||||
|
if ((parentPath(itemPath) || '') !== currentPath) continue;
|
||||||
|
items.push({
|
||||||
|
path: itemPath,
|
||||||
|
name: basename(itemPath) || itemPath,
|
||||||
|
isDirectory: true,
|
||||||
|
size: null,
|
||||||
|
modifiedAt: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const content of extractXmlBlocks(xml, 'Contents')) {
|
||||||
|
const fullKey = trimSlashes(extractXmlFirst(content, 'Key') || '');
|
||||||
|
if (!fullKey || (targetPrefix && fullKey === trimSlashes(targetPrefix))) continue;
|
||||||
|
const relative = rootPrefix
|
||||||
|
? fullKey.startsWith(`${rootPrefix}/`)
|
||||||
|
? fullKey.slice(rootPrefix.length + 1)
|
||||||
|
: ''
|
||||||
|
: fullKey;
|
||||||
|
const normalizedRelative = trimSlashes(relative);
|
||||||
|
if (!normalizedRelative || (parentPath(normalizedRelative) || '') !== currentPath) continue;
|
||||||
|
items.push({
|
||||||
|
path: normalizedRelative,
|
||||||
|
name: basename(normalizedRelative) || normalizedRelative,
|
||||||
|
isDirectory: false,
|
||||||
|
size: Number(extractXmlFirst(content, 'Size') || 0) || null,
|
||||||
|
modifiedAt: parseHttpDate(extractXmlFirst(content, 'LastModified') || '') || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const deduped = new Map<string, RemoteBackupItem>();
|
||||||
|
for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item);
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: 'e3',
|
||||||
|
currentPath,
|
||||||
|
parentPath: parentPath(currentPath),
|
||||||
|
items: sortRemoteItems(Array.from(deduped.values())),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFromE3(config: E3BackupDestination, relativePath: string): Promise<RemoteBackupFile> {
|
||||||
|
const normalized = normalizeRelativePath(relativePath);
|
||||||
|
if (!normalized || normalized.endsWith('/')) {
|
||||||
|
throw new Error('Please select a backup file');
|
||||||
|
}
|
||||||
|
const objectKey = normalizeE3ObjectKey(config, normalized);
|
||||||
|
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||||
|
const response = await signedE3Request(config, 'GET', url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`E3 download failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
provider: 'e3',
|
||||||
|
remotePath: normalized,
|
||||||
|
fileName: basename(normalized) || 'backup.zip',
|
||||||
|
contentType: String(response.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip',
|
||||||
|
bytes: new Uint8Array(await response.arrayBuffer()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFromE3(config: E3BackupDestination, relativePath: string): Promise<void> {
|
||||||
|
const objectKey = normalizeE3ObjectKey(config, relativePath);
|
||||||
|
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||||
|
const response = await signedE3Request(config, 'DELETE', url);
|
||||||
|
if (!response.ok && response.status !== 404) {
|
||||||
|
throw new Error(`E3 delete failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function existsInE3(config: E3BackupDestination, relativePath: string): Promise<boolean> {
|
||||||
|
const objectKey = normalizeE3ObjectKey(config, relativePath);
|
||||||
|
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||||
|
const response = await signedE3Request(config, 'HEAD', url);
|
||||||
|
if (response.status === 404) return false;
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`E3 existence check failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfiguredDestinationAdapter {
|
||||||
|
provider: 'webdav' | 'e3';
|
||||||
|
config: WebDavBackupDestination | E3BackupDestination;
|
||||||
|
upload: (config: WebDavBackupDestination | E3BackupDestination, archive: Uint8Array, fileName: string) => Promise<BackupUploadResult>;
|
||||||
|
putFile: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions) => Promise<void>;
|
||||||
|
list: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<RemoteBackupListResult>;
|
||||||
|
download: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
|
||||||
|
deleteFile: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<void>;
|
||||||
|
exists: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteBackupTransferSession {
|
||||||
|
provider: BackupDestinationType;
|
||||||
|
uploadArchive(archive: Uint8Array, fileName: string): Promise<BackupUploadResult>;
|
||||||
|
putFile(relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions): Promise<void>;
|
||||||
|
list(relativePath: string): Promise<RemoteBackupListResult>;
|
||||||
|
download(relativePath: string): Promise<RemoteBackupFile>;
|
||||||
|
deleteFile(relativePath: string): Promise<void>;
|
||||||
|
exists(relativePath: string): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConfiguredDestinationAdapter(
|
||||||
|
destination: BackupDestinationRecord
|
||||||
|
): ConfiguredDestinationAdapter {
|
||||||
|
ensureDestinationConfigReady(destination);
|
||||||
|
|
||||||
|
if (destination.type === 'webdav') {
|
||||||
|
return {
|
||||||
|
provider: 'webdav',
|
||||||
|
config: destination.destination as WebDavBackupDestination,
|
||||||
|
upload: (config, archive, fileName) => uploadToWebDav(config as WebDavBackupDestination, archive, fileName),
|
||||||
|
putFile: (config, relativePath, bytes, options) => putToWebDav(config as WebDavBackupDestination, relativePath, bytes, options),
|
||||||
|
list: (config, relativePath) => listWebDavEntries(config as WebDavBackupDestination, relativePath),
|
||||||
|
download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath),
|
||||||
|
deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath),
|
||||||
|
exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (destination.type === 'e3') {
|
||||||
|
return {
|
||||||
|
provider: 'e3',
|
||||||
|
config: destination.destination as E3BackupDestination,
|
||||||
|
upload: (config, archive, fileName) => uploadToE3(config as E3BackupDestination, archive, fileName),
|
||||||
|
putFile: (config, relativePath, bytes, options) => putToE3(config as E3BackupDestination, relativePath, bytes, options),
|
||||||
|
list: (config, relativePath) => listE3Entries(config as E3BackupDestination, relativePath),
|
||||||
|
download: (config, relativePath) => downloadFromE3(config as E3BackupDestination, relativePath),
|
||||||
|
deleteFile: (config, relativePath) => deleteFromE3(config as E3BackupDestination, relativePath),
|
||||||
|
exists: (config, relativePath) => existsInE3(config as E3BackupDestination, relativePath),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unsupported backup destination type');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRemoteBackupTransferSession(destination: BackupDestinationRecord): RemoteBackupTransferSession {
|
||||||
|
const adapter = resolveConfiguredDestinationAdapter(destination);
|
||||||
|
const ensuredDirectories = adapter.provider === 'webdav' ? new Set<string>() : null;
|
||||||
|
|
||||||
|
const putFile = async (relativePath: string, bytes: Uint8Array, options: RemoteBackupFilePutOptions = {}): Promise<void> => {
|
||||||
|
const normalized = normalizeRelativePath(relativePath);
|
||||||
|
if (adapter.provider === 'webdav' && ensuredDirectories) {
|
||||||
|
await putToWebDav(adapter.config as WebDavBackupDestination, normalized, bytes, options, ensuredDirectories);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await adapter.putFile(adapter.config, normalized, bytes, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: adapter.provider,
|
||||||
|
uploadArchive: async (archive: Uint8Array, fileName: string) => {
|
||||||
|
await putFile(fileName, archive, { contentType: 'application/zip' });
|
||||||
|
return {
|
||||||
|
provider: adapter.provider,
|
||||||
|
remotePath: adapter.provider === 'webdav'
|
||||||
|
? buildJoinedPath((adapter.config as WebDavBackupDestination).remotePath, fileName)
|
||||||
|
: normalizeE3ObjectKey(adapter.config as E3BackupDestination, fileName),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
putFile,
|
||||||
|
list: async (relativePath: string) => adapter.list(adapter.config, relativePath),
|
||||||
|
download: async (relativePath: string) => adapter.download(adapter.config, relativePath),
|
||||||
|
deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)),
|
||||||
|
exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadBackupArchive(
|
||||||
|
destination: BackupDestinationRecord,
|
||||||
|
archive: Uint8Array,
|
||||||
|
fileName: string
|
||||||
|
): Promise<BackupUploadResult> {
|
||||||
|
return createRemoteBackupTransferSession(destination).uploadArchive(archive, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRemoteBackupEntries(destination: BackupDestinationRecord, relativePath: string): Promise<RemoteBackupListResult> {
|
||||||
|
return createRemoteBackupTransferSession(destination).list(relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise<RemoteBackupFile> {
|
||||||
|
return createRemoteBackupTransferSession(destination).download(relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise<void> {
|
||||||
|
const normalized = ensureRemoteRestoreCandidate(relativePath);
|
||||||
|
await createRemoteBackupTransferSession(destination).deleteFile(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function remoteBackupFileExists(destination: BackupDestinationRecord, relativePath: string): Promise<boolean> {
|
||||||
|
const normalized = normalizeRelativePath(relativePath);
|
||||||
|
return createRemoteBackupTransferSession(destination).exists(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadRemoteBackupFile(
|
||||||
|
destination: BackupDestinationRecord,
|
||||||
|
relativePath: string,
|
||||||
|
bytes: Uint8Array,
|
||||||
|
options: RemoteBackupFilePutOptions = {}
|
||||||
|
): Promise<void> {
|
||||||
|
const normalized = normalizeRelativePath(relativePath);
|
||||||
|
await createRemoteBackupTransferSession(destination).putFile(normalized, bytes, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareBackupItemsByRecency(a: RemoteBackupItem, b: RemoteBackupItem, preferredFileName?: string): number {
|
||||||
|
if (preferredFileName) {
|
||||||
|
const aPreferred = a.name === preferredFileName ? 1 : 0;
|
||||||
|
const bPreferred = b.name === preferredFileName ? 1 : 0;
|
||||||
|
if (aPreferred !== bPreferred) return bPreferred - aPreferred;
|
||||||
|
}
|
||||||
|
const aTime = a.modifiedAt ? new Date(a.modifiedAt).getTime() : 0;
|
||||||
|
const bTime = b.modifiedAt ? new Date(b.modifiedAt).getTime() : 0;
|
||||||
|
if (aTime !== bTime) return bTime - aTime;
|
||||||
|
return b.name.localeCompare(a.name, 'en');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pruneRemoteBackupArchives(
|
||||||
|
destination: BackupDestinationRecord,
|
||||||
|
retentionCount: number | null,
|
||||||
|
preferredFileName?: string
|
||||||
|
): Promise<number> {
|
||||||
|
if (retentionCount === null) return 0;
|
||||||
|
const adapter = resolveConfiguredDestinationAdapter(destination);
|
||||||
|
const listing = await adapter.list(adapter.config, '');
|
||||||
|
const backupFiles = listing.items
|
||||||
|
.filter((item) => !item.isDirectory && isBackupArchiveName(item.name))
|
||||||
|
.sort((a, b) => compareBackupItemsByRecency(a, b, preferredFileName));
|
||||||
|
if (backupFiles.length <= retentionCount) return 0;
|
||||||
|
for (const item of backupFiles.slice(retentionCount)) {
|
||||||
|
await adapter.deleteFile(adapter.config, item.path);
|
||||||
|
}
|
||||||
|
return backupFiles.length - retentionCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureRemoteRestoreCandidate(relativePath: string): string {
|
||||||
|
const normalized = normalizeRelativePath(relativePath);
|
||||||
|
if (!normalized || !/\.zip$/i.test(normalized)) {
|
||||||
|
throw new Error('Please select a backup ZIP file');
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { Env } from '../types';
|
||||||
|
|
||||||
|
const DEFAULT_CONTENT_TYPE = 'application/octet-stream';
|
||||||
|
export const KV_MAX_OBJECT_BYTES = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
interface KVBlobMetadata {
|
||||||
|
size?: number;
|
||||||
|
contentType?: string;
|
||||||
|
customMetadata?: Record<string, string> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlobObject {
|
||||||
|
body: ReadableStream | null;
|
||||||
|
size: number;
|
||||||
|
contentType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PutBlobOptions {
|
||||||
|
size: number;
|
||||||
|
contentType?: string;
|
||||||
|
customMetadata?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasR2Storage(env: Env): env is Env & { ATTACHMENTS: R2Bucket } {
|
||||||
|
return !!env.ATTACHMENTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasKvStorage(env: Env): env is Env & { ATTACHMENTS_KV: KVNamespace } {
|
||||||
|
return !!env.ATTACHMENTS_KV;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlobStorageKind(env: Env): 'r2' | 'kv' | null {
|
||||||
|
// Keep R2 as preferred backend when both are bound.
|
||||||
|
if (hasR2Storage(env)) return 'r2';
|
||||||
|
if (hasKvStorage(env)) return 'kv';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlobStorageMaxBytes(env: Env, configuredLimit: number): number {
|
||||||
|
if (getBlobStorageKind(env) === 'kv') {
|
||||||
|
return Math.min(configuredLimit, KV_MAX_OBJECT_BYTES);
|
||||||
|
}
|
||||||
|
return configuredLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAttachmentObjectKey(cipherId: string, attachmentId: string): string {
|
||||||
|
return `${cipherId}/${attachmentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSendFileObjectKey(sendId: string, fileId: string): string {
|
||||||
|
return `sends/${sendId}/${fileId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putBlobObject(
|
||||||
|
env: Env,
|
||||||
|
key: string,
|
||||||
|
value: string | ArrayBuffer | ArrayBufferView | ReadableStream,
|
||||||
|
options: PutBlobOptions
|
||||||
|
): Promise<void> {
|
||||||
|
const contentType = options.contentType || DEFAULT_CONTENT_TYPE;
|
||||||
|
|
||||||
|
if (hasR2Storage(env)) {
|
||||||
|
await env.ATTACHMENTS.put(key, value, {
|
||||||
|
httpMetadata: { contentType },
|
||||||
|
customMetadata: options.customMetadata,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasKvStorage(env)) {
|
||||||
|
if (options.size > KV_MAX_OBJECT_BYTES) {
|
||||||
|
throw new Error('KV object too large');
|
||||||
|
}
|
||||||
|
const metadata: KVBlobMetadata = {
|
||||||
|
size: options.size,
|
||||||
|
contentType,
|
||||||
|
customMetadata: options.customMetadata || null,
|
||||||
|
};
|
||||||
|
await env.ATTACHMENTS_KV.put(key, value, { metadata });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Attachment storage is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBlobObject(env: Env, key: string): Promise<BlobObject | null> {
|
||||||
|
if (hasR2Storage(env)) {
|
||||||
|
const object = await env.ATTACHMENTS.get(key);
|
||||||
|
if (!object) return null;
|
||||||
|
return {
|
||||||
|
body: object.body,
|
||||||
|
size: Number(object.size) || 0,
|
||||||
|
contentType: object.httpMetadata?.contentType || DEFAULT_CONTENT_TYPE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasKvStorage(env)) {
|
||||||
|
const result = await env.ATTACHMENTS_KV.getWithMetadata<KVBlobMetadata>(key, 'arrayBuffer');
|
||||||
|
if (!result.value) return null;
|
||||||
|
|
||||||
|
const sizeFromMeta = Number(result.metadata?.size || 0);
|
||||||
|
const size = sizeFromMeta > 0 ? sizeFromMeta : result.value.byteLength;
|
||||||
|
const body = new Response(result.value).body;
|
||||||
|
|
||||||
|
return {
|
||||||
|
body,
|
||||||
|
size,
|
||||||
|
contentType: result.metadata?.contentType || DEFAULT_CONTENT_TYPE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBlobObject(env: Env, key: string): Promise<void> {
|
||||||
|
if (hasR2Storage(env)) {
|
||||||
|
await env.ATTACHMENTS.delete(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasKvStorage(env)) {
|
||||||
|
await env.ATTACHMENTS_KV.delete(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +1,76 @@
|
|||||||
// D1-backed rate limiting.
|
import { LIMITS } from '../config/limits';
|
||||||
// Notes:
|
|
||||||
// - Login attempts are tracked per email.
|
// Rate limiting service.
|
||||||
// - API rate is tracked per identifier per fixed window.
|
// - Login attempts: D1-backed (low volume, security-critical, needs cross-colo persistence).
|
||||||
|
// - API budgets: Cloudflare Cache API (high volume, auto-expires, zero D1 writes).
|
||||||
|
|
||||||
// Rate limit configuration
|
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
LOGIN_MAX_ATTEMPTS: 15,
|
LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts,
|
||||||
LOGIN_LOCKOUT_MINUTES: 5,
|
LOGIN_LOCKOUT_MINUTES: LIMITS.rateLimit.loginLockoutMinutes,
|
||||||
|
API_WINDOW_SECONDS: LIMITS.rateLimit.apiWindowSeconds,
|
||||||
API_REQUESTS_PER_MINUTE: 300,
|
|
||||||
API_WINDOW_SECONDS: 60,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export class RateLimitService {
|
export class RateLimitService {
|
||||||
|
private static loginIpTableReady = false;
|
||||||
|
private static lastLoginIpCleanupAt = 0;
|
||||||
|
|
||||||
|
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.rateLimit.cleanupProbability;
|
||||||
|
private static readonly LOGIN_IP_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs;
|
||||||
|
private static readonly LOGIN_IP_RETENTION_MS = LIMITS.rateLimit.loginIpRetentionMs;
|
||||||
|
|
||||||
constructor(private db: D1Database) {}
|
constructor(private db: D1Database) {}
|
||||||
|
|
||||||
async checkLoginAttempt(email: string): Promise<{
|
private shouldRunCleanup(lastRunAt: number, intervalMs: number): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastRunAt < intervalMs) return false;
|
||||||
|
return Math.random() < RateLimitService.PERIODIC_CLEANUP_PROBABILITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async maybeCleanupLoginAttemptsIp(nowMs: number): Promise<void> {
|
||||||
|
if (!this.shouldRunCleanup(RateLimitService.lastLoginIpCleanupAt, RateLimitService.LOGIN_IP_CLEANUP_INTERVAL_MS)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cutoff = nowMs - RateLimitService.LOGIN_IP_RETENTION_MS;
|
||||||
|
await this.db
|
||||||
|
.prepare(
|
||||||
|
'DELETE FROM login_attempts_ip WHERE updated_at < ? AND (locked_until IS NULL OR locked_until < ?)'
|
||||||
|
)
|
||||||
|
.bind(cutoff, nowMs)
|
||||||
|
.run();
|
||||||
|
RateLimitService.lastLoginIpCleanupAt = nowMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureLoginIpTable(): Promise<void> {
|
||||||
|
if (RateLimitService.loginIpTableReady) return;
|
||||||
|
|
||||||
|
await this.db
|
||||||
|
.prepare(
|
||||||
|
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
|
||||||
|
'ip TEXT PRIMARY KEY, ' +
|
||||||
|
'attempts INTEGER NOT NULL, ' +
|
||||||
|
'locked_until INTEGER, ' +
|
||||||
|
'updated_at INTEGER NOT NULL' +
|
||||||
|
')'
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
RateLimitService.loginIpTableReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkLoginAttempt(ip: string): Promise<{
|
||||||
allowed: boolean;
|
allowed: boolean;
|
||||||
remainingAttempts: number;
|
remainingAttempts: number;
|
||||||
retryAfterSeconds?: number;
|
retryAfterSeconds?: number;
|
||||||
}> {
|
}> {
|
||||||
const key = email.toLowerCase();
|
await this.ensureLoginIpTable();
|
||||||
|
|
||||||
|
const key = ip.trim() || 'unknown';
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
await this.maybeCleanupLoginAttemptsIp(now);
|
||||||
|
|
||||||
const row = await this.db
|
const row = await this.db
|
||||||
.prepare('SELECT attempts, locked_until FROM login_attempts WHERE email = ?')
|
.prepare('SELECT attempts, locked_until FROM login_attempts_ip WHERE ip = ?')
|
||||||
.bind(key)
|
.bind(key)
|
||||||
.first<{ attempts: number; locked_until: number | null }>();
|
.first<{ attempts: number; locked_until: number | null }>();
|
||||||
|
|
||||||
@@ -41,7 +87,7 @@ export class RateLimitService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (row.locked_until && row.locked_until <= now) {
|
if (row.locked_until && row.locked_until <= now) {
|
||||||
await this.db.prepare('DELETE FROM login_attempts WHERE email = ?').bind(key).run();
|
await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run();
|
||||||
return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS };
|
return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,23 +95,26 @@ export class RateLimitService {
|
|||||||
return { allowed: true, remainingAttempts };
|
return { allowed: true, remainingAttempts };
|
||||||
}
|
}
|
||||||
|
|
||||||
async recordFailedLogin(email: string): Promise<{ locked: boolean; retryAfterSeconds?: number }> {
|
async recordFailedLogin(ip: string): Promise<{ locked: boolean; retryAfterSeconds?: number }> {
|
||||||
const key = email.toLowerCase();
|
await this.ensureLoginIpTable();
|
||||||
|
|
||||||
|
const key = ip.trim() || 'unknown';
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
await this.maybeCleanupLoginAttemptsIp(now);
|
||||||
|
|
||||||
// D1 in Workers forbids raw BEGIN/COMMIT statements.
|
// D1 in Workers forbids raw BEGIN/COMMIT statements.
|
||||||
// Use a single atomic UPSERT to increment attempts.
|
// Use a single atomic UPSERT to increment attempts.
|
||||||
// This is concurrency-safe because the row is keyed by email.
|
// This is concurrency-safe because the row is keyed by IP.
|
||||||
await this.db
|
await this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
'INSERT INTO login_attempts(email, attempts, locked_until, updated_at) VALUES(?, 1, NULL, ?) ' +
|
'INSERT INTO login_attempts_ip(ip, attempts, locked_until, updated_at) VALUES(?, 1, NULL, ?) ' +
|
||||||
'ON CONFLICT(email) DO UPDATE SET attempts = attempts + 1, updated_at = excluded.updated_at'
|
'ON CONFLICT(ip) DO UPDATE SET attempts = attempts + 1, updated_at = excluded.updated_at'
|
||||||
)
|
)
|
||||||
.bind(key, now)
|
.bind(key, now)
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
const row = await this.db
|
const row = await this.db
|
||||||
.prepare('SELECT attempts FROM login_attempts WHERE email = ?')
|
.prepare('SELECT attempts FROM login_attempts_ip WHERE ip = ?')
|
||||||
.bind(key)
|
.bind(key)
|
||||||
.first<{ attempts: number }>();
|
.first<{ attempts: number }>();
|
||||||
|
|
||||||
@@ -73,7 +122,7 @@ export class RateLimitService {
|
|||||||
if (attempts >= CONFIG.LOGIN_MAX_ATTEMPTS) {
|
if (attempts >= CONFIG.LOGIN_MAX_ATTEMPTS) {
|
||||||
const lockedUntil = now + CONFIG.LOGIN_LOCKOUT_MINUTES * 60 * 1000;
|
const lockedUntil = now + CONFIG.LOGIN_LOCKOUT_MINUTES * 60 * 1000;
|
||||||
await this.db
|
await this.db
|
||||||
.prepare('UPDATE login_attempts SET locked_until = ?, updated_at = ? WHERE email = ?')
|
.prepare('UPDATE login_attempts_ip SET locked_until = ?, updated_at = ? WHERE ip = ?')
|
||||||
.bind(lockedUntil, now, key)
|
.bind(lockedUntil, now, key)
|
||||||
.run();
|
.run();
|
||||||
return { locked: true, retryAfterSeconds: CONFIG.LOGIN_LOCKOUT_MINUTES * 60 };
|
return { locked: true, retryAfterSeconds: CONFIG.LOGIN_LOCKOUT_MINUTES * 60 };
|
||||||
@@ -82,56 +131,227 @@ export class RateLimitService {
|
|||||||
return { locked: false };
|
return { locked: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
async clearLoginAttempts(email: string): Promise<void> {
|
async clearLoginAttempts(ip: string): Promise<void> {
|
||||||
await this.db.prepare('DELETE FROM login_attempts WHERE email = ?').bind(email.toLowerCase()).run();
|
await this.ensureLoginIpTable();
|
||||||
|
const key = ip.trim() || 'unknown';
|
||||||
|
await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkApiRateLimit(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
// Cache API-backed fixed-window rate limiter.
|
||||||
|
// Uses Cloudflare edge cache instead of D1 — zero database writes, auto-expires via TTL.
|
||||||
|
// Per-colo isolation is acceptable (matches Cloudflare's own rate limiting behaviour).
|
||||||
|
private async consumeFixedWindowBudget(
|
||||||
|
identifier: string,
|
||||||
|
maxRequests: number,
|
||||||
|
windowSeconds: number
|
||||||
|
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
const windowStart = nowSec - (nowSec % CONFIG.API_WINDOW_SECONDS);
|
const windowStart = nowSec - (nowSec % windowSeconds);
|
||||||
const windowEnd = windowStart + CONFIG.API_WINDOW_SECONDS;
|
const windowEnd = windowStart + windowSeconds;
|
||||||
|
const ttl = Math.max(1, windowEnd - nowSec);
|
||||||
|
|
||||||
const row = await this.db
|
const cache = await caches.open('rate-limit');
|
||||||
.prepare('SELECT count FROM api_rate_limits WHERE identifier = ? AND window_start = ?')
|
const cacheKey = new Request(`https://rl/${identifier}/${windowStart}`);
|
||||||
.bind(identifier, windowStart)
|
|
||||||
.first<{ count: number }>();
|
|
||||||
|
|
||||||
const count = row?.count || 0;
|
const cached = await cache.match(cacheKey);
|
||||||
if (count >= CONFIG.API_REQUESTS_PER_MINUTE) {
|
let count = 0;
|
||||||
return {
|
if (cached) {
|
||||||
allowed: false,
|
count = parseInt(await cached.text(), 10) || 0;
|
||||||
remaining: 0,
|
}
|
||||||
retryAfterSeconds: windowEnd - nowSec,
|
|
||||||
|
if (count >= maxRequests) {
|
||||||
|
return { allowed: false, remaining: 0, retryAfterSeconds: ttl };
|
||||||
|
}
|
||||||
|
|
||||||
|
count++;
|
||||||
|
await cache.put(
|
||||||
|
cacheKey,
|
||||||
|
new Response(String(count), {
|
||||||
|
headers: { 'Cache-Control': `public, max-age=${ttl}` },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return { allowed: true, remaining: Math.max(0, maxRequests - count) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// General-purpose fixed-window budget.
|
||||||
|
// Callers supply an identifier (must be unique per rate-limit category) and the
|
||||||
|
// per-window maximum. This single method replaces all previous specialised
|
||||||
|
// budget helpers (write / sync / knownDevice / publicSend).
|
||||||
|
async consumeBudget(
|
||||||
|
identifier: string,
|
||||||
|
maxRequests: number
|
||||||
|
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
||||||
|
return this.consumeFixedWindowBudget(identifier, maxRequests, CONFIG.API_WINDOW_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async consumeBudgetWithWindow(
|
||||||
|
identifier: string,
|
||||||
|
maxRequests: number,
|
||||||
|
windowSeconds: number
|
||||||
|
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
||||||
|
return this.consumeFixedWindowBudget(identifier, maxRequests, windowSeconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIpv4Octets(input: string): number[] | null {
|
||||||
|
const parts = input.split('.');
|
||||||
|
if (parts.length !== 4) return null;
|
||||||
|
|
||||||
|
const octets: number[] = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!/^\d{1,3}$/.test(part)) return null;
|
||||||
|
const value = Number(part);
|
||||||
|
if (!Number.isInteger(value) || value < 0 || value > 255) return null;
|
||||||
|
octets.push(value);
|
||||||
|
}
|
||||||
|
return octets;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIpv6Hextets(input: string): number[] | null {
|
||||||
|
let value = input.trim().toLowerCase();
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
if (value.startsWith('[') && value.endsWith(']')) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
const zoneIndex = value.indexOf('%');
|
||||||
|
if (zoneIndex >= 0) {
|
||||||
|
value = value.slice(0, zoneIndex);
|
||||||
|
}
|
||||||
|
if (!value.includes(':')) return null;
|
||||||
|
|
||||||
|
// Handle IPv4-mapped tail (e.g. ::ffff:192.0.2.1).
|
||||||
|
if (value.includes('.')) {
|
||||||
|
const lastColon = value.lastIndexOf(':');
|
||||||
|
if (lastColon < 0) return null;
|
||||||
|
const ipv4Tail = value.slice(lastColon + 1);
|
||||||
|
const octets = parseIpv4Octets(ipv4Tail);
|
||||||
|
if (!octets) return null;
|
||||||
|
const high = ((octets[0] << 8) | octets[1]).toString(16);
|
||||||
|
const low = ((octets[2] << 8) | octets[3]).toString(16);
|
||||||
|
value = `${value.slice(0, lastColon)}:${high}:${low}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doubleColon = value.indexOf('::');
|
||||||
|
if (doubleColon !== value.lastIndexOf('::')) return null;
|
||||||
|
|
||||||
|
const parsePart = (part: string): number | null => {
|
||||||
|
if (!/^[0-9a-f]{1,4}$/.test(part)) return null;
|
||||||
|
const n = parseInt(part, 16);
|
||||||
|
return Number.isNaN(n) ? null : n;
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
const parseParts = (parts: string[]): number[] | null => {
|
||||||
allowed: true,
|
const out: number[] = [];
|
||||||
remaining: CONFIG.API_REQUESTS_PER_MINUTE - count,
|
for (const p of parts) {
|
||||||
|
if (!p) return null;
|
||||||
|
const n = parsePart(p);
|
||||||
|
if (n === null) return null;
|
||||||
|
out.push(n);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (doubleColon >= 0) {
|
||||||
|
const [headRaw, tailRaw] = value.split('::');
|
||||||
|
const head = headRaw ? headRaw.split(':') : [];
|
||||||
|
const tail = tailRaw ? tailRaw.split(':') : [];
|
||||||
|
|
||||||
|
const headNums = parseParts(head);
|
||||||
|
const tailNums = parseParts(tail);
|
||||||
|
if (!headNums || !tailNums) return null;
|
||||||
|
|
||||||
|
const missing = 8 - (headNums.length + tailNums.length);
|
||||||
|
if (missing < 1) return null;
|
||||||
|
|
||||||
|
return [...headNums, ...new Array<number>(missing).fill(0), ...tailNums];
|
||||||
}
|
}
|
||||||
|
|
||||||
async incrementApiCount(identifier: string): Promise<void> {
|
const all = parseParts(value.split(':'));
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
if (!all || all.length !== 8) return null;
|
||||||
const windowStart = nowSec - (nowSec % CONFIG.API_WINDOW_SECONDS);
|
return all;
|
||||||
|
|
||||||
// Atomic increment via UPSERT.
|
|
||||||
await this.db
|
|
||||||
.prepare(
|
|
||||||
'INSERT INTO api_rate_limits(identifier, window_start, count) VALUES(?, ?, 1) ' +
|
|
||||||
'ON CONFLICT(identifier, window_start) DO UPDATE SET count = count + 1'
|
|
||||||
)
|
|
||||||
.bind(identifier, windowStart)
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getClientIdentifier(request: Request): string {
|
function normalizeClientIpForRateLimit(rawIp: string): string | null {
|
||||||
const cfIp = request.headers.get('CF-Connecting-IP');
|
const input = rawIp.trim();
|
||||||
if (cfIp) return cfIp;
|
if (!input) return null;
|
||||||
|
|
||||||
const forwardedFor = request.headers.get('X-Forwarded-For');
|
const ipv4 = parseIpv4Octets(input);
|
||||||
if (forwardedFor) return forwardedFor.split(',')[0].trim();
|
if (ipv4) {
|
||||||
|
return `ip4:${ipv4.join('.')}`;
|
||||||
return 'unknown';
|
}
|
||||||
|
|
||||||
|
const ipv6 = parseIpv6Hextets(input);
|
||||||
|
if (!ipv6) return null;
|
||||||
|
|
||||||
|
// Handle IPv4-mapped / IPv4-compatible IPv6 as IPv4 identity.
|
||||||
|
// Examples: ::ffff:192.0.2.1, ::192.0.2.1
|
||||||
|
if (
|
||||||
|
ipv6[0] === 0 &&
|
||||||
|
ipv6[1] === 0 &&
|
||||||
|
ipv6[2] === 0 &&
|
||||||
|
ipv6[3] === 0 &&
|
||||||
|
ipv6[4] === 0 &&
|
||||||
|
(ipv6[5] === 0xffff || ipv6[5] === 0)
|
||||||
|
) {
|
||||||
|
const octets = [ipv6[6] >> 8, ipv6[6] & 0xff, ipv6[7] >> 8, ipv6[7] & 0xff];
|
||||||
|
return `ip4:${octets.join('.')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse to /64 to reduce brute-force bypass via IPv6 address rotation.
|
||||||
|
const prefix64 = ipv6
|
||||||
|
.slice(0, 4)
|
||||||
|
.map(part => part.toString(16).padStart(4, '0'))
|
||||||
|
.join(':');
|
||||||
|
return `ip6:${prefix64}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalRequest(request: Request): boolean {
|
||||||
|
const isLoopbackHost = (host: string | null): boolean => {
|
||||||
|
if (!host) return false;
|
||||||
|
const normalized = host.split(':')[0].trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
normalized === 'localhost' ||
|
||||||
|
normalized.endsWith('.localhost') ||
|
||||||
|
normalized === '127.0.0.1' ||
|
||||||
|
normalized === '0.0.0.0' ||
|
||||||
|
normalized === '::1' ||
|
||||||
|
normalized === '[::1]'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isLoopbackHost(new URL(request.url).hostname)) return true;
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed URL and fall back to Host header check.
|
||||||
|
}
|
||||||
|
|
||||||
|
return isLoopbackHost(request.headers.get('Host'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClientIdentifier(request: Request): string | null {
|
||||||
|
// Strict fallback order:
|
||||||
|
// 1) CF-Connecting-IP
|
||||||
|
// 2) X-Real-IP
|
||||||
|
// 3) first item of X-Forwarded-For
|
||||||
|
// If none are present/valid, treat client IP as unavailable.
|
||||||
|
const candidates: Array<string | null> = [
|
||||||
|
request.headers.get('CF-Connecting-IP'),
|
||||||
|
request.headers.get('X-Real-IP'),
|
||||||
|
request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim() || null,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const raw of candidates) {
|
||||||
|
if (!raw) continue;
|
||||||
|
const normalized = normalizeClientIpForRateLimit(raw);
|
||||||
|
if (normalized) return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local dev (wrangler dev / localhost): allow a deterministic loopback identifier.
|
||||||
|
if (isLocalRequest(request)) {
|
||||||
|
return 'ip4:127.0.0.1';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { AuditLog, Invite } from '../types';
|
||||||
|
|
||||||
|
export async function createInvite(db: D1Database, invite: Invite): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO invites(code, created_by, used_by, expires_at, status, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
)
|
||||||
|
.bind(invite.code, invite.createdBy, invite.usedBy, invite.expiresAt, invite.status, invite.createdAt, invite.updatedAt)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInvite(db: D1Database, code: string): Promise<Invite | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT code, created_by, used_by, expires_at, status, created_at, updated_at FROM invites WHERE code = ?')
|
||||||
|
.bind(code)
|
||||||
|
.first<any>();
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
code: row.code,
|
||||||
|
createdBy: row.created_by,
|
||||||
|
usedBy: row.used_by ?? null,
|
||||||
|
expiresAt: row.expires_at,
|
||||||
|
status: row.status,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listInvites(db: D1Database, includeInactive: boolean = false): Promise<Invite[]> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const predicate = includeInactive
|
||||||
|
? '1 = 1'
|
||||||
|
: "(status = 'active' AND expires_at > ?)";
|
||||||
|
const query =
|
||||||
|
'SELECT code, created_by, used_by, expires_at, status, created_at, updated_at FROM invites ' +
|
||||||
|
`WHERE ${predicate} ORDER BY created_at DESC`;
|
||||||
|
const res = includeInactive
|
||||||
|
? await db.prepare(query).all<any>()
|
||||||
|
: await db.prepare(query).bind(now).all<any>();
|
||||||
|
|
||||||
|
return (res.results || []).map((row) => ({
|
||||||
|
code: row.code,
|
||||||
|
createdBy: row.created_by,
|
||||||
|
usedBy: row.used_by ?? null,
|
||||||
|
expiresAt: row.expires_at,
|
||||||
|
status: row.status,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
"UPDATE invites SET status = 'used', used_by = ?, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?"
|
||||||
|
)
|
||||||
|
.bind(userId, now, code, now)
|
||||||
|
.run();
|
||||||
|
return (result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeInvite(db: D1Database, code: string): Promise<boolean> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const result = await db
|
||||||
|
.prepare("UPDATE invites SET status = 'revoked', updated_at = ? WHERE code = ? AND status = 'active'")
|
||||||
|
.bind(now, code)
|
||||||
|
.run();
|
||||||
|
return (result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAllInvites(db: D1Database): Promise<number> {
|
||||||
|
const result = await db.prepare('DELETE FROM invites').run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAuditLog(db: D1Database, log: AuditLog): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO audit_logs(id, actor_user_id, action, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
)
|
||||||
|
.bind(log.id, log.actorUserId, log.action, log.targetType, log.targetId, log.metadata, log.createdAt)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import type { Attachment, Cipher } from '../types';
|
||||||
|
|
||||||
|
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
|
||||||
|
type SqlChunkSize = (fixedBindCount: number) => number;
|
||||||
|
type GetCipher = (id: string) => Promise<Cipher | null>;
|
||||||
|
type SaveCipher = (cipher: Cipher) => Promise<void>;
|
||||||
|
type UpdateRevisionDate = (userId: string) => Promise<string>;
|
||||||
|
|
||||||
|
export async function getAttachment(db: D1Database, id: string): Promise<Attachment | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE id = ?')
|
||||||
|
.bind(id)
|
||||||
|
.first<any>();
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
cipherId: row.cipher_id,
|
||||||
|
fileName: row.file_name,
|
||||||
|
size: row.size,
|
||||||
|
sizeName: row.size_name,
|
||||||
|
key: row.key,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAttachment(db: D1Database, safeBind: SafeBind, attachment: Attachment): Promise<void> {
|
||||||
|
const stmt = db.prepare(
|
||||||
|
'INSERT INTO attachments(id, cipher_id, file_name, size, size_name, key) VALUES(?, ?, ?, ?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(id) DO UPDATE SET cipher_id=excluded.cipher_id, file_name=excluded.file_name, size=excluded.size, size_name=excluded.size_name, key=excluded.key'
|
||||||
|
);
|
||||||
|
await safeBind(stmt, attachment.id, attachment.cipherId, attachment.fileName, attachment.size, attachment.sizeName, attachment.key).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAttachment(db: D1Database, id: string): Promise<void> {
|
||||||
|
await db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAttachmentsByCipher(db: D1Database, cipherId: string): Promise<Attachment[]> {
|
||||||
|
const res = await db
|
||||||
|
.prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id = ?')
|
||||||
|
.bind(cipherId)
|
||||||
|
.all<any>();
|
||||||
|
return (res.results || []).map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
cipherId: r.cipher_id,
|
||||||
|
fileName: r.file_name,
|
||||||
|
size: r.size,
|
||||||
|
sizeName: r.size_name,
|
||||||
|
key: r.key,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAttachmentsByCipherIds(
|
||||||
|
db: D1Database,
|
||||||
|
sqlChunkSize: SqlChunkSize,
|
||||||
|
cipherIds: string[]
|
||||||
|
): Promise<Map<string, Attachment[]>> {
|
||||||
|
const grouped = new Map<string, Attachment[]>();
|
||||||
|
if (cipherIds.length === 0) return grouped;
|
||||||
|
|
||||||
|
const uniqueCipherIds = [...new Set(cipherIds)];
|
||||||
|
const chunkSize = sqlChunkSize(0);
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueCipherIds.length; i += chunkSize) {
|
||||||
|
const chunk = uniqueCipherIds.slice(i, i + chunkSize);
|
||||||
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
|
const res = await db
|
||||||
|
.prepare(`SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id IN (${placeholders})`)
|
||||||
|
.bind(...chunk)
|
||||||
|
.all<any>();
|
||||||
|
|
||||||
|
for (const row of res.results || []) {
|
||||||
|
const item: Attachment = {
|
||||||
|
id: row.id,
|
||||||
|
cipherId: row.cipher_id,
|
||||||
|
fileName: row.file_name,
|
||||||
|
size: row.size,
|
||||||
|
sizeName: row.size_name,
|
||||||
|
key: row.key,
|
||||||
|
};
|
||||||
|
const list = grouped.get(item.cipherId);
|
||||||
|
if (list) list.push(item);
|
||||||
|
else grouped.set(item.cipherId, [item]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAttachmentsByUserId(db: D1Database, userId: string): Promise<Map<string, Attachment[]>> {
|
||||||
|
const grouped = new Map<string, Attachment[]>();
|
||||||
|
const res = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT a.id, a.cipher_id, a.file_name, a.size, a.size_name, a.key
|
||||||
|
FROM attachments a
|
||||||
|
INNER JOIN ciphers c ON c.id = a.cipher_id
|
||||||
|
WHERE c.user_id = ?`
|
||||||
|
)
|
||||||
|
.bind(userId)
|
||||||
|
.all<any>();
|
||||||
|
|
||||||
|
for (const row of res.results || []) {
|
||||||
|
const item: Attachment = {
|
||||||
|
id: row.id,
|
||||||
|
cipherId: row.cipher_id,
|
||||||
|
fileName: row.file_name,
|
||||||
|
size: row.size,
|
||||||
|
sizeName: row.size_name,
|
||||||
|
key: row.key,
|
||||||
|
};
|
||||||
|
const list = grouped.get(item.cipherId);
|
||||||
|
if (list) list.push(item);
|
||||||
|
else grouped.set(item.cipherId, [item]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addAttachmentToCipher(db: D1Database, cipherId: string, attachmentId: string): Promise<void> {
|
||||||
|
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> {
|
||||||
|
await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCipherRevisionDate(
|
||||||
|
getCipherById: GetCipher,
|
||||||
|
saveCipherRecord: SaveCipher,
|
||||||
|
updateRevisionDate: UpdateRevisionDate,
|
||||||
|
cipherId: string
|
||||||
|
): Promise<{ userId: string; revisionDate: string } | null> {
|
||||||
|
const cipher = await getCipherById(cipherId);
|
||||||
|
if (!cipher) return null;
|
||||||
|
cipher.updatedAt = new Date().toISOString();
|
||||||
|
await saveCipherRecord(cipher);
|
||||||
|
const revisionDate = await updateRevisionDate(cipher.userId);
|
||||||
|
return { userId: cipher.userId, revisionDate };
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
type ShouldRunPeriodicCleanup = (lastRunAt: number, intervalMs: number) => boolean;
|
||||||
|
|
||||||
|
export async function ensureUsedAttachmentDownloadTokenTable(db: D1Database): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' +
|
||||||
|
'jti TEXT PRIMARY KEY, ' +
|
||||||
|
'expires_at INTEGER NOT NULL' +
|
||||||
|
')'
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function consumeAttachmentDownloadToken(
|
||||||
|
db: D1Database,
|
||||||
|
shouldRunPeriodicCleanup: ShouldRunPeriodicCleanup,
|
||||||
|
lastCleanupAt: number,
|
||||||
|
cleanupIntervalMs: number,
|
||||||
|
jti: string,
|
||||||
|
expUnixSeconds: number
|
||||||
|
): Promise<{ consumed: boolean; cleanedUpAt: number | null }> {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
let cleanedUpAt: number | null = null;
|
||||||
|
|
||||||
|
if (shouldRunPeriodicCleanup(lastCleanupAt, cleanupIntervalMs)) {
|
||||||
|
await db
|
||||||
|
.prepare('DELETE FROM used_attachment_download_tokens WHERE expires_at < ?')
|
||||||
|
.bind(nowMs)
|
||||||
|
.run();
|
||||||
|
cleanedUpAt = nowMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAtMs = expUnixSeconds * 1000;
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO used_attachment_download_tokens(jti, expires_at) VALUES(?, ?) ' +
|
||||||
|
'ON CONFLICT(jti) DO NOTHING'
|
||||||
|
)
|
||||||
|
.bind(jti, expiresAtMs)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return {
|
||||||
|
consumed: (result.meta.changes ?? 0) > 0,
|
||||||
|
cleanedUpAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
import type { Cipher } from '../types';
|
||||||
|
|
||||||
|
function normalizeOptionalId(value: unknown): string | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
return normalized ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
|
||||||
|
type SqlChunkSize = (fixedBindCount: number) => number;
|
||||||
|
type UpdateRevisionDate = (userId: string) => Promise<string>;
|
||||||
|
|
||||||
|
interface CipherRow {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
type: number | null;
|
||||||
|
folder_id: string | null;
|
||||||
|
name: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
favorite: number | null;
|
||||||
|
data: string;
|
||||||
|
reprompt: number | null;
|
||||||
|
key: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
archived_at: string | null;
|
||||||
|
deleted_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
|
||||||
|
if (!row?.data) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(row.data) as Cipher;
|
||||||
|
const folderId = normalizeOptionalId(row.folder_id ?? parsed.folderId ?? null);
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
id: row.id,
|
||||||
|
userId: row.user_id,
|
||||||
|
type: Number(row.type) || Number(parsed.type) || 1,
|
||||||
|
folderId,
|
||||||
|
name: row.name ?? parsed.name ?? null,
|
||||||
|
notes: row.notes ?? parsed.notes ?? null,
|
||||||
|
favorite: row.favorite != null ? !!row.favorite : !!parsed.favorite,
|
||||||
|
reprompt: row.reprompt ?? parsed.reprompt ?? 0,
|
||||||
|
key: row.key ?? parsed.key ?? null,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
archivedAt: row.archived_at ?? parsed.archivedAt ?? parsed.archivedDate ?? null,
|
||||||
|
deletedAt: row.deleted_at ?? null,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
console.error('Corrupted cipher data, id:', row.id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCipherColumns(): string {
|
||||||
|
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> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(`SELECT ${selectCipherColumns()} FROM ciphers WHERE id = ?`)
|
||||||
|
.bind(id)
|
||||||
|
.first<CipherRow>();
|
||||||
|
return parseCipherRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> {
|
||||||
|
const folderId = normalizeOptionalId(cipher.folderId);
|
||||||
|
const data = JSON.stringify({
|
||||||
|
...cipher,
|
||||||
|
folderId,
|
||||||
|
});
|
||||||
|
const stmt = db.prepare(
|
||||||
|
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
|
||||||
|
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||||
|
'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, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at'
|
||||||
|
);
|
||||||
|
await safeBind(
|
||||||
|
stmt,
|
||||||
|
cipher.id,
|
||||||
|
cipher.userId,
|
||||||
|
Number(cipher.type) || 1,
|
||||||
|
folderId,
|
||||||
|
cipher.name,
|
||||||
|
cipher.notes,
|
||||||
|
cipher.favorite ? 1 : 0,
|
||||||
|
data,
|
||||||
|
cipher.reprompt ?? 0,
|
||||||
|
cipher.key,
|
||||||
|
cipher.createdAt,
|
||||||
|
cipher.updatedAt,
|
||||||
|
cipher.archivedAt ?? null,
|
||||||
|
cipher.deletedAt
|
||||||
|
).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeIds(ids: string[]): string[] {
|
||||||
|
return Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCipher(db: D1Database, id: string, userId: string): Promise<void> {
|
||||||
|
await db.prepare('DELETE FROM ciphers WHERE id = ? AND user_id = ?').bind(id, userId).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkSoftDeleteCiphers(
|
||||||
|
db: D1Database,
|
||||||
|
sqlChunkSize: SqlChunkSize,
|
||||||
|
updateRevisionDate: UpdateRevisionDate,
|
||||||
|
ids: string[],
|
||||||
|
userId: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (ids.length === 0) return null;
|
||||||
|
const uniqueIds = sanitizeIds(ids);
|
||||||
|
if (!uniqueIds.length) return null;
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const patch = JSON.stringify({ deletedAt: now, updatedAt: now });
|
||||||
|
const chunkSize = sqlChunkSize(4);
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE ciphers
|
||||||
|
SET deleted_at = ?, updated_at = ?, data = json_patch(data, ?)
|
||||||
|
WHERE user_id = ? AND id IN (${placeholders})`
|
||||||
|
)
|
||||||
|
.bind(now, now, patch, userId, ...chunk)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateRevisionDate(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkRestoreCiphers(
|
||||||
|
db: D1Database,
|
||||||
|
sqlChunkSize: SqlChunkSize,
|
||||||
|
updateRevisionDate: UpdateRevisionDate,
|
||||||
|
ids: string[],
|
||||||
|
userId: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (ids.length === 0) return null;
|
||||||
|
const uniqueIds = sanitizeIds(ids);
|
||||||
|
if (!uniqueIds.length) return null;
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const patch = JSON.stringify({ deletedAt: null, updatedAt: now });
|
||||||
|
const chunkSize = sqlChunkSize(3);
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE ciphers
|
||||||
|
SET deleted_at = NULL, updated_at = ?, data = json_patch(data, ?)
|
||||||
|
WHERE user_id = ? AND id IN (${placeholders})`
|
||||||
|
)
|
||||||
|
.bind(now, patch, userId, ...chunk)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateRevisionDate(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkDeleteCiphers(
|
||||||
|
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 chunkSize = sqlChunkSize(1);
|
||||||
|
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 ciphers WHERE user_id = ? AND id IN (${placeholders})`).bind(userId, ...chunk).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateRevisionDate(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllCiphers(db: D1Database, userId: string): Promise<Cipher[]> {
|
||||||
|
const res = await db
|
||||||
|
.prepare(`SELECT ${selectCipherColumns()} FROM ciphers WHERE user_id = ? ORDER BY updated_at DESC`)
|
||||||
|
.bind(userId)
|
||||||
|
.all<CipherRow>();
|
||||||
|
return (res.results || []).flatMap((row) => {
|
||||||
|
const cipher = parseCipherRow(row);
|
||||||
|
return cipher ? [cipher] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCiphersPage(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
includeDeleted: boolean,
|
||||||
|
limit: number,
|
||||||
|
offset: number
|
||||||
|
): Promise<Cipher[]> {
|
||||||
|
const whereDeleted = includeDeleted ? '' : 'AND deleted_at IS NULL';
|
||||||
|
const res = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT ${selectCipherColumns()} FROM ciphers
|
||||||
|
WHERE user_id = ?
|
||||||
|
${whereDeleted}
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT ? OFFSET ?`
|
||||||
|
)
|
||||||
|
.bind(userId, limit, offset)
|
||||||
|
.all<CipherRow>();
|
||||||
|
return (res.results || []).flatMap((row) => {
|
||||||
|
const cipher = parseCipherRow(row);
|
||||||
|
return cipher ? [cipher] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCiphersByIds(
|
||||||
|
db: D1Database,
|
||||||
|
sqlChunkSize: SqlChunkSize,
|
||||||
|
ids: string[],
|
||||||
|
userId: string
|
||||||
|
): Promise<Cipher[]> {
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
const uniqueIds = sanitizeIds(ids);
|
||||||
|
if (!uniqueIds.length) return [];
|
||||||
|
|
||||||
|
const chunkSize = sqlChunkSize(1);
|
||||||
|
const out: Cipher[] = [];
|
||||||
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
|
const stmt = db.prepare(`SELECT ${selectCipherColumns()} FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`);
|
||||||
|
const res = await stmt.bind(userId, ...chunk).all<CipherRow>();
|
||||||
|
out.push(
|
||||||
|
...(res.results || []).flatMap((row) => {
|
||||||
|
const cipher = parseCipherRow(row);
|
||||||
|
return cipher ? [cipher] : [];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkMoveCiphers(
|
||||||
|
db: D1Database,
|
||||||
|
sqlChunkSize: SqlChunkSize,
|
||||||
|
updateRevisionDate: UpdateRevisionDate,
|
||||||
|
ids: string[],
|
||||||
|
folderId: string | null,
|
||||||
|
userId: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (ids.length === 0) return null;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const normalizedFolderId = normalizeOptionalId(folderId);
|
||||||
|
const uniqueIds = sanitizeIds(ids);
|
||||||
|
const patch = JSON.stringify({ folderId: normalizedFolderId, updatedAt: now });
|
||||||
|
const chunkSize = sqlChunkSize(4);
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE ciphers
|
||||||
|
SET folder_id = ?, updated_at = ?, data = json_patch(data, ?)
|
||||||
|
WHERE user_id = ? AND id IN (${placeholders})`
|
||||||
|
)
|
||||||
|
.bind(normalizedFolderId, now, patch, userId, ...chunk)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateRevisionDate(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkArchiveCiphers(
|
||||||
|
db: D1Database,
|
||||||
|
sqlChunkSize: SqlChunkSize,
|
||||||
|
updateRevisionDate: UpdateRevisionDate,
|
||||||
|
ids: string[],
|
||||||
|
userId: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (ids.length === 0) return null;
|
||||||
|
const uniqueIds = sanitizeIds(ids);
|
||||||
|
if (!uniqueIds.length) return null;
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const patch = JSON.stringify({ archivedAt: now, archivedDate: now, updatedAt: now });
|
||||||
|
const chunkSize = sqlChunkSize(4);
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE ciphers
|
||||||
|
SET archived_at = ?, updated_at = ?, data = json_patch(data, ?)
|
||||||
|
WHERE user_id = ? AND id IN (${placeholders}) AND deleted_at IS NULL`
|
||||||
|
)
|
||||||
|
.bind(now, now, patch, userId, ...chunk)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateRevisionDate(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkUnarchiveCiphers(
|
||||||
|
db: D1Database,
|
||||||
|
sqlChunkSize: SqlChunkSize,
|
||||||
|
updateRevisionDate: UpdateRevisionDate,
|
||||||
|
ids: string[],
|
||||||
|
userId: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (ids.length === 0) return null;
|
||||||
|
const uniqueIds = sanitizeIds(ids);
|
||||||
|
if (!uniqueIds.length) return null;
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const patch = JSON.stringify({ archivedAt: null, archivedDate: null, updatedAt: now });
|
||||||
|
const chunkSize = sqlChunkSize(3);
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE ciphers
|
||||||
|
SET archived_at = NULL, updated_at = ?, data = json_patch(data, ?)
|
||||||
|
WHERE user_id = ? AND id IN (${placeholders})`
|
||||||
|
)
|
||||||
|
.bind(now, patch, userId, ...chunk)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateRevisionDate(userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export async function isRegistered(db: D1Database): Promise<boolean> {
|
||||||
|
const row = await db.prepare('SELECT value FROM config WHERE key = ?').bind('registered').first<{ value: string }>();
|
||||||
|
return row?.value === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConfigValue(db: D1Database, key: string): Promise<string | null> {
|
||||||
|
const row = await db.prepare('SELECT value FROM config WHERE key = ?').bind(key).first<{ value: string }>();
|
||||||
|
return typeof row?.value === 'string' ? row.value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setConfigValue(db: D1Database, key: string, value: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
||||||
|
.bind(key, value)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setRegistered(db: D1Database): Promise<void> {
|
||||||
|
await db.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
||||||
|
.bind('registered', 'true')
|
||||||
|
.run();
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import type { Device, TrustedDeviceTokenSummary, User } from '../types';
|
||||||
|
|
||||||
|
type GetUserByEmail = (email: string) => Promise<User | null>;
|
||||||
|
type TrustedTokenKeyFn = (token: string) => Promise<string>;
|
||||||
|
|
||||||
|
function mapDeviceRow(row: any): Device {
|
||||||
|
return {
|
||||||
|
userId: row.user_id,
|
||||||
|
deviceIdentifier: row.device_identifier,
|
||||||
|
name: row.name,
|
||||||
|
type: row.type,
|
||||||
|
sessionStamp: row.session_stamp || '',
|
||||||
|
encryptedUserKey: row.encrypted_user_key ?? null,
|
||||||
|
encryptedPublicKey: row.encrypted_public_key ?? null,
|
||||||
|
encryptedPrivateKey: row.encrypted_private_key ?? null,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertDevice(
|
||||||
|
db: D1Database,
|
||||||
|
getDeviceById: (userId: string, deviceIdentifier: string) => Promise<Device | null>,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string,
|
||||||
|
name: string,
|
||||||
|
type: number,
|
||||||
|
sessionStamp?: string,
|
||||||
|
keys?: {
|
||||||
|
encryptedUserKey?: string | null;
|
||||||
|
encryptedPublicKey?: string | null;
|
||||||
|
encryptedPrivateKey?: string | null;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const effectiveSessionStamp = String(sessionStamp || '').trim() || (await getDeviceById(userId, deviceIdentifier))?.sessionStamp || '';
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?) ' +
|
||||||
|
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' +
|
||||||
|
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
|
||||||
|
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
|
||||||
|
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
|
||||||
|
'updated_at=excluded.updated_at'
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
userId,
|
||||||
|
deviceIdentifier,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
effectiveSessionStamp,
|
||||||
|
keys?.encryptedUserKey ?? null,
|
||||||
|
keys?.encryptedPublicKey ?? null,
|
||||||
|
keys?.encryptedPrivateKey ?? null,
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDeviceKeys(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string,
|
||||||
|
keys: {
|
||||||
|
encryptedUserKey?: string | null;
|
||||||
|
encryptedPublicKey?: string | null;
|
||||||
|
encryptedPrivateKey?: string | null;
|
||||||
|
}
|
||||||
|
): Promise<boolean> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
'UPDATE devices SET encrypted_user_key = ?, encrypted_public_key = ?, encrypted_private_key = ?, updated_at = ? ' +
|
||||||
|
'WHERE user_id = ? AND device_identifier = ?'
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
keys.encryptedUserKey ?? null,
|
||||||
|
keys.encryptedPublicKey ?? null,
|
||||||
|
keys.encryptedPrivateKey ?? null,
|
||||||
|
now,
|
||||||
|
userId,
|
||||||
|
deviceIdentifier
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearDeviceKeys(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifiers: string[]
|
||||||
|
): Promise<number> {
|
||||||
|
const uniqueIds = Array.from(
|
||||||
|
new Set(deviceIdentifiers.map((id) => String(id || '').trim()).filter(Boolean))
|
||||||
|
);
|
||||||
|
if (!uniqueIds.length) return 0;
|
||||||
|
|
||||||
|
const placeholders = uniqueIds.map(() => '?').join(',');
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE devices
|
||||||
|
SET encrypted_user_key = NULL,
|
||||||
|
encrypted_public_key = NULL,
|
||||||
|
encrypted_private_key = NULL,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE user_id = ? AND device_identifier IN (${placeholders})`
|
||||||
|
)
|
||||||
|
.bind(new Date().toISOString(), userId, ...uniqueIds)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isKnownDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
|
||||||
|
.bind(userId, deviceIdentifier)
|
||||||
|
.first<{ '1': number }>();
|
||||||
|
return !!row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isKnownDeviceByEmail(
|
||||||
|
getUserByEmail: GetUserByEmail,
|
||||||
|
isKnownDeviceForUser: (userId: string, deviceIdentifier: string) => Promise<boolean>,
|
||||||
|
email: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const user = await getUserByEmail(email);
|
||||||
|
if (!user) return false;
|
||||||
|
return isKnownDeviceForUser(user.id, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
|
||||||
|
const res = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' +
|
||||||
|
'FROM devices WHERE user_id = ? ORDER BY updated_at DESC'
|
||||||
|
)
|
||||||
|
.bind(userId)
|
||||||
|
.all<any>();
|
||||||
|
return (res.results || []).map(mapDeviceRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' +
|
||||||
|
'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
|
||||||
|
)
|
||||||
|
.bind(userId, deviceIdentifier)
|
||||||
|
.first<any>();
|
||||||
|
return row ? mapDeviceRow(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||||
|
const result = await db
|
||||||
|
.prepare('DELETE FROM devices WHERE user_id = ? AND device_identifier = ?')
|
||||||
|
.bind(userId, deviceIdentifier)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDevicesByUserId(db: D1Database, userId: string): Promise<number> {
|
||||||
|
const result = await db.prepare('DELETE FROM devices WHERE user_id = ?').bind(userId).run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTrustedDeviceTokenSummariesByUserId(db: D1Database, userId: string): Promise<TrustedDeviceTokenSummary[]> {
|
||||||
|
const now = Date.now();
|
||||||
|
await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(now).run();
|
||||||
|
|
||||||
|
const res = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT device_identifier, MAX(expires_at) AS expires_at, COUNT(*) AS token_count ' +
|
||||||
|
'FROM trusted_two_factor_device_tokens WHERE user_id = ? GROUP BY device_identifier ORDER BY expires_at DESC'
|
||||||
|
)
|
||||||
|
.bind(userId)
|
||||||
|
.all<any>();
|
||||||
|
|
||||||
|
return (res.results || []).map((row) => ({
|
||||||
|
deviceIdentifier: row.device_identifier,
|
||||||
|
expiresAt: Number(row.expires_at || 0),
|
||||||
|
tokenCount: Number(row.token_count || 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTrustedTwoFactorTokensByDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<number> {
|
||||||
|
const result = await db
|
||||||
|
.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE user_id = ? AND device_identifier = ?')
|
||||||
|
.bind(userId, deviceIdentifier)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTrustedTwoFactorTokensByUserId(db: D1Database, userId: string): Promise<number> {
|
||||||
|
const result = await db
|
||||||
|
.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE user_id = ?')
|
||||||
|
.bind(userId)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveTrustedTwoFactorDeviceToken(
|
||||||
|
db: D1Database,
|
||||||
|
trustedTokenKey: TrustedTokenKeyFn,
|
||||||
|
token: string,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string,
|
||||||
|
expiresAtMs: number
|
||||||
|
): Promise<void> {
|
||||||
|
const tokenKey = await trustedTokenKey(token);
|
||||||
|
await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(Date.now()).run();
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO trusted_two_factor_device_tokens(token, user_id, device_identifier, expires_at) VALUES(?, ?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, device_identifier=excluded.device_identifier, expires_at=excluded.expires_at'
|
||||||
|
)
|
||||||
|
.bind(tokenKey, userId, deviceIdentifier, expiresAtMs)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTrustedTwoFactorDeviceTokenUserId(
|
||||||
|
db: D1Database,
|
||||||
|
trustedTokenKey: TrustedTokenKeyFn,
|
||||||
|
token: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const now = Date.now();
|
||||||
|
const tokenKey = await trustedTokenKey(token);
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT user_id, expires_at FROM trusted_two_factor_device_tokens WHERE token = ? AND device_identifier = ?')
|
||||||
|
.bind(tokenKey, deviceIdentifier)
|
||||||
|
.first<{ user_id: string; expires_at: number }>();
|
||||||
|
|
||||||
|
if (!row) return null;
|
||||||
|
if (row.expires_at && row.expires_at < now) {
|
||||||
|
await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE token = ?').bind(tokenKey).run();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return row.user_id;
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import type { Cipher, Folder } from '../types';
|
||||||
|
|
||||||
|
function mapFolderRow(row: any): Folder {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
userId: row.user_id,
|
||||||
|
name: row.name,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFolder(db: D1Database, id: string): Promise<Folder | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE id = ?')
|
||||||
|
.bind(id)
|
||||||
|
.first<any>();
|
||||||
|
if (!row) return null;
|
||||||
|
return mapFolderRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveFolder(db: D1Database, folder: Folder): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO folders(id, user_id, name, created_at, updated_at) VALUES(?, ?, ?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(id) DO UPDATE SET user_id=excluded.user_id, name=excluded.name, updated_at=excluded.updated_at'
|
||||||
|
)
|
||||||
|
.bind(folder.id, folder.userId, folder.name, folder.createdAt, folder.updatedAt)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFolder(db: D1Database, id: string, userId: string): Promise<void> {
|
||||||
|
await db.prepare('DELETE FROM folders WHERE id = ? AND user_id = ?').bind(id, userId).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearFolderFromCiphers(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
folderId: string,
|
||||||
|
saveCipher: (cipher: Cipher) => Promise<void>
|
||||||
|
): Promise<void> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const res = await db
|
||||||
|
.prepare('SELECT data FROM ciphers WHERE user_id = ? AND folder_id = ?')
|
||||||
|
.bind(userId, folderId)
|
||||||
|
.all<{ data: string }>();
|
||||||
|
|
||||||
|
for (const row of (res.results || [])) {
|
||||||
|
let cipher: Cipher;
|
||||||
|
try {
|
||||||
|
cipher = JSON.parse(row.data) as Cipher;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cipher.folderId = null;
|
||||||
|
cipher.updatedAt = now;
|
||||||
|
await saveCipher(cipher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkDeleteFolders(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
ids: string[],
|
||||||
|
sqlChunkSize: (fixedBindCount: number) => number,
|
||||||
|
saveCipher: (cipher: Cipher) => Promise<void>,
|
||||||
|
updateRevisionDate: (userId: string) => Promise<string>
|
||||||
|
): Promise<string | null> {
|
||||||
|
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||||
|
if (!uniqueIds.length) return null;
|
||||||
|
|
||||||
|
const chunkSize = sqlChunkSize(1);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
|
const res = await db
|
||||||
|
.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND folder_id IN (${placeholders})`)
|
||||||
|
.bind(userId, ...chunk)
|
||||||
|
.all<{ data: string }>();
|
||||||
|
|
||||||
|
for (const row of res.results || []) {
|
||||||
|
let cipher: Cipher;
|
||||||
|
try {
|
||||||
|
cipher = JSON.parse(row.data) as Cipher;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cipher.folderId = null;
|
||||||
|
cipher.updatedAt = now;
|
||||||
|
await saveCipher(cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.prepare(`DELETE FROM folders WHERE user_id = ? AND id IN (${placeholders})`)
|
||||||
|
.bind(userId, ...chunk)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateRevisionDate(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllFolders(db: D1Database, userId: string): Promise<Folder[]> {
|
||||||
|
const res = await db
|
||||||
|
.prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE user_id = ? ORDER BY updated_at DESC')
|
||||||
|
.bind(userId)
|
||||||
|
.all<any>();
|
||||||
|
return (res.results || []).map((row) => mapFolderRow(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFoldersPage(db: D1Database, userId: string, limit: number, offset: number): Promise<Folder[]> {
|
||||||
|
const res = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT id, user_id, name, created_at, updated_at FROM folders WHERE user_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?'
|
||||||
|
)
|
||||||
|
.bind(userId, limit, offset)
|
||||||
|
.all<any>();
|
||||||
|
return (res.results || []).map((row) => mapFolderRow(row));
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import type { RefreshTokenRecord } from '../types';
|
||||||
|
|
||||||
|
type RefreshTokenKeyFn = (token: string) => Promise<string>;
|
||||||
|
type CleanupExpiredFn = (nowMs: number) => Promise<void>;
|
||||||
|
|
||||||
|
export async function saveRefreshToken(
|
||||||
|
db: D1Database,
|
||||||
|
refreshTokenKey: RefreshTokenKeyFn,
|
||||||
|
maybeCleanupExpiredRefreshTokens: CleanupExpiredFn,
|
||||||
|
token: string,
|
||||||
|
userId: string,
|
||||||
|
expiresAtMs: number,
|
||||||
|
deviceIdentifier?: string | null,
|
||||||
|
deviceSessionStamp?: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
await maybeCleanupExpiredRefreshTokens(Date.now());
|
||||||
|
const tokenKey = await refreshTokenKey(token);
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO refresh_tokens(token, user_id, expires_at, device_identifier, device_session_stamp) VALUES(?, ?, ?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at, device_identifier=excluded.device_identifier, device_session_stamp=excluded.device_session_stamp'
|
||||||
|
)
|
||||||
|
.bind(tokenKey, userId, expiresAtMs, deviceIdentifier ?? null, deviceSessionStamp ?? null)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRefreshTokenRecord(
|
||||||
|
db: D1Database,
|
||||||
|
refreshTokenKey: RefreshTokenKeyFn,
|
||||||
|
maybeCleanupExpiredRefreshTokens: CleanupExpiredFn,
|
||||||
|
saveRefreshTokenRecord: (
|
||||||
|
token: string,
|
||||||
|
userId: string,
|
||||||
|
expiresAtMs?: number,
|
||||||
|
deviceIdentifier?: string | null,
|
||||||
|
deviceSessionStamp?: string | null
|
||||||
|
) => Promise<void>,
|
||||||
|
deleteRefreshTokenRecord: (token: string) => Promise<void>,
|
||||||
|
token: string
|
||||||
|
): Promise<RefreshTokenRecord | null> {
|
||||||
|
const now = Date.now();
|
||||||
|
await maybeCleanupExpiredRefreshTokens(now);
|
||||||
|
const tokenKey = await refreshTokenKey(token);
|
||||||
|
|
||||||
|
let row = await db
|
||||||
|
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
|
||||||
|
.bind(tokenKey)
|
||||||
|
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
const legacyRow = await db
|
||||||
|
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
|
||||||
|
.bind(token)
|
||||||
|
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
|
||||||
|
|
||||||
|
if (legacyRow) {
|
||||||
|
if (legacyRow.expires_at && legacyRow.expires_at < now) {
|
||||||
|
await deleteRefreshTokenRecord(token);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await saveRefreshTokenRecord(
|
||||||
|
token,
|
||||||
|
legacyRow.user_id,
|
||||||
|
legacyRow.expires_at,
|
||||||
|
legacyRow.device_identifier ?? null,
|
||||||
|
legacyRow.device_session_stamp ?? null
|
||||||
|
);
|
||||||
|
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
|
||||||
|
return {
|
||||||
|
userId: legacyRow.user_id,
|
||||||
|
expiresAt: legacyRow.expires_at,
|
||||||
|
deviceIdentifier: legacyRow.device_identifier ?? null,
|
||||||
|
deviceSessionStamp: legacyRow.device_session_stamp ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row) return null;
|
||||||
|
if (row.expires_at && row.expires_at < now) {
|
||||||
|
await deleteRefreshTokenRecord(token);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
userId: row.user_id,
|
||||||
|
expiresAt: row.expires_at,
|
||||||
|
deviceIdentifier: row.device_identifier ?? null,
|
||||||
|
deviceSessionStamp: row.device_session_stamp ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRefreshToken(db: D1Database, refreshTokenKey: RefreshTokenKeyFn, token: string): Promise<void> {
|
||||||
|
const tokenKey = await refreshTokenKey(token);
|
||||||
|
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
|
||||||
|
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(tokenKey).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRefreshTokensByUserId(db: D1Database, userId: string): Promise<number> {
|
||||||
|
const result = await db.prepare('DELETE FROM refresh_tokens WHERE user_id = ?').bind(userId).run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRefreshTokensByDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<number> {
|
||||||
|
const result = await db
|
||||||
|
.prepare('DELETE FROM refresh_tokens WHERE user_id = ? AND device_identifier = ?')
|
||||||
|
.bind(userId, deviceIdentifier)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function constrainRefreshTokenExpiry(
|
||||||
|
db: D1Database,
|
||||||
|
refreshTokenKey: RefreshTokenKeyFn,
|
||||||
|
token: string,
|
||||||
|
maxExpiresAtMs: number
|
||||||
|
): Promise<void> {
|
||||||
|
const tokenKey = await refreshTokenKey(token);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'UPDATE refresh_tokens ' +
|
||||||
|
'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' +
|
||||||
|
'WHERE token = ?'
|
||||||
|
)
|
||||||
|
.bind(maxExpiresAtMs, maxExpiresAtMs, tokenKey)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'UPDATE refresh_tokens ' +
|
||||||
|
'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' +
|
||||||
|
'WHERE token = ?'
|
||||||
|
)
|
||||||
|
.bind(maxExpiresAtMs, maxExpiresAtMs, token)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export async function getRevisionDate(db: D1Database, userId: string): Promise<string> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT revision_date FROM user_revisions WHERE user_id = ?')
|
||||||
|
.bind(userId)
|
||||||
|
.first<{ revision_date: string }>();
|
||||||
|
|
||||||
|
if (row?.revision_date) return row.revision_date;
|
||||||
|
|
||||||
|
const date = new Date().toISOString();
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO user_revisions(user_id, revision_date) VALUES(?, ?) ' +
|
||||||
|
'ON CONFLICT(user_id) DO NOTHING'
|
||||||
|
)
|
||||||
|
.bind(userId, date)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRevisionDate(db: D1Database, userId: string): Promise<string> {
|
||||||
|
const date = new Date().toISOString();
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO user_revisions(user_id, revision_date) VALUES(?, ?) ' +
|
||||||
|
'ON CONFLICT(user_id) DO UPDATE SET revision_date = excluded.revision_date'
|
||||||
|
)
|
||||||
|
.bind(userId, date)
|
||||||
|
.run();
|
||||||
|
return date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// IMPORTANT:
|
||||||
|
// Keep this schema list in sync with migrations/0001_init.sql.
|
||||||
|
// Any new table/column/index must be added to both places together.
|
||||||
|
const SCHEMA_STATEMENTS: readonly string[] = [
|
||||||
|
'CREATE TABLE IF NOT EXISTS users (' +
|
||||||
|
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
|
||||||
|
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
|
||||||
|
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
|
||||||
|
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||||
|
'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
|
||||||
|
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
|
||||||
|
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
|
||||||
|
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1',
|
||||||
|
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
|
||||||
|
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS user_revisions (' +
|
||||||
|
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
|
||||||
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS ciphers (' +
|
||||||
|
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, folder_id TEXT, name TEXT, notes TEXT, ' +
|
||||||
|
'favorite INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL, reprompt INTEGER, key TEXT, ' +
|
||||||
|
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, archived_at TEXT, deleted_at TEXT, ' +
|
||||||
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
'ALTER TABLE ciphers ADD COLUMN archived_at TEXT',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted_updated ON ciphers(user_id, deleted_at, updated_at)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS folders (' +
|
||||||
|
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||||
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_folders_user_updated ON folders(user_id, updated_at)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS attachments (' +
|
||||||
|
'id TEXT PRIMARY KEY, cipher_id TEXT NOT NULL, file_name TEXT NOT NULL, size INTEGER NOT NULL, ' +
|
||||||
|
'size_name TEXT NOT NULL, key TEXT, ' +
|
||||||
|
'FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS sends (' +
|
||||||
|
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, name TEXT NOT NULL, notes TEXT, data TEXT NOT NULL, ' +
|
||||||
|
'key TEXT NOT NULL, password_hash TEXT, password_salt TEXT, password_iterations INTEGER, auth_type INTEGER NOT NULL DEFAULT 2, emails TEXT, ' +
|
||||||
|
'max_access_count INTEGER, access_count INTEGER NOT NULL DEFAULT 0, disabled INTEGER NOT NULL DEFAULT 0, hide_email INTEGER, ' +
|
||||||
|
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, expiration_date TEXT, deletion_date TEXT NOT NULL, ' +
|
||||||
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_sends_user_updated_id ON sends(user_id, updated_at, id)',
|
||||||
|
'ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2',
|
||||||
|
'ALTER TABLE sends ADD COLUMN emails TEXT',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS refresh_tokens (' +
|
||||||
|
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, device_identifier TEXT, device_session_stamp TEXT, ' +
|
||||||
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)',
|
||||||
|
'ALTER TABLE refresh_tokens ADD COLUMN device_identifier TEXT',
|
||||||
|
'ALTER TABLE refresh_tokens ADD COLUMN device_session_stamp TEXT',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS invites (' +
|
||||||
|
'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||||
|
'FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE, ' +
|
||||||
|
'FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_invites_status_expires ON invites(status, expires_at)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS audit_logs (' +
|
||||||
|
'id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, ' +
|
||||||
|
'FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS devices (' +
|
||||||
|
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, ' +
|
||||||
|
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||||
|
'PRIMARY KEY (user_id, device_identifier), ' +
|
||||||
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at)',
|
||||||
|
'ALTER TABLE devices ADD COLUMN session_stamp TEXT',
|
||||||
|
'ALTER TABLE devices ADD COLUMN encrypted_user_key TEXT',
|
||||||
|
'ALTER TABLE devices ADD COLUMN encrypted_public_key TEXT',
|
||||||
|
'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT',
|
||||||
|
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
|
||||||
|
'ALTER TABLE devices ADD COLUMN banned_at TEXT',
|
||||||
|
|
||||||
|
'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, ' +
|
||||||
|
'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 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 (' +
|
||||||
|
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' +
|
||||||
|
'jti TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function executeSchemaStatement(db: D1Database, statement: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await db.prepare(statement).run();
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
||||||
|
if (msg.includes('already exists') || msg.includes('duplicate column name')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureAdminUserExists(db: D1Database): Promise<void> {
|
||||||
|
const admin = await db.prepare("SELECT id FROM users WHERE role = 'admin' LIMIT 1").first<{ id: string }>();
|
||||||
|
if (admin?.id) return;
|
||||||
|
|
||||||
|
const firstUser = await db
|
||||||
|
.prepare('SELECT id FROM users ORDER BY created_at ASC LIMIT 1')
|
||||||
|
.first<{ id: string }>();
|
||||||
|
if (!firstUser?.id) return;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.prepare("UPDATE users SET role = 'admin', updated_at = ? WHERE id = ?")
|
||||||
|
.bind(new Date().toISOString(), firstUser.id)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureStorageSchema(db: D1Database): Promise<void> {
|
||||||
|
await db.prepare('PRAGMA foreign_keys = ON').run();
|
||||||
|
await db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run();
|
||||||
|
for (const stmt of SCHEMA_STATEMENTS) {
|
||||||
|
await executeSchemaStatement(db, stmt);
|
||||||
|
}
|
||||||
|
await ensureAdminUserExists(db);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import type { Send } from '../types';
|
||||||
|
|
||||||
|
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
|
||||||
|
type SqlChunkSize = (fixedBindCount: number) => number;
|
||||||
|
type UpdateRevisionDate = (userId: string) => Promise<string>;
|
||||||
|
|
||||||
|
function mapSendRow(row: any): Send {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
userId: row.user_id,
|
||||||
|
type: row.type,
|
||||||
|
name: row.name,
|
||||||
|
notes: row.notes,
|
||||||
|
data: row.data,
|
||||||
|
key: row.key,
|
||||||
|
passwordHash: row.password_hash,
|
||||||
|
passwordSalt: row.password_salt,
|
||||||
|
passwordIterations: row.password_iterations,
|
||||||
|
authType: row.auth_type ?? 0,
|
||||||
|
emails: row.emails ?? null,
|
||||||
|
maxAccessCount: row.max_access_count,
|
||||||
|
accessCount: row.access_count,
|
||||||
|
disabled: !!row.disabled,
|
||||||
|
hideEmail: row.hide_email === null || row.hide_email === undefined ? null : !!row.hide_email,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
expirationDate: row.expiration_date,
|
||||||
|
deletionDate: row.deletion_date,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSend(db: D1Database, id: string): Promise<Send | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE id = ?'
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.first<any>();
|
||||||
|
if (!row) return null;
|
||||||
|
return mapSendRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSend(db: D1Database, safeBind: SafeBind, send: Send): Promise<void> {
|
||||||
|
const stmt = db.prepare(
|
||||||
|
'INSERT INTO sends(id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date) ' +
|
||||||
|
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||||
|
'user_id=excluded.user_id, type=excluded.type, name=excluded.name, notes=excluded.notes, data=excluded.data, key=excluded.key, ' +
|
||||||
|
'password_hash=excluded.password_hash, password_salt=excluded.password_salt, password_iterations=excluded.password_iterations, auth_type=excluded.auth_type, emails=excluded.emails, ' +
|
||||||
|
'max_access_count=excluded.max_access_count, access_count=excluded.access_count, disabled=excluded.disabled, hide_email=excluded.hide_email, ' +
|
||||||
|
'updated_at=excluded.updated_at, expiration_date=excluded.expiration_date, deletion_date=excluded.deletion_date'
|
||||||
|
);
|
||||||
|
|
||||||
|
await safeBind(
|
||||||
|
stmt,
|
||||||
|
send.id,
|
||||||
|
send.userId,
|
||||||
|
Number(send.type) || 0,
|
||||||
|
send.name,
|
||||||
|
send.notes,
|
||||||
|
send.data,
|
||||||
|
send.key,
|
||||||
|
send.passwordHash,
|
||||||
|
send.passwordSalt,
|
||||||
|
send.passwordIterations,
|
||||||
|
send.authType,
|
||||||
|
send.emails,
|
||||||
|
send.maxAccessCount,
|
||||||
|
send.accessCount,
|
||||||
|
send.disabled ? 1 : 0,
|
||||||
|
send.hideEmail === null || send.hideEmail === undefined ? null : send.hideEmail ? 1 : 0,
|
||||||
|
send.createdAt,
|
||||||
|
send.updatedAt,
|
||||||
|
send.expirationDate,
|
||||||
|
send.deletionDate
|
||||||
|
).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function incrementSendAccessCount(db: D1Database, sendId: string): Promise<boolean> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
'UPDATE sends SET access_count = access_count + 1, updated_at = ? ' +
|
||||||
|
'WHERE id = ? AND (max_access_count IS NULL OR access_count < max_access_count)'
|
||||||
|
)
|
||||||
|
.bind(now, sendId)
|
||||||
|
.run();
|
||||||
|
return (result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSend(db: D1Database, id: string, userId: string): Promise<void> {
|
||||||
|
await db.prepare('DELETE FROM sends WHERE id = ? AND user_id = ?').bind(id, userId).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSendsByIds(
|
||||||
|
db: D1Database,
|
||||||
|
sqlChunkSize: SqlChunkSize,
|
||||||
|
ids: string[],
|
||||||
|
userId: string
|
||||||
|
): Promise<Send[]> {
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||||
|
if (!uniqueIds.length) return [];
|
||||||
|
const chunkSize = sqlChunkSize(1);
|
||||||
|
const out: Send[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
|
const res = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date
|
||||||
|
FROM sends
|
||||||
|
WHERE user_id = ? AND id IN (${placeholders})`
|
||||||
|
)
|
||||||
|
.bind(userId, ...chunk)
|
||||||
|
.all<any>();
|
||||||
|
out.push(...(res.results || []).map((row) => mapSendRow(row)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkDeleteSends(
|
||||||
|
db: D1Database,
|
||||||
|
sqlChunkSize: SqlChunkSize,
|
||||||
|
updateRevisionDate: UpdateRevisionDate,
|
||||||
|
ids: string[],
|
||||||
|
userId: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (ids.length === 0) return null;
|
||||||
|
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||||
|
if (!uniqueIds.length) return null;
|
||||||
|
const chunkSize = sqlChunkSize(1);
|
||||||
|
|
||||||
|
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 sends WHERE user_id = ? AND id IN (${placeholders})`).bind(userId, ...chunk).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateRevisionDate(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllSends(db: D1Database, userId: string): Promise<Send[]> {
|
||||||
|
const res = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE user_id = ? ORDER BY updated_at DESC'
|
||||||
|
)
|
||||||
|
.bind(userId)
|
||||||
|
.all<any>();
|
||||||
|
return (res.results || []).map((row) => mapSendRow(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSendsPage(db: D1Database, userId: string, limit: number, offset: number): Promise<Send[]> {
|
||||||
|
const res = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE user_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?'
|
||||||
|
)
|
||||||
|
.bind(userId, limit, offset)
|
||||||
|
.all<any>();
|
||||||
|
return (res.results || []).map((row) => mapSendRow(row));
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import type { User } from '../types';
|
||||||
|
|
||||||
|
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
|
||||||
|
const USER_SELECT_COLUMNS =
|
||||||
|
'id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, ' +
|
||||||
|
'kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, ' +
|
||||||
|
'totp_secret, totp_recovery_code, created_at, updated_at';
|
||||||
|
|
||||||
|
function mapUserRow(row: any): User {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
email: row.email,
|
||||||
|
name: row.name,
|
||||||
|
masterPasswordHint: row.master_password_hint ?? null,
|
||||||
|
masterPasswordHash: row.master_password_hash,
|
||||||
|
key: row.key,
|
||||||
|
privateKey: row.private_key,
|
||||||
|
publicKey: row.public_key,
|
||||||
|
kdfType: row.kdf_type,
|
||||||
|
kdfIterations: row.kdf_iterations,
|
||||||
|
kdfMemory: row.kdf_memory ?? undefined,
|
||||||
|
kdfParallelism: row.kdf_parallelism ?? undefined,
|
||||||
|
securityStamp: row.security_stamp,
|
||||||
|
role: row.role === 'admin' ? 'admin' : 'user',
|
||||||
|
status: row.status === 'banned' ? 'banned' : 'active',
|
||||||
|
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
|
||||||
|
totpSecret: row.totp_secret ?? null,
|
||||||
|
totpRecoveryCode: row.totp_recovery_code ?? null,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUser(db: D1Database, email: string): Promise<User | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users WHERE email = ?`)
|
||||||
|
.bind(email.toLowerCase())
|
||||||
|
.first<any>();
|
||||||
|
if (!row) return null;
|
||||||
|
return mapUserRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserById(db: D1Database, id: string): Promise<User | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users WHERE id = ?`)
|
||||||
|
.bind(id)
|
||||||
|
.first<any>();
|
||||||
|
if (!row) return null;
|
||||||
|
return mapUserRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserCount(db: D1Database): Promise<number> {
|
||||||
|
const row = await db.prepare('SELECT COUNT(*) AS count FROM users').first<{ count: number }>();
|
||||||
|
return Number(row?.count || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllUsers(db: D1Database): Promise<User[]> {
|
||||||
|
const res = await db
|
||||||
|
.prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users ORDER BY created_at ASC`)
|
||||||
|
.all<any>();
|
||||||
|
return (res.results || []).map((row) => mapUserRow(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
|
||||||
|
const email = user.email.toLowerCase();
|
||||||
|
const stmt = db.prepare(
|
||||||
|
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at) ' +
|
||||||
|
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||||
|
'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' +
|
||||||
|
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at'
|
||||||
|
);
|
||||||
|
await safeBind(
|
||||||
|
stmt,
|
||||||
|
user.id,
|
||||||
|
email,
|
||||||
|
user.name,
|
||||||
|
user.masterPasswordHint,
|
||||||
|
user.masterPasswordHash,
|
||||||
|
user.key,
|
||||||
|
user.privateKey,
|
||||||
|
user.publicKey,
|
||||||
|
user.kdfType,
|
||||||
|
user.kdfIterations,
|
||||||
|
user.kdfMemory,
|
||||||
|
user.kdfParallelism,
|
||||||
|
user.securityStamp,
|
||||||
|
user.role,
|
||||||
|
user.status,
|
||||||
|
user.verifyDevices ? 1 : 0,
|
||||||
|
user.totpSecret,
|
||||||
|
user.totpRecoveryCode,
|
||||||
|
user.createdAt,
|
||||||
|
user.updatedAt
|
||||||
|
).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
|
||||||
|
await saveUser(db, safeBind, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
|
||||||
|
const email = user.email.toLowerCase();
|
||||||
|
const stmt = db.prepare(
|
||||||
|
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at) ' +
|
||||||
|
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||||
|
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
|
||||||
|
);
|
||||||
|
const result = await safeBind(
|
||||||
|
stmt,
|
||||||
|
user.id,
|
||||||
|
email,
|
||||||
|
user.name,
|
||||||
|
user.masterPasswordHint,
|
||||||
|
user.masterPasswordHash,
|
||||||
|
user.key,
|
||||||
|
user.privateKey,
|
||||||
|
user.publicKey,
|
||||||
|
user.kdfType,
|
||||||
|
user.kdfIterations,
|
||||||
|
user.kdfMemory,
|
||||||
|
user.kdfParallelism,
|
||||||
|
user.securityStamp,
|
||||||
|
user.role,
|
||||||
|
user.status,
|
||||||
|
user.verifyDevices ? 1 : 0,
|
||||||
|
user.totpSecret,
|
||||||
|
user.totpRecoveryCode,
|
||||||
|
user.createdAt,
|
||||||
|
user.updatedAt
|
||||||
|
).run();
|
||||||
|
|
||||||
|
return (result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUserById(db: D1Database, id: string): Promise<boolean> {
|
||||||
|
const result = await db.prepare('DELETE FROM users WHERE id = ?').bind(id).run();
|
||||||
|
return (result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
@@ -1,10 +1,21 @@
|
|||||||
// Environment bindings
|
// Environment bindings
|
||||||
export interface Env {
|
export interface Env {
|
||||||
DB: D1Database;
|
DB: D1Database;
|
||||||
ATTACHMENTS: R2Bucket;
|
NOTIFICATIONS_HUB: DurableObjectNamespace;
|
||||||
|
ASSETS?: {
|
||||||
|
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
||||||
|
};
|
||||||
|
// Prefer R2 when available. Optional to support KV-only deployments.
|
||||||
|
ATTACHMENTS?: R2Bucket;
|
||||||
|
// Optional fallback for attachment/send file storage (no credit card required).
|
||||||
|
ATTACHMENTS_KV?: KVNamespace;
|
||||||
JWT_SECRET: string;
|
JWT_SECRET: string;
|
||||||
|
TOTP_SECRET?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UserRole = 'admin' | 'user';
|
||||||
|
export type UserStatus = 'active' | 'banned';
|
||||||
|
|
||||||
// Sample JWT secret used by `.dev.vars.example`.
|
// Sample JWT secret used by `.dev.vars.example`.
|
||||||
// If runtime JWT_SECRET equals this value, treat it as unsafe.
|
// If runtime JWT_SECRET equals this value, treat it as unsafe.
|
||||||
export const DEFAULT_DEV_SECRET = 'Enter-your-JWT-key-here-at-least-32-characters';
|
export const DEFAULT_DEV_SECRET = 'Enter-your-JWT-key-here-at-least-32-characters';
|
||||||
@@ -24,6 +35,7 @@ export interface User {
|
|||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
masterPasswordHint: string | null;
|
||||||
masterPasswordHash: string;
|
masterPasswordHash: string;
|
||||||
key: string;
|
key: string;
|
||||||
privateKey: string | null;
|
privateKey: string | null;
|
||||||
@@ -33,10 +45,35 @@ export interface User {
|
|||||||
kdfMemory?: number;
|
kdfMemory?: number;
|
||||||
kdfParallelism?: number;
|
kdfParallelism?: number;
|
||||||
securityStamp: string;
|
securityStamp: string;
|
||||||
|
role: UserRole;
|
||||||
|
status: UserStatus;
|
||||||
|
verifyDevices?: boolean;
|
||||||
|
totpSecret: string | null;
|
||||||
|
totpRecoveryCode: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Invite {
|
||||||
|
code: string;
|
||||||
|
createdBy: string;
|
||||||
|
usedBy: string | null;
|
||||||
|
expiresAt: string;
|
||||||
|
status: 'active' | 'used' | 'revoked' | 'expired';
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLog {
|
||||||
|
id: string;
|
||||||
|
actorUserId: string | null;
|
||||||
|
action: string;
|
||||||
|
targetType: string | null;
|
||||||
|
targetId: string | null;
|
||||||
|
metadata: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Cipher types
|
// Cipher types
|
||||||
export enum CipherType {
|
export enum CipherType {
|
||||||
Login = 1,
|
Login = 1,
|
||||||
@@ -133,7 +170,10 @@ 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. */
|
||||||
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Folder model
|
// Folder model
|
||||||
@@ -145,6 +185,122 @@ export interface Folder {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Device {
|
||||||
|
userId: string;
|
||||||
|
deviceIdentifier: string;
|
||||||
|
name: string;
|
||||||
|
type: number;
|
||||||
|
sessionStamp: string;
|
||||||
|
encryptedUserKey: string | null;
|
||||||
|
encryptedPublicKey: string | null;
|
||||||
|
encryptedPrivateKey: string | null;
|
||||||
|
devicePendingAuthRequest?: DevicePendingAuthRequest | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DevicePendingAuthRequest {
|
||||||
|
id: string;
|
||||||
|
creationDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceResponse {
|
||||||
|
id: string;
|
||||||
|
userId?: string | null;
|
||||||
|
name: string;
|
||||||
|
identifier: string;
|
||||||
|
type: number;
|
||||||
|
creationDate: string;
|
||||||
|
revisionDate: string;
|
||||||
|
isTrusted: boolean;
|
||||||
|
encryptedUserKey: string | null;
|
||||||
|
encryptedPublicKey: string | null;
|
||||||
|
devicePendingAuthRequest: DevicePendingAuthRequest | null;
|
||||||
|
object: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProtectedDeviceResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
identifier: string;
|
||||||
|
type: number;
|
||||||
|
creationDate: string;
|
||||||
|
encryptedUserKey: string | null;
|
||||||
|
encryptedPublicKey: string | null;
|
||||||
|
object: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshTokenRecord {
|
||||||
|
userId: string;
|
||||||
|
expiresAt: number;
|
||||||
|
deviceIdentifier: string | null;
|
||||||
|
deviceSessionStamp: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrustedDeviceTokenSummary {
|
||||||
|
deviceIdentifier: string;
|
||||||
|
expiresAt: number;
|
||||||
|
tokenCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SendType {
|
||||||
|
Text = 0,
|
||||||
|
File = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SendAuthType {
|
||||||
|
Email = 0,
|
||||||
|
Password = 1,
|
||||||
|
None = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Send {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
type: SendType;
|
||||||
|
name: string;
|
||||||
|
notes: string | null;
|
||||||
|
data: string;
|
||||||
|
key: string;
|
||||||
|
passwordHash: string | null;
|
||||||
|
passwordSalt: string | null;
|
||||||
|
passwordIterations: number | null;
|
||||||
|
authType: SendAuthType;
|
||||||
|
emails: string | null;
|
||||||
|
maxAccessCount: number | null;
|
||||||
|
accessCount: number;
|
||||||
|
disabled: boolean;
|
||||||
|
hideEmail: boolean | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
expirationDate: string | null;
|
||||||
|
deletionDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendResponse {
|
||||||
|
id: string;
|
||||||
|
accessId: string;
|
||||||
|
type: number;
|
||||||
|
name: string;
|
||||||
|
notes: string | null;
|
||||||
|
text: any | null;
|
||||||
|
file: any | null;
|
||||||
|
key: string;
|
||||||
|
maxAccessCount: number | null;
|
||||||
|
accessCount: number;
|
||||||
|
password: string | null;
|
||||||
|
emails: string | null;
|
||||||
|
authType: SendAuthType;
|
||||||
|
disabled: boolean;
|
||||||
|
hideEmail: boolean | null;
|
||||||
|
revisionDate: string;
|
||||||
|
expirationDate: string | null;
|
||||||
|
deletionDate: string;
|
||||||
|
object: string;
|
||||||
|
}
|
||||||
|
|
||||||
// JWT Payload
|
// JWT Payload
|
||||||
export interface JWTPayload {
|
export interface JWTPayload {
|
||||||
sub: string; // user id
|
sub: string; // user id
|
||||||
@@ -153,6 +309,8 @@ export interface JWTPayload {
|
|||||||
email_verified: boolean; // required by mobile client
|
email_verified: boolean; // required by mobile client
|
||||||
amr: string[]; // authentication methods reference - required by mobile client
|
amr: string[]; // authentication methods reference - required by mobile client
|
||||||
sstamp: string; // security stamp - invalidates token when user changes password
|
sstamp: string; // security stamp - invalidates token when user changes password
|
||||||
|
did?: string; // device identifier - invalidates per-device sessions
|
||||||
|
dstamp?: string; // device session stamp
|
||||||
iat: number;
|
iat: number;
|
||||||
exp: number;
|
exp: number;
|
||||||
iss: string;
|
iss: string;
|
||||||
@@ -180,6 +338,8 @@ export interface UserDecryptionOptions {
|
|||||||
Object: string;
|
Object: string;
|
||||||
// Bitwarden Android 2026.1.x expects this to exist; missing it breaks unlock when the vault is empty.
|
// Bitwarden Android 2026.1.x expects this to exist; missing it breaks unlock when the vault is empty.
|
||||||
MasterPasswordUnlock: MasterPasswordUnlock;
|
MasterPasswordUnlock: MasterPasswordUnlock;
|
||||||
|
TrustedDeviceOption: null;
|
||||||
|
KeyConnectorOption: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// API Response types
|
// API Response types
|
||||||
@@ -187,7 +347,9 @@ 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;
|
||||||
Key: string;
|
Key: string;
|
||||||
PrivateKey: string | null;
|
PrivateKey: string | null;
|
||||||
Kdf: number;
|
Kdf: number;
|
||||||
@@ -198,7 +360,18 @@ export interface TokenResponse {
|
|||||||
ResetMasterPassword: boolean;
|
ResetMasterPassword: boolean;
|
||||||
scope: string;
|
scope: string;
|
||||||
unofficialServer: boolean;
|
unofficialServer: boolean;
|
||||||
|
MasterPasswordPolicy?: {
|
||||||
|
Object: string;
|
||||||
|
} | null;
|
||||||
|
ApiUseKeyConnector?: boolean;
|
||||||
|
AccountKeys?: any | null;
|
||||||
|
accountKeys?: any | null;
|
||||||
UserDecryptionOptions: UserDecryptionOptions;
|
UserDecryptionOptions: UserDecryptionOptions;
|
||||||
|
userDecryptionOptions?: UserDecryptionOptions;
|
||||||
|
VaultKeys?: {
|
||||||
|
symEncKey: string;
|
||||||
|
symMacKey: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProfileResponse {
|
export interface ProfileResponse {
|
||||||
@@ -222,6 +395,9 @@ export interface ProfileResponse {
|
|||||||
forcePasswordReset: boolean;
|
forcePasswordReset: boolean;
|
||||||
avatarColor: string | null;
|
avatarColor: string | null;
|
||||||
creationDate: string;
|
creationDate: string;
|
||||||
|
verifyDevices?: boolean;
|
||||||
|
role?: UserRole;
|
||||||
|
status?: UserStatus;
|
||||||
object: string;
|
object: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,6 +430,8 @@ export interface CipherResponse {
|
|||||||
attachments: any[] | null;
|
attachments: any[] | null;
|
||||||
key: string | null;
|
key: string | null;
|
||||||
encryptedFor: string | null;
|
encryptedFor: string | null;
|
||||||
|
/** Allow unknown fields to pass through to clients transparently. */
|
||||||
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CipherPermissions {
|
export interface CipherPermissions {
|
||||||
@@ -275,7 +453,14 @@ export interface SyncResponse {
|
|||||||
ciphers: CipherResponse[];
|
ciphers: CipherResponse[];
|
||||||
domains: any;
|
domains: any;
|
||||||
policies: any[];
|
policies: any[];
|
||||||
sends: any[];
|
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"))
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
const DEFAULT_DEVICE_NAME = 'Unknown device';
|
||||||
|
const DEFAULT_DEVICE_TYPE = 14;
|
||||||
|
|
||||||
|
function decodeBase64UrlUtf8(value: string): string | null {
|
||||||
|
try {
|
||||||
|
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const padding = normalized.length % 4;
|
||||||
|
const padded = padding === 0 ? normalized : normalized + '='.repeat(4 - padding);
|
||||||
|
const binary = atob(padded);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDeviceIdentifier(value: string | undefined | null): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
if (!normalized) return null;
|
||||||
|
return normalized.slice(0, 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDeviceName(value: string | undefined | null): string {
|
||||||
|
const normalized = String(value || '').trim();
|
||||||
|
if (!normalized) return DEFAULT_DEVICE_NAME;
|
||||||
|
return normalized.slice(0, 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDeviceType(value: string | number | undefined | null): number {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return Math.max(0, Math.floor(value));
|
||||||
|
}
|
||||||
|
const parsed = Number.parseInt(String(value || ''), 10);
|
||||||
|
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
|
||||||
|
return DEFAULT_DEVICE_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthRequestDeviceInfo {
|
||||||
|
deviceIdentifier: string | null;
|
||||||
|
deviceName: string;
|
||||||
|
deviceType: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readAuthRequestDeviceInfo(
|
||||||
|
body: Record<string, string | undefined>,
|
||||||
|
request: Request
|
||||||
|
): AuthRequestDeviceInfo {
|
||||||
|
const bodyIdentifier = body.deviceIdentifier || body.device_identifier;
|
||||||
|
const headerIdentifier = request.headers.get('X-Device-Identifier') || undefined;
|
||||||
|
const bodyName = body.deviceName || body.device_name;
|
||||||
|
const headerName = request.headers.get('X-Device-Name') || undefined;
|
||||||
|
const bodyType = body.deviceType || body.device_type;
|
||||||
|
const headerType = request.headers.get('Device-Type') || undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
deviceIdentifier: normalizeDeviceIdentifier(bodyIdentifier || headerIdentifier),
|
||||||
|
deviceName: normalizeDeviceName(bodyName || headerName),
|
||||||
|
deviceType: parseDeviceType(bodyType || headerType),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readKnownDeviceProbe(request: Request): { email: string | null; deviceIdentifier: string | null } {
|
||||||
|
const encodedEmail = request.headers.get('X-Request-Email') || '';
|
||||||
|
const decodedEmail = decodeBase64UrlUtf8(encodedEmail);
|
||||||
|
const fallbackRawEmail = request.headers.get('X-Request-Email');
|
||||||
|
const email = (decodedEmail || fallbackRawEmail || '').trim().toLowerCase() || null;
|
||||||
|
const deviceIdentifier = normalizeDeviceIdentifier(request.headers.get('X-Device-Identifier'));
|
||||||
|
return { email, deviceIdentifier };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readActingDeviceIdentifier(request: Request): string | null {
|
||||||
|
return normalizeDeviceIdentifier(request.headers.get('X-NodeWarden-Acting-Device-Id'));
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { LIMITS } from '../config/limits';
|
||||||
|
import { DEFAULT_DEV_SECRET, Env } from '../types';
|
||||||
|
import { errorResponse } from './response';
|
||||||
|
|
||||||
|
export interface DirectUploadPayload {
|
||||||
|
body: ReadableStream;
|
||||||
|
contentType: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParseDirectUploadOptions {
|
||||||
|
expectedSize?: number | null;
|
||||||
|
expectedFileName?: string | null;
|
||||||
|
maxFileSize: number;
|
||||||
|
tooLargeMessage: string;
|
||||||
|
missingBodyMessage?: string;
|
||||||
|
contentLengthRequiredMessage?: string;
|
||||||
|
sizeMismatchMessage?: string;
|
||||||
|
fileNameMismatchMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDirectUploadUrl(request: Request, path: string, token: string): string {
|
||||||
|
const version = '2023-11-03';
|
||||||
|
const expiresAt = '2099-12-31T23:59:59Z';
|
||||||
|
const origin = new URL(request.url).origin;
|
||||||
|
return `${origin}${path}?sv=${encodeURIComponent(version)}&se=${encodeURIComponent(expiresAt)}&token=${encodeURIComponent(token)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSafeJwtSecret(env: Env): string | null {
|
||||||
|
const secret = (env.JWT_SECRET || '').trim();
|
||||||
|
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseContentLength(request: Request): number | null {
|
||||||
|
const raw = request.headers.get('content-length');
|
||||||
|
if (!raw) return null;
|
||||||
|
const value = Number(raw);
|
||||||
|
if (!Number.isFinite(value) || value < 0) return null;
|
||||||
|
return Math.floor(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseDirectUploadPayload(
|
||||||
|
request: Request,
|
||||||
|
options: ParseDirectUploadOptions
|
||||||
|
): Promise<DirectUploadPayload | Response> {
|
||||||
|
const {
|
||||||
|
expectedSize = null,
|
||||||
|
expectedFileName = null,
|
||||||
|
maxFileSize,
|
||||||
|
tooLargeMessage,
|
||||||
|
missingBodyMessage = 'No file uploaded',
|
||||||
|
contentLengthRequiredMessage = 'Content-Length is required for direct uploads',
|
||||||
|
sizeMismatchMessage,
|
||||||
|
fileNameMismatchMessage,
|
||||||
|
} = options;
|
||||||
|
const contentType = request.headers.get('content-type') || '';
|
||||||
|
|
||||||
|
if (contentType.includes('multipart/form-data')) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('data') as File | null;
|
||||||
|
if (!file) {
|
||||||
|
return errorResponse(missingBodyMessage, 400);
|
||||||
|
}
|
||||||
|
if (file.size > maxFileSize) {
|
||||||
|
return errorResponse(tooLargeMessage, 413);
|
||||||
|
}
|
||||||
|
if (expectedFileName && file.name !== expectedFileName) {
|
||||||
|
return errorResponse(fileNameMismatchMessage || 'File name does not match.', 400);
|
||||||
|
}
|
||||||
|
if (expectedSize !== null && expectedSize !== undefined && file.size !== expectedSize) {
|
||||||
|
return errorResponse(sizeMismatchMessage || 'File size does not match.', 400);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
body: file.stream(),
|
||||||
|
contentType: file.type || 'application/octet-stream',
|
||||||
|
size: file.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.body) {
|
||||||
|
return errorResponse(missingBodyMessage, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const declaredSize = parseContentLength(request);
|
||||||
|
const uploadSize = declaredSize ?? (expectedSize && expectedSize > 0 ? expectedSize : null);
|
||||||
|
if (uploadSize === null) {
|
||||||
|
return errorResponse(contentLengthRequiredMessage, 400);
|
||||||
|
}
|
||||||
|
if (uploadSize > maxFileSize) {
|
||||||
|
return errorResponse(tooLargeMessage, 413);
|
||||||
|
}
|
||||||
|
if (expectedSize !== null && expectedSize !== undefined && uploadSize !== expectedSize) {
|
||||||
|
return errorResponse(sizeMismatchMessage || 'File size does not match.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
body: request.body,
|
||||||
|
contentType: contentType || 'application/octet-stream',
|
||||||
|
size: uploadSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { JWTPayload } from '../types';
|
import { JWTPayload } from '../types';
|
||||||
|
import { LIMITS } from '../config/limits';
|
||||||
|
|
||||||
// Base64 URL encode
|
// Base64 URL encode
|
||||||
function base64UrlEncode(data: Uint8Array): string {
|
function base64UrlEncode(data: Uint8Array): string {
|
||||||
@@ -19,7 +20,7 @@ function base64UrlDecode(str: string): Uint8Array {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create JWT
|
// Create JWT
|
||||||
export async function createJWT(payload: Omit<JWTPayload, 'iat' | 'exp' | 'iss' | 'premium' | 'email_verified' | 'amr'>, secret: string, expiresIn: number = 7200): 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' };
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
@@ -90,13 +91,21 @@ export async function verifyJWT(token: string, secret: string): Promise<JWTPaylo
|
|||||||
|
|
||||||
// Create refresh token (simple random string)
|
// Create refresh token (simple random string)
|
||||||
export function createRefreshToken(): string {
|
export function createRefreshToken(): string {
|
||||||
const bytes = new Uint8Array(32);
|
const bytes = new Uint8Array(LIMITS.auth.refreshTokenRandomBytes);
|
||||||
crypto.getRandomValues(bytes);
|
crypto.getRandomValues(bytes);
|
||||||
return base64UrlEncode(bytes);
|
return base64UrlEncode(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// File download token payload
|
// File download token payload
|
||||||
export interface FileDownloadClaims {
|
export interface FileDownloadClaims {
|
||||||
|
cipherId: string;
|
||||||
|
attachmentId: string;
|
||||||
|
jti: string;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttachmentUploadClaims {
|
||||||
|
userId: string;
|
||||||
cipherId: string;
|
cipherId: string;
|
||||||
attachmentId: string;
|
attachmentId: string;
|
||||||
exp: number;
|
exp: number;
|
||||||
@@ -114,7 +123,8 @@ export async function createFileDownloadToken(
|
|||||||
const payload: FileDownloadClaims = {
|
const payload: FileDownloadClaims = {
|
||||||
cipherId,
|
cipherId,
|
||||||
attachmentId,
|
attachmentId,
|
||||||
exp: now + 300, // 5 minutes
|
jti: createRefreshToken(),
|
||||||
|
exp: now + LIMITS.auth.fileDownloadTokenTtlSeconds, // 5 minutes
|
||||||
};
|
};
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
@@ -174,3 +184,292 @@ export async function verifyFileDownloadToken(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createAttachmentUploadToken(
|
||||||
|
userId: string,
|
||||||
|
cipherId: string,
|
||||||
|
attachmentId: string,
|
||||||
|
secret: string
|
||||||
|
): Promise<string> {
|
||||||
|
const header = { alg: 'HS256', typ: 'JWT' };
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const payload: AttachmentUploadClaims = {
|
||||||
|
userId,
|
||||||
|
cipherId,
|
||||||
|
attachmentId,
|
||||||
|
exp: now + LIMITS.auth.fileDownloadTokenTtlSeconds,
|
||||||
|
};
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header)));
|
||||||
|
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
|
||||||
|
const data = `${headerB64}.${payloadB64}`;
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(secret),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
|
||||||
|
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||||
|
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
||||||
|
return `${data}.${signatureB64}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyAttachmentUploadToken(
|
||||||
|
token: string,
|
||||||
|
secret: string
|
||||||
|
): Promise<AttachmentUploadClaims | null> {
|
||||||
|
try {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
|
||||||
|
const [headerB64, payloadB64, signatureB64] = parts;
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(secret),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['verify']
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = `${headerB64}.${payloadB64}`;
|
||||||
|
const signature = base64UrlDecode(signatureB64);
|
||||||
|
const valid = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(data));
|
||||||
|
if (!valid) return null;
|
||||||
|
|
||||||
|
const payload: AttachmentUploadClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (payload.exp < now) return null;
|
||||||
|
if (!payload.userId || !payload.cipherId || !payload.attachmentId) return null;
|
||||||
|
return payload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendFileDownloadClaims {
|
||||||
|
sendId: string;
|
||||||
|
fileId: string;
|
||||||
|
jti: string;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendFileUploadClaims {
|
||||||
|
userId: string;
|
||||||
|
sendId: string;
|
||||||
|
fileId: string;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSendFileDownloadToken(
|
||||||
|
sendId: string,
|
||||||
|
fileId: string,
|
||||||
|
secret: string
|
||||||
|
): Promise<string> {
|
||||||
|
const header = { alg: 'HS256', typ: 'JWT' };
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const payload: SendFileDownloadClaims = {
|
||||||
|
sendId,
|
||||||
|
fileId,
|
||||||
|
jti: createRefreshToken(),
|
||||||
|
exp: now + LIMITS.auth.fileDownloadTokenTtlSeconds,
|
||||||
|
};
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header)));
|
||||||
|
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
|
||||||
|
const data = `${headerB64}.${payloadB64}`;
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(secret),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
|
||||||
|
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||||
|
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
||||||
|
return `${data}.${signatureB64}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifySendFileDownloadToken(
|
||||||
|
token: string,
|
||||||
|
secret: string
|
||||||
|
): Promise<SendFileDownloadClaims | null> {
|
||||||
|
try {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
|
||||||
|
const [headerB64, payloadB64, signatureB64] = parts;
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(secret),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['verify']
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = `${headerB64}.${payloadB64}`;
|
||||||
|
const signature = base64UrlDecode(signatureB64);
|
||||||
|
const valid = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(data));
|
||||||
|
if (!valid) return null;
|
||||||
|
|
||||||
|
const payload: SendFileDownloadClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
|
||||||
|
if (
|
||||||
|
typeof payload.sendId !== 'string' ||
|
||||||
|
typeof payload.fileId !== 'string' ||
|
||||||
|
typeof payload.jti !== 'string' ||
|
||||||
|
!payload.jti ||
|
||||||
|
typeof payload.exp !== 'number'
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (payload.exp < now) return null;
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSendFileUploadToken(
|
||||||
|
userId: string,
|
||||||
|
sendId: string,
|
||||||
|
fileId: string,
|
||||||
|
secret: string
|
||||||
|
): Promise<string> {
|
||||||
|
const header = { alg: 'HS256', typ: 'JWT' };
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const payload: SendFileUploadClaims = {
|
||||||
|
userId,
|
||||||
|
sendId,
|
||||||
|
fileId,
|
||||||
|
exp: now + LIMITS.auth.fileDownloadTokenTtlSeconds,
|
||||||
|
};
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header)));
|
||||||
|
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
|
||||||
|
const data = `${headerB64}.${payloadB64}`;
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(secret),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
|
||||||
|
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||||
|
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
||||||
|
return `${data}.${signatureB64}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifySendFileUploadToken(
|
||||||
|
token: string,
|
||||||
|
secret: string
|
||||||
|
): Promise<SendFileUploadClaims | null> {
|
||||||
|
try {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
|
||||||
|
const [headerB64, payloadB64, signatureB64] = parts;
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(secret),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['verify']
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = `${headerB64}.${payloadB64}`;
|
||||||
|
const signature = base64UrlDecode(signatureB64);
|
||||||
|
const valid = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(data));
|
||||||
|
if (!valid) return null;
|
||||||
|
|
||||||
|
const payload: SendFileUploadClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (payload.exp < now) return null;
|
||||||
|
if (!payload.userId || !payload.sendId || !payload.fileId) return null;
|
||||||
|
return payload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendAccessTokenClaims {
|
||||||
|
sub: string; // send id
|
||||||
|
typ: 'send_access';
|
||||||
|
iat: number;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSendAccessToken(sendId: string, secret: string): Promise<string> {
|
||||||
|
const header = { alg: 'HS256', typ: 'JWT' };
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const payload: SendAccessTokenClaims = {
|
||||||
|
sub: sendId,
|
||||||
|
typ: 'send_access',
|
||||||
|
iat: now,
|
||||||
|
exp: now + LIMITS.auth.sendAccessTokenTtlSeconds,
|
||||||
|
};
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header)));
|
||||||
|
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
|
||||||
|
const data = `${headerB64}.${payloadB64}`;
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(secret),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||||
|
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
||||||
|
return `${data}.${signatureB64}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifySendAccessToken(token: string, secret: string): Promise<SendAccessTokenClaims | null> {
|
||||||
|
try {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
|
||||||
|
const [headerB64, payloadB64, signatureB64] = parts;
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(secret),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['verify']
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = `${headerB64}.${payloadB64}`;
|
||||||
|
const signature = base64UrlDecode(signatureB64);
|
||||||
|
const valid = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(data));
|
||||||
|
if (!valid) return null;
|
||||||
|
|
||||||
|
const payload: SendAccessTokenClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (payload.exp < now) return null;
|
||||||
|
if (payload.typ !== 'send_access') return null;
|
||||||
|
if (!payload.sub) return null;
|
||||||
|
return payload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { LIMITS } from '../config/limits';
|
||||||
|
|
||||||
|
const MAX_PAGE_SIZE = LIMITS.pagination.maxPageSize;
|
||||||
|
|
||||||
|
export interface PaginationRequest {
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePagination(url: URL): PaginationRequest | null {
|
||||||
|
const pageSizeRaw = url.searchParams.get('pageSize');
|
||||||
|
const continuationToken = url.searchParams.get('continuationToken');
|
||||||
|
if (!pageSizeRaw && !continuationToken) return null;
|
||||||
|
|
||||||
|
const pageSize = pageSizeRaw ? Number(pageSizeRaw) : LIMITS.pagination.defaultPageSize;
|
||||||
|
if (!Number.isInteger(pageSize) || pageSize <= 0) return null;
|
||||||
|
|
||||||
|
const limit = Math.min(pageSize, MAX_PAGE_SIZE);
|
||||||
|
const offset = decodeContinuationToken(continuationToken);
|
||||||
|
|
||||||
|
return { limit, offset };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeContinuationToken(offset: number): string {
|
||||||
|
return btoa(String(offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeContinuationToken(token: string | null): number {
|
||||||
|
if (!token) return 0;
|
||||||
|
try {
|
||||||
|
const decoded = atob(token);
|
||||||
|
const offset = Number(decoded);
|
||||||
|
if (!Number.isInteger(offset) || offset < 0) return 0;
|
||||||
|
return offset;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
const RECOVERY_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||||
|
const RECOVERY_ALPHABET_LENGTH = RECOVERY_ALPHABET.length;
|
||||||
|
const RECOVERY_MAX_UNBIASED_BYTE = Math.floor(256 / RECOVERY_ALPHABET_LENGTH) * RECOVERY_ALPHABET_LENGTH;
|
||||||
|
|
||||||
|
function normalizeRecoveryCode(raw: string): string {
|
||||||
|
return String(raw || '').toUpperCase().replace(/[^A-Z2-7]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRecoveryCode(compact: string): string {
|
||||||
|
return compact.replace(/(.{4})/g, '$1 ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRecoveryCode(): string {
|
||||||
|
let compact = '';
|
||||||
|
while (compact.length < 32) {
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||||
|
for (const b of bytes) {
|
||||||
|
if (b >= RECOVERY_MAX_UNBIASED_BYTE) continue;
|
||||||
|
compact += RECOVERY_ALPHABET[b % RECOVERY_ALPHABET_LENGTH];
|
||||||
|
if (compact.length >= 32) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return formatRecoveryCode(compact.slice(0, 32));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recoveryCodeEquals(input: string, storedCode: string | null | undefined): boolean {
|
||||||
|
if (!storedCode) return false;
|
||||||
|
const a = new TextEncoder().encode(normalizeRecoveryCode(input));
|
||||||
|
const b = new TextEncoder().encode(normalizeRecoveryCode(storedCode));
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
diff |= a[i] ^ b[i];
|
||||||
|
}
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
@@ -1,10 +1,117 @@
|
|||||||
|
import { LIMITS } from '../config/limits';
|
||||||
|
|
||||||
|
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
|
||||||
|
const DEFAULT_CORS_HEADERS = [
|
||||||
|
'Content-Type',
|
||||||
|
'Authorization',
|
||||||
|
'Accept',
|
||||||
|
'Device-Type',
|
||||||
|
'Device-Identifier',
|
||||||
|
'Device-Name',
|
||||||
|
'Bitwarden-Client-Name',
|
||||||
|
'Bitwarden-Client-Version',
|
||||||
|
'Bitwarden-Package-Type',
|
||||||
|
'Is-Prerelease',
|
||||||
|
'X-Request-Email',
|
||||||
|
'X-Device-Identifier',
|
||||||
|
'X-Device-Name',
|
||||||
|
'X-NodeWarden-Web-Session',
|
||||||
|
];
|
||||||
|
|
||||||
|
function isExtensionOrigin(origin: string): boolean {
|
||||||
|
return (
|
||||||
|
origin.startsWith('chrome-extension://')
|
||||||
|
|| origin.startsWith('moz-extension://')
|
||||||
|
|| origin.startsWith('safari-web-extension://')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWildcardCorsPath(path: string): boolean {
|
||||||
|
return (
|
||||||
|
path.startsWith('/icons/')
|
||||||
|
|| path === '/config'
|
||||||
|
|| path === '/api/config'
|
||||||
|
|| path === '/api/version'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCredentials: boolean } {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const origin = request.headers.get('Origin');
|
||||||
|
if (isWildcardCorsPath(url.pathname)) {
|
||||||
|
return { allowOrigin: '*', allowCredentials: false };
|
||||||
|
}
|
||||||
|
if (!origin) {
|
||||||
|
return { allowOrigin: null, allowCredentials: false };
|
||||||
|
}
|
||||||
|
if (origin === url.origin) {
|
||||||
|
return { allowOrigin: origin, allowCredentials: true };
|
||||||
|
}
|
||||||
|
if (isExtensionOrigin(origin)) {
|
||||||
|
return { allowOrigin: origin, allowCredentials: false };
|
||||||
|
}
|
||||||
|
return { allowOrigin: null, allowCredentials: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCorsHeaders(request: Request): Record<string, string> {
|
||||||
|
const requestedHeaders = String(request.headers.get('Access-Control-Request-Headers') || '')
|
||||||
|
.split(',')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const allowHeaders = Array.from(new Set([...DEFAULT_CORS_HEADERS, ...requestedHeaders]));
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Access-Control-Allow-Methods': CORS_METHODS,
|
||||||
|
'Access-Control-Allow-Headers': allowHeaders.join(', '),
|
||||||
|
'Access-Control-Expose-Headers': '*',
|
||||||
|
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
|
||||||
|
};
|
||||||
|
|
||||||
|
const corsPolicy = getCorsPolicy(request);
|
||||||
|
if (corsPolicy.allowOrigin) {
|
||||||
|
headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin;
|
||||||
|
if (corsPolicy.allowCredentials) {
|
||||||
|
headers['Access-Control-Allow-Credentials'] = 'true';
|
||||||
|
}
|
||||||
|
headers['Vary'] = 'Origin, Access-Control-Request-Headers';
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyCors(
|
||||||
|
request: Request,
|
||||||
|
response: Response
|
||||||
|
): Response {
|
||||||
|
// WebSocket upgrade responses must be returned untouched.
|
||||||
|
const webSocket = (response as Response & { webSocket?: unknown }).webSocket;
|
||||||
|
if (response.status === 101 || webSocket) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = new Headers(response.headers);
|
||||||
|
const corsHeaders = buildCorsHeaders(request);
|
||||||
|
for (const [k, v] of Object.entries(corsHeaders)) {
|
||||||
|
headers.set(k, v);
|
||||||
|
}
|
||||||
|
// Security headers applied to every response.
|
||||||
|
headers.set('X-Frame-Options', 'DENY');
|
||||||
|
headers.set('X-Content-Type-Options', 'nosniff');
|
||||||
|
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
|
headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:");
|
||||||
|
return new Response(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// JSON response helper
|
// JSON response helper
|
||||||
export function jsonResponse(data: any, status: number = 200, headers: Record<string, string> = {}): Response {
|
export function jsonResponse(data: any, status: number = 200, headers: Record<string, string> = {}): Response {
|
||||||
return new Response(JSON.stringify(data), {
|
return new Response(JSON.stringify(data), {
|
||||||
status,
|
status,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...getCorsHeaders(),
|
|
||||||
...headers,
|
...headers,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -40,21 +147,11 @@ export function identityErrorResponse(message: string, error: string = 'invalid_
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// CORS headers
|
|
||||||
export function getCorsHeaders(): Record<string, string> {
|
|
||||||
return {
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
|
||||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Accept, Device-Type, Bitwarden-Client-Name, Bitwarden-Client-Version',
|
|
||||||
'Access-Control-Max-Age': '86400',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle CORS preflight
|
// Handle CORS preflight
|
||||||
export function handleCors(): Response {
|
export function handleCors(request: Request): Response {
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 204,
|
status: 204,
|
||||||
headers: getCorsHeaders(),
|
headers: buildCorsHeaders(request),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +161,6 @@ export function htmlResponse(html: string, status: number = 200): Response {
|
|||||||
status,
|
status,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'text/html; charset=utf-8',
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
...getCorsHeaders(),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
const TOTP_STEP_SECONDS = 30;
|
||||||
|
const TOTP_DIGITS = 6;
|
||||||
|
const TOTP_WINDOW = 1; // allow previous/current/next step for small clock drift
|
||||||
|
|
||||||
|
function normalizeBase32(input: string): string {
|
||||||
|
const raw = String(input || '').toUpperCase();
|
||||||
|
let out = '';
|
||||||
|
for (const char of raw) {
|
||||||
|
if (char === ' ' || char === '\t' || char === '\n' || char === '\r' || char === '-') continue;
|
||||||
|
out += char;
|
||||||
|
}
|
||||||
|
while (out.endsWith('=')) {
|
||||||
|
out = out.slice(0, -1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function base32Decode(input: string): Uint8Array | null {
|
||||||
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||||
|
const normalized = normalizeBase32(input);
|
||||||
|
if (!normalized) return null;
|
||||||
|
|
||||||
|
let bits = 0;
|
||||||
|
let value = 0;
|
||||||
|
const output: number[] = [];
|
||||||
|
|
||||||
|
for (const char of normalized) {
|
||||||
|
const idx = alphabet.indexOf(char);
|
||||||
|
if (idx === -1) return null;
|
||||||
|
value = (value << 5) | idx;
|
||||||
|
bits += 5;
|
||||||
|
if (bits >= 8) {
|
||||||
|
bits -= 8;
|
||||||
|
output.push((value >> bits) & 0xff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.length > 0 ? new Uint8Array(output) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hotp(secret: Uint8Array, counter: number): Promise<string> {
|
||||||
|
const counterBytes = new Uint8Array(8);
|
||||||
|
let c = counter;
|
||||||
|
for (let i = 7; i >= 0; i--) {
|
||||||
|
counterBytes[i] = c & 0xff;
|
||||||
|
c = Math.floor(c / 256);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
secret,
|
||||||
|
{ name: 'HMAC', hash: 'SHA-1' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
|
||||||
|
const signature = new Uint8Array(await crypto.subtle.sign('HMAC', key, counterBytes));
|
||||||
|
const offset = signature[signature.length - 1] & 0x0f;
|
||||||
|
const binary =
|
||||||
|
((signature[offset] & 0x7f) << 24) |
|
||||||
|
((signature[offset + 1] & 0xff) << 16) |
|
||||||
|
((signature[offset + 2] & 0xff) << 8) |
|
||||||
|
(signature[offset + 3] & 0xff);
|
||||||
|
|
||||||
|
const otp = binary % (10 ** TOTP_DIGITS);
|
||||||
|
return otp.toString().padStart(TOTP_DIGITS, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToken(token: string): string {
|
||||||
|
return token.replace(/\s+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs: number = Date.now()): Promise<boolean> {
|
||||||
|
const token = normalizeToken(tokenRaw);
|
||||||
|
if (!/^\d{6}$/.test(token)) return false;
|
||||||
|
|
||||||
|
const secret = base32Decode(secretRaw);
|
||||||
|
if (!secret) return false;
|
||||||
|
|
||||||
|
const currentCounter = Math.floor(nowMs / 1000 / TOTP_STEP_SECONDS);
|
||||||
|
let matched = false;
|
||||||
|
for (let delta = -TOTP_WINDOW; delta <= TOTP_WINDOW; delta++) {
|
||||||
|
const expected = await hotp(secret, currentCounter + delta);
|
||||||
|
// Constant-time comparison: always check all windows, never short-circuit.
|
||||||
|
const a = new TextEncoder().encode(expected);
|
||||||
|
const b = new TextEncoder().encode(token);
|
||||||
|
let diff = a.length ^ b.length;
|
||||||
|
for (let i = 0; i < a.length && i < b.length; i++) {
|
||||||
|
diff |= a[i] ^ b[i];
|
||||||
|
}
|
||||||
|
if (diff === 0) matched = true;
|
||||||
|
}
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTotpEnabled(secretRaw: string | undefined | null): boolean {
|
||||||
|
return Boolean(secretRaw && normalizeBase32(secretRaw).length > 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { User, UserDecryptionOptions } from '../types';
|
||||||
|
|
||||||
|
function normalizeOptionalPublicKey(value: unknown): string {
|
||||||
|
if (value == null) return '';
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAccountKeys(user: Pick<User, 'privateKey' | 'publicKey'>): Record<string, unknown> | null {
|
||||||
|
if (!user.privateKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = normalizeOptionalPublicKey(user.publicKey);
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicKeyEncryptionKeyPair: {
|
||||||
|
wrappedPrivateKey: user.privateKey,
|
||||||
|
publicKey,
|
||||||
|
Object: 'publicKeyEncryptionKeyPair',
|
||||||
|
},
|
||||||
|
Object: 'privateKeys',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMasterPasswordUnlock(
|
||||||
|
user: Pick<User, 'email' | 'key' | 'kdfType' | 'kdfIterations' | 'kdfMemory' | 'kdfParallelism'>
|
||||||
|
): UserDecryptionOptions['MasterPasswordUnlock'] {
|
||||||
|
return {
|
||||||
|
Kdf: {
|
||||||
|
KdfType: user.kdfType,
|
||||||
|
Iterations: user.kdfIterations,
|
||||||
|
Memory: user.kdfMemory ?? null,
|
||||||
|
Parallelism: user.kdfParallelism ?? null,
|
||||||
|
},
|
||||||
|
MasterKeyEncryptedUserKey: user.key,
|
||||||
|
MasterKeyWrappedUserKey: user.key,
|
||||||
|
Salt: user.email.toLowerCase(),
|
||||||
|
Object: 'masterPasswordUnlock',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildUserDecryptionOptions(
|
||||||
|
user: Pick<User, 'email' | 'key' | 'kdfType' | 'kdfIterations' | 'kdfMemory' | 'kdfParallelism'>
|
||||||
|
): UserDecryptionOptions {
|
||||||
|
return {
|
||||||
|
HasMasterPassword: true,
|
||||||
|
Object: 'userDecryptionOptions',
|
||||||
|
MasterPasswordUnlock: buildMasterPasswordUnlock(user),
|
||||||
|
TrustedDeviceOption: null,
|
||||||
|
KeyConnectorOption: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildUserDecryptionCompat(
|
||||||
|
user: Pick<User, 'email' | 'key' | 'kdfType' | 'kdfIterations' | 'kdfMemory' | 'kdfParallelism'>
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
masterPasswordUnlock: {
|
||||||
|
kdf: {
|
||||||
|
kdfType: user.kdfType,
|
||||||
|
iterations: user.kdfIterations,
|
||||||
|
memory: user.kdfMemory ?? null,
|
||||||
|
parallelism: user.kdfParallelism ?? null,
|
||||||
|
},
|
||||||
|
masterKeyWrappedUserKey: user.key,
|
||||||
|
masterKeyEncryptedUserKey: user.key,
|
||||||
|
salt: user.email.toLowerCase(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,6 +15,6 @@
|
|||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false
|
"noUnusedParameters": false
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*", "shared/**/*"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<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" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<title>NodeWarden</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,179 @@
|
|||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
import { ChevronLeft, ChevronRight, Clipboard, Plus, RefreshCw, Trash2, UserCheck, UserX } from 'lucide-preact';
|
||||||
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
|
import type { AdminInvite, AdminUser } from '@/lib/types';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
|
interface AdminPageProps {
|
||||||
|
currentUserId: string;
|
||||||
|
users: AdminUser[];
|
||||||
|
invites: AdminInvite[];
|
||||||
|
onRefresh: () => void;
|
||||||
|
onCreateInvite: (hours: number) => Promise<void>;
|
||||||
|
onDeleteAllInvites: () => Promise<void>;
|
||||||
|
onToggleUserStatus: (userId: string, currentStatus: 'active' | 'banned') => Promise<void>;
|
||||||
|
onDeleteUser: (userId: string) => Promise<void>;
|
||||||
|
onRevokeInvite: (code: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPage(props: AdminPageProps) {
|
||||||
|
const [inviteHours, setInviteHours] = useState(168);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const pageSize = 20;
|
||||||
|
const formatExpiresAt = (x?: string) => (x ? new Date(x).toLocaleString() : t('txt_dash'));
|
||||||
|
const totalPages = Math.max(1, Math.ceil(props.invites.length / pageSize));
|
||||||
|
const safePage = Math.min(page, totalPages);
|
||||||
|
const pagedInvites = props.invites.slice((safePage - 1) * pageSize, safePage * pageSize);
|
||||||
|
|
||||||
|
const roleText = (role: string) => {
|
||||||
|
const normalized = String(role || '').toLowerCase();
|
||||||
|
if (normalized === 'admin') return t('txt_role_admin');
|
||||||
|
if (normalized === 'user') return t('txt_role_user');
|
||||||
|
return role || '-';
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusText = (status: string) => {
|
||||||
|
const normalized = String(status || '').toLowerCase();
|
||||||
|
if (normalized === 'active') return t('txt_status_active');
|
||||||
|
if (normalized === 'banned') return t('txt_status_banned');
|
||||||
|
if (normalized === 'inactive') return t('txt_status_inactive');
|
||||||
|
return status || '-';
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeToggleableStatus = (status: string): 'active' | 'banned' | null => {
|
||||||
|
const normalized = String(status || '').toLowerCase();
|
||||||
|
if (normalized === 'active' || normalized === 'banned') return normalized;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
<section className="card">
|
||||||
|
<h3>{t('txt_users')}</h3>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('txt_email')}</th>
|
||||||
|
<th>{t('txt_name')}</th>
|
||||||
|
<th>{t('txt_role')}</th>
|
||||||
|
<th>{t('txt_status')}</th>
|
||||||
|
<th>{t('txt_actions')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{props.users.map((user) => {
|
||||||
|
const toggleableStatus = normalizeToggleableStatus(user.status);
|
||||||
|
return (
|
||||||
|
<tr key={user.id}>
|
||||||
|
<td data-label={t('txt_email')}>{user.email}</td>
|
||||||
|
<td data-label={t('txt_name')}>{user.name || t('txt_dash')}</td>
|
||||||
|
<td data-label={t('txt_role')}>{roleText(user.role)}</td>
|
||||||
|
<td data-label={t('txt_status')}>{statusText(user.status)}</td>
|
||||||
|
<td data-label={t('txt_actions')}>
|
||||||
|
<div className="actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={user.id === props.currentUserId || !toggleableStatus}
|
||||||
|
onClick={() => {
|
||||||
|
if (!toggleableStatus) return;
|
||||||
|
void props.onToggleUserStatus(user.id, toggleableStatus);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.status === 'active' ? <UserX size={14} className="btn-icon" /> : <UserCheck size={14} className="btn-icon" />}
|
||||||
|
{user.status === 'active' ? t('txt_ban') : t('txt_unban')}
|
||||||
|
</button>
|
||||||
|
{user.role !== 'admin' && (
|
||||||
|
<button type="button" className="btn btn-danger" onClick={() => void props.onDeleteUser(user.id)}>
|
||||||
|
<Trash2 size={14} className="btn-icon" />
|
||||||
|
{t('txt_delete')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<div className="section-head">
|
||||||
|
<h3>{t('txt_invites')}</h3>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={props.onRefresh}>
|
||||||
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="invite-toolbar">
|
||||||
|
<div className="actions invite-create-group">
|
||||||
|
<label className="field invite-hours-field">
|
||||||
|
<span>{t('txt_invite_validity_hours')}</span>
|
||||||
|
<input
|
||||||
|
className="input small"
|
||||||
|
type="number"
|
||||||
|
value={inviteHours}
|
||||||
|
min={1}
|
||||||
|
max={720}
|
||||||
|
onInput={(e) => setInviteHours(Number((e.currentTarget as HTMLInputElement).value || 168))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => void props.onCreateInvite(inviteHours)}>
|
||||||
|
<Plus size={14} className="btn-icon" />
|
||||||
|
{t('txt_create_timed_invite')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-danger" onClick={() => void props.onDeleteAllInvites()}>
|
||||||
|
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_all')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('txt_code')}</th>
|
||||||
|
<th>{t('txt_status')}</th>
|
||||||
|
<th>{t('txt_expires_at')}</th>
|
||||||
|
<th className="invite-actions-head">{t('txt_actions')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{pagedInvites.map((invite) => (
|
||||||
|
<tr key={invite.code}>
|
||||||
|
<td data-label={t('txt_code')}>{invite.code}</td>
|
||||||
|
<td data-label={t('txt_status')}>{statusText(invite.status)}</td>
|
||||||
|
<td data-label={t('txt_expires_at')}>{formatExpiresAt(invite.expiresAt)}</td>
|
||||||
|
<td data-label={t('txt_actions')}>
|
||||||
|
<div className="actions invite-row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => void copyTextToClipboard(invite.inviteLink || '', { successMessage: t('txt_link_copied') })}
|
||||||
|
>
|
||||||
|
<Clipboard size={14} className="btn-icon" /> {t('txt_copy_link')}
|
||||||
|
</button>
|
||||||
|
{invite.status === 'active' && (
|
||||||
|
<button type="button" className="btn btn-danger" onClick={() => void props.onRevokeInvite(invite.code)}>
|
||||||
|
<Trash2 size={14} className="btn-icon" /> {t('txt_revoke')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={safePage <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||||
|
<ChevronLeft size={14} className="btn-icon" />
|
||||||
|
{t('txt_prev')}
|
||||||
|
</button>
|
||||||
|
<span className="muted-inline">{safePage} / {totalPages}</span>
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={safePage >= totalPages} onClick={() => setPage((p) => Math.min(totalPages, p + 1))}>
|
||||||
|
{t('txt_next')}
|
||||||
|
<ChevronRight size={14} className="btn-icon" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||||
|
import { Link } from 'wouter';
|
||||||
|
import AppMainRoutes from '@/components/AppMainRoutes';
|
||||||
|
import ThemeSwitch from '@/components/ThemeSwitch';
|
||||||
|
import type { AppMainRoutesProps } from '@/components/AppMainRoutes';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
import type { Profile } from '@/lib/types';
|
||||||
|
|
||||||
|
interface AppAuthenticatedShellProps {
|
||||||
|
profile: Profile | null;
|
||||||
|
location: string;
|
||||||
|
mobilePrimaryRoute: string;
|
||||||
|
currentPageTitle: string;
|
||||||
|
showSidebarToggle: boolean;
|
||||||
|
sidebarToggleTitle: string;
|
||||||
|
settingsAccountRoute: string;
|
||||||
|
importRoute: string;
|
||||||
|
isImportRoute: boolean;
|
||||||
|
darkMode: boolean;
|
||||||
|
themeToggleTitle: string;
|
||||||
|
onLock: () => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
onToggleTheme: () => void;
|
||||||
|
mainRoutesProps: AppMainRoutesProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
|
||||||
|
const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-page">
|
||||||
|
<div className="app-shell">
|
||||||
|
<header className="topbar">
|
||||||
|
<div className="brand">
|
||||||
|
<img src="/logo-64.png" alt="NodeWarden logo" className="brand-logo" />
|
||||||
|
<span className="brand-name">NodeWarden</span>
|
||||||
|
<span className="mobile-page-title">{props.currentPageTitle}</span>
|
||||||
|
</div>
|
||||||
|
<div className="topbar-actions">
|
||||||
|
<div className="user-chip">
|
||||||
|
<ShieldUser size={16} />
|
||||||
|
<span>{props.profile?.email}</span>
|
||||||
|
</div>
|
||||||
|
<ThemeSwitch checked={props.darkMode} title={props.themeToggleTitle} onToggle={props.onToggleTheme} />
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={props.onLock}>
|
||||||
|
<Lock size={14} className="btn-icon" /> {t('txt_lock')}
|
||||||
|
</button>
|
||||||
|
{props.showSidebarToggle && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small mobile-sidebar-toggle"
|
||||||
|
aria-label={props.sidebarToggleTitle}
|
||||||
|
title={props.sidebarToggleTitle}
|
||||||
|
onClick={() => window.dispatchEvent(new CustomEvent('nodewarden:toggle-sidebar'))}
|
||||||
|
>
|
||||||
|
<FolderIcon size={16} className="btn-icon" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="mobile-theme-btn">
|
||||||
|
<ThemeSwitch checked={props.darkMode} title={props.themeToggleTitle} onToggle={props.onToggleTheme} />
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-secondary small mobile-lock-btn" aria-label={t('txt_lock')} title={t('txt_lock')} onClick={props.onLock}>
|
||||||
|
<Lock size={14} className="btn-icon" />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={props.onLogout}>
|
||||||
|
<LogOut size={14} className="btn-icon" /> {t('txt_sign_out')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="app-main">
|
||||||
|
<aside className="app-side">
|
||||||
|
<Link href="/vault" className={`side-link ${props.location === '/vault' ? 'active' : ''}`}>
|
||||||
|
<KeyRound size={16} />
|
||||||
|
<span>{t('nav_my_vault')}</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/vault/totp" className={`side-link ${props.location === '/vault/totp' ? 'active' : ''}`}>
|
||||||
|
<Clock3 size={16} />
|
||||||
|
<span>{t('txt_verification_code')}</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/sends" className={`side-link ${props.location === '/sends' ? 'active' : ''}`}>
|
||||||
|
<SendIcon size={16} />
|
||||||
|
<span>{t('nav_sends')}</span>
|
||||||
|
</Link>
|
||||||
|
{props.profile?.role === 'admin' && (
|
||||||
|
<Link href="/admin" className={`side-link ${props.location === '/admin' ? 'active' : ''}`}>
|
||||||
|
<ShieldUser size={16} />
|
||||||
|
<span>{t('nav_admin_panel')}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Link href={props.settingsAccountRoute} className={`side-link ${props.location === props.settingsAccountRoute ? 'active' : ''}`}>
|
||||||
|
<SettingsIcon size={16} />
|
||||||
|
<span>{t('nav_account_settings')}</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/security/devices" className={`side-link ${props.location === '/security/devices' ? 'active' : ''}`}>
|
||||||
|
<Shield size={16} />
|
||||||
|
<span>{t('nav_device_management')}</span>
|
||||||
|
</Link>
|
||||||
|
{props.profile?.role === 'admin' && (
|
||||||
|
<Link href="/backup" className={`side-link ${props.location === '/backup' ? 'active' : ''}`}>
|
||||||
|
<Cloud size={16} />
|
||||||
|
<span>{t('nav_backup_strategy')}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Link href={props.importRoute} className={`side-link ${props.isImportRoute ? 'active' : ''}`}>
|
||||||
|
<ArrowUpDown size={14} />
|
||||||
|
<span>{t('nav_import_export')}</span>
|
||||||
|
</Link>
|
||||||
|
</aside>
|
||||||
|
<main className="content">
|
||||||
|
<div key={routeAnimationKey} className="route-stage">
|
||||||
|
<AppMainRoutes {...props.mainRoutesProps} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="mobile-tabbar" aria-label={t('txt_menu')}>
|
||||||
|
<Link href="/vault" className={`mobile-tab ${props.mobilePrimaryRoute === '/vault' ? 'active' : ''}`}>
|
||||||
|
<KeyRound size={18} />
|
||||||
|
<span>{t('nav_my_vault')}</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/vault/totp" className={`mobile-tab ${props.mobilePrimaryRoute === '/vault/totp' ? 'active' : ''}`}>
|
||||||
|
<Clock3 size={18} />
|
||||||
|
<span>{t('txt_verification_code')}</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/sends" className={`mobile-tab ${props.mobilePrimaryRoute === '/sends' ? 'active' : ''}`}>
|
||||||
|
<SendIcon size={18} />
|
||||||
|
<span>{t('nav_sends')}</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/settings" className={`mobile-tab ${props.mobilePrimaryRoute === '/settings' ? 'active' : ''}`}>
|
||||||
|
<SettingsIcon size={18} />
|
||||||
|
<span>{t('txt_settings')}</span>
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
|
import ToastHost from '@/components/ToastHost';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
import type { ToastMessage } from '@/lib/types';
|
||||||
|
|
||||||
|
export interface AppConfirmState {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
danger?: boolean;
|
||||||
|
showIcon?: boolean;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
hideCancel?: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppGlobalOverlaysProps {
|
||||||
|
toasts: ToastMessage[];
|
||||||
|
onCloseToast: (id: string) => void;
|
||||||
|
confirm: AppConfirmState | null;
|
||||||
|
onCancelConfirm: () => void;
|
||||||
|
pendingTotpOpen: boolean;
|
||||||
|
totpCode: string;
|
||||||
|
rememberDevice: boolean;
|
||||||
|
onTotpCodeChange: (value: string) => void;
|
||||||
|
onRememberDeviceChange: (checked: boolean) => void;
|
||||||
|
onConfirmTotp: () => void;
|
||||||
|
onCancelTotp: () => void;
|
||||||
|
onUseRecoveryCode: () => void;
|
||||||
|
totpSubmitting: boolean;
|
||||||
|
disableTotpOpen: boolean;
|
||||||
|
disableTotpPassword: string;
|
||||||
|
onDisableTotpPasswordChange: (value: string) => void;
|
||||||
|
onConfirmDisableTotp: () => void;
|
||||||
|
onCancelDisableTotp: () => void;
|
||||||
|
disableTotpSubmitting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!props.confirm}
|
||||||
|
title={props.confirm?.title || ''}
|
||||||
|
message={props.confirm?.message || ''}
|
||||||
|
danger={props.confirm?.danger}
|
||||||
|
showIcon={props.confirm?.showIcon}
|
||||||
|
confirmText={props.confirm?.confirmText}
|
||||||
|
cancelText={props.confirm?.cancelText}
|
||||||
|
hideCancel={props.confirm?.hideCancel}
|
||||||
|
onConfirm={() => props.confirm?.onConfirm()}
|
||||||
|
onCancel={props.onCancelConfirm}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={props.pendingTotpOpen}
|
||||||
|
title={t('txt_two_step_verification')}
|
||||||
|
message={t('txt_password_is_already_verified')}
|
||||||
|
confirmText={t('txt_verify')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
showIcon={false}
|
||||||
|
confirmDisabled={props.totpSubmitting}
|
||||||
|
cancelDisabled={props.totpSubmitting}
|
||||||
|
onConfirm={props.onConfirmTotp}
|
||||||
|
onCancel={props.onCancelTotp}
|
||||||
|
afterActions={(
|
||||||
|
<div className="dialog-extra">
|
||||||
|
<div className="dialog-divider" />
|
||||||
|
<button type="button" className="btn btn-secondary dialog-btn" disabled={props.totpSubmitting} onClick={props.onUseRecoveryCode}>
|
||||||
|
{t('txt_use_recovery_code')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
<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)} />
|
||||||
|
</label>
|
||||||
|
<label className="check-line" style={{ marginBottom: 0 }}>
|
||||||
|
<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>
|
||||||
|
</label>
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={props.disableTotpOpen}
|
||||||
|
title={t('txt_disable_totp')}
|
||||||
|
message={t('txt_enter_master_password_to_disable_two_step_verification')}
|
||||||
|
confirmText={t('txt_disable_totp')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
danger
|
||||||
|
showIcon={false}
|
||||||
|
confirmDisabled={props.disableTotpSubmitting}
|
||||||
|
cancelDisabled={props.disableTotpSubmitting}
|
||||||
|
onConfirm={props.onConfirmDisableTotp}
|
||||||
|
onCancel={props.onCancelDisableTotp}
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_master_password')}</span>
|
||||||
|
<input className="input" type="password" autoComplete="current-password" value={props.disableTotpPassword} onInput={(e) => props.onDisableTotpPasswordChange((e.currentTarget as HTMLInputElement).value)} />
|
||||||
|
</label>
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<ToastHost toasts={props.toasts} onClose={props.onCloseToast} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
import { lazy, Suspense } from 'preact/compat';
|
||||||
|
import { useEffect } from 'preact/hooks';
|
||||||
|
import { Link, Route, Switch } from 'wouter';
|
||||||
|
import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||||
|
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||||
|
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
||||||
|
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
|
||||||
|
import type { ExportRequest } from '@/lib/export-formats';
|
||||||
|
|
||||||
|
const SendsPage = lazy(() => import('@/components/SendsPage'));
|
||||||
|
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
|
||||||
|
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
||||||
|
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
|
||||||
|
const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage'));
|
||||||
|
const AdminPage = lazy(() => import('@/components/AdminPage'));
|
||||||
|
const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage'));
|
||||||
|
const ImportPage = lazy(() => import('@/components/ImportPage'));
|
||||||
|
|
||||||
|
function RouteContentFallback() {
|
||||||
|
return <div className="loading-screen">{t('txt_loading_nodewarden')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LegacyBackupRedirect(props: { onNavigate: (path: string) => void }) {
|
||||||
|
useEffect(() => {
|
||||||
|
props.onNavigate('/backup');
|
||||||
|
}, [props]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppMainRoutesProps {
|
||||||
|
profile: Profile | null;
|
||||||
|
session: SessionState | null;
|
||||||
|
mobileLayout: boolean;
|
||||||
|
importRoute: string;
|
||||||
|
settingsHomeRoute: string;
|
||||||
|
settingsAccountRoute: string;
|
||||||
|
decryptedCiphers: Cipher[];
|
||||||
|
decryptedFolders: VaultFolder[];
|
||||||
|
decryptedSends: Send[];
|
||||||
|
ciphersLoading: boolean;
|
||||||
|
foldersLoading: boolean;
|
||||||
|
sendsLoading: boolean;
|
||||||
|
users: AdminUser[];
|
||||||
|
invites: AdminInvite[];
|
||||||
|
totpEnabled: boolean;
|
||||||
|
authorizedDevices: AuthorizedDevice[];
|
||||||
|
authorizedDevicesLoading: boolean;
|
||||||
|
onNavigate: (path: string) => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||||
|
onImport: (
|
||||||
|
payload: CiphersImportPayload,
|
||||||
|
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
|
||||||
|
attachments?: ImportAttachmentFile[]
|
||||||
|
) => Promise<ImportResultSummary>;
|
||||||
|
onImportEncryptedRaw: (
|
||||||
|
payload: CiphersImportPayload,
|
||||||
|
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
|
||||||
|
attachments?: ImportAttachmentFile[]
|
||||||
|
) => Promise<ImportResultSummary>;
|
||||||
|
onExport: (request: ExportRequest) => Promise<void>;
|
||||||
|
onCreateVaultItem: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
|
||||||
|
onUpdateVaultItem: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>;
|
||||||
|
onDeleteVaultItem: (cipher: Cipher) => Promise<void>;
|
||||||
|
onArchiveVaultItem: (cipher: Cipher) => Promise<void>;
|
||||||
|
onUnarchiveVaultItem: (cipher: Cipher) => Promise<void>;
|
||||||
|
onBulkDeleteVaultItems: (ids: string[]) => Promise<void>;
|
||||||
|
onBulkPermanentDeleteVaultItems: (ids: string[]) => Promise<void>;
|
||||||
|
onBulkRestoreVaultItems: (ids: string[]) => Promise<void>;
|
||||||
|
onBulkArchiveVaultItems: (ids: string[]) => Promise<void>;
|
||||||
|
onBulkUnarchiveVaultItems: (ids: string[]) => Promise<void>;
|
||||||
|
onBulkMoveVaultItems: (ids: string[], folderId: string | null) => Promise<void>;
|
||||||
|
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
|
||||||
|
onCreateFolder: (name: string) => Promise<void>;
|
||||||
|
onRenameFolder: (folderId: string, name: string) => Promise<void>;
|
||||||
|
onDeleteFolder: (folderId: string) => Promise<void>;
|
||||||
|
onBulkDeleteFolders: (folderIds: string[]) => Promise<void>;
|
||||||
|
onDownloadVaultAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
|
||||||
|
downloadingAttachmentKey: string;
|
||||||
|
attachmentDownloadPercent: number | null;
|
||||||
|
uploadingAttachmentName: string;
|
||||||
|
attachmentUploadPercent: number | null;
|
||||||
|
onRefreshVault: () => Promise<void>;
|
||||||
|
onCreateSend: (draft: SendDraft, autoCopyLink: boolean) => Promise<void>;
|
||||||
|
onUpdateSend: (send: Send, draft: SendDraft, autoCopyLink: boolean) => Promise<void>;
|
||||||
|
onDeleteSend: (send: Send) => Promise<void>;
|
||||||
|
onBulkDeleteSends: (ids: string[]) => Promise<void>;
|
||||||
|
uploadingSendFileName: string;
|
||||||
|
sendUploadPercent: number | null;
|
||||||
|
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
||||||
|
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
|
||||||
|
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
||||||
|
onOpenDisableTotp: () => void;
|
||||||
|
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||||
|
onRefreshAuthorizedDevices: () => Promise<void>;
|
||||||
|
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
||||||
|
onRemoveDevice: (device: AuthorizedDevice) => void;
|
||||||
|
onRevokeAllDeviceTrust: () => void;
|
||||||
|
onRemoveAllDevices: () => void;
|
||||||
|
onCreateInvite: (hours: number) => Promise<void>;
|
||||||
|
onRefreshAdmin: () => void;
|
||||||
|
onDeleteAllInvites: () => Promise<void>;
|
||||||
|
onToggleUserStatus: (userId: string, status: 'active' | 'banned') => Promise<void>;
|
||||||
|
onDeleteUser: (userId: string) => Promise<void>;
|
||||||
|
onRevokeInvite: (code: string) => Promise<void>;
|
||||||
|
onExportBackup: (includeAttachments?: boolean) => Promise<void>;
|
||||||
|
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
|
onImportBackupAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
|
onLoadBackupSettings: () => Promise<AdminBackupSettings>;
|
||||||
|
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||||
|
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||||
|
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||||
|
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
||||||
|
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>;
|
||||||
|
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
||||||
|
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
|
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||||
|
const importRoutePaths = [props.importRoute, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const;
|
||||||
|
const importPageContent = (
|
||||||
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
|
<ImportPage
|
||||||
|
onImport={props.onImport}
|
||||||
|
onImportEncryptedRaw={props.onImportEncryptedRaw}
|
||||||
|
accountKeys={props.session?.symEncKey && props.session?.symMacKey ? { encB64: props.session.symEncKey, macB64: props.session.symMacKey } : null}
|
||||||
|
onNotify={props.onNotify}
|
||||||
|
folders={props.decryptedFolders}
|
||||||
|
onExport={props.onExport}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderImportPageRoute = () => (
|
||||||
|
<div className="stack">
|
||||||
|
{props.mobileLayout && (
|
||||||
|
<div className="mobile-settings-subhead">
|
||||||
|
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
|
||||||
|
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||||
|
{t('txt_back')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{importPageContent}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Route path="/sends">
|
||||||
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
|
<SendsPage
|
||||||
|
sends={props.decryptedSends}
|
||||||
|
loading={props.sendsLoading}
|
||||||
|
onRefresh={props.onRefreshVault}
|
||||||
|
onCreate={props.onCreateSend}
|
||||||
|
onUpdate={props.onUpdateSend}
|
||||||
|
onDelete={props.onDeleteSend}
|
||||||
|
onBulkDelete={props.onBulkDeleteSends}
|
||||||
|
uploadingSendFileName={props.uploadingSendFileName}
|
||||||
|
sendUploadPercent={props.sendUploadPercent}
|
||||||
|
onNotify={props.onNotify}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</Route>
|
||||||
|
<Route path="/vault/totp">
|
||||||
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
|
<TotpCodesPage ciphers={props.decryptedCiphers} loading={props.ciphersLoading} onNotify={props.onNotify} />
|
||||||
|
</Suspense>
|
||||||
|
</Route>
|
||||||
|
<Route path="/vault">
|
||||||
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
|
<VaultPage
|
||||||
|
ciphers={props.decryptedCiphers}
|
||||||
|
folders={props.decryptedFolders}
|
||||||
|
loading={props.ciphersLoading || props.foldersLoading}
|
||||||
|
emailForReprompt={props.profile?.email || props.session?.email || ''}
|
||||||
|
onRefresh={props.onRefreshVault}
|
||||||
|
onCreate={props.onCreateVaultItem}
|
||||||
|
onUpdate={props.onUpdateVaultItem}
|
||||||
|
onDelete={props.onDeleteVaultItem}
|
||||||
|
onArchive={props.onArchiveVaultItem}
|
||||||
|
onUnarchive={props.onUnarchiveVaultItem}
|
||||||
|
onBulkDelete={props.onBulkDeleteVaultItems}
|
||||||
|
onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems}
|
||||||
|
onBulkRestore={props.onBulkRestoreVaultItems}
|
||||||
|
onBulkArchive={props.onBulkArchiveVaultItems}
|
||||||
|
onBulkUnarchive={props.onBulkUnarchiveVaultItems}
|
||||||
|
onBulkMove={props.onBulkMoveVaultItems}
|
||||||
|
onVerifyMasterPassword={props.onVerifyMasterPassword}
|
||||||
|
onNotify={props.onNotify}
|
||||||
|
onCreateFolder={props.onCreateFolder}
|
||||||
|
onRenameFolder={props.onRenameFolder}
|
||||||
|
onDeleteFolder={props.onDeleteFolder}
|
||||||
|
onBulkDeleteFolders={props.onBulkDeleteFolders}
|
||||||
|
onDownloadAttachment={props.onDownloadVaultAttachment}
|
||||||
|
downloadingAttachmentKey={props.downloadingAttachmentKey}
|
||||||
|
attachmentDownloadPercent={props.attachmentDownloadPercent}
|
||||||
|
uploadingAttachmentName={props.uploadingAttachmentName}
|
||||||
|
attachmentUploadPercent={props.attachmentUploadPercent}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</Route>
|
||||||
|
<Route path={props.settingsAccountRoute}>
|
||||||
|
{props.profile && (
|
||||||
|
<div className="stack">
|
||||||
|
{props.mobileLayout && (
|
||||||
|
<div className="mobile-settings-subhead">
|
||||||
|
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
|
||||||
|
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||||
|
{t('txt_back')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
|
<SettingsPage
|
||||||
|
profile={props.profile}
|
||||||
|
totpEnabled={props.totpEnabled}
|
||||||
|
onChangePassword={props.onChangePassword}
|
||||||
|
onSavePasswordHint={props.onSavePasswordHint}
|
||||||
|
onEnableTotp={props.onEnableTotp}
|
||||||
|
onOpenDisableTotp={props.onOpenDisableTotp}
|
||||||
|
onGetRecoveryCode={props.onGetRecoveryCode}
|
||||||
|
onNotify={props.onNotify}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Route>
|
||||||
|
<Route path="/settings">
|
||||||
|
{props.profile && (
|
||||||
|
<section className="card mobile-settings-card">
|
||||||
|
<div className="mobile-settings-links">
|
||||||
|
<Link href={props.settingsAccountRoute} className="mobile-settings-link">
|
||||||
|
<SettingsIcon size={18} />
|
||||||
|
<span>{t('nav_account_settings')}</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/security/devices" className="mobile-settings-link">
|
||||||
|
<Shield size={18} />
|
||||||
|
<span>{t('nav_device_management')}</span>
|
||||||
|
</Link>
|
||||||
|
<Link href={props.importRoute} className="mobile-settings-link">
|
||||||
|
<ArrowUpDown size={18} />
|
||||||
|
<span>{t('nav_import_export')}</span>
|
||||||
|
</Link>
|
||||||
|
{props.profile.role === 'admin' && (
|
||||||
|
<Link href="/admin" className="mobile-settings-link">
|
||||||
|
<ShieldUser size={18} />
|
||||||
|
<span>{t('nav_admin_panel')}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{props.profile.role === 'admin' && (
|
||||||
|
<Link href="/backup" className="mobile-settings-link">
|
||||||
|
<Cloud size={18} />
|
||||||
|
<span>{t('nav_backup_strategy')}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-secondary mobile-settings-logout" onClick={props.onLogout}>
|
||||||
|
<LogOut size={14} className="btn-icon" />
|
||||||
|
{t('txt_sign_out')}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</Route>
|
||||||
|
<Route path="/security/devices">
|
||||||
|
<div className="stack">
|
||||||
|
{props.mobileLayout && (
|
||||||
|
<div className="mobile-settings-subhead">
|
||||||
|
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
|
||||||
|
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||||
|
{t('txt_back')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
|
<SecurityDevicesPage
|
||||||
|
devices={props.authorizedDevices}
|
||||||
|
loading={props.authorizedDevicesLoading}
|
||||||
|
onRefresh={() => void props.onRefreshAuthorizedDevices()}
|
||||||
|
onRevokeTrust={props.onRevokeDeviceTrust}
|
||||||
|
onRemoveDevice={props.onRemoveDevice}
|
||||||
|
onRevokeAll={props.onRevokeAllDeviceTrust}
|
||||||
|
onRemoveAll={props.onRemoveAllDevices}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</Route>
|
||||||
|
<Route path="/admin">
|
||||||
|
<div className="stack">
|
||||||
|
{props.mobileLayout && (
|
||||||
|
<div className="mobile-settings-subhead">
|
||||||
|
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
|
||||||
|
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||||
|
{t('txt_back')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
|
<AdminPage
|
||||||
|
currentUserId={props.profile?.id || ''}
|
||||||
|
users={props.users}
|
||||||
|
invites={props.invites}
|
||||||
|
onRefresh={props.onRefreshAdmin}
|
||||||
|
onCreateInvite={props.onCreateInvite}
|
||||||
|
onDeleteAllInvites={props.onDeleteAllInvites}
|
||||||
|
onToggleUserStatus={props.onToggleUserStatus}
|
||||||
|
onDeleteUser={props.onDeleteUser}
|
||||||
|
onRevokeInvite={props.onRevokeInvite}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</Route>
|
||||||
|
{importRoutePaths.map((path) => (
|
||||||
|
<Route key={path} path={path}>
|
||||||
|
{renderImportPageRoute()}
|
||||||
|
</Route>
|
||||||
|
))}
|
||||||
|
<Route path="/help">
|
||||||
|
<LegacyBackupRedirect onNavigate={props.onNavigate} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/backup">
|
||||||
|
{props.profile?.role === 'admin' ? (
|
||||||
|
<div className="stack">
|
||||||
|
{props.mobileLayout && (
|
||||||
|
<div className="mobile-settings-subhead">
|
||||||
|
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
|
||||||
|
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||||
|
{t('txt_back')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
|
<BackupCenterPage
|
||||||
|
currentUserId={props.profile?.id || null}
|
||||||
|
onExport={props.onExportBackup}
|
||||||
|
onImport={props.onImportBackup}
|
||||||
|
onImportAllowingChecksumMismatch={props.onImportBackupAllowingChecksumMismatch}
|
||||||
|
onLoadSettings={props.onLoadBackupSettings}
|
||||||
|
onListRemoteBackups={props.onListRemoteBackups}
|
||||||
|
onDownloadRemoteBackup={props.onDownloadRemoteBackup}
|
||||||
|
onInspectRemoteBackup={props.onInspectRemoteBackup}
|
||||||
|
onDeleteRemoteBackup={props.onDeleteRemoteBackup}
|
||||||
|
onRestoreRemoteBackup={props.onRestoreRemoteBackup}
|
||||||
|
onRestoreRemoteBackupAllowingChecksumMismatch={props.onRestoreRemoteBackupAllowingChecksumMismatch}
|
||||||
|
onSaveSettings={props.onSaveBackupSettings}
|
||||||
|
onRunRemoteBackup={props.onRunRemoteBackup}
|
||||||
|
onNotify={props.onNotify}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Route>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
import { ArrowLeft, Eye, EyeOff, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
|
||||||
|
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
|
interface LoginValues {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegisterValues {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
password2: string;
|
||||||
|
passwordHint: string;
|
||||||
|
inviteCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthViewsProps {
|
||||||
|
mode: 'login' | 'register' | 'locked';
|
||||||
|
pendingAction: 'login' | 'register' | 'unlock' | null;
|
||||||
|
unlockReady: boolean;
|
||||||
|
unlockPreparing: boolean;
|
||||||
|
loginValues: LoginValues;
|
||||||
|
registerValues: RegisterValues;
|
||||||
|
unlockPassword: string;
|
||||||
|
emailForLock: string;
|
||||||
|
loginHintLoading: boolean;
|
||||||
|
onChangeLogin: (next: LoginValues) => void;
|
||||||
|
onChangeRegister: (next: RegisterValues) => void;
|
||||||
|
onChangeUnlock: (password: string) => void;
|
||||||
|
onSubmitLogin: () => void;
|
||||||
|
onSubmitRegister: () => void;
|
||||||
|
onSubmitUnlock: () => void;
|
||||||
|
onGotoLogin: () => void;
|
||||||
|
onGotoRegister: () => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
onTogglePasswordHint: () => void;
|
||||||
|
onShowLockedPasswordHint: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PasswordField(props: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onInput: (v: string) => void;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
autoComplete?: string;
|
||||||
|
}) {
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
return (
|
||||||
|
<label className="field">
|
||||||
|
<span>{props.label}</span>
|
||||||
|
<div className="password-wrap">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type={show ? 'text' : 'password'}
|
||||||
|
value={props.value}
|
||||||
|
onInput={(e) => props.onInput((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
autoFocus={props.autoFocus}
|
||||||
|
autoComplete={props.autoComplete}
|
||||||
|
/>
|
||||||
|
<button type="button" className="eye-btn" onClick={() => setShow((v) => !v)}>
|
||||||
|
{show ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuthViews(props: AuthViewsProps) {
|
||||||
|
const loginBusy = props.pendingAction === 'login';
|
||||||
|
const registerBusy = props.pendingAction === 'register';
|
||||||
|
const unlockBusy = props.pendingAction === 'unlock';
|
||||||
|
|
||||||
|
if (props.mode === 'locked') {
|
||||||
|
return (
|
||||||
|
<div className="auth-page">
|
||||||
|
<StandalonePageFrame title={t('txt_unlock_vault')}>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
props.onSubmitUnlock();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="muted standalone-muted">{props.emailForLock}</p>
|
||||||
|
<input type="text" value={props.emailForLock} autoComplete="username" readOnly hidden tabIndex={-1} aria-hidden="true" />
|
||||||
|
<PasswordField
|
||||||
|
label={t('txt_master_password')}
|
||||||
|
value={props.unlockPassword}
|
||||||
|
autoFocus
|
||||||
|
autoComplete="current-password"
|
||||||
|
onInput={props.onChangeUnlock}
|
||||||
|
/>
|
||||||
|
<div className="auth-support-row">
|
||||||
|
<span />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="auth-link-btn"
|
||||||
|
onClick={props.onShowLockedPasswordHint}
|
||||||
|
disabled={unlockBusy || props.unlockPreparing}
|
||||||
|
>
|
||||||
|
{t('txt_show_password_hint')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{props.unlockPreparing ? (
|
||||||
|
<p className="muted standalone-muted">{t('txt_loading')}</p>
|
||||||
|
) : null}
|
||||||
|
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || props.unlockPreparing || !props.unlockReady}>
|
||||||
|
<Unlock size={16} className="btn-icon" />
|
||||||
|
{unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')}
|
||||||
|
</button>
|
||||||
|
<div className="or">{t('txt_or')}</div>
|
||||||
|
<button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy}>
|
||||||
|
<LogOut size={16} className="btn-icon" />
|
||||||
|
{t('txt_log_out')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</StandalonePageFrame>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.mode === 'register') {
|
||||||
|
return (
|
||||||
|
<div className="auth-page">
|
||||||
|
<StandalonePageFrame title={t('txt_create_account')}>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
props.onSubmitRegister();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_name')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={props.registerValues.name}
|
||||||
|
autoComplete="name"
|
||||||
|
onInput={(e) =>
|
||||||
|
props.onChangeRegister({ ...props.registerValues, name: (e.currentTarget as HTMLInputElement).value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_email')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="email"
|
||||||
|
value={props.registerValues.email}
|
||||||
|
autoComplete="email"
|
||||||
|
onInput={(e) =>
|
||||||
|
props.onChangeRegister({ ...props.registerValues, email: (e.currentTarget as HTMLInputElement).value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<PasswordField
|
||||||
|
label={t('txt_master_password')}
|
||||||
|
value={props.registerValues.password}
|
||||||
|
autoComplete="new-password"
|
||||||
|
onInput={(v) => props.onChangeRegister({ ...props.registerValues, password: v })}
|
||||||
|
/>
|
||||||
|
<PasswordField
|
||||||
|
label={t('txt_confirm_master_password')}
|
||||||
|
value={props.registerValues.password2}
|
||||||
|
autoComplete="new-password"
|
||||||
|
onInput={(v) => props.onChangeRegister({ ...props.registerValues, password2: v })}
|
||||||
|
/>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_password_hint_optional')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
maxLength={120}
|
||||||
|
value={props.registerValues.passwordHint}
|
||||||
|
placeholder={t('txt_password_hint_register_placeholder')}
|
||||||
|
onInput={(e) =>
|
||||||
|
props.onChangeRegister({ ...props.registerValues, passwordHint: (e.currentTarget as HTMLInputElement).value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_invite_code_optional')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={props.registerValues.inviteCode}
|
||||||
|
autoComplete="off"
|
||||||
|
onInput={(e) =>
|
||||||
|
props.onChangeRegister({ ...props.registerValues, inviteCode: (e.currentTarget as HTMLInputElement).value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="btn btn-primary full" disabled={registerBusy}>
|
||||||
|
<UserPlus size={16} className="btn-icon" />
|
||||||
|
{registerBusy ? t('txt_registering') : t('txt_create_account')}
|
||||||
|
</button>
|
||||||
|
<div className="or">{t('txt_or')}</div>
|
||||||
|
<button type="button" className="btn btn-secondary full" onClick={props.onGotoLogin} disabled={registerBusy}>
|
||||||
|
<ArrowLeft size={16} className="btn-icon" />
|
||||||
|
{t('txt_back_to_login')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</StandalonePageFrame>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-page">
|
||||||
|
<StandalonePageFrame title={t('txt_log_in')}>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
props.onSubmitLogin();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_email')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="email"
|
||||||
|
value={props.loginValues.email}
|
||||||
|
autoComplete="username"
|
||||||
|
onInput={(e) => props.onChangeLogin({ ...props.loginValues, email: (e.currentTarget as HTMLInputElement).value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<PasswordField
|
||||||
|
label={t('txt_master_password')}
|
||||||
|
value={props.loginValues.password}
|
||||||
|
autoComplete="current-password"
|
||||||
|
onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="auth-support-row">
|
||||||
|
<span />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="auth-link-btn"
|
||||||
|
onClick={props.onTogglePasswordHint}
|
||||||
|
disabled={loginBusy || !props.loginValues.email.trim()}
|
||||||
|
>
|
||||||
|
{props.loginHintLoading
|
||||||
|
? t('txt_loading_password_hint')
|
||||||
|
: t('txt_show_password_hint')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn-primary full" disabled={loginBusy}>
|
||||||
|
<LogIn size={16} className="btn-icon" />
|
||||||
|
{loginBusy ? t('txt_logging_in') : t('txt_log_in')}
|
||||||
|
</button>
|
||||||
|
<div className="or">{t('txt_or')}</div>
|
||||||
|
<button type="button" className="btn btn-secondary full" onClick={props.onGotoRegister} disabled={loginBusy}>
|
||||||
|
<UserPlus size={16} className="btn-icon" />
|
||||||
|
{t('txt_create_account')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</StandalonePageFrame>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { createPortal } from 'preact/compat';
|
||||||
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
|
import type { ComponentChildren } from 'preact';
|
||||||
|
import { TriangleAlert } from 'lucide-preact';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
variant?: 'default' | 'warning';
|
||||||
|
showIcon?: boolean;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
danger?: boolean;
|
||||||
|
hideCancel?: boolean;
|
||||||
|
confirmDisabled?: boolean;
|
||||||
|
cancelDisabled?: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
children?: 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDialogLifecycle(active: boolean, onCancel?: (() => void) | null) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
incrementDialogBodyLock();
|
||||||
|
return () => decrementDialogBodyLock();
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active || !onCancel || typeof window === 'undefined') return;
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== 'Escape') return;
|
||||||
|
event.preventDefault();
|
||||||
|
onCancel();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [active, onCancel]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||||
|
const [present, setPresent] = useState(props.open);
|
||||||
|
const [closing, setClosing] = useState(false);
|
||||||
|
const canDismiss = !props.cancelDisabled && !closing && !props.hideCancel;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.open) {
|
||||||
|
setPresent(true);
|
||||||
|
setClosing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!present) return;
|
||||||
|
setClosing(true);
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setPresent(false);
|
||||||
|
setClosing(false);
|
||||||
|
}, 240);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [props.open, present]);
|
||||||
|
|
||||||
|
useDialogLifecycle(present, canDismiss ? props.onCancel : null);
|
||||||
|
|
||||||
|
if (!present || typeof document === 'undefined') return null;
|
||||||
|
return createPortal((
|
||||||
|
<div
|
||||||
|
className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target !== event.currentTarget || !canDismiss) return;
|
||||||
|
props.onCancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
className={`dialog-card ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={props.title}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (props.confirmDisabled || closing) return;
|
||||||
|
props.onConfirm();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.variant === 'warning' ? (
|
||||||
|
<>
|
||||||
|
<div className="dialog-warning-strip" aria-hidden="true" />
|
||||||
|
<div className="dialog-warning-head">
|
||||||
|
<div className="dialog-warning-badge" aria-hidden="true">
|
||||||
|
<TriangleAlert size={24} />
|
||||||
|
</div>
|
||||||
|
<div className="dialog-warning-kicker">{t('txt_warning')}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<h3 className="dialog-title">{props.title}</h3>
|
||||||
|
<div className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>
|
||||||
|
{props.children}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
|
||||||
|
disabled={props.confirmDisabled}
|
||||||
|
>
|
||||||
|
{props.confirmText || t('txt_yes')}
|
||||||
|
</button>
|
||||||
|
{!props.hideCancel && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary dialog-btn"
|
||||||
|
disabled={props.cancelDisabled}
|
||||||
|
onClick={() => {
|
||||||
|
if (props.cancelDisabled) return;
|
||||||
|
props.onCancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.cancelText || t('txt_no')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{props.afterActions}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
), document.body);
|
||||||
|
}
|
||||||
@@ -0,0 +1,890 @@
|
|||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
import { argon2idAsync } from '@noble/hashes/argon2.js';
|
||||||
|
import { createPortal } from 'preact/compat';
|
||||||
|
import { strFromU8, unzipSync } from 'fflate';
|
||||||
|
import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js';
|
||||||
|
import { Download, FileUp } from 'lucide-preact';
|
||||||
|
import ConfirmDialog, { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||||
|
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||||
|
import {
|
||||||
|
type EncryptedJsonMode,
|
||||||
|
EXPORT_FORMATS,
|
||||||
|
type ExportFormatId,
|
||||||
|
type ExportRequest,
|
||||||
|
} from '@/lib/export-formats';
|
||||||
|
import {
|
||||||
|
parseImportPayloadBySource,
|
||||||
|
} from '@/lib/import-formats';
|
||||||
|
import { getFileAcceptBySource, IMPORT_SOURCES, type ImportSourceId } from '@/lib/import-format-sources';
|
||||||
|
import {
|
||||||
|
type BitwardenJsonInput,
|
||||||
|
normalizeBitwardenEncryptedAccountImport,
|
||||||
|
normalizeBitwardenImport,
|
||||||
|
} from '@/lib/import-formats-bitwarden';
|
||||||
|
import { base64ToBytes, decryptStr, hkdfExpand, pbkdf2 } from '@/lib/crypto';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
import type { Folder } from '@/lib/types';
|
||||||
|
|
||||||
|
configureZipJs({ useWebWorkers: false });
|
||||||
|
|
||||||
|
export interface ImportAttachmentFile {
|
||||||
|
sourceCipherId: string | null;
|
||||||
|
sourceCipherIndex: number | null;
|
||||||
|
fileName: string;
|
||||||
|
bytes: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportPageProps {
|
||||||
|
onImport: (
|
||||||
|
payload: CiphersImportPayload,
|
||||||
|
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
|
||||||
|
attachments?: ImportAttachmentFile[]
|
||||||
|
) => Promise<ImportResultSummary>;
|
||||||
|
onImportEncryptedRaw: (
|
||||||
|
payload: CiphersImportPayload,
|
||||||
|
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
|
||||||
|
attachments?: ImportAttachmentFile[]
|
||||||
|
) => Promise<ImportResultSummary>;
|
||||||
|
accountKeys?: { encB64: string; macB64: string } | null;
|
||||||
|
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||||
|
folders: Folder[];
|
||||||
|
onExport: (request: ExportRequest) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportResultSummary {
|
||||||
|
totalItems: number;
|
||||||
|
folderCount: number;
|
||||||
|
typeCounts: Array<{ label: string; count: number }>;
|
||||||
|
attachmentCount: number;
|
||||||
|
importedAttachmentCount: number;
|
||||||
|
failedAttachments: Array<{ fileName: string; reason: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BitwardenPasswordProtectedInput extends BitwardenJsonInput {
|
||||||
|
encrypted: true;
|
||||||
|
passwordProtected: true;
|
||||||
|
salt?: string;
|
||||||
|
kdfIterations?: number;
|
||||||
|
kdfMemory?: number;
|
||||||
|
kdfParallelism?: number;
|
||||||
|
kdfType?: number;
|
||||||
|
data?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMON_IMPORT_SOURCE_IDS: ImportSourceId[] = [
|
||||||
|
'bitwarden_json',
|
||||||
|
'bitwarden_csv',
|
||||||
|
'bitwarden_zip',
|
||||||
|
'nodewarden_json',
|
||||||
|
'onepassword_1pux',
|
||||||
|
'onepassword_1pif',
|
||||||
|
'onepassword_mac_csv',
|
||||||
|
'onepassword_win_csv',
|
||||||
|
'protonpass_json',
|
||||||
|
'chrome',
|
||||||
|
'edge',
|
||||||
|
'brave',
|
||||||
|
'opera',
|
||||||
|
'vivaldi',
|
||||||
|
'firefox_csv',
|
||||||
|
'safari_csv',
|
||||||
|
'lastpass',
|
||||||
|
'dashlane_csv',
|
||||||
|
'dashlane_json',
|
||||||
|
'keepass_xml',
|
||||||
|
'keepassx_csv',
|
||||||
|
];
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === 'object';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPasswordProtectedExport(value: unknown): value is BitwardenPasswordProtectedInput {
|
||||||
|
return isRecord(value) && value.encrypted === true && value.passwordProtected === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function derivePasswordProtectedFileKey(
|
||||||
|
parsed: BitwardenPasswordProtectedInput,
|
||||||
|
password: string
|
||||||
|
): Promise<{ enc: Uint8Array; mac: Uint8Array }> {
|
||||||
|
const salt = String(parsed.salt || '').trim();
|
||||||
|
const iterations = Number(parsed.kdfIterations || 0);
|
||||||
|
const kdfType = Number(parsed.kdfType);
|
||||||
|
if (!salt || !Number.isFinite(iterations) || iterations <= 0) {
|
||||||
|
throw new Error(t('txt_import_invalid_password_protected_file'));
|
||||||
|
}
|
||||||
|
|
||||||
|
let keyMaterial: Uint8Array;
|
||||||
|
if (kdfType === 0) {
|
||||||
|
keyMaterial = await pbkdf2(password, salt, iterations, 32);
|
||||||
|
} else if (kdfType === 1) {
|
||||||
|
const memoryMiB = Number(parsed.kdfMemory || 0);
|
||||||
|
const parallelism = Number(parsed.kdfParallelism || 0);
|
||||||
|
if (!Number.isFinite(memoryMiB) || memoryMiB <= 0 || !Number.isFinite(parallelism) || parallelism <= 0) {
|
||||||
|
throw new Error(t('txt_invalid_argon2id_params'));
|
||||||
|
}
|
||||||
|
const memoryKiB = Math.floor(memoryMiB * 1024);
|
||||||
|
const maxmem = memoryKiB * 1024 + 1024 * 1024;
|
||||||
|
keyMaterial = await argon2idAsync(new TextEncoder().encode(password), new TextEncoder().encode(salt), {
|
||||||
|
t: Math.floor(iterations),
|
||||||
|
m: memoryKiB,
|
||||||
|
p: Math.floor(parallelism),
|
||||||
|
dkLen: 32,
|
||||||
|
maxmem,
|
||||||
|
asyncTick: 10,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error(t('txt_unsupported_kdf_type', { type: String(kdfType) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const enc = await hkdfExpand(keyMaterial, 'enc', 32);
|
||||||
|
const mac = await hkdfExpand(keyMaterial, 'mac', 32);
|
||||||
|
return { enc, mac };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptPasswordProtectedExport(parsed: BitwardenPasswordProtectedInput, password: string): Promise<unknown> {
|
||||||
|
if (!parsed.encKeyValidation_DO_NOT_EDIT || !parsed.data) {
|
||||||
|
throw new Error(t('txt_import_invalid_password_protected_file'));
|
||||||
|
}
|
||||||
|
const pass = String(password || '').trim();
|
||||||
|
if (!pass) {
|
||||||
|
throw new Error(t('txt_import_file_password_required'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await derivePasswordProtectedFileKey(parsed, pass);
|
||||||
|
try {
|
||||||
|
await decryptStr(parsed.encKeyValidation_DO_NOT_EDIT, key.enc, key.mac);
|
||||||
|
} catch {
|
||||||
|
throw new Error(t('txt_invalid_file_password'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const plainJson = await decryptStr(parsed.data, key.enc, key.mac);
|
||||||
|
try {
|
||||||
|
return JSON.parse(plainJson);
|
||||||
|
} catch {
|
||||||
|
throw new Error(t('txt_import_decrypt_failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isZipPayload(bytes: Uint8Array): boolean {
|
||||||
|
return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readZipText(bytes: Uint8Array, source: ImportSourceId): string {
|
||||||
|
const unzipped = unzipSync(bytes);
|
||||||
|
const fileNames = Object.keys(unzipped);
|
||||||
|
if (!fileNames.length) throw new Error(t('txt_import_empty_zip_archive'));
|
||||||
|
|
||||||
|
const preferred = source === 'onepassword_1pux' ? ['export.data', 'export.json'] : ['protonpass.json', 'export.json'];
|
||||||
|
for (const p of preferred) {
|
||||||
|
const hit = fileNames.find((n) => n.toLowerCase().endsWith(p.toLowerCase()));
|
||||||
|
if (hit) return strFromU8(unzipped[hit]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstJson = fileNames.find((n) => n.toLowerCase().endsWith('.json') || n.toLowerCase().endsWith('.data'));
|
||||||
|
if (firstJson) return strFromU8(unzipped[firstJson]);
|
||||||
|
throw new Error(t('txt_import_no_json_found_in_zip'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readImportText(file: File, source: ImportSourceId): Promise<string> {
|
||||||
|
if (source !== 'onepassword_1pux' && source !== 'protonpass_json') {
|
||||||
|
return file.text();
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
if (isZipPayload(bytes)) return readZipText(bytes, source);
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingPasswordImportContext {
|
||||||
|
parsed: BitwardenPasswordProtectedInput;
|
||||||
|
source: 'bitwarden_json' | 'nodewarden_json' | 'bitwarden_zip';
|
||||||
|
attachments: ImportAttachmentFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
class ZipNeedsPasswordError extends Error {}
|
||||||
|
class ZipInvalidPasswordError extends Error {}
|
||||||
|
|
||||||
|
function looksLikeZipPasswordError(error: unknown): boolean {
|
||||||
|
const message = error instanceof Error ? String(error.message || '').toLowerCase() : '';
|
||||||
|
if (!message) return false;
|
||||||
|
return message.includes('password') || message.includes('encrypted');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBitwardenZipPayload(
|
||||||
|
file: File,
|
||||||
|
passwordRaw: string
|
||||||
|
): Promise<{ jsonText: string; attachments: ImportAttachmentFile[] }> {
|
||||||
|
const password = String(passwordRaw || '').trim();
|
||||||
|
const reader = new ZipReader(new BlobReader(file), { useWebWorkers: false });
|
||||||
|
try {
|
||||||
|
const entries = await reader.getEntries();
|
||||||
|
if (!entries.length) throw new Error(t('txt_import_empty_zip_archive'));
|
||||||
|
|
||||||
|
let jsonText = '';
|
||||||
|
const attachments: ImportAttachmentFile[] = [];
|
||||||
|
const options = password ? { password } : undefined;
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.directory) continue;
|
||||||
|
const name = String(entry.filename || '').trim().replace(/\\/g, '/');
|
||||||
|
if (!name) continue;
|
||||||
|
|
||||||
|
const bytes = await entry.getData(new Uint8ArrayWriter(), options);
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
if (lower === 'data.json') {
|
||||||
|
jsonText = new TextDecoder().decode(bytes);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachmentMatch = name.match(/^attachments\/([^/]+)\/(.+)$/i);
|
||||||
|
if (!attachmentMatch) continue;
|
||||||
|
const sourceCipherId = String(attachmentMatch[1] || '').trim() || null;
|
||||||
|
const fileName = String(attachmentMatch[2] || '').trim() || 'attachment.bin';
|
||||||
|
attachments.push({
|
||||||
|
sourceCipherId,
|
||||||
|
sourceCipherIndex: null,
|
||||||
|
fileName,
|
||||||
|
bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!jsonText) throw new Error(t('txt_import_data_json_not_found'));
|
||||||
|
return { jsonText, attachments };
|
||||||
|
} catch (error) {
|
||||||
|
if (looksLikeZipPasswordError(error)) {
|
||||||
|
if (!password) throw new ZipNeedsPasswordError(t('txt_import_zip_password_required'));
|
||||||
|
throw new ZipInvalidPasswordError(t('txt_import_invalid_zip_password'));
|
||||||
|
}
|
||||||
|
if (!password && error instanceof Error && /invalid|corrupt|unsupported/.test(error.message.toLowerCase())) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await reader.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNodeWardenAttachmentArray(raw: unknown): ImportAttachmentFile[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
const out: ImportAttachmentFile[] = [];
|
||||||
|
for (const entry of raw) {
|
||||||
|
if (!entry || typeof entry !== 'object') continue;
|
||||||
|
const row = entry as Record<string, unknown>;
|
||||||
|
const fileName = String(row.fileName || '').trim() || 'attachment.bin';
|
||||||
|
const base64 = String(row.data || '').trim();
|
||||||
|
if (!base64) continue;
|
||||||
|
try {
|
||||||
|
const bytes = base64ToBytes(base64);
|
||||||
|
const sourceCipherId = String(row.cipherId || '').trim() || null;
|
||||||
|
const indexRaw = Number(row.cipherIndex);
|
||||||
|
out.push({
|
||||||
|
sourceCipherId,
|
||||||
|
sourceCipherIndex: Number.isFinite(indexRaw) ? indexRaw : null,
|
||||||
|
fileName,
|
||||||
|
bytes,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// skip malformed attachment row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys, onNotify, folders, onExport }: ImportPageProps) {
|
||||||
|
const [source, setSource] = useState<ImportSourceId>('bitwarden_json');
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isPasswordSubmitting, setIsPasswordSubmitting] = useState(false);
|
||||||
|
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||||
|
const [importPassword, setImportPassword] = useState('');
|
||||||
|
const [pendingPasswordImport, setPendingPasswordImport] = useState<PendingPasswordImportContext | null>(null);
|
||||||
|
const [zipPasswordDialogOpen, setZipPasswordDialogOpen] = useState(false);
|
||||||
|
const [zipImportPassword, setZipImportPassword] = useState('');
|
||||||
|
const [pendingZipFile, setPendingZipFile] = useState<File | null>(null);
|
||||||
|
const [isZipPasswordSubmitting, setIsZipPasswordSubmitting] = useState(false);
|
||||||
|
const [folderMode, setFolderMode] = useState<'original' | 'none' | 'target'>('original');
|
||||||
|
const [targetFolderId, setTargetFolderId] = useState('');
|
||||||
|
const [exportFormat, setExportFormat] = useState<ExportFormatId>('bitwarden_json');
|
||||||
|
const [encryptedJsonMode, setEncryptedJsonMode] = useState<EncryptedJsonMode>('account');
|
||||||
|
const [exportPassword, setExportPassword] = useState('');
|
||||||
|
const [zipPassword, setZipPassword] = useState('');
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
const [exportAuthDialogOpen, setExportAuthDialogOpen] = useState(false);
|
||||||
|
const [exportAuthPassword, setExportAuthPassword] = useState('');
|
||||||
|
const [importSummary, setImportSummary] = useState<ImportResultSummary | null>(null);
|
||||||
|
|
||||||
|
useDialogLifecycle(!!importSummary, importSummary ? () => setImportSummary(null) : null);
|
||||||
|
const commonSourceSet = new Set<ImportSourceId>(COMMON_IMPORT_SOURCE_IDS);
|
||||||
|
const commonSources = IMPORT_SOURCES.filter((item) => commonSourceSet.has(item.id as ImportSourceId));
|
||||||
|
const otherSources = IMPORT_SOURCES.filter((item) => !commonSourceSet.has(item.id as ImportSourceId));
|
||||||
|
|
||||||
|
async function runBitwardenJsonImport(parsed: unknown, attachments: ImportAttachmentFile[] = []): Promise<ImportResultSummary> {
|
||||||
|
if (isRecord(parsed) && parsed.encrypted === true) {
|
||||||
|
const accountEncrypted = parsed as BitwardenJsonInput;
|
||||||
|
if (!accountKeys?.encB64 || !accountKeys?.macB64) {
|
||||||
|
throw new Error(t('txt_vault_key_unavailable'));
|
||||||
|
}
|
||||||
|
const validation = String(accountEncrypted.encKeyValidation_DO_NOT_EDIT || '').trim();
|
||||||
|
if (!validation) throw new Error(t('txt_invalid_encrypted_export'));
|
||||||
|
const accountEncKey = base64ToBytes(accountKeys.encB64);
|
||||||
|
const accountMacKey = base64ToBytes(accountKeys.macB64);
|
||||||
|
try {
|
||||||
|
await decryptStr(validation, accountEncKey, accountMacKey);
|
||||||
|
} catch {
|
||||||
|
throw new Error(t('txt_export_belongs_to_another_account'));
|
||||||
|
}
|
||||||
|
return onImportEncryptedRaw(
|
||||||
|
normalizeBitwardenEncryptedAccountImport(accountEncrypted),
|
||||||
|
{
|
||||||
|
folderMode,
|
||||||
|
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
|
||||||
|
},
|
||||||
|
attachments
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return onImport(
|
||||||
|
normalizeBitwardenImport(parsed),
|
||||||
|
{
|
||||||
|
folderMode,
|
||||||
|
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
|
||||||
|
},
|
||||||
|
attachments
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractNodeWardenAttachments(parsed: unknown): Promise<ImportAttachmentFile[]> {
|
||||||
|
if (!isRecord(parsed)) return [];
|
||||||
|
const direct = parseNodeWardenAttachmentArray(parsed.nodewardenAttachments);
|
||||||
|
if (direct.length) return direct;
|
||||||
|
|
||||||
|
const encryptedPayload = String(parsed.nodewardenAttachmentsEnc || '').trim();
|
||||||
|
if (!encryptedPayload) return [];
|
||||||
|
if (!accountKeys?.encB64 || !accountKeys?.macB64) {
|
||||||
|
throw new Error(t('txt_vault_key_unavailable'));
|
||||||
|
}
|
||||||
|
const accountEnc = base64ToBytes(accountKeys.encB64);
|
||||||
|
const accountMac = base64ToBytes(accountKeys.macB64);
|
||||||
|
const plain = await decryptStr(encryptedPayload, accountEnc, accountMac);
|
||||||
|
const unpacked = JSON.parse(plain) as Record<string, unknown>;
|
||||||
|
return parseNodeWardenAttachmentArray(unpacked.nodewardenAttachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runNodeWardenJsonImport(parsed: unknown, extraAttachments: ImportAttachmentFile[] = []): Promise<ImportResultSummary> {
|
||||||
|
const bundled = await extractNodeWardenAttachments(parsed);
|
||||||
|
return runBitwardenJsonImport(parsed, [...bundled, ...extraAttachments]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processPasswordProtectedImport(ctx: PendingPasswordImportContext): Promise<ImportResultSummary> {
|
||||||
|
const parsed = await decryptPasswordProtectedExport(ctx.parsed, importPassword);
|
||||||
|
if (ctx.source === 'nodewarden_json') {
|
||||||
|
return runNodeWardenJsonImport(parsed, ctx.attachments);
|
||||||
|
}
|
||||||
|
return runBitwardenJsonImport(parsed, ctx.attachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!file) {
|
||||||
|
onNotify('error', t('txt_please_select_a_file'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
if (source === 'bitwarden_zip') {
|
||||||
|
try {
|
||||||
|
const bundle = await readBitwardenZipPayload(file, '');
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(bundle.jsonText);
|
||||||
|
} catch {
|
||||||
|
throw new Error(t('txt_import_invalid_json_file'));
|
||||||
|
}
|
||||||
|
if (isPasswordProtectedExport(parsed)) {
|
||||||
|
setPendingPasswordImport({
|
||||||
|
parsed,
|
||||||
|
source: 'bitwarden_zip',
|
||||||
|
attachments: bundle.attachments,
|
||||||
|
});
|
||||||
|
setImportPassword('');
|
||||||
|
setPasswordDialogOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const summary = await runBitwardenJsonImport(parsed, bundle.attachments);
|
||||||
|
setImportSummary(summary);
|
||||||
|
setFile(null);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZipNeedsPasswordError) {
|
||||||
|
setPendingZipFile(file);
|
||||||
|
setZipImportPassword('');
|
||||||
|
setZipPasswordDialogOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await readImportText(file, source);
|
||||||
|
if (source === 'bitwarden_json' || source === 'nodewarden_json') {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new Error(t('txt_import_invalid_json_file'));
|
||||||
|
}
|
||||||
|
if (isPasswordProtectedExport(parsed)) {
|
||||||
|
setPendingPasswordImport({
|
||||||
|
parsed,
|
||||||
|
source,
|
||||||
|
attachments: [],
|
||||||
|
});
|
||||||
|
setImportPassword('');
|
||||||
|
setPasswordDialogOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const summary =
|
||||||
|
source === 'nodewarden_json'
|
||||||
|
? await runNodeWardenJsonImport(parsed)
|
||||||
|
: await runBitwardenJsonImport(parsed);
|
||||||
|
setImportSummary(summary);
|
||||||
|
} else {
|
||||||
|
const summary = await onImport(
|
||||||
|
parseImportPayloadBySource(source, text),
|
||||||
|
{
|
||||||
|
folderMode,
|
||||||
|
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
setImportSummary(summary);
|
||||||
|
}
|
||||||
|
setFile(null);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : t('txt_import_failed');
|
||||||
|
onNotify('error', message);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePasswordImportConfirm() {
|
||||||
|
if (isPasswordSubmitting) return;
|
||||||
|
if (!pendingPasswordImport) return;
|
||||||
|
setIsPasswordSubmitting(true);
|
||||||
|
try {
|
||||||
|
const summary = await processPasswordProtectedImport(pendingPasswordImport);
|
||||||
|
setImportSummary(summary);
|
||||||
|
setFile(null);
|
||||||
|
setImportPassword('');
|
||||||
|
setPendingPasswordImport(null);
|
||||||
|
setPasswordDialogOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : t('txt_import_failed');
|
||||||
|
onNotify('error', message);
|
||||||
|
} finally {
|
||||||
|
setIsPasswordSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleZipPasswordImportConfirm() {
|
||||||
|
if (isZipPasswordSubmitting) return;
|
||||||
|
if (!pendingZipFile) return;
|
||||||
|
setIsZipPasswordSubmitting(true);
|
||||||
|
try {
|
||||||
|
const bundle = await readBitwardenZipPayload(pendingZipFile, zipImportPassword);
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(bundle.jsonText);
|
||||||
|
} catch {
|
||||||
|
throw new Error(t('txt_import_invalid_json_file'));
|
||||||
|
}
|
||||||
|
if (isPasswordProtectedExport(parsed)) {
|
||||||
|
setPendingPasswordImport({
|
||||||
|
parsed,
|
||||||
|
source: 'bitwarden_zip',
|
||||||
|
attachments: bundle.attachments,
|
||||||
|
});
|
||||||
|
setImportPassword('');
|
||||||
|
setPasswordDialogOpen(true);
|
||||||
|
} else {
|
||||||
|
const summary = await runBitwardenJsonImport(parsed, bundle.attachments);
|
||||||
|
setImportSummary(summary);
|
||||||
|
setFile(null);
|
||||||
|
}
|
||||||
|
setZipPasswordDialogOpen(false);
|
||||||
|
setPendingZipFile(null);
|
||||||
|
setZipImportPassword('');
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZipInvalidPasswordError) {
|
||||||
|
onNotify('error', t('txt_import_invalid_zip_password'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : t('txt_import_failed');
|
||||||
|
onNotify('error', message);
|
||||||
|
} finally {
|
||||||
|
setIsZipPasswordSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportNeedsMode =
|
||||||
|
exportFormat === 'bitwarden_encrypted_json' ||
|
||||||
|
exportFormat === 'bitwarden_encrypted_json_zip' ||
|
||||||
|
exportFormat === 'nodewarden_encrypted_json';
|
||||||
|
const exportNeedsFilePassword = exportNeedsMode && encryptedJsonMode === 'password';
|
||||||
|
const exportIsZip = exportFormat === 'bitwarden_json_zip' || exportFormat === 'bitwarden_encrypted_json_zip';
|
||||||
|
|
||||||
|
async function runExportWithMasterPassword(masterPassword: string) {
|
||||||
|
const filePassword = exportPassword.trim();
|
||||||
|
const zipPass = zipPassword.trim();
|
||||||
|
if (exportNeedsFilePassword && !filePassword) {
|
||||||
|
onNotify('error', t('txt_import_file_password_required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsExporting(true);
|
||||||
|
try {
|
||||||
|
await onExport({
|
||||||
|
format: exportFormat,
|
||||||
|
encryptedJsonMode: exportNeedsMode ? encryptedJsonMode : undefined,
|
||||||
|
filePassword,
|
||||||
|
zipPassword: exportIsZip ? zipPass : '',
|
||||||
|
masterPassword,
|
||||||
|
});
|
||||||
|
onNotify('success', t('txt_export_completed'));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : t('txt_export_failed');
|
||||||
|
onNotify('error', message);
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExportConfirmPassword() {
|
||||||
|
if (isExporting) return;
|
||||||
|
const masterPassword = String(exportAuthPassword || '').trim();
|
||||||
|
if (!masterPassword) {
|
||||||
|
onNotify('error', t('txt_master_password_is_required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runExportWithMasterPassword(masterPassword);
|
||||||
|
if (!isExporting) {
|
||||||
|
setExportAuthPassword('');
|
||||||
|
setExportAuthDialogOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExport() {
|
||||||
|
setExportAuthPassword('');
|
||||||
|
setExportAuthDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="import-export-page">
|
||||||
|
<div className="import-export-panels">
|
||||||
|
<section className="card import-export-panel">
|
||||||
|
<h3>{t('txt_import')}</h3>
|
||||||
|
<p className="backup-inline-note">{t('txt_import_vault_data_hint')}</p>
|
||||||
|
<div className="field-grid">
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_format')}</span>
|
||||||
|
<select className="input" value={source} onChange={(e) => setSource((e.currentTarget as HTMLSelectElement).value as ImportSourceId)}>
|
||||||
|
{commonSources.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{otherSources.length > 0 && (
|
||||||
|
<option disabled value="__separator__">
|
||||||
|
--------------------
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
{otherSources.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_source_file')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="file"
|
||||||
|
accept={getFileAcceptBySource(source)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = (e.currentTarget as HTMLInputElement).files?.[0] || null;
|
||||||
|
setFile(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_folder_handling')}</span>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={folderMode}
|
||||||
|
onChange={(e) => setFolderMode((e.currentTarget as HTMLSelectElement).value as 'original' | 'none' | 'target')}
|
||||||
|
>
|
||||||
|
<option value="original">{t('txt_import_folder_mode_original')}</option>
|
||||||
|
<option value="none">{t('txt_import_folder_mode_none')}</option>
|
||||||
|
<option value="target">{t('txt_import_folder_mode_target')}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{folderMode === 'target' && (
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_target_folder')}</span>
|
||||||
|
<select className="input" value={targetFolderId} onChange={(e) => setTargetFolderId((e.currentTarget as HTMLSelectElement).value)}>
|
||||||
|
<option value="">{t('txt_select_folder_placeholder')}</option>
|
||||||
|
{folders
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => String(a.decName || a.name || '').localeCompare(String(b.decName || b.name || '')))
|
||||||
|
.map((folder) => (
|
||||||
|
<option key={folder.id} value={folder.id}>
|
||||||
|
{folder.decName || folder.name || folder.id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={isSubmitting || (folderMode === 'target' && !targetFolderId)}
|
||||||
|
onClick={() => void handleSubmit()}
|
||||||
|
>
|
||||||
|
<FileUp size={15} /> {isSubmitting ? t('txt_loading') : t('txt_import')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card import-export-panel">
|
||||||
|
<h3>{t('txt_export')}</h3>
|
||||||
|
<p className="backup-inline-note">{t('txt_export_vault_data_hint')}</p>
|
||||||
|
<div className="field-grid">
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_format')}</span>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={exportFormat}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = (e.currentTarget as HTMLSelectElement).value as ExportFormatId;
|
||||||
|
setExportFormat(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{EXPORT_FORMATS.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{exportNeedsMode && (
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_encrypted_mode')}</span>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={encryptedJsonMode}
|
||||||
|
onChange={(e) => setEncryptedJsonMode((e.currentTarget as HTMLSelectElement).value as EncryptedJsonMode)}
|
||||||
|
>
|
||||||
|
<option value="account">{t('txt_account_verification')}</option>
|
||||||
|
<option value="password">{t('txt_password_verification')}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{exportNeedsFilePassword && (
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_file_password')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={exportPassword}
|
||||||
|
onInput={(e) => setExportPassword((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{exportIsZip && (
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_zip_password_optional')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={zipPassword}
|
||||||
|
onInput={(e) => setZipPassword((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" className="btn btn-primary" disabled={isExporting} onClick={() => void handleExport()}>
|
||||||
|
<Download size={15} className="btn-icon" />
|
||||||
|
{isExporting ? t('txt_loading') : t('txt_export')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={exportAuthDialogOpen}
|
||||||
|
title={t('txt_export')}
|
||||||
|
message={t('txt_enter_master_password_to_view_this_item')}
|
||||||
|
confirmText={isExporting ? t('txt_loading') : t('txt_verify')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
showIcon={false}
|
||||||
|
confirmDisabled={isExporting}
|
||||||
|
cancelDisabled={isExporting}
|
||||||
|
onConfirm={() => void handleExportConfirmPassword()}
|
||||||
|
onCancel={() => {
|
||||||
|
if (isExporting) return;
|
||||||
|
setExportAuthDialogOpen(false);
|
||||||
|
setExportAuthPassword('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_master_password')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={exportAuthPassword}
|
||||||
|
onInput={(e) => setExportAuthPassword((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={passwordDialogOpen}
|
||||||
|
title={t('txt_import_encrypted_file_title')}
|
||||||
|
message={t('txt_import_encrypted_file_message')}
|
||||||
|
confirmText={isPasswordSubmitting ? t('txt_loading') : t('txt_import')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
showIcon={false}
|
||||||
|
confirmDisabled={isPasswordSubmitting}
|
||||||
|
cancelDisabled={isPasswordSubmitting}
|
||||||
|
onConfirm={() => void handlePasswordImportConfirm()}
|
||||||
|
onCancel={() => {
|
||||||
|
if (isPasswordSubmitting) return;
|
||||||
|
setPasswordDialogOpen(false);
|
||||||
|
setImportPassword('');
|
||||||
|
setPendingPasswordImport(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_file_password')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={importPassword}
|
||||||
|
onInput={(e) => setImportPassword((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={zipPasswordDialogOpen}
|
||||||
|
title={t('txt_import_encrypted_zip_title')}
|
||||||
|
message={t('txt_import_encrypted_zip_message')}
|
||||||
|
confirmText={isZipPasswordSubmitting ? t('txt_loading') : t('txt_import')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
showIcon={false}
|
||||||
|
confirmDisabled={isZipPasswordSubmitting}
|
||||||
|
cancelDisabled={isZipPasswordSubmitting}
|
||||||
|
onConfirm={() => void handleZipPasswordImportConfirm()}
|
||||||
|
onCancel={() => {
|
||||||
|
if (isZipPasswordSubmitting) return;
|
||||||
|
setZipPasswordDialogOpen(false);
|
||||||
|
setZipImportPassword('');
|
||||||
|
setPendingZipFile(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_zip_password')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={zipImportPassword}
|
||||||
|
onInput={(e) => setZipImportPassword((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
{importSummary && typeof document !== 'undefined' ? createPortal((
|
||||||
|
<div
|
||||||
|
className="dialog-mask"
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target !== event.currentTarget) return;
|
||||||
|
setImportSummary(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<section className="dialog-card import-summary-dialog" role="dialog" aria-modal="true" aria-label={t('txt_import_success')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="import-summary-close"
|
||||||
|
onClick={() => setImportSummary(null)}
|
||||||
|
aria-label={t('txt_close')}
|
||||||
|
>
|
||||||
|
X
|
||||||
|
</button>
|
||||||
|
<h3 className="dialog-title">{t('txt_import_success')}</h3>
|
||||||
|
<div className="dialog-message">{t('txt_import_success_number_of_items', { count: importSummary.totalItems })}</div>
|
||||||
|
{importSummary.attachmentCount > 0 && (
|
||||||
|
<div className="dialog-message">
|
||||||
|
{t('txt_import_attachment_summary', {
|
||||||
|
imported: String(importSummary.importedAttachmentCount),
|
||||||
|
total: String(importSummary.attachmentCount),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{importSummary.failedAttachments.length > 0 && (
|
||||||
|
<div className="import-summary-failed-list">
|
||||||
|
<div className="import-summary-failed-title">
|
||||||
|
{t('txt_import_failed_attachments_title', { count: String(importSummary.failedAttachments.length) })}
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{importSummary.failedAttachments.map((row, index) => (
|
||||||
|
<li key={`${row.fileName}-${index}`}>
|
||||||
|
<strong>{row.fileName}</strong>
|
||||||
|
{`: ${row.reason}`}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="import-summary-table-wrap">
|
||||||
|
<table className="import-summary-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('txt_type')}</th>
|
||||||
|
<th>{t('txt_total')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{importSummary.typeCounts.map((row) => (
|
||||||
|
<tr key={row.label}>
|
||||||
|
<td>{row.label}</td>
|
||||||
|
<td>{row.count}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr>
|
||||||
|
<td>{t('txt_folder')}</td>
|
||||||
|
<td>{importSummary.folderCount}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-primary dialog-btn" onClick={() => setImportSummary(null)}>
|
||||||
|
{t('txt_confirm')}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
), document.body) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useMemo, useState } from 'preact/hooks';
|
||||||
|
import { AlertTriangle, Copy, RefreshCw } from 'lucide-preact';
|
||||||
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
|
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
|
interface JwtWarningPageProps {
|
||||||
|
reason: 'missing' | 'default' | 'too_short';
|
||||||
|
minLength: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLOUDFLARE_SETTINGS_URL =
|
||||||
|
'https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings';
|
||||||
|
|
||||||
|
export default function JwtWarningPage(props: JwtWarningPageProps) {
|
||||||
|
const [seed, setSeed] = useState(0);
|
||||||
|
const [copyHint, setCopyHint] = useState('');
|
||||||
|
|
||||||
|
const generatedSecret = useMemo(() => generateJwtSecret(32), [seed]);
|
||||||
|
|
||||||
|
const title =
|
||||||
|
props.reason === 'missing'
|
||||||
|
? t('txt_jwt_title_missing')
|
||||||
|
: props.reason === 'default'
|
||||||
|
? t('txt_jwt_title_default')
|
||||||
|
: t('txt_jwt_title_too_short');
|
||||||
|
|
||||||
|
const isMissing = props.reason === 'missing';
|
||||||
|
const fixTitle = isMissing ? t('txt_jwt_how_to_fix_add') : t('txt_jwt_how_to_fix_replace');
|
||||||
|
const fixStep1 = isMissing ? t('txt_jwt_add_step_1') : t('txt_jwt_replace_step_1', { min: props.minLength });
|
||||||
|
const fixStep2Prefix = isMissing ? t('txt_jwt_add_step_2_prefix') : t('txt_jwt_replace_step_2_prefix');
|
||||||
|
const fixStep2Suffix = isMissing ? t('txt_jwt_add_step_2_suffix') : t('txt_jwt_replace_step_2_suffix');
|
||||||
|
const fixStep3 = isMissing ? t('txt_jwt_add_step_3') : t('txt_jwt_replace_step_3');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-page">
|
||||||
|
<StandalonePageFrame title={title}>
|
||||||
|
<div className="jwt-warning-head">
|
||||||
|
<AlertTriangle size={20} />
|
||||||
|
<strong>{t('txt_jwt_warning_subtitle')}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="jwt-warning-box">
|
||||||
|
<div className="jwt-warning-label">{t('txt_jwt_what_is')}</div>
|
||||||
|
<p className="jwt-warning-copy">{t('txt_jwt_what_is_body')}</p>
|
||||||
|
|
||||||
|
<div className="jwt-warning-label">{fixTitle}</div>
|
||||||
|
<ol className="jwt-warning-list">
|
||||||
|
<li>{fixStep1}</li>
|
||||||
|
<li>
|
||||||
|
{fixStep2Prefix}
|
||||||
|
<a
|
||||||
|
href={CLOUDFLARE_SETTINGS_URL}
|
||||||
|
className="jwt-inline-link"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{t('txt_settings')}
|
||||||
|
</a>
|
||||||
|
{fixStep2Suffix}
|
||||||
|
<div className="jwt-secret-fields">
|
||||||
|
<div className="jwt-secret-row">
|
||||||
|
<span>{t('txt_jwt_secret_type_label')}</span>
|
||||||
|
<strong>{t('txt_jwt_secret_type_value')}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="jwt-secret-row">
|
||||||
|
<span>{t('txt_jwt_secret_name_label')}</span>
|
||||||
|
<strong>JWT_SECRET</strong>
|
||||||
|
</div>
|
||||||
|
<div className="jwt-secret-row">
|
||||||
|
<span>{t('txt_jwt_secret_value_label')}</span>
|
||||||
|
<strong>{t('txt_jwt_secret_value_requirement', { min: props.minLength })}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>{fixStep3}</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div className="jwt-generator">
|
||||||
|
<div className="jwt-warning-label">{t('txt_random_secret_generator')}</div>
|
||||||
|
<input className="input input-readonly" readOnly value={generatedSecret} />
|
||||||
|
<div className="jwt-generator-actions">
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => setSeed((v) => v + 1)}>
|
||||||
|
<RefreshCw size={15} className="btn-icon" />
|
||||||
|
{t('txt_regenerate')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={async () => {
|
||||||
|
await copyTextToClipboard(generatedSecret, {
|
||||||
|
onSuccess: () => setCopyHint(t('txt_copied')),
|
||||||
|
onError: () => setCopyHint(t('txt_copy_failed')),
|
||||||
|
});
|
||||||
|
window.setTimeout(() => setCopyHint(''), 1500);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy size={15} className="btn-icon" />
|
||||||
|
{t('txt_copy')}
|
||||||
|
</button>
|
||||||
|
{copyHint && <span className="jwt-copy-hint">{copyHint}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</StandalonePageFrame>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateJwtSecret(length: number): string {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
||||||
|
let out = '';
|
||||||
|
const maxUnbiasedByte = Math.floor(256 / chars.length) * chars.length;
|
||||||
|
while (out.length < length) {
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(length));
|
||||||
|
for (const value of bytes) {
|
||||||
|
if (value >= maxUnbiasedByte) continue;
|
||||||
|
out += chars[value % chars.length];
|
||||||
|
if (out.length >= length) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
|
import { Download, Eye, Lock } from 'lucide-preact';
|
||||||
|
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send';
|
||||||
|
import { toBufferSource } from '@/lib/crypto';
|
||||||
|
import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download';
|
||||||
|
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
|
interface PublicSendPageProps {
|
||||||
|
accessId: string;
|
||||||
|
keyPart: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PublicSendPage(props: PublicSendPageProps) {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [needPassword, setNeedPassword] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [sendData, setSendData] = useState<any>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [downloadPercent, setDownloadPercent] = useState<number | null>(null);
|
||||||
|
|
||||||
|
async function loadSend(pass?: string): Promise<void> {
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const data = await accessPublicSend(props.accessId, props.keyPart, pass);
|
||||||
|
if (!props.keyPart) {
|
||||||
|
setError(t('txt_this_link_is_missing_decryption_key'));
|
||||||
|
setSendData(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const decrypted = await decryptPublicSend(data, props.keyPart);
|
||||||
|
setSendData(decrypted);
|
||||||
|
setNeedPassword(false);
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as Error & { status?: number };
|
||||||
|
if (err.status === 401) {
|
||||||
|
setNeedPassword(true);
|
||||||
|
setError(t('txt_this_send_is_password_protected'));
|
||||||
|
} else {
|
||||||
|
setError(err.message || t('txt_failed_to_open_send'));
|
||||||
|
}
|
||||||
|
setSendData(null);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(): Promise<void> {
|
||||||
|
if (!sendData?.id || !sendData?.file?.id) return;
|
||||||
|
setBusy(true);
|
||||||
|
setDownloadPercent(null);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const url = await accessPublicSendFile(sendData.id, sendData.file.id, props.keyPart, password || undefined);
|
||||||
|
const resp = await fetch(url);
|
||||||
|
if (!resp.ok) throw new Error(t('txt_download_failed'));
|
||||||
|
const encryptedBytes = await readResponseBytesWithProgress(resp, (progress) => setDownloadPercent(progress.percent));
|
||||||
|
let blob: Blob;
|
||||||
|
if (props.keyPart) {
|
||||||
|
try {
|
||||||
|
const decryptedBytes = await decryptPublicSendFileBytes(encryptedBytes, props.keyPart);
|
||||||
|
blob = new Blob([toBufferSource(decryptedBytes)], { type: 'application/octet-stream' });
|
||||||
|
} catch {
|
||||||
|
// Legacy compatibility: early web-created file sends uploaded plaintext bytes.
|
||||||
|
blob = new Blob([toBufferSource(encryptedBytes)], { type: 'application/octet-stream' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
blob = new Blob([toBufferSource(encryptedBytes)], { type: 'application/octet-stream' });
|
||||||
|
}
|
||||||
|
downloadBytesAsFile(
|
||||||
|
new Uint8Array(await blob.arrayBuffer()),
|
||||||
|
sendData.decFileName || sendData.file?.fileName || t('txt_send_file'),
|
||||||
|
'application/octet-stream'
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as Error;
|
||||||
|
setError(err.message || t('txt_download_failed'));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
setDownloadPercent(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadSend();
|
||||||
|
}, [props.accessId, props.keyPart]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-page public-send-page">
|
||||||
|
<StandalonePageFrame title={t('txt_nodewarden_send')}>
|
||||||
|
{loading && <p className="muted">{t('txt_loading')}</p>}
|
||||||
|
|
||||||
|
{!loading && needPassword && (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void loadSend(password);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_password')}</span>
|
||||||
|
<div className="password-wrap">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
autoComplete="current-password"
|
||||||
|
onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="btn btn-primary full" disabled={busy}>
|
||||||
|
<Lock size={14} className="btn-icon" /> {t('txt_unlock_send')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && sendData && (
|
||||||
|
<>
|
||||||
|
<h2 style={{ marginTop: '8px' }}>{sendData.decName || t('txt_no_name')}</h2>
|
||||||
|
{sendData.type === 0 ? (
|
||||||
|
<div className="card" style={{ marginTop: '10px' }}>
|
||||||
|
<div className="notes">{sendData.decText || ''}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card" style={{ marginTop: '10px' }}>
|
||||||
|
<div className="kv-line">
|
||||||
|
<span>{t('txt_file')}</span>
|
||||||
|
<strong>{sendData.decFileName || sendData.file?.fileName || sendData.file?.sizeName || t('txt_encrypted_file')}</strong>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-primary full" disabled={busy} onClick={() => void downloadFile()}>
|
||||||
|
<Download size={14} className="btn-icon" /> {downloadPercent == null ? (busy ? t('txt_downloading') : t('txt_download')) : t('txt_downloading_percent', { percent: downloadPercent })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!!sendData.expirationDate && <p className="muted">{t('txt_expires_at_value', { value: sendData.expirationDate })}</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !sendData && !needPassword && !error && (
|
||||||
|
<p className="muted">
|
||||||
|
<Eye size={14} style={{ verticalAlign: 'text-bottom' }} /> {t('txt_send_unavailable')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!!error && <p className="local-error">{error}</p>}
|
||||||
|
</StandalonePageFrame>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
import { Eye, EyeOff, Send, X } from 'lucide-preact';
|
||||||
|
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
|
interface RecoverTwoFactorPageProps {
|
||||||
|
values: { email: string; password: string; recoveryCode: string };
|
||||||
|
onChange: (next: { email: string; password: string; recoveryCode: string }) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecoverTwoFactorPage(props: RecoverTwoFactorPageProps) {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-page">
|
||||||
|
<StandalonePageFrame title={t('txt_recover_two_step_login')}>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
props.onSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="muted standalone-muted">{t('txt_use_your_one_time_recovery_code_to_disable_two_step_verification')}</p>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_email')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="email"
|
||||||
|
value={props.values.email}
|
||||||
|
autoComplete="username"
|
||||||
|
onInput={(e) => props.onChange({ ...props.values, email: (e.currentTarget as HTMLInputElement).value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_master_password')}</span>
|
||||||
|
<div className="password-wrap">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={props.values.password}
|
||||||
|
autoComplete="current-password"
|
||||||
|
onInput={(e) => props.onChange({ ...props.values, password: (e.currentTarget as HTMLInputElement).value })}
|
||||||
|
/>
|
||||||
|
<button type="button" className="eye-btn" onClick={() => setShowPassword((v) => !v)}>
|
||||||
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_recovery_code')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={props.values.recoveryCode}
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
onInput={(e) => props.onChange({ ...props.values, recoveryCode: (e.currentTarget as HTMLInputElement).value.toUpperCase() })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="field-grid">
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
<Send size={14} className="btn-icon" />
|
||||||
|
{t('txt_submit')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={props.onCancel}>
|
||||||
|
<X size={14} className="btn-icon" />
|
||||||
|
{t('txt_cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</StandalonePageFrame>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { Clock3, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact';
|
||||||
|
import type { AuthorizedDevice } from '@/lib/types';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
|
interface SecurityDevicesPageProps {
|
||||||
|
devices: AuthorizedDevice[];
|
||||||
|
loading: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onRevokeTrust: (device: AuthorizedDevice) => void;
|
||||||
|
onRemoveDevice: (device: AuthorizedDevice) => void;
|
||||||
|
onRevokeAll: () => void;
|
||||||
|
onRemoveAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string | null | undefined): string {
|
||||||
|
if (!value) return t('txt_dash');
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return t('txt_dash');
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDeviceTypeName(type: number): string {
|
||||||
|
switch (type) {
|
||||||
|
case 0: return t('txt_android');
|
||||||
|
case 1: return t('txt_ios');
|
||||||
|
case 2: return t('txt_chrome_extension');
|
||||||
|
case 3: return t('txt_firefox_extension');
|
||||||
|
case 4: return t('txt_opera_extension');
|
||||||
|
case 5: return t('txt_edge_extension');
|
||||||
|
case 6: return t('txt_windows_desktop');
|
||||||
|
case 7: return t('txt_macos_desktop');
|
||||||
|
case 8: return t('txt_linux_desktop');
|
||||||
|
case 9: return t('txt_chrome_browser');
|
||||||
|
case 10: return t('txt_firefox_browser');
|
||||||
|
case 11: return t('txt_opera_browser');
|
||||||
|
case 12: return t('txt_edge_browser');
|
||||||
|
case 13: return t('txt_ie_browser');
|
||||||
|
case 14: return t('txt_web');
|
||||||
|
default: return t('txt_type_type', { type });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
<section className="card">
|
||||||
|
<div className="section-head">
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: 0 }}>{t('txt_device_management')}</h3>
|
||||||
|
<div className="muted-inline" style={{ marginTop: 4 }}>
|
||||||
|
{t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={props.onRefresh}>
|
||||||
|
<RefreshCw size={14} className="btn-icon" />
|
||||||
|
{t('txt_refresh')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-danger small" onClick={props.onRevokeAll}>
|
||||||
|
<ShieldOff size={14} className="btn-icon" />
|
||||||
|
{t('txt_revoke_all_trusted')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-danger small" onClick={props.onRemoveAll}>
|
||||||
|
<Trash2 size={14} className="btn-icon" />
|
||||||
|
{t('txt_remove_all_devices')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h3 style={{ marginTop: 0 }}>{t('txt_authorized_devices')}</h3>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('txt_device')}</th>
|
||||||
|
<th>{t('txt_type')}</th>
|
||||||
|
<th>{t('txt_status')}</th>
|
||||||
|
<th>{t('txt_added')}</th>
|
||||||
|
<th>{t('txt_last_seen')}</th>
|
||||||
|
<th>{t('txt_trusted_until')}</th>
|
||||||
|
<th>{t('txt_actions')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{props.devices.map((device) => (
|
||||||
|
<tr key={device.identifier}>
|
||||||
|
<td data-label={t('txt_device')}>
|
||||||
|
<div>{device.name || t('txt_unknown_device')}</div>
|
||||||
|
<div className="muted-inline">{device.identifier}</div>
|
||||||
|
</td>
|
||||||
|
<td data-label={t('txt_type')}>{mapDeviceTypeName(device.type)}</td>
|
||||||
|
<td data-label={t('txt_status')}>
|
||||||
|
<span className={`device-status-pill ${device.online ? 'online' : 'offline'}`}>
|
||||||
|
{device.online ? t('txt_online') : t('txt_offline')}
|
||||||
|
</span>
|
||||||
|
</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_trusted_until')}>
|
||||||
|
{device.trusted ? (
|
||||||
|
<div className="trusted-cell">
|
||||||
|
<Clock3 size={13} />
|
||||||
|
<span>{formatDateTime(device.trustedUntil)}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="muted-inline">{t('txt_not_trusted')}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td data-label={t('txt_actions')}>
|
||||||
|
<div className="actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small"
|
||||||
|
disabled={!device.trusted}
|
||||||
|
onClick={() => props.onRevokeTrust(device)}
|
||||||
|
>
|
||||||
|
<ShieldOff size={14} className="btn-icon" />
|
||||||
|
{t('txt_revoke_trust')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-danger small" onClick={() => props.onRemoveDevice(device)}>
|
||||||
|
<Trash2 size={14} className="btn-icon" />
|
||||||
|
{t('txt_remove_device_2')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!props.loading && props.devices.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7}>
|
||||||
|
<div className="empty" style={{ minHeight: 80 }}>{t('txt_no_devices_found')}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
import { useEffect, useMemo, 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 { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
|
import type { Send, SendDraft } from '@/lib/types';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
|
interface SendsPageProps {
|
||||||
|
sends: Send[];
|
||||||
|
loading: boolean;
|
||||||
|
onRefresh: () => Promise<void>;
|
||||||
|
onCreate: (draft: SendDraft, autoCopyLink: boolean) => Promise<void>;
|
||||||
|
onUpdate: (send: Send, draft: SendDraft, autoCopyLink: boolean) => Promise<void>;
|
||||||
|
onDelete: (send: Send) => Promise<void>;
|
||||||
|
onBulkDelete: (ids: string[]) => Promise<void>;
|
||||||
|
uploadingSendFileName: string;
|
||||||
|
sendUploadPercent: number | null;
|
||||||
|
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SendTypeFilter = 'all' | 'text' | 'file';
|
||||||
|
const AUTO_COPY_KEY = 'nodewarden.send.auto_copy_link.v1';
|
||||||
|
const MOBILE_LAYOUT_QUERY = '(max-width: 900px)';
|
||||||
|
|
||||||
|
function daysFromNow(iso: string | null | undefined, fallback: number): string {
|
||||||
|
if (!iso) return String(fallback);
|
||||||
|
const d = new Date(iso).getTime();
|
||||||
|
if (!Number.isFinite(d)) return String(fallback);
|
||||||
|
const diff = d - Date.now();
|
||||||
|
const days = Math.ceil(diff / (24 * 60 * 60 * 1000));
|
||||||
|
return String(Math.max(days, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDefaultDraft(): SendDraft {
|
||||||
|
return {
|
||||||
|
type: 'text',
|
||||||
|
name: '',
|
||||||
|
notes: '',
|
||||||
|
text: '',
|
||||||
|
file: null,
|
||||||
|
deletionDays: '7',
|
||||||
|
expirationDays: '0',
|
||||||
|
maxAccessCount: '',
|
||||||
|
password: '',
|
||||||
|
disabled: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftFromSend(send: Send): SendDraft {
|
||||||
|
return {
|
||||||
|
id: send.id,
|
||||||
|
type: Number(send.type) === 1 ? 'file' : 'text',
|
||||||
|
name: send.decName || '',
|
||||||
|
notes: send.decNotes || '',
|
||||||
|
text: send.decText || '',
|
||||||
|
file: null,
|
||||||
|
deletionDays: daysFromNow(send.deletionDate, 7),
|
||||||
|
expirationDays: daysFromNow(send.expirationDate, 0),
|
||||||
|
maxAccessCount: send.maxAccessCount !== null && send.maxAccessCount !== undefined ? String(send.maxAccessCount) : '',
|
||||||
|
password: '',
|
||||||
|
disabled: !!send.disabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SendsPage(props: SendsPageProps) {
|
||||||
|
const getInitialIsMobileLayout = () =>
|
||||||
|
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||||
|
? window.matchMedia(MOBILE_LAYOUT_QUERY).matches
|
||||||
|
: false;
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [typeFilter, setTypeFilter] = useState<SendTypeFilter>('all');
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [draft, setDraft] = useState<SendDraft | null>(null);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
||||||
|
const [isMobileLayout, setIsMobileLayout] = useState(getInitialIsMobileLayout);
|
||||||
|
const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list');
|
||||||
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
|
const [autoCopyLink, setAutoCopyLink] = useState<boolean>(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(AUTO_COPY_KEY) === '1';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const sendUploadLabel =
|
||||||
|
props.sendUploadPercent == null
|
||||||
|
? t('txt_uploading_file_named', { name: props.uploadingSendFileName || t('txt_file') })
|
||||||
|
: t('txt_uploading_file_named_percent', {
|
||||||
|
name: props.uploadingSendFileName || t('txt_file'),
|
||||||
|
percent: props.sendUploadPercent,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
||||||
|
const media = window.matchMedia(MOBILE_LAYOUT_QUERY);
|
||||||
|
const sync = () => setIsMobileLayout(media.matches);
|
||||||
|
sync();
|
||||||
|
if (typeof media.addEventListener === 'function') {
|
||||||
|
media.addEventListener('change', sync);
|
||||||
|
return () => media.removeEventListener('change', sync);
|
||||||
|
}
|
||||||
|
media.addListener(sync);
|
||||||
|
return () => media.removeListener(sync);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onToggleSidebar = () => {
|
||||||
|
setMobileSidebarOpen((open) => !open);
|
||||||
|
};
|
||||||
|
window.addEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
|
||||||
|
return () => window.removeEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(AUTO_COPY_KEY, autoCopyLink ? '1' : '0');
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
}, [autoCopyLink]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMobileLayout) {
|
||||||
|
setMobilePanel('list');
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isEditing) {
|
||||||
|
setMobilePanel('edit');
|
||||||
|
} else if (!selectedId) {
|
||||||
|
setMobilePanel('list');
|
||||||
|
}
|
||||||
|
}, [isMobileLayout, isEditing, selectedId]);
|
||||||
|
|
||||||
|
const filteredSends = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return props.sends.filter((send) => {
|
||||||
|
if (typeFilter === 'text' && Number(send.type) !== 0) return false;
|
||||||
|
if (typeFilter === 'file' && Number(send.type) !== 1) return false;
|
||||||
|
if (!q) return true;
|
||||||
|
const name = (send.decName || '').toLowerCase();
|
||||||
|
const text = (send.decText || '').toLowerCase();
|
||||||
|
return name.includes(q) || text.includes(q);
|
||||||
|
});
|
||||||
|
}, [props.sends, search, typeFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!filteredSends.length) {
|
||||||
|
setSelectedId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedId || !filteredSends.some((x) => x.id === selectedId)) {
|
||||||
|
setSelectedId(filteredSends[0].id);
|
||||||
|
setIsEditing(false);
|
||||||
|
setIsCreating(false);
|
||||||
|
setDraft(null);
|
||||||
|
}
|
||||||
|
}, [filteredSends, selectedId]);
|
||||||
|
|
||||||
|
const selectedSend = useMemo(
|
||||||
|
() => props.sends.find((x) => x.id === selectedId) || null,
|
||||||
|
[props.sends, selectedId]
|
||||||
|
);
|
||||||
|
const selectedIds = useMemo(() => Object.keys(selectedMap).filter((id) => selectedMap[id]), [selectedMap]);
|
||||||
|
const selectedCount = selectedIds.length;
|
||||||
|
|
||||||
|
async function saveDraft(): Promise<void> {
|
||||||
|
if (!draft) return;
|
||||||
|
if (!draft.name.trim()) {
|
||||||
|
props.onNotify('error', t('txt_name_is_required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (draft.type === 'text' && !draft.text.trim()) {
|
||||||
|
props.onNotify('error', t('txt_text_is_required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (draft.type === 'file' && isCreating && !draft.file) {
|
||||||
|
props.onNotify('error', t('txt_please_select_a_file'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
if (isCreating) {
|
||||||
|
await props.onCreate(draft, autoCopyLink);
|
||||||
|
setSelectedId(null);
|
||||||
|
} else if (selectedSend) {
|
||||||
|
await props.onUpdate(selectedSend, draft, autoCopyLink);
|
||||||
|
}
|
||||||
|
setIsEditing(false);
|
||||||
|
setIsCreating(false);
|
||||||
|
setDraft(null);
|
||||||
|
setShowPassword(false);
|
||||||
|
if (isMobileLayout) setMobilePanel('detail');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSend(send: Send): Promise<void> {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await props.onDelete(send);
|
||||||
|
if (selectedId === send.id) setSelectedId(null);
|
||||||
|
setIsEditing(false);
|
||||||
|
setDraft(null);
|
||||||
|
if (isMobileLayout) setMobilePanel('list');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSelected(): Promise<void> {
|
||||||
|
if (!selectedCount) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await props.onBulkDelete(selectedIds);
|
||||||
|
setSelectedMap({});
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyAccessUrl(send: Send): void {
|
||||||
|
const url = send.shareUrl || `${window.location.origin}/#/send/${send.accessId}`;
|
||||||
|
void copyTextToClipboard(url, { successMessage: t('txt_link_copied') });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
|
||||||
|
{isMobileLayout && (
|
||||||
|
<div
|
||||||
|
className={`mobile-sidebar-mask ${mobileSidebarOpen ? 'open' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (!mobileSidebarOpen) return;
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<aside className={`sidebar ${isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${isMobileLayout && mobileSidebarOpen ? 'open' : ''}`}>
|
||||||
|
{isMobileLayout && (
|
||||||
|
<div className="mobile-sidebar-head">
|
||||||
|
<div className="mobile-sidebar-title">{t('txt_all_sends')}</div>
|
||||||
|
<button type="button" className="mobile-sidebar-close" onClick={() => setMobileSidebarOpen(false)} aria-label={t('txt_close')}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="sidebar-block">
|
||||||
|
<div className="sidebar-title">{t('txt_all_sends')}</div>
|
||||||
|
<button type="button" className={`tree-btn ${typeFilter === 'all' ? 'active' : ''}`} onClick={() => setTypeFilter('all')}>
|
||||||
|
<LayoutGrid size={14} className="tree-icon" />
|
||||||
|
<span className="tree-label">{t('txt_all_sends')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="sidebar-block">
|
||||||
|
<div className="sidebar-title">{t('txt_type')}</div>
|
||||||
|
<button type="button" className={`tree-btn ${typeFilter === 'text' ? 'active' : ''}`} onClick={() => setTypeFilter('text')}>
|
||||||
|
<FileText size={14} className="tree-icon" />
|
||||||
|
<span className="tree-label">{t('txt_text')}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className={`tree-btn ${typeFilter === 'file' ? 'active' : ''}`} onClick={() => setTypeFilter('file')}>
|
||||||
|
<File size={14} className="tree-icon" />
|
||||||
|
<span className="tree-label">{t('txt_file')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="list-col">
|
||||||
|
<div className="list-head">
|
||||||
|
<input
|
||||||
|
className="search-input"
|
||||||
|
placeholder={t('txt_search_sends')}
|
||||||
|
value={search}
|
||||||
|
onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={busy || props.loading} onClick={() => void props.onRefresh()}>
|
||||||
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_refresh')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="toolbar actions">
|
||||||
|
<button type="button" className="btn btn-danger small" disabled={!selectedCount || busy} onClick={() => void removeSelected()}>
|
||||||
|
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_selected')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small"
|
||||||
|
disabled={!filteredSends.length}
|
||||||
|
onClick={() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
for (const send of filteredSends) map[send.id] = true;
|
||||||
|
setSelectedMap(map);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckCheck size={14} className="btn-icon" />
|
||||||
|
{t('txt_select_all')}
|
||||||
|
</button>
|
||||||
|
{!!selectedCount && (
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => setSelectedMap({})}>
|
||||||
|
<X size={14} className="btn-icon" />
|
||||||
|
{t('txt_cancel')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary small mobile-fab-trigger"
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={t('txt_add')}
|
||||||
|
title={t('txt_add')}
|
||||||
|
onClick={() => {
|
||||||
|
setIsCreating(true);
|
||||||
|
setIsEditing(true);
|
||||||
|
setDraft(buildDefaultDraft());
|
||||||
|
setShowPassword(false);
|
||||||
|
if (isMobileLayout) setMobilePanel('edit');
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={14} className="btn-icon" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="list-panel">
|
||||||
|
{filteredSends.map((send, index) => (
|
||||||
|
<div
|
||||||
|
key={send.id}
|
||||||
|
className={`list-item stagger-item ${selectedId === send.id ? 'active' : ''}`}
|
||||||
|
style={{ animationDelay: `${Math.min(index, 10) * 26}ms` }}
|
||||||
|
onClick={(event) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target.closest('.row-check')) return;
|
||||||
|
setSelectedId(send.id);
|
||||||
|
setIsEditing(false);
|
||||||
|
setIsCreating(false);
|
||||||
|
setDraft(null);
|
||||||
|
if (isMobileLayout) setMobilePanel('detail');
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="row-check"
|
||||||
|
checked={!!selectedMap[send.id]}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onInput={(e) =>
|
||||||
|
setSelectedMap((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[send.id]: (e.currentTarget as HTMLInputElement).checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="row-main"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedId(send.id);
|
||||||
|
setIsEditing(false);
|
||||||
|
setIsCreating(false);
|
||||||
|
setDraft(null);
|
||||||
|
if (isMobileLayout) setMobilePanel('detail');
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="list-icon-wrap">
|
||||||
|
<span className="list-icon-fallback">
|
||||||
|
<SendIcon />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="list-text">
|
||||||
|
<span className="list-title" title={send.decName || t('txt_no_name')}>{send.decName || t('txt_no_name')}</span>
|
||||||
|
<span className="list-sub">
|
||||||
|
{Number(send.type) === 1 ? t('txt_file') : t('txt_text')} - {t('txt_accessed_count_times', { count: send.accessCount || 0 })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!filteredSends.length && <div className="empty">{t('txt_no_sends')}</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={`detail-col ${isMobileLayout ? 'mobile-detail-sheet' : ''} ${isMobileLayout && mobilePanel !== 'list' ? 'open' : ''}`}>
|
||||||
|
{isMobileLayout && mobilePanel !== 'list' && (
|
||||||
|
<div className="mobile-panel-head">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small mobile-panel-back"
|
||||||
|
onClick={() => {
|
||||||
|
if (isEditing) {
|
||||||
|
setIsEditing(false);
|
||||||
|
setIsCreating(false);
|
||||||
|
setDraft(null);
|
||||||
|
setShowPassword(false);
|
||||||
|
setMobilePanel(selectedSend ? 'detail' : 'list');
|
||||||
|
} else {
|
||||||
|
setMobilePanel('list');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={14} className="btn-icon" />
|
||||||
|
{t('txt_back')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isEditing && draft && (
|
||||||
|
<div key={`send-editor-${draft.id || selectedSend?.id || 'new'}-${draft.type}`} className="detail-switch-stage">
|
||||||
|
<div className="card stagger-item" style={{ animationDelay: '0ms' }}>
|
||||||
|
<h3 className="detail-title">{isCreating ? t('txt_new_send') : t('txt_edit_send')}</h3>
|
||||||
|
{!!props.uploadingSendFileName && <div className="detail-sub">{sendUploadLabel}</div>}
|
||||||
|
<div className="field-grid">
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_name')}</span>
|
||||||
|
<input className="input" value={draft.name} onInput={(e) => setDraft({ ...draft, name: (e.currentTarget as HTMLInputElement).value })} />
|
||||||
|
</label>
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_type')}</span>
|
||||||
|
<div className="send-options">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={draft.type === 'file'}
|
||||||
|
disabled={!isCreating}
|
||||||
|
onInput={() => setDraft({ ...draft, type: 'file' })}
|
||||||
|
/>
|
||||||
|
{t('txt_file')}
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={draft.type === 'text'}
|
||||||
|
disabled={!isCreating}
|
||||||
|
onInput={() => setDraft({ ...draft, type: 'text' })}
|
||||||
|
/>
|
||||||
|
{t('txt_text')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{draft.type === 'file' ? (
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_file')}</span>
|
||||||
|
<input className="input" type="file" onInput={(e) => setDraft({ ...draft, file: (e.currentTarget as HTMLInputElement).files?.[0] || null })} />
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_text')}</span>
|
||||||
|
<textarea className="input textarea" rows={8} value={draft.text} onInput={(e) => setDraft({ ...draft, text: (e.currentTarget as HTMLTextAreaElement).value })} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_deletion_days')}</span>
|
||||||
|
<input className="input" type="number" min="1" max="31" value={draft.deletionDays} onInput={(e) => setDraft({ ...draft, deletionDays: (e.currentTarget as HTMLInputElement).value })} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_expiration_days_0_never')}</span>
|
||||||
|
<input className="input" type="number" min="0" max="3650" value={draft.expirationDays} onInput={(e) => setDraft({ ...draft, expirationDays: (e.currentTarget as HTMLInputElement).value })} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_max_access_count')}</span>
|
||||||
|
<input className="input" value={draft.maxAccessCount} onInput={(e) => setDraft({ ...draft, maxAccessCount: (e.currentTarget as HTMLInputElement).value })} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_password')}</span>
|
||||||
|
<div className="password-wrap">
|
||||||
|
<input className="input" type={showPassword ? 'text' : 'password'} value={draft.password} onInput={(e) => setDraft({ ...draft, password: (e.currentTarget as HTMLInputElement).value })} />
|
||||||
|
<button type="button" className="password-toggle" onClick={() => setShowPassword((v) => !v)}>
|
||||||
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_notes')}</span>
|
||||||
|
<textarea className="input textarea" rows={5} value={draft.notes} onInput={(e) => setDraft({ ...draft, notes: (e.currentTarget as HTMLTextAreaElement).value })} />
|
||||||
|
</label>
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_options')}</span>
|
||||||
|
<div className="send-options">
|
||||||
|
<label><input type="checkbox" checked={draft.disabled} onInput={(e) => setDraft({ ...draft, disabled: (e.currentTarget as HTMLInputElement).checked })} /> {t('txt_disable_this_send')}</label>
|
||||||
|
<label><input type="checkbox" checked={autoCopyLink} onInput={(e) => setAutoCopyLink((e.currentTarget as HTMLInputElement).checked)} /> {t('txt_auto_copy_link_after_save')}</label>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="detail-actions">
|
||||||
|
<button type="button" className="btn btn-primary small" disabled={busy} onClick={() => void saveDraft()}>
|
||||||
|
<Save size={14} className="btn-icon" /> {t('txt_save')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setIsCreating(false);
|
||||||
|
setDraft(null);
|
||||||
|
setShowPassword(false);
|
||||||
|
if (isMobileLayout) setMobilePanel(selectedSend ? 'detail' : 'list');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={14} className="btn-icon" /> {t('txt_cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isEditing && selectedSend && (
|
||||||
|
<div key={`send-detail-${selectedSend.id}`} className="detail-switch-stage">
|
||||||
|
<div className="card stagger-item" style={{ animationDelay: '36ms' }}>
|
||||||
|
<h3 className="detail-title">{selectedSend.decName || t('txt_no_name')}</h3>
|
||||||
|
<div className="detail-sub">{Number(selectedSend.type) === 1 ? t('txt_file_send') : t('txt_text_send')}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card stagger-item" style={{ animationDelay: '72ms' }}>
|
||||||
|
<h4>{t('txt_send_details')}</h4>
|
||||||
|
<div className="kv-line"><span>{t('txt_access_count')}</span><strong>{selectedSend.accessCount || 0}</strong></div>
|
||||||
|
<div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{selectedSend.deletionDate || t('txt_dash')}</strong></div>
|
||||||
|
<div className="kv-line"><span>{t('txt_expiration_date')}</span><strong>{selectedSend.expirationDate || t('txt_dash')}</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
{Number(selectedSend.type) === 1 ? (
|
||||||
|
<>
|
||||||
|
<h4>{t('txt_file')}</h4>
|
||||||
|
<div className="kv-line"><span>{t('txt_file_name')}</span><strong>{selectedSend.file?.fileName || t('txt_encrypted_file_2')}</strong></div>
|
||||||
|
<div className="kv-line"><span>{t('txt_file_size')}</span><strong>{selectedSend.file?.sizeName || t('txt_dash')}</strong></div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h4>{t('txt_text')}</h4>
|
||||||
|
<div className="notes">{selectedSend.decText || ''}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!!(selectedSend.decNotes || '').trim() && (
|
||||||
|
<div className="card stagger-item" style={{ animationDelay: '108ms' }}>
|
||||||
|
<h4>{t('txt_notes')}</h4>
|
||||||
|
<div className="notes">{selectedSend.decNotes || ''}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="detail-actions">
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => copyAccessUrl(selectedSend)}>
|
||||||
|
<Copy size={14} className="btn-icon" /> {t('txt_copy_link')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => { setDraft(draftFromSend(selectedSend)); setIsCreating(false); setIsEditing(true); }}>
|
||||||
|
<Pencil size={14} className="btn-icon" /> {t('txt_edit')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-danger small detail-delete-btn" disabled={busy} onClick={() => void removeSend(selectedSend)}>
|
||||||
|
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
|
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-preact';
|
||||||
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
|
import qrcode from 'qrcode-generator';
|
||||||
|
import type { Profile } from '@/lib/types';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
|
|
||||||
|
interface SettingsPageProps {
|
||||||
|
profile: Profile;
|
||||||
|
totpEnabled: boolean;
|
||||||
|
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
||||||
|
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
|
||||||
|
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
||||||
|
onOpenDisableTotp: () => void;
|
||||||
|
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||||
|
onNotify?: (type: 'success' | 'error', text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomBase32Secret(length: number): string {
|
||||||
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||||
|
let out = '';
|
||||||
|
const maxUnbiasedByte = Math.floor(256 / alphabet.length) * alphabet.length;
|
||||||
|
while (out.length < length) {
|
||||||
|
const random = crypto.getRandomValues(new Uint8Array(length));
|
||||||
|
for (const x of random) {
|
||||||
|
if (x >= maxUnbiasedByte) continue;
|
||||||
|
out += alphabet[x % alphabet.length];
|
||||||
|
if (out.length >= length) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOtpUri(email: string, secret: string): string {
|
||||||
|
const issuer = 'NodeWarden';
|
||||||
|
return `otpauth://totp/${encodeURIComponent(`${issuer}:${email}`)}?secret=${encodeURIComponent(secret)}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsPage(props: SettingsPageProps) {
|
||||||
|
const totpSecretStorageKey = `nodewarden.totp.secret.${props.profile.id}`;
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [newPassword2, setNewPassword2] = useState('');
|
||||||
|
const [passwordHint, setPasswordHint] = useState(props.profile.masterPasswordHint || '');
|
||||||
|
const [secret, setSecret] = useState(() => localStorage.getItem(totpSecretStorageKey) || randomBase32Secret(32));
|
||||||
|
const [token, setToken] = useState('');
|
||||||
|
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
|
||||||
|
const [recoveryMasterPassword, setRecoveryMasterPassword] = useState('');
|
||||||
|
const [recoveryCode, setRecoveryCode] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!props.totpEnabled) {
|
||||||
|
setTotpLocked(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTotpLocked(true);
|
||||||
|
}, [props.totpEnabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPasswordHint(props.profile.masterPasswordHint || '');
|
||||||
|
}, [props.profile.masterPasswordHint]);
|
||||||
|
|
||||||
|
const qrDataUrl = useMemo(() => {
|
||||||
|
const qr = qrcode(0, 'M');
|
||||||
|
qr.addData(buildOtpUri(props.profile.email, secret));
|
||||||
|
qr.make();
|
||||||
|
// Keep a visible quiet zone so authenticator apps can scan reliably in both themes.
|
||||||
|
const svg = qr.createSvgTag({ scalable: true, margin: 4 });
|
||||||
|
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||||
|
}, [props.profile.email, secret]);
|
||||||
|
|
||||||
|
async function enableTotp(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await props.onEnableTotp(secret, token);
|
||||||
|
// Secret is now stored on the server; remove plaintext copy from localStorage.
|
||||||
|
localStorage.removeItem(totpSecretStorageKey);
|
||||||
|
setTotpLocked(true);
|
||||||
|
} catch {
|
||||||
|
// Keep inputs editable after a failed attempt.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecoveryCode(): Promise<void> {
|
||||||
|
const code = await props.onGetRecoveryCode(recoveryMasterPassword);
|
||||||
|
setRecoveryCode(code);
|
||||||
|
props.onNotify?.('success', t('txt_recovery_code_loaded'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string | null | undefined): string {
|
||||||
|
if (!value) return t('txt_dash');
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return value;
|
||||||
|
return parsed.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
<section className="card">
|
||||||
|
<h3>{t('txt_profile')}</h3>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_password_hint_optional')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
maxLength={120}
|
||||||
|
value={passwordHint}
|
||||||
|
placeholder={t('txt_password_hint_placeholder')}
|
||||||
|
onInput={(e) => setPasswordHint((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
<div className="field-help">{t('txt_password_hint_register_help')}</div>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => void props.onSavePasswordHint(passwordHint)}
|
||||||
|
>
|
||||||
|
{t('txt_save_profile')}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h3>{t('txt_change_master_password')}</h3>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_current_password')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onInput={(e) => setCurrentPassword((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="field-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_new_password')}</span>
|
||||||
|
<input className="input" type="password" value={newPassword} onInput={(e) => setNewPassword((e.currentTarget as HTMLInputElement).value)} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_confirm_password')}</span>
|
||||||
|
<input className="input" type="password" value={newPassword2} onInput={(e) => setNewPassword2((e.currentTarget as HTMLInputElement).value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={() => void props.onChangePassword(currentPassword, newPassword, newPassword2)}
|
||||||
|
>
|
||||||
|
<KeyRound size={14} className="btn-icon" />
|
||||||
|
{t('txt_change_password')}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<div className="settings-twofactor-grid">
|
||||||
|
<div className="settings-subcard">
|
||||||
|
<h3>{t('txt_totp')}</h3>
|
||||||
|
{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>
|
||||||
|
<input className="input" value={secret} disabled={totpLocked} onInput={(e) => setSecret((e.currentTarget as HTMLInputElement).value.toUpperCase())} />
|
||||||
|
</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-secondary" disabled={totpLocked} onClick={() => setSecret(randomBase32Secret(32))}>
|
||||||
|
<RefreshCw size={14} className="btn-icon" />
|
||||||
|
{t('txt_regenerate')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={totpLocked}
|
||||||
|
onClick={() => {
|
||||||
|
void copyTextToClipboard(secret, { successMessage: t('txt_secret_copied') });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Clipboard size={14} className="btn-icon" />
|
||||||
|
{t('txt_copy_secret')}
|
||||||
|
</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 className="settings-subcard">
|
||||||
|
<h3>{t('txt_recovery_code')}</h3>
|
||||||
|
<p className="muted-inline" style={{ marginBottom: 8 }}>
|
||||||
|
{t('txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically')}
|
||||||
|
</p>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_master_password')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={recoveryMasterPassword}
|
||||||
|
onInput={(e) => setRecoveryMasterPassword((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => void loadRecoveryCode()}>
|
||||||
|
<ShieldCheck size={14} className="btn-icon" />
|
||||||
|
{t('txt_view_recovery_code')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={!recoveryCode}
|
||||||
|
onClick={() => {
|
||||||
|
void copyTextToClipboard(recoveryCode, { successMessage: t('txt_recovery_code_copied') });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Clipboard size={14} className="btn-icon" />
|
||||||
|
{t('txt_copy_code')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{recoveryCode && (
|
||||||
|
<div className="card" style={{ marginTop: 10, marginBottom: 0 }}>
|
||||||
|
<div style={{ fontWeight: 800, letterSpacing: '0.08em' }}>{recoveryCode}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { ComponentChildren } from 'preact';
|
||||||
|
import { APP_VERSION } from '@shared/app-version';
|
||||||
|
|
||||||
|
interface StandalonePageFrameProps {
|
||||||
|
title: string;
|
||||||
|
children: ComponentChildren;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StandalonePageFrame(props: StandalonePageFrameProps) {
|
||||||
|
return (
|
||||||
|
<div className="standalone-shell">
|
||||||
|
<div className="standalone-brand standalone-brand-outside">
|
||||||
|
<img src="/logo-64.png" alt="NodeWarden logo" className="standalone-brand-logo" />
|
||||||
|
<div>
|
||||||
|
<div className="standalone-brand-title">NodeWarden</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="auth-card">
|
||||||
|
<h1 className="standalone-title">{props.title}</h1>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="standalone-footer">
|
||||||
|
<a href="https://github.com/shuaiplus/NodeWarden" target="_blank" rel="noreferrer">NodeWarden Repository</a>
|
||||||
|
<span> | </span>
|
||||||
|
<a href="https://github.com/shuaiplus" target="_blank" rel="noreferrer">Author: @shuaiplus</a>
|
||||||
|
<span> | </span>
|
||||||
|
<span className="standalone-version">v{APP_VERSION}</span>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { ToastMessage } from '@/lib/types';
|
||||||
|
|
||||||
|
interface ToastHostProps {
|
||||||
|
toasts: ToastMessage[];
|
||||||
|
onClose: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ToastHost({ toasts, onClose }: ToastHostProps) {
|
||||||
|
if (!toasts.length) return null;
|
||||||
|
return (
|
||||||
|
<ul className="toast-stack">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<li key={toast.id} className={`toast-item ${toast.type}`}>
|
||||||
|
<div className="toast-text">{toast.text}</div>
|
||||||
|
<button type="button" className="toast-close" onClick={() => onClose(toast.id)}>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
<div className="toast-progress" />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||