Compare commits
485 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b671450a8 | |||
| c0df6d1c16 | |||
| 35f9512d94 | |||
| 9e39161fc7 | |||
| 7c58282e42 | |||
| e0d81f2733 | |||
| 1d23b3fe5e | |||
| a0d4d7a1ff | |||
| 2f1b61e883 | |||
| 4e62c90700 | |||
| 7afb496eb0 | |||
| 5809e3eebc | |||
| 2e9bbe6801 | |||
| dc0eec7c54 | |||
| a0605299f0 | |||
| db68437a0b | |||
| 77d8411ea9 | |||
| 0c1ab3db48 | |||
| 6cc6e94b91 | |||
| 37ae493fa7 | |||
| 33f7c5d88a | |||
| c6c8979772 | |||
| a00279f47d | |||
| 669d7ef242 | |||
| 97d2117e15 | |||
| 429b747710 | |||
| a06853835d | |||
| c4ff063865 | |||
| 70b0a3a394 | |||
| e7c07fda4e | |||
| 0a001bebcc | |||
| 246c73a3d3 | |||
| 3d95c959f7 | |||
| e0737006c2 | |||
| 70dc9a76a9 | |||
| ba38b77387 | |||
| 1b4d263d6e | |||
| 97a3aa691d | |||
| 0ab7c44981 | |||
| 75a6a593dc | |||
| 45f0387526 | |||
| 851c9c4080 | |||
| a73f9a6d87 | |||
| 77a9faac88 | |||
| 0c00114cc8 | |||
| 9c5fbda374 | |||
| 85147e1569 | |||
| 29a846c562 | |||
| 3c5f43ecc2 | |||
| 68ded534a4 | |||
| 69b98f9e67 | |||
| 1b0386bf78 | |||
| aa6f9210b4 | |||
| 3be6a16d90 | |||
| fdb4cb91bf | |||
| 4b69f71ddb | |||
| 44020541e8 | |||
| 5869755c74 | |||
| 5b62d2142e | |||
| 575cf7ca79 | |||
| bfd347a52c | |||
| 7ab836d0f3 | |||
| d589b15123 | |||
| f48f3d0c8e | |||
| 2f7e66ee69 | |||
| 0cffbcd1f8 | |||
| 64b4da4035 | |||
| 3d2285e7af | |||
| 62f0aedc27 | |||
| 193e0ca189 | |||
| 4a63c077f5 | |||
| 15ee922777 | |||
| 2ea0b2c14c | |||
| 4ec1926888 | |||
| 3995e01336 | |||
| 481536ba24 | |||
| db8b9263a1 | |||
| a1f7250e90 | |||
| e4bc1b9bbe | |||
| 514889adfc | |||
| fccc85c4bb | |||
| acd59a7387 | |||
| d40b0514fd | |||
| 033d44808f | |||
| 4246e179f1 | |||
| fe8d9e0b7d | |||
| 1147c1e013 | |||
| 31ffd98166 | |||
| 7d7562d191 | |||
| d6e5a1c40b | |||
| 77794e43ce | |||
| b990f17a3e | |||
| 31b8ec6f7d | |||
| ef47597be5 | |||
| 408874ac05 | |||
| dabd2c923e | |||
| 08414d7cf2 | |||
| 38b33df719 | |||
| 7ebd12fa07 | |||
| f7cbdaf730 | |||
| 6cae5cb218 | |||
| d96ad9bb1c | |||
| 92d1f07998 | |||
| a8432ab94b | |||
| 2230f75d8a | |||
| a982a5a57b | |||
| 4d7ee2164a | |||
| 34d4851981 | |||
| 4827a4958e | |||
| 70463d3fc7 | |||
| 681705ee13 | |||
| 5bf7c79ada | |||
| c516194d54 | |||
| 53231a4878 | |||
| c9e7417825 | |||
| 76623d7201 | |||
| 90a7731351 | |||
| f4adeb8ec9 | |||
| bb0b82f838 | |||
| be82c953d6 | |||
| edd2ba2e44 | |||
| 0f6da7d147 | |||
| 1184cb8d9a | |||
| 882fa2e8c8 | |||
| b6b7e46f79 | |||
| 144d3d9406 | |||
| 10707cf902 | |||
| 3bd4f6a9fe | |||
| 3d4e95ef66 | |||
| 2a7879efaa | |||
| bd8e26d2ab | |||
| 783fcbbe4b | |||
| 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 | |||
| b33ee64c58 | |||
| c825280707 | |||
| c445714fd5 | |||
| f2a857d3f3 | |||
| 435a21072c | |||
| 70a58aeb04 | |||
| 866ffb8390 | |||
| d2ce2aea24 | |||
| 5fc2436552 |
@@ -1,3 +1,5 @@
|
||||
# JWT Secret for signing tokens (required)
|
||||
# IMPORTANT: change this value before any real deployment.
|
||||
# Generate one with: openssl rand -hex 32
|
||||
JWT_SECRET=your-secret-key-herexxs22fd2ds
|
||||
# (Example only, 64 hex chars = 32 bytes)
|
||||
JWT_SECRET=Enter-your-JWT-key-here-at-least-32-characters
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
name: "Bug Report"
|
||||
description: "Report a reproducible bug / 反馈可复现问题"
|
||||
title: "[Bug] "
|
||||
labels: ["bug", "needs-triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for reporting. Please provide enough detail so maintainers can reproduce quickly.
|
||||
感谢反馈,请尽量提供可复现信息,方便快速定位。
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Pre-check / 提交前确认
|
||||
options:
|
||||
- label: I have searched existing issues and did not find a duplicate. / 我已搜索现有 issue,确认不是重复问题。
|
||||
required: true
|
||||
- label: I have read README and Project Wiki / 我已阅读 README 与 项目 Wiki。
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version / 版本
|
||||
description: "Which version of NodeWarden are you using? Please provide the exact version or commit hash."
|
||||
placeholder: "1.0.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduce_steps
|
||||
attributes:
|
||||
label: Steps to Reproduce / 复现步骤
|
||||
placeholder: |
|
||||
1. Start service with ...
|
||||
2. Open ...
|
||||
3. Click ...
|
||||
4. Observe ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior / 预期行为
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Behavior / 实际行为
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs and Screenshots / 日志与截图
|
||||
description: "Please paste key logs (docker logs / browser console / network errors)."
|
||||
render: shell
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Additional Context / 补充信息
|
||||
description: "Any workaround, frequency, impact scope, etc."
|
||||
@@ -0,0 +1,12 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Project Wiki/ 项目文档
|
||||
url: https://github.com/shuaiplus/nodewarden/wiki
|
||||
about: |
|
||||
Please check the documentation for common questions and troubleshooting steps.
|
||||
请先查看文档,常见问题和排查步骤可能已经覆盖了你的问题。
|
||||
- name: Project Discussions / 讨论区
|
||||
url: https://github.com/shuaiplus/nodewarden/discussions
|
||||
about: |
|
||||
For general questions, feature discussions, or if you're not sure which template to use, please post in the Discussions section.
|
||||
如果你有一般性问题、功能讨论,或者不确定使用哪个模板,请在讨论区发帖。
|
||||
@@ -0,0 +1,62 @@
|
||||
name: "Feature Request"
|
||||
description: "Suggest an improvement / 功能建议"
|
||||
title: "[Feature] "
|
||||
labels: ["enhancement", "needs-triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Proposals with clear use-case and expected value are easier to evaluate.
|
||||
说明清晰的使用场景和价值,有助于快速评估。
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Pre-check / 提交前确认
|
||||
options:
|
||||
- label: I have searched existing issues and this request is not duplicated. / 我已搜索现有 issue,确认不是重复建议。
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem Statement / 现存问题
|
||||
description: "What is difficult or missing today?"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed Solution / 建议方案
|
||||
description: "Describe your expected behavior, UI flow, API changes, etc."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered / 备选方案
|
||||
description: "Any alternatives or workarounds you've considered."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: impact
|
||||
attributes:
|
||||
label: Expected Impact / 预期价值
|
||||
description: "Who benefits? Any performance/security/maintenance concerns?"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: scope
|
||||
attributes:
|
||||
label: Scope (Optional) / 影响范围(可选)
|
||||
placeholder: "frontend / backend / docs / deployment"
|
||||
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Additional Context / 补充信息
|
||||
description: "Mockups, references, related links, etc."
|
||||
@@ -0,0 +1,31 @@
|
||||
## Summary
|
||||
|
||||
<!-- What changed and why? -->
|
||||
|
||||
## Change Type
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] Feature
|
||||
- [ ] Compatibility update
|
||||
- [ ] Documentation
|
||||
- [ ] Refactor
|
||||
|
||||
## Cross-File Checklist
|
||||
|
||||
- [ ] I read `CONTRIBUTING.md`.
|
||||
- [ ] Schema changes, if any, updated both runtime schema and `migrations/0001_init.sql`.
|
||||
- [ ] Persistent data changes, if any, updated backup export/import or documented why backup is not needed.
|
||||
- [ ] User-facing text changes, if any, updated all locale files.
|
||||
- [ ] Bitwarden client compatibility was considered for sync/API shape changes.
|
||||
- [ ] No secrets, tokens, private deployment values, or real vault data are included.
|
||||
|
||||
## Checks
|
||||
|
||||
- [ ] `npx tsc -p tsconfig.json --noEmit`
|
||||
- [ ] `npx tsc -p webapp/tsconfig.json --noEmit`
|
||||
- [ ] `npm run i18n:validate`
|
||||
- [ ] `npm run build`
|
||||
|
||||
## Notes
|
||||
|
||||
<!-- Anything reviewers should pay special attention to? -->
|
||||
@@ -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: '💡 *由 NodeWarden 安全工作流生成。透明度是我们的承诺。*',
|
||||
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 the NodeWarden security workflow. 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,51 @@
|
||||
name: Sync Bitwarden global domains
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 4 * * 1"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bitwarden_ref:
|
||||
description: "bitwarden/server ref to sync"
|
||||
required: false
|
||||
default: "main"
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
sync-global-domains:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Sync generated Bitwarden domains
|
||||
run: npm run domains:sync -- --ref "${{ inputs.bitwarden_ref || 'main' }}"
|
||||
|
||||
- name: Verify custom domains were not touched
|
||||
run: git diff --exit-code -- src/static/global_domains.custom.json
|
||||
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
branch: chore/sync-bitwarden-global-domains
|
||||
delete-branch: true
|
||||
title: "chore: sync Bitwarden global domain rules"
|
||||
commit-message: "chore: sync Bitwarden global domain rules"
|
||||
body: |
|
||||
Automated sync from bitwarden/server.
|
||||
|
||||
This PR only updates:
|
||||
- `src/static/global_domains.bitwarden.json`
|
||||
- `src/static/global_domains.bitwarden.meta.json`
|
||||
|
||||
`src/static/global_domains.custom.json` is intentionally left untouched.
|
||||
add-paths: |
|
||||
src/static/global_domains.bitwarden.json
|
||||
src/static/global_domains.bitwarden.meta.json
|
||||
@@ -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
|
||||
wrangler.my.toml
|
||||
RELEASE_NOTES.md
|
||||
tests/selfcheck.ts
|
||||
problem.md
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
@@ -35,3 +37,13 @@ npm-debug.log*
|
||||
|
||||
# Package lock (optional - remove if you want to commit it)
|
||||
# package-lock.json
|
||||
|
||||
tmp/
|
||||
.tmp/
|
||||
|
||||
nodewarden.wiki/
|
||||
wiki/
|
||||
AGENTS.md
|
||||
settings.json
|
||||
.claude/
|
||||
NodeWarden-compat/
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# Contributing to NodeWarden
|
||||
|
||||
Thanks for taking the time to improve NodeWarden.
|
||||
|
||||
NodeWarden is a Bitwarden-compatible server with a custom web vault, Cloudflare
|
||||
Workers/D1 storage, attachment storage, imports/exports, and scheduled backups.
|
||||
Small changes can affect official clients, backups, migrations, or locale files,
|
||||
so please keep changes focused and check the related parts of the project.
|
||||
|
||||
## Before Opening an Issue
|
||||
|
||||
For bug reports, include enough detail for someone else to reproduce the problem:
|
||||
|
||||
- The client or browser you used.
|
||||
- The page, API route, or action that failed.
|
||||
- Screenshots, logs, or the exact error message.
|
||||
- Whether the problem happened after sync, import, export, restore, upgrade, or
|
||||
a fresh deployment.
|
||||
|
||||
Please do not report NodeWarden-specific problems to the official Bitwarden
|
||||
team. This project is independent from Bitwarden.
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
Keep pull requests small enough to review. A good PR should explain:
|
||||
|
||||
- What changed and why.
|
||||
- What user-facing behavior changed.
|
||||
- Which related areas were checked.
|
||||
- Which commands were run before submitting.
|
||||
|
||||
Avoid mixing unrelated refactors with feature or bug-fix work. If a cleanup is
|
||||
needed before the real fix, mention that clearly in the PR.
|
||||
|
||||
## Areas That Need Extra Care
|
||||
|
||||
Some parts of the codebase are deliberately connected. When changing one of
|
||||
these areas, check the related files before calling the work complete.
|
||||
|
||||
### Database Changes
|
||||
|
||||
Runtime schema lives in `src/services/storage-schema.ts`. The initial D1 schema
|
||||
lives in `migrations/0001_init.sql`.
|
||||
|
||||
If you add or change a table, column, or index:
|
||||
|
||||
- Update both schema files.
|
||||
- Bump `STORAGE_SCHEMA_VERSION` in `src/services/storage.ts`.
|
||||
- Decide whether the data should be included in instance backup.
|
||||
|
||||
### Backup And Restore
|
||||
|
||||
Backup export and restore are whitelist-based. This protects old backups from
|
||||
breaking when fields are removed and prevents transient or secret runtime data
|
||||
from being exported by accident.
|
||||
|
||||
When adding persistent data, check:
|
||||
|
||||
- `src/services/backup-archive.ts`
|
||||
- `src/services/backup-import.ts`
|
||||
- `webapp/src/lib/api/backup.ts`
|
||||
|
||||
Do not export runtime lock rows such as `backup.runner.lock.v1`. Do not import
|
||||
retired sensitive fields such as `users.api_key`.
|
||||
|
||||
### Secrets And Provider Settings
|
||||
|
||||
Provider credentials must not be stored or exported as plain config JSON. Follow
|
||||
the encrypted settings pattern in `src/services/backup-settings-crypto.ts`, or
|
||||
document a replacement design before changing it.
|
||||
|
||||
### Bitwarden Client Compatibility
|
||||
|
||||
Official Bitwarden clients may send or expect fields that are not used directly
|
||||
by the web vault. Cipher and sync changes should preserve unknown client fields
|
||||
unless they are known-invalid or server-owned.
|
||||
|
||||
Check these files when changing vault item shape or sync behavior:
|
||||
|
||||
- `src/handlers/ciphers.ts`
|
||||
- `src/handlers/sync.ts`
|
||||
- `src/services/storage-cipher-repo.ts`
|
||||
|
||||
### Domain Rules
|
||||
|
||||
Equivalent-domain settings store both client/UI rule state and derived active
|
||||
groups. Do not remove `equivalent_domains`, `custom_equivalent_domains`, or
|
||||
`excluded_global_equivalent_domains` as duplicates without a migration and
|
||||
compatibility plan.
|
||||
|
||||
### Accounts And Passwords
|
||||
|
||||
`users.master_password_hash` is for server-side login verification. It is not the
|
||||
vault decryption key. Password changes, key material, `securityStamp`, and
|
||||
refresh-token revocation must stay aligned.
|
||||
|
||||
Password hints are reminders, not recovery secrets. They must never contain the
|
||||
master password, recovery codes, API keys, or anything that directly unlocks the
|
||||
vault.
|
||||
|
||||
### i18n
|
||||
|
||||
Locale files are complete standalone bundles. When adding or changing user-facing
|
||||
text, keep every locale in sync and run the validation script.
|
||||
|
||||
For new locales, update:
|
||||
|
||||
- `webapp/src/lib/i18n.ts`
|
||||
- `webapp/src/lib/i18n/locales/*`
|
||||
- `scripts/i18n-utils.cjs`
|
||||
|
||||
## Recommended Checks
|
||||
|
||||
For most backend or shared changes:
|
||||
|
||||
```sh
|
||||
npx tsc -p tsconfig.json --noEmit
|
||||
npm run build
|
||||
```
|
||||
|
||||
For webapp text or locale changes:
|
||||
|
||||
```sh
|
||||
npm run i18n:validate
|
||||
npx tsc -p webapp/tsconfig.json --noEmit
|
||||
npm run build
|
||||
```
|
||||
|
||||
For documentation-only changes:
|
||||
|
||||
```sh
|
||||
git diff --check
|
||||
```
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,19 @@
|
||||
<svg width="960" height="180" viewBox="0 0 1240 220" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(0 30) scale(0.276)">
|
||||
<path d="M370.5 93C481.785 93 572 181.2 572 290C572 329.877 559.879 366.986 539.046 398H1.68164C0.576599 391.834 0 385.484 0 379C0 323.617 42.0774 278.061 96.0078 272.558C92.7712 263.989 91 254.701 91 245C91 201.922 125.922 167 169 167C182.365 167 194.945 170.362 205.94 176.286C242.437 125.895 302.539 93 370.5 93Z" fill="#F6821F"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M76.6568 1.00686C72.7796 172.923 85.5495 291.119 127.869 379.459C170.188 467.799 242.092 526.353 356.665 578.892C469.877 526.354 540.929 467.802 582.746 379.461C624.564 291.12 637.181 172.923 633.35 1.00686H76.6568ZM523.796 342.933C554.479 275.533 565.347 188.379 566.419 63.9394L566.422 63.432H361.661V503.786L362.405 503.364C442.602 457.962 493.101 410.36 523.796 342.933Z" fill="#116FF9"/>
|
||||
<path d="M588.465 215C664.976 215 727 277.233 727 354C727 369.378 724.509 384.172 719.913 398H363V333.553C375.721 307.751 402.287 290 433 290C443.483 290 453.482 292.068 462.613 295.818C484.559 248.11 532.658 215 588.465 215Z" fill="#FD9C33"/>
|
||||
</g>
|
||||
<g transform="translate(225 50) scale(0.112)" fill="#116FF9">
|
||||
<path d="M238.439 995.188H0V209.944C0 111.788 76.3004 53.1675 156.688 53.1675C220.726 53.1675 276.589 74.9799 309.289 126.784L633.566 640.737V74.9799H872.005V860.224C872.005 958.379 795.704 1015.64 715.317 1015.64C652.641 1015.64 595.416 993.824 562.716 942.02L238.439 428.067V995.188Z"/>
|
||||
<path d="M1389.81 1015.64C1177.26 1015.64 1015.12 852.044 1015.12 653.007C1015.12 455.332 1177.26 291.74 1389.81 291.74C1602.36 291.74 1764.5 455.332 1764.5 653.007C1764.5 852.044 1602.36 1015.64 1389.81 1015.64ZM1389.81 785.244C1467.47 785.244 1519.25 725.26 1519.25 654.37C1519.25 582.117 1467.47 522.133 1389.81 522.133C1312.15 522.133 1260.37 582.117 1260.37 654.37C1260.37 725.26 1312.15 785.244 1389.81 785.244Z"/>
|
||||
<path d="M2221.42 1015.64C2008.87 1015.64 1846.73 853.407 1846.73 655.733C1846.73 437.61 1991.16 293.103 2207.79 293.103C2258.21 293.103 2308.62 308.099 2350.86 331.275V0H2596.11V655.733C2596.11 864.314 2439.42 1015.64 2221.42 1015.64ZM2221.42 785.244C2299.08 785.244 2350.86 726.623 2350.86 654.37C2350.86 583.48 2299.08 523.496 2221.42 523.496C2143.76 523.496 2091.98 583.48 2091.98 654.37C2091.98 726.623 2143.76 785.244 2221.42 785.244Z"/>
|
||||
<path d="M3086.45 1014.27C2868.45 1014.27 2704.95 869.767 2704.95 646.19C2704.95 449.879 2852.1 286.287 3067.38 286.287C3290.83 286.287 3414.82 452.606 3414.82 635.284V696.631H2940.66C2957.01 764.795 3008.79 805.693 3083.73 805.693C3149.13 805.693 3200.9 770.248 3225.43 717.08L3413.45 811.146C3354.87 937.93 3239.05 1014.27 3086.45 1014.27ZM2951.56 569.847H3170.93C3160.03 531.676 3121.88 496.231 3064.65 496.231C3006.06 496.231 2966.55 530.312 2951.56 569.847Z"/>
|
||||
<path d="M3604.95 845.228L3441.45 74.9799H3693.51L3812.05 704.811L3915.6 246.752C3945.58 111.788 4009.62 54.5308 4107.72 54.5308C4205.82 54.5308 4269.85 111.788 4299.83 246.752L4403.38 704.811L4521.92 74.9799H4773.98L4610.48 845.228C4587.32 955.653 4513.74 1017 4414.28 1017C4324.35 1017 4243.97 957.016 4220.8 856.134L4107.72 358.54L3994.63 856.134C3971.46 957.016 3891.08 1017 3801.15 1017C3701.69 1017 3628.11 955.653 3604.95 845.228Z"/>
|
||||
<path d="M5121.11 1015.64C4922.19 1015.64 4787.3 852.044 4787.3 653.007C4787.3 455.332 4949.44 291.74 5161.99 291.74C5379.99 291.74 5536.68 444.426 5536.68 653.007V995.188H5305.05V944.747C5261.45 989.735 5200.14 1015.64 5121.11 1015.64ZM5161.99 785.244C5239.65 785.244 5291.43 725.26 5291.43 654.37C5291.43 582.117 5239.65 522.133 5161.99 522.133C5084.33 522.133 5032.55 582.117 5032.55 654.37C5032.55 725.26 5084.33 785.244 5161.99 785.244Z"/>
|
||||
<path d="M5918.02 995.188H5672.77V617.562C5672.77 436.247 5776.32 291.74 5998.41 291.74C6044.73 291.74 6095.15 299.92 6129.21 314.916V550.761C6096.51 533.039 6055.63 523.496 6021.57 523.496C5957.53 523.496 5918.02 560.304 5918.02 625.741V995.188Z"/>
|
||||
<path d="M6565.74 1015.64C6353.19 1015.64 6191.05 853.407 6191.05 655.733C6191.05 437.61 6335.48 293.103 6552.12 293.103C6602.53 293.103 6652.94 308.099 6695.18 331.275V0H6940.43V655.733C6940.43 864.314 6783.74 1015.64 6565.74 1015.64ZM6565.74 785.244C6643.41 785.244 6695.18 726.623 6695.18 654.37C6695.18 583.48 6643.41 523.496 6565.74 523.496C6488.08 523.496 6436.31 583.48 6436.31 654.37C6436.31 726.623 6488.08 785.244 6565.74 785.244Z"/>
|
||||
<path d="M7430.78 1014.27C7212.77 1014.27 7049.27 869.767 7049.27 646.19C7049.27 449.879 7196.42 286.287 7411.7 286.287C7635.15 286.287 7759.14 452.606 7759.14 635.284V696.631H7284.99C7301.34 764.795 7353.11 805.693 7428.05 805.693C7493.45 805.693 7545.23 770.248 7569.75 717.08L7757.78 811.146C7699.19 937.93 7583.38 1014.27 7430.78 1014.27ZM7295.89 569.847H7515.25C7504.35 531.676 7466.2 496.231 7408.98 496.231C7350.39 496.231 7310.88 530.312 7295.89 569.847Z"/>
|
||||
<path d="M8250.76 531.676C8160.84 531.676 8126.77 603.929 8126.77 689.815V995.188H7881.52V659.823C7881.52 459.422 7998.7 293.103 8250.76 293.103C8502.82 293.103 8620 459.422 8620 659.823V995.188H8374.75V689.815C8374.75 603.929 8340.69 531.676 8250.76 531.676Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
@@ -1,108 +1,159 @@
|
||||
# NodeWarden
|
||||
中文文档:[`README_ZH.md`](./README_ZH.md)
|
||||
<p align="center">
|
||||
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
|
||||
</p>
|
||||
|
||||
A **Bitwarden-compatible** server that runs on **Cloudflare Workers**, designed for personal use.
|
||||
<p align="center">
|
||||
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
|
||||
</p>
|
||||
|
||||
- Simple deploy (no VPS)
|
||||
- Focused feature set
|
||||
- Low maintenance
|
||||
<p align="center">
|
||||
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
|
||||
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
|
||||
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
|
||||
<a href="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml"><img src="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg" alt="Sync Upstream" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
|
||||
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a>
|
||||
</p>
|
||||
|
||||
> Disclaimer
|
||||
> - This project is **not affiliated** with Bitwarden.
|
||||
> - Use at your own risk. Keep regular backups of your vault.
|
||||
<p align="center">
|
||||
<a href="./README_EN.md">English</a> |
|
||||
<a href="./CONTRIBUTING.md">贡献指南</a>
|
||||
</p>
|
||||
|
||||
> **免责声明**
|
||||
> 本项目仅供学习与交流使用,请定期备份你的密码库。
|
||||
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
## 与 Bitwarden 官方服务端能力对比
|
||||
|
||||
- ✅ **Free to use. No server to manage.**
|
||||
- ✅ Full support for logins, notes, cards, and identities
|
||||
- ✅ Folders and favorites
|
||||
- ✅ Attachments (Cloudflare R2)
|
||||
- ✅ Import / export
|
||||
- ✅ Website icons
|
||||
- ✅ End-to-end encryption (the server can’t see plaintext)
|
||||
- ✅ Compatible with common Bitwarden official clients
|
||||
|
||||
## Tested clients / platforms
|
||||
|
||||
- ✅ Windows desktop client(v2026.1.0)
|
||||
- ✅ Android app (v2026.1.0)
|
||||
- ✅ Browser extension(v2026.1.0)
|
||||
- ⬜ macOS desktop client (not tested)
|
||||
- ⬜ Linux desktop client (not tested)
|
||||
| 能力 | Bitwarden | NodeWarden | 说明 |
|
||||
|---|---|---|---|
|
||||
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
|
||||
| 全量同步 `/api/sync` | ✅ | ✅ | 已针对官方客户端做兼容优化 |
|
||||
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
|
||||
| Send | ✅ | ✅ | 支持文本与文件 Send |
|
||||
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
|
||||
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / E3 定时备份** |
|
||||
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
|
||||
| TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 |
|
||||
| 多用户 | ✅ | ✅ | 支持邀请码注册 |
|
||||
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
|
||||
| 登录 2FA | ✅ | ⚠️ 部分支持 | 当前仅支持用户级 TOTP |
|
||||
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
|
||||
|
||||
---
|
||||
|
||||
# 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
|
||||
2. Sign in to Cloudflare
|
||||
3. **Important**: set `JWT_SECRET` to a strong random string (recommended: `openssl rand -hex 32`)
|
||||
4. KV namespace and R2 bucket will be created automatically
|
||||
5. Click **Deploy** and wait for it to finish
|
||||
6. After deploy, open the Cloudflare-provided Workers URL (your service URL), and register on the web page
|
||||
1. Fork `NodeWarden` 仓库到自己的 GitHub 账号
|
||||
2. 进入 [Cloudflare Workers 创建页面](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
||||
3. 选择 `Continue with GitHub`
|
||||
4. 选择你刚刚 Fork 的仓库
|
||||
5. 保持默认配置继续部署
|
||||
6. 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
|
||||
7. 等部署完成后,打开生成的 Workers 域名
|
||||
8. 根据页面提示设置`JWT_SECRET` ,不建议临时乱填。这个值直接关系到令牌签发安全,正式环境至少使用 32 个字符以上的随机字符串。
|
||||
|
||||
> ⚠️ **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
|
||||
npx wrangler login
|
||||
|
||||
# 默认:R2 模式
|
||||
npm run deploy
|
||||
|
||||
# 可选:KV 模式
|
||||
npm run deploy:kv
|
||||
|
||||
# 本地开发
|
||||
npm run dev
|
||||
npm run dev:kv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tech stack
|
||||
## 云端备份说明
|
||||
|
||||
- **Runtime**: Cloudflare Workers
|
||||
- **Data storage**: Cloudflare KV
|
||||
- **File storage**: Cloudflare R2
|
||||
- **Language**: TypeScript
|
||||
- **Crypto**: Client-side AES-256-CBC, JWT uses HS256
|
||||
- 远程备份支持 **WebDAV** 与 **E3**
|
||||
- 勾选“包含附件”后:
|
||||
- ZIP 内仍只包含 `db.json` 与 `manifest.json`
|
||||
- 真实附件单独存放在 `attachments/`
|
||||
- 后续备份会按稳定 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?**
|
||||
A: It can’t be recovered (end-to-end encryption). Keep it safe.
|
||||
- Bitwarden JSON
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
## Credits
|
||||
## 致谢
|
||||
|
||||
- [Bitwarden](https://bitwarden.com/) - original design and clients
|
||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - server implementation reference
|
||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - serverless platform
|
||||
- [Bitwarden](https://bitwarden.com/) - 原始设计与客户端
|
||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务端实现参考
|
||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
<p align="center">
|
||||
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Bitwarden-compatible server running on Cloudflare Workers
|
||||
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
|
||||
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
|
||||
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
|
||||
<a href="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml"><img src="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg" alt="Sync Upstream" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
|
||||
<a href="https://t.me/NodeWarden_Official">Telegram Group</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.md">中文说明</a> |
|
||||
<a href="./CONTRIBUTING.md">Contributing</a>
|
||||
</p>
|
||||
|
||||
> **Disclaimer**
|
||||
>
|
||||
> This project is for learning and discussion purposes only. Please back up your vault regularly.
|
||||
>
|
||||
> This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team.
|
||||
|
||||
---
|
||||
|
||||
## Feature Comparison with the Official Bitwarden Server
|
||||
|
||||
| Capability | Bitwarden | NodeWarden | Notes |
|
||||
|---|---|---|---|
|
||||
| Web Vault | ✅ | ✅ | **Original Web Vault interface** |
|
||||
| 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. KV 存储和 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 KV
|
||||
- **文件存储**: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/) - 无服务器平台
|
||||
@@ -0,0 +1,208 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- IMPORTANT:
|
||||
-- This is the initial D1 schema. Keep it in sync with
|
||||
-- src/services/storage-schema.ts (SCHEMA_STATEMENTS).
|
||||
-- Any new table/column/index must be added to both places together.
|
||||
--
|
||||
-- WHEN CHANGING THIS:
|
||||
-- - Also bump STORAGE_SCHEMA_VERSION in src/services/storage.ts.
|
||||
-- - If the new table stores persistent data, update backup export/import.
|
||||
-- - Keep src/services/storage-schema.ts idempotent for existing installs.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
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,
|
||||
api_key TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS domain_settings (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
equivalent_domains TEXT NOT NULL DEFAULT '[]',
|
||||
custom_equivalent_domains TEXT NOT NULL DEFAULT '[]',
|
||||
excluded_global_equivalent_domains TEXT NOT NULL DEFAULT '[]',
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Per-user sync revision date
|
||||
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
|
||||
);
|
||||
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 INDEX IF NOT EXISTS idx_ciphers_user_folder ON ciphers(user_id, folder_id);
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
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,
|
||||
device_note TEXT,
|
||||
last_seen_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);
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
|
||||
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
|
||||
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
|
||||
);
|
||||
@@ -1,15 +1,23 @@
|
||||
{
|
||||
"name": "nodewarden",
|
||||
"version": "0.1.0",
|
||||
"version": "1.5.2",
|
||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||
"author": "shuaiplus",
|
||||
"license": "LGPL-3.0",
|
||||
"main": "src/index.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev -c wrangler.dev.toml",
|
||||
"deploymy": "wrangler deploy -c wrangler.my.toml",
|
||||
"deploy": "wrangler deploy "
|
||||
"dev": "npm run build && wrangler dev -c wrangler.toml",
|
||||
"dev:kv": "npm run build && wrangler dev -c wrangler.kv.toml",
|
||||
"dev:demo": "vite --config webapp/vite.config.ts --mode demo --host 127.0.0.1 --port 5174",
|
||||
"build": "vite build --config webapp/vite.config.ts",
|
||||
"build:demo": "vite build --config webapp/vite.config.ts --mode demo && node scripts/pages-spa-redirects.cjs",
|
||||
"domains:sync": "node scripts/sync-global-domains.mjs",
|
||||
"i18n": "node scripts/i18n-validate.cjs",
|
||||
"i18n:validate": "node scripts/i18n-validate.cjs",
|
||||
"deploy": "npm run build && wrangler deploy",
|
||||
"deploy:kv": "npm run build && wrangler deploy -c wrangler.kv.toml",
|
||||
"deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo"
|
||||
},
|
||||
"keywords": [
|
||||
"bitwarden",
|
||||
@@ -21,20 +29,40 @@
|
||||
"cloudflare": {
|
||||
"bindings": {
|
||||
"JWT_SECRET": {
|
||||
"description": "用于签名 JWT 的密钥。请输入一个随机的复杂字符串(建议 32 位以上)"
|
||||
"description": "Use a strong random string (32+ characters recommended)"
|
||||
},
|
||||
"VAULT": {
|
||||
"description": "用于存储密码库数据的 KV 存储"
|
||||
"DB": {
|
||||
"description": "D1 database for storing vault data"
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
"description": "用于存储文件附件的 R2 存储桶"
|
||||
"description": "R2 bucket for storing file attachments"
|
||||
},
|
||||
"ATTACHMENTS_KV": {
|
||||
"description": "Optional KV namespace fallback for attachment/send-file storage"
|
||||
}
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260131.0",
|
||||
"@preact/preset-vite": "^2.10.3",
|
||||
"@types/node": "^25.2.3",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"opencc-js": "^1.0.5",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"wrangler": "^4.61.1"
|
||||
"vite": "^7.3.1",
|
||||
"wrangler": "^4.71.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
// CONTRACT:
|
||||
// This list is the script-side locale source of truth. Keep it in sync with
|
||||
// webapp/src/lib/i18n.ts whenever adding/removing a locale.
|
||||
const localeDir = path.join(__dirname, '..', 'webapp', 'src', 'lib', 'i18n', 'locales');
|
||||
|
||||
const localeFiles = [
|
||||
['en', 'en.ts', 'en', 'English'],
|
||||
['zh-CN', 'zh-CN.ts', 'zhCN', 'Simplified Chinese'],
|
||||
['zh-TW', 'zh-TW.ts', 'zhTW', 'Traditional Chinese'],
|
||||
['ru', 'ru.ts', 'ru', 'Russian'],
|
||||
['es', 'es.ts', 'es', 'Spanish'],
|
||||
];
|
||||
|
||||
function readLocale(fileName, variableName) {
|
||||
let code = fs.readFileSync(path.join(localeDir, fileName), 'utf8');
|
||||
code = code
|
||||
.replace(/const (\w+): Record<string, string> =/g, 'const $1 =')
|
||||
.replace(/export default \w+;\s*$/m, '');
|
||||
code += `\nresult = ${variableName};`;
|
||||
const sandbox = { result: null };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(code, sandbox, { filename: fileName });
|
||||
return sandbox.result;
|
||||
}
|
||||
|
||||
function writeLocale(fileName, variableName, table, header) {
|
||||
const body = JSON.stringify(table, null, 2);
|
||||
fs.writeFileSync(
|
||||
path.join(localeDir, fileName),
|
||||
`${header}\nconst ${variableName}: Record<string, string> = ${body};\n\nexport default ${variableName};\n`,
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
localeFiles,
|
||||
localeDir,
|
||||
readLocale,
|
||||
writeLocale,
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
const { localeFiles, readLocale } = require('./i18n-utils.cjs');
|
||||
|
||||
// CONTRACT:
|
||||
// This is the authoritative locale consistency gate. It checks key parity,
|
||||
// placeholder parity, and accidental mostly-English locale files. Run after any
|
||||
// user-facing text or locale-file change.
|
||||
const locales = Object.fromEntries(
|
||||
localeFiles.map(([locale, fileName, variableName]) => [locale, readLocale(fileName, variableName)])
|
||||
);
|
||||
const base = locales.en;
|
||||
const baseKeys = Object.keys(base).sort();
|
||||
const placeholderRe = /\{\w+\}/g;
|
||||
const errors = [];
|
||||
const intentionallyEnglishKeys = new Set([
|
||||
'txt_backup_destination_detail_note',
|
||||
'txt_backup_protocol_webdav',
|
||||
'txt_backup_protocol_s3',
|
||||
'txt_backup_recommend_group_webdav',
|
||||
'txt_backup_recommend_group_s3',
|
||||
'txt_backup_destination_name_default_webdav',
|
||||
'txt_backup_destination_name_default_s3',
|
||||
'txt_dash',
|
||||
'txt_text_3',
|
||||
]);
|
||||
|
||||
for (const [locale, table] of Object.entries(locales)) {
|
||||
const keys = Object.keys(table).sort();
|
||||
const missing = baseKeys.filter((key) => !(key in table));
|
||||
const extra = keys.filter((key) => !baseKeys.includes(key));
|
||||
if (missing.length || extra.length) {
|
||||
errors.push({ locale, missing, extra });
|
||||
}
|
||||
|
||||
for (const key of baseKeys) {
|
||||
const basePlaceholders = Array.from(String(base[key]).matchAll(placeholderRe), (match) => match[0]).sort().join('|');
|
||||
const localePlaceholders = Array.from(String(table[key]).matchAll(placeholderRe), (match) => match[0]).sort().join('|');
|
||||
if (basePlaceholders !== localePlaceholders) {
|
||||
errors.push({ locale, key, basePlaceholders, localePlaceholders });
|
||||
}
|
||||
}
|
||||
|
||||
if (locale !== 'en') {
|
||||
const sameAsEnglish = baseKeys.filter((key) => table[key] === base[key] && !intentionallyEnglishKeys.has(key));
|
||||
if (sameAsEnglish.length > 40) {
|
||||
errors.push({
|
||||
locale,
|
||||
sameAsEnglishCount: sameAsEnglish.length,
|
||||
sameAsEnglishSample: sameAsEnglish.slice(0, 25),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
counts: Object.fromEntries(Object.entries(locales).map(([locale, table]) => [locale, Object.keys(table).length])),
|
||||
errors,
|
||||
}, null, 2));
|
||||
|
||||
if (errors.length) {
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const distDir = path.resolve(__dirname, '..', 'dist');
|
||||
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(distDir, '_redirects'), '/* /index.html 200\n');
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const DEFAULT_REF = 'main';
|
||||
const OUTPUT_DIR = path.join(process.cwd(), 'src', 'static');
|
||||
const OUT_FILE = path.join(OUTPUT_DIR, 'global_domains.bitwarden.json');
|
||||
const META_FILE = path.join(OUTPUT_DIR, 'global_domains.bitwarden.meta.json');
|
||||
const ENUM_PATH = 'src/Core/Enums/GlobalEquivalentDomainsType.cs';
|
||||
const STATIC_STORE_PATH = 'src/Core/Utilities/StaticStore.cs';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { ref: process.env.BITWARDEN_SERVER_REF || DEFAULT_REF };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--ref' && argv[i + 1]) {
|
||||
args.ref = argv[i + 1];
|
||||
i += 1;
|
||||
} else if (arg.startsWith('--ref=')) {
|
||||
args.ref = arg.slice('--ref='.length);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function rawUrl(ref, filePath) {
|
||||
return `https://raw.githubusercontent.com/bitwarden/server/${encodeURIComponent(ref)}/${filePath}`;
|
||||
}
|
||||
|
||||
async function fetchText(url) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'NodeWarden global domains sync',
|
||||
Accept: 'text/plain',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
function parseEnumTypes(source) {
|
||||
const map = new Map();
|
||||
const enumMatch = source.match(/enum\s+GlobalEquivalentDomainsType\b[\s\S]*?\{([\s\S]*?)\}/);
|
||||
if (!enumMatch) {
|
||||
throw new Error('GlobalEquivalentDomainsType enum was not found');
|
||||
}
|
||||
|
||||
const body = enumMatch[1].replace(/\/\/.*$/gm, '');
|
||||
const entryRe = /\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(\d+)\b/g;
|
||||
let match;
|
||||
while ((match = entryRe.exec(body)) !== null) {
|
||||
map.set(match[1], Number(match[2]));
|
||||
}
|
||||
|
||||
if (!map.size) {
|
||||
throw new Error('No enum values were parsed from GlobalEquivalentDomainsType');
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function parseStringList(source) {
|
||||
const domains = [];
|
||||
const stringRe = /"((?:\\.|[^"\\])*)"/g;
|
||||
let match;
|
||||
while ((match = stringRe.exec(source)) !== null) {
|
||||
domains.push(match[1].replace(/\\"/g, '"').trim().toLowerCase());
|
||||
}
|
||||
return Array.from(new Set(domains.filter(Boolean)));
|
||||
}
|
||||
|
||||
function parseGlobalDomains(source, enumTypes) {
|
||||
const out = [];
|
||||
const addRe = /GlobalDomains\.Add\s*\(\s*GlobalEquivalentDomainsType\.([A-Za-z_][A-Za-z0-9_]*)\s*,\s*new\s+List(?:<\s*string\s*>)?\s*\{([\s\S]*?)\}\s*\)\s*;/g;
|
||||
let match;
|
||||
while ((match = addRe.exec(source)) !== null) {
|
||||
const name = match[1];
|
||||
const type = enumTypes.get(name);
|
||||
if (!Number.isInteger(type)) {
|
||||
throw new Error(`GlobalDomains references unknown enum value ${name}`);
|
||||
}
|
||||
|
||||
const domains = parseStringList(match[2]);
|
||||
if (domains.length < 2) {
|
||||
throw new Error(`GlobalDomains.${name} has fewer than two domains`);
|
||||
}
|
||||
|
||||
out.push({
|
||||
type,
|
||||
domains,
|
||||
excluded: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (!out.length) {
|
||||
throw new Error('No GlobalDomains.Add(...) rules were parsed from StaticStore.cs');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatRulesJson(rules) {
|
||||
return `[\n${rules.map((rule) => ` ${JSON.stringify(rule)}`).join(',\n')}\n]`;
|
||||
}
|
||||
|
||||
function formatMetaJson(meta) {
|
||||
return JSON.stringify(meta, null, 2);
|
||||
}
|
||||
|
||||
const { ref } = parseArgs(process.argv.slice(2));
|
||||
const enumUrl = rawUrl(ref, ENUM_PATH);
|
||||
const staticStoreUrl = rawUrl(ref, STATIC_STORE_PATH);
|
||||
|
||||
const [enumSource, staticStoreSource] = await Promise.all([
|
||||
fetchText(enumUrl),
|
||||
fetchText(staticStoreUrl),
|
||||
]);
|
||||
|
||||
const enumTypes = parseEnumTypes(enumSource);
|
||||
const rules = parseGlobalDomains(staticStoreSource, enumTypes);
|
||||
const domainsCount = rules.reduce((sum, rule) => sum + rule.domains.length, 0);
|
||||
const rulesJson = formatRulesJson(rules);
|
||||
|
||||
async function readJsonFile(filePath) {
|
||||
try {
|
||||
return JSON.parse(await readFile(filePath, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const existingRules = await readJsonFile(OUT_FILE);
|
||||
const existingMeta = await readJsonFile(META_FILE);
|
||||
const unchangedRules = JSON.stringify(existingRules) === JSON.stringify(rules);
|
||||
const unchangedRef = existingMeta?.ref === ref;
|
||||
|
||||
const meta = {
|
||||
source: 'https://github.com/bitwarden/server',
|
||||
ref,
|
||||
generatedAt: unchangedRules && unchangedRef && existingMeta?.generatedAt
|
||||
? existingMeta.generatedAt
|
||||
: new Date().toISOString(),
|
||||
rulesCount: rules.length,
|
||||
domainsCount,
|
||||
sourceFiles: [
|
||||
ENUM_PATH,
|
||||
STATIC_STORE_PATH,
|
||||
],
|
||||
sourceUrls: [
|
||||
enumUrl,
|
||||
staticStoreUrl,
|
||||
],
|
||||
};
|
||||
|
||||
await mkdir(OUTPUT_DIR, { recursive: true });
|
||||
await writeFile(OUT_FILE, `${rulesJson}\n`, 'utf8');
|
||||
await writeFile(META_FILE, `${formatMetaJson(meta)}\n`, 'utf8');
|
||||
|
||||
console.log(`Wrote ${rules.length} global domain rules (${domainsCount} domains) from bitwarden/server@${ref}.`);
|
||||
@@ -0,0 +1 @@
|
||||
export const APP_VERSION = '1.5.2';
|
||||
@@ -0,0 +1,159 @@
|
||||
// Shared backup settings types used by both Worker and webapp code.
|
||||
//
|
||||
// CONTRACT:
|
||||
// Keep this file serializable and provider-neutral. Runtime state is operational
|
||||
// metadata; destination fields can contain provider credentials and must be
|
||||
// encrypted by src/services/backup-settings-crypto.ts before storage/export.
|
||||
// User-facing provider names should use canonical values here. Legacy aliases
|
||||
// belong in backend normalization, not in this shared type.
|
||||
export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
|
||||
export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
|
||||
export const BACKUP_DEFAULT_S3_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 = 's3' | 'webdav';
|
||||
|
||||
export interface S3BackupDestination {
|
||||
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 =
|
||||
| S3BackupDestination
|
||||
| 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 === 's3') {
|
||||
return {
|
||||
endpoint: '',
|
||||
bucket: '',
|
||||
region: BACKUP_DEFAULT_S3_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 === 's3') return `S3 ${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,151 @@
|
||||
const MULTI_LABEL_PUBLIC_SUFFIXES = new Set([
|
||||
'ac.cn',
|
||||
'com.cn',
|
||||
'edu.cn',
|
||||
'gov.cn',
|
||||
'net.cn',
|
||||
'org.cn',
|
||||
'ah.cn',
|
||||
'bj.cn',
|
||||
'cq.cn',
|
||||
'fj.cn',
|
||||
'gd.cn',
|
||||
'gs.cn',
|
||||
'gx.cn',
|
||||
'gz.cn',
|
||||
'ha.cn',
|
||||
'hb.cn',
|
||||
'he.cn',
|
||||
'hi.cn',
|
||||
'hk.cn',
|
||||
'hl.cn',
|
||||
'hn.cn',
|
||||
'jl.cn',
|
||||
'js.cn',
|
||||
'jx.cn',
|
||||
'ln.cn',
|
||||
'mo.cn',
|
||||
'nm.cn',
|
||||
'nx.cn',
|
||||
'qh.cn',
|
||||
'sc.cn',
|
||||
'sd.cn',
|
||||
'sh.cn',
|
||||
'sn.cn',
|
||||
'sx.cn',
|
||||
'tj.cn',
|
||||
'tw.cn',
|
||||
'xj.cn',
|
||||
'xz.cn',
|
||||
'yn.cn',
|
||||
'zj.cn',
|
||||
'co.uk',
|
||||
'org.uk',
|
||||
'net.uk',
|
||||
'ac.uk',
|
||||
'gov.uk',
|
||||
'com.au',
|
||||
'net.au',
|
||||
'org.au',
|
||||
'edu.au',
|
||||
'gov.au',
|
||||
'co.nz',
|
||||
'org.nz',
|
||||
'net.nz',
|
||||
'com.br',
|
||||
'com.mx',
|
||||
'com.ar',
|
||||
'com.tr',
|
||||
'com.sg',
|
||||
'com.my',
|
||||
'com.hk',
|
||||
'com.tw',
|
||||
'co.jp',
|
||||
'ne.jp',
|
||||
'or.jp',
|
||||
'co.kr',
|
||||
'or.kr',
|
||||
'co.in',
|
||||
'firm.in',
|
||||
'net.in',
|
||||
'org.in',
|
||||
'co.id',
|
||||
'or.id',
|
||||
'web.id',
|
||||
'co.il',
|
||||
'org.il',
|
||||
'co.za',
|
||||
'com.sa',
|
||||
'com.ph',
|
||||
'com.vn',
|
||||
'com.pk',
|
||||
'com.bd',
|
||||
'com.ng',
|
||||
'github.io',
|
||||
'pages.dev',
|
||||
'workers.dev',
|
||||
'cloudflareaccess.com',
|
||||
'vercel.app',
|
||||
'netlify.app',
|
||||
'web.app',
|
||||
'firebaseapp.com',
|
||||
'herokuapp.com',
|
||||
'fly.dev',
|
||||
'railway.app',
|
||||
'render.com',
|
||||
'onrender.com',
|
||||
]);
|
||||
|
||||
function extractHost(input: string): string {
|
||||
let raw = input.trim().toLowerCase();
|
||||
if (!raw) return '';
|
||||
raw = raw.replace(/\\/g, '/');
|
||||
|
||||
try {
|
||||
const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
|
||||
const parsed = new URL(candidate);
|
||||
raw = parsed.hostname;
|
||||
} catch {
|
||||
raw = raw.split(/[/?#]/, 1)[0] || '';
|
||||
const atIndex = raw.lastIndexOf('@');
|
||||
if (atIndex >= 0) raw = raw.slice(atIndex + 1);
|
||||
if (raw.startsWith('[')) return '';
|
||||
const colonIndex = raw.lastIndexOf(':');
|
||||
if (colonIndex > -1 && raw.indexOf(':') === colonIndex) raw = raw.slice(0, colonIndex);
|
||||
}
|
||||
|
||||
return raw
|
||||
.replace(/^\*+\./, '')
|
||||
.replace(/^\.+/, '')
|
||||
.replace(/\.+$/, '');
|
||||
}
|
||||
|
||||
function isValidHost(host: string): boolean {
|
||||
if (!host || host.length > 253 || !host.includes('.')) return false;
|
||||
if (host.includes('..') || /[:/\s]/.test(host)) return false;
|
||||
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) return false;
|
||||
return host.split('.').every((label) => (
|
||||
label.length > 0
|
||||
&& label.length <= 63
|
||||
&& /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)
|
||||
));
|
||||
}
|
||||
|
||||
export function normalizeEquivalentDomain(value: unknown): string {
|
||||
const host = extractHost(String(value || ''));
|
||||
if (!isValidHost(host)) return '';
|
||||
|
||||
const labels = host.split('.');
|
||||
for (let index = 0; index < labels.length; index += 1) {
|
||||
const suffix = labels.slice(index).join('.');
|
||||
if (!MULTI_LABEL_PUBLIC_SUFFIXES.has(suffix)) continue;
|
||||
if (index === 0) return '';
|
||||
return labels.slice(index - 1).join('.');
|
||||
}
|
||||
|
||||
return labels.length >= 2 ? labels.slice(-2).join('.') : '';
|
||||
}
|
||||
|
||||
export function isValidEquivalentDomain(value: unknown): boolean {
|
||||
return !!normalizeEquivalentDomain(value);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
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,
|
||||
// clientSecret length
|
||||
// clientSecret 长度
|
||||
clientSecretLength: 30,
|
||||
},
|
||||
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, waitUntil } 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 function notifyUserVaultSync(
|
||||
env: Env,
|
||||
userId: string,
|
||||
revisionDate: string,
|
||||
contextId?: string | null
|
||||
): void {
|
||||
waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_VAULT, revisionDate, contextId ?? null, null));
|
||||
}
|
||||
|
||||
export function notifyUserLogout(
|
||||
env: Env,
|
||||
userId: string,
|
||||
targetDeviceIdentifier?: string | null
|
||||
): void {
|
||||
waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_LOG_OUT, new Date().toISOString(), null, targetDeviceIdentifier ?? null));
|
||||
}
|
||||
|
||||
export async function getOnlineUserDevices(env: Env, userId: string): Promise<string[]> {
|
||||
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,29 +1,153 @@
|
||||
import { Env, User, ProfileResponse } from '../types';
|
||||
import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { AuthService } from '../services/auth';
|
||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
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';
|
||||
|
||||
// POST /api/accounts/register (only used from setup page, not client)
|
||||
// CONTRACT:
|
||||
// users.master_password_hash is server-side login verification only. It does
|
||||
// not decrypt vault data. Password changes must keep encrypted user key material,
|
||||
// securityStamp, refresh-token invalidation, and client compatibility together.
|
||||
// Password hints are non-secret reminders; never treat them as recovery secrets.
|
||||
function looksLikeEncString(value: string): boolean {
|
||||
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 {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
if (!secret) return 'missing';
|
||||
if (secret === DEFAULT_DEV_SECRET) return 'default';
|
||||
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
|
||||
return null;
|
||||
}
|
||||
|
||||
async function verifyUserSecret(
|
||||
auth: AuthService,
|
||||
user: User,
|
||||
secret: string | null | undefined
|
||||
): Promise<boolean> {
|
||||
const normalized = String(secret || '').trim();
|
||||
if (!normalized) return false;
|
||||
return auth.verifyPassword(normalized, user.masterPasswordHash, user.email);
|
||||
}
|
||||
|
||||
function toProfile(user: User, env: Env): ProfileResponse {
|
||||
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> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
// Check if already registered
|
||||
const isRegistered = await storage.isRegistered();
|
||||
if (isRegistered) {
|
||||
return errorResponse('Registration is closed', 403);
|
||||
const unsafe = jwtSecretUnsafeReason(env);
|
||||
if (unsafe) {
|
||||
const message = unsafe === 'missing'
|
||||
? 'JWT_SECRET is not set'
|
||||
: unsafe === 'default'
|
||||
? 'JWT_SECRET is using the default/sample value. Please change it.'
|
||||
: 'JWT_SECRET must be at least 32 characters';
|
||||
return errorResponse(message, 400);
|
||||
}
|
||||
|
||||
let body: {
|
||||
email?: string;
|
||||
name?: string;
|
||||
masterPasswordHash?: string;
|
||||
masterPasswordHint?: string;
|
||||
key?: string;
|
||||
kdf?: number;
|
||||
kdfIterations?: number;
|
||||
kdfMemory?: number;
|
||||
kdfParallelism?: number;
|
||||
inviteCode?: string;
|
||||
masterPasswordHint?: string;
|
||||
keys?: {
|
||||
publicKey?: string;
|
||||
encryptedPrivateKey?: string;
|
||||
@@ -36,110 +160,266 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const email = body.email?.toLowerCase();
|
||||
const name = body.name || email;
|
||||
const email = body.email?.toLowerCase().trim();
|
||||
const name = body.name?.trim() || email;
|
||||
const masterPasswordHash = body.masterPasswordHash;
|
||||
const key = body.key;
|
||||
const privateKey = body.keys?.encryptedPrivateKey;
|
||||
const publicKey = body.keys?.publicKey;
|
||||
const inviteCode = (body.inviteCode || '').trim();
|
||||
const masterPasswordHint = normalizeMasterPasswordHint(body.masterPasswordHint);
|
||||
|
||||
if (!email || !masterPasswordHash || !key) {
|
||||
return errorResponse('Email, masterPasswordHash, and key are required', 400);
|
||||
}
|
||||
|
||||
if (!email.includes('@') || email.length < 3) {
|
||||
return errorResponse('Invalid email address', 400);
|
||||
}
|
||||
if (!privateKey || !publicKey) {
|
||||
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 = {
|
||||
id: generateUUID(),
|
||||
email: email,
|
||||
email,
|
||||
name: name || email,
|
||||
masterPasswordHash: masterPasswordHash,
|
||||
key: key,
|
||||
privateKey: privateKey,
|
||||
publicKey: publicKey,
|
||||
masterPasswordHint,
|
||||
masterPasswordHash: serverHash,
|
||||
key,
|
||||
privateKey,
|
||||
publicKey,
|
||||
kdfType: body.kdf ?? 0,
|
||||
kdfIterations: body.kdfIterations ?? 600000,
|
||||
kdfIterations: body.kdfIterations ?? LIMITS.auth.defaultKdfIterations,
|
||||
kdfMemory: body.kdfMemory,
|
||||
kdfParallelism: body.kdfParallelism,
|
||||
securityStamp: generateUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
verifyDevices: true,
|
||||
totpSecret: null,
|
||||
totpRecoveryCode: null,
|
||||
apiKey: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await storage.saveUser(user);
|
||||
await storage.setRegistered();
|
||||
|
||||
return jsonResponse({ success: true }, 200);
|
||||
}
|
||||
|
||||
// GET /api/accounts/profile
|
||||
export async function handleGetProfile(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const user = await storage.getUserById(userId);
|
||||
|
||||
if (!user) {
|
||||
return errorResponse('User not found', 404);
|
||||
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.createAuditLog({
|
||||
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);
|
||||
}
|
||||
|
||||
const profile: ProfileResponse = {
|
||||
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.VAULT);
|
||||
const user = await storage.getUserById(userId);
|
||||
|
||||
if (!user) {
|
||||
return errorResponse('User not found', 404);
|
||||
if (!inviteCode) {
|
||||
return errorResponse('Invite code is required', 403);
|
||||
}
|
||||
|
||||
let body: { name?: string; masterPasswordHint?: string };
|
||||
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 clientIdentifier = getClientIdentifier(request);
|
||||
if (!clientIdentifier) {
|
||||
return errorResponse('Client IP is required', 403);
|
||||
}
|
||||
|
||||
let body: { email?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
if (body.name) {
|
||||
user.name = body.name;
|
||||
const email = String(body.email || '').trim().toLowerCase();
|
||||
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);
|
||||
|
||||
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
|
||||
export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const storage = new StorageService(env.DB);
|
||||
const auth = new AuthService(env);
|
||||
const user = await storage.getUserById(userId);
|
||||
|
||||
if (!user) {
|
||||
@@ -147,6 +427,7 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
|
||||
}
|
||||
|
||||
let body: {
|
||||
masterPasswordHash?: string;
|
||||
key?: string;
|
||||
encryptedPrivateKey?: string;
|
||||
publicKey?: string;
|
||||
@@ -158,6 +439,22 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
|
||||
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.encryptedPrivateKey) user.privateKey = body.encryptedPrivateKey;
|
||||
if (body.publicKey) user.publicKey = body.publicKey;
|
||||
@@ -168,11 +465,265 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
|
||||
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
|
||||
export async function handleGetRevisionDate(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
void request;
|
||||
const storage = new StorageService(env.DB);
|
||||
const revisionDate = await storage.getRevisionDate(userId);
|
||||
|
||||
|
||||
// Return as milliseconds timestamp (Bitwarden format)
|
||||
const timestamp = new Date(revisionDate).getTime();
|
||||
return jsonResponse(timestamp);
|
||||
@@ -180,7 +731,8 @@ export async function handleGetRevisionDate(request: Request, env: Env, userId:
|
||||
|
||||
// POST /api/accounts/verify-password
|
||||
export async function handleVerifyPassword(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const storage = new StorageService(env.DB);
|
||||
const auth = new AuthService(env);
|
||||
const user = await storage.getUserById(userId);
|
||||
|
||||
if (!user) {
|
||||
@@ -198,9 +750,81 @@ export async function handleVerifyPassword(request: Request, env: Env, userId: s
|
||||
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 new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
// POST /api/accounts/api-key
|
||||
export async function handleGetApiKey(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
return apiKey(request, env, userId, false);
|
||||
}
|
||||
|
||||
// POST /api/accounts/rotate-api-key
|
||||
export async function handleRotateApiKey(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
return apiKey(request, env, userId, true);
|
||||
}
|
||||
|
||||
async function apiKey(request: Request, env: Env, userId: string, rotate: boolean): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const auth = new AuthService(env);
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) return errorResponse('User not found', 404);
|
||||
|
||||
let body: Record<string, string | undefined>;
|
||||
try {
|
||||
const contentType = request.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
const formData = await request.formData();
|
||||
body = Object.fromEntries(formData.entries()) as Record<string, string>;
|
||||
} else {
|
||||
body = await request.json();
|
||||
}
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const currentHash = String(body.masterPasswordHash || body.master_password_hash || body.password || '').trim();
|
||||
if (!currentHash) return errorResponse('masterPasswordHash is required', 400);
|
||||
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
|
||||
if (!valid) return errorResponse('Invalid password', 400);
|
||||
|
||||
if (rotate || user.apiKey === null) {
|
||||
// Upstream apikeys are 30-character random alphanumeric strings
|
||||
user.apiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength);
|
||||
if (rotate) {
|
||||
user.securityStamp = generateUUID();
|
||||
await storage.deleteRefreshTokensByUserId(user.id);
|
||||
}
|
||||
user.updatedAt = new Date().toISOString();
|
||||
await storage.saveUser(user);
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
apiKey: user.apiKey,
|
||||
revisionDate: user.updatedAt,
|
||||
object: 'apiKey',
|
||||
});
|
||||
}
|
||||
|
||||
// Generate a random alphanumeric string of the given length using crypto.getRandomValues.
|
||||
function randomStringAlphanum(length: number): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
const maxUnbiased = Math.floor(256 / chars.length) * chars.length;
|
||||
const bytes = new Uint8Array(Math.max(16, length));
|
||||
|
||||
while (result.length < length) {
|
||||
crypto.getRandomValues(bytes);
|
||||
for (const value of bytes) {
|
||||
if (value >= maxUnbiased) continue;
|
||||
result += chars[value % chars.length];
|
||||
if (result.length >= length) break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -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,8 +1,34 @@
|
||||
import { Env, Attachment, Cipher } from '../types';
|
||||
import { Env, Attachment, DEFAULT_DEV_SECRET } from '../types';
|
||||
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
||||
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 { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt';
|
||||
import {
|
||||
createAttachmentUploadToken,
|
||||
createFileDownloadToken,
|
||||
verifyAttachmentUploadToken,
|
||||
verifyFileDownloadToken,
|
||||
} from '../utils/jwt';
|
||||
import { cipherToResponse } from './ciphers';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { readActingDeviceIdentifier } from '../utils/device';
|
||||
import {
|
||||
deleteBlobObject,
|
||||
getAttachmentObjectKey,
|
||||
getBlobObject,
|
||||
getBlobStorageMaxBytes,
|
||||
putBlobObject,
|
||||
} from '../services/blob-store';
|
||||
|
||||
function notifyVaultSyncForRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
revisionDate: string
|
||||
): void {
|
||||
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
}
|
||||
|
||||
// Format file size to human readable
|
||||
function formatSize(bytes: number): string {
|
||||
@@ -12,9 +38,65 @@ function formatSize(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
// Get R2 object path for attachment
|
||||
function getAttachmentPath(cipherId: string, attachmentId: string): string {
|
||||
return `${cipherId}/${attachmentId}`;
|
||||
async function runWithConcurrency<T>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
worker: (item: T) => Promise<void>
|
||||
): Promise<void> {
|
||||
if (items.length === 0) return;
|
||||
const limit = Math.max(1, concurrency);
|
||||
for (let index = 0; index < items.length; index += limit) {
|
||||
await Promise.all(items.slice(index, index + limit).map(worker));
|
||||
}
|
||||
}
|
||||
|
||||
async function processAttachmentUpload(
|
||||
request: Request,
|
||||
env: Env,
|
||||
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) {
|
||||
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 201 });
|
||||
}
|
||||
|
||||
// POST /api/ciphers/{cipherId}/attachment/v2
|
||||
@@ -25,7 +107,7 @@ export async function handleCreateAttachment(
|
||||
userId: string,
|
||||
cipherId: string
|
||||
): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
// Verify cipher exists and belongs to user
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
@@ -69,24 +151,29 @@ export async function handleCreateAttachment(
|
||||
await storage.addAttachmentToCipher(cipherId, attachmentId);
|
||||
|
||||
// Update cipher revision date
|
||||
await storage.updateCipherRevisionDate(cipherId);
|
||||
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||
if (revisionInfo) {
|
||||
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||
}
|
||||
|
||||
// Get updated cipher for response
|
||||
const updatedCipher = await storage.getCipher(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({
|
||||
object: 'attachment-fileUpload',
|
||||
attachmentId: attachmentId,
|
||||
url: `/api/ciphers/${cipherId}/attachment/${attachmentId}`,
|
||||
fileUploadType: 0, // Direct upload
|
||||
cipherResponse: formatCipherResponse(updatedCipher!, attachments),
|
||||
url: buildDirectUploadUrl(request, `/api/ciphers/${cipherId}/attachment/${attachmentId}`, uploadToken),
|
||||
fileUploadType: 1,
|
||||
cipherResponse: cipherToResponse(updatedCipher!, attachments),
|
||||
});
|
||||
}
|
||||
|
||||
// Maximum file size: 100MB
|
||||
const MAX_FILE_SIZE = 100 * 1024 * 1024;
|
||||
|
||||
// POST /api/ciphers/{cipherId}/attachment/{attachmentId}
|
||||
// Upload attachment file content
|
||||
export async function handleUploadAttachment(
|
||||
@@ -96,7 +183,7 @@ export async function handleUploadAttachment(
|
||||
cipherId: string,
|
||||
attachmentId: string
|
||||
): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
// Verify cipher exists and belongs to user
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
@@ -110,54 +197,45 @@ export async function handleUploadAttachment(
|
||||
return errorResponse('Attachment not found', 404);
|
||||
}
|
||||
|
||||
// Check content-length header for size limit
|
||||
const contentLength = request.headers.get('content-length');
|
||||
if (contentLength && parseInt(contentLength) > MAX_FILE_SIZE) {
|
||||
return errorResponse('File too large. Maximum size is 100MB', 413);
|
||||
return processAttachmentUpload(request, env, attachment, cipherId);
|
||||
}
|
||||
|
||||
export async function handlePublicUploadAttachment(
|
||||
request: Request,
|
||||
env: Env,
|
||||
cipherId: string,
|
||||
attachmentId: string
|
||||
): Promise<Response> {
|
||||
const jwtSecret = getSafeJwtSecret(env);
|
||||
if (!jwtSecret) {
|
||||
return errorResponse('Server configuration error', 500);
|
||||
}
|
||||
|
||||
// Get the file from multipart form data
|
||||
const contentType = request.headers.get('content-type') || '';
|
||||
if (!contentType.includes('multipart/form-data')) {
|
||||
return errorResponse('Content-Type must be multipart/form-data', 400);
|
||||
const token = new URL(request.url).searchParams.get('token');
|
||||
if (!token) {
|
||||
return errorResponse('Token required', 401);
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('data') as File | null;
|
||||
|
||||
if (!file) {
|
||||
return errorResponse('No file uploaded', 400);
|
||||
const claims = await verifyAttachmentUploadToken(token, jwtSecret);
|
||||
if (!claims) {
|
||||
return errorResponse('Invalid or expired token', 401);
|
||||
}
|
||||
if (claims.cipherId !== cipherId || claims.attachmentId !== attachmentId) {
|
||||
return errorResponse('Token mismatch', 401);
|
||||
}
|
||||
|
||||
// Check actual file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return errorResponse('File too large. Maximum size is 100MB', 413);
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
if (!cipher || cipher.userId !== claims.userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
// Store file in R2
|
||||
const path = getAttachmentPath(cipherId, attachmentId);
|
||||
await env.ATTACHMENTS.put(path, file.stream(), {
|
||||
httpMetadata: {
|
||||
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);
|
||||
const attachment = await storage.getAttachment(attachmentId);
|
||||
if (!attachment || attachment.cipherId !== cipherId) {
|
||||
return errorResponse('Attachment not found', 404);
|
||||
}
|
||||
|
||||
// Update cipher revision date
|
||||
await storage.updateCipherRevisionDate(cipherId);
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
return processAttachmentUpload(request, env, attachment, cipherId);
|
||||
}
|
||||
|
||||
// GET /api/ciphers/{cipherId}/attachment/{attachmentId}
|
||||
@@ -169,7 +247,7 @@ export async function handleGetAttachment(
|
||||
cipherId: string,
|
||||
attachmentId: string
|
||||
): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
// Verify cipher exists and belongs to user
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
@@ -196,7 +274,65 @@ export async function handleGetAttachment(
|
||||
url: downloadUrl,
|
||||
fileName: attachment.fileName,
|
||||
key: attachment.key,
|
||||
size: Number(attachment.size) || 0,
|
||||
size: String(Number(attachment.size) || 0),
|
||||
sizeName: attachment.sizeName,
|
||||
});
|
||||
}
|
||||
|
||||
// PUT /api/ciphers/{cipherId}/attachment/{attachmentId}/metadata
|
||||
// 修正旧附件的加密元数据,供官方客户端按当前 Bitwarden 契约解密。
|
||||
export async function handleUpdateAttachmentMetadata(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
cipherId: string,
|
||||
attachmentId: string
|
||||
): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
const attachment = await storage.getAttachment(attachmentId);
|
||||
if (!attachment || attachment.cipherId !== cipherId) {
|
||||
return errorResponse('Attachment not found', 404);
|
||||
}
|
||||
|
||||
let body: { fileName?: string | null; key?: string | null };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(body, 'fileName') && !Object.prototype.hasOwnProperty.call(body, 'key')) {
|
||||
return errorResponse('No metadata fields supplied', 400);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, 'fileName')) {
|
||||
const fileName = String(body.fileName || '').trim();
|
||||
if (!fileName) return errorResponse('fileName is required', 400);
|
||||
attachment.fileName = fileName;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(body, 'key')) {
|
||||
const key = body.key == null ? null : String(body.key || '').trim();
|
||||
attachment.key = key || null;
|
||||
}
|
||||
|
||||
await storage.saveAttachment(attachment);
|
||||
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||
if (revisionInfo) {
|
||||
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
object: 'attachment',
|
||||
id: attachment.id,
|
||||
fileName: attachment.fileName,
|
||||
key: attachment.key,
|
||||
size: String(Number(attachment.size) || 0),
|
||||
sizeName: attachment.sizeName,
|
||||
});
|
||||
}
|
||||
@@ -209,6 +345,11 @@ export async function handlePublicDownloadAttachment(
|
||||
cipherId: string,
|
||||
attachmentId: string
|
||||
): 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 token = url.searchParams.get('token');
|
||||
|
||||
@@ -227,7 +368,7 @@ export async function handlePublicDownloadAttachment(
|
||||
return errorResponse('Token mismatch', 401);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
// Verify attachment exists
|
||||
const attachment = await storage.getAttachment(attachmentId);
|
||||
@@ -235,20 +376,23 @@ export async function handlePublicDownloadAttachment(
|
||||
return errorResponse('Attachment not found', 404);
|
||||
}
|
||||
|
||||
// Get file from R2
|
||||
const path = getAttachmentPath(cipherId, attachmentId);
|
||||
const object = await env.ATTACHMENTS.get(path);
|
||||
const path = getAttachmentObjectKey(cipherId, attachmentId);
|
||||
const object = await getBlobObject(env, path);
|
||||
|
||||
if (!object) {
|
||||
return errorResponse('Attachment file not found', 404);
|
||||
}
|
||||
|
||||
const firstUse = await storage.consumeAttachmentDownloadToken(claims.jti, claims.exp);
|
||||
if (!firstUse) {
|
||||
return errorResponse('Invalid or expired token', 401);
|
||||
}
|
||||
|
||||
return new Response(object.body, {
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Type': object.contentType || 'application/octet-stream',
|
||||
'Content-Length': String(object.size),
|
||||
'Cache-Control': 'private, no-cache',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -262,7 +406,7 @@ export async function handleDeleteAttachment(
|
||||
cipherId: string,
|
||||
attachmentId: string
|
||||
): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
// Verify cipher exists and belongs to user
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
@@ -276,84 +420,50 @@ export async function handleDeleteAttachment(
|
||||
return errorResponse('Attachment not found', 404);
|
||||
}
|
||||
|
||||
// Delete file from R2
|
||||
const path = getAttachmentPath(cipherId, attachmentId);
|
||||
await env.ATTACHMENTS.delete(path);
|
||||
const path = getAttachmentObjectKey(cipherId, attachmentId);
|
||||
await deleteBlobObject(env, path);
|
||||
|
||||
// Delete attachment metadata
|
||||
await storage.deleteAttachment(attachmentId);
|
||||
|
||||
// Remove attachment from cipher
|
||||
await storage.removeAttachmentFromCipher(cipherId, attachmentId);
|
||||
|
||||
// Update cipher revision date
|
||||
await storage.updateCipherRevisionDate(cipherId);
|
||||
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||
if (revisionInfo) {
|
||||
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||
}
|
||||
|
||||
// Get updated cipher for response
|
||||
const updatedCipher = await storage.getCipher(cipherId);
|
||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||
|
||||
return jsonResponse({
|
||||
cipher: formatCipherResponse(updatedCipher!, attachments),
|
||||
cipher: cipherToResponse(updatedCipher!, attachments),
|
||||
});
|
||||
}
|
||||
|
||||
// Helper: Format cipher response with attachments
|
||||
function formatCipherResponse(cipher: Cipher, attachments: Attachment[]): any {
|
||||
return {
|
||||
id: cipher.id,
|
||||
organizationId: null,
|
||||
folderId: cipher.folderId,
|
||||
type: Number(cipher.type) || 1,
|
||||
name: cipher.name,
|
||||
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,
|
||||
creationDate: cipher.createdAt,
|
||||
revisionDate: cipher.updatedAt,
|
||||
deletedDate: cipher.deletedAt,
|
||||
archivedDate: null,
|
||||
edit: true,
|
||||
viewPassword: true,
|
||||
permissions: {
|
||||
delete: true,
|
||||
restore: true,
|
||||
},
|
||||
object: 'cipher',
|
||||
collectionIds: [],
|
||||
attachments: attachments.length > 0 ? attachments.map(a => ({
|
||||
id: a.id,
|
||||
fileName: a.fileName,
|
||||
size: Number(a.size) || 0,
|
||||
sizeName: a.sizeName,
|
||||
key: a.key,
|
||||
url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`,
|
||||
object: 'attachment',
|
||||
})) : null,
|
||||
key: cipher.key,
|
||||
encryptedFor: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Delete all attachments for a cipher (used when deleting cipher)
|
||||
export async function deleteAllAttachmentsForCipher(
|
||||
env: Env,
|
||||
cipherId: string
|
||||
): Promise<void> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||
|
||||
for (const attachment of attachments) {
|
||||
const path = getAttachmentPath(cipherId, attachment.id);
|
||||
await env.ATTACHMENTS.delete(path);
|
||||
await storage.deleteAttachment(attachment.id);
|
||||
}
|
||||
await deleteAllAttachmentsForCiphers(env, [cipherId]);
|
||||
}
|
||||
|
||||
export async function deleteAllAttachmentsForCiphers(
|
||||
env: Env,
|
||||
cipherIds: string[]
|
||||
): Promise<void> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(cipherIds);
|
||||
const attachments = Array.from(attachmentsByCipher.entries()).flatMap(([ownedCipherId, items]) =>
|
||||
items.map((attachment) => ({ attachment, cipherId: ownedCipherId }))
|
||||
);
|
||||
if (!attachments.length) return;
|
||||
|
||||
await runWithConcurrency(attachments, LIMITS.performance.attachmentDeleteConcurrency, async ({ attachment, cipherId }) => {
|
||||
const path = getAttachmentObjectKey(cipherId, attachment.id);
|
||||
await deleteBlobObject(env, path);
|
||||
});
|
||||
|
||||
await storage.bulkDeleteAttachmentsByIds(attachments.map(({ attachment }) => attachment.id));
|
||||
}
|
||||
|
||||
@@ -1,90 +1,394 @@
|
||||
import { Env, Cipher, CipherResponse, Attachment } from '../types';
|
||||
import {
|
||||
Env,
|
||||
Cipher,
|
||||
CipherCard,
|
||||
CipherIdentity,
|
||||
CipherLogin,
|
||||
CipherResponse,
|
||||
CipherSecureNote,
|
||||
CipherSshKey,
|
||||
Attachment,
|
||||
PasswordHistory,
|
||||
} from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { deleteAllAttachmentsForCipher } from './attachments';
|
||||
import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from './attachments';
|
||||
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
||||
import { readActingDeviceIdentifier } from '../utils/device';
|
||||
|
||||
// Format attachments for API response
|
||||
function formatAttachments(attachments: Attachment[]): any[] | null {
|
||||
if (attachments.length === 0) return null;
|
||||
return attachments.map(a => ({
|
||||
id: a.id,
|
||||
fileName: a.fileName,
|
||||
size: Number(a.size) || 0, // Android expects Int, not String
|
||||
sizeName: a.sizeName,
|
||||
key: a.key,
|
||||
url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`, // Android requires non-null url!
|
||||
object: 'attachment',
|
||||
}));
|
||||
// CONTRACT:
|
||||
// Cipher JSON is the highest-risk Bitwarden compatibility surface. Preserve
|
||||
// unknown/future client fields by default, then override only server-owned
|
||||
// fields. Any change to cipher response shape must be checked against /api/sync,
|
||||
// attachments, import/export, and current official clients.
|
||||
function normalizeOptionalId(value: unknown): string | null {
|
||||
if (value == null) return null;
|
||||
const normalized = String(value).trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
// Convert internal cipher to API response format
|
||||
function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): CipherResponse {
|
||||
function notifyVaultSyncForRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
revisionDate: string
|
||||
): void {
|
||||
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 readCipherProp<T = unknown>(source: any, aliases: string[]): { present: boolean; value: T | undefined } {
|
||||
return getAliasedProp(source, aliases) as { present: boolean; value: T | undefined };
|
||||
}
|
||||
|
||||
function normalizeCipherTimestamp(value: unknown): string | null {
|
||||
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 readCipherRevisionDate(source: any): string | null {
|
||||
const revision = getAliasedProp(source, ['lastKnownRevisionDate', 'LastKnownRevisionDate']);
|
||||
return revision.present ? normalizeCipherTimestamp(revision.value) : null;
|
||||
}
|
||||
|
||||
function isStaleCipherUpdate(existingUpdatedAt: string, clientRevisionDate: string | null): boolean {
|
||||
if (!clientRevisionDate) return false;
|
||||
const existingTs = Date.parse(existingUpdatedAt);
|
||||
const clientTs = Date.parse(clientRevisionDate);
|
||||
if (Number.isNaN(existingTs) || Number.isNaN(clientTs)) return false;
|
||||
return existingTs - clientTs > 1000;
|
||||
}
|
||||
|
||||
function syncCipherComputedAliases(cipher: Cipher): Cipher {
|
||||
cipher.archivedDate = cipher.archivedAt ?? null;
|
||||
cipher.deletedDate = cipher.deletedAt ?? null;
|
||||
return cipher;
|
||||
}
|
||||
|
||||
function isValidEncString(value: unknown): value is string {
|
||||
if (typeof value !== 'string') return false;
|
||||
const trimmed = value.trim();
|
||||
const dot = trimmed.indexOf('.');
|
||||
if (dot <= 0) return false;
|
||||
const type = Number(trimmed.slice(0, dot));
|
||||
if (!Number.isInteger(type) || type < 0) return false;
|
||||
const parts = trimmed.slice(dot + 1).split('|');
|
||||
if (parts.some((part) => part.length === 0)) return false;
|
||||
|
||||
// Bitwarden's legacy symmetric EncString variants require IV + data,
|
||||
// while the authenticated AES-CBC-HMAC variant requires IV + data + MAC.
|
||||
if (type === 0 || type === 1 || type === 4) return parts.length >= 2;
|
||||
if (type === 2) return parts.length === 3;
|
||||
|
||||
// Keep newer one-part formats, such as COSE Encrypt0, future-compatible.
|
||||
return parts.length >= 1;
|
||||
}
|
||||
|
||||
function optionalEncString(value: unknown): string | null {
|
||||
if (value == null || value === '') return null;
|
||||
return isValidEncString(value) ? value.trim() : null;
|
||||
}
|
||||
|
||||
function sanitizeEncryptedObject<T extends Record<string, any>>(
|
||||
source: T | null | undefined,
|
||||
encryptedKeys: readonly string[]
|
||||
): T | null {
|
||||
if (!source || typeof source !== 'object') return source ?? null;
|
||||
const next: Record<string, any> = { ...source };
|
||||
for (const key of encryptedKeys) {
|
||||
if (!Object.prototype.hasOwnProperty.call(next, key)) continue;
|
||||
next[key] = optionalEncString(next[key]);
|
||||
}
|
||||
return next as T;
|
||||
}
|
||||
|
||||
function normalizeCipherForStorage(cipher: Cipher): Cipher {
|
||||
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
|
||||
cipher.folderId = normalizeOptionalId(cipher.folderId);
|
||||
const hasArchivedAt = Object.prototype.hasOwnProperty.call(cipher as object, 'archivedAt');
|
||||
cipher.archivedAt = hasArchivedAt
|
||||
? normalizeCipherTimestamp(cipher.archivedAt) ?? null
|
||||
: normalizeCipherTimestamp(cipher.archivedDate) ?? null;
|
||||
return syncCipherComputedAliases(cipher);
|
||||
}
|
||||
|
||||
export function normalizeCipherLoginForStorage(login: any): any {
|
||||
if (!login || typeof login !== 'object') return login ?? null;
|
||||
return {
|
||||
id: cipher.id,
|
||||
organizationId: null,
|
||||
folderId: cipher.folderId,
|
||||
...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;
|
||||
const next = sanitizeEncryptedObject(normalized, ['username', 'password', 'totp', 'uri']);
|
||||
if (!next) return null;
|
||||
next.uris = Array.isArray(next.uris)
|
||||
? next.uris
|
||||
.map((uri: any) => sanitizeEncryptedObject(uri, ['uri', 'uriChecksum']))
|
||||
.filter((uri: any) => !!uri && (uri.uri || uri.uriChecksum || uri.match != null))
|
||||
: null;
|
||||
next.fido2Credentials = normalizeFido2CredentialsForCompatibility(next.fido2Credentials);
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizeFido2CredentialsForCompatibility(credentials: any): any[] | null {
|
||||
if (!Array.isArray(credentials) || credentials.length === 0) return null;
|
||||
const requiredEncryptedKeys = [
|
||||
'credentialId',
|
||||
'keyType',
|
||||
'keyAlgorithm',
|
||||
'keyCurve',
|
||||
'keyValue',
|
||||
'rpId',
|
||||
'counter',
|
||||
'discoverable',
|
||||
];
|
||||
const optionalEncryptedKeys = ['userHandle', 'userName', 'rpName', 'userDisplayName'];
|
||||
const out: any[] = [];
|
||||
|
||||
for (const credential of credentials) {
|
||||
if (!credential || typeof credential !== 'object') continue;
|
||||
const next: Record<string, any> = { ...credential };
|
||||
let valid = true;
|
||||
for (const key of requiredEncryptedKeys) {
|
||||
if (!isValidEncString(next[key])) {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
next[key] = String(next[key]).trim();
|
||||
}
|
||||
if (!valid) continue;
|
||||
for (const key of optionalEncryptedKeys) {
|
||||
if (Object.prototype.hasOwnProperty.call(next, key)) {
|
||||
next[key] = optionalEncString(next[key]);
|
||||
}
|
||||
}
|
||||
out.push(next);
|
||||
}
|
||||
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
// Android 2026.2.0 requires sshKey.keyFingerprint in sync payloads.
|
||||
// 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);
|
||||
|
||||
if (
|
||||
!isValidEncString(sshKey.privateKey) ||
|
||||
!isValidEncString(sshKey.publicKey) ||
|
||||
!isValidEncString(normalizedFingerprint)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...sshKey,
|
||||
privateKey: String(sshKey.privateKey).trim(),
|
||||
publicKey: String(sshKey.publicKey).trim(),
|
||||
keyFingerprint: normalizedFingerprint,
|
||||
fingerprint: normalizedFingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
// Format attachments for API response
|
||||
export function formatAttachments(attachments: Attachment[]): any[] | null {
|
||||
if (attachments.length === 0) return null;
|
||||
const formatted = attachments
|
||||
.filter((a) => isValidEncString(a.fileName))
|
||||
.map(a => ({
|
||||
id: a.id,
|
||||
fileName: a.fileName.trim(),
|
||||
// Bitwarden clients decode attachment size as string in cipher payloads.
|
||||
size: String(Number(a.size) || 0),
|
||||
sizeName: a.sizeName,
|
||||
key: optionalEncString(a.key),
|
||||
url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`, // Android requires non-null url!
|
||||
object: 'attachment',
|
||||
}));
|
||||
return formatted.length ? formatted : null;
|
||||
}
|
||||
|
||||
function normalizeCipherFieldsForCompatibility(fields: any): any[] | null {
|
||||
if (!Array.isArray(fields) || fields.length === 0) return null;
|
||||
const out = fields
|
||||
.map((field: any) => {
|
||||
if (!field || typeof field !== 'object') return null;
|
||||
return {
|
||||
...field,
|
||||
name: optionalEncString(field.name),
|
||||
value: optionalEncString(field.value),
|
||||
type: Number(field.type) || 0,
|
||||
linkedId: field.linkedId ?? null,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
function normalizePasswordHistoryForCompatibility(passwordHistory: any): PasswordHistory[] | null {
|
||||
if (!Array.isArray(passwordHistory) || passwordHistory.length === 0) return null;
|
||||
const out = passwordHistory
|
||||
.filter((entry: any) => entry && typeof entry === 'object' && isValidEncString(entry.password))
|
||||
.map((entry: any) => ({
|
||||
...entry,
|
||||
password: String(entry.password).trim(),
|
||||
lastUsedDate: normalizeCipherTimestamp(entry.lastUsedDate) ?? new Date().toISOString(),
|
||||
}));
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
export function isCipherResponseSyncCompatible(cipher: CipherResponse): boolean {
|
||||
return isValidEncString(cipher.name);
|
||||
}
|
||||
|
||||
// Convert internal cipher to API response format.
|
||||
// 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 normalizedCard = sanitizeEncryptedObject((passthrough as any).card ?? null, ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code']);
|
||||
const normalizedIdentity = sanitizeEncryptedObject((passthrough as any).identity ?? null, [
|
||||
'title',
|
||||
'firstName',
|
||||
'middleName',
|
||||
'lastName',
|
||||
'address1',
|
||||
'address2',
|
||||
'address3',
|
||||
'city',
|
||||
'state',
|
||||
'postalCode',
|
||||
'country',
|
||||
'company',
|
||||
'email',
|
||||
'phone',
|
||||
'ssn',
|
||||
'username',
|
||||
'passportNumber',
|
||||
'licenseNumber',
|
||||
]);
|
||||
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
|
||||
|
||||
return {
|
||||
// Pass through ALL stored cipher fields (known + unknown)
|
||||
...passthrough,
|
||||
// Server-computed / enforced fields (always override)
|
||||
folderId: normalizeOptionalId(cipher.folderId),
|
||||
type: Number(cipher.type) || 1,
|
||||
name: cipher.name,
|
||||
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,
|
||||
creationDate: cipher.createdAt,
|
||||
revisionDate: cipher.updatedAt,
|
||||
deletedDate: cipher.deletedAt,
|
||||
archivedDate: null,
|
||||
organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null),
|
||||
organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false),
|
||||
creationDate: createdAt,
|
||||
revisionDate: updatedAt,
|
||||
deletedDate: deletedAt,
|
||||
archivedDate: archivedAt ?? null,
|
||||
edit: true,
|
||||
viewPassword: true,
|
||||
permissions: {
|
||||
delete: true,
|
||||
restore: true,
|
||||
},
|
||||
object: 'cipher',
|
||||
collectionIds: [],
|
||||
object: 'cipherDetails',
|
||||
collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [],
|
||||
attachments: formatAttachments(attachments),
|
||||
key: cipher.key,
|
||||
encryptedFor: null,
|
||||
name: isValidEncString(cipher.name) ? cipher.name.trim() : cipher.name,
|
||||
notes: optionalEncString(cipher.notes),
|
||||
login: normalizedLogin,
|
||||
card: normalizedCard,
|
||||
identity: normalizedIdentity,
|
||||
fields: normalizeCipherFieldsForCompatibility((passthrough as any).fields),
|
||||
passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory),
|
||||
sshKey: normalizedSshKey,
|
||||
key: optionalEncString(cipher.key),
|
||||
encryptedFor: (passthrough as any).encryptedFor ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/ciphers
|
||||
export async function handleGetCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const ciphers = await storage.getAllCiphers(userId);
|
||||
|
||||
// Filter out soft-deleted ciphers unless specifically requested
|
||||
const storage = new StorageService(env.DB);
|
||||
const url = new URL(request.url);
|
||||
const includeDeleted = url.searchParams.get('deleted') === 'true';
|
||||
|
||||
const filteredCiphers = includeDeleted
|
||||
? ciphers
|
||||
: ciphers.filter(c => !c.deletedAt);
|
||||
const pagination = parsePagination(url);
|
||||
|
||||
// Get attachments for all ciphers
|
||||
const cipherResponses = [];
|
||||
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.filter(c => !c.deletedAt);
|
||||
}
|
||||
|
||||
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(
|
||||
filteredCiphers.map((cipher) => cipher.id)
|
||||
);
|
||||
|
||||
// Build responses only for the current page to keep pagination cheap.
|
||||
const cipherResponses: CipherResponse[] = [];
|
||||
for (const cipher of filteredCiphers) {
|
||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||
cipherResponses.push(cipherToResponse(cipher, attachments));
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
data: cipherResponses,
|
||||
object: 'list',
|
||||
continuationToken: null,
|
||||
continuationToken: continuationToken,
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/ciphers/:id
|
||||
export async function handleGetCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
@@ -92,12 +396,20 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
|
||||
}
|
||||
|
||||
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
|
||||
export async function handleCreateCipher(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
let body: any;
|
||||
try {
|
||||
@@ -109,39 +421,62 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
||||
// Handle nested cipher object (from some clients)
|
||||
// Android client sends PascalCase "Cipher" for organization ciphers
|
||||
const cipherData = body.Cipher || body.cipher || body;
|
||||
const createFolderId = readCipherProp<string | null>(cipherData, ['folderId', 'FolderId']);
|
||||
const createKey = readCipherProp<string | null>(cipherData, ['key', 'Key']);
|
||||
const createLogin = readCipherProp<CipherLogin | null>(cipherData, ['login', 'Login']);
|
||||
const createCard = readCipherProp<CipherCard | null>(cipherData, ['card', 'Card']);
|
||||
const createIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
|
||||
const createSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
|
||||
const createSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
|
||||
const createPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
// Opaque passthrough: spread ALL client fields to preserve unknown/future ones,
|
||||
// then override only server-controlled fields.
|
||||
const cipher: Cipher = {
|
||||
...cipherData,
|
||||
// Server-controlled fields (always override client values)
|
||||
id: generateUUID(),
|
||||
userId: userId,
|
||||
type: Number(cipherData.type) || 1,
|
||||
folderId: cipherData.folderId || null,
|
||||
name: cipherData.name,
|
||||
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,
|
||||
favorite: !!cipherData.favorite,
|
||||
reprompt: cipherData.reprompt || 0,
|
||||
key: cipherData.key || null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
archivedAt: readCipherArchivedAt(cipherData, null),
|
||||
deletedAt: null,
|
||||
};
|
||||
cipher.folderId = createFolderId.present ? normalizeOptionalId(createFolderId.value) : normalizeOptionalId(cipher.folderId);
|
||||
cipher.key = createKey.present ? (createKey.value ?? null) : (cipher.key ?? null);
|
||||
cipher.login = createLogin.present ? (createLogin.value ?? null) : (cipher.login ?? null);
|
||||
cipher.card = createCard.present ? (createCard.value ?? null) : (cipher.card ?? null);
|
||||
cipher.identity = createIdentity.present ? (createIdentity.value ?? null) : (cipher.identity ?? null);
|
||||
cipher.secureNote = createSecureNote.present ? (createSecureNote.value ?? null) : (cipher.secureNote ?? null);
|
||||
cipher.sshKey = createSshKey.present ? (createSshKey.value ?? null) : (cipher.sshKey ?? null);
|
||||
cipher.passwordHistory = createPasswordHistory.present ? (createPasswordHistory.value ?? null) : (cipher.passwordHistory ?? null);
|
||||
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
|
||||
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.updateRevisionDate(userId);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(cipherToResponse(cipher), 200);
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, []),
|
||||
200
|
||||
);
|
||||
}
|
||||
|
||||
// PUT /api/ciphers/:id
|
||||
export async function handleUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const storage = new StorageService(env.DB);
|
||||
const existingCipher = await storage.getCipher(id);
|
||||
|
||||
if (!existingCipher || existingCipher.userId !== userId) {
|
||||
@@ -158,35 +493,84 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
// Handle nested cipher object
|
||||
// Android client sends PascalCase "Cipher" for organization ciphers
|
||||
const cipherData = body.Cipher || body.cipher || body;
|
||||
const incomingFolderId = readCipherProp<string | null>(cipherData, ['folderId', 'FolderId']);
|
||||
const incomingKey = readCipherProp<string | null>(cipherData, ['key', 'Key']);
|
||||
const incomingLogin = readCipherProp<CipherLogin | null>(cipherData, ['login', 'Login']);
|
||||
const incomingCard = readCipherProp<CipherCard | null>(cipherData, ['card', 'Card']);
|
||||
const incomingIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
|
||||
const incomingSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
|
||||
const incomingSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
|
||||
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
|
||||
const incomingRevisionDate = readCipherRevisionDate(cipherData);
|
||||
|
||||
if (isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) {
|
||||
return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400);
|
||||
}
|
||||
|
||||
const nextType = Number(cipherData.type) || existingCipher.type;
|
||||
|
||||
// Opaque passthrough: merge existing stored data with ALL incoming client fields.
|
||||
// Unknown/future fields from the client are preserved; server-controlled fields are protected.
|
||||
const cipher: Cipher = {
|
||||
...existingCipher,
|
||||
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,
|
||||
...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: nextType,
|
||||
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,
|
||||
key: cipherData.key !== undefined ? cipherData.key : existingCipher.key,
|
||||
createdAt: existingCipher.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
|
||||
deletedAt: existingCipher.deletedAt,
|
||||
};
|
||||
if (incomingFolderId.present) {
|
||||
cipher.folderId = normalizeOptionalId(incomingFolderId.value);
|
||||
}
|
||||
if (incomingKey.present) {
|
||||
cipher.key = incomingKey.value ?? null;
|
||||
}
|
||||
cipher.login = nextType === 1 ? (incomingLogin.present ? (incomingLogin.value ?? null) : (existingCipher.login ?? null)) : null;
|
||||
cipher.secureNote = nextType === 2 ? (incomingSecureNote.present ? (incomingSecureNote.value ?? null) : (existingCipher.secureNote ?? null)) : null;
|
||||
cipher.card = nextType === 3 ? (incomingCard.present ? (incomingCard.value ?? null) : (existingCipher.card ?? null)) : null;
|
||||
cipher.identity = nextType === 4 ? (incomingIdentity.present ? (incomingIdentity.value ?? null) : (existingCipher.identity ?? null)) : null;
|
||||
cipher.sshKey = nextType === 5 ? (incomingSshKey.present ? (incomingSshKey.value ?? null) : (existingCipher.sshKey ?? null)) : null;
|
||||
if (incomingPasswordHistory.present) {
|
||||
cipher.passwordHistory = incomingPasswordHistory.value ?? null;
|
||||
}
|
||||
|
||||
// Custom fields deletion compatibility:
|
||||
// - 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.updateRevisionDate(userId);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||
|
||||
return jsonResponse(cipherToResponse(cipher));
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, attachments)
|
||||
);
|
||||
}
|
||||
|
||||
// DELETE /api/ciphers/:id
|
||||
export async function handleDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
@@ -196,15 +580,43 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
|
||||
// Soft delete
|
||||
cipher.deletedAt = new Date().toISOString();
|
||||
cipher.updatedAt = cipher.deletedAt;
|
||||
syncCipherComputedAliases(cipher);
|
||||
await storage.saveCipher(cipher);
|
||||
await storage.updateRevisionDate(userId);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
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);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
return handleDeleteCipher(request, env, userId, id);
|
||||
}
|
||||
|
||||
// DELETE /api/ciphers/:id (permanent)
|
||||
export async function handlePermanentDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
@@ -215,14 +627,15 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
|
||||
await deleteAllAttachmentsForCipher(env, id);
|
||||
|
||||
await storage.deleteCipher(id, userId);
|
||||
await storage.updateRevisionDate(userId);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
// PUT /api/ciphers/:id/restore
|
||||
export async function handleRestoreCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
@@ -231,15 +644,19 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
|
||||
|
||||
cipher.deletedAt = null;
|
||||
cipher.updatedAt = new Date().toISOString();
|
||||
syncCipherComputedAliases(cipher);
|
||||
await storage.saveCipher(cipher);
|
||||
await storage.updateRevisionDate(userId);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(cipherToResponse(cipher));
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, [])
|
||||
);
|
||||
}
|
||||
|
||||
// PUT /api/ciphers/:id/partial - Update only favorite/folderId
|
||||
export async function handlePartialUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
@@ -254,22 +671,31 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
|
||||
}
|
||||
|
||||
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) {
|
||||
cipher.favorite = body.favorite;
|
||||
}
|
||||
cipher.updatedAt = new Date().toISOString();
|
||||
syncCipherComputedAliases(cipher);
|
||||
|
||||
await storage.saveCipher(cipher);
|
||||
await storage.updateRevisionDate(userId);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(cipherToResponse(cipher));
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, [])
|
||||
);
|
||||
}
|
||||
|
||||
// POST/PUT /api/ciphers/move - Bulk move to folder
|
||||
export async function handleBulkMoveCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
let body: { ids?: string[]; folderId?: string | null };
|
||||
try {
|
||||
@@ -282,7 +708,216 @@ export async function handleBulkMoveCiphers(request: Request, env: Env, userId:
|
||||
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) {
|
||||
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);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, attachments)
|
||||
);
|
||||
}
|
||||
|
||||
// PUT/POST /api/ciphers/:id/unarchive
|
||||
export async function handleUnarchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
cipher.archivedAt = null;
|
||||
cipher.updatedAt = new Date().toISOString();
|
||||
normalizeCipherForStorage(cipher);
|
||||
await storage.saveCipher(cipher);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, attachments)
|
||||
);
|
||||
}
|
||||
|
||||
// PUT/POST /api/ciphers/archive
|
||||
export async function handleBulkArchiveCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
let body: { ids?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const ids = parseCipherIdList(body);
|
||||
if (!ids) {
|
||||
return errorResponse('ids array is required', 400);
|
||||
}
|
||||
|
||||
const revisionDate = await storage.bulkArchiveCiphers(ids, userId);
|
||||
if (revisionDate) {
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
}
|
||||
|
||||
return buildCipherListResponse(request, storage, userId, ids);
|
||||
}
|
||||
|
||||
// PUT/POST /api/ciphers/unarchive
|
||||
export async function handleBulkUnarchiveCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
let body: { ids?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const ids = parseCipherIdList(body);
|
||||
if (!ids) {
|
||||
return errorResponse('ids array is required', 400);
|
||||
}
|
||||
|
||||
const revisionDate = await storage.bulkUnarchiveCiphers(ids, userId);
|
||||
if (revisionDate) {
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
}
|
||||
|
||||
return buildCipherListResponse(request, storage, userId, ids);
|
||||
}
|
||||
|
||||
// POST /api/ciphers/delete - Bulk soft delete
|
||||
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) {
|
||||
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) {
|
||||
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 });
|
||||
}
|
||||
|
||||
const ownedCiphers = await storage.getCiphersByIds(ids, userId);
|
||||
const ownedIds = ownedCiphers.map((cipher) => cipher.id);
|
||||
if (!ownedIds.length) {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
await deleteAllAttachmentsForCiphers(env, ownedIds);
|
||||
|
||||
const revisionDate = await storage.bulkDeleteCiphers(ownedIds, userId);
|
||||
if (revisionDate) {
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
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 displayName = String(device.deviceNote || '').trim() || device.name;
|
||||
const response = {
|
||||
Id: device.deviceIdentifier,
|
||||
id: device.deviceIdentifier,
|
||||
UserId: device.userId,
|
||||
userId: device.userId,
|
||||
Name: displayName,
|
||||
name: displayName,
|
||||
SystemName: device.name,
|
||||
systemName: device.name,
|
||||
DeviceNote: device.deviceNote,
|
||||
deviceNote: device.deviceNote,
|
||||
Identifier: device.deviceIdentifier,
|
||||
identifier: device.deviceIdentifier,
|
||||
Type: device.type,
|
||||
type: device.type,
|
||||
CreationDate: device.createdAt,
|
||||
creationDate: device.createdAt,
|
||||
RevisionDate: device.updatedAt,
|
||||
revisionDate: device.updatedAt,
|
||||
LastSeenAt: device.lastSeenAt,
|
||||
lastSeenAt: device.lastSeenAt,
|
||||
HasStoredDevice: true,
|
||||
hasStoredDevice: true,
|
||||
IsTrusted: isTrustedDevice(device),
|
||||
isTrusted: isTrustedDevice(device),
|
||||
EncryptedUserKey: device.encryptedUserKey,
|
||||
encryptedUserKey: device.encryptedUserKey,
|
||||
EncryptedPublicKey: device.encryptedPublicKey,
|
||||
encryptedPublicKey: device.encryptedPublicKey,
|
||||
DevicePendingAuthRequest: buildDevicePendingAuthRequest(device.devicePendingAuthRequest),
|
||||
devicePendingAuthRequest: buildDevicePendingAuthRequest(device.devicePendingAuthRequest),
|
||||
object: 'device',
|
||||
};
|
||||
return response as DeviceResponse;
|
||||
}
|
||||
|
||||
function buildProtectedDeviceResponse(device: Device): ProtectedDeviceWireResponse {
|
||||
const response = {
|
||||
Id: device.deviceIdentifier,
|
||||
id: device.deviceIdentifier,
|
||||
Name: String(device.deviceNote || '').trim() || device.name,
|
||||
name: String(device.deviceNote || '').trim() || device.name,
|
||||
SystemName: device.name,
|
||||
systemName: device.name,
|
||||
DeviceNote: device.deviceNote,
|
||||
deviceNote: device.deviceNote,
|
||||
Identifier: device.deviceIdentifier,
|
||||
identifier: device.deviceIdentifier,
|
||||
Type: device.type,
|
||||
type: device.type,
|
||||
CreationDate: device.createdAt,
|
||||
creationDate: device.createdAt,
|
||||
EncryptedUserKey: device.encryptedUserKey,
|
||||
encryptedUserKey: device.encryptedUserKey,
|
||||
EncryptedPublicKey: device.encryptedPublicKey,
|
||||
encryptedPublicKey: device.encryptedPublicKey,
|
||||
object: 'protectedDevice',
|
||||
};
|
||||
return response as ProtectedDeviceWireResponse;
|
||||
}
|
||||
|
||||
function parseKeysBody(body: any, fallback?: Device): {
|
||||
encryptedUserKey?: string | null;
|
||||
encryptedPublicKey?: string | null;
|
||||
encryptedPrivateKey?: string | null;
|
||||
} {
|
||||
return {
|
||||
encryptedUserKey:
|
||||
Object.prototype.hasOwnProperty.call(body || {}, 'encryptedUserKey')
|
||||
? body?.encryptedUserKey ?? null
|
||||
: fallback?.encryptedUserKey ?? null,
|
||||
encryptedPublicKey:
|
||||
Object.prototype.hasOwnProperty.call(body || {}, 'encryptedPublicKey')
|
||||
? body?.encryptedPublicKey ?? null
|
||||
: fallback?.encryptedPublicKey ?? null,
|
||||
encryptedPrivateKey:
|
||||
Object.prototype.hasOwnProperty.call(body || {}, 'encryptedPrivateKey')
|
||||
? body?.encryptedPrivateKey ?? null
|
||||
: fallback?.encryptedPrivateKey ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function readJsonBody(request: Request): Promise<any> {
|
||||
try {
|
||||
return await request.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseDeviceName(value: unknown): string {
|
||||
return String(value || '').trim().slice(0, 128);
|
||||
}
|
||||
|
||||
// GET /api/devices/knowndevice
|
||||
// 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,
|
||||
deviceNote: null,
|
||||
lastSeenAt: null,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
};
|
||||
data.push({
|
||||
...buildDeviceResponse(placeholderDevice),
|
||||
isTrusted: true,
|
||||
hasStoredDevice: false,
|
||||
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) {
|
||||
notifyUserLogout(env, userId, normalized);
|
||||
}
|
||||
return jsonResponse({ success: deleted });
|
||||
}
|
||||
|
||||
// PUT /api/devices/:deviceIdentifier/name
|
||||
export async function handleUpdateDeviceName(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
deviceIdentifier: string
|
||||
): Promise<Response> {
|
||||
const normalized = String(deviceIdentifier || '').trim();
|
||||
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
const name = parseDeviceName(body?.name);
|
||||
if (!name) return errorResponse('Device name is required', 400);
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const updated = await storage.updateDeviceName(userId, normalized, name);
|
||||
if (!updated) return errorResponse('Device not found', 404);
|
||||
|
||||
const device = await storage.getDevice(userId, normalized);
|
||||
if (!device) return errorResponse('Device not found', 404);
|
||||
return jsonResponse(buildDeviceResponse(device));
|
||||
}
|
||||
|
||||
// DELETE /api/devices
|
||||
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);
|
||||
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) {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { Env } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import {
|
||||
buildDomainsResponse,
|
||||
customRulesToActiveEquivalentDomains,
|
||||
normalizeCustomEquivalentDomains,
|
||||
normalizeEquivalentDomains,
|
||||
normalizeExcludedGlobalTypes,
|
||||
} from '../services/domain-rules';
|
||||
import { errorResponse, jsonResponse } from '../utils/response';
|
||||
|
||||
// CONTRACT:
|
||||
// This route accepts both camelCase and PascalCase Bitwarden-compatible payloads.
|
||||
// It stores custom rules, then derives equivalentDomains from the non-excluded
|
||||
// custom rules. Keep this behavior aligned with backup import/export and
|
||||
// src/services/storage-domain-rules-repo.ts.
|
||||
function firstPresent(payload: Record<string, unknown>, keys: string[]): unknown {
|
||||
for (const key of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(payload, key)) return payload[key];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function readPayload(request: Request): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const parsed = await request.json();
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? parsed as Record<string, unknown>
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGetDomains(env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const settings = await storage.getUserDomainSettings(userId);
|
||||
return jsonResponse(buildDomainsResponse(
|
||||
settings.equivalentDomains,
|
||||
settings.customEquivalentDomains,
|
||||
settings.excludedGlobalEquivalentDomains
|
||||
));
|
||||
}
|
||||
|
||||
export async function handleUpdateDomains(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const payload = await readPayload(request);
|
||||
const current = await storage.getUserDomainSettings(userId);
|
||||
const equivalentDomainsRaw = firstPresent(payload, [
|
||||
'equivalentDomains',
|
||||
'EquivalentDomains',
|
||||
]);
|
||||
const customEquivalentDomainsRaw = firstPresent(payload, [
|
||||
'customEquivalentDomains',
|
||||
'CustomEquivalentDomains',
|
||||
]);
|
||||
const excludedGlobalEquivalentDomainsRaw = firstPresent(payload, [
|
||||
'excludedGlobalEquivalentDomains',
|
||||
'ExcludedGlobalEquivalentDomains',
|
||||
// Some older compatible clients send the excluded type list under this key.
|
||||
'globalEquivalentDomains',
|
||||
'GlobalEquivalentDomains',
|
||||
]);
|
||||
const customEquivalentDomains = customEquivalentDomainsRaw === undefined
|
||||
? (equivalentDomainsRaw === undefined
|
||||
? current.customEquivalentDomains
|
||||
: normalizeCustomEquivalentDomains(normalizeEquivalentDomains(equivalentDomainsRaw)))
|
||||
: normalizeCustomEquivalentDomains(customEquivalentDomainsRaw);
|
||||
const equivalentDomains = customRulesToActiveEquivalentDomains(customEquivalentDomains);
|
||||
const excludedGlobalEquivalentDomains = excludedGlobalEquivalentDomainsRaw === undefined
|
||||
? current.excludedGlobalEquivalentDomains
|
||||
: normalizeExcludedGlobalTypes(excludedGlobalEquivalentDomainsRaw);
|
||||
|
||||
await storage.saveUserDomainSettings(userId, equivalentDomains, customEquivalentDomains, excludedGlobalEquivalentDomains);
|
||||
|
||||
const settings = await storage.getUserDomainSettings(userId);
|
||||
if (!settings) {
|
||||
return errorResponse('Domain settings unavailable', 500);
|
||||
}
|
||||
return jsonResponse(buildDomainsResponse(
|
||||
settings.equivalentDomains,
|
||||
settings.customEquivalentDomains,
|
||||
settings.excludedGlobalEquivalentDomains
|
||||
));
|
||||
}
|
||||
@@ -1,7 +1,19 @@
|
||||
import { Env, Folder, FolderResponse } 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 { generateUUID } from '../utils/uuid';
|
||||
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
||||
|
||||
function notifyVaultSyncForRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
revisionDate: string
|
||||
): void {
|
||||
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
}
|
||||
|
||||
// Convert internal folder to API response format
|
||||
function folderToResponse(folder: Folder): FolderResponse {
|
||||
@@ -9,25 +21,38 @@ function folderToResponse(folder: Folder): FolderResponse {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
revisionDate: folder.updatedAt,
|
||||
creationDate: folder.createdAt,
|
||||
object: 'folder',
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/folders
|
||||
export async function handleGetFolders(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const folders = await storage.getAllFolders(userId);
|
||||
const storage = new StorageService(env.DB);
|
||||
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({
|
||||
data: folders.map(folderToResponse),
|
||||
object: 'list',
|
||||
continuationToken: null,
|
||||
continuationToken: continuationToken,
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/folders/:id
|
||||
export async function handleGetFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const storage = new StorageService(env.DB);
|
||||
const folder = await storage.getFolder(id);
|
||||
|
||||
if (!folder || folder.userId !== userId) {
|
||||
@@ -39,7 +64,7 @@ export async function handleGetFolder(request: Request, env: Env, userId: string
|
||||
|
||||
// POST /api/folders
|
||||
export async function handleCreateFolder(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
let body: { name?: string };
|
||||
try {
|
||||
@@ -62,13 +87,15 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
|
||||
};
|
||||
|
||||
await storage.saveFolder(folder);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(folderToResponse(folder), 200);
|
||||
}
|
||||
|
||||
// PUT /api/folders/:id
|
||||
export async function handleUpdateFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const storage = new StorageService(env.DB);
|
||||
const folder = await storage.getFolder(id);
|
||||
|
||||
if (!folder || folder.userId !== userId) {
|
||||
@@ -88,20 +115,49 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
|
||||
folder.updatedAt = new Date().toISOString();
|
||||
|
||||
await storage.saveFolder(folder);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(folderToResponse(folder));
|
||||
}
|
||||
|
||||
// DELETE /api/folders/:id
|
||||
export async function handleDeleteFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const storage = new StorageService(env.DB);
|
||||
const folder = await storage.getFolder(id);
|
||||
|
||||
if (!folder || folder.userId !== userId) {
|
||||
return errorResponse('Folder not found', 404);
|
||||
}
|
||||
|
||||
await storage.clearFolderFromCiphers(userId, id);
|
||||
await storage.deleteFolder(id, userId);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
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) {
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
@@ -1,43 +1,230 @@
|
||||
import { Env, TokenResponse } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { AuthService } from '../services/auth';
|
||||
import { RateLimitService } from '../services/ratelimit';
|
||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||
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 constantTimeEquals(a: string, b: string): boolean {
|
||||
const encA = new TextEncoder().encode(a);
|
||||
const encB = new TextEncoder().encode(b);
|
||||
if (encA.length !== encB.length) return false;
|
||||
|
||||
let diff = 0;
|
||||
for (let i = 0; i < encA.length; i++) {
|
||||
diff |= encA[i] ^ encB[i];
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string {
|
||||
const isHttps = new URL(request.url).protocol === 'https:';
|
||||
const parts = [
|
||||
`${WEB_REFRESH_COOKIE}=${encodeURIComponent(refreshToken)}`,
|
||||
'Path=/identity/connect',
|
||||
'HttpOnly',
|
||||
'SameSite=Strict',
|
||||
`Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
|
||||
];
|
||||
if (isHttps) parts.push('Secure');
|
||||
return parts.join('; ');
|
||||
}
|
||||
|
||||
function buildClearedRefreshCookie(request: Request): string {
|
||||
return buildRefreshCookie(request, '', 0);
|
||||
}
|
||||
|
||||
function withWebRefreshCookie(request: Request, response: Response, refreshToken: string | null): Response {
|
||||
const headers = new Headers(response.headers);
|
||||
headers.append(
|
||||
'Set-Cookie',
|
||||
refreshToken
|
||||
? buildRefreshCookie(request, refreshToken, Math.floor(LIMITS.auth.refreshTokenTtlMs / 1000))
|
||||
: buildClearedRefreshCookie(request)
|
||||
);
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
function buildPreloginResponse(
|
||||
email: string,
|
||||
kdfType: number,
|
||||
kdfIterations: number,
|
||||
kdfMemory: number | null,
|
||||
kdfParallelism: number | null
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
kdf: kdfType,
|
||||
kdfIterations,
|
||||
kdfMemory,
|
||||
kdfParallelism,
|
||||
KdfSettings: {
|
||||
KdfType: kdfType,
|
||||
Iterations: kdfIterations,
|
||||
Memory: kdfMemory,
|
||||
Parallelism: kdfParallelism,
|
||||
},
|
||||
Salt: email.toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
function twoFactorRequiredResponse(message: string = 'Two factor required.', includeRecoveryCode: boolean = false): Response {
|
||||
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
|
||||
export async function handleToken(request: Request, env: Env): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const storage = new StorageService(env.DB);
|
||||
const auth = new AuthService(env);
|
||||
const rateLimit = new RateLimitService(env.VAULT);
|
||||
const rateLimit = new RateLimitService(env.DB);
|
||||
|
||||
let body: Record<string, string>;
|
||||
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();
|
||||
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 identityErrorResponse('Invalid request payload', 'invalid_request', 400);
|
||||
}
|
||||
|
||||
const grantType = body.grant_type;
|
||||
const clientIdentifier = getClientIdentifier(request);
|
||||
if (!clientIdentifier) {
|
||||
return identityErrorResponse('Client IP is required', 'invalid_request', 403);
|
||||
}
|
||||
|
||||
if (grantType === 'password') {
|
||||
// Login with password
|
||||
const email = body.username?.toLowerCase();
|
||||
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) {
|
||||
return errorResponse('Email and password are required', 400);
|
||||
// Bitwarden clients expect OAuth-style error fields.
|
||||
return identityErrorResponse('Email and password are required', 'invalid_request', 400);
|
||||
}
|
||||
|
||||
const user = await storage.getUser(email);
|
||||
if (!user) {
|
||||
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);
|
||||
// Check login lockout before user lookup to reduce user-enumeration signal
|
||||
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
|
||||
if (!loginCheck.allowed) {
|
||||
return identityErrorResponse(
|
||||
`Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`,
|
||||
@@ -46,117 +233,371 @@ 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) {
|
||||
// Record failed login attempt
|
||||
const result = await rateLimit.recordFailedLogin(email);
|
||||
if (result.locked) {
|
||||
return identityErrorResponse(
|
||||
`Too many failed login attempts. Account locked for ${Math.ceil(result.retryAfterSeconds! / 60)} minutes.`,
|
||||
'TooManyRequests',
|
||||
429
|
||||
return recordFailedLoginAndBuildResponse(
|
||||
rateLimit,
|
||||
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
|
||||
);
|
||||
}
|
||||
return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
// 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(email);
|
||||
await rateLimit.clearLoginAttempts(loginIdentifier);
|
||||
|
||||
const accessToken = await auth.generateAccessToken(user);
|
||||
const refreshToken = await auth.generateRefreshToken(user.id);
|
||||
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: 7200,
|
||||
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
||||
token_type: 'Bearer',
|
||||
refresh_token: refreshToken,
|
||||
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
|
||||
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
||||
Key: user.key,
|
||||
PrivateKey: user.privateKey,
|
||||
AccountKeys: 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: {
|
||||
HasMasterPassword: true,
|
||||
Object: 'userDecryptionOptions',
|
||||
MasterPasswordUnlock: {
|
||||
Kdf: {
|
||||
KdfType: user.kdfType,
|
||||
Iterations: user.kdfIterations,
|
||||
Memory: user.kdfMemory || null,
|
||||
Parallelism: user.kdfParallelism || null,
|
||||
},
|
||||
MasterKeyEncryptedUserKey: user.key,
|
||||
Salt: email, // email is already lowercased above
|
||||
},
|
||||
},
|
||||
UserDecryptionOptions: userDecryptionOptions,
|
||||
userDecryptionOptions: userDecryptionOptions,
|
||||
};
|
||||
|
||||
return jsonResponse(response);
|
||||
const baseResponse = jsonResponse(response);
|
||||
return shouldUseWebSession(request)
|
||||
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
||||
: baseResponse;
|
||||
|
||||
} else if (grantType === 'client_credentials') {
|
||||
// Login with client credentials
|
||||
const clientId = body.client_id;
|
||||
const clientSecret = body.client_secret;
|
||||
const scope = body.scope;
|
||||
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
||||
|
||||
const loginIdentifier = `${clientIdentifier}:${clientId}`;
|
||||
const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope);
|
||||
if (!parmValid) {
|
||||
return identityErrorResponse('Parameter error', 'invalid_request', 400);
|
||||
}
|
||||
|
||||
// Check login lockout before user lookup to reduce user-enumeration signal
|
||||
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
|
||||
if (!loginCheck.allowed) {
|
||||
return identityErrorResponse(
|
||||
`Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`,
|
||||
'TooManyRequests',
|
||||
429
|
||||
);
|
||||
}
|
||||
|
||||
const uid = clientId.slice(5);
|
||||
const user = await storage.getUserById(uid);
|
||||
if (!user) {
|
||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400);
|
||||
}
|
||||
if (user.status !== 'active') {
|
||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) {
|
||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
// Persist device only after successful client credential verification.
|
||||
const deviceSession =
|
||||
deviceInfo.deviceIdentifier
|
||||
? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() }
|
||||
: null;
|
||||
if (deviceSession) {
|
||||
await storage.upsertDevice(
|
||||
user.id,
|
||||
deviceSession.identifier,
|
||||
deviceInfo.deviceName,
|
||||
deviceInfo.deviceType,
|
||||
deviceSession.sessionStamp
|
||||
);
|
||||
}
|
||||
|
||||
// Successful login - clear failed attempts
|
||||
await rateLimit.clearLoginAttempts(loginIdentifier);
|
||||
|
||||
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
||||
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||
|
||||
const response: TokenResponse = {
|
||||
access_token: accessToken,
|
||||
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
||||
token_type: 'Bearer',
|
||||
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
|
||||
Key: user.key,
|
||||
PrivateKey: user.privateKey,
|
||||
AccountKeys: accountKeys,
|
||||
accountKeys: accountKeys,
|
||||
Kdf: user.kdfType,
|
||||
KdfIterations: user.kdfIterations,
|
||||
KdfMemory: user.kdfMemory,
|
||||
KdfParallelism: user.kdfParallelism,
|
||||
ForcePasswordReset: false,
|
||||
ResetMasterPassword: false,
|
||||
MasterPasswordPolicy: {
|
||||
Object: 'masterPasswordPolicy',
|
||||
},
|
||||
ApiUseKeyConnector: false,
|
||||
scope: 'api offline_access',
|
||||
unofficialServer: true,
|
||||
UserDecryptionOptions: userDecryptionOptions,
|
||||
userDecryptionOptions: userDecryptionOptions,
|
||||
};
|
||||
|
||||
const baseResponse = jsonResponse(response);
|
||||
return shouldUseWebSession(request)
|
||||
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
||||
: baseResponse;
|
||||
|
||||
} else if (grantType === 'send_access') {
|
||||
const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
|
||||
if (!sendAccessLimit.allowed) {
|
||||
return identityErrorResponse(
|
||||
`Rate limit exceeded. Try again in ${sendAccessLimit.retryAfterSeconds} seconds.`,
|
||||
'TooManyRequests',
|
||||
429
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
const passwordHashB64 = String(
|
||||
body.password_hash_b64 || body.passwordHashB64 || body.passwordHash || body.password_hash || ''
|
||||
).trim() || null;
|
||||
const password = String(body.password || '').trim() || null;
|
||||
|
||||
const result = await issueSendAccessToken(
|
||||
env,
|
||||
sendId,
|
||||
passwordHashB64,
|
||||
password,
|
||||
rateLimit,
|
||||
`${clientIdentifier}:send-password`
|
||||
);
|
||||
if ('error' in result) {
|
||||
return result.error;
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
access_token: result.token,
|
||||
expires_in: LIMITS.auth.sendAccessTokenTtlSeconds,
|
||||
token_type: 'Bearer',
|
||||
scope: 'api.send',
|
||||
unofficialServer: true,
|
||||
});
|
||||
} 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
|
||||
const refreshToken = body.refresh_token;
|
||||
const refreshToken = String(body.refresh_token || '').trim() || (
|
||||
shouldUseWebSession(request)
|
||||
? parseCookieValue(request, WEB_REFRESH_COOKIE)
|
||||
: null
|
||||
);
|
||||
if (!refreshToken) {
|
||||
return errorResponse('Refresh token is required', 400);
|
||||
return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
|
||||
}
|
||||
|
||||
const result = await auth.refreshAccessToken(refreshToken);
|
||||
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)
|
||||
await storage.deleteRefreshToken(refreshToken);
|
||||
// Keep a short overlap window for old refresh token to absorb
|
||||
// concurrent refresh requests from multiple client contexts.
|
||||
await storage.constrainRefreshTokenExpiry(
|
||||
refreshToken,
|
||||
Date.now() + LIMITS.auth.refreshTokenOverlapGraceMs
|
||||
);
|
||||
|
||||
const { accessToken, user } = result;
|
||||
const newRefreshToken = await auth.generateRefreshToken(user.id);
|
||||
const { accessToken, user, device } = result;
|
||||
if (device?.identifier) {
|
||||
await storage.touchDeviceLastSeen(user.id, device.identifier);
|
||||
}
|
||||
const newRefreshToken = await auth.generateRefreshToken(user.id, device);
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||
|
||||
const response: TokenResponse = {
|
||||
access_token: accessToken,
|
||||
expires_in: 7200,
|
||||
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
||||
token_type: 'Bearer',
|
||||
refresh_token: newRefreshToken,
|
||||
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: newRefreshToken }),
|
||||
Key: user.key,
|
||||
PrivateKey: user.privateKey,
|
||||
AccountKeys: 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: {
|
||||
HasMasterPassword: true,
|
||||
Object: 'userDecryptionOptions',
|
||||
MasterPasswordUnlock: {
|
||||
Kdf: {
|
||||
KdfType: user.kdfType,
|
||||
Iterations: user.kdfIterations,
|
||||
Memory: user.kdfMemory || null,
|
||||
Parallelism: user.kdfParallelism || null,
|
||||
},
|
||||
MasterKeyEncryptedUserKey: user.key,
|
||||
Salt: user.email.toLowerCase(),
|
||||
},
|
||||
},
|
||||
UserDecryptionOptions: userDecryptionOptions,
|
||||
userDecryptionOptions: userDecryptionOptions,
|
||||
};
|
||||
|
||||
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
|
||||
export async function handlePrelogin(request: Request, env: Env): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
let body: { email?: string };
|
||||
try {
|
||||
@@ -174,14 +615,58 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
|
||||
|
||||
// Return default KDF settings even if user doesn't exist (to prevent user enumeration)
|
||||
const kdfType = user?.kdfType ?? 0;
|
||||
const kdfIterations = user?.kdfIterations ?? 600000;
|
||||
const kdfMemory = user?.kdfMemory;
|
||||
const kdfParallelism = user?.kdfParallelism;
|
||||
const kdfIterations = user?.kdfIterations ?? LIMITS.auth.defaultKdfIterations;
|
||||
// Use ?? null so non-existent users return null (not undefined/omitted) for these fields,
|
||||
// matching the response shape of real PBKDF2 users and reducing enumeration signal.
|
||||
const kdfMemory = user?.kdfMemory ?? null;
|
||||
const kdfParallelism = user?.kdfParallelism ?? null;
|
||||
|
||||
return jsonResponse({
|
||||
kdf: kdfType,
|
||||
kdfIterations: kdfIterations,
|
||||
kdfMemory: kdfMemory,
|
||||
kdfParallelism: kdfParallelism,
|
||||
});
|
||||
return jsonResponse(buildPreloginResponse(email, kdfType, kdfIterations, kdfMemory, kdfParallelism));
|
||||
}
|
||||
|
||||
// POST /identity/connect/revocation
|
||||
// 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;
|
||||
}
|
||||
|
||||
export function checkClientCredentialsParam(clientId: string, clientSecret: string, scope: string): boolean {
|
||||
if (scope !== 'api') {
|
||||
return false;
|
||||
}
|
||||
if (!clientId.startsWith('user.')) {
|
||||
return false;
|
||||
}
|
||||
if (!clientSecret) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
import { Env, Cipher, Folder, CipherType } from '../types';
|
||||
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
||||
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 { LIMITS } from '../config/limits';
|
||||
import { normalizeCipherLoginForStorage, normalizeCipherSshKeyForCompatibility } from './ciphers';
|
||||
|
||||
// Bitwarden client import request format
|
||||
interface CiphersImportRequest {
|
||||
ciphers: Array<{
|
||||
id?: string | null;
|
||||
type: number;
|
||||
name: string;
|
||||
name?: string | null;
|
||||
notes?: string | null;
|
||||
favorite?: boolean;
|
||||
reprompt?: number;
|
||||
sshKey?: any | null;
|
||||
key?: string | null;
|
||||
login?: {
|
||||
uris?: Array<{ uri: string | null; match?: number | null }> | null;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
totp?: string | null;
|
||||
autofillOnPageLoad?: boolean | null;
|
||||
uri?: string | null;
|
||||
passwordRevisionDate?: string | null;
|
||||
[key: string]: any;
|
||||
} | null;
|
||||
card?: {
|
||||
cardholderName?: string | null;
|
||||
@@ -56,6 +67,7 @@ interface CiphersImportRequest {
|
||||
password: string;
|
||||
lastUsedDate: string;
|
||||
}> | null;
|
||||
[key: string]: any;
|
||||
}>;
|
||||
folders: Array<{
|
||||
name: string;
|
||||
@@ -66,9 +78,32 @@ interface CiphersImportRequest {
|
||||
}>;
|
||||
}
|
||||
|
||||
function bindNull(v: any): any {
|
||||
return v === undefined ? null : v;
|
||||
}
|
||||
|
||||
function readAliasedImportProp<T = unknown>(source: any, aliases: string[]): T | undefined {
|
||||
if (!source || typeof source !== 'object') return undefined;
|
||||
for (const key of aliases) {
|
||||
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
return source[key] as T;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> {
|
||||
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
|
||||
export async function handleCiphersImport(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const storage = new StorageService(env.DB);
|
||||
const url = new URL(request.url);
|
||||
const returnCipherMap = url.searchParams.get('returnCipherMap') === '1';
|
||||
|
||||
let importData: CiphersImportRequest;
|
||||
try {
|
||||
@@ -81,10 +116,16 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
const ciphers = importData.ciphers || [];
|
||||
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 batchChunkSize = LIMITS.performance.bulkMoveChunkSize;
|
||||
|
||||
// Create folders and build index -> id mapping
|
||||
const folderIdMap = new Map<number, string>();
|
||||
const folderRows: Folder[] = [];
|
||||
|
||||
for (let i = 0; i < folders.length; i++) {
|
||||
const folderId = generateUUID();
|
||||
@@ -98,7 +139,19 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
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
|
||||
@@ -111,81 +164,139 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
}
|
||||
|
||||
// Create ciphers
|
||||
const cipherRows: Cipher[] = [];
|
||||
const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = [];
|
||||
for (let i = 0; i < ciphers.length; i++) {
|
||||
const c = ciphers[i];
|
||||
const folderId = cipherFolderMap.get(i) || null;
|
||||
const folderId = cipherFolderMap.get(i) || readAliasedImportProp<string | null>(c, ['folderId', 'FolderId']) || null;
|
||||
const sourceIdRaw = String(c?.id ?? '').trim();
|
||||
const sourceId = sourceIdRaw || null;
|
||||
const login = readAliasedImportProp<any | null>(c, ['login', 'Login']);
|
||||
const card = readAliasedImportProp<any | null>(c, ['card', 'Card']);
|
||||
const identity = readAliasedImportProp<any | null>(c, ['identity', 'Identity']);
|
||||
const secureNote = readAliasedImportProp<any | null>(c, ['secureNote', 'SecureNote']);
|
||||
const fields = readAliasedImportProp<any[] | null>(c, ['fields', 'Fields']);
|
||||
const passwordHistory = readAliasedImportProp<any[] | null>(c, ['passwordHistory', 'PasswordHistory']);
|
||||
const key = readAliasedImportProp<string | null>(c, ['key', 'Key']);
|
||||
|
||||
const cipher: Cipher = {
|
||||
...c,
|
||||
id: generateUUID(),
|
||||
userId: userId,
|
||||
type: c.type as CipherType,
|
||||
folderId: folderId,
|
||||
name: c.name || 'Untitled',
|
||||
notes: c.notes || null,
|
||||
favorite: c.favorite || false,
|
||||
login: c.login ? {
|
||||
username: c.login.username || null,
|
||||
password: c.login.password || null,
|
||||
uris: c.login.uris?.map(u => ({
|
||||
uri: u.uri || null,
|
||||
name: c.name ?? 'Untitled',
|
||||
notes: c.notes ?? null,
|
||||
favorite: c.favorite ?? false,
|
||||
login: login ? {
|
||||
...login,
|
||||
username: login.username ?? null,
|
||||
password: login.password ?? null,
|
||||
uris: login.uris?.map((u: any) => ({
|
||||
...u,
|
||||
uri: u.uri ?? null,
|
||||
uriChecksum: null,
|
||||
match: u.match ?? null,
|
||||
})) || null,
|
||||
totp: c.login.totp || null,
|
||||
autofillOnPageLoad: null,
|
||||
fido2Credentials: null,
|
||||
uri: null,
|
||||
passwordRevisionDate: null,
|
||||
totp: login.totp ?? null,
|
||||
autofillOnPageLoad: login.autofillOnPageLoad ?? null,
|
||||
fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
|
||||
uri: login.uri ?? null,
|
||||
passwordRevisionDate: login.passwordRevisionDate ?? null,
|
||||
} : null,
|
||||
card: c.card ? {
|
||||
cardholderName: c.card.cardholderName || null,
|
||||
brand: c.card.brand || null,
|
||||
number: c.card.number || null,
|
||||
expMonth: c.card.expMonth || null,
|
||||
expYear: c.card.expYear || null,
|
||||
code: c.card.code || null,
|
||||
card: card ? {
|
||||
...card,
|
||||
cardholderName: card.cardholderName ?? null,
|
||||
brand: card.brand ?? null,
|
||||
number: card.number ?? null,
|
||||
expMonth: card.expMonth ?? null,
|
||||
expYear: card.expYear ?? null,
|
||||
code: card.code ?? null,
|
||||
} : null,
|
||||
identity: c.identity ? {
|
||||
title: c.identity.title || null,
|
||||
firstName: c.identity.firstName || null,
|
||||
middleName: c.identity.middleName || null,
|
||||
lastName: c.identity.lastName || null,
|
||||
address1: c.identity.address1 || null,
|
||||
address2: c.identity.address2 || null,
|
||||
address3: c.identity.address3 || null,
|
||||
city: c.identity.city || null,
|
||||
state: c.identity.state || null,
|
||||
postalCode: c.identity.postalCode || null,
|
||||
country: c.identity.country || null,
|
||||
company: c.identity.company || null,
|
||||
email: c.identity.email || null,
|
||||
phone: c.identity.phone || null,
|
||||
ssn: c.identity.ssn || null,
|
||||
username: c.identity.username || null,
|
||||
passportNumber: c.identity.passportNumber || null,
|
||||
licenseNumber: c.identity.licenseNumber || null,
|
||||
identity: identity ? {
|
||||
...identity,
|
||||
title: identity.title ?? null,
|
||||
firstName: identity.firstName ?? null,
|
||||
middleName: identity.middleName ?? null,
|
||||
lastName: identity.lastName ?? null,
|
||||
address1: identity.address1 ?? null,
|
||||
address2: identity.address2 ?? null,
|
||||
address3: identity.address3 ?? null,
|
||||
city: identity.city ?? null,
|
||||
state: identity.state ?? null,
|
||||
postalCode: identity.postalCode ?? null,
|
||||
country: identity.country ?? null,
|
||||
company: identity.company ?? null,
|
||||
email: identity.email ?? null,
|
||||
phone: identity.phone ?? null,
|
||||
ssn: identity.ssn ?? null,
|
||||
username: identity.username ?? null,
|
||||
passportNumber: identity.passportNumber ?? null,
|
||||
licenseNumber: identity.licenseNumber ?? null,
|
||||
} : null,
|
||||
secureNote: c.secureNote || null,
|
||||
fields: c.fields?.map(f => ({
|
||||
name: f.name || null,
|
||||
value: f.value || null,
|
||||
secureNote: secureNote ?? null,
|
||||
fields: fields?.map((f: any) => ({
|
||||
...f,
|
||||
name: f.name ?? null,
|
||||
value: f.value ?? null,
|
||||
type: f.type,
|
||||
linkedId: f.linkedId ?? null,
|
||||
})) || null,
|
||||
passwordHistory: c.passwordHistory || null,
|
||||
reprompt: c.reprompt || 0,
|
||||
sshKey: null,
|
||||
key: null,
|
||||
passwordHistory: passwordHistory ?? null,
|
||||
reprompt: c.reprompt ?? 0,
|
||||
sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null),
|
||||
key: key ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
archivedAt: 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
|
||||
await storage.updateRevisionDate(userId);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
|
||||
if (returnCipherMap) {
|
||||
return jsonResponse({
|
||||
object: 'import-result',
|
||||
cipherMap: cipherMapRows,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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) {
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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 function notifyVaultSyncForRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
revisionDate: string
|
||||
): void {
|
||||
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,795 +0,0 @@
|
||||
import { Env } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { jsonResponse, htmlResponse, errorResponse } from '../utils/response';
|
||||
|
||||
// Setup page HTML (single-file, no external assets)
|
||||
const setupPageHTML = `<!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);
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
}
|
||||
.hero {
|
||||
padding: 26px;
|
||||
border: 1px solid var(--border);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.10), rgba(255,255,255,0.06));
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.hero::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
background: radial-gradient(500px 240px at 20% 0%, rgba(100, 210, 255, 0.18), transparent 60%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.panel h2 {
|
||||
font-size: 16px;
|
||||
margin: 0 0 14px 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">部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端(个人使用)。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="height: 12px"></div>
|
||||
<div class="muted" id="t_intro" style="font-size: 13px; line-height: 1.7;">
|
||||
创建第一个账号完成初始化,然后用任意 Bitwarden 官方客户端登录。
|
||||
</div>
|
||||
|
||||
<div style="height: 14px"></div>
|
||||
<h2 id="t_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>
|
||||
const AUTHOR = { name: 'shuaiplus', website: 'https://shuai.plus', github: 'https://github.com/shuaiplus/nodewarden' };
|
||||
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 (personal use).',
|
||||
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');
|
||||
}
|
||||
|
||||
// Check if already registered
|
||||
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;
|
||||
}
|
||||
|
||||
// PBKDF2-SHA256 key derivation (compatible with Bitwarden)
|
||||
// password can be string or Uint8Array
|
||||
async function pbkdf2(password, salt, iterations, keyLen) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// Handle password as string or Uint8Array
|
||||
const passwordBytes = (password instanceof Uint8Array)
|
||||
? password
|
||||
: encoder.encode(password);
|
||||
|
||||
// Handle salt as string or Uint8Array
|
||||
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);
|
||||
}
|
||||
|
||||
// HKDF expand
|
||||
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;
|
||||
}
|
||||
|
||||
// Generate symmetric key
|
||||
function generateSymmetricKey() {
|
||||
return crypto.getRandomValues(new Uint8Array(64));
|
||||
}
|
||||
|
||||
// Encrypt with AES-256-CBC
|
||||
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);
|
||||
}
|
||||
|
||||
// HMAC-SHA256
|
||||
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);
|
||||
}
|
||||
|
||||
// Base64 encode
|
||||
function base64Encode(bytes) {
|
||||
return btoa(String.fromCharCode.apply(null, bytes));
|
||||
}
|
||||
|
||||
// Create encrypted string in Bitwarden format
|
||||
async function encryptToBitwardenFormat(data, encKey, macKey) {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(16));
|
||||
const encrypted = await encryptAesCbc(data, encKey, iv);
|
||||
|
||||
// Calculate MAC over IV + encrypted data
|
||||
const macData = new Uint8Array(iv.length + encrypted.length);
|
||||
macData.set(iv);
|
||||
macData.set(encrypted, iv.length);
|
||||
const mac = await hmacSha256(macKey, macData);
|
||||
|
||||
// Format: 2.{base64(iv)}|{base64(encrypted)}|{base64(mac)}
|
||||
return '2.' + base64Encode(iv) + '|' + base64Encode(encrypted) + '|' + base64Encode(mac);
|
||||
}
|
||||
|
||||
// Generate RSA key pair
|
||||
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']
|
||||
);
|
||||
|
||||
// Export public key
|
||||
const publicKeySpki = await crypto.subtle.exportKey('spki', keyPair.publicKey);
|
||||
const publicKeyB64 = base64Encode(new Uint8Array(publicKeySpki));
|
||||
|
||||
// Export private key
|
||||
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 {
|
||||
// Generate master key using PBKDF2 (Bitwarden default: 600000 iterations)
|
||||
const iterations = 600000;
|
||||
const masterKey = await pbkdf2(password, email, iterations, 32);
|
||||
|
||||
// Generate master password hash (for authentication)
|
||||
// Bitwarden: PBKDF2(masterKey as raw bytes, password, 1 iteration)
|
||||
const masterPasswordHash = await pbkdf2(masterKey, password, 1, 32);
|
||||
const masterPasswordHashB64 = base64Encode(masterPasswordHash);
|
||||
|
||||
// Stretch master key using HKDF
|
||||
const stretchedKey = await hkdfExpand(masterKey, 'enc', 32);
|
||||
const stretchedMacKey = await hkdfExpand(masterKey, 'mac', 32);
|
||||
|
||||
// Generate symmetric key (will be encrypted with stretched master key)
|
||||
const symmetricKey = generateSymmetricKey();
|
||||
|
||||
// Encrypt symmetric key with stretched master key
|
||||
const encryptedKey = await encryptToBitwardenFormat(symmetricKey, stretchedKey, stretchedMacKey);
|
||||
|
||||
// Generate RSA key pair
|
||||
const rsaKeys = await generateRsaKeyPair();
|
||||
|
||||
// Encrypt private key with symmetric key
|
||||
const encryptedPrivateKey = await encryptToBitwardenFormat(rsaKeys.privateKey, symmetricKey.slice(0, 32), symmetricKey.slice(32, 64));
|
||||
|
||||
// Register with server
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
// Check status on page load
|
||||
applyI18n();
|
||||
checkStatus();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// GET / - Setup page
|
||||
export async function handleSetupPage(request: Request, env: Env): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const disabled = await storage.isSetupDisabled();
|
||||
if (disabled) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
return htmlResponse(setupPageHTML);
|
||||
}
|
||||
|
||||
// GET /setup/status
|
||||
export async function handleSetupStatus(request: Request, env: Env): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
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.VAULT);
|
||||
const registered = await storage.isRegistered();
|
||||
if (!registered) {
|
||||
return errorResponse('Registration required', 403);
|
||||
}
|
||||
await storage.setSetupDisabled();
|
||||
return jsonResponse({ success: true });
|
||||
}
|
||||
@@ -1,34 +1,71 @@
|
||||
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse, Attachment } from '../types';
|
||||
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { errorResponse } from '../utils/response';
|
||||
import { cipherToResponse, isCipherResponseSyncCompatible } from './ciphers';
|
||||
import { sendToResponse } from './sends';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import {
|
||||
buildAccountKeys,
|
||||
buildUserDecryptionCompat,
|
||||
buildUserDecryptionOptions,
|
||||
} from '../utils/user-decryption';
|
||||
import { buildDomainsResponse } from '../services/domain-rules';
|
||||
|
||||
// Format attachments for API response
|
||||
function formatAttachments(attachments: Attachment[]): any[] | null {
|
||||
if (attachments.length === 0) return null;
|
||||
return attachments.map(a => ({
|
||||
id: a.id,
|
||||
fileName: a.fileName,
|
||||
size: Number(a.size) || 0, // Android expects Int, not String
|
||||
sizeName: a.sizeName,
|
||||
key: a.key,
|
||||
url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`, // Android requires non-null url!
|
||||
object: 'attachment',
|
||||
}));
|
||||
// CONTRACT:
|
||||
// /api/sync reuses cipherToResponse() as the single cipher response shaper.
|
||||
// Filtering invalid cipher responses here protects clients from stored rows that
|
||||
// would otherwise make official apps fail after an HTTP 200 sync.
|
||||
// Keep this aligned with src/handlers/ciphers.ts when adding new vault fields.
|
||||
function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean, excludeSends: boolean): Request {
|
||||
const url = new URL(request.url);
|
||||
const cacheUrl = new URL(
|
||||
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${excludeDomains ? '1' : '0'}/${excludeSends ? '1' : '0'}`,
|
||||
url.origin
|
||||
);
|
||||
return new Request(cacheUrl.toString(), { method: 'GET' });
|
||||
}
|
||||
|
||||
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
|
||||
export async function handleSync(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
|
||||
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 excludeSendsParam = url.searchParams.get('excludeSends');
|
||||
const excludeSends = excludeSendsParam !== null && /^(1|true|yes)$/i.test(excludeSendsParam);
|
||||
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) {
|
||||
return errorResponse('User not found', 404);
|
||||
}
|
||||
|
||||
const ciphers = await storage.getAllCiphers(userId);
|
||||
const folders = await storage.getAllFolders(userId);
|
||||
const revisionDate = await storage.getRevisionDate(userId);
|
||||
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains, excludeSends);
|
||||
const cachedResponse = await readSyncCache(cacheRequest);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
const [ciphers, folders, sends, attachmentsByCipher, domainSettings] = await Promise.all([
|
||||
storage.getAllCiphers(userId),
|
||||
storage.getAllFolders(userId),
|
||||
excludeSends ? Promise.resolve([]) : storage.getAllSends(userId),
|
||||
storage.getAttachmentsByUserId(userId),
|
||||
excludeDomains ? Promise.resolve(null) : storage.getUserDomainSettings(userId),
|
||||
]);
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||
|
||||
// Build profile response
|
||||
const profile: ProfileResponse = {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
@@ -37,12 +74,12 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
premium: true,
|
||||
premiumFromOrganization: false,
|
||||
usesKeyConnector: false,
|
||||
masterPasswordHint: null,
|
||||
masterPasswordHint: user.masterPasswordHint,
|
||||
culture: 'en-US',
|
||||
twoFactorEnabled: false,
|
||||
twoFactorEnabled: !!user.totpSecret,
|
||||
key: user.key,
|
||||
privateKey: user.privateKey,
|
||||
accountKeys: null,
|
||||
accountKeys,
|
||||
securityStamp: user.securityStamp || user.id,
|
||||
organizations: [],
|
||||
providers: [],
|
||||
@@ -50,82 +87,63 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
forcePasswordReset: false,
|
||||
avatarColor: null,
|
||||
creationDate: user.createdAt,
|
||||
verifyDevices: user.verifyDevices,
|
||||
object: 'profile',
|
||||
};
|
||||
|
||||
// Build cipher responses with attachments
|
||||
const cipherResponses: CipherResponse[] = [];
|
||||
for (const cipher of ciphers) {
|
||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||
cipherResponses.push({
|
||||
id: cipher.id,
|
||||
organizationId: null,
|
||||
folderId: cipher.folderId,
|
||||
type: Number(cipher.type) || 1,
|
||||
name: cipher.name,
|
||||
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,
|
||||
creationDate: cipher.createdAt,
|
||||
revisionDate: cipher.updatedAt,
|
||||
deletedDate: cipher.deletedAt,
|
||||
archivedDate: null,
|
||||
edit: true,
|
||||
viewPassword: true,
|
||||
permissions: {
|
||||
delete: true,
|
||||
restore: true,
|
||||
},
|
||||
object: 'cipher',
|
||||
collectionIds: [],
|
||||
attachments: formatAttachments(attachments),
|
||||
key: cipher.key,
|
||||
encryptedFor: null,
|
||||
const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []);
|
||||
if (isCipherResponseSyncCompatible(response)) {
|
||||
cipherResponses.push(response);
|
||||
}
|
||||
}
|
||||
|
||||
const folderResponses: FolderResponse[] = [];
|
||||
for (const folder of folders) {
|
||||
folderResponses.push({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
revisionDate: folder.updatedAt,
|
||||
creationDate: folder.createdAt,
|
||||
object: 'folder',
|
||||
});
|
||||
};
|
||||
|
||||
// Build folder responses
|
||||
const folderResponses: FolderResponse[] = folders.map(folder => ({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
revisionDate: folder.updatedAt,
|
||||
object: 'folder',
|
||||
}));
|
||||
}
|
||||
|
||||
const sendResponses = sends.map(sendToResponse);
|
||||
const syncResponse: SyncResponse = {
|
||||
profile: profile,
|
||||
profile,
|
||||
folders: folderResponses,
|
||||
collections: [],
|
||||
ciphers: cipherResponses,
|
||||
domains: {
|
||||
equivalentDomains: [],
|
||||
globalEquivalentDomains: [],
|
||||
object: 'domains',
|
||||
},
|
||||
domains: excludeDomains
|
||||
? null
|
||||
: buildDomainsResponse(
|
||||
domainSettings?.equivalentDomains || [],
|
||||
domainSettings?.customEquivalentDomains || [],
|
||||
domainSettings?.excludedGlobalEquivalentDomains || [],
|
||||
{ omitExcludedGlobals: true }
|
||||
),
|
||||
policies: [],
|
||||
sends: [],
|
||||
userDecryption: {
|
||||
masterPasswordUnlock: {
|
||||
salt: user.email,
|
||||
kdf: {
|
||||
kdfType: user.kdfType,
|
||||
iterations: user.kdfIterations,
|
||||
memory: user.kdfMemory || null,
|
||||
parallelism: user.kdfParallelism || null,
|
||||
},
|
||||
masterKeyEncryptedUserKey: user.key,
|
||||
},
|
||||
sends: sendResponses,
|
||||
UserDecryption: {
|
||||
MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock,
|
||||
TrustedDeviceOption: null,
|
||||
KeyConnectorOption: null,
|
||||
Object: 'userDecryption',
|
||||
},
|
||||
UserDecryptionOptions: userDecryptionOptions,
|
||||
userDecryption: buildUserDecryptionCompat(user) as SyncResponse['userDecryption'],
|
||||
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,18 +1,129 @@
|
||||
import { Env } from './types';
|
||||
import { NotificationsHub } from './durable/notifications-hub';
|
||||
import { handleRequest } from './router';
|
||||
import { StorageService } from './services/storage';
|
||||
import { applyCors, jsonResponse } from './utils/response';
|
||||
import { runScheduledBackupIfDue } from './handlers/backup';
|
||||
|
||||
let dbInitialized = false;
|
||||
let dbInitError: string | null = null;
|
||||
let dbInitPromise: Promise<void> | null = null;
|
||||
|
||||
function normalizeRequestUrl(request: Request): Request {
|
||||
const url = new URL(request.url);
|
||||
const normalizedPathname = url.pathname.length <= 1 ? url.pathname : url.pathname.replace(/\/+$/, '');
|
||||
if (normalizedPathname === url.pathname) return request;
|
||||
|
||||
url.pathname = normalizedPathname;
|
||||
return new Request(url.toString(), request);
|
||||
}
|
||||
|
||||
function isWorkerHandledPath(path: string): boolean {
|
||||
return (
|
||||
path.startsWith('/api/') ||
|
||||
path.startsWith('/identity/') ||
|
||||
path.startsWith('/icons/') ||
|
||||
path.startsWith('/notifications/') ||
|
||||
path.startsWith('/.well-known/') ||
|
||||
path === '/config' ||
|
||||
path === '/api/config' ||
|
||||
path === '/api/version'
|
||||
);
|
||||
}
|
||||
|
||||
function addSearchIndexHeaders(request: Request, response: Response): Response {
|
||||
const url = new URL(request.url);
|
||||
const contentType = String(response.headers.get('Content-Type') || '').toLowerCase();
|
||||
const shouldNoIndex =
|
||||
url.pathname === '/robots.txt' ||
|
||||
contentType.includes('text/html');
|
||||
|
||||
if (!shouldNoIndex) return response;
|
||||
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive, nosnippet');
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
async function maybeServeAsset(request: Request, env: Env): Promise<Response | null> {
|
||||
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;
|
||||
|
||||
const response = await env.ASSETS.fetch(request);
|
||||
return addSearchIndexHeaders(request, response);
|
||||
}
|
||||
|
||||
async function ensureDatabaseInitialized(env: Env): Promise<void> {
|
||||
if (dbInitialized) return;
|
||||
|
||||
if (!dbInitPromise) {
|
||||
dbInitPromise = (async () => {
|
||||
const storage = new StorageService(env.DB);
|
||||
await storage.initializeDatabase();
|
||||
dbInitialized = true;
|
||||
dbInitError = null;
|
||||
})()
|
||||
.catch((error: unknown) => {
|
||||
console.error('Failed to initialize database:', error);
|
||||
dbInitError = error instanceof Error ? error.message : 'Unknown database initialization error';
|
||||
})
|
||||
.finally(() => {
|
||||
dbInitPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
await dbInitPromise;
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
// Security check: JWT_SECRET must be set
|
||||
if (!env.JWT_SECRET) {
|
||||
return new Response('Server configuration error: JWT_SECRET is not set', { status: 500 });
|
||||
void ctx;
|
||||
const normalizedRequest = normalizeRequestUrl(request);
|
||||
const assetResponse = await maybeServeAsset(normalizedRequest, env);
|
||||
if (assetResponse) {
|
||||
return applyCors(normalizedRequest, assetResponse);
|
||||
}
|
||||
|
||||
// Security check: warn if JWT_SECRET is too weak
|
||||
if (env.JWT_SECRET.length < 32) {
|
||||
console.warn('[SECURITY WARNING] JWT_SECRET should be at least 32 characters for adequate security');
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return handleRequest(request, env);
|
||||
|
||||
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,314 @@
|
||||
import type { Env, User } from './types';
|
||||
import { errorResponse, jsonResponse } from './utils/response';
|
||||
import {
|
||||
handleGetProfile,
|
||||
handleUpdateProfile,
|
||||
handleSetKeys,
|
||||
handleGetRevisionDate,
|
||||
handleVerifyPassword,
|
||||
handleChangePassword,
|
||||
handleSetVerifyDevices,
|
||||
handleGetTotpStatus,
|
||||
handleSetTotpStatus,
|
||||
handleGetTotpRecoveryCode,
|
||||
handleGetApiKey,
|
||||
handleRotateApiKey,
|
||||
} 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,
|
||||
handleUpdateAttachmentMetadata,
|
||||
handleDeleteAttachment,
|
||||
} from './handlers/attachments';
|
||||
import { handleAuthenticatedDeviceRoute } from './router-devices';
|
||||
import { handleAdminRoute } from './router-admin';
|
||||
import { handleGetDomains, handleUpdateDomains } from './handlers/domains';
|
||||
|
||||
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/accounts/api-key' || path === '/api/accounts/api_key') && method === 'POST') {
|
||||
return handleGetApiKey(request, env, userId);
|
||||
}
|
||||
|
||||
if ((path === '/api/accounts/rotate-api-key' || path === '/api/accounts/rotate_api_key') && method === 'POST') {
|
||||
return handleRotateApiKey(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/sync' && method === 'GET') {
|
||||
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 attachmentMetadataMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/metadata$/i);
|
||||
if (attachmentMetadataMatch && (method === 'POST' || method === 'PUT')) {
|
||||
return handleUpdateAttachmentMetadata(request, env, userId, cipherId, attachmentMetadataMatch[1]);
|
||||
}
|
||||
|
||||
const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i);
|
||||
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' || path === '/settings/domains') {
|
||||
if (method === 'GET') return handleGetDomains(env, userId);
|
||||
if (method === 'PUT' || method === 'POST') return handleUpdateDomains(request, env, userId);
|
||||
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,114 @@
|
||||
import type { Env } from './types';
|
||||
import {
|
||||
handleGetAuthorizedDevices,
|
||||
handleGetDevice,
|
||||
handleGetDevices,
|
||||
handleGetDeviceByIdentifier,
|
||||
handleUpdateDeviceKeys,
|
||||
handleUpdateDeviceTrust,
|
||||
handleUntrustDevices,
|
||||
handleRetrieveDeviceKeys,
|
||||
handleDeactivateDevice,
|
||||
handleRevokeAllTrustedDevices,
|
||||
handleRevokeTrustedDevice,
|
||||
handleDeleteAllDevices,
|
||||
handleDeleteDevice,
|
||||
handleUpdateDeviceName,
|
||||
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 updateDeviceNameMatch = path.match(/^\/api\/devices\/([^/]+)\/name$/i);
|
||||
if (updateDeviceNameMatch && method === 'PUT') {
|
||||
const deviceIdentifier = decodeURIComponent(updateDeviceNameMatch[1]);
|
||||
return handleUpdateDeviceName(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i);
|
||||
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,422 @@
|
||||
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 { StorageService } from './services/storage';
|
||||
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;
|
||||
registrationInviteRequired: boolean;
|
||||
}
|
||||
|
||||
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 getDefaultWebsiteIconSvg(): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="Globe icon"><circle cx="48" cy="48" r="34" fill="none" stroke="#8ea9c7" stroke-width="6"/><path d="M14 48h68M48 14c10 10 16 21.5 16 34s-6 24-16 34c-10-10-16-21.5-16-34s6-24 16-34zm-24 10c8 5 17 8 24 8s16-3 24-8m-48 48c8-5 17-8 24-8s16 3 24 8" fill="none" stroke="#8ea9c7" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
||||
}
|
||||
|
||||
function handleNwFavicon(): Response {
|
||||
return new Response(getDefaultWebsiteIconSvg(), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/svg+xml; charset=utf-8',
|
||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleMissingWebsiteIcon(): Response {
|
||||
return new Response(null, {
|
||||
status: 404,
|
||||
headers: {
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function 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: {
|
||||
'cipher-key-encryption': true,
|
||||
'duo-redirect': true,
|
||||
'email-verification': true,
|
||||
'pm-19051-send-email-verification': false,
|
||||
'pm-19148-innovation-archive': true,
|
||||
'unauth-ui-refresh': true,
|
||||
'web-push': false,
|
||||
},
|
||||
object: 'config',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeIconHost(rawHost: string): string | null {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(String(rawHost || '').trim()).toLowerCase().replace(/\.+$/, '');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!decoded || decoded.includes('/') || decoded.includes('\\')) return null;
|
||||
try {
|
||||
const parsed = new URL(`https://${decoded}`);
|
||||
return parsed.hostname === decoded ? decoded : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const ICON_UPSTREAM_TIMEOUT_MS = 2500;
|
||||
const BITWARDEN_DEFAULT_GLOBE_ICON_BYTES = 500;
|
||||
const BITWARDEN_DEFAULT_GLOBE_ICON_SHA256 = 'aaa64871332ad5b7d28fe8874efb19c2d9cc2f1e6de75d52b080b438225a0783';
|
||||
|
||||
type IconSource = {
|
||||
url: string;
|
||||
rejectImage?: {
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
};
|
||||
headers?: HeadersInit;
|
||||
};
|
||||
|
||||
async function fetchIconSource(source: { url: string; headers?: HeadersInit }): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), ICON_UPSTREAM_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(source.url, {
|
||||
headers: source.headers,
|
||||
redirect: 'follow',
|
||||
signal: controller.signal,
|
||||
cf: {
|
||||
cacheEverything: true,
|
||||
cacheTtl: LIMITS.cache.iconTtlSeconds,
|
||||
},
|
||||
} as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function sha256Hex(bytes: ArrayBuffer): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function iconResponse(body: BodyInit | null, contentType: string | null): Response {
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType || 'image/png',
|
||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
|
||||
const normalizedHost = normalizeIconHost(host);
|
||||
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||
|
||||
const encodedHost = encodeURIComponent(normalizedHost);
|
||||
const requestHeaders = { 'User-Agent': 'NodeWarden/1.0' };
|
||||
const upstreamSources: IconSource[] = [
|
||||
{
|
||||
url: `https://favicon.im/zh/${encodedHost}?larger=true&throw-error-on-404=true`,
|
||||
headers: requestHeaders,
|
||||
},
|
||||
{
|
||||
url: `https://icons.bitwarden.net/${encodedHost}/icon.png`,
|
||||
rejectImage: {
|
||||
byteLength: BITWARDEN_DEFAULT_GLOBE_ICON_BYTES,
|
||||
sha256: BITWARDEN_DEFAULT_GLOBE_ICON_SHA256,
|
||||
},
|
||||
headers: requestHeaders,
|
||||
},
|
||||
];
|
||||
|
||||
for (const source of upstreamSources) {
|
||||
try {
|
||||
const resp = await fetchIconSource(source);
|
||||
|
||||
if (!resp.ok) continue;
|
||||
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
|
||||
if (!contentType.startsWith('image/')) continue;
|
||||
|
||||
if (!source.rejectImage) {
|
||||
return iconResponse(resp.body, resp.headers.get('Content-Type'));
|
||||
}
|
||||
|
||||
const contentLength = Number(resp.headers.get('Content-Length') || '');
|
||||
if (Number.isFinite(contentLength) && contentLength > 0 && contentLength !== source.rejectImage.byteLength) {
|
||||
return iconResponse(resp.body, resp.headers.get('Content-Type'));
|
||||
}
|
||||
|
||||
const bytes = await resp.arrayBuffer();
|
||||
if (bytes.byteLength === 0) continue;
|
||||
if (bytes.byteLength === source.rejectImage.byteLength && (await sha256Hex(bytes)) === source.rejectImage.sha256) continue;
|
||||
|
||||
return iconResponse(bytes, resp.headers.get('Content-Type'));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||
}
|
||||
|
||||
export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapResponse> {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
const jwtUnsafeReason =
|
||||
!secret
|
||||
? 'missing'
|
||||
: secret === DEFAULT_DEV_SECRET
|
||||
? 'default'
|
||||
: secret.length < LIMITS.auth.jwtSecretMinLength
|
||||
? 'too_short'
|
||||
: null;
|
||||
const storage = new StorageService(env.DB);
|
||||
const userCount = await storage.getUserCount();
|
||||
|
||||
return {
|
||||
defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
|
||||
jwtUnsafeReason,
|
||||
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
||||
registrationInviteRequired: userCount > 0,
|
||||
};
|
||||
}
|
||||
|
||||
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(await buildWebBootstrapResponse(env));
|
||||
}
|
||||
|
||||
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
||||
if (iconMatch && method === 'GET') {
|
||||
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
|
||||
return handleWebsiteIcon(iconMatch[1], fallbackMode);
|
||||
}
|
||||
|
||||
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
||||
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,417 +1,143 @@
|
||||
import { Env } from './types';
|
||||
import { DEFAULT_DEV_SECRET, Env } from './types';
|
||||
import { AuthService } from './services/auth';
|
||||
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
|
||||
import { handleToken, handlePrelogin } from './handlers/identity';
|
||||
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
if (!secret) return 'missing';
|
||||
if (secret === DEFAULT_DEV_SECRET) return 'default';
|
||||
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
|
||||
return null;
|
||||
}
|
||||
|
||||
// Account handlers
|
||||
import { handleRegister, handleGetProfile, handleUpdateProfile, handleSetKeys, handleGetRevisionDate, handleVerifyPassword } from './handlers/accounts';
|
||||
function isImportBypassRequest(request: Request, path: string, method: string): boolean {
|
||||
if (request.headers.get('X-NodeWarden-Import') !== '1') return false;
|
||||
|
||||
// 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 });
|
||||
} catch {
|
||||
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> {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
const method = request.method;
|
||||
const clientId = getClientIdentifier(request);
|
||||
|
||||
// Handle CORS preflight
|
||||
if (method === 'OPTIONS') {
|
||||
return handleCors();
|
||||
}
|
||||
|
||||
// Route matching
|
||||
try {
|
||||
// Setup page (root)
|
||||
if (path === '/' && method === 'GET') {
|
||||
return handleSetupPage(request, env);
|
||||
async function enforcePublicRateLimit(
|
||||
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' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Setup status
|
||||
if (path === '/setup/status' && method === 'GET') {
|
||||
return handleSetupStatus(request, env);
|
||||
}
|
||||
const rateLimit = new RateLimitService(env.DB);
|
||||
const check = await rateLimit.consumeBudget(`${clientId}:${category}`, maxRequests);
|
||||
if (check.allowed) return null;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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.VAULT);
|
||||
const clientId = getClientIdentifier(request);
|
||||
const rateLimitCheck = await rateLimit.checkApiRateLimit(userId + ':' + clientId);
|
||||
|
||||
if (!rateLimitCheck.allowed) {
|
||||
return new Response(JSON.stringify({
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
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,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': rateLimitCheck.retryAfterSeconds!.toString(),
|
||||
'Retry-After': String(check.retryAfterSeconds || 60),
|
||||
'X-RateLimit-Remaining': '0',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Increment rate limit counter
|
||||
await rateLimit.incrementApiCount(userId + ':' + clientId);
|
||||
if (method === 'OPTIONS') {
|
||||
return handleCors(request);
|
||||
}
|
||||
|
||||
// Block account operations that could change password or delete user
|
||||
if (method === 'POST' || method === 'PUT' || method === 'DELETE') {
|
||||
const blockedAccountPaths = new Set([
|
||||
'/api/accounts/password',
|
||||
'/api/accounts/change-password',
|
||||
'/api/accounts/set-password',
|
||||
'/api/accounts/master-password',
|
||||
'/api/accounts/delete',
|
||||
'/api/accounts/delete-account',
|
||||
'/api/accounts/delete-vault',
|
||||
]);
|
||||
if (blockedAccountPaths.has(path)) {
|
||||
return errorResponse('This operation is disabled', 403);
|
||||
try {
|
||||
const isLargeUploadPath =
|
||||
/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path) ||
|
||||
/^\/api\/sends\/[a-f0-9-]+\/file\/[a-f0-9-]+$/i.test(path) ||
|
||||
path === '/api/admin/backup/import';
|
||||
if (!isLargeUploadPath) {
|
||||
const contentLength = parseInt(request.headers.get('Content-Length') || '0', 10);
|
||||
if (contentLength > LIMITS.request.maxBodyBytes) {
|
||||
return errorResponse('Request body too large', 413);
|
||||
}
|
||||
}
|
||||
|
||||
// Account endpoints
|
||||
if (path === '/api/accounts/profile') {
|
||||
if (method === 'GET') return handleGetProfile(request, env, userId);
|
||||
if (method === 'PUT') return handleUpdateProfile(request, env, userId);
|
||||
const publicResponse = await handlePublicRoute(request, env, path, method, enforcePublicRateLimit);
|
||||
if (publicResponse) return publicResponse;
|
||||
|
||||
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') {
|
||||
return handleSetKeys(request, env, userId);
|
||||
const auth = new AuthService(env);
|
||||
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
|
||||
if (path === '/api/accounts/revision-date' && method === 'GET') {
|
||||
return handleGetRevisionDate(request, env, userId);
|
||||
const userId = payload.sub;
|
||||
if (currentUser.status !== 'active') {
|
||||
return errorResponse('Account is disabled', 403);
|
||||
}
|
||||
|
||||
// Verify password endpoint
|
||||
if (path === '/api/accounts/verify-password' && method === 'POST') {
|
||||
return handleVerifyPassword(request, env, userId);
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (!isImportBypassRequest(request, path, method)) {
|
||||
const rateLimit = new RateLimitService(env.DB);
|
||||
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',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Match /api/ciphers/:id patterns
|
||||
const cipherMatch = path.match(/^\/api\/ciphers\/([a-f0-9-]+)(\/.*)?$/i);
|
||||
if (cipherMatch) {
|
||||
const cipherId = cipherMatch[1];
|
||||
const subPath = cipherMatch[2] || '';
|
||||
const authenticatedResponse = await handleAuthenticatedRoute(request, env, userId, currentUser, path, method);
|
||||
if (authenticatedResponse) return authenticatedResponse;
|
||||
|
||||
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);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Request error:', error);
|
||||
return errorResponse('Internal server error', 500);
|
||||
|
||||
@@ -2,42 +2,168 @@ import { Env, JWTPayload, User } from '../types';
|
||||
import { verifyJWT, createJWT, createRefreshToken } from '../utils/jwt';
|
||||
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;
|
||||
const AUTH_CONTEXT_CACHE_TTL_MS = 15 * 1000;
|
||||
|
||||
interface CachedUserEntry {
|
||||
user: User | null;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface CachedDeviceEntry {
|
||||
device: Awaited<ReturnType<StorageService['getDevice']>>;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface VerifiedAccessContext {
|
||||
payload: JWTPayload;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
private storage: StorageService;
|
||||
private static userCache = new Map<string, CachedUserEntry>();
|
||||
private static deviceCache = new Map<string, CachedDeviceEntry>();
|
||||
|
||||
constructor(private env: Env) {
|
||||
this.storage = new StorageService(env.VAULT);
|
||||
this.storage = new StorageService(env.DB);
|
||||
}
|
||||
|
||||
// Verify password hash (compare with stored hash)
|
||||
async verifyPassword(inputHash: string, storedHash: string): Promise<boolean> {
|
||||
// In Bitwarden, the client sends the password hash directly
|
||||
// We compare the hashes
|
||||
return inputHash === storedHash;
|
||||
private readCachedUser(userId: string): User | null | undefined {
|
||||
const cached = AuthService.userCache.get(userId);
|
||||
if (!cached) return undefined;
|
||||
if (cached.expiresAt <= Date.now()) {
|
||||
AuthService.userCache.delete(userId);
|
||||
return undefined;
|
||||
}
|
||||
return cached.user;
|
||||
}
|
||||
|
||||
private writeCachedUser(userId: string, user: User | null): void {
|
||||
AuthService.userCache.set(userId, {
|
||||
user,
|
||||
expiresAt: Date.now() + AUTH_CONTEXT_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
private async getCachedUser(userId: string): Promise<User | null> {
|
||||
const cached = this.readCachedUser(userId);
|
||||
if (cached !== undefined) return cached;
|
||||
const user = await this.storage.getUserById(userId);
|
||||
this.writeCachedUser(userId, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
private async getFreshUser(userId: string): Promise<User | null> {
|
||||
const user = await this.storage.getUserById(userId);
|
||||
this.writeCachedUser(userId, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
private readCachedDevice(userId: string, deviceId: string) {
|
||||
const cacheKey = `${userId}:${deviceId}`;
|
||||
const cached = AuthService.deviceCache.get(cacheKey);
|
||||
if (!cached) return undefined;
|
||||
if (cached.expiresAt <= Date.now()) {
|
||||
AuthService.deviceCache.delete(cacheKey);
|
||||
return undefined;
|
||||
}
|
||||
return cached.device;
|
||||
}
|
||||
|
||||
private writeCachedDevice(userId: string, deviceId: string, device: Awaited<ReturnType<StorageService['getDevice']>>): void {
|
||||
const cacheKey = `${userId}:${deviceId}`;
|
||||
AuthService.deviceCache.set(cacheKey, {
|
||||
device,
|
||||
expiresAt: Date.now() + AUTH_CONTEXT_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
private async getCachedDevice(userId: string, deviceId: string) {
|
||||
const cached = this.readCachedDevice(userId, deviceId);
|
||||
if (cached !== undefined) return cached;
|
||||
const device = await this.storage.getDevice(userId, deviceId);
|
||||
this.writeCachedDevice(userId, deviceId, device);
|
||||
return device;
|
||||
}
|
||||
|
||||
private async getFreshDevice(userId: string, deviceId: string) {
|
||||
const device = await this.storage.getDevice(userId, deviceId);
|
||||
this.writeCachedDevice(userId, deviceId, device);
|
||||
return device;
|
||||
}
|
||||
|
||||
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
|
||||
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
|
||||
// Result is prefixed with "$s$" to distinguish from legacy raw client hashes.
|
||||
async hashPasswordServer(clientHash: string, email: string): Promise<string> {
|
||||
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
|
||||
async generateAccessToken(user: User): Promise<string> {
|
||||
async generateAccessToken(user: User, device?: { identifier: string; sessionStamp: string } | null): Promise<string> {
|
||||
return createJWT(
|
||||
{
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
sstamp: user.securityStamp,
|
||||
...(device?.identifier ? { did: device.identifier, dstamp: device.sessionStamp } : {}),
|
||||
},
|
||||
this.env.JWT_SECRET
|
||||
);
|
||||
}
|
||||
|
||||
// Generate refresh token
|
||||
async generateRefreshToken(userId: string): Promise<string> {
|
||||
async generateRefreshToken(userId: string, device?: { identifier: string; sessionStamp: string } | null): Promise<string> {
|
||||
const token = createRefreshToken();
|
||||
await this.storage.saveRefreshToken(token, userId);
|
||||
await this.storage.saveRefreshToken(token, userId, undefined, device?.identifier ?? null, device?.sessionStamp ?? null);
|
||||
return token;
|
||||
}
|
||||
|
||||
// Verify access token from Authorization header
|
||||
async verifyAccessToken(authHeader: string | null): Promise<JWTPayload | null> {
|
||||
async verifyAccessTokenWithUser(authHeader: string | null): Promise<VerifiedAccessContext | null> {
|
||||
if (!authHeader) return null;
|
||||
|
||||
const parts = authHeader.split(' ');
|
||||
@@ -48,26 +174,64 @@ export class AuthService {
|
||||
const payload = await verifyJWT(parts[1], this.env.JWT_SECRET);
|
||||
if (!payload) return null;
|
||||
|
||||
// Verify security stamp - ensures token is invalidated after password change
|
||||
const user = await this.storage.getUserById(payload.sub);
|
||||
let user = await this.getCachedUser(payload.sub);
|
||||
if (!user || user.status !== 'active' || payload.sstamp !== user.securityStamp) {
|
||||
user = await this.getFreshUser(payload.sub);
|
||||
}
|
||||
if (!user) return null;
|
||||
|
||||
if (user.status !== 'active') return null;
|
||||
|
||||
if (payload.sstamp !== user.securityStamp) {
|
||||
return null; // Token was issued before password change
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
if (payload.did) {
|
||||
let device = await this.getCachedDevice(user.id, payload.did);
|
||||
if (!device || !payload.dstamp || payload.dstamp !== device.sessionStamp) {
|
||||
device = await this.getFreshDevice(user.id, payload.did);
|
||||
}
|
||||
if (!device) return null;
|
||||
if (!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
|
||||
async refreshAccessToken(refreshToken: string): Promise<{ accessToken: string; user: User } | null> {
|
||||
const userId = await this.storage.getRefreshTokenUserId(refreshToken);
|
||||
if (!userId) return null;
|
||||
async refreshAccessToken(
|
||||
refreshToken: string
|
||||
): 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.status !== 'active') {
|
||||
await this.storage.deleteRefreshToken(refreshToken);
|
||||
return null;
|
||||
}
|
||||
|
||||
const accessToken = await this.generateAccessToken(user);
|
||||
return { accessToken, user };
|
||||
let device: { identifier: string; sessionStamp: string } | null = null;
|
||||
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,479 @@
|
||||
import { zipSync, unzipSync } from 'fflate';
|
||||
import type { Env } from '../types';
|
||||
import { APP_VERSION } from '../../shared/app-version';
|
||||
import { BACKUP_SETTINGS_CONFIG_KEY } from './backup-config';
|
||||
import { exportPortableBackupSettingsEnvelope } from './backup-settings-crypto';
|
||||
import {
|
||||
getAttachmentObjectKey,
|
||||
getBlobStorageKind,
|
||||
} from './blob-store';
|
||||
|
||||
// CONTRACT:
|
||||
// This file defines the exported instance-backup archive shape. Keep it in lock
|
||||
// step with src/services/backup-import.ts and webapp/src/lib/api/backup.ts.
|
||||
//
|
||||
// WHEN CHANGING THIS:
|
||||
// - Add persistent tables to BackupPayload, export SQL, manifest tableCounts,
|
||||
// and validateBackupPayloadContents().
|
||||
// - Keep secrets and transient runtime rows sanitized before writing db.json.
|
||||
// - users.api_key is intentionally not exported.
|
||||
// - backup.settings.v1 is exported as portable-only; the current server runtime
|
||||
// envelope must not leave the instance.
|
||||
type SqlRow = Record<string, string | number | null>;
|
||||
|
||||
const BACKUP_FORMAT_VERSION = 1;
|
||||
const BACKUP_RUNNER_LOCK_CONFIG_KEY = 'backup.runner.lock.v1';
|
||||
const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
|
||||
// Worker-side backup export must stay well below Cloudflare CPU limits.
|
||||
// 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[];
|
||||
domain_settings: 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 }));
|
||||
}
|
||||
|
||||
function sanitizeConfigRowsForExport(rows: SqlRow[]): SqlRow[] {
|
||||
const sanitized: SqlRow[] = [];
|
||||
for (const row of rows) {
|
||||
const key = String(row.key || '').trim();
|
||||
if (!key || key === BACKUP_RUNNER_LOCK_CONFIG_KEY) continue;
|
||||
|
||||
if (key === BACKUP_SETTINGS_CONFIG_KEY) {
|
||||
const portableOnly = exportPortableBackupSettingsEnvelope(typeof row.value === 'string' ? row.value : null);
|
||||
if (portableOnly) sanitized.push({ ...row, value: portableOnly });
|
||||
continue;
|
||||
}
|
||||
|
||||
sanitized.push({ ...row });
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function getDateParts(date: Date, timeZone: string): string {
|
||||
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
});
|
||||
const parts = formatter.formatToParts(date);
|
||||
const pick = (type: string): string => parts.find((part) => part.type === type)?.value || '';
|
||||
return `${pick('year')}${pick('month')}${pick('day')}_${pick('hour')}${pick('minute')}${pick('second')}`;
|
||||
}
|
||||
|
||||
function buildBackupFileNameInTimeZone(
|
||||
date: Date = new Date(),
|
||||
checksumPrefix: string | null = null,
|
||||
timeZone: string = 'UTC'
|
||||
): string {
|
||||
const parts = getDateParts(date, timeZone);
|
||||
const suffix = checksumPrefix ? `_${checksumPrefix}` : '';
|
||||
return `nodewarden_backup_${parts}${suffix}.zip`;
|
||||
}
|
||||
|
||||
export function extractBackupFileChecksumPrefix(fileName: string): string | null {
|
||||
const normalized = String(fileName || '').trim();
|
||||
const match = normalized.match(/_([0-9a-f]{5})\.zip$/i);
|
||||
return match ? match[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
export async function inspectBackupArchiveFileNameChecksum(
|
||||
bytes: Uint8Array,
|
||||
fileName: string
|
||||
): Promise<BackupFileIntegrityCheckResult> {
|
||||
const expectedPrefix = extractBackupFileChecksumPrefix(fileName);
|
||||
const actualHash = await sha256Hex(bytes);
|
||||
const actualPrefix = actualHash.slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
|
||||
return {
|
||||
hasChecksumPrefix: !!expectedPrefix,
|
||||
expectedPrefix,
|
||||
actualPrefix,
|
||||
matches: !expectedPrefix || actualPrefix === expectedPrefix,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyBackupArchiveFileNameChecksum(bytes: Uint8Array, fileName: string): Promise<boolean> {
|
||||
const result = await inspectBackupArchiveFileNameChecksum(bytes, fileName);
|
||||
return result.matches;
|
||||
}
|
||||
|
||||
function validateArchiveSize(bytes: Uint8Array): void {
|
||||
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 domainSettingsRows = ensureRowArray(payload.db.domain_settings || [], 'domain_settings');
|
||||
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 domainSettingUserIds = new Set<string>();
|
||||
for (const row of domainSettingsRows) {
|
||||
const userId = String(row.user_id || '').trim();
|
||||
if (!userId || !userIds.has(userId)) {
|
||||
throw new Error(`Backup archive contains domain settings for an unknown user: ${userId || '(empty)'}`);
|
||||
}
|
||||
if (domainSettingUserIds.has(userId)) {
|
||||
throw new Error(`Backup archive contains duplicate domain settings for user: ${userId}`);
|
||||
}
|
||||
domainSettingUserIds.add(userId);
|
||||
}
|
||||
|
||||
const folderIds = new Set<string>();
|
||||
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, domainSettingsRows, 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, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'),
|
||||
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
|
||||
queryRows(env.DB, 'SELECT 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 exportedConfigRows = sanitizeConfigRowsForExport(configRows);
|
||||
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: exportedConfigRows.length,
|
||||
users: userRows.length,
|
||||
domain_settings: domainSettingsRows.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: exportedConfigRows,
|
||||
users: userRows,
|
||||
domain_settings: domainSettingsRows,
|
||||
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,672 @@
|
||||
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 S3BackupDestination,
|
||||
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,
|
||||
S3BackupDestination,
|
||||
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 normalizeS3Destination(value: unknown, allowIncomplete = false): S3BackupDestination {
|
||||
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('S3 endpoint is required');
|
||||
if (!/^https?:\/\//i.test(endpoint)) throw new Error('S3 endpoint must start with http:// or https://');
|
||||
}
|
||||
if (!allowIncomplete || bucket) {
|
||||
if (!bucket) throw new Error('S3 bucket is required');
|
||||
}
|
||||
if (!allowIncomplete || accessKeyId) {
|
||||
if (!accessKeyId) throw new Error('S3 access key is required');
|
||||
}
|
||||
if (!allowIncomplete || secretAccessKey) {
|
||||
if (!secretAccessKey) throw new Error('S3 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 === 's3') return normalizeS3Destination(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') return 's3';
|
||||
if (value === 's3' || 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 === 's3' || destinationTypeRaw === 'webdav'
|
||||
? getDestinationType(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 hasBackupSlotBetween(
|
||||
destination: BackupDestinationRecord,
|
||||
startInclusive: Date,
|
||||
endExclusive: Date
|
||||
): boolean {
|
||||
if (!destination.schedule.enabled) return false;
|
||||
const startMs = startInclusive.getTime();
|
||||
const endMs = endExclusive.getTime();
|
||||
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return false;
|
||||
|
||||
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
|
||||
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime())
|
||||
? lastAttemptAt.getTime()
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
|
||||
const dayCursor = new Date(startMs);
|
||||
dayCursor.setUTCHours(0, 0, 0, 0);
|
||||
const endDay = new Date(endMs);
|
||||
endDay.setUTCHours(0, 0, 0, 0);
|
||||
const checkedLocalDateKeys = new Set<string>();
|
||||
|
||||
while (dayCursor.getTime() <= endDay.getTime() + 24 * 60 * 60 * 1000) {
|
||||
const localDateKey = getBackupLocalDateKey(dayCursor, destination.schedule.timezone);
|
||||
if (!checkedLocalDateKeys.has(localDateKey)) {
|
||||
checkedLocalDateKeys.add(localDateKey);
|
||||
const slotStarts = getBackupSlotStartsForLocalDay(
|
||||
localDateKey,
|
||||
destination.schedule.timezone,
|
||||
destination.schedule.startTime,
|
||||
destination.schedule.intervalHours
|
||||
);
|
||||
for (const slotStart of slotStarts) {
|
||||
const slotStartMs = slotStart.getTime();
|
||||
if (slotStartMs < startMs || slotStartMs >= endMs) continue;
|
||||
if (lastAttemptMs >= slotStartMs) continue;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
dayCursor.setUTCDate(dayCursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isBackupDueNow(
|
||||
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,933 @@
|
||||
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';
|
||||
|
||||
// CONTRACT:
|
||||
// Restore is intentionally whitelist-based. Old backups may contain retired
|
||||
// fields, but only the columns listed here are imported. Keep this file in sync
|
||||
// with src/services/backup-archive.ts whenever backup contents change.
|
||||
//
|
||||
// WHEN CHANGING THIS:
|
||||
// - Update BackupTableName, BACKUP_TABLES, reset statements, prepared payloads,
|
||||
// shadow-table count validation, insert column lists, and frontend import
|
||||
// count types together.
|
||||
// - Do not import users.api_key, even if an older backup contains it.
|
||||
type SqlRow = Record<string, string | number | null>;
|
||||
type BackupTableName =
|
||||
| 'config'
|
||||
| 'users'
|
||||
| 'domain_settings'
|
||||
| 'user_revisions'
|
||||
| 'folders'
|
||||
| 'ciphers'
|
||||
| 'attachments';
|
||||
|
||||
const BACKUP_TABLES: BackupTableName[] = [
|
||||
'config',
|
||||
'users',
|
||||
'domain_settings',
|
||||
'user_revisions',
|
||||
'folders',
|
||||
'ciphers',
|
||||
'attachments',
|
||||
];
|
||||
|
||||
function shadowTableName(table: BackupTableName): string {
|
||||
return `${table}__restore`;
|
||||
}
|
||||
|
||||
export interface BackupImportResultBody {
|
||||
object: 'instance-backup-import';
|
||||
imported: {
|
||||
config: number;
|
||||
users: number;
|
||||
domainSettings: 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 domain_settings',
|
||||
'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,
|
||||
})),
|
||||
domain_settings: cloneRows(payload.domain_settings || []),
|
||||
user_revisions: cloneRows(payload.user_revisions || []),
|
||||
folders: cloneRows(payload.folders || []),
|
||||
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
||||
...row,
|
||||
archived_at: row.archived_at ?? null,
|
||||
})),
|
||||
attachments: cloneRows(payload.attachments || []),
|
||||
};
|
||||
await importBackupRows(db, preparedDb, true);
|
||||
return preparedDb;
|
||||
}
|
||||
|
||||
function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files: Record<string, Uint8Array>): PreparedBackupImportPayload {
|
||||
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('domain_settings'),
|
||||
buildInsertStatements(
|
||||
db,
|
||||
tableName('domain_settings'),
|
||||
['user_id', 'equivalent_domains', 'custom_equivalent_domains', 'excluded_global_equivalent_domains', 'updated_at'],
|
||||
payload.domain_settings || [],
|
||||
true
|
||||
)
|
||||
);
|
||||
await runInsertBatch(
|
||||
db,
|
||||
tableName('folders'),
|
||||
buildInsertStatements(db, tableName('folders'), ['id', 'user_id', 'name', 'created_at', 'updated_at'], payload.folders || [])
|
||||
);
|
||||
await runInsertBatch(
|
||||
db,
|
||||
tableName('ciphers'),
|
||||
buildInsertStatements(
|
||||
db,
|
||||
tableName('ciphers'),
|
||||
['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'archived_at', 'deleted_at'],
|
||||
payload.ciphers || []
|
||||
)
|
||||
);
|
||||
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,
|
||||
domain_settings: (db.domain_settings || []).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,
|
||||
domain_settings: (db.domain_settings || []).length,
|
||||
user_revisions: (db.user_revisions || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: restored.restoredAttachments.length,
|
||||
});
|
||||
await progress?.({
|
||||
source: 'local',
|
||||
step: 'local_finalize',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
|
||||
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
|
||||
replaceExisting,
|
||||
});
|
||||
await swapShadowTablesIntoPlace(env.DB);
|
||||
await resetRestoreArtifacts(env.DB).catch(() => undefined);
|
||||
if (replaceExisting && previousBlobKeys.size) {
|
||||
const nextBlobKeys = await collectCurrentBlobKeys(env.DB).catch(() => null);
|
||||
if (nextBlobKeys) {
|
||||
await cleanupOrphanedBlobFiles(env, previousBlobKeys, nextBlobKeys).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
await progress?.({
|
||||
source: 'local',
|
||||
step: 'local_complete',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
|
||||
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
|
||||
replaceExisting,
|
||||
done: true,
|
||||
ok: true,
|
||||
});
|
||||
return {
|
||||
auditActorUserId: (db.users || []).some((row) => String(row.id || '').trim() === actorUserId) ? actorUserId : null,
|
||||
result: {
|
||||
object: 'instance-backup-import',
|
||||
imported: {
|
||||
config: (db.config || []).length,
|
||||
users: (db.users || []).length,
|
||||
domainSettings: (db.domain_settings || []).length,
|
||||
userRevisions: (db.user_revisions || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: restored.restoredAttachments.length,
|
||||
attachmentFiles: restored.imported,
|
||||
},
|
||||
skipped: {
|
||||
reason: restored.skipped.reason || prepared.skipped.reason,
|
||||
attachments: prepared.skipped.attachments + restored.skipped.attachments,
|
||||
items: [...prepared.skipped.items, ...restored.skipped.items],
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await progress?.({
|
||||
source: 'local',
|
||||
step: 'local_failed',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
|
||||
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
|
||||
replaceExisting,
|
||||
done: true,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
await resetRestoreArtifacts(env.DB).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
domain_settings: (db.domain_settings || []).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,
|
||||
domain_settings: (db.domain_settings || []).length,
|
||||
user_revisions: (db.user_revisions || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: restored.restoredAttachments.length,
|
||||
});
|
||||
await progress?.({
|
||||
source: 'remote',
|
||||
step: 'remote_finalize',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_remote_finalize_title',
|
||||
stageDetail: 'txt_backup_restore_progress_remote_finalize_detail',
|
||||
replaceExisting,
|
||||
});
|
||||
await swapShadowTablesIntoPlace(env.DB);
|
||||
await resetRestoreArtifacts(env.DB).catch(() => undefined);
|
||||
|
||||
if (replaceExisting && previousBlobKeys.size) {
|
||||
const nextBlobKeys = await collectCurrentBlobKeys(env.DB).catch(() => null);
|
||||
if (nextBlobKeys) {
|
||||
await cleanupOrphanedBlobFiles(env, previousBlobKeys, nextBlobKeys).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
await progress?.({
|
||||
source: 'remote',
|
||||
step: 'remote_complete',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_remote_finalize_title',
|
||||
stageDetail: 'txt_backup_restore_progress_remote_finalize_detail',
|
||||
replaceExisting,
|
||||
done: true,
|
||||
ok: true,
|
||||
});
|
||||
const finalSkippedItems = [...preparedRemote.skipped.items, ...restored.skipped.items];
|
||||
const finalSkippedReason = finalSkippedItems.length
|
||||
? restored.skipped.reason || preparedRemote.skipped.reason
|
||||
: null;
|
||||
|
||||
return {
|
||||
auditActorUserId: (db.users || []).some((row) => String(row.id || '').trim() === actorUserId) ? actorUserId : null,
|
||||
result: {
|
||||
object: 'instance-backup-import',
|
||||
imported: {
|
||||
config: (db.config || []).length,
|
||||
users: (db.users || []).length,
|
||||
domainSettings: (db.domain_settings || []).length,
|
||||
userRevisions: (db.user_revisions || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: restored.restoredAttachments.length,
|
||||
attachmentFiles: restored.imported,
|
||||
},
|
||||
skipped: {
|
||||
reason: finalSkippedReason,
|
||||
attachments: finalSkippedItems.length,
|
||||
items: finalSkippedItems,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await progress?.({
|
||||
source: 'remote',
|
||||
step: 'remote_failed',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_remote_finalize_title',
|
||||
stageDetail: 'txt_backup_restore_progress_remote_finalize_detail',
|
||||
replaceExisting,
|
||||
done: true,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
await resetRestoreArtifacts(env.DB).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { Env, User } from '../types';
|
||||
|
||||
// CONTRACT:
|
||||
// Backup settings contain provider credentials. They are stored as a v2 envelope:
|
||||
// - runtime: AES-GCM encrypted with a key derived from JWT_SECRET for the current
|
||||
// server's scheduled backup runner.
|
||||
// - portable: AES-GCM encrypted with a random DEK; that DEK is RSA-wrapped for
|
||||
// active admin public keys so settings can be repaired after restore/migration.
|
||||
//
|
||||
// New admin-entered provider secrets, such as mail API keys, should use this
|
||||
// pattern or a deliberately documented replacement. Do not store provider
|
||||
// secrets as plain config JSON.
|
||||
const RUNTIME_SALT = 'nodewarden.backup-settings.runtime.v2';
|
||||
const RUNTIME_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 function exportPortableBackupSettingsEnvelope(raw: string | null): string | null {
|
||||
const envelope = parseBackupSettingsEnvelope(raw);
|
||||
if (!envelope) return null;
|
||||
return JSON.stringify({
|
||||
version: 2,
|
||||
portableOnly: true,
|
||||
runtime: {
|
||||
iv: '',
|
||||
ciphertext: '',
|
||||
},
|
||||
portable: envelope.portable,
|
||||
});
|
||||
}
|
||||
|
||||
export async function encryptBackupSettingsEnvelope(
|
||||
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,
|
||||
S3BackupDestination,
|
||||
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 === 's3') {
|
||||
const config = destination.destination as S3BackupDestination;
|
||||
if (!String(config.endpoint || '').trim()) throw new Error('S3 endpoint is required');
|
||||
if (!/^https?:\/\//i.test(String(config.endpoint || '').trim())) throw new Error('S3 endpoint must start with http:// or https://');
|
||||
if (!String(config.bucket || '').trim()) throw new Error('S3 bucket is required');
|
||||
if (!String(config.accessKeyId || '').trim()) throw new Error('S3 access key is required');
|
||||
if (!String(config.secretAccessKey || '')) throw new Error('S3 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 s3BucketBaseUrl(config: S3BackupDestination): URL {
|
||||
return new URL(`${config.endpoint.replace(/\/+$/, '')}/${encodeURIComponent(config.bucket)}`);
|
||||
}
|
||||
|
||||
function normalizeS3ObjectKey(config: S3BackupDestination, relativePath: string): string {
|
||||
return buildJoinedPath(config.rootPath, normalizeRelativePath(relativePath));
|
||||
}
|
||||
|
||||
async function signedS3Request(
|
||||
config: S3BackupDestination,
|
||||
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 putToS3(
|
||||
config: S3BackupDestination,
|
||||
relativePath: string,
|
||||
bytes: Uint8Array,
|
||||
options: RemoteBackupFilePutOptions = {}
|
||||
): Promise<void> {
|
||||
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||
const response = await signedS3Request(config, 'PUT', url, bytes, options.contentType);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`S3 upload failed: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadToS3(config: S3BackupDestination, archive: Uint8Array, fileName: string): Promise<BackupUploadResult> {
|
||||
await putToS3(config, fileName, archive, { contentType: 'application/zip' });
|
||||
return {
|
||||
provider: 's3',
|
||||
remotePath: normalizeS3ObjectKey(config, fileName),
|
||||
};
|
||||
}
|
||||
|
||||
async function listS3Entries(config: S3BackupDestination, relativePath: string): Promise<RemoteBackupListResult> {
|
||||
const currentPath = normalizeRelativePath(relativePath);
|
||||
const targetPrefixBase = normalizeS3ObjectKey(config, currentPath);
|
||||
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
|
||||
const url = s3BucketBaseUrl(config);
|
||||
url.searchParams.set('list-type', '2');
|
||||
url.searchParams.set('delimiter', '/');
|
||||
if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
|
||||
|
||||
const response = await signedS3Request(config, 'GET', url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`S3 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: 's3',
|
||||
currentPath,
|
||||
parentPath: parentPath(currentPath),
|
||||
items: sortRemoteItems(Array.from(deduped.values())),
|
||||
};
|
||||
}
|
||||
|
||||
async function downloadFromS3(config: S3BackupDestination, relativePath: string): Promise<RemoteBackupFile> {
|
||||
const normalized = normalizeRelativePath(relativePath);
|
||||
if (!normalized || normalized.endsWith('/')) {
|
||||
throw new Error('Please select a backup file');
|
||||
}
|
||||
const objectKey = normalizeS3ObjectKey(config, normalized);
|
||||
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||
const response = await signedS3Request(config, 'GET', url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`S3 download failed: ${response.status}`);
|
||||
}
|
||||
return {
|
||||
provider: 's3',
|
||||
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 deleteFromS3(config: S3BackupDestination, relativePath: string): Promise<void> {
|
||||
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||
const response = await signedS3Request(config, 'DELETE', url);
|
||||
if (!response.ok && response.status !== 404) {
|
||||
throw new Error(`S3 delete failed: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> {
|
||||
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||
const response = await signedS3Request(config, 'HEAD', url);
|
||||
if (response.status === 404) return false;
|
||||
if (!response.ok) {
|
||||
throw new Error(`S3 existence check failed: ${response.status}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
interface ConfiguredDestinationAdapter {
|
||||
provider: 'webdav' | 's3';
|
||||
config: WebDavBackupDestination | S3BackupDestination;
|
||||
upload: (config: WebDavBackupDestination | S3BackupDestination, archive: Uint8Array, fileName: string) => Promise<BackupUploadResult>;
|
||||
putFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions) => Promise<void>;
|
||||
list: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupListResult>;
|
||||
download: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
|
||||
deleteFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<void>;
|
||||
exists: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface RemoteBackupTransferSession {
|
||||
provider: BackupDestinationType;
|
||||
uploadArchive(archive: Uint8Array, fileName: string): Promise<BackupUploadResult>;
|
||||
putFile(relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions): Promise<void>;
|
||||
list(relativePath: string): Promise<RemoteBackupListResult>;
|
||||
download(relativePath: string): Promise<RemoteBackupFile>;
|
||||
deleteFile(relativePath: string): Promise<void>;
|
||||
exists(relativePath: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
function resolveConfiguredDestinationAdapter(
|
||||
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 === 's3') {
|
||||
return {
|
||||
provider: 's3',
|
||||
config: destination.destination as S3BackupDestination,
|
||||
upload: (config, archive, fileName) => uploadToS3(config as S3BackupDestination, archive, fileName),
|
||||
putFile: (config, relativePath, bytes, options) => putToS3(config as S3BackupDestination, relativePath, bytes, options),
|
||||
list: (config, relativePath) => listS3Entries(config as S3BackupDestination, relativePath),
|
||||
download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath),
|
||||
deleteFile: (config, relativePath) => deleteFromS3(config as S3BackupDestination, relativePath),
|
||||
exists: (config, relativePath) => existsInS3(config as S3BackupDestination, 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)
|
||||
: normalizeS3ObjectKey(adapter.config as S3BackupDestination, fileName),
|
||||
};
|
||||
},
|
||||
putFile,
|
||||
list: async (relativePath: string) => adapter.list(adapter.config, relativePath),
|
||||
download: async (relativePath: string) => adapter.download(adapter.config, relativePath),
|
||||
deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)),
|
||||
exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function uploadBackupArchive(
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import bitwardenGlobalDomainsRaw from '../static/global_domains.bitwarden.json';
|
||||
import customGlobalDomainsRaw from '../static/global_domains.custom.json';
|
||||
import type { CustomEquivalentDomain, DomainRulesResponse, GlobalEquivalentDomain } from '../types';
|
||||
import { normalizeEquivalentDomain } from '../../shared/domain-normalize';
|
||||
|
||||
// CONTRACT:
|
||||
// Equivalent domains are a Bitwarden compatibility surface. The DB stores both
|
||||
// the full custom rule list and the derived active equivalent-domain groups:
|
||||
// - custom_equivalent_domains: UI/client rules with id + excluded state.
|
||||
// - equivalent_domains: active groups derived from non-excluded custom rules.
|
||||
// - excluded_global_equivalent_domains: disabled global rule type ids.
|
||||
// Do not treat equivalent_domains and custom_equivalent_domains as accidental
|
||||
// duplicates without a migration and compatibility plan.
|
||||
type RawGlobalDomain = Partial<GlobalEquivalentDomain> & {
|
||||
Type?: unknown;
|
||||
Domains?: unknown;
|
||||
Excluded?: unknown;
|
||||
};
|
||||
|
||||
function normalizeDomain(value: unknown): string {
|
||||
return normalizeEquivalentDomain(value);
|
||||
}
|
||||
|
||||
function normalizeGlobalDomain(entry: RawGlobalDomain): GlobalEquivalentDomain | null {
|
||||
const type = Number(entry.type ?? entry.Type);
|
||||
if (!Number.isInteger(type)) return null;
|
||||
|
||||
const rawDomains = entry.domains ?? entry.Domains;
|
||||
if (!Array.isArray(rawDomains)) return null;
|
||||
|
||||
const domains = Array.from(new Set(rawDomains.map(normalizeDomain).filter(Boolean)));
|
||||
if (domains.length < 2) return null;
|
||||
|
||||
return {
|
||||
type,
|
||||
domains,
|
||||
excluded: Boolean(entry.excluded ?? entry.Excluded ?? false),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGlobalDomains(input: unknown): GlobalEquivalentDomain[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
|
||||
const seen = new Set<number>();
|
||||
const out: GlobalEquivalentDomain[] = [];
|
||||
for (const entry of input) {
|
||||
const normalized = normalizeGlobalDomain(entry as RawGlobalDomain);
|
||||
if (!normalized || seen.has(normalized.type)) continue;
|
||||
seen.add(normalized.type);
|
||||
out.push(normalized);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const bitwardenGlobalDomains = normalizeGlobalDomains(bitwardenGlobalDomainsRaw);
|
||||
const customGlobalDomains = normalizeGlobalDomains(customGlobalDomainsRaw);
|
||||
|
||||
export const globalDomains: readonly GlobalEquivalentDomain[] = [
|
||||
...bitwardenGlobalDomains,
|
||||
...customGlobalDomains,
|
||||
];
|
||||
|
||||
export function normalizeEquivalentDomains(input: unknown): string[][] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
|
||||
const groups: string[][] = [];
|
||||
const seenGroups = new Set<string>();
|
||||
for (const group of input) {
|
||||
if (!Array.isArray(group)) continue;
|
||||
const domains = Array.from(new Set(group.map(normalizeDomain).filter(Boolean)));
|
||||
if (domains.length < 2) continue;
|
||||
const key = domains.slice().sort().join('\n');
|
||||
if (seenGroups.has(key)) continue;
|
||||
seenGroups.add(key);
|
||||
groups.push(domains);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function mergeEquivalentDomainGroups(input: string[][]): string[][] {
|
||||
const parent = new Map<string, string>();
|
||||
|
||||
function find(domain: string): string {
|
||||
const current = parent.get(domain);
|
||||
if (!current) {
|
||||
parent.set(domain, domain);
|
||||
return domain;
|
||||
}
|
||||
if (current === domain) return domain;
|
||||
const root = find(current);
|
||||
parent.set(domain, root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function union(a: string, b: string): void {
|
||||
const rootA = find(a);
|
||||
const rootB = find(b);
|
||||
if (rootA !== rootB) parent.set(rootB, rootA);
|
||||
}
|
||||
|
||||
for (const group of normalizeEquivalentDomains(input)) {
|
||||
if (group.length < 2) continue;
|
||||
const [first, ...rest] = group;
|
||||
find(first);
|
||||
for (const domain of rest) union(first, domain);
|
||||
}
|
||||
|
||||
const components = new Map<string, string[]>();
|
||||
for (const domain of parent.keys()) {
|
||||
const root = find(domain);
|
||||
const group = components.get(root) || [];
|
||||
group.push(domain);
|
||||
components.set(root, group);
|
||||
}
|
||||
|
||||
return Array.from(components.values())
|
||||
.map((group) => group.sort())
|
||||
.filter((group) => group.length >= 2)
|
||||
.sort((a, b) => a[0].localeCompare(b[0]));
|
||||
}
|
||||
|
||||
export function expandCustomEquivalentDomainsWithGlobals(
|
||||
customGroups: string[][],
|
||||
activeGlobalGroups: string[][]
|
||||
): string[][] {
|
||||
const normalizedCustomGroups = normalizeEquivalentDomains(customGroups);
|
||||
if (!normalizedCustomGroups.length) return [];
|
||||
|
||||
const customDomains = new Set(normalizedCustomGroups.flat());
|
||||
return mergeEquivalentDomainGroups([
|
||||
...activeGlobalGroups,
|
||||
...normalizedCustomGroups,
|
||||
]).filter((group) => group.some((domain) => customDomains.has(domain)));
|
||||
}
|
||||
|
||||
function createCustomDomainId(domains: string[], index: number): string {
|
||||
return `custom:${domains.slice().sort().join('|')}:${index}`;
|
||||
}
|
||||
|
||||
export function normalizeCustomEquivalentDomains(input: unknown): CustomEquivalentDomain[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
|
||||
const rules: CustomEquivalentDomain[] = [];
|
||||
const seenGroups = new Set<string>();
|
||||
for (const [index, item] of input.entries()) {
|
||||
const record = Array.isArray(item)
|
||||
? { domains: item, excluded: false, id: '' }
|
||||
: item && typeof item === 'object'
|
||||
? item as Record<string, unknown>
|
||||
: null;
|
||||
if (!record) continue;
|
||||
|
||||
const domains = normalizeEquivalentDomains([record.domains ?? record.Domains])[0];
|
||||
if (!domains) continue;
|
||||
|
||||
const key = domains.slice().sort().join('\n');
|
||||
if (seenGroups.has(key)) continue;
|
||||
seenGroups.add(key);
|
||||
|
||||
const rawId = String(record.id ?? record.Id ?? '').trim();
|
||||
rules.push({
|
||||
id: rawId || createCustomDomainId(domains, index),
|
||||
domains,
|
||||
excluded: Boolean(record.excluded ?? record.Excluded ?? false),
|
||||
});
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
export function customRulesToActiveEquivalentDomains(rules: CustomEquivalentDomain[]): string[][] {
|
||||
return mergeEquivalentDomainGroups(rules
|
||||
.filter((rule) => !rule.excluded)
|
||||
.map((rule) => rule.domains));
|
||||
}
|
||||
|
||||
export function normalizeExcludedGlobalTypes(input: unknown): number[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
|
||||
const validTypes = new Set(globalDomains.map((entry) => entry.type));
|
||||
const seen = new Set<number>();
|
||||
const out: number[] = [];
|
||||
for (const item of input) {
|
||||
const type = Number(typeof item === 'object' && item !== null ? (item as Record<string, unknown>).type : item);
|
||||
const excluded = typeof item === 'object' && item !== null
|
||||
? Boolean((item as Record<string, unknown>).excluded)
|
||||
: true;
|
||||
if (!excluded || !Number.isInteger(type) || !validTypes.has(type) || seen.has(type)) continue;
|
||||
seen.add(type);
|
||||
out.push(type);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildDomainsResponse(
|
||||
equivalentDomains: string[][],
|
||||
customEquivalentDomains: CustomEquivalentDomain[],
|
||||
excludedGlobalEquivalentDomains: number[],
|
||||
options: { omitExcludedGlobals?: boolean } = {}
|
||||
): DomainRulesResponse {
|
||||
const excluded = new Set(excludedGlobalEquivalentDomains);
|
||||
const activeGlobalDomainGroups = globalDomains
|
||||
.filter((entry) => !excluded.has(entry.type))
|
||||
.map((entry) => entry.domains);
|
||||
const mergedEquivalentDomains = expandCustomEquivalentDomainsWithGlobals(
|
||||
equivalentDomains,
|
||||
activeGlobalDomainGroups
|
||||
);
|
||||
const globals = globalDomains
|
||||
.map((entry) => ({
|
||||
type: entry.type,
|
||||
domains: entry.domains,
|
||||
excluded: excluded.has(entry.type),
|
||||
}))
|
||||
.filter((entry) => !options.omitExcludedGlobals || !entry.excluded);
|
||||
|
||||
return {
|
||||
equivalentDomains: mergedEquivalentDomains,
|
||||
customEquivalentDomains,
|
||||
globalEquivalentDomains: globals,
|
||||
object: 'domains',
|
||||
};
|
||||
}
|
||||
@@ -1,171 +1,357 @@
|
||||
import { Env } from '../types';
|
||||
import { LIMITS } from '../config/limits';
|
||||
|
||||
// Rate limiting service.
|
||||
// - 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 = {
|
||||
// Login attempt limits
|
||||
LOGIN_MAX_ATTEMPTS: 15, // Max failed login attempts
|
||||
LOGIN_LOCKOUT_MINUTES: 5, // Lockout duration after max attempts
|
||||
|
||||
// API rate limits (per minute)
|
||||
API_REQUESTS_PER_MINUTE: 300, // General API rate limit
|
||||
API_WINDOW_SECONDS: 60, // Rate limit window
|
||||
};
|
||||
|
||||
// KV key prefixes
|
||||
const KEYS = {
|
||||
LOGIN_ATTEMPTS: 'ratelimit:login:',
|
||||
API_RATE: 'ratelimit:api:',
|
||||
LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts,
|
||||
LOGIN_LOCKOUT_MINUTES: LIMITS.rateLimit.loginLockoutMinutes,
|
||||
API_WINDOW_SECONDS: LIMITS.rateLimit.apiWindowSeconds,
|
||||
};
|
||||
|
||||
export class RateLimitService {
|
||||
constructor(private kv: KVNamespace) {}
|
||||
private static loginIpTableReady = false;
|
||||
private static lastLoginIpCleanupAt = 0;
|
||||
|
||||
/**
|
||||
* Check and record login attempt
|
||||
* Returns { allowed: boolean, remainingAttempts: number, retryAfterSeconds?: number }
|
||||
*/
|
||||
async checkLoginAttempt(email: string): Promise<{
|
||||
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) {}
|
||||
|
||||
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;
|
||||
remainingAttempts: number;
|
||||
retryAfterSeconds?: number;
|
||||
}> {
|
||||
const key = `${KEYS.LOGIN_ATTEMPTS}${email.toLowerCase()}`;
|
||||
const data = await this.kv.get(key);
|
||||
|
||||
if (!data) {
|
||||
await this.ensureLoginIpTable();
|
||||
|
||||
const key = ip.trim() || 'unknown';
|
||||
const now = Date.now();
|
||||
await this.maybeCleanupLoginAttemptsIp(now);
|
||||
|
||||
const row = await this.db
|
||||
.prepare('SELECT attempts, locked_until FROM login_attempts_ip WHERE ip = ?')
|
||||
.bind(key)
|
||||
.first<{ attempts: number; locked_until: number | null }>();
|
||||
|
||||
if (!row) {
|
||||
return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS };
|
||||
}
|
||||
|
||||
const record: { attempts: number; lockedUntil?: number } = JSON.parse(data);
|
||||
const now = Date.now();
|
||||
|
||||
// Check if currently locked out
|
||||
if (record.lockedUntil && record.lockedUntil > now) {
|
||||
const retryAfterSeconds = Math.ceil((record.lockedUntil - now) / 1000);
|
||||
if (row.locked_until && row.locked_until > now) {
|
||||
return {
|
||||
allowed: false,
|
||||
remainingAttempts: 0,
|
||||
retryAfterSeconds,
|
||||
retryAfterSeconds: Math.ceil((row.locked_until - now) / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
// If lockout expired, reset
|
||||
if (record.lockedUntil && record.lockedUntil <= now) {
|
||||
await this.kv.delete(key);
|
||||
if (row.locked_until && row.locked_until <= now) {
|
||||
await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run();
|
||||
return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS };
|
||||
}
|
||||
|
||||
const remainingAttempts = CONFIG.LOGIN_MAX_ATTEMPTS - record.attempts;
|
||||
const remainingAttempts = Math.max(0, CONFIG.LOGIN_MAX_ATTEMPTS - (row.attempts || 0));
|
||||
return { allowed: true, remainingAttempts };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a failed login attempt
|
||||
*/
|
||||
async recordFailedLogin(email: string): Promise<{
|
||||
locked: boolean;
|
||||
retryAfterSeconds?: number;
|
||||
}> {
|
||||
const key = `${KEYS.LOGIN_ATTEMPTS}${email.toLowerCase()}`;
|
||||
const data = await this.kv.get(key);
|
||||
|
||||
let record: { attempts: number; lockedUntil?: number };
|
||||
|
||||
if (data) {
|
||||
record = JSON.parse(data);
|
||||
record.attempts += 1;
|
||||
} else {
|
||||
record = { attempts: 1 };
|
||||
}
|
||||
async recordFailedLogin(ip: string): Promise<{ locked: boolean; retryAfterSeconds?: number }> {
|
||||
await this.ensureLoginIpTable();
|
||||
|
||||
// Check if should lock out
|
||||
if (record.attempts >= CONFIG.LOGIN_MAX_ATTEMPTS) {
|
||||
record.lockedUntil = Date.now() + CONFIG.LOGIN_LOCKOUT_MINUTES * 60 * 1000;
|
||||
await this.kv.put(key, JSON.stringify(record), {
|
||||
expirationTtl: CONFIG.LOGIN_LOCKOUT_MINUTES * 60 + 60, // Extra minute buffer
|
||||
});
|
||||
return {
|
||||
locked: true,
|
||||
retryAfterSeconds: CONFIG.LOGIN_LOCKOUT_MINUTES * 60,
|
||||
};
|
||||
}
|
||||
const key = ip.trim() || 'unknown';
|
||||
const now = Date.now();
|
||||
await this.maybeCleanupLoginAttemptsIp(now);
|
||||
|
||||
// Store with expiration (auto-reset after lockout period even without lockout)
|
||||
await this.kv.put(key, JSON.stringify(record), {
|
||||
expirationTtl: CONFIG.LOGIN_LOCKOUT_MINUTES * 60,
|
||||
});
|
||||
// D1 in Workers forbids raw BEGIN/COMMIT statements.
|
||||
// Use a single atomic UPSERT to increment attempts.
|
||||
// This is concurrency-safe because the row is keyed by IP.
|
||||
await this.db
|
||||
.prepare(
|
||||
'INSERT INTO login_attempts_ip(ip, attempts, locked_until, updated_at) VALUES(?, 1, NULL, ?) ' +
|
||||
'ON CONFLICT(ip) DO UPDATE SET attempts = attempts + 1, updated_at = excluded.updated_at'
|
||||
)
|
||||
.bind(key, now)
|
||||
.run();
|
||||
|
||||
const row = await this.db
|
||||
.prepare('SELECT attempts FROM login_attempts_ip WHERE ip = ?')
|
||||
.bind(key)
|
||||
.first<{ attempts: number }>();
|
||||
|
||||
const attempts = row?.attempts || 1;
|
||||
if (attempts >= CONFIG.LOGIN_MAX_ATTEMPTS) {
|
||||
const lockedUntil = now + CONFIG.LOGIN_LOCKOUT_MINUTES * 60 * 1000;
|
||||
await this.db
|
||||
.prepare('UPDATE login_attempts_ip SET locked_until = ?, updated_at = ? WHERE ip = ?')
|
||||
.bind(lockedUntil, now, key)
|
||||
.run();
|
||||
return { locked: true, retryAfterSeconds: CONFIG.LOGIN_LOCKOUT_MINUTES * 60 };
|
||||
}
|
||||
|
||||
return { locked: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear login attempts on successful login
|
||||
*/
|
||||
async clearLoginAttempts(email: string): Promise<void> {
|
||||
const key = `${KEYS.LOGIN_ATTEMPTS}${email.toLowerCase()}`;
|
||||
await this.kv.delete(key);
|
||||
async clearLoginAttempts(ip: string): Promise<void> {
|
||||
await this.ensureLoginIpTable();
|
||||
const key = ip.trim() || 'unknown';
|
||||
await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check API rate limit for a user or IP
|
||||
* Returns { allowed: boolean, remaining: number, retryAfterSeconds?: number }
|
||||
*/
|
||||
async checkApiRateLimit(identifier: string): Promise<{
|
||||
allowed: boolean;
|
||||
remaining: number;
|
||||
retryAfterSeconds?: number;
|
||||
}> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const windowStart = now - (now % CONFIG.API_WINDOW_SECONDS);
|
||||
const key = `${KEYS.API_RATE}${identifier}:${windowStart}`;
|
||||
// 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 windowStart = nowSec - (nowSec % windowSeconds);
|
||||
const windowEnd = windowStart + windowSeconds;
|
||||
const ttl = Math.max(1, windowEnd - nowSec);
|
||||
|
||||
const countStr = await this.kv.get(key);
|
||||
const count = countStr ? parseInt(countStr, 10) : 0;
|
||||
const cache = await caches.open('rate-limit');
|
||||
const cacheKey = new Request(`https://rl/${identifier}/${windowStart}`);
|
||||
|
||||
if (count >= CONFIG.API_REQUESTS_PER_MINUTE) {
|
||||
const retryAfterSeconds = CONFIG.API_WINDOW_SECONDS - (now % CONFIG.API_WINDOW_SECONDS);
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
retryAfterSeconds,
|
||||
};
|
||||
const cached = await cache.match(cacheKey);
|
||||
let count = 0;
|
||||
if (cached) {
|
||||
count = parseInt(await cached.text(), 10) || 0;
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: CONFIG.API_REQUESTS_PER_MINUTE - count,
|
||||
};
|
||||
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) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment API request count
|
||||
*/
|
||||
async incrementApiCount(identifier: string): Promise<void> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const windowStart = now - (now % CONFIG.API_WINDOW_SECONDS);
|
||||
const key = `${KEYS.API_RATE}${identifier}:${windowStart}`;
|
||||
// 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);
|
||||
}
|
||||
|
||||
const countStr = await this.kv.get(key);
|
||||
const count = countStr ? parseInt(countStr, 10) : 0;
|
||||
|
||||
await this.kv.put(key, (count + 1).toString(), {
|
||||
expirationTtl: CONFIG.API_WINDOW_SECONDS + 10, // Slight buffer
|
||||
});
|
||||
async consumeBudgetWithWindow(
|
||||
identifier: string,
|
||||
maxRequests: number,
|
||||
windowSeconds: number
|
||||
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
||||
return this.consumeFixedWindowBudget(identifier, maxRequests, windowSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client identifier from request (IP or CF-Connecting-IP)
|
||||
*/
|
||||
export function getClientIdentifier(request: Request): string {
|
||||
// Cloudflare provides the real client IP
|
||||
const cfIp = request.headers.get('CF-Connecting-IP');
|
||||
if (cfIp) return cfIp;
|
||||
function parseIpv4Octets(input: string): number[] | null {
|
||||
const parts = input.split('.');
|
||||
if (parts.length !== 4) return null;
|
||||
|
||||
// Fallback for local development
|
||||
const forwardedFor = request.headers.get('X-Forwarded-For');
|
||||
if (forwardedFor) return forwardedFor.split(',')[0].trim();
|
||||
|
||||
// Last resort
|
||||
return 'unknown';
|
||||
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;
|
||||
};
|
||||
|
||||
const parseParts = (parts: string[]): number[] | null => {
|
||||
const out: number[] = [];
|
||||
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];
|
||||
}
|
||||
|
||||
const all = parseParts(value.split(':'));
|
||||
if (!all || all.length !== 8) return null;
|
||||
return all;
|
||||
}
|
||||
|
||||
function normalizeClientIpForRateLimit(rawIp: string): string | null {
|
||||
const input = rawIp.trim();
|
||||
if (!input) return null;
|
||||
|
||||
const ipv4 = parseIpv4Octets(input);
|
||||
if (ipv4) {
|
||||
return `ip4:${ipv4.join('.')}`;
|
||||
}
|
||||
|
||||
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,154 @@
|
||||
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 bulkDeleteAttachmentsByIds(
|
||||
db: D1Database,
|
||||
sqlChunkSize: SqlChunkSize,
|
||||
attachmentIds: string[]
|
||||
): Promise<void> {
|
||||
const uniqueIds = [...new Set(attachmentIds.map((id) => String(id || '').trim()).filter(Boolean))];
|
||||
if (!uniqueIds.length) return;
|
||||
const chunkSize = sqlChunkSize(0);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
await db.prepare(`DELETE FROM attachments WHERE id IN (${placeholders})`).bind(...chunk).run();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAttachmentsByCipher(db: D1Database, cipherId: string): Promise<Attachment[]> {
|
||||
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 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,378 @@
|
||||
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;
|
||||
}
|
||||
|
||||
const CIPHER_SCALAR_DATA_KEYS = new Set([
|
||||
'id',
|
||||
'userId',
|
||||
'user_id',
|
||||
'type',
|
||||
'folderId',
|
||||
'folder_id',
|
||||
'name',
|
||||
'notes',
|
||||
'favorite',
|
||||
'reprompt',
|
||||
'key',
|
||||
'createdAt',
|
||||
'created_at',
|
||||
'creationDate',
|
||||
'updatedAt',
|
||||
'updated_at',
|
||||
'revisionDate',
|
||||
'archivedAt',
|
||||
'archived_at',
|
||||
'archivedDate',
|
||||
'deletedAt',
|
||||
'deleted_at',
|
||||
'deletedDate',
|
||||
]);
|
||||
|
||||
function buildCipherData(cipher: Cipher, folderId: string | null): string {
|
||||
const payload: Record<string, unknown> = {
|
||||
...cipher,
|
||||
folderId,
|
||||
};
|
||||
for (const key of CIPHER_SCALAR_DATA_KEYS) {
|
||||
delete payload[key];
|
||||
}
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
|
||||
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 = buildCipherData(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 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 = ?, updated_at = ?,
|
||||
data = json_remove(data, '$.deletedAt', '$.deletedDate', '$.updatedAt', '$.revisionDate')
|
||||
WHERE user_id = ? AND id IN (${placeholders})`
|
||||
)
|
||||
.bind(now, now, 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 chunkSize = sqlChunkSize(2);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE ciphers
|
||||
SET deleted_at = NULL, updated_at = ?,
|
||||
data = json_remove(data, '$.deletedAt', '$.deletedDate', '$.updatedAt', '$.revisionDate')
|
||||
WHERE user_id = ? AND id IN (${placeholders})`
|
||||
)
|
||||
.bind(now, 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 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 folder_id = ?, updated_at = ?,
|
||||
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
|
||||
WHERE user_id = ? AND id IN (${placeholders})`
|
||||
)
|
||||
.bind(normalizedFolderId, now, userId, ...chunk)
|
||||
.run();
|
||||
}
|
||||
|
||||
return updateRevisionDate(userId);
|
||||
}
|
||||
|
||||
export async function bulkArchiveCiphers(
|
||||
db: D1Database,
|
||||
sqlChunkSize: SqlChunkSize,
|
||||
updateRevisionDate: UpdateRevisionDate,
|
||||
ids: string[],
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
if (ids.length === 0) return null;
|
||||
const uniqueIds = sanitizeIds(ids);
|
||||
if (!uniqueIds.length) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const chunkSize = sqlChunkSize(3);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE ciphers
|
||||
SET archived_at = ?, updated_at = ?,
|
||||
data = json_remove(data, '$.archivedAt', '$.archivedDate', '$.updatedAt', '$.revisionDate')
|
||||
WHERE user_id = ? AND id IN (${placeholders}) AND deleted_at IS NULL`
|
||||
)
|
||||
.bind(now, now, userId, ...chunk)
|
||||
.run();
|
||||
}
|
||||
|
||||
return updateRevisionDate(userId);
|
||||
}
|
||||
|
||||
export async function bulkUnarchiveCiphers(
|
||||
db: D1Database,
|
||||
sqlChunkSize: SqlChunkSize,
|
||||
updateRevisionDate: UpdateRevisionDate,
|
||||
ids: string[],
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
if (ids.length === 0) return null;
|
||||
const uniqueIds = sanitizeIds(ids);
|
||||
if (!uniqueIds.length) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const chunkSize = sqlChunkSize(2);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE ciphers
|
||||
SET archived_at = NULL, updated_at = ?,
|
||||
data = json_remove(data, '$.archivedAt', '$.archivedDate', '$.updatedAt', '$.revisionDate')
|
||||
WHERE user_id = ? AND id IN (${placeholders})`
|
||||
)
|
||||
.bind(now, userId, ...chunk)
|
||||
.run();
|
||||
}
|
||||
|
||||
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,274 @@
|
||||
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,
|
||||
deviceNote: row.device_note ?? null,
|
||||
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,
|
||||
lastSeenAt: row.last_seen_at ?? 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 existingDevice = await getDeviceById(userId, deviceIdentifier);
|
||||
const effectiveSessionStamp = String(sessionStamp || '').trim() || existingDevice?.sessionStamp || '';
|
||||
const effectiveName = String(name || '').trim() || String(existingDevice?.name || '').trim();
|
||||
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, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' +
|
||||
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
|
||||
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
|
||||
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
|
||||
'last_seen_at=excluded.last_seen_at, ' +
|
||||
'updated_at=excluded.updated_at'
|
||||
)
|
||||
.bind(
|
||||
userId,
|
||||
deviceIdentifier,
|
||||
effectiveName,
|
||||
type,
|
||||
effectiveSessionStamp,
|
||||
keys?.encryptedUserKey ?? null,
|
||||
keys?.encryptedPublicKey ?? null,
|
||||
keys?.encryptedPrivateKey ?? null,
|
||||
existingDevice?.deviceNote ?? null,
|
||||
now,
|
||||
now,
|
||||
now
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function updateDeviceName(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
deviceIdentifier: string,
|
||||
name: string
|
||||
): Promise<boolean> {
|
||||
const result = await db
|
||||
.prepare('UPDATE devices SET device_note = ? WHERE user_id = ? AND device_identifier = ?')
|
||||
.bind(String(name || '').trim(), userId, deviceIdentifier)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function touchDeviceLastSeen(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
deviceIdentifier: string
|
||||
): Promise<boolean> {
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare('UPDATE devices SET last_seen_at = ? WHERE user_id = ? AND device_identifier = ?')
|
||||
.bind(now, userId, deviceIdentifier)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function updateDeviceKeys(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
deviceIdentifier: string,
|
||||
keys: {
|
||||
encryptedUserKey?: string | null;
|
||||
encryptedPublicKey?: string | null;
|
||||
encryptedPrivateKey?: string | null;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare(
|
||||
'UPDATE devices SET encrypted_user_key = ?, encrypted_public_key = ?, encrypted_private_key = ?, updated_at = ? ' +
|
||||
'WHERE user_id = ? AND device_identifier = ?'
|
||||
)
|
||||
.bind(
|
||||
keys.encryptedUserKey ?? null,
|
||||
keys.encryptedPublicKey ?? null,
|
||||
keys.encryptedPrivateKey ?? null,
|
||||
now,
|
||||
userId,
|
||||
deviceIdentifier
|
||||
)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function clearDeviceKeys(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
deviceIdentifiers: string[]
|
||||
): Promise<number> {
|
||||
const uniqueIds = Array.from(
|
||||
new Set(deviceIdentifiers.map((id) => String(id || '').trim()).filter(Boolean))
|
||||
);
|
||||
if (!uniqueIds.length) return 0;
|
||||
|
||||
const placeholders = uniqueIds.map(() => '?').join(',');
|
||||
const result = await db
|
||||
.prepare(
|
||||
`UPDATE devices
|
||||
SET encrypted_user_key = NULL,
|
||||
encrypted_public_key = NULL,
|
||||
encrypted_private_key = NULL,
|
||||
updated_at = ?
|
||||
WHERE user_id = ? AND device_identifier IN (${placeholders})`
|
||||
)
|
||||
.bind(new Date().toISOString(), userId, ...uniqueIds)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0);
|
||||
}
|
||||
|
||||
export async function isKnownDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||
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, device_note, last_seen_at, created_at, updated_at ' +
|
||||
'FROM devices WHERE user_id = ? ORDER BY COALESCE(last_seen_at, created_at) DESC, 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, device_note, last_seen_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,73 @@
|
||||
import type { UserDomainSettings } from '../types';
|
||||
import { normalizeCustomEquivalentDomains, normalizeEquivalentDomains } from './domain-rules';
|
||||
|
||||
// Storage adapter for the domain_settings table.
|
||||
//
|
||||
// CONTRACT:
|
||||
// equivalent_domains is kept as the active derived groups for compatibility and
|
||||
// fallback reads. custom_equivalent_domains is the full rule list that preserves
|
||||
// UI/client state. Save both together through saveUserDomainSettings().
|
||||
function parseJsonArray<T>(raw: string | null | undefined, fallback: T[]): T[] {
|
||||
if (!raw) return fallback;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return Array.isArray(parsed) ? parsed as T[] : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserDomainSettings(db: D1Database, userId: string): Promise<UserDomainSettings> {
|
||||
const row = await db
|
||||
.prepare('SELECT equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings WHERE user_id = ?')
|
||||
.bind(userId)
|
||||
.first<{
|
||||
equivalent_domains: string | null;
|
||||
custom_equivalent_domains: string | null;
|
||||
excluded_global_equivalent_domains: string | null;
|
||||
updated_at: string | null;
|
||||
}>();
|
||||
const equivalentDomains = normalizeEquivalentDomains(parseJsonArray<string[]>(row?.equivalent_domains, []));
|
||||
const storedCustomEquivalentDomains = row?.custom_equivalent_domains
|
||||
? normalizeCustomEquivalentDomains(parseJsonArray<unknown>(row.custom_equivalent_domains, []))
|
||||
: [];
|
||||
const customEquivalentDomains = storedCustomEquivalentDomains.length
|
||||
? storedCustomEquivalentDomains
|
||||
: normalizeCustomEquivalentDomains(equivalentDomains);
|
||||
|
||||
return {
|
||||
userId,
|
||||
equivalentDomains,
|
||||
customEquivalentDomains,
|
||||
excludedGlobalEquivalentDomains: parseJsonArray<number>(row?.excluded_global_equivalent_domains, []),
|
||||
updatedAt: row?.updated_at || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveUserDomainSettings(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
equivalentDomains: string[][],
|
||||
customEquivalentDomains: UserDomainSettings['customEquivalentDomains'],
|
||||
excludedGlobalEquivalentDomains: number[],
|
||||
updatedAt: string
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO domain_settings(user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(user_id) DO UPDATE SET ' +
|
||||
'equivalent_domains = excluded.equivalent_domains, ' +
|
||||
'custom_equivalent_domains = excluded.custom_equivalent_domains, ' +
|
||||
'excluded_global_equivalent_domains = excluded.excluded_global_equivalent_domains, ' +
|
||||
'updated_at = excluded.updated_at'
|
||||
)
|
||||
.bind(
|
||||
userId,
|
||||
JSON.stringify(equivalentDomains),
|
||||
JSON.stringify(customEquivalentDomains),
|
||||
JSON.stringify(excludedGlobalEquivalentDomains),
|
||||
updatedAt
|
||||
)
|
||||
.run();
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { 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
|
||||
): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE ciphers
|
||||
SET folder_id = NULL, updated_at = ?,
|
||||
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
|
||||
WHERE user_id = ? AND folder_id = ?`
|
||||
)
|
||||
.bind(now, userId, folderId)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function bulkDeleteFolders(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
ids: string[],
|
||||
sqlChunkSize: (fixedBindCount: number) => number,
|
||||
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 now = new Date().toISOString();
|
||||
const chunkSize = sqlChunkSize(2);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE ciphers
|
||||
SET folder_id = NULL, updated_at = ?,
|
||||
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
|
||||
WHERE user_id = ? AND folder_id IN (${placeholders})`
|
||||
)
|
||||
.bind(now, userId, ...chunk)
|
||||
.run();
|
||||
|
||||
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,152 @@
|
||||
// IMPORTANT:
|
||||
// This is the runtime D1 schema bootstrap. Keep it in sync with
|
||||
// migrations/0001_init.sql. Any new table/column/index must be added to both
|
||||
// places together.
|
||||
//
|
||||
// WHEN CHANGING THIS:
|
||||
// - Bump STORAGE_SCHEMA_VERSION in src/services/storage.ts so existing installs
|
||||
// rerun these idempotent statements.
|
||||
// - If the new table stores persistent data, update the backup export/import
|
||||
// contract in src/services/backup-archive.ts and backup-import.ts.
|
||||
// - Keep statements idempotent; D1 may execute them again on later requests.
|
||||
const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'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, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||
'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
|
||||
'ALTER TABLE users ADD COLUMN 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',
|
||||
'ALTER TABLE users ADD COLUMN api_key TEXT',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS domain_settings (' +
|
||||
'user_id TEXT PRIMARY KEY, equivalent_domains TEXT NOT NULL DEFAULT \'[]\', custom_equivalent_domains TEXT NOT NULL DEFAULT \'[]\', excluded_global_equivalent_domains TEXT NOT NULL DEFAULT \'[]\', updated_at TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'ALTER TABLE domain_settings ADD COLUMN custom_equivalent_domains TEXT NOT NULL DEFAULT \'[]\'',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS user_revisions (' +
|
||||
'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 INDEX IF NOT EXISTS idx_ciphers_user_folder ON ciphers(user_id, folder_id)',
|
||||
|
||||
'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, device_note TEXT, last_seen_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',
|
||||
'ALTER TABLE devices ADD COLUMN device_note TEXT',
|
||||
'ALTER TABLE devices ADD COLUMN last_seen_at TEXT',
|
||||
'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' +
|
||||
'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 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,142 @@
|
||||
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, api_key, 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,
|
||||
apiKey: row.api_key ?? 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, api_key, 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, api_key=excluded.api_key, 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.apiKey,
|
||||
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, api_key, 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.apiKey,
|
||||
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,256 +1,669 @@
|
||||
import { Env, User, Cipher, Folder, Attachment } from '../types';
|
||||
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain } from '../types';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { ensureStorageSchema } from './storage-schema';
|
||||
import {
|
||||
getConfigValue as getStoredConfigValue,
|
||||
isRegistered as getRegisteredFlag,
|
||||
setConfigValue as saveConfigValue,
|
||||
setRegistered as saveRegisteredFlag,
|
||||
} from './storage-config-repo';
|
||||
import {
|
||||
createFirstUser as createFirstStoredUser,
|
||||
createUser as createStoredUser,
|
||||
deleteUserById as deleteStoredUserById,
|
||||
getAllUsers as listStoredUsers,
|
||||
getUser as findStoredUserByEmail,
|
||||
getUserById as findStoredUserById,
|
||||
getUserCount as countStoredUsers,
|
||||
saveUser as saveStoredUser,
|
||||
} from './storage-user-repo';
|
||||
import {
|
||||
createAuditLog as createStoredAuditLog,
|
||||
createInvite as createStoredInvite,
|
||||
deleteAllInvites as deleteStoredInvites,
|
||||
getInvite as findStoredInvite,
|
||||
listInvites as listStoredInvites,
|
||||
markInviteUsed as markStoredInviteUsed,
|
||||
revokeInvite as revokeStoredInvite,
|
||||
} from './storage-admin-repo';
|
||||
import {
|
||||
bulkDeleteFolders as deleteStoredFolders,
|
||||
clearFolderFromCiphers as clearStoredFolderFromCiphers,
|
||||
deleteFolder as deleteStoredFolder,
|
||||
getAllFolders as listStoredFolders,
|
||||
getFolder as findStoredFolder,
|
||||
getFoldersPage as listStoredFoldersPage,
|
||||
saveFolder as saveStoredFolder,
|
||||
} from './storage-folder-repo';
|
||||
import {
|
||||
bulkArchiveCiphers as archiveStoredCiphers,
|
||||
bulkDeleteCiphers as deleteStoredCiphers,
|
||||
bulkMoveCiphers as moveStoredCiphers,
|
||||
bulkRestoreCiphers as restoreStoredCiphers,
|
||||
bulkSoftDeleteCiphers as softDeleteStoredCiphers,
|
||||
bulkUnarchiveCiphers as unarchiveStoredCiphers,
|
||||
getAllCiphers as listStoredCiphers,
|
||||
getCipher as findStoredCipher,
|
||||
getCiphersByIds as listStoredCiphersByIds,
|
||||
getCiphersPage as listStoredCiphersPage,
|
||||
saveCipher as saveStoredCipher,
|
||||
deleteCipher as deleteStoredCipher,
|
||||
} from './storage-cipher-repo';
|
||||
import {
|
||||
addAttachmentToCipher as attachStoredAttachmentToCipher,
|
||||
bulkDeleteAttachmentsByIds as deleteStoredAttachmentsByIds,
|
||||
deleteAllAttachmentsByCipher as deleteStoredAttachmentsByCipher,
|
||||
deleteAttachment as deleteStoredAttachment,
|
||||
getAttachment as findStoredAttachment,
|
||||
getAttachmentsByCipher as listStoredAttachmentsByCipher,
|
||||
getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds,
|
||||
getAttachmentsByUserId as listStoredAttachmentsByUserId,
|
||||
saveAttachment as saveStoredAttachment,
|
||||
updateCipherRevisionDate as updateStoredCipherRevisionDate,
|
||||
} from './storage-attachment-repo';
|
||||
import {
|
||||
bulkDeleteSends as deleteStoredSends,
|
||||
deleteSend as deleteStoredSend,
|
||||
getAllSends as listStoredSends,
|
||||
getSend as findStoredSend,
|
||||
getSendsByIds as listStoredSendsByIds,
|
||||
getSendsPage as listStoredSendsPage,
|
||||
incrementSendAccessCount as incrementStoredSendAccessCount,
|
||||
saveSend as saveStoredSend,
|
||||
} from './storage-send-repo';
|
||||
import {
|
||||
constrainRefreshTokenExpiry as constrainStoredRefreshTokenExpiry,
|
||||
deleteRefreshToken as deleteStoredRefreshToken,
|
||||
deleteRefreshTokensByDevice as deleteStoredRefreshTokensByDevice,
|
||||
deleteRefreshTokensByUserId as deleteStoredRefreshTokensByUserId,
|
||||
getRefreshTokenRecord as findStoredRefreshTokenRecord,
|
||||
saveRefreshToken as saveStoredRefreshToken,
|
||||
} from './storage-refresh-token-repo';
|
||||
import {
|
||||
deleteDevice as deleteStoredDevice,
|
||||
deleteDevicesByUserId as deleteStoredDevicesByUserId,
|
||||
clearDeviceKeys as clearStoredDeviceKeys,
|
||||
deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice,
|
||||
deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId,
|
||||
getDevice as findStoredDevice,
|
||||
getDevicesByUserId as listStoredDevicesByUserId,
|
||||
getTrustedDeviceTokenSummariesByUserId as listStoredTrustedTokenSummaries,
|
||||
getTrustedTwoFactorDeviceTokenUserId as findStoredTrustedTokenUserId,
|
||||
isKnownDevice as getKnownStoredDevice,
|
||||
isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
|
||||
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
|
||||
touchDeviceLastSeen as touchStoredDeviceLastSeen,
|
||||
upsertDevice as saveStoredDevice,
|
||||
updateDeviceName as updateStoredDeviceName,
|
||||
updateDeviceKeys as updateStoredDeviceKeys,
|
||||
} from './storage-device-repo';
|
||||
import {
|
||||
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
|
||||
consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken,
|
||||
} from './storage-attachment-token-repo';
|
||||
import {
|
||||
getRevisionDate as getStoredRevisionDate,
|
||||
updateRevisionDate as updateStoredRevisionDate,
|
||||
} from './storage-revision-repo';
|
||||
import {
|
||||
getUserDomainSettings as getStoredUserDomainSettings,
|
||||
saveUserDomainSettings as saveStoredUserDomainSettings,
|
||||
} from './storage-domain-rules-repo';
|
||||
|
||||
const KEYS = {
|
||||
CONFIG_REGISTERED: 'config:registered',
|
||||
CONFIG_SETUP_DISABLED: 'config:setup_disabled',
|
||||
USER_PREFIX: 'user:',
|
||||
CIPHER_PREFIX: 'cipher:',
|
||||
FOLDER_PREFIX: 'folder:',
|
||||
ATTACHMENT_PREFIX: 'attachment:',
|
||||
CIPHERS_INDEX: 'index:ciphers',
|
||||
FOLDERS_INDEX: 'index:folders',
|
||||
ATTACHMENTS_INDEX: 'index:attachments',
|
||||
REFRESH_TOKEN_PREFIX: 'refresh:',
|
||||
REVISION_DATE_PREFIX: 'revision:',
|
||||
};
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
||||
// IMPORTANT:
|
||||
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
|
||||
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
|
||||
// differs from config.schema.version.
|
||||
const STORAGE_SCHEMA_VERSION = '2026-05-05-domain-rules-v2';
|
||||
|
||||
// D1-backed storage.
|
||||
// Contract:
|
||||
// - All methods are scoped by userId where applicable.
|
||||
// - Uses SQL constraints (PK/unique/FK) to avoid KV-style index race conditions.
|
||||
// - Revision date is maintained per user for Bitwarden sync.
|
||||
|
||||
export class StorageService {
|
||||
constructor(private kv: KVNamespace) {}
|
||||
private static attachmentTokenTableReady = false;
|
||||
private static schemaVerified = false;
|
||||
private static lastRefreshTokenCleanupAt = 0;
|
||||
private static lastAttachmentTokenCleanupAt = 0;
|
||||
private static readonly MAX_D1_SQL_VARIABLES = 100;
|
||||
|
||||
private static readonly REFRESH_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.refreshTokenCleanupIntervalMs;
|
||||
private static readonly ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.attachmentTokenCleanupIntervalMs;
|
||||
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.cleanup.cleanupProbability;
|
||||
|
||||
constructor(private db: D1Database) {}
|
||||
|
||||
/**
|
||||
* D1 .bind() throws on `undefined` values. This helper converts every
|
||||
* `undefined` in the argument list to `null` so we never hit that runtime
|
||||
* error - especially important after the opaque-passthrough change where
|
||||
* client-supplied JSON may omit fields we later reference as columns.
|
||||
*/
|
||||
private safeBind(stmt: D1PreparedStatement, ...values: any[]): D1PreparedStatement {
|
||||
return stmt.bind(...values.map(v => v === undefined ? null : v));
|
||||
}
|
||||
|
||||
private sqlChunkSize(fixedBindCount: number): number {
|
||||
return Math.max(
|
||||
1,
|
||||
Math.min(LIMITS.performance.bulkMoveChunkSize, StorageService.MAX_D1_SQL_VARIABLES - fixedBindCount)
|
||||
);
|
||||
}
|
||||
|
||||
private async sha256Hex(input: string): Promise<string> {
|
||||
const bytes = new TextEncoder().encode(input);
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||
return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
private async refreshTokenKey(token: string): Promise<string> {
|
||||
const digest = await this.sha256Hex(token);
|
||||
return `sha256:${digest}`;
|
||||
}
|
||||
|
||||
private shouldRunPeriodicCleanup(lastRunAt: number, intervalMs: number): boolean {
|
||||
const now = Date.now();
|
||||
if (now - lastRunAt < intervalMs) return false;
|
||||
return Math.random() < StorageService.PERIODIC_CLEANUP_PROBABILITY;
|
||||
}
|
||||
|
||||
private async maybeCleanupExpiredRefreshTokens(nowMs: number): Promise<void> {
|
||||
if (!this.shouldRunPeriodicCleanup(StorageService.lastRefreshTokenCleanupAt, StorageService.REFRESH_TOKEN_CLEANUP_INTERVAL_MS)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.db.prepare('DELETE FROM refresh_tokens WHERE expires_at < ?').bind(nowMs).run();
|
||||
StorageService.lastRefreshTokenCleanupAt = nowMs;
|
||||
}
|
||||
|
||||
// --- Database initialization ---
|
||||
// Strategy:
|
||||
// - Run only once per isolate.
|
||||
// - Execute idempotent schema SQL on first request in each isolate.
|
||||
// - Keep statements idempotent so updates are safe.
|
||||
async initializeDatabase(): Promise<void> {
|
||||
if (StorageService.schemaVerified) return;
|
||||
|
||||
await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run();
|
||||
const schemaVersion = await getStoredConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY);
|
||||
if (schemaVersion !== STORAGE_SCHEMA_VERSION) {
|
||||
await ensureStorageSchema(this.db);
|
||||
await saveConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY, STORAGE_SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
StorageService.schemaVerified = true;
|
||||
}
|
||||
|
||||
// --- Config / setup ---
|
||||
|
||||
// Registration status
|
||||
async isRegistered(): Promise<boolean> {
|
||||
const value = await this.kv.get(KEYS.CONFIG_REGISTERED);
|
||||
return value === 'true';
|
||||
return getRegisteredFlag(this.db);
|
||||
}
|
||||
|
||||
async getConfigValue(key: string): Promise<string | null> {
|
||||
return getStoredConfigValue(this.db, key);
|
||||
}
|
||||
|
||||
async setConfigValue(key: string, value: string): Promise<void> {
|
||||
await saveConfigValue(this.db, key, value);
|
||||
}
|
||||
|
||||
async setRegistered(): Promise<void> {
|
||||
await this.kv.put(KEYS.CONFIG_REGISTERED, 'true');
|
||||
await saveRegisteredFlag(this.db);
|
||||
}
|
||||
|
||||
// Setup page visibility
|
||||
async isSetupDisabled(): Promise<boolean> {
|
||||
const value = await this.kv.get(KEYS.CONFIG_SETUP_DISABLED);
|
||||
return value === 'true';
|
||||
}
|
||||
// --- Users ---
|
||||
|
||||
async setSetupDisabled(): Promise<void> {
|
||||
await this.kv.put(KEYS.CONFIG_SETUP_DISABLED, 'true');
|
||||
}
|
||||
|
||||
// User operations
|
||||
async getUser(email: string): Promise<User | null> {
|
||||
const data = await this.kv.get(`${KEYS.USER_PREFIX}${email.toLowerCase()}`);
|
||||
return data ? JSON.parse(data) : null;
|
||||
return findStoredUserByEmail(this.db, email);
|
||||
}
|
||||
|
||||
async getUserById(id: string): Promise<User | null> {
|
||||
// Get user email from id mapping
|
||||
const email = await this.kv.get(`userid:${id}`);
|
||||
if (!email) return null;
|
||||
return this.getUser(email);
|
||||
return findStoredUserById(this.db, id);
|
||||
}
|
||||
|
||||
async getUserCount(): Promise<number> {
|
||||
return countStoredUsers(this.db);
|
||||
}
|
||||
|
||||
async getAllUsers(): Promise<User[]> {
|
||||
return listStoredUsers(this.db);
|
||||
}
|
||||
|
||||
async saveUser(user: User): Promise<void> {
|
||||
await this.kv.put(`${KEYS.USER_PREFIX}${user.email.toLowerCase()}`, JSON.stringify(user));
|
||||
await this.kv.put(`userid:${user.id}`, user.email.toLowerCase());
|
||||
await saveStoredUser(this.db, this.safeBind.bind(this), user);
|
||||
}
|
||||
|
||||
// Cipher operations
|
||||
async getCipher(id: string): Promise<Cipher | null> {
|
||||
const data = await this.kv.get(`${KEYS.CIPHER_PREFIX}${id}`);
|
||||
return data ? JSON.parse(data) : null;
|
||||
async createUser(user: User): Promise<void> {
|
||||
await createStoredUser(this.db, this.safeBind.bind(this), user);
|
||||
}
|
||||
|
||||
async saveCipher(cipher: Cipher): Promise<void> {
|
||||
await this.kv.put(`${KEYS.CIPHER_PREFIX}${cipher.id}`, JSON.stringify(cipher));
|
||||
|
||||
// Update index
|
||||
const index = await this.getCipherIds(cipher.userId);
|
||||
if (!index.includes(cipher.id)) {
|
||||
index.push(cipher.id);
|
||||
await this.kv.put(`${KEYS.CIPHERS_INDEX}:${cipher.userId}`, JSON.stringify(index));
|
||||
}
|
||||
async createFirstUser(user: User): Promise<boolean> {
|
||||
return createFirstStoredUser(this.db, this.safeBind.bind(this), user);
|
||||
}
|
||||
|
||||
async deleteCipher(id: string, userId: string): Promise<void> {
|
||||
await this.kv.delete(`${KEYS.CIPHER_PREFIX}${id}`);
|
||||
|
||||
// Update index
|
||||
const index = await this.getCipherIds(userId);
|
||||
const newIndex = index.filter(cid => cid !== id);
|
||||
await this.kv.put(`${KEYS.CIPHERS_INDEX}:${userId}`, JSON.stringify(newIndex));
|
||||
async deleteUserById(id: string): Promise<boolean> {
|
||||
return deleteStoredUserById(this.db, id);
|
||||
}
|
||||
|
||||
async getCipherIds(userId: string): Promise<string[]> {
|
||||
const data = await this.kv.get(`${KEYS.CIPHERS_INDEX}:${userId}`);
|
||||
return data ? JSON.parse(data) : [];
|
||||
async createInvite(invite: Invite): Promise<void> {
|
||||
await createStoredInvite(this.db, invite);
|
||||
}
|
||||
|
||||
async getAllCiphers(userId: string): Promise<Cipher[]> {
|
||||
const ids = await this.getCipherIds(userId);
|
||||
const ciphers: Cipher[] = [];
|
||||
|
||||
for (const id of ids) {
|
||||
const cipher = await this.getCipher(id);
|
||||
if (cipher) ciphers.push(cipher);
|
||||
}
|
||||
|
||||
return ciphers;
|
||||
async getInvite(code: string): Promise<Invite | null> {
|
||||
return findStoredInvite(this.db, code);
|
||||
}
|
||||
|
||||
// Folder operations
|
||||
async getFolder(id: string): Promise<Folder | null> {
|
||||
const data = await this.kv.get(`${KEYS.FOLDER_PREFIX}${id}`);
|
||||
return data ? JSON.parse(data) : null;
|
||||
async listInvites(includeInactive: boolean = false): Promise<Invite[]> {
|
||||
return listStoredInvites(this.db, includeInactive);
|
||||
}
|
||||
|
||||
async saveFolder(folder: Folder): Promise<void> {
|
||||
await this.kv.put(`${KEYS.FOLDER_PREFIX}${folder.id}`, JSON.stringify(folder));
|
||||
|
||||
// Update index
|
||||
const index = await this.getFolderIds(folder.userId);
|
||||
if (!index.includes(folder.id)) {
|
||||
index.push(folder.id);
|
||||
await this.kv.put(`${KEYS.FOLDERS_INDEX}:${folder.userId}`, JSON.stringify(index));
|
||||
}
|
||||
async markInviteUsed(code: string, userId: string): Promise<boolean> {
|
||||
return markStoredInviteUsed(this.db, code, userId);
|
||||
}
|
||||
|
||||
async deleteFolder(id: string, userId: string): Promise<void> {
|
||||
await this.kv.delete(`${KEYS.FOLDER_PREFIX}${id}`);
|
||||
|
||||
// Update index
|
||||
const index = await this.getFolderIds(userId);
|
||||
const newIndex = index.filter(fid => fid !== id);
|
||||
await this.kv.put(`${KEYS.FOLDERS_INDEX}:${userId}`, JSON.stringify(newIndex));
|
||||
async revokeInvite(code: string): Promise<boolean> {
|
||||
return revokeStoredInvite(this.db, code);
|
||||
}
|
||||
|
||||
async getFolderIds(userId: string): Promise<string[]> {
|
||||
const data = await this.kv.get(`${KEYS.FOLDERS_INDEX}:${userId}`);
|
||||
return data ? JSON.parse(data) : [];
|
||||
async deleteAllInvites(): Promise<number> {
|
||||
return deleteStoredInvites(this.db);
|
||||
}
|
||||
|
||||
async getAllFolders(userId: string): Promise<Folder[]> {
|
||||
const ids = await this.getFolderIds(userId);
|
||||
const folders: Folder[] = [];
|
||||
|
||||
for (const id of ids) {
|
||||
const folder = await this.getFolder(id);
|
||||
if (folder) folders.push(folder);
|
||||
}
|
||||
|
||||
return folders;
|
||||
async createAuditLog(log: AuditLog): Promise<void> {
|
||||
await createStoredAuditLog(this.db, log);
|
||||
}
|
||||
|
||||
// Refresh token operations
|
||||
async saveRefreshToken(token: string, userId: string): Promise<void> {
|
||||
// Store refresh token with 30 day expiry
|
||||
await this.kv.put(`${KEYS.REFRESH_TOKEN_PREFIX}${token}`, userId, {
|
||||
expirationTtl: 30 * 24 * 60 * 60,
|
||||
});
|
||||
// --- Domain rules ---
|
||||
|
||||
async getUserDomainSettings(userId: string) {
|
||||
return getStoredUserDomainSettings(this.db, userId);
|
||||
}
|
||||
|
||||
async getRefreshTokenUserId(token: string): Promise<string | null> {
|
||||
return await this.kv.get(`${KEYS.REFRESH_TOKEN_PREFIX}${token}`);
|
||||
}
|
||||
|
||||
async deleteRefreshToken(token: string): Promise<void> {
|
||||
await this.kv.delete(`${KEYS.REFRESH_TOKEN_PREFIX}${token}`);
|
||||
}
|
||||
|
||||
// Revision date operations (for incremental sync)
|
||||
async getRevisionDate(userId: string): Promise<string> {
|
||||
const date = await this.kv.get(`${KEYS.REVISION_DATE_PREFIX}${userId}`);
|
||||
return date || new Date().toISOString();
|
||||
}
|
||||
|
||||
async updateRevisionDate(userId: string): Promise<string> {
|
||||
const date = new Date().toISOString();
|
||||
await this.kv.put(`${KEYS.REVISION_DATE_PREFIX}${userId}`, date);
|
||||
return date;
|
||||
}
|
||||
|
||||
// Bulk cipher operations
|
||||
async getCiphersByIds(ids: string[], userId: string): Promise<Cipher[]> {
|
||||
const ciphers: Cipher[] = [];
|
||||
for (const id of ids) {
|
||||
const cipher = await this.getCipher(id);
|
||||
if (cipher && cipher.userId === userId) {
|
||||
ciphers.push(cipher);
|
||||
}
|
||||
}
|
||||
return ciphers;
|
||||
}
|
||||
|
||||
async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
for (const id of ids) {
|
||||
const cipher = await this.getCipher(id);
|
||||
if (cipher && cipher.userId === userId) {
|
||||
cipher.folderId = folderId;
|
||||
cipher.updatedAt = now;
|
||||
await this.saveCipher(cipher);
|
||||
}
|
||||
}
|
||||
async saveUserDomainSettings(
|
||||
userId: string,
|
||||
equivalentDomains: string[][],
|
||||
customEquivalentDomains: CustomEquivalentDomain[],
|
||||
excludedGlobalEquivalentDomains: number[]
|
||||
): Promise<void> {
|
||||
await saveStoredUserDomainSettings(
|
||||
this.db,
|
||||
userId,
|
||||
equivalentDomains,
|
||||
customEquivalentDomains,
|
||||
excludedGlobalEquivalentDomains,
|
||||
new Date().toISOString()
|
||||
);
|
||||
await this.updateRevisionDate(userId);
|
||||
}
|
||||
|
||||
// Attachment operations
|
||||
// --- Ciphers ---
|
||||
|
||||
async getCipher(id: string): Promise<Cipher | null> {
|
||||
return findStoredCipher(this.db, id);
|
||||
}
|
||||
|
||||
async saveCipher(cipher: Cipher): Promise<void> {
|
||||
await saveStoredCipher(this.db, this.safeBind.bind(this), cipher);
|
||||
}
|
||||
|
||||
async deleteCipher(id: string, userId: string): Promise<void> {
|
||||
await deleteStoredCipher(this.db, id, userId);
|
||||
}
|
||||
|
||||
async bulkSoftDeleteCiphers(ids: string[], userId: string): Promise<string | null> {
|
||||
return softDeleteStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
|
||||
}
|
||||
|
||||
async bulkRestoreCiphers(ids: string[], userId: string): Promise<string | null> {
|
||||
return restoreStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
|
||||
}
|
||||
|
||||
async bulkArchiveCiphers(ids: string[], userId: string): Promise<string | null> {
|
||||
return archiveStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
|
||||
}
|
||||
|
||||
async bulkUnarchiveCiphers(ids: string[], userId: string): Promise<string | null> {
|
||||
return unarchiveStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
|
||||
}
|
||||
|
||||
async bulkDeleteCiphers(ids: string[], userId: string): Promise<string | null> {
|
||||
return deleteStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
|
||||
}
|
||||
|
||||
async getAllCiphers(userId: string): Promise<Cipher[]> {
|
||||
return listStoredCiphers(this.db, userId);
|
||||
}
|
||||
|
||||
async getCiphersPage(userId: string, includeDeleted: boolean, limit: number, offset: number): Promise<Cipher[]> {
|
||||
return listStoredCiphersPage(this.db, userId, includeDeleted, limit, offset);
|
||||
}
|
||||
|
||||
async getCiphersByIds(ids: string[], userId: string): Promise<Cipher[]> {
|
||||
return listStoredCiphersByIds(this.db, this.sqlChunkSize.bind(this), ids, userId);
|
||||
}
|
||||
|
||||
async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise<string | null> {
|
||||
return moveStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, folderId, userId);
|
||||
}
|
||||
|
||||
// --- Folders ---
|
||||
|
||||
async getFolder(id: string): Promise<Folder | null> {
|
||||
return findStoredFolder(this.db, id);
|
||||
}
|
||||
|
||||
async saveFolder(folder: Folder): Promise<void> {
|
||||
await saveStoredFolder(this.db, folder);
|
||||
}
|
||||
|
||||
async deleteFolder(id: string, userId: string): Promise<void> {
|
||||
await deleteStoredFolder(this.db, id, userId);
|
||||
}
|
||||
|
||||
async bulkDeleteFolders(ids: string[], userId: string): Promise<string | null> {
|
||||
return deleteStoredFolders(
|
||||
this.db,
|
||||
userId,
|
||||
ids,
|
||||
this.sqlChunkSize.bind(this),
|
||||
this.updateRevisionDate.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
// Clear folder references from all ciphers owned by the user.
|
||||
// Without this, deleting a folder leaves stale folderId values in cipher JSON.
|
||||
async clearFolderFromCiphers(userId: string, folderId: string): Promise<void> {
|
||||
await clearStoredFolderFromCiphers(this.db, userId, folderId);
|
||||
}
|
||||
|
||||
async getAllFolders(userId: string): Promise<Folder[]> {
|
||||
return listStoredFolders(this.db, userId);
|
||||
}
|
||||
|
||||
async getFoldersPage(userId: string, limit: number, offset: number): Promise<Folder[]> {
|
||||
return listStoredFoldersPage(this.db, userId, limit, offset);
|
||||
}
|
||||
|
||||
// --- Attachments ---
|
||||
|
||||
async getAttachment(id: string): Promise<Attachment | null> {
|
||||
const data = await this.kv.get(`${KEYS.ATTACHMENT_PREFIX}${id}`);
|
||||
return data ? JSON.parse(data) : null;
|
||||
return findStoredAttachment(this.db, id);
|
||||
}
|
||||
|
||||
async saveAttachment(attachment: Attachment): Promise<void> {
|
||||
await this.kv.put(`${KEYS.ATTACHMENT_PREFIX}${attachment.id}`, JSON.stringify(attachment));
|
||||
await saveStoredAttachment(this.db, this.safeBind.bind(this), attachment);
|
||||
}
|
||||
|
||||
async deleteAttachment(id: string): Promise<void> {
|
||||
await this.kv.delete(`${KEYS.ATTACHMENT_PREFIX}${id}`);
|
||||
await deleteStoredAttachment(this.db, id);
|
||||
}
|
||||
|
||||
async getAttachmentIdsByCipher(cipherId: string): Promise<string[]> {
|
||||
const data = await this.kv.get(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`);
|
||||
return data ? JSON.parse(data) : [];
|
||||
async bulkDeleteAttachmentsByIds(ids: string[]): Promise<void> {
|
||||
await deleteStoredAttachmentsByIds(this.db, this.sqlChunkSize.bind(this), ids);
|
||||
}
|
||||
|
||||
async getAttachmentsByCipher(cipherId: string): Promise<Attachment[]> {
|
||||
const ids = await this.getAttachmentIdsByCipher(cipherId);
|
||||
const attachments: Attachment[] = [];
|
||||
for (const id of ids) {
|
||||
const attachment = await this.getAttachment(id);
|
||||
if (attachment) attachments.push(attachment);
|
||||
}
|
||||
return attachments;
|
||||
return listStoredAttachmentsByCipher(this.db, cipherId);
|
||||
}
|
||||
|
||||
async getAttachmentsByCipherIds(cipherIds: string[]): Promise<Map<string, Attachment[]>> {
|
||||
return listStoredAttachmentsByCipherIds(this.db, this.sqlChunkSize.bind(this), cipherIds);
|
||||
}
|
||||
|
||||
async getAttachmentsByUserId(userId: string): Promise<Map<string, Attachment[]>> {
|
||||
return listStoredAttachmentsByUserId(this.db, userId);
|
||||
}
|
||||
|
||||
async addAttachmentToCipher(cipherId: string, attachmentId: string): Promise<void> {
|
||||
const ids = await this.getAttachmentIdsByCipher(cipherId);
|
||||
if (!ids.includes(attachmentId)) {
|
||||
ids.push(attachmentId);
|
||||
await this.kv.put(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`, JSON.stringify(ids));
|
||||
}
|
||||
}
|
||||
|
||||
async removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise<void> {
|
||||
const ids = await this.getAttachmentIdsByCipher(cipherId);
|
||||
const newIds = ids.filter(id => id !== attachmentId);
|
||||
await this.kv.put(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`, JSON.stringify(newIds));
|
||||
await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId);
|
||||
}
|
||||
|
||||
async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> {
|
||||
const ids = await this.getAttachmentIdsByCipher(cipherId);
|
||||
for (const id of ids) {
|
||||
await this.deleteAttachment(id);
|
||||
}
|
||||
await this.kv.delete(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`);
|
||||
await deleteStoredAttachmentsByCipher(this.db, cipherId);
|
||||
}
|
||||
|
||||
async updateCipherRevisionDate(cipherId: string): Promise<void> {
|
||||
const cipher = await this.getCipher(cipherId);
|
||||
if (cipher) {
|
||||
cipher.updatedAt = new Date().toISOString();
|
||||
await this.saveCipher(cipher);
|
||||
await this.updateRevisionDate(cipher.userId);
|
||||
async updateCipherRevisionDate(cipherId: string): Promise<{ userId: string; revisionDate: string } | null> {
|
||||
return updateStoredCipherRevisionDate(
|
||||
this.getCipher.bind(this),
|
||||
this.saveCipher.bind(this),
|
||||
this.updateRevisionDate.bind(this),
|
||||
cipherId
|
||||
);
|
||||
}
|
||||
|
||||
// --- Refresh tokens ---
|
||||
|
||||
async saveRefreshToken(
|
||||
token: string,
|
||||
userId: string,
|
||||
expiresAtMs?: number,
|
||||
deviceIdentifier?: string | null,
|
||||
deviceSessionStamp?: string | null
|
||||
): Promise<void> {
|
||||
const expiresAt = expiresAtMs ?? (Date.now() + LIMITS.auth.refreshTokenTtlMs);
|
||||
await saveStoredRefreshToken(
|
||||
this.db,
|
||||
this.refreshTokenKey.bind(this),
|
||||
this.maybeCleanupExpiredRefreshTokens.bind(this),
|
||||
token,
|
||||
userId,
|
||||
expiresAt,
|
||||
deviceIdentifier,
|
||||
deviceSessionStamp
|
||||
);
|
||||
}
|
||||
|
||||
async getRefreshTokenRecord(token: string): Promise<RefreshTokenRecord | null> {
|
||||
return findStoredRefreshTokenRecord(
|
||||
this.db,
|
||||
this.refreshTokenKey.bind(this),
|
||||
this.maybeCleanupExpiredRefreshTokens.bind(this),
|
||||
this.saveRefreshToken.bind(this),
|
||||
this.deleteRefreshToken.bind(this),
|
||||
token
|
||||
);
|
||||
}
|
||||
|
||||
async getRefreshTokenUserId(token: string): Promise<string | null> {
|
||||
const record = await this.getRefreshTokenRecord(token);
|
||||
return record?.userId ?? null;
|
||||
}
|
||||
|
||||
async deleteRefreshToken(token: string): Promise<void> {
|
||||
await deleteStoredRefreshToken(this.db, this.refreshTokenKey.bind(this), token);
|
||||
}
|
||||
|
||||
// --- Sends ---
|
||||
|
||||
async getSend(id: string): Promise<Send | null> {
|
||||
return findStoredSend(this.db, id);
|
||||
}
|
||||
|
||||
async saveSend(send: Send): Promise<void> {
|
||||
await saveStoredSend(this.db, this.safeBind.bind(this), send);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically increment access_count and update updated_at.
|
||||
* Returns true if the row was updated (send still available),
|
||||
* false if max_access_count has already been reached.
|
||||
*/
|
||||
async incrementSendAccessCount(sendId: string): Promise<boolean> {
|
||||
return incrementStoredSendAccessCount(this.db, sendId);
|
||||
}
|
||||
|
||||
async deleteSend(id: string, userId: string): Promise<void> {
|
||||
await deleteStoredSend(this.db, id, userId);
|
||||
}
|
||||
|
||||
async getSendsByIds(ids: string[], userId: string): Promise<Send[]> {
|
||||
return listStoredSendsByIds(this.db, this.sqlChunkSize.bind(this), ids, userId);
|
||||
}
|
||||
|
||||
async bulkDeleteSends(ids: string[], userId: string): Promise<string | null> {
|
||||
return deleteStoredSends(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
|
||||
}
|
||||
|
||||
async getAllSends(userId: string): Promise<Send[]> {
|
||||
return listStoredSends(this.db, userId);
|
||||
}
|
||||
|
||||
async getSendsPage(userId: string, limit: number, offset: number): Promise<Send[]> {
|
||||
return listStoredSendsPage(this.db, userId, limit, offset);
|
||||
}
|
||||
|
||||
async deleteRefreshTokensByUserId(userId: string): Promise<number> {
|
||||
return deleteStoredRefreshTokensByUserId(this.db, userId);
|
||||
}
|
||||
|
||||
async deleteRefreshTokensByDevice(userId: string, deviceIdentifier: string): Promise<number> {
|
||||
return deleteStoredRefreshTokensByDevice(this.db, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
// Keep a short overlap window for rotated refresh token to reduce
|
||||
// multi-context refresh races (e.g. browser extension popup/background).
|
||||
// Expiry is only tightened, never extended.
|
||||
async constrainRefreshTokenExpiry(token: string, maxExpiresAtMs: number): Promise<void> {
|
||||
await constrainStoredRefreshTokenExpiry(this.db, this.refreshTokenKey.bind(this), token, maxExpiresAtMs);
|
||||
}
|
||||
|
||||
private async trustedTwoFactorTokenKey(token: string): Promise<string> {
|
||||
const digest = await this.sha256Hex(token);
|
||||
return `sha256:${digest}`;
|
||||
}
|
||||
|
||||
// --- Devices ---
|
||||
|
||||
async upsertDevice(
|
||||
userId: string,
|
||||
deviceIdentifier: string,
|
||||
name: string,
|
||||
type: number,
|
||||
sessionStamp?: string,
|
||||
keys?: {
|
||||
encryptedUserKey?: string | null;
|
||||
encryptedPublicKey?: string | null;
|
||||
encryptedPrivateKey?: string | null;
|
||||
}
|
||||
): Promise<void> {
|
||||
await saveStoredDevice(this.db, this.getDevice.bind(this), userId, deviceIdentifier, name, type, sessionStamp, keys);
|
||||
}
|
||||
|
||||
async isKnownDevice(userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||
return getKnownStoredDevice(this.db, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
async isKnownDeviceByEmail(email: string, deviceIdentifier: string): Promise<boolean> {
|
||||
return getKnownStoredDeviceByEmail(this.getUser.bind(this), this.isKnownDevice.bind(this), email, deviceIdentifier);
|
||||
}
|
||||
|
||||
async getDevicesByUserId(userId: string): Promise<Device[]> {
|
||||
return listStoredDevicesByUserId(this.db, userId);
|
||||
}
|
||||
|
||||
async getDevice(userId: string, deviceIdentifier: string): Promise<Device | null> {
|
||||
return findStoredDevice(this.db, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
async updateDeviceKeys(
|
||||
userId: string,
|
||||
deviceIdentifier: string,
|
||||
keys: {
|
||||
encryptedUserKey?: string | null;
|
||||
encryptedPublicKey?: string | null;
|
||||
encryptedPrivateKey?: string | null;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
return updateStoredDeviceKeys(this.db, userId, deviceIdentifier, keys);
|
||||
}
|
||||
|
||||
async updateDeviceName(userId: string, deviceIdentifier: string, name: string): Promise<boolean> {
|
||||
return updateStoredDeviceName(this.db, userId, deviceIdentifier, name);
|
||||
}
|
||||
|
||||
async touchDeviceLastSeen(userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||
return touchStoredDeviceLastSeen(this.db, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> {
|
||||
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
|
||||
}
|
||||
|
||||
async deleteDevice(userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||
return deleteStoredDevice(this.db, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
async deleteDevicesByUserId(userId: string): Promise<number> {
|
||||
return deleteStoredDevicesByUserId(this.db, userId);
|
||||
}
|
||||
|
||||
async getTrustedDeviceTokenSummariesByUserId(userId: string): Promise<TrustedDeviceTokenSummary[]> {
|
||||
return listStoredTrustedTokenSummaries(this.db, userId);
|
||||
}
|
||||
|
||||
async deleteTrustedTwoFactorTokensByDevice(userId: string, deviceIdentifier: string): Promise<number> {
|
||||
return deleteStoredTrustedTokensByDevice(this.db, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
async deleteTrustedTwoFactorTokensByUserId(userId: string): Promise<number> {
|
||||
return deleteStoredTrustedTokensByUserId(this.db, userId);
|
||||
}
|
||||
|
||||
// --- Trusted 2FA remember tokens (device-bound) ---
|
||||
|
||||
async saveTrustedTwoFactorDeviceToken(
|
||||
token: string,
|
||||
userId: string,
|
||||
deviceIdentifier: string,
|
||||
expiresAtMs?: number
|
||||
): Promise<void> {
|
||||
const expiresAt = expiresAtMs ?? (Date.now() + TWO_FACTOR_REMEMBER_TTL_MS);
|
||||
await saveStoredTrustedDeviceToken(this.db, this.trustedTwoFactorTokenKey.bind(this), token, userId, deviceIdentifier, expiresAt);
|
||||
}
|
||||
|
||||
async getTrustedTwoFactorDeviceTokenUserId(token: string, deviceIdentifier: string): Promise<string | null> {
|
||||
return findStoredTrustedTokenUserId(this.db, this.trustedTwoFactorTokenKey.bind(this), token, deviceIdentifier);
|
||||
}
|
||||
|
||||
// --- Revision dates ---
|
||||
|
||||
async getRevisionDate(userId: string): Promise<string> {
|
||||
return getStoredRevisionDate(this.db, userId);
|
||||
}
|
||||
|
||||
async updateRevisionDate(userId: string): Promise<string> {
|
||||
return updateStoredRevisionDate(this.db, userId);
|
||||
}
|
||||
|
||||
// --- One-time attachment download tokens ---
|
||||
|
||||
private async ensureUsedAttachmentDownloadTokenTable(): Promise<void> {
|
||||
if (StorageService.attachmentTokenTableReady) return;
|
||||
await ensureStoredAttachmentTokenTable(this.db);
|
||||
|
||||
StorageService.attachmentTokenTableReady = true;
|
||||
}
|
||||
|
||||
// Marks an attachment download token JTI as consumed.
|
||||
// Returns true only on first use. Reuse returns false.
|
||||
async consumeAttachmentDownloadToken(jti: string, expUnixSeconds: number): Promise<boolean> {
|
||||
await this.ensureUsedAttachmentDownloadTokenTable();
|
||||
const result = await consumeStoredAttachmentDownloadToken(
|
||||
this.db,
|
||||
this.shouldRunPeriodicCleanup.bind(this),
|
||||
StorageService.lastAttachmentTokenCleanupAt,
|
||||
StorageService.ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS,
|
||||
jti,
|
||||
expUnixSeconds
|
||||
);
|
||||
if (result.cleanedUpAt !== null) {
|
||||
StorageService.lastAttachmentTokenCleanupAt = result.cleanedUpAt;
|
||||
}
|
||||
return result.consumed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
[
|
||||
{"type":2,"domains":["ameritrade.com","tdameritrade.com"],"excluded":false},
|
||||
{"type":3,"domains":["bankofamerica.com","bofa.com","mbna.com","usecfo.com"],"excluded":false},
|
||||
{"type":4,"domains":["sprint.com","sprintpcs.com","nextel.com"],"excluded":false},
|
||||
{"type":0,"domains":["youtube.com","google.com","gmail.com"],"excluded":false},
|
||||
{"type":1,"domains":["apple.com","icloud.com"],"excluded":false},
|
||||
{"type":5,"domains":["wellsfargo.com","wf.com","wellsfargoadvisors.com"],"excluded":false},
|
||||
{"type":6,"domains":["mymerrill.com","ml.com","merrilledge.com"],"excluded":false},
|
||||
{"type":7,"domains":["accountonline.com","citi.com","citibank.com","citicards.com","citibankonline.com"],"excluded":false},
|
||||
{"type":8,"domains":["cnet.com","cnettv.com","com.com","download.com","news.com","search.com","upload.com"],"excluded":false},
|
||||
{"type":9,"domains":["bananarepublic.com","gap.com","oldnavy.com","piperlime.com"],"excluded":false},
|
||||
{"type":10,"domains":["bing.com","hotmail.com","live.com","microsoft.com","msn.com","passport.net","windows.com","microsoftonline.com","office.com","office365.com","microsoftstore.com","xbox.com","azure.com","windowsazure.com","cloud.microsoft"],"excluded":false},
|
||||
{"type":11,"domains":["ua2go.com","ual.com","united.com","unitedwifi.com"],"excluded":false},
|
||||
{"type":12,"domains":["overture.com","yahoo.com"],"excluded":false},
|
||||
{"type":13,"domains":["zonealarm.com","zonelabs.com"],"excluded":false},
|
||||
{"type":14,"domains":["paypal.com","paypal-search.com"],"excluded":false},
|
||||
{"type":15,"domains":["avon.com","youravon.com"],"excluded":false},
|
||||
{"type":16,"domains":["diapers.com","soap.com","wag.com","yoyo.com","beautybar.com","casa.com","afterschool.com","vine.com","bookworm.com","look.com","vinemarket.com"],"excluded":false},
|
||||
{"type":17,"domains":["1800contacts.com","800contacts.com"],"excluded":false},
|
||||
{"type":18,"domains":["amazon.com","amazon.com.be","amazon.ae","amazon.ca","amazon.co.uk","amazon.com.au","amazon.com.br","amazon.com.mx","amazon.com.tr","amazon.de","amazon.es","amazon.fr","amazon.in","amazon.it","amazon.nl","amazon.pl","amazon.sa","amazon.se","amazon.sg"],"excluded":false},
|
||||
{"type":19,"domains":["cox.com","cox.net","coxbusiness.com"],"excluded":false},
|
||||
{"type":20,"domains":["mynortonaccount.com","norton.com"],"excluded":false},
|
||||
{"type":21,"domains":["verizon.com","verizon.net"],"excluded":false},
|
||||
{"type":22,"domains":["rakuten.com","buy.com"],"excluded":false},
|
||||
{"type":23,"domains":["siriusxm.com","sirius.com"],"excluded":false},
|
||||
{"type":24,"domains":["ea.com","origin.com","play4free.com","tiberiumalliance.com"],"excluded":false},
|
||||
{"type":25,"domains":["37signals.com","basecamp.com","basecamphq.com","highrisehq.com"],"excluded":false},
|
||||
{"type":26,"domains":["steampowered.com","steamcommunity.com","steamgames.com"],"excluded":false},
|
||||
{"type":27,"domains":["chart.io","chartio.com"],"excluded":false},
|
||||
{"type":28,"domains":["gotomeeting.com","citrixonline.com"],"excluded":false},
|
||||
{"type":29,"domains":["gogoair.com","gogoinflight.com"],"excluded":false},
|
||||
{"type":30,"domains":["mysql.com","oracle.com"],"excluded":false},
|
||||
{"type":31,"domains":["discover.com","discovercard.com"],"excluded":false},
|
||||
{"type":32,"domains":["dcu.org","dcu-online.org"],"excluded":false},
|
||||
{"type":33,"domains":["healthcare.gov","cuidadodesalud.gov","cms.gov"],"excluded":false},
|
||||
{"type":34,"domains":["pepco.com","pepcoholdings.com"],"excluded":false},
|
||||
{"type":35,"domains":["century21.com","21online.com"],"excluded":false},
|
||||
{"type":36,"domains":["comcast.com","comcast.net","xfinity.com"],"excluded":false},
|
||||
{"type":37,"domains":["cricketwireless.com","aiowireless.com"],"excluded":false},
|
||||
{"type":38,"domains":["mandtbank.com","mtb.com"],"excluded":false},
|
||||
{"type":39,"domains":["dropbox.com","getdropbox.com"],"excluded":false},
|
||||
{"type":40,"domains":["snapfish.com","snapfish.ca"],"excluded":false},
|
||||
{"type":41,"domains":["alibaba.com","aliexpress.com","aliyun.com","net.cn"],"excluded":false},
|
||||
{"type":42,"domains":["playstation.com","sonyentertainmentnetwork.com"],"excluded":false},
|
||||
{"type":43,"domains":["mercadolivre.com","mercadolivre.com.br","mercadolibre.com","mercadolibre.com.ar","mercadolibre.com.mx"],"excluded":false},
|
||||
{"type":44,"domains":["zendesk.com","zopim.com"],"excluded":false},
|
||||
{"type":45,"domains":["autodesk.com","tinkercad.com"],"excluded":false},
|
||||
{"type":46,"domains":["railnation.ru","railnation.de","rail-nation.com","railnation.gr","railnation.us","trucknation.de","traviangames.com"],"excluded":false},
|
||||
{"type":47,"domains":["wpcu.coop","wpcuonline.com"],"excluded":false},
|
||||
{"type":48,"domains":["mathletics.com","mathletics.com.au","mathletics.co.uk"],"excluded":false},
|
||||
{"type":49,"domains":["discountbank.co.il","telebank.co.il"],"excluded":false},
|
||||
{"type":50,"domains":["mi.com","xiaomi.com"],"excluded":false},
|
||||
{"type":52,"domains":["postepay.it","poste.it"],"excluded":false},
|
||||
{"type":51,"domains":["facebook.com","messenger.com"],"excluded":false},
|
||||
{"type":53,"domains":["skysports.com","skybet.com","skyvegas.com"],"excluded":false},
|
||||
{"type":54,"domains":["disneymoviesanywhere.com","go.com","disney.com","dadt.com","disneyplus.com"],"excluded":false},
|
||||
{"type":55,"domains":["pokemon-gl.com","pokemon.com"],"excluded":false},
|
||||
{"type":56,"domains":["myuv.com","uvvu.com"],"excluded":false},
|
||||
{"type":58,"domains":["mdsol.com","imedidata.com"],"excluded":false},
|
||||
{"type":57,"domains":["bank-yahav.co.il","bankhapoalim.co.il"],"excluded":false},
|
||||
{"type":59,"domains":["sears.com","shld.net"],"excluded":false},
|
||||
{"type":60,"domains":["xiami.com","alipay.com"],"excluded":false},
|
||||
{"type":61,"domains":["belkin.com","seedonk.com"],"excluded":false},
|
||||
{"type":62,"domains":["turbotax.com","intuit.com"],"excluded":false},
|
||||
{"type":63,"domains":["shopify.com","myshopify.com"],"excluded":false},
|
||||
{"type":64,"domains":["ebay.com","ebay.at","ebay.be","ebay.ca","ebay.ch","ebay.cn","ebay.co.jp","ebay.co.th","ebay.co.uk","ebay.com.au","ebay.com.hk","ebay.com.my","ebay.com.sg","ebay.com.tw","ebay.de","ebay.es","ebay.fr","ebay.ie","ebay.in","ebay.it","ebay.nl","ebay.ph","ebay.pl"],"excluded":false},
|
||||
{"type":65,"domains":["techdata.com","techdata.ch"],"excluded":false},
|
||||
{"type":66,"domains":["schwab.com","schwabplan.com"],"excluded":false},
|
||||
{"type":68,"domains":["tesla.com","teslamotors.com"],"excluded":false},
|
||||
{"type":69,"domains":["morganstanley.com","morganstanleyclientserv.com","stockplanconnect.com","ms.com"],"excluded":false},
|
||||
{"type":70,"domains":["taxact.com","taxactonline.com"],"excluded":false},
|
||||
{"type":71,"domains":["mediawiki.org","wikibooks.org","wikidata.org","wikimedia.org","wikinews.org","wikipedia.org","wikiquote.org","wikisource.org","wikiversity.org","wikivoyage.org","wiktionary.org"],"excluded":false},
|
||||
{"type":72,"domains":["airbnb.at","airbnb.be","airbnb.ca","airbnb.ch","airbnb.cl","airbnb.co.cr","airbnb.co.id","airbnb.co.in","airbnb.co.kr","airbnb.co.nz","airbnb.co.uk","airbnb.co.ve","airbnb.com","airbnb.com.ar","airbnb.com.au","airbnb.com.bo","airbnb.com.br","airbnb.com.bz","airbnb.com.co","airbnb.com.ec","airbnb.com.gt","airbnb.com.hk","airbnb.com.hn","airbnb.com.mt","airbnb.com.my","airbnb.com.ni","airbnb.com.pa","airbnb.com.pe","airbnb.com.py","airbnb.com.sg","airbnb.com.sv","airbnb.com.tr","airbnb.com.tw","airbnb.cz","airbnb.de","airbnb.dk","airbnb.es","airbnb.fi","airbnb.fr","airbnb.gr","airbnb.gy","airbnb.hu","airbnb.ie","airbnb.is","airbnb.it","airbnb.jp","airbnb.mx","airbnb.nl","airbnb.no","airbnb.pl","airbnb.pt","airbnb.ru","airbnb.se"],"excluded":false},
|
||||
{"type":73,"domains":["eventbrite.at","eventbrite.be","eventbrite.ca","eventbrite.ch","eventbrite.cl","eventbrite.co","eventbrite.co.nz","eventbrite.co.uk","eventbrite.com","eventbrite.com.ar","eventbrite.com.au","eventbrite.com.br","eventbrite.com.mx","eventbrite.com.pe","eventbrite.de","eventbrite.dk","eventbrite.es","eventbrite.fi","eventbrite.fr","eventbrite.hk","eventbrite.ie","eventbrite.it","eventbrite.nl","eventbrite.pt","eventbrite.se","eventbrite.sg"],"excluded":false},
|
||||
{"type":74,"domains":["stackexchange.com","superuser.com","stackoverflow.com","serverfault.com","mathoverflow.net","askubuntu.com","stackapps.com"],"excluded":false},
|
||||
{"type":75,"domains":["docusign.com","docusign.net"],"excluded":false},
|
||||
{"type":76,"domains":["envato.com","themeforest.net","codecanyon.net","videohive.net","audiojungle.net","graphicriver.net","photodune.net","3docean.net"],"excluded":false},
|
||||
{"type":77,"domains":["x10hosting.com","x10premium.com"],"excluded":false},
|
||||
{"type":78,"domains":["dnsomatic.com","opendns.com","umbrella.com"],"excluded":false},
|
||||
{"type":79,"domains":["cagreatamerica.com","canadaswonderland.com","carowinds.com","cedarfair.com","cedarpoint.com","dorneypark.com","kingsdominion.com","knotts.com","miadventure.com","schlitterbahn.com","valleyfair.com","visitkingsisland.com","worldsoffun.com"],"excluded":false},
|
||||
{"type":80,"domains":["ubnt.com","ui.com"],"excluded":false},
|
||||
{"type":81,"domains":["discordapp.com","discord.com"],"excluded":false},
|
||||
{"type":82,"domains":["netcup.de","netcup.eu","customercontrolpanel.de"],"excluded":false},
|
||||
{"type":83,"domains":["yandex.com","ya.ru","yandex.az","yandex.by","yandex.co.il","yandex.com.am","yandex.com.ge","yandex.com.tr","yandex.ee","yandex.fi","yandex.fr","yandex.kg","yandex.kz","yandex.lt","yandex.lv","yandex.md","yandex.pl","yandex.ru","yandex.tj","yandex.tm","yandex.ua","yandex.uz"],"excluded":false},
|
||||
{"type":84,"domains":["sonyentertainmentnetwork.com","sony.com"],"excluded":false},
|
||||
{"type":85,"domains":["proton.me","protonmail.com","protonvpn.com"],"excluded":false},
|
||||
{"type":86,"domains":["ubisoft.com","ubi.com"],"excluded":false},
|
||||
{"type":87,"domains":["transferwise.com","wise.com"],"excluded":false},
|
||||
{"type":88,"domains":["takeaway.com","just-eat.dk","just-eat.no","just-eat.fr","just-eat.ch","lieferando.de","lieferando.at","thuisbezorgd.nl","pyszne.pl"],"excluded":false},
|
||||
{"type":89,"domains":["atlassian.com","bitbucket.org","trello.com","statuspage.io","atlassian.net","jira.com"],"excluded":false},
|
||||
{"type":90,"domains":["pinterest.com","pinterest.com.au","pinterest.cl","pinterest.de","pinterest.dk","pinterest.es","pinterest.fr","pinterest.co.uk","pinterest.jp","pinterest.co.kr","pinterest.nz","pinterest.pt","pinterest.se"],"excluded":false},
|
||||
{"type":91,"domains":["twitter.com","x.com"],"excluded":false}
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"source": "https://github.com/bitwarden/server",
|
||||
"ref": "main",
|
||||
"generatedAt": "2026-05-05T00:00:00.000Z",
|
||||
"rulesCount": 91,
|
||||
"domainsCount": 436,
|
||||
"sourceFiles": [
|
||||
"src/Core/Enums/GlobalEquivalentDomainsType.cs",
|
||||
"src/Core/Utilities/StaticStore.cs"
|
||||
],
|
||||
"sourceUrls": [
|
||||
"https://raw.githubusercontent.com/bitwarden/server/main/src/Core/Enums/GlobalEquivalentDomainsType.cs",
|
||||
"https://raw.githubusercontent.com/bitwarden/server/main/src/Core/Utilities/StaticStore.cs"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[
|
||||
{"type":-10001,"domains":["nodewarden.example","nw.example"],"excluded":false,"source":"nodewarden"}
|
||||
]
|
||||
@@ -1,10 +1,25 @@
|
||||
// Environment bindings
|
||||
export interface Env {
|
||||
VAULT: KVNamespace;
|
||||
ATTACHMENTS: R2Bucket;
|
||||
DB: D1Database;
|
||||
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;
|
||||
TOTP_SECRET?: string;
|
||||
}
|
||||
|
||||
export type UserRole = 'admin' | 'user';
|
||||
export type UserStatus = 'active' | 'banned';
|
||||
|
||||
// Sample JWT secret used by `.dev.vars.example`.
|
||||
// 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';
|
||||
|
||||
// Attachment model
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
@@ -19,7 +34,8 @@ export interface Attachment {
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
name: string | null;
|
||||
masterPasswordHint: string | null;
|
||||
masterPasswordHash: string;
|
||||
key: string;
|
||||
privateKey: string | null;
|
||||
@@ -29,10 +45,64 @@ export interface User {
|
||||
kdfMemory?: number;
|
||||
kdfParallelism?: number;
|
||||
securityStamp: string;
|
||||
role: UserRole;
|
||||
status: UserStatus;
|
||||
verifyDevices?: boolean;
|
||||
totpSecret: string | null;
|
||||
totpRecoveryCode: string | null;
|
||||
apiKey: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UserDomainSettings {
|
||||
userId: string;
|
||||
equivalentDomains: string[][];
|
||||
customEquivalentDomains: CustomEquivalentDomain[];
|
||||
excludedGlobalEquivalentDomains: number[];
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CustomEquivalentDomain {
|
||||
id: string;
|
||||
domains: string[];
|
||||
excluded: boolean;
|
||||
}
|
||||
|
||||
export interface GlobalEquivalentDomain {
|
||||
type: number;
|
||||
domains: string[];
|
||||
excluded: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DomainRulesResponse {
|
||||
equivalentDomains: string[][];
|
||||
customEquivalentDomains: CustomEquivalentDomain[];
|
||||
globalEquivalentDomains: GlobalEquivalentDomain[];
|
||||
object: 'domains';
|
||||
}
|
||||
|
||||
export interface Invite {
|
||||
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
|
||||
export enum CipherType {
|
||||
Login = 1,
|
||||
@@ -115,7 +185,7 @@ export interface Cipher {
|
||||
userId: string;
|
||||
type: CipherType;
|
||||
folderId: string | null;
|
||||
name: string;
|
||||
name: string | null;
|
||||
notes: string | null;
|
||||
favorite: boolean;
|
||||
login: CipherLogin | null;
|
||||
@@ -129,7 +199,10 @@ export interface Cipher {
|
||||
key: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
archivedAt: string | null;
|
||||
deletedAt: string | null;
|
||||
/** Allow unknown fields from Bitwarden clients to be stored and passed through transparently. */
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Folder model
|
||||
@@ -141,14 +214,138 @@ export interface Folder {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
userId: string;
|
||||
deviceIdentifier: string;
|
||||
name: string;
|
||||
deviceNote: string | null;
|
||||
type: number;
|
||||
sessionStamp: string;
|
||||
encryptedUserKey: string | null;
|
||||
encryptedPublicKey: string | null;
|
||||
encryptedPrivateKey: string | null;
|
||||
devicePendingAuthRequest?: DevicePendingAuthRequest | null;
|
||||
lastSeenAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DevicePendingAuthRequest {
|
||||
id: string;
|
||||
creationDate: string;
|
||||
}
|
||||
|
||||
export interface DeviceResponse {
|
||||
id: string;
|
||||
userId?: string | null;
|
||||
name: string;
|
||||
systemName?: string | null;
|
||||
deviceNote?: string | null;
|
||||
identifier: string;
|
||||
type: number;
|
||||
creationDate: string;
|
||||
revisionDate: string;
|
||||
lastSeenAt?: string | null;
|
||||
hasStoredDevice?: boolean;
|
||||
isTrusted: boolean;
|
||||
encryptedUserKey: string | null;
|
||||
encryptedPublicKey: string | null;
|
||||
devicePendingAuthRequest: DevicePendingAuthRequest | null;
|
||||
object: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ProtectedDeviceResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
identifier: string;
|
||||
type: number;
|
||||
creationDate: string;
|
||||
encryptedUserKey: string | null;
|
||||
encryptedPublicKey: string | null;
|
||||
object: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface RefreshTokenRecord {
|
||||
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
|
||||
export interface JWTPayload {
|
||||
sub: string; // user id
|
||||
email: string;
|
||||
name: string;
|
||||
name: string | null;
|
||||
email_verified: boolean; // required by mobile client
|
||||
amr: string[]; // authentication methods reference - required by mobile client
|
||||
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;
|
||||
exp: number;
|
||||
iss: string;
|
||||
@@ -166,13 +363,18 @@ export interface MasterPasswordUnlockKdf {
|
||||
export interface MasterPasswordUnlock {
|
||||
Kdf: MasterPasswordUnlockKdf;
|
||||
MasterKeyEncryptedUserKey: string;
|
||||
MasterKeyWrappedUserKey: string;
|
||||
Salt: string;
|
||||
Object: string;
|
||||
}
|
||||
|
||||
export interface UserDecryptionOptions {
|
||||
HasMasterPassword: boolean;
|
||||
Object: string;
|
||||
MasterPasswordUnlock?: MasterPasswordUnlock;
|
||||
// Bitwarden Android 2026.1.x expects this to exist; missing it breaks unlock when the vault is empty.
|
||||
MasterPasswordUnlock: MasterPasswordUnlock;
|
||||
TrustedDeviceOption: null;
|
||||
KeyConnectorOption: null;
|
||||
}
|
||||
|
||||
// API Response types
|
||||
@@ -180,7 +382,9 @@ export interface TokenResponse {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
refresh_token: string;
|
||||
refresh_token?: string;
|
||||
web_session?: boolean;
|
||||
TwoFactorToken?: string;
|
||||
Key: string;
|
||||
PrivateKey: string | null;
|
||||
Kdf: number;
|
||||
@@ -191,12 +395,23 @@ export interface TokenResponse {
|
||||
ResetMasterPassword: boolean;
|
||||
scope: string;
|
||||
unofficialServer: boolean;
|
||||
MasterPasswordPolicy?: {
|
||||
Object: string;
|
||||
} | null;
|
||||
ApiUseKeyConnector?: boolean;
|
||||
AccountKeys?: any | null;
|
||||
accountKeys?: any | null;
|
||||
UserDecryptionOptions: UserDecryptionOptions;
|
||||
userDecryptionOptions?: UserDecryptionOptions;
|
||||
VaultKeys?: {
|
||||
symEncKey: string;
|
||||
symMacKey: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProfileResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
premium: boolean;
|
||||
@@ -215,6 +430,9 @@ export interface ProfileResponse {
|
||||
forcePasswordReset: boolean;
|
||||
avatarColor: string | null;
|
||||
creationDate: string;
|
||||
verifyDevices?: boolean;
|
||||
role?: UserRole;
|
||||
status?: UserStatus;
|
||||
object: string;
|
||||
}
|
||||
|
||||
@@ -223,7 +441,7 @@ export interface CipherResponse {
|
||||
organizationId: string | null;
|
||||
folderId: string | null;
|
||||
type: number;
|
||||
name: string;
|
||||
name: string | null;
|
||||
notes: string | null;
|
||||
favorite: boolean;
|
||||
login: CipherLogin | null;
|
||||
@@ -247,6 +465,8 @@ export interface CipherResponse {
|
||||
attachments: any[] | null;
|
||||
key: string | null;
|
||||
encryptedFor: string | null;
|
||||
/** Allow unknown fields to pass through to clients transparently. */
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CipherPermissions {
|
||||
@@ -258,6 +478,7 @@ export interface FolderResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
revisionDate: string;
|
||||
creationDate: string;
|
||||
object: string;
|
||||
}
|
||||
|
||||
@@ -268,7 +489,29 @@ export interface SyncResponse {
|
||||
ciphers: CipherResponse[];
|
||||
domains: any;
|
||||
policies: any[];
|
||||
sends: any[];
|
||||
userDecryption: any | null;
|
||||
sends: SendResponse[];
|
||||
UserDecryption?: {
|
||||
MasterPasswordUnlock: MasterPasswordUnlock | null;
|
||||
TrustedDeviceOption?: null;
|
||||
KeyConnectorOption?: null;
|
||||
WebAuthnPrfOption?: null;
|
||||
Object?: string;
|
||||
} | null;
|
||||
// PascalCase for desktop/browser clients
|
||||
UserDecryptionOptions: UserDecryptionOptions | null;
|
||||
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
||||
userDecryption: {
|
||||
masterPasswordUnlock: {
|
||||
kdf: {
|
||||
kdfType: number;
|
||||
iterations: number;
|
||||
memory: number | null;
|
||||
parallelism: number | null;
|
||||
};
|
||||
masterKeyWrappedUserKey: string;
|
||||
masterKeyEncryptedUserKey: string;
|
||||
salt: string;
|
||||
} | null;
|
||||
} | null;
|
||||
object: string;
|
||||
}
|
||||
|
||||
@@ -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,7 @@
|
||||
import { JWTPayload } from '../types';
|
||||
import { LIMITS } from '../config/limits';
|
||||
|
||||
const hmacKeyCache = new Map<string, Promise<CryptoKey>>();
|
||||
|
||||
// Base64 URL encode
|
||||
function base64UrlEncode(data: Uint8Array): string {
|
||||
@@ -18,8 +21,25 @@ function base64UrlDecode(str: string): Uint8Array {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function getHmacKey(secret: string): Promise<CryptoKey> {
|
||||
const cacheKey = secret;
|
||||
let cached = hmacKeyCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
cached = crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign', 'verify']
|
||||
);
|
||||
hmacKeyCache.set(cacheKey, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Create JWT
|
||||
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 now = Math.floor(Date.now() / 1000);
|
||||
|
||||
@@ -39,13 +59,7 @@ export async function createJWT(payload: Omit<JWTPayload, 'iat' | 'exp' | 'iss'
|
||||
|
||||
const data = `${headerB64}.${payloadB64}`;
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
const key = await getHmacKey(secret);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
||||
@@ -62,13 +76,7 @@ export async function verifyJWT(token: string, secret: string): Promise<JWTPaylo
|
||||
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 key = await getHmacKey(secret);
|
||||
|
||||
const data = `${headerB64}.${payloadB64}`;
|
||||
const signature = base64UrlDecode(signatureB64);
|
||||
@@ -90,13 +98,21 @@ export async function verifyJWT(token: string, secret: string): Promise<JWTPaylo
|
||||
|
||||
// Create refresh token (simple random string)
|
||||
export function createRefreshToken(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
const bytes = new Uint8Array(LIMITS.auth.refreshTokenRandomBytes);
|
||||
crypto.getRandomValues(bytes);
|
||||
return base64UrlEncode(bytes);
|
||||
}
|
||||
|
||||
// File download token payload
|
||||
export interface FileDownloadClaims {
|
||||
cipherId: string;
|
||||
attachmentId: string;
|
||||
jti: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export interface AttachmentUploadClaims {
|
||||
userId: string;
|
||||
cipherId: string;
|
||||
attachmentId: string;
|
||||
exp: number;
|
||||
@@ -114,7 +130,8 @@ export async function createFileDownloadToken(
|
||||
const payload: FileDownloadClaims = {
|
||||
cipherId,
|
||||
attachmentId,
|
||||
exp: now + 300, // 5 minutes
|
||||
jti: createRefreshToken(),
|
||||
exp: now + LIMITS.auth.fileDownloadTokenTtlSeconds, // 5 minutes
|
||||
};
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
@@ -123,13 +140,7 @@ export async function createFileDownloadToken(
|
||||
|
||||
const data = `${headerB64}.${payloadB64}`;
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
const key = await getHmacKey(secret);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
||||
@@ -149,13 +160,7 @@ export async function verifyFileDownloadToken(
|
||||
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 key = await getHmacKey(secret);
|
||||
|
||||
const data = `${headerB64}.${payloadB64}`;
|
||||
const signature = base64UrlDecode(signatureB64);
|
||||
@@ -174,3 +179,244 @@ export async function verifyFileDownloadToken(
|
||||
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 getHmacKey(secret);
|
||||
|
||||
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 getHmacKey(secret);
|
||||
|
||||
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 getHmacKey(secret);
|
||||
|
||||
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 getHmacKey(secret);
|
||||
|
||||
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 getHmacKey(secret);
|
||||
|
||||
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 getHmacKey(secret);
|
||||
|
||||
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 getHmacKey(secret);
|
||||
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 getHmacKey(secret);
|
||||
|
||||
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: true };
|
||||
}
|
||||
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
|
||||
export function jsonResponse(data: any, status: number = 200, headers: Record<string, string> = {}): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getCorsHeaders(),
|
||||
...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
|
||||
export function handleCors(): Response {
|
||||
export function handleCors(request: Request): Response {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: getCorsHeaders(),
|
||||
headers: buildCorsHeaders(request),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,7 +161,6 @@ export function htmlResponse(html: string, status: number = 200): Response {
|
||||
status,
|
||||
headers: {
|
||||
'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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./webapp/index.html', './webapp/src/**/*.{ts,tsx}'],
|
||||
darkMode: ['class', '[data-theme="dark"]'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
canvas: 'var(--bg-accent)',
|
||||
panel: 'var(--panel)',
|
||||
'panel-soft': 'var(--panel-soft)',
|
||||
'panel-muted': 'var(--panel-muted)',
|
||||
line: 'var(--line)',
|
||||
'line-soft': 'var(--line-soft)',
|
||||
ink: 'var(--text)',
|
||||
muted: 'var(--muted)',
|
||||
'muted-strong': 'var(--muted-strong)',
|
||||
brand: 'var(--primary)',
|
||||
'brand-hover': 'var(--primary-hover)',
|
||||
'brand-strong': 'var(--primary-strong)',
|
||||
danger: 'var(--danger)',
|
||||
},
|
||||
boxShadow: {
|
||||
soft: 'var(--shadow-sm)',
|
||||
panel: 'var(--shadow-md)',
|
||||
elevated: 'var(--shadow-lg)',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -15,6 +15,6 @@
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"include": ["src/**/*", "shared/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<!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';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' data:;
|
||||
connect-src 'self';
|
||||
font-src 'self';
|
||||
form-action 'self';
|
||||
base-uri 'self';
|
||||
" />
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="/nodewarden-logo-bg.svg" />
|
||||
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
<title>NodeWarden</title>
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #eef4ff;
|
||||
color: #0f172a;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.boot-screen {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.boot-card {
|
||||
width: min(420px, 100%);
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
justify-items: center;
|
||||
padding: 28px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.10);
|
||||
}
|
||||
|
||||
.boot-logo {
|
||||
width: 74px;
|
||||
height: 58px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.boot-line {
|
||||
width: 72%;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #dbeafe, #bfdbfe, #dbeafe);
|
||||
background-size: 180% 100%;
|
||||
animation: boot-shimmer 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.boot-line.short {
|
||||
width: 46%;
|
||||
}
|
||||
|
||||
@keyframes boot-shimmer {
|
||||
0% { background-position: 180% 0; }
|
||||
100% { background-position: -180% 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<div class="boot-screen">
|
||||
<div class="boot-card" aria-label="Loading NodeWarden">
|
||||
<img class="boot-logo" src="/nodewarden-logo.svg" alt="" />
|
||||
<div class="boot-line"></div>
|
||||
<div class="boot-line short"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="760" height="760" viewBox="0 0 760 760" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="760" height="760" fill="#116FF9"/>
|
||||
<path d="M386.5 183C497.785 183 588 271.2 588 380C588 419.877 575.879 456.986 555.046 488H17.6816C16.5766 481.834 16 475.484 16 469C16 413.617 58.0774 368.061 112.008 362.558C108.771 353.989 107 344.701 107 335C107 291.922 141.922 257 185 257C198.365 257 210.945 260.362 221.94 266.286C258.437 215.895 318.539 183 386.5 183Z" fill="#F6821F"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M92.6568 91.0069C88.7796 262.923 101.55 381.119 143.869 469.459C186.188 557.799 258.092 616.353 372.665 668.892C485.877 616.354 556.929 557.802 598.746 469.461C640.564 381.12 653.181 262.923 649.35 91.0069H92.6568ZM539.796 432.933C570.479 365.533 581.347 278.379 582.419 153.939L582.422 153.432H377.661V593.786L378.405 593.364C458.602 547.962 509.101 500.36 539.796 432.933Z" fill="white"/>
|
||||
<path d="M604.465 305C680.976 305 743 367.233 743 444C743 459.378 740.509 474.172 735.913 488H379V423.553C391.721 397.751 418.287 380 449 380C459.483 380 469.482 382.068 478.613 385.818C500.559 338.11 548.658 305 604.465 305Z" fill="#FD9C33"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="727" height="580" viewBox="0 0 727 580" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M370.5 93C481.785 93 572 181.2 572 290C572 329.877 559.879 366.986 539.046 398H1.68164C0.576599 391.834 0 385.484 0 379C0 323.617 42.0774 278.061 96.0078 272.558C92.7712 263.989 91 254.701 91 245C91 201.922 125.922 167 169 167C182.365 167 194.945 170.362 205.94 176.286C242.437 125.895 302.539 93 370.5 93Z" fill="#F6821F"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M76.6568 1.00686C72.7796 172.923 85.5495 291.119 127.869 379.459C170.188 467.799 242.092 526.353 356.665 578.892C469.877 526.354 540.929 467.802 582.746 379.461C624.564 291.12 637.181 172.923 633.35 1.00686H76.6568ZM523.796 342.933C554.479 275.533 565.347 188.379 566.419 63.9394L566.422 63.432H361.661V503.786L362.405 503.364C442.602 457.962 493.101 410.36 523.796 342.933Z" fill="#116FF9"/>
|
||||
<path d="M588.465 215C664.976 215 727 277.233 727 354C727 369.378 724.509 384.172 719.913 398H363V333.553C375.721 307.751 402.287 290 433 290C443.483 290 453.482 292.068 462.613 295.818C484.559 248.11 532.658 215 588.465 215Z" fill="#FD9C33"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |