mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 06:50:10 +00:00
Compare commits
685
Commits
v1.0.0
..
8f303983ae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f303983ae | ||
|
|
8e5d9e294b | ||
|
|
6ffdf05dc6 | ||
|
|
82d9f61163 | ||
|
|
f761fffd58 | ||
|
|
39c076b2e1 | ||
|
|
a50a503788 | ||
|
|
6cbc06f833 | ||
|
|
72d8ec9cba | ||
|
|
299eda597f | ||
|
|
19de8d6e57 | ||
|
|
63b642b251 | ||
|
|
e943357067 | ||
|
|
573451c52f | ||
|
|
b731a014f1 | ||
|
|
e25ec159bb | ||
|
|
b093c01fd7 | ||
|
|
fa611dc843 | ||
|
|
3c581d1fb1 | ||
|
|
fb376797d2 | ||
|
|
99b50275a6 | ||
|
|
dfc98008cb | ||
|
|
b472121f43 | ||
|
|
9caa064488 | ||
|
|
aae614a079 | ||
|
|
0e46cd371f | ||
|
|
db31792cef | ||
|
|
8c65cb2e80 | ||
|
|
14dff8ee6a | ||
|
|
8d399f431b | ||
|
|
bb3f866220 | ||
|
|
39d9df78ea | ||
|
|
a1b12fc447 | ||
|
|
099217062a | ||
|
|
dd90d7b8b8 | ||
|
|
525b773cf4 | ||
|
|
04cb475935 | ||
|
|
e063f45cd9 | ||
|
|
e10920d142 | ||
|
|
b07edb0850 | ||
|
|
58a86ae8fd | ||
|
|
b986af86dc | ||
|
|
8e33f92b33 | ||
|
|
a0f832e8a5 | ||
|
|
8a5b210a1d | ||
|
|
ebc8e8e340 | ||
|
|
a870142b7b | ||
|
|
a366acbac0 | ||
|
|
57c5ef9da6 | ||
|
|
f532d3ace3 | ||
|
|
cc4a830be8 | ||
|
|
c6438747e3 | ||
|
|
5c8f01be59 | ||
|
|
7ac6ae50bb | ||
|
|
ace00e8e74 | ||
|
|
51428461a8 | ||
|
|
23c53bd1af | ||
|
|
ae168bea31 | ||
|
|
00e0ec0892 | ||
|
|
2df43ccdb0 | ||
|
|
fd46dffc34 | ||
|
|
56b301f2d1 | ||
|
|
f0e523376c | ||
|
|
8b2f98b847 | ||
|
|
cde4555add | ||
|
|
1bad32fd90 | ||
|
|
e376a840c2 | ||
|
|
9de0d3bd87 | ||
|
|
109593da90 | ||
|
|
01ff627ac6 | ||
|
|
d028b194e7 | ||
|
|
c53d71fc28 | ||
|
|
6e722205b1 | ||
|
|
cf14704d99 | ||
|
|
0cef6a04e9 | ||
|
|
8c481a1564 | ||
|
|
d9a36fefe6 | ||
|
|
12af18e3a3 | ||
|
|
d8cc88d9c0 | ||
|
|
94b5f3e975 | ||
|
|
062c966e14 | ||
|
|
e73ae3d5ea | ||
|
|
c019c93726 | ||
|
|
f63b745d05 | ||
|
|
c7eb6c663d | ||
|
|
1ec6ed44a1 | ||
|
|
6284c632de | ||
|
|
60dd298dee | ||
|
|
439683d350 | ||
|
|
1545881eae | ||
|
|
680e287c8d | ||
|
|
baf569983d | ||
|
|
73bbe8b268 | ||
|
|
d024798548 | ||
|
|
b0a679b1c2 | ||
|
|
ce3674669e | ||
|
|
aa7b87e041 | ||
|
|
e4215b4025 | ||
|
|
8d292ca7b8 | ||
|
|
5dd9dff045 | ||
|
|
709a8c1768 | ||
|
|
e2c3516ce9 | ||
|
|
55b5c57f9e | ||
|
|
b6fb62603b | ||
|
|
35071c2719 | ||
|
|
5bd7dab277 | ||
|
|
99f2d7f444 | ||
|
|
fb9a2aeda1 | ||
|
|
c87e6ac984 | ||
|
|
a6f1c6dea2 | ||
|
|
49c872a8ec | ||
|
|
78af1f9bdd | ||
|
|
32b3d2ade1 | ||
|
|
64f26e76f6 | ||
|
|
68c42a0330 | ||
|
|
0d1bb196e2 | ||
|
|
e31f82c0d6 | ||
|
|
f82dcc3c17 | ||
|
|
4378e1b430 | ||
|
|
5eeaf4e32e | ||
|
|
82f968e51f | ||
|
|
a5ad16ac27 | ||
|
|
6a1a8357bf | ||
|
|
31cfd19b6b | ||
|
|
4cd9ad00d2 | ||
|
|
31dcc76ee2 | ||
|
|
bf6ac7b405 | ||
|
|
1bfb9a647d | ||
|
|
e9272ec29a | ||
|
|
8942e5bd49 | ||
|
|
d722815999 | ||
|
|
ff85698edb | ||
|
|
c3dc53bac1 | ||
|
|
1acc31eda0 | ||
|
|
c694f1bfce | ||
|
|
bf51309fbb | ||
|
|
23b23f39b9 | ||
|
|
0daad46591 | ||
|
|
a2a8f1c7b6 | ||
|
|
850fe0f044 | ||
|
|
7279668955 | ||
|
|
5048cc0720 | ||
|
|
3f785febc8 | ||
|
|
907126d152 | ||
|
|
c1f57957c0 | ||
|
|
cd2ec8240b | ||
|
|
16bde22604 | ||
|
|
4900de0444 | ||
|
|
79ed7c9f85 | ||
|
|
9a21504f40 | ||
|
|
045b23fc47 | ||
|
|
42b765b113 | ||
|
|
f9fe53285f | ||
|
|
46ba8b9950 | ||
|
|
f096681a2b | ||
|
|
fe0c66c561 | ||
|
|
add921b3b3 | ||
|
|
f1b716fb31 | ||
|
|
8f2704fd41 | ||
|
|
7e0406f751 | ||
|
|
d5c2ab2b0f | ||
|
|
9e0908f43c | ||
|
|
7b3be2c819 | ||
|
|
a8183166ac | ||
|
|
f6169b7610 | ||
|
|
493f901ec1 | ||
|
|
b4dfb0409b | ||
|
|
a06cb0ed71 | ||
|
|
b0242265f4 | ||
|
|
b444c0f4b8 | ||
|
|
b1b25fe678 | ||
|
|
7cf2ab7c88 | ||
|
|
1918735520 | ||
|
|
c652cc1533 | ||
|
|
e9aef72df7 | ||
|
|
9adb24d4bb | ||
|
|
563570e3e0 | ||
|
|
3035a77579 | ||
|
|
28333f0e9b | ||
|
|
91320a4eba | ||
|
|
19b96a7aca | ||
|
|
18e0396c0a | ||
|
|
18d3490c4f | ||
|
|
615caf5946 | ||
|
|
1a10df4a18 | ||
|
|
d4749d3f82 | ||
|
|
5ed7c949c1 | ||
|
|
af70cab766 | ||
|
|
bfea5d0a1c | ||
|
|
cda654e1c3 | ||
|
|
1ee7b0f31b | ||
|
|
2d2cbea530 | ||
|
|
4f5d992f10 | ||
|
|
667afa305b | ||
|
|
85bd2fa4bf | ||
|
|
fd9707c396 | ||
|
|
192071e4a7 | ||
|
|
fcf7c80daa | ||
|
|
ed9251c014 | ||
|
|
a75955ca6d | ||
|
|
03f7fbf601 | ||
|
|
a63336764f | ||
|
|
f56d7f01ca | ||
|
|
8ff60aed24 | ||
|
|
749de4e2e1 | ||
|
|
ea9e238aa7 | ||
|
|
22d267f5bc | ||
|
|
18eefd1174 | ||
|
|
d468745841 | ||
|
|
970621c459 | ||
|
|
385a873e65 | ||
|
|
56185ecb69 | ||
|
|
04ebfc7021 | ||
|
|
c50247b8fe | ||
|
|
776408e9d0 | ||
|
|
e641da517d | ||
|
|
b7878ffe01 | ||
|
|
bbad9d60a7 | ||
|
|
ed58467766 | ||
|
|
2f911e66a6 | ||
|
|
d06e050162 | ||
|
|
d0dc31ce86 | ||
|
|
f64abaa75d | ||
|
|
7312086f92 | ||
|
|
3e4c104e1d | ||
|
|
17ceec45b1 | ||
|
|
2685741386 | ||
|
|
83a1fc2376 | ||
|
|
06431c4145 | ||
|
|
700910099b | ||
|
|
6b671450a8 | ||
|
|
c0df6d1c16 | ||
|
|
35f9512d94 | ||
|
|
9e39161fc7 | ||
|
|
7c58282e42 | ||
|
|
e0d81f2733 | ||
|
|
1d23b3fe5e | ||
|
|
a0d4d7a1ff | ||
|
|
2f1b61e883 | ||
|
|
4e62c90700 | ||
|
|
7afb496eb0 | ||
|
|
5809e3eebc | ||
|
|
2e9bbe6801 | ||
|
|
dc0eec7c54 | ||
|
|
a0605299f0 | ||
|
|
db68437a0b | ||
|
|
77d8411ea9 | ||
|
|
0c1ab3db48 | ||
|
|
6cc6e94b91 | ||
|
|
37ae493fa7 | ||
|
|
33f7c5d88a | ||
|
|
c6c8979772 | ||
|
|
a00279f47d | ||
|
|
669d7ef242 | ||
|
|
97d2117e15 | ||
|
|
429b747710 | ||
|
|
a06853835d | ||
|
|
c4ff063865 | ||
|
|
70b0a3a394 | ||
|
|
e7c07fda4e | ||
|
|
0a001bebcc | ||
|
|
246c73a3d3 | ||
|
|
3d95c959f7 | ||
|
|
e0737006c2 | ||
|
|
70dc9a76a9 | ||
|
|
ba38b77387 | ||
|
|
1b4d263d6e | ||
|
|
97a3aa691d | ||
|
|
0ab7c44981 | ||
|
|
75a6a593dc | ||
|
|
45f0387526 | ||
|
|
851c9c4080 | ||
|
|
a73f9a6d87 | ||
|
|
77a9faac88 | ||
|
|
0c00114cc8 | ||
|
|
9c5fbda374 | ||
|
|
85147e1569 | ||
|
|
29a846c562 | ||
|
|
3c5f43ecc2 | ||
|
|
68ded534a4 | ||
|
|
69b98f9e67 | ||
|
|
1b0386bf78 | ||
|
|
aa6f9210b4 | ||
|
|
3be6a16d90 | ||
|
|
fdb4cb91bf | ||
|
|
4b69f71ddb | ||
|
|
44020541e8 | ||
|
|
5869755c74 | ||
|
|
5b62d2142e | ||
|
|
575cf7ca79 | ||
|
|
bfd347a52c | ||
|
|
7ab836d0f3 | ||
|
|
d589b15123 | ||
|
|
f48f3d0c8e | ||
|
|
2f7e66ee69 | ||
|
|
0cffbcd1f8 | ||
|
|
64b4da4035 | ||
|
|
3d2285e7af | ||
|
|
62f0aedc27 | ||
|
|
193e0ca189 | ||
|
|
4a63c077f5 | ||
|
|
15ee922777 | ||
|
|
2ea0b2c14c | ||
|
|
4ec1926888 | ||
|
|
3995e01336 | ||
|
|
481536ba24 | ||
|
|
db8b9263a1 | ||
|
|
a1f7250e90 | ||
|
|
e4bc1b9bbe | ||
|
|
514889adfc | ||
|
|
fccc85c4bb | ||
|
|
acd59a7387 | ||
|
|
d40b0514fd | ||
|
|
033d44808f | ||
|
|
4246e179f1 | ||
|
|
fe8d9e0b7d | ||
|
|
1147c1e013 | ||
|
|
31ffd98166 | ||
|
|
7d7562d191 | ||
|
|
d6e5a1c40b | ||
|
|
77794e43ce | ||
|
|
b990f17a3e | ||
|
|
31b8ec6f7d | ||
|
|
ef47597be5 | ||
|
|
408874ac05 | ||
|
|
dabd2c923e | ||
|
|
08414d7cf2 | ||
|
|
38b33df719 | ||
|
|
7ebd12fa07 | ||
|
|
f7cbdaf730 | ||
|
|
6cae5cb218 | ||
|
|
d96ad9bb1c | ||
|
|
92d1f07998 | ||
|
|
a8432ab94b | ||
|
|
2230f75d8a | ||
|
|
a982a5a57b | ||
|
|
4d7ee2164a | ||
|
|
34d4851981 | ||
|
|
4827a4958e | ||
|
|
70463d3fc7 | ||
|
|
681705ee13 | ||
|
|
5bf7c79ada | ||
|
|
c516194d54 | ||
|
|
53231a4878 | ||
|
|
c9e7417825 | ||
|
|
76623d7201 | ||
|
|
90a7731351 | ||
|
|
f4adeb8ec9 | ||
|
|
bb0b82f838 | ||
|
|
be82c953d6 | ||
|
|
edd2ba2e44 | ||
|
|
0f6da7d147 | ||
|
|
1184cb8d9a | ||
|
|
882fa2e8c8 | ||
|
|
b6b7e46f79 | ||
|
|
144d3d9406 | ||
|
|
10707cf902 | ||
|
|
3bd4f6a9fe | ||
|
|
3d4e95ef66 | ||
|
|
2a7879efaa | ||
|
|
bd8e26d2ab | ||
|
|
783fcbbe4b | ||
|
|
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 | ||
|
|
6b8ee28e54 | ||
|
|
2f7dbc78d3 | ||
|
|
1a22b108ca | ||
|
|
40549147bd | ||
|
|
c0a390baa5 | ||
|
|
7cdccde684 | ||
|
|
9edaa647c4 | ||
|
|
ba9710cdf0 | ||
|
|
69f4fde5a2 | ||
|
|
2a747c996d | ||
|
|
e1f1c6f865 | ||
|
|
73db6c518b | ||
|
|
1d1cbd2c8e | ||
|
|
72ec21415b | ||
|
|
649f54f923 | ||
|
|
beefe2227e | ||
|
|
326e13adf0 | ||
|
|
6e1a8e7b5c | ||
|
|
c5d3052080 |
@@ -0,0 +1,5 @@
|
||||
# CodeGraph data files — local to each machine, not for committing.
|
||||
# Ignore everything in .codegraph/ except this file itself, so transient
|
||||
# files (the database, daemon.pid, sockets, logs) never show up in git.
|
||||
*
|
||||
!.gitignore
|
||||
@@ -1,5 +0,0 @@
|
||||
# JWT Secret for signing tokens (required)
|
||||
# IMPORTANT: change this value before any real deployment.
|
||||
# Generate one with: openssl rand -hex 32
|
||||
# (Example only, 64 hex chars = 32 bytes)
|
||||
JWT_SECRET=Enter-your-JWT-key-here-at-least-32-characters
|
||||
@@ -0,0 +1,2 @@
|
||||
custom:
|
||||
- https://nodewarden.app/sponsor
|
||||
@@ -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://nodewarden.app
|
||||
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,33 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "05:00"
|
||||
timezone: "Asia/Shanghai"
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
npm-minor-and-patch:
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
ignore:
|
||||
- dependency-name: "tailwindcss"
|
||||
update-types:
|
||||
- "version-update:semver-major"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "05:10"
|
||||
timezone: "Asia/Shanghai"
|
||||
open-pull-requests-limit: 0
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
@@ -0,0 +1,44 @@
|
||||
name: "CodeQL Advanced"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: CodeQL Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: actions
|
||||
build-mode: none
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@411bbbe57033eedfc1a82d68c01345aa96c737d7
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
queries: security-extended,security-and-quality
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@411bbbe57033eedfc1a82d68c01345aa96c737d7
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
@@ -0,0 +1,200 @@
|
||||
name: "Extra Security Scan"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
gitleaks:
|
||||
name: Gitleaks Secret Scan
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout full history
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run Gitleaks
|
||||
uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITLEAKS_ENABLE_SUMMARY: "true"
|
||||
GITLEAKS_ENABLE_UPLOAD_ARTIFACT: "true"
|
||||
# 如果仓库属于 GitHub Organization,需要在 Settings -> Secrets 里加 GITLEAKS_LICENSE
|
||||
# GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
|
||||
|
||||
osv:
|
||||
name: OSV Dependency Scan
|
||||
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
|
||||
with:
|
||||
scan-args: |-
|
||||
--recursive
|
||||
./
|
||||
upload-sarif: true
|
||||
fail-on-vuln: true
|
||||
|
||||
pnpm-audit:
|
||||
name: pnpm audit
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Run pnpm audit
|
||||
shell: bash
|
||||
run: |
|
||||
if [ ! -f pnpm-lock.yaml ]; then
|
||||
echo "pnpm-lock.yaml not found, skip pnpm audit."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
corepack enable
|
||||
corepack prepare pnpm@10 --activate
|
||||
pnpm audit --audit-level=high
|
||||
|
||||
semgrep:
|
||||
name: Semgrep CE Scan
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run Semgrep CE
|
||||
shell: bash
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "${PWD}:/src" \
|
||||
-w /src \
|
||||
semgrep/semgrep:latest \
|
||||
semgrep scan --config p/default --sarif --output semgrep.sarif . || true
|
||||
|
||||
if [ ! -f semgrep.sarif ]; then
|
||||
cat > semgrep.sarif <<'EOF'
|
||||
{
|
||||
"version": "2.1.0",
|
||||
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
||||
"runs": [
|
||||
{
|
||||
"tool": {
|
||||
"driver": {
|
||||
"name": "Semgrep",
|
||||
"informationUri": "https://semgrep.dev",
|
||||
"rules": []
|
||||
}
|
||||
},
|
||||
"results": []
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
- name: Upload Semgrep SARIF
|
||||
uses: github/codeql-action/upload-sarif@411bbbe57033eedfc1a82d68c01345aa96c737d7
|
||||
with:
|
||||
sarif_file: semgrep.sarif
|
||||
category: semgrep
|
||||
|
||||
actionlint:
|
||||
name: GitHub Actions Syntax Scan
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run actionlint
|
||||
shell: bash
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "${PWD}:/repo" \
|
||||
-w /repo \
|
||||
rhysd/actionlint:latest
|
||||
|
||||
zizmor:
|
||||
name: GitHub Actions Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor
|
||||
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa
|
||||
with:
|
||||
persona: auditor
|
||||
min-severity: medium
|
||||
min-confidence: medium
|
||||
|
||||
scorecard:
|
||||
name: OpenSSF Scorecard
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run OpenSSF Scorecard
|
||||
uses: ossf/scorecard-action@99c09fe975337306107572b4fdf4db224cf8e2f2
|
||||
with:
|
||||
results_file: scorecard.sarif
|
||||
results_format: sarif
|
||||
publish_results: false
|
||||
|
||||
- name: Upload Scorecard SARIF
|
||||
uses: github/codeql-action/upload-sarif@411bbbe57033eedfc1a82d68c01345aa96c737d7
|
||||
with:
|
||||
sarif_file: scorecard.sarif
|
||||
category: openssf-scorecard
|
||||
@@ -0,0 +1,60 @@
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Sync generated Bitwarden domains
|
||||
env:
|
||||
BITWARDEN_REF: ${{ inputs.bitwarden_ref || 'main' }}
|
||||
run: |
|
||||
case "$BITWARDEN_REF" in
|
||||
"" | *[!A-Za-z0-9._/-]* )
|
||||
echo "Invalid bitwarden_ref"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
npm run domains:sync -- --ref "$BITWARDEN_REF"
|
||||
|
||||
- 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@5f6978faf089d4d20b00c7766989d076bb2fc7f1
|
||||
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
|
||||
@@ -1,28 +0,0 @@
|
||||
name: Sync upstream
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- run: |
|
||||
git remote add upstream https://github.com/shuaiplus/nodewarden.git || true
|
||||
git fetch upstream
|
||||
|
||||
# 强制让当前分支完全等于 upstream
|
||||
git reset --hard upstream/main
|
||||
|
||||
# 强制推送
|
||||
git push origin main --force
|
||||
+29
-1
@@ -7,6 +7,7 @@ node_modules/
|
||||
wrangler.my.toml
|
||||
RELEASE_NOTES.md
|
||||
tests/selfcheck.ts
|
||||
problem.md
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
@@ -17,6 +18,7 @@ build/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
docs/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -25,7 +27,7 @@ Thumbs.db
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
.vite-tailwind.err
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
@@ -36,3 +38,29 @@ npm-debug.log*
|
||||
|
||||
# Package lock (optional - remove if you want to commit it)
|
||||
# package-lock.json
|
||||
|
||||
tmp/
|
||||
.tmp/
|
||||
.tmp-bitwarden-clients/
|
||||
|
||||
nodewarden-wiki/
|
||||
wiki/
|
||||
AGENTS.md
|
||||
settings.json
|
||||
.claude/
|
||||
NodeWarden-compat/
|
||||
.codex-upstream/
|
||||
.codex-upstream/bitwarden-server/
|
||||
.codex-upstream/bitwarden-clients/
|
||||
.codex-upstream/bitwarden-web/
|
||||
.codex-upstream/bitwarden-browser/
|
||||
|
||||
.reasonix/
|
||||
.upstream/
|
||||
|
||||
# Compatibility analysis documents
|
||||
BITWARDEN_COMPATIBILITY_ANALYSIS.md
|
||||
security-audits/
|
||||
.mcp.json
|
||||
opencode.jsonc
|
||||
.cursor/
|
||||
|
||||
+133
@@ -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
|
||||
```
|
||||
Binary file not shown.
|
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,89 +1,152 @@
|
||||
# NodeWarden
|
||||
English:[`README_EN.md`](./README_EN.md)
|
||||
<p align="center">
|
||||
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
|
||||
</p>
|
||||
|
||||
运行在 **Cloudflare Workers** 上的 **Bitwarden 第三方服务端**。
|
||||
<p align="center">
|
||||
Bitwarden-compatible server running on Cloudflare Workers
|
||||
</p>
|
||||
|
||||
> **免责声明**
|
||||
> 本项目仅供学习交流使用。我们不对任何数据丢失负责,强烈建议定期备份您的密码库。
|
||||
> 本项目与 Bitwarden 官方无关,请勿向 Bitwarden 官方反馈问题。
|
||||
<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>
|
||||
|
||||
</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_ZH.md">中文</a> |
|
||||
<a href="./CONTRIBUTING.md">Contributing</a> |
|
||||
<a href="https://nodewarden.app">Official wiki</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.
|
||||
|
||||
---
|
||||
## 与 Bitwarden 官方服务端能力对比
|
||||
|
||||
| 能力项 | Bitwarden | NodeWarden | 说明 |
|
||||
## Feature comparison with the official Bitwarden server
|
||||
|
||||
| Feature | Bitwarden Free | NodeWarden | Notes |
|
||||
|---|---|---|---|
|
||||
| 单用户保管库(登录/笔记/卡片/身份) | ✅ | ✅ | 基于Cloudflare D1 |
|
||||
| 文件夹 / 收藏 | ✅ | ✅ | 常用管理能力可用 |
|
||||
| 全量同步 `/api/sync` | ✅ | ✅ | 已做兼容与性能优化 |
|
||||
| 附件上传/下载 | ✅ | ✅ | 基于 Cloudflare R2 |
|
||||
| 导入功能 | ✅ | ✅ | 覆盖常见导入路径 |
|
||||
| 网站图标代理 | ✅ | ✅ | 通过 `/icons/{hostname}/icon.png` |
|
||||
| 多用户 | ✅ | ❌ | NodeWarden 定位单用户 |
|
||||
| 组织/集合/成员权限 | ✅ | ❌ | 没必要实现 |
|
||||
| 完整 2FA(TOTP/WebAuthn/Duo/Email) | ✅ | ❌ | 没必要实现 |
|
||||
| SSO / SCIM / 企业目录 | ✅ | ❌ | 没必要实现 |
|
||||
| Send | ✅ | ❌ | 基本没人用 |
|
||||
| 紧急访问 | ✅ | ❌ | 没必要实现 |
|
||||
| 管理后台 / 计费订阅 | ✅ | ❌ | 纯免费 |
|
||||
| 推送通知完整链路 | ✅ | ❌ | 没必要实现 |
|
||||
|
||||
## 测试情况:
|
||||
|
||||
- ✅ Windows 客户端(v2026.1.0)
|
||||
- ✅ 手机 App(v2026.1.0)
|
||||
- ✅ 浏览器扩展(v2026.1.0)
|
||||
- ⬜ macOS 客户端(未测试)
|
||||
- ⬜ Linux 客户端(未测试)
|
||||
---
|
||||
|
||||
# 快速开始
|
||||
|
||||
### 一键部署
|
||||
|
||||
**部署步骤:**
|
||||
|
||||
1. 先在右上角fork此项目(若后续不需要更新,可不fork)
|
||||
2. [](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
|
||||
3. 打开部署后生成的链接,并根据网页提示完成后续操作。
|
||||
| Web vault | ✅ | ✅ | **Original Web Vault UI** |
|
||||
| TOTP | ❌ | ✅ | Includes `steam://` support |
|
||||
| **PWA / offline** | ❌ | ✅ | **Installable, offline** |
|
||||
| **Passkey login** | ✅ | ✅ | **passwordless auth** |
|
||||
| API keys | ✅ | ✅ | CLI keys; create and rotate |
|
||||
| Login 2FA | ✅ | ✅ | TOTP, YubiKey, Passkey |
|
||||
| 2FA recovery codes | ✅ | ✅ | One-time 2FA disable codes |
|
||||
| Real-time push sync | ✅ | ✅ | All device sync |
|
||||
| Attachments / Send | ✅ | ✅ | Cloudflare R2 or KV |
|
||||
| Import / export | ✅ | ✅ | Bitwarden JSON / CSV / **ZIP** |
|
||||
| **Cloud backup center** | ❌ | ✅ | **Scheduled WebDAV / S3 incrementals** |
|
||||
| Device management | ✅ | ✅ | **Remove devices; trust controls** |
|
||||
| Login requests | ✅ | ✅ | **Cross-device login approval/unlock** |
|
||||
| **Multi-user** | ✅ | ✅ | Invite-code registration |
|
||||
| Domain rules | ✅ | ✅ | Equivalent domains, global exclusions |
|
||||
| Fill-assist | ✅ | ✅ | `POST /fill-assist`|
|
||||
| Organizations / collections / roles | ✅ | ❌ | Not implemented |
|
||||
| SSO / SCIM / directory | ✅ | ❌ | Not implemented |
|
||||
|
||||
---
|
||||
|
||||
## 本地开发
|
||||
## Tested clients
|
||||
|
||||
这是一个 Cloudflare Workers 的 TypeScript 项目(Wrangler)。
|
||||
- ✅ Windows desktop
|
||||
- ✅ Mobile app
|
||||
- ✅ Browser extension
|
||||
- ✅ Linux desktop
|
||||
- ⚠️ macOS desktop not fully verified yet
|
||||
|
||||
---
|
||||
|
||||
## Visual quick deploy
|
||||
|
||||
1. Fork the NodeWarden repository to your GitHub account
|
||||
2. Open [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
||||
3. Choose **Continue with GitHub** and select your fork
|
||||
4. Set **build command** to `npm run build` and **deploy command** to `npm run deploy`
|
||||
- For KV mode, change the deploy command to `npm run deploy:kv`
|
||||
5. After deployment finishes, open the generated Workers URL
|
||||
|
||||
- The default Workers hostname may be unreachable on some networks. To use a custom domain, add it in [Workers settings](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings).
|
||||
|
||||
- If the site reports a missing `JWT_SECRET`, add it as a **Secret** in Workers settings. In production use a random string of at least 32 characters; do not use temporary or example values.
|
||||
|
||||
- To hide the Web Vault, add a text variable named `HIDE_WEB_VAULT` with the value `1` under **Workers settings → Variables and Secrets**. While enabled, server-hosted frontend pages and static assets return `404 Not Found`, while the login, sync, attachment, icon, notification, and other server endpoints used by Bitwarden clients remain available; an already installed or cached PWA can continue using its local frontend. Delete the variable (or change it to anything other than `1`) to restore the server-hosted Web Vault.
|
||||
|
||||
- In this flow you hand code to Cloudflare to build and deploy. `wrangler.toml` or `wrangler.kv.toml` in the repo defines binding names; the Worker initializes the D1 schema on first request—no manual SQL upload.
|
||||
|
||||
|
||||
> [!TIP]
|
||||
> Default R2 vs optional KV:
|
||||
> | Storage | Card required | Max single attachment / Send file | Free tier |
|
||||
> |---|---|---|---|
|
||||
> | R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
|
||||
> | KV | No | 25 MiB (Cloudflare limit) | 1 GB |
|
||||
|
||||
|
||||
## How to update
|
||||
|
||||
- Manual: open your fork on GitHub; when the sync banner appears, click **Sync fork** → **Update branch**
|
||||
|
||||
|
||||
|
||||
|
||||
## CLI deploy
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||
cd NodeWarden
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q: 如何备份数据?**
|
||||
A: 在客户端中选择「导出密码库」,保存 JSON 文件。
|
||||
|
||||
**Q: 忘记主密码怎么办?**
|
||||
A: 无法恢复,这是端到端加密的特性。建议妥善保管主密码。
|
||||
|
||||
**Q: 可以多人使用吗?**
|
||||
A: 不建议。本项目为单用户设计,多人使用请选择 Vaultwarden。
|
||||
|
||||
---
|
||||
|
||||
## 开源协议
|
||||
## 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/) - 无服务器平台
|
||||
---
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/shuaiplus/nodewarden/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=shuaiplus/nodewarden" alt="NodeWarden contributors" />
|
||||
</a>
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
||||
<a href="https://www.star-history.com/?repos=shuaiplus%2FNodeWarden&type=timeline&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&theme=dark&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
# NodeWarden
|
||||
中文文档:[`README.md`](./README.md)
|
||||
|
||||
A **Bitwarden-compatible** server that runs on **Cloudflare Workers**.
|
||||
|
||||
> Disclaimer
|
||||
> - This project is for learning and communication only.
|
||||
> - We are not responsible for any data loss. Regular vault backups are strongly recommended.
|
||||
> - This project is not affiliated with Bitwarden. Please do not report issues to the official Bitwarden team.
|
||||
|
||||
---
|
||||
|
||||
## Feature Comparison Table (vs Official Bitwarden Server)
|
||||
|
||||
| Capability | Bitwarden | NodeWarden | Notes |
|
||||
|---|---|---|---|
|
||||
| Single-user vault (logins/notes/cards/identities) | ✅ | ✅ | Core vault model supported |
|
||||
| Folders / Favorites | ✅ | ✅ | Common vault organization supported |
|
||||
| Full sync `/api/sync` | ✅ | ✅ | Compatibility-focused implementation |
|
||||
| Attachment upload/download | ✅ | ✅ | Backed by Cloudflare R2 |
|
||||
| Import flow (common clients) | ✅ | ✅ | Common import paths covered |
|
||||
| Website icon proxy | ✅ | ✅ | Via `/icons/{hostname}/icon.png` |
|
||||
| Multi-user | ✅ | ❌ | NodeWarden is single-user by design |
|
||||
| Organizations / Collections / Member roles | ✅ | ❌ | Not necessary to implement |
|
||||
| Full 2FA (TOTP/WebAuthn/Duo/Email) | ✅ | ❌ | Not necessary to implement |
|
||||
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not necessary to implement |
|
||||
| Send | ✅ | ❌ | Not necessary to implement |
|
||||
| Emergency access | ✅ | ❌ | Not necessary to implement |
|
||||
| Admin console / Billing & subscription | ✅ | ❌ | Free only |
|
||||
| Full push notification pipeline | ✅ | ❌ | Not necessary to implement |
|
||||
|
||||
|
||||
## Tested clients / platforms
|
||||
|
||||
- ✅ Windows desktop client (v2026.1.0)
|
||||
- ✅ Android app (v2026.1.0)
|
||||
- ✅ Browser extension (v2026.1.0)
|
||||
- ⬜ macOS desktop client (not tested)
|
||||
- ⬜ Linux desktop client (not tested)
|
||||
|
||||
---
|
||||
|
||||
# Quick start
|
||||
|
||||
### One-click deploy
|
||||
|
||||
**Deploy steps:**
|
||||
|
||||
1. Fork this project (you don't need to fork it if you don't need to update it later).
|
||||
2. [](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
|
||||
3. Open the generated service URL and follow the on-page instructions.
|
||||
|
||||
|
||||
## Local development
|
||||
|
||||
This repo is a Cloudflare Workers TypeScript project (Wrangler).
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
**Q: Can multiple people use it?**
|
||||
A: Not recommended. This project is designed for single-user usage. For multi-user usage, choose Vaultwarden.
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
<p align="center">
|
||||
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
|
||||
</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>
|
||||
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
|
||||
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.md">English</a> |
|
||||
<a href="./CONTRIBUTING.md">贡献指南</a> |
|
||||
<a href="https://nodewarden.app">官方wiki</a>
|
||||
</p>
|
||||
|
||||
> **免责声明**
|
||||
> 本项目仅供学习与交流使用,请定期备份你的密码库。
|
||||
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
|
||||
|
||||
---
|
||||
|
||||
## 与 Bitwarden 官方服务端能力对比
|
||||
|
||||
| 能力 | Bitwarden免费版 | NodeWarden | 说明 |
|
||||
|---|---|---|---|
|
||||
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
|
||||
| TOTP | ❌ | ✅ | 包括 `steam://` 支持 |
|
||||
| **PWA / 离线使用** | ❌ | ✅ | **可安装、离线使用、App快捷方式** |
|
||||
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
|
||||
| API 密钥 | ✅ | ✅ | 供bitwarden cli使用,支持获取和轮换 |
|
||||
| 登录 2FA | ✅ | ✅ | 支持 TOTP、YubiKey、Passkey |
|
||||
| 2FA 恢复码 | ✅ | ✅ | 一次性恢复码用于禁用 2FA |
|
||||
| 实时推送同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 |
|
||||
| 附件 / Send| ✅ | ✅ | Cloudflare R2 或 KV |
|
||||
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
|
||||
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时增量备份** |
|
||||
| 设备管理 | ✅ | ✅ | **删除设备、撤销信任、永久信任** |
|
||||
| 登录请求 | ✅ | ✅ | **多端免密登录审批、跨设备解锁请求** |
|
||||
| **多用户使用** | ✅ | ✅ | 支持邀请码注册 |
|
||||
| 域名规则 | ✅ | ✅ | 自定义等效域名、全局域名排除 |
|
||||
| Fill-assist | ✅ | ✅ | `POST /fill-assist` 辅助客户端自动填充;不能绕过保险库解锁 |
|
||||
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
|
||||
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
|
||||
|
||||
---
|
||||
|
||||
## 已测试客户端
|
||||
|
||||
- ✅ Windows 桌面端
|
||||
- ✅ 手机 App
|
||||
- ✅ 浏览器扩展
|
||||
- ✅ Linux 桌面端
|
||||
- ⚠️ macOS 桌面端尚未完整验证
|
||||
|
||||
---
|
||||
|
||||
## 可视化快速部署
|
||||
|
||||
1. Fork NodeWarden 仓库到自己的 GitHub 账号
|
||||
2. 进入 [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
||||
3. 选择 Continue with GitHub 并选择你的仓库
|
||||
4. 构建命令填 `npm run build`,部署命令填 `npm run deploy`
|
||||
- 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
|
||||
5. 等部署完成后,打开生成的 Workers 域名
|
||||
|
||||
- Workers 默认域名在部分网络环境不可直连。如需自定义域名,到 [Workers 设置](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings)里添加。
|
||||
|
||||
- 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。
|
||||
|
||||
- 如需隐藏 Web Vault,在 Workers 的“设置 → 变量和机密”中添加文本变量 `HIDE_WEB_VAULT`,值设为 `1`。启用后,服务器上的前端页面和静态资源统一返回 `404 Not Found`,Bitwarden 客户端所需的登录、同步、附件、图标、通知等服务端接口仍可使用;已经安装或缓存的 PWA 可以继续使用本地前端。删除该变量(或将值改为非 `1`)即可恢复服务器上的 Web Vault。
|
||||
|
||||
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml` 或 `wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。
|
||||
|
||||
|
||||
> [!TIP]
|
||||
> 默认R2与可选KV的区别:
|
||||
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|
||||
> |---|---|---|---|
|
||||
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
|
||||
> | KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
||||
|
||||
|
||||
## 更新方法:
|
||||
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork` ➜ `Update branch`
|
||||
|
||||
|
||||
|
||||
|
||||
## CLI 部署
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||
cd NodeWarden
|
||||
|
||||
npm install
|
||||
npx wrangler login
|
||||
|
||||
# 默认:R2 模式
|
||||
npm run deploy
|
||||
|
||||
# 可选:KV 模式
|
||||
npm run deploy:kv
|
||||
|
||||
# 本地开发
|
||||
npm run dev
|
||||
npm run dev:kv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 开源协议
|
||||
|
||||
LGPL-3.0 License
|
||||
|
||||
---
|
||||
|
||||
## 致谢
|
||||
|
||||
- [Bitwarden](https://bitwarden.com/) - 原始设计与客户端
|
||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务端实现参考
|
||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
|
||||
|
||||
---
|
||||
|
||||
## 贡献者
|
||||
|
||||
<a href="https://github.com/shuaiplus/nodewarden/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=shuaiplus/nodewarden" alt="NodeWarden contributors" />
|
||||
</a>
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=shuaiplus%2FNodeWarden&type=timeline&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&theme=dark&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||
</picture>
|
||||
</a>
|
||||
@@ -0,0 +1,389 @@
|
||||
<!--
|
||||
Release note writing rules:
|
||||
1. Always add the newest release at the top of this file; do not delete older release notes.
|
||||
2. Move previous releases down unchanged, then write the new release above them.
|
||||
3. Use this exact release structure:
|
||||
- One H1 title: "# vX.Y.Z - Short English Summary".
|
||||
- English section first with "### Added", "### Improved", and "### Fixed".
|
||||
- Then a horizontal rule "---".
|
||||
- Chinese section second with "### 新增", "### 改进", and "### 修复".
|
||||
4. Use numbered items under each group. Each item must be:
|
||||
- "1. **Short feature/fix title.** One concise paragraph explaining what changed and why it matters."
|
||||
- No one-line commit dumps, no raw changelog lists, no vague marketing copy.
|
||||
- Merge related commits into one readable item instead of listing every commit separately.
|
||||
5. Keep the tone user-friendly and concrete:
|
||||
- Explain behavior, compatibility, UI impact, migration impact, or bug impact in plain language.
|
||||
- Be concise but specific; one item is usually 1-3 sentences.
|
||||
- Avoid internal-only implementation details unless they explain a user-visible change.
|
||||
6. Add GitHub commit links at the end of every numbered item:
|
||||
- English: "Commit: [abc1234](...)." or "Commits: [abc1234](...), [def5678](...)."
|
||||
- Chinese: "提交:[abc1234](...)。" or "提交:[abc1234](...)、[def5678](...)。"
|
||||
7. The English and Chinese versions should match in content and ordering, not be two different summaries.
|
||||
-->
|
||||
|
||||
# v1.8.0 - Deployment Control, Session Reliability, and Compatibility Fixes
|
||||
|
||||
### Added
|
||||
|
||||
1. **Web Vault visibility control.** Cloudflare Workers deployments can now set `HIDE_WEB_VAULT=1` to return `404 Not Found` for server-hosted Web Vault pages and static assets while keeping Bitwarden login, sync, attachment, icon, notification, and compatibility endpoints available. Removing the variable restores the Web Vault, and already installed or cached PWAs can continue using their local frontend. Commit: [d990b2c](https://github.com/shuaiplus/nodewarden/commit/d990b2c71ff43a43f4598cad715b09c673e87b53).
|
||||
|
||||
### Improved
|
||||
|
||||
1. **Simpler desktop and mobile navigation.** The Web Vault now uses a consistent grouped sidebar with persistent expanded sections, clearer separation between tools, settings, and system management, and matching mobile settings navigation. This removes the previous layout-mode picker and makes frequently used destinations easier to find. Commit: [e25ec15](https://github.com/shuaiplus/nodewarden/commit/e25ec159bb2cd07ec6b3a794032a8a2978340d3d).
|
||||
|
||||
1. **Safer YubiKey validation credential management.** Yubico validation credentials now use centralized, concurrency-safe initialization; only active administrators can view or replace configured credentials, and credential initialization and reconfiguration are recorded in the security audit log. Regular users can still enroll YubiKeys without gaining access to server-wide credentials. Commit: [573451c](https://github.com/shuaiplus/nodewarden/commit/573451c52f02978dee5ab8379ff86e59da805437).
|
||||
|
||||
1. **Bitwarden-compatible personal API keys and safer backups.** Personal API keys can now be viewed after master-password verification and rotated without revoking unrelated sessions. Legacy hashed keys continue to authenticate until the user explicitly rotates them, while new backups exclude personal API keys and runtime authentication or device-trust state; older archives remain importable, but that runtime state is intentionally ignored during restore. Commit: [299eda5](https://github.com/shuaiplus/nodewarden/commit/299eda597ff8a07bf0b7ddfb6e3a5e7f800096db).
|
||||
|
||||
### Fixed
|
||||
|
||||
1. **Unexpected logout during temporary refresh failures.** Refresh sessions now use client-aware sliding lifetimes with an absolute upper bound, and transient network or service failures no longer turn a locked Web Vault into a forced full login. The Web Vault preserves the locked session, shows a retry path, and keeps official desktop, mobile, browser-extension, and web-cookie flows compatible. Commit: [b731a01](https://github.com/shuaiplus/nodewarden/commit/b731a014f1d86552615110cdf543f809a5c6a7e6).
|
||||
|
||||
1. **Complete key data required for master-password changes.** Password changes now reject incomplete or inconsistent authentication and unlock data, require the newly wrapped user key, and prevent KDF settings from being changed through the password-only endpoint. This avoids accepting a password update that could leave the account unable to decrypt its vault. Commit: [19de8d6](https://github.com/shuaiplus/nodewarden/commit/19de8d6e5769be463f973c8f2ec4de2e1530005c).
|
||||
|
||||
1. **Extended vault item imports preserve their data.** Web imports now correctly map bank accounts, driver's licenses, and passports instead of reducing item types 6-8 to incomplete generic drafts. Import summaries also report these item types by name. Commit: [e943357](https://github.com/shuaiplus/nodewarden/commit/e943357067236deeaa76ac0003020fa986abf2ab).
|
||||
|
||||
1. **Reliable bulk folder deletion.** Bulk folder deletion now calculates Cloudflare D1 bind limits correctly and batches the related cipher cleanup and folder removal statements safely, preventing large selections from failing or leaving partially updated folder references. Commit: [63b642b](https://github.com/shuaiplus/nodewarden/commit/63b642b2511207f435546802e197b6842f5c7aca).
|
||||
|
||||
1. **Fresh remote backup directory listings.** Opening a remote backup destination now automatically refreshes directory data when the saved cache is older than five minutes, so newly created backup files appear without requiring a manual refresh. Commit: [72d8ec9](https://github.com/shuaiplus/nodewarden/commit/72d8ec9cbadcb1b74d032deb5e5eea96e785d9c4).
|
||||
|
||||
---
|
||||
|
||||
### 新增
|
||||
|
||||
1. **Web Vault 显示开关。** Cloudflare Workers 部署现在可以设置 `HIDE_WEB_VAULT=1`,让服务器托管的 Web Vault 页面和静态资源统一返回 `404 Not Found`,同时继续提供 Bitwarden 客户端所需的登录、同步、附件、图标、通知和兼容接口。删除变量即可恢复 Web Vault,已经安装或缓存的 PWA 仍可继续使用本地前端。提交:[d990b2c](https://github.com/shuaiplus/nodewarden/commit/d990b2c71ff43a43f4598cad715b09c673e87b53)。
|
||||
|
||||
### 改进
|
||||
|
||||
1. **更简洁的桌面端和移动端导航。** Web Vault 现在统一使用分组侧边栏,并会保存各分组的展开状态;工具、设置和系统管理的层级更加清楚,移动端设置入口也同步调整。原有布局模式选择器已移除,常用功能更容易查找。提交:[e25ec15](https://github.com/shuaiplus/nodewarden/commit/e25ec159bb2cd07ec6b3a794032a8a2978340d3d)。
|
||||
|
||||
1. **更安全的 YubiKey 验证凭据管理。** Yubico 验证凭据现在通过统一且可防并发冲突的流程初始化;只有状态正常的管理员能够查看或替换服务器凭据,初始化和重新配置操作也会写入安全审计日志。普通用户仍可绑定 YubiKey,但无法接触服务器级凭据。提交:[573451c](https://github.com/shuaiplus/nodewarden/commit/573451c52f02978dee5ab8379ff86e59da805437)。
|
||||
|
||||
1. **兼容 Bitwarden 的个人 API Key 与更安全的备份。** 用户现在可以在验证主密码后查看个人 API Key,并在不撤销无关会话的情况下轮换密钥。旧版哈希密钥在用户主动轮换前仍可继续认证;新备份不再包含个人 API Key、运行中的认证状态或设备信任状态,旧备份仍能导入,但其中这类运行时状态会被主动忽略。提交:[299eda5](https://github.com/shuaiplus/nodewarden/commit/299eda597ff8a07bf0b7ddfb6e3a5e7f800096db)。
|
||||
|
||||
### 修复
|
||||
|
||||
1. **临时刷新失败导致意外退出登录。** 刷新会话现在根据客户端采用滑动有效期,并保留绝对最长时限;临时网络或服务故障不会再把已锁定的 Web Vault 直接变成完整登录页。Web Vault 会保留锁定会话并提供重试入口,同时兼容官方桌面端、移动端、浏览器扩展和 Web Cookie 会话。提交:[b731a01](https://github.com/shuaiplus/nodewarden/commit/b731a014f1d86552615110cdf543f809a5c6a7e6)。
|
||||
|
||||
1. **修改主密码时必须提交完整密钥数据。** 密码修改接口现在会拒绝不完整或不一致的认证与解锁数据,强制要求新的用户密钥包装结果,并禁止通过仅修改密码的接口顺带更改 KDF 设置,避免出现密码已更新但保险库无法解密的账户状态。提交:[19de8d6](https://github.com/shuaiplus/nodewarden/commit/19de8d6e5769be463f973c8f2ec4de2e1530005c)。
|
||||
|
||||
1. **扩展保险库条目导入时完整保留数据。** Web 导入现在会正确映射银行账户、驾驶证和护照,不再把类型 6-8 转换为字段缺失的通用草稿;导入结果摘要也会按名称显示这些条目类型。提交:[e943357](https://github.com/shuaiplus/nodewarden/commit/e943357067236deeaa76ac0003020fa986abf2ab)。
|
||||
|
||||
1. **可靠的批量文件夹删除。** 批量删除文件夹时现在会正确计算 Cloudflare D1 的绑定参数上限,并安全批处理密码条目引用清理和文件夹删除语句,避免大量选择时失败或留下只更新了一部分的文件夹引用。提交:[63b642b](https://github.com/shuaiplus/nodewarden/commit/63b642b2511207f435546802e197b6842f5c7aca)。
|
||||
|
||||
1. **远端备份目录自动显示最新文件。** 打开远端备份目标时,如果已保存的目录缓存超过五分钟,页面会自动获取最新数据,新生成的备份文件无需手动点击刷新即可出现。提交:[72d8ec9](https://github.com/shuaiplus/nodewarden/commit/72d8ec9cbadcb1b74d032deb5e5eea96e785d9c4)。
|
||||
|
||||
# v1.7.4 - Password Tools, Localization, and Security Hardening
|
||||
|
||||
### Added
|
||||
|
||||
1. **Built-in password generator.** The web vault now provides a dedicated generator for creating strong, configurable passwords, including memorable passphrases backed by the EFF word list. Generated values can be used while creating or editing vault items without leaving the app. Commits: [dfc9800](https://github.com/shuaiplus/nodewarden/commit/dfc98008cb58e9ed01b21ba158bb2584291462a3), [fb37679](https://github.com/shuaiplus/nodewarden/commit/fb376797d266003f8e23b64870f3638fde35d428).
|
||||
|
||||
1. **Password security dashboard.** A new password-security view scans the vault and reports weak, reused, exposed, and missing two-factor-authentication passwords, helping users prioritize account cleanup. Commit: [99b5027](https://github.com/shuaiplus/nodewarden/commit/99b50275a6a845e6ebbbae4d647350df939457f9).
|
||||
|
||||
1. **Duplicate-item selection tools.** Duplicate results are grouped by color and sorted alphabetically, and the vault now lets users select the unique items from a duplicate group for quicker review and cleanup. Commits: [0992170](https://github.com/shuaiplus/nodewarden/commit/099217062a4cb3a3caacce7513354bb388e8d76c), [39d9df7](https://github.com/shuaiplus/nodewarden/commit/39d9df78ea324fb2d1509221606408b1bb610118).
|
||||
|
||||
1. **Five additional interface languages.** Finnish, German, French, Italian, and Swedish are now available in the web vault, expanding the supported interface languages to ten. Commits: [dd90d7b](https://github.com/shuaiplus/nodewarden/commit/dd90d7b8b88a8a49ff1423bb36abb4eeb8f2f329), [9caa064](https://github.com/shuaiplus/nodewarden/commit/9caa0644888c25db835f8c5c93b8341ed80a42fe).
|
||||
|
||||
### Improved
|
||||
|
||||
1. **Passkey unlock and Bitwarden-client compatibility.** Account passkeys can now unlock the web vault directly, and supported FIDO2 origins, worker-routed fill-assist, Android asset-links checks, and web bootstrap paths are recognized more consistently. Commits: [8c65cb2](https://github.com/shuaiplus/nodewarden/commit/8c65cb2e80c6e5454fb53dbd7ea45cb83bf88ef7), [db31792](https://github.com/shuaiplus/nodewarden/commit/db31792cefc0f21fd543faf21407107a53b8dac2).
|
||||
|
||||
1. **Clearer offline and Send experiences.** The app now clearly indicates when it is operating offline, Send pages use improved date formatting, and closing dialogs retain their title through the animation to avoid a visual flash. Commits: [aae614a](https://github.com/shuaiplus/nodewarden/commit/aae614a079b5fa151e2bb98506f1b4fceac29072), [04cb475](https://github.com/shuaiplus/nodewarden/commit/04cb4759358b85029e3e32a5cab6ca39cbbef744), [525b773](https://github.com/shuaiplus/nodewarden/commit/525b773cf4799913ac24e34857348e3aa176608b).
|
||||
|
||||
1. **Simplified new-device sign-in.** New-device verification is no longer enforced, removing an extra login step for devices that are otherwise able to authenticate successfully. Commit: [14dff8e](https://github.com/shuaiplus/nodewarden/commit/14dff8ee6a59b741d86a42b25451116b120ac404).
|
||||
|
||||
### Fixed
|
||||
|
||||
1. **Backup destination SSRF protection.** Backup destination validation now rejects IPv6 loopback addresses, closing a path that could otherwise bypass loopback-host safeguards. Commit: [3c581d1](https://github.com/shuaiplus/nodewarden/commit/3c581d1fb1d92da9e00d3ff139c46f080462e6e8).
|
||||
|
||||
1. **Sensitive admin actions require the master password.** Administrative operations and device-wipe actions now require master-password confirmation, reducing the impact of an unattended or compromised web session. Commit: [fa611dc](https://github.com/shuaiplus/nodewarden/commit/fa611dc8430fc80744662feaaf3912341d5b63f2).
|
||||
|
||||
---
|
||||
|
||||
### 新增
|
||||
|
||||
1. **内置密码生成器。** Web 保险库现在提供专用的密码生成器,可创建高强度且可配置的密码,也支持基于 EFF 词表的易记口令短语。生成的值可直接用于新建或编辑保险库条目,无需离开应用。提交:[dfc9800](https://github.com/shuaiplus/nodewarden/commit/dfc98008cb58e9ed01b21ba158bb2584291462a3)、[fb37679](https://github.com/shuaiplus/nodewarden/commit/fb376797d266003f8e23b64870f3638fde35d428)。
|
||||
|
||||
1. **密码安全仪表板。** 新增的密码安全视图会扫描保险库,并报告弱密码、重复密码、已泄露密码和缺少双因素认证的密码,帮助用户优先处理需要清理的账户。提交:[99b5027](https://github.com/shuaiplus/nodewarden/commit/99b50275a6a845e6ebbbae4d647350df939457f9)。
|
||||
|
||||
1. **重复条目选择工具。** 重复项结果会按颜色分组并按字母顺序排序;保险库现在还可以从重复组中选择唯一条目,以便更快地审查和清理。提交:[0992170](https://github.com/shuaiplus/nodewarden/commit/099217062a4cb3a3caacce7513354bb388e8d76c)、[39d9df7](https://github.com/shuaiplus/nodewarden/commit/39d9df78ea324fb2d1509221606408b1bb610118)。
|
||||
|
||||
1. **新增五种界面语言。** Web 保险库现在提供芬兰语、德语、法语、意大利语和瑞典语,支持的界面语言扩展至十种。提交:[dd90d7b](https://github.com/shuaiplus/nodewarden/commit/dd90d7b8b88a8a49ff1423bb36abb4eeb8f2f329)、[9caa064](https://github.com/shuaiplus/nodewarden/commit/9caa0644888c25db835f8c5c93b8341ed80a42fe)。
|
||||
|
||||
### 改进
|
||||
|
||||
1. **通行密钥解锁和 Bitwarden 客户端兼容性。** 账户通行密钥现在可以直接解锁 Web 保险库;同时,对受支持 FIDO2 来源、Worker 路由的 fill-assist、Android asset-links 检查和 Web 启动路径的识别更加一致。提交:[8c65cb2](https://github.com/shuaiplus/nodewarden/commit/8c65cb2e80c6e5454fb53dbd7ea45cb83bf88ef7)、[db31792](https://github.com/shuaiplus/nodewarden/commit/db31792cefc0f21fd543faf21407107a53b8dac2)。
|
||||
|
||||
1. **更清晰的离线和 Send 使用体验。** 应用现在会明确提示离线运行状态,Send 页面采用了更好的日期格式,关闭对话框时会在动画期间保留标题,避免视觉闪烁。提交:[aae614a](https://github.com/shuaiplus/nodewarden/commit/aae614a079b5fa151e2bb98506f1b4fceac29072)、[04cb475](https://github.com/shuaiplus/nodewarden/commit/04cb4759358b85029e3e32a5cab6ca39cbbef744)、[525b773](https://github.com/shuaiplus/nodewarden/commit/525b773cf4799913ac24e34857348e3aa176608b)。
|
||||
|
||||
1. **简化新设备登录。** 不再强制执行新设备验证,能够正常完成身份验证的设备无需额外登录步骤。提交:[14dff8e](https://github.com/shuaiplus/nodewarden/commit/14dff8ee6a59b741d86a42b25451116b120ac404)。
|
||||
|
||||
### 修复
|
||||
|
||||
1. **备份目标的 SSRF 防护。** 备份目标校验现在会拒绝 IPv6 回环地址,堵住了可能绕过回环主机保护的路径。提交:[3c581d1](https://github.com/shuaiplus/nodewarden/commit/3c581d1fb1d92da9e00d3ff139c46f080462e6e8)。
|
||||
|
||||
1. **敏感管理员操作需要主密码。** 管理员操作和设备擦除操作现在需要确认主密码,降低无人值守或会话遭入侵时的影响。提交:[fa611dc](https://github.com/shuaiplus/nodewarden/commit/fa611dc8430fc80744662feaaf3912341d5b63f2)。
|
||||
|
||||
# v1.7.3 - Stronger Two-Step Login and Client Compatibility
|
||||
|
||||
### Added
|
||||
|
||||
1. **YubiKey OTP and passkey two-step login.** NodeWarden now supports YubiKey OTP as a managed two-factor provider and adds passkey-based two-factor authentication, including setup screens, WebAuthn fallback connector handling, multi-provider login prompts, and safer WebAuthn response normalization. Commits: [f63b745](https://github.com/shuaiplus/nodewarden/commit/f63b745), [c019c93](https://github.com/shuaiplus/nodewarden/commit/c019c93), [e73ae3d](https://github.com/shuaiplus/nodewarden/commit/e73ae3d), [d8cc88d](https://github.com/shuaiplus/nodewarden/commit/d8cc88d).
|
||||
|
||||
1. **Bitwarden extended vault item types.** Vault items now cover bank accounts, driver's licenses, and passports in addition to the existing login, card, identity, secure note, and SSH key flows. The web vault can create, display, decrypt, import, and export these item types with clearer sidebar icons. Commits: [109593d](https://github.com/shuaiplus/nodewarden/commit/109593d), [9de0d3b](https://github.com/shuaiplus/nodewarden/commit/9de0d3b).
|
||||
|
||||
1. **More Bitwarden client compatibility endpoints.** Added device verification settings, device registration routes, admin auth-request compatibility, fill-assist alignment, and push relay installation handling so more official Bitwarden client flows receive expected responses. Unsupported email verification and KDF routes now return explicit unsupported responses instead of ambiguous failures. Commits: [e376a84](https://github.com/shuaiplus/nodewarden/commit/e376a84), [8b2f98b](https://github.com/shuaiplus/nodewarden/commit/8b2f98b), [f0e5233](https://github.com/shuaiplus/nodewarden/commit/f0e5233), [56b301f](https://github.com/shuaiplus/nodewarden/commit/56b301f), [fd46dff](https://github.com/shuaiplus/nodewarden/commit/fd46dff), [cde4555](https://github.com/shuaiplus/nodewarden/commit/cde4555).
|
||||
|
||||
### Improved
|
||||
|
||||
1. **TOTP QR scanning and Bitwarden-compatible TOTP behavior.** Uploading TOTP QR codes now falls back to `jsQR` when browser `BarcodeDetector` support is incomplete, handles transparent PNGs correctly, validates uploaded QR images, and throttles camera fallback decoding to reduce CPU usage. TOTP storage and decryption behavior is also aligned more closely with Bitwarden clients. Commits: [b0a679b](https://github.com/shuaiplus/nodewarden/commit/b0a679b), [d024798](https://github.com/shuaiplus/nodewarden/commit/d024798), [73bbe8b](https://github.com/shuaiplus/nodewarden/commit/73bbe8b), [6e72220](https://github.com/shuaiplus/nodewarden/commit/6e72220), [8a5b210](https://github.com/shuaiplus/nodewarden/commit/8a5b210).
|
||||
|
||||
1. **Settings, device management, and localization polish.** Device management now lives inside Settings with updated navigation, the two-step provider UI is more responsive, and new settings, audit-log, and validation messages are localized across supported languages. This makes the security settings area easier to scan on desktop and mobile. Commits: [c7eb6c6](https://github.com/shuaiplus/nodewarden/commit/c7eb6c6), [062c966](https://github.com/shuaiplus/nodewarden/commit/062c966), [12af18e](https://github.com/shuaiplus/nodewarden/commit/12af18e), [c53d71f](https://github.com/shuaiplus/nodewarden/commit/c53d71f), [01ff627](https://github.com/shuaiplus/nodewarden/commit/01ff627).
|
||||
|
||||
1. **Encrypted Send password visibility and editing.** Password-protected Sends now show a lock indicator in the list, display masked password dots when editing an existing protected Send, and provide a compact trash-icon control for removing the stored password. This makes password state visible without exposing the password itself. Commits: [a870142](https://github.com/shuaiplus/nodewarden/commit/a870142), [ebc8e8e](https://github.com/shuaiplus/nodewarden/commit/ebc8e8e).
|
||||
|
||||
1. **Website icon behavior and workflow maintenance.** Website icons are now always available without the old `WEBSITE_ICONS_ENABLED` environment toggle, while icon requests keep privacy protections. The global-domains sync workflow also validates its ref before running. Commits: [57c5ef9](https://github.com/shuaiplus/nodewarden/commit/57c5ef9), [c643874](https://github.com/shuaiplus/nodewarden/commit/c643874), [680e287](https://github.com/shuaiplus/nodewarden/commit/680e287).
|
||||
|
||||
### Fixed
|
||||
|
||||
1. **Authentication, token, and rate-limit hardening.** API keys are stored as hashes, password rotation and JWT handling were tightened, user cache invalidates on token handling, remembered 2FA tokens survive a bad password attempt, the current access-token session is revoked correctly, and known rate-limit reset bypasses were closed. Commits: [1545881](https://github.com/shuaiplus/nodewarden/commit/1545881), [439683d](https://github.com/shuaiplus/nodewarden/commit/439683d), [60dd298](https://github.com/shuaiplus/nodewarden/commit/60dd298), [d9a36fe](https://github.com/shuaiplus/nodewarden/commit/d9a36fe), [1bad32f](https://github.com/shuaiplus/nodewarden/commit/1bad32f), [2df43cc](https://github.com/shuaiplus/nodewarden/commit/2df43cc), [ae168be](https://github.com/shuaiplus/nodewarden/commit/ae168be).
|
||||
|
||||
1. **User data isolation and request validation.** Storage reads are scoped by user, Send file routes gate access more strictly, anonymous notification hub requests are validated, and multipart backup/upload requests now have caps. This reduces the chance of cross-user data reads or oversized requests reaching deeper handlers. Commits: [baf5699](https://github.com/shuaiplus/nodewarden/commit/baf5699), [8c481a1](https://github.com/shuaiplus/nodewarden/commit/8c481a1), [23c53bd](https://github.com/shuaiplus/nodewarden/commit/23c53bd), [5142846](https://github.com/shuaiplus/nodewarden/commit/5142846).
|
||||
|
||||
1. **Backup, restore, and download safety.** Remote backup deletes are verified, archives and backup blobs are validated before use, destination secrets are redacted from settings responses, backup/download token flows are harder to misuse, and WebAuthn credential purpose survives backup export/import. A backup uploader redirect guard was also reverted to restore compatible remote behavior. Commits: [0cef6a0](https://github.com/shuaiplus/nodewarden/commit/0cef6a0), [00e0ec0](https://github.com/shuaiplus/nodewarden/commit/00e0ec0), [5c8f01b](https://github.com/shuaiplus/nodewarden/commit/5c8f01b), [cc4a830](https://github.com/shuaiplus/nodewarden/commit/cc4a830), [f532d3a](https://github.com/shuaiplus/nodewarden/commit/f532d3a), [a366acb](https://github.com/shuaiplus/nodewarden/commit/a366acb).
|
||||
|
||||
1. **Import compatibility and encrypted-field validation.** Imports now validate payload structure and ZIP entries before processing, and plaintext FIDO2 credential, SSH key, and password-history fields are rejected instead of being silently accepted and later dropped. This makes failed imports clearer and protects encrypted vault fields from incompatible plaintext data. Commits: [cf14704](https://github.com/shuaiplus/nodewarden/commit/cf14704), [1ec6ed4](https://github.com/shuaiplus/nodewarden/commit/1ec6ed4).
|
||||
|
||||
1. **Admin, audit, WebAuthn, and backup endpoint edge cases.** Admin audit-clears are recorded, passkey 2FA status is reported correctly, WebAuthn extension origins are constrained, and auth-request plus backup endpoint checks were tightened around sensitive flows. Commits: [d028b19](https://github.com/shuaiplus/nodewarden/commit/d028b19), [ace00e8](https://github.com/shuaiplus/nodewarden/commit/ace00e8), [7ac6ae5](https://github.com/shuaiplus/nodewarden/commit/7ac6ae5).
|
||||
|
||||
---
|
||||
|
||||
### 新增
|
||||
|
||||
1. **YubiKey OTP 和通行密钥两步登录。** NodeWarden 现在支持将 YubiKey OTP 作为可管理的双因素提供商,并新增基于通行密钥的双因素认证,包含设置界面、WebAuthn 备用连接器处理、多提供商登录提示,以及更安全的 WebAuthn 响应规范化。提交:[f63b745](https://github.com/shuaiplus/nodewarden/commit/f63b745)、[c019c93](https://github.com/shuaiplus/nodewarden/commit/c019c93)、[e73ae3d](https://github.com/shuaiplus/nodewarden/commit/e73ae3d)、[d8cc88d](https://github.com/shuaiplus/nodewarden/commit/d8cc88d)。
|
||||
|
||||
1. **Bitwarden 扩展保险库条目类型。** 除现有登录、银行卡、身份、安全笔记和 SSH 密钥流程外,保险库条目现在还覆盖银行账户、驾驶证和护照。Web 保险库可以创建、展示、解密、导入和导出这些条目类型,并提供更清晰的侧边栏图标。提交:[109593d](https://github.com/shuaiplus/nodewarden/commit/109593d)、[9de0d3b](https://github.com/shuaiplus/nodewarden/commit/9de0d3b)。
|
||||
|
||||
1. **更多 Bitwarden 客户端兼容端点。** 新增设备验证设置、设备注册路由、管理员认证请求兼容、fill-assist 对齐和推送中继安装处理,让更多官方 Bitwarden 客户端流程能获得预期响应。不支持的邮箱验证和 KDF 路由现在会返回明确的不支持响应,而不是含糊失败。提交:[e376a84](https://github.com/shuaiplus/nodewarden/commit/e376a84)、[8b2f98b](https://github.com/shuaiplus/nodewarden/commit/8b2f98b)、[f0e5233](https://github.com/shuaiplus/nodewarden/commit/f0e5233)、[56b301f](https://github.com/shuaiplus/nodewarden/commit/56b301f)、[fd46dff](https://github.com/shuaiplus/nodewarden/commit/fd46dff)、[cde4555](https://github.com/shuaiplus/nodewarden/commit/cde4555)。
|
||||
|
||||
### 改进
|
||||
|
||||
1. **TOTP 二维码扫描和 Bitwarden 兼容 TOTP 行为。** 上传 TOTP 二维码时,如果浏览器 `BarcodeDetector` 支持不完整,现在会回退到 `jsQR`,并正确处理透明 PNG、校验上传的二维码图片、限制摄像头回退解码频率以降低 CPU 占用。TOTP 的存储和解密行为也更贴近 Bitwarden 客户端。提交:[b0a679b](https://github.com/shuaiplus/nodewarden/commit/b0a679b)、[d024798](https://github.com/shuaiplus/nodewarden/commit/d024798)、[73bbe8b](https://github.com/shuaiplus/nodewarden/commit/73bbe8b)、[6e72220](https://github.com/shuaiplus/nodewarden/commit/6e72220)、[8a5b210](https://github.com/shuaiplus/nodewarden/commit/8a5b210)。
|
||||
|
||||
1. **设置、设备管理和本地化打磨。** 设备管理现在整合进设置页并更新了导航,两步验证提供商界面在响应式布局下更顺手,新的设置、审计日志和校验消息也补齐了受支持语言的本地化。安全设置区域在桌面和移动端都更容易浏览。提交:[c7eb6c6](https://github.com/shuaiplus/nodewarden/commit/c7eb6c6)、[062c966](https://github.com/shuaiplus/nodewarden/commit/062c966)、[12af18e](https://github.com/shuaiplus/nodewarden/commit/12af18e)、[c53d71f](https://github.com/shuaiplus/nodewarden/commit/c53d71f)、[01ff627](https://github.com/shuaiplus/nodewarden/commit/01ff627)。
|
||||
|
||||
1. **加密 Send 的密码状态展示与编辑。** 受密码保护的 Send 现在会在列表中显示锁定标记,编辑已有受保护 Send 时会显示密码掩码圆点,并提供紧凑的垃圾桶图标用于移除已保存密码。这样可以看清密码状态,同时不暴露密码本身。提交:[a870142](https://github.com/shuaiplus/nodewarden/commit/a870142)、[ebc8e8e](https://github.com/shuaiplus/nodewarden/commit/ebc8e8e)。
|
||||
|
||||
1. **网站图标行为和工作流维护。** 网站图标现在无需旧的 `WEBSITE_ICONS_ENABLED` 环境开关即可始终可用,同时图标请求仍保留隐私保护。global-domains 同步工作流也会在运行前校验引用。提交:[57c5ef9](https://github.com/shuaiplus/nodewarden/commit/57c5ef9)、[c643874](https://github.com/shuaiplus/nodewarden/commit/c643874)、[680e287](https://github.com/shuaiplus/nodewarden/commit/680e287)。
|
||||
|
||||
### 修复
|
||||
|
||||
1. **认证、令牌和速率限制加固。** API key 现在以哈希形式存储,密码轮换和 JWT 处理更严格,令牌处理时会使用户缓存失效,错误密码不会丢失已记住的 2FA token,当前访问令牌会被正确撤销,并关闭了已知的速率限制重置绕过路径。提交:[1545881](https://github.com/shuaiplus/nodewarden/commit/1545881)、[439683d](https://github.com/shuaiplus/nodewarden/commit/439683d)、[60dd298](https://github.com/shuaiplus/nodewarden/commit/60dd298)、[d9a36fe](https://github.com/shuaiplus/nodewarden/commit/d9a36fe)、[1bad32f](https://github.com/shuaiplus/nodewarden/commit/1bad32f)、[2df43cc](https://github.com/shuaiplus/nodewarden/commit/2df43cc)、[ae168be](https://github.com/shuaiplus/nodewarden/commit/ae168be)。
|
||||
|
||||
1. **用户数据隔离和请求校验。** 存储读取现在按用户限定范围,Send 文件路由更严格地拦截访问,匿名通知 hub 请求会被校验,并且多段备份/上传请求增加了上限。这降低了跨用户数据读取或超大请求进入深层处理器的风险。提交:[baf5699](https://github.com/shuaiplus/nodewarden/commit/baf5699)、[8c481a1](https://github.com/shuaiplus/nodewarden/commit/8c481a1)、[23c53bd](https://github.com/shuaiplus/nodewarden/commit/23c53bd)、[5142846](https://github.com/shuaiplus/nodewarden/commit/5142846)。
|
||||
|
||||
1. **备份、恢复和下载安全性。** 远端备份删除现在会被验证,归档和备份 blob 使用前会校验,目标配置里的密钥会在设置响应中脱敏,备份/下载令牌流程更难被误用,WebAuthn 凭据用途也会在备份导出/导入中保留。备份上传器的重定向防护也已回退,以恢复兼容的远端行为。提交:[0cef6a0](https://github.com/shuaiplus/nodewarden/commit/0cef6a0)、[00e0ec0](https://github.com/shuaiplus/nodewarden/commit/00e0ec0)、[5c8f01b](https://github.com/shuaiplus/nodewarden/commit/5c8f01b)、[cc4a830](https://github.com/shuaiplus/nodewarden/commit/cc4a830)、[f532d3a](https://github.com/shuaiplus/nodewarden/commit/f532d3a)、[a366acb](https://github.com/shuaiplus/nodewarden/commit/a366acb)。
|
||||
|
||||
1. **导入兼容性和加密字段校验。** 导入流程现在会在处理前校验 payload 结构和 ZIP 条目,明文 FIDO2 凭据、SSH 密钥和密码历史字段会被拒绝,而不是先被静默接受再在响应时丢弃。这让失败导入更清楚,也保护加密保险库字段不接收不兼容的明文数据。提交:[cf14704](https://github.com/shuaiplus/nodewarden/commit/cf14704)、[1ec6ed4](https://github.com/shuaiplus/nodewarden/commit/1ec6ed4)。
|
||||
|
||||
1. **管理员、审计、WebAuthn 和备份端点边界情况。** 管理员清空审计日志会被记录,通行密钥 2FA 状态会正确上报,WebAuthn 扩展来源会受到限制,并且认证请求与备份端点围绕敏感流程的校验也更严格。提交:[d028b19](https://github.com/shuaiplus/nodewarden/commit/d028b19)、[ace00e8](https://github.com/shuaiplus/nodewarden/commit/ace00e8)、[7ac6ae5](https://github.com/shuaiplus/nodewarden/commit/7ac6ae5)。
|
||||
|
||||
---
|
||||
|
||||
# v1.7.2 - New Backup Providers, WebAuthn PRF, and UI Polish
|
||||
|
||||
### Added
|
||||
|
||||
1. **Three new S3-compatible backup providers: Backblaze B2, Cloudflare R2, and Tigris.** Each new destination comes with detailed provider-specific recommendations, storage-class guidance, and localization strings across all five supported languages. You can now back up to more services without custom scripting. Commits: [1acc31e](https://github.com/shuaiplus/nodewarden/commit/1acc31e), [c3dc53b](https://github.com/shuaiplus/nodewarden/commit/c3dc53b), [ff85698](https://github.com/shuaiplus/nodewarden/commit/ff85698).
|
||||
|
||||
2. **WebAuthn PRF (pseudorandom function) extension support.** Credential creation and assertion now pass browser-compatible PRF extension requests, support excluding PRF extensions where the client doesn't need them, and handle the underlying passkey operations more robustly. This improves WebAuthn compatibility with modern browsers and password managers that rely on PRF for per-credential keys. Commits: [8942e5b](https://github.com/shuaiplus/nodewarden/commit/8942e5b), [31cfd19](https://github.com/shuaiplus/nodewarden/commit/31cfd19), [6a1a835](https://github.com/shuaiplus/nodewarden/commit/6a1a835), [bf6ac7b](https://github.com/shuaiplus/nodewarden/commit/bf6ac7b).
|
||||
|
||||
3. **Backup import locking and checksum verification.** Restoring a full backup now acquires an exclusive lock so concurrent imports cannot collide, and the importer verifies file checksums before applying the data. This makes disaster recovery safer when multiple admins might trigger restores. Commit: [e9272ec](https://github.com/shuaiplus/nodewarden/commit/e9272ec).
|
||||
|
||||
4. **Fullscreen layout toggle.** The web vault can now switch to fullscreen mode with a dedicated toggle button, with corresponding localization updates. Useful for kiosk-mode or presentation setups. Commit: [d722815](https://github.com/shuaiplus/nodewarden/commit/d722815).
|
||||
|
||||
5. **Fill-assist API handlers.** NodeWarden now implements Bitwarden-compatible credential fill-assist endpoints, letting clients fetch credentials inline via the new `POST /fill-assist` route. Device response types are also updated to include the fields needed by the fill-assist flow. Commit: [e4215b4](https://github.com/shuaiplus/nodewarden/commit/e4215b4).
|
||||
|
||||
6. **Device selection and removal in SecurityDevicesPage.** The security devices panel now supports selecting individual trusted devices and removing them directly from the web UI, so you no longer need to use the API to revoke a specific device. Commit: [a5ad16a](https://github.com/shuaiplus/nodewarden/commit/a5ad16a).
|
||||
|
||||
7. **Delete invalid organization invitations.** Admins can now detect and remove dangling or invalid invitations from the admin panel, helping keep the invitation list clean. The API also renamed `revokeInvite` to `deleteInvite` for clearer semantics. Commits: [0d1bb19](https://github.com/shuaiplus/nodewarden/commit/0d1bb19), [f82dcc3](https://github.com/shuaiplus/nodewarden/commit/f82dcc3).
|
||||
|
||||
8. **validFolderIds support in cipher responses.** Sync and cipher responses now include a `validFolderIds` field so clients can distinguish real folders from orphaned references. The folder repository also validates folder existence more strictly. Commit: [82f968e](https://github.com/shuaiplus/nodewarden/commit/82f968e).
|
||||
|
||||
9. **Pending auth request loading state.** The pending login-request panel shows a refreshing indicator while fetching or updating the request list, providing clearer feedback during auth request workflows. Commit: [4378e1b](https://github.com/shuaiplus/nodewarden/commit/4378e1b).
|
||||
|
||||
### Improved
|
||||
|
||||
1. **Enhanced Bitwarden CSV import with custom field and multiline support.** The CSV parser now recognizes custom fields and restores their metadata correctly during import. It also preserves multiline values such as SSH private keys—previously, any line without a `: ` delimiter was silently dropped, truncating private keys to the first line. Text fields containing newlines now survive a full export-import round-trip. Commits: [5eeaf4e](https://github.com/shuaiplus/nodewarden/commit/5eeaf4e), [68c42a0](https://github.com/shuaiplus/nodewarden/commit/68c42a0).
|
||||
|
||||
2. **Consolidated security devices UI.** Device management and authorized devices sections are merged into a single coherent card on SecurityDevicesPage, and the pending-auth-requests panel has been removed from the general SettingsPage to reduce clutter. The device list also includes improved selection controls. Commit: [c694f1b](https://github.com/shuaiplus/nodewarden/commit/c694f1b).
|
||||
|
||||
3. **Refined app-shell styles and dark mode consistency.** Removed redundant global styles, cleaned up shell component spacing, and improved dark-mode visual consistency across the header, sidebar, and main content areas. Commit: [1bfb9a6](https://github.com/shuaiplus/nodewarden/commit/1bfb9a6).
|
||||
|
||||
4. **Backup and restore error messages across all locales.** New error strings for backup/restore edge cases—lock failures, checksum mismatches, missing files—are now localized in all five supported languages (en, es, ru, zh-CN, zh-TW), with improved UI prompts for backup browser refresh scenarios. Commit: [4cd9ad0](https://github.com/shuaiplus/nodewarden/commit/4cd9ad0).
|
||||
|
||||
5. **Updated project wiki link and removed obsolete security scripts.** The issue-template wiki link now points to the correct URL, and the old local security scanning scripts and workflows have been removed in favor of GitHub-native security automation (CodeQL, security-extra workflows). Commit: [e31f82c](https://github.com/shuaiplus/nodewarden/commit/e31f82c).
|
||||
|
||||
6. **Security automation and dependency hardening.** Added GitHub-native CodeQL and security-extra workflows, overrode a `ws` vulnerability, and upgraded CI actions to pinned major versions (checkout v7, setup-node v6, create-pull-request v8). Dependencies refreshed include TypeScript 6.0, `@types/node` 26, `lucide-preact` 1.x, and many others across npm and GitHub Actions. Commits: [64f26e7](https://github.com/shuaiplus/nodewarden/commit/64f26e7), [32b3d2a](https://github.com/shuaiplus/nodewarden/commit/32b3d2a), [5dd9dff](https://github.com/shuaiplus/nodewarden/commit/5dd9dff), [8d292ca](https://github.com/shuaiplus/nodewarden/commit/8d292ca), [5bd7dab](https://github.com/shuaiplus/nodewarden/commit/5bd7dab), [99f2d7f](https://github.com/shuaiplus/nodewarden/commit/99f2d7f), [fb9a2ae](https://github.com/shuaiplus/nodewarden/commit/fb9a2ae), [c87e6ac](https://github.com/shuaiplus/nodewarden/commit/c87e6ac).
|
||||
|
||||
### Fixed
|
||||
|
||||
1. **CSV import truncating multiline field values.** `parseBitwardenCsvFieldLines` previously discarded any line that did not contain a `: ` delimiter, silently dropping SSH private keys and other multiline content to only the first line. The parser now accumulates continuation lines correctly, restoring full private key content through a CSV round-trip. Commit: [68c42a0](https://github.com/shuaiplus/nodewarden/commit/68c42a0).
|
||||
|
||||
---
|
||||
|
||||
### 新增
|
||||
|
||||
1. **三个新的 S3 兼容备份提供商:Backblaze B2、Cloudflare R2 和 Tigris。** 每个新目标都带有详细的提供商建议、存储层级指导和五种语言的本地化字符串,无需额外脚本即可将备份扩展到更多存储服务。提交:[1acc31e](https://github.com/shuaiplus/nodewarden/commit/1acc31e)、[c3dc53b](https://github.com/shuaiplus/nodewarden/commit/c3dc53b)、[ff85698](https://github.com/shuaiplus/nodewarden/commit/ff85698)。
|
||||
|
||||
2. **WebAuthn PRF(伪随机函数)扩展支持。** 创建和断言凭证时会传递浏览器兼容的 PRF 扩展请求,支持在不需要时排除 PRF 扩展,并且底层密钥操作更健壮。这改善了与依赖 PRF 做每凭据密钥派生功能的现代浏览器和密码管理器的兼容性。提交:[8942e5b](https://github.com/shuaiplus/nodewarden/commit/8942e5b)、[31cfd19](https://github.com/shuaiplus/nodewarden/commit/31cfd19)、[6a1a835](https://github.com/shuaiplus/nodewarden/commit/6a1a835)、[bf6ac7b](https://github.com/shuaiplus/nodewarden/commit/bf6ac7b)。
|
||||
|
||||
3. **备份导入加锁和校验和验证。** 完整恢复备份时现在会获取独占锁,防止并发导入冲突;导入前还会验证文件校验和再应用数据。多管理员可能同时触发恢复时,该机制让灾难恢复更加安全。提交:[e9272ec](https://github.com/shuaiplus/nodewarden/commit/e9272ec)。
|
||||
|
||||
4. **全屏布局切换。** Web 保险库现在可以通过专用按钮切换全屏模式,附带对应本地化更新。适合信息亭模式或展示等场景。提交:[d722815](https://github.com/shuaiplus/nodewarden/commit/d722815)。
|
||||
|
||||
5. **Fill-assist API 处理器。** NodeWarden 现在实现了与 Bitwarden 兼容的凭据填充辅助端点,客户端可以通过新的 `POST /fill-assist` 路由内联获取凭据。设备响应类型也补上了 fill-assist 流程需要的字段。提交:[e4215b4](https://github.com/shuaiplus/nodewarden/commit/e4215b4)。
|
||||
|
||||
6. **安全设备页的设备选择与删除。** 设备面板现在支持在 Web UI 中直接选择单个可信设备并移除,无需通过 API 手动撤销指定设备。提交:[a5ad16a](https://github.com/shuaiplus/nodewarden/commit/a5ad16a)。
|
||||
|
||||
7. **删除无效邀请码。** 管理员现在可以在管理面板中检测并删除悬空或无效的邀请,保持邀请列表整洁。API 也将 `revokeInvite` 改名为 `deleteInvite`,语义更清晰。提交:[0d1bb19](https://github.com/shuaiplus/nodewarden/commit/0d1bb19)、[f82dcc3](https://github.com/shuaiplus/nodewarden/commit/f82dcc3)。
|
||||
|
||||
8. **密码条目响应增加 validFolderIds。** 同步和密码条目响应现在包含 `validFolderIds` 字段,方便客户端区分真实文件夹和孤立引用;文件夹存储也加强了对文件夹存在性的校验。提交:[82f968e](https://github.com/shuaiplus/nodewarden/commit/82f968e)。
|
||||
|
||||
9. **待处理认证请求的加载状态。** 待处理的登录请求面板现在会在获取或更新请求列表时显示刷新指示器,为认证请求操作提供更清晰的反馈。提交:[4378e1b](https://github.com/shuaiplus/nodewarden/commit/4378e1b)。
|
||||
|
||||
### 改进
|
||||
|
||||
1. **增强的 Bitwarden CSV 导入——自定义字段和多行支持。** CSV 解析器现在可以识别自定义字段并在导入时正确恢复其元数据。同时保留了 SSH 私钥等多行值——之前任何不带 `: ` 分隔符的行都会被丢弃,导致私钥只保留第一行。包含换行符的文本字段现在可以完整通过导出-导入周期。提交:[5eeaf4e](https://github.com/shuaiplus/nodewarden/commit/5eeaf4e)、[68c42a0](https://github.com/shuaiplus/nodewarden/commit/68c42a0)。
|
||||
|
||||
2. **整合安全设备界面。** 设备管理和已授权设备两个部分合并为 SecurityDevicesPage 上的一个统一卡片;待处理认证请求面板从 SettingsPage 中移除以减少杂乱。设备列表也改进了选择操作。提交:[c694f1b](https://github.com/shuaiplus/nodewarden/commit/c694f1b)。
|
||||
|
||||
3. **精简应用外壳样式与暗色模式一致性。** 移除了冗余全局样式,清理了外壳组件间距,改善了头部、侧边栏和主内容区在暗色模式下的视觉一致性。提交:[1bfb9a6](https://github.com/shuaiplus/nodewarden/commit/1bfb9a6)。
|
||||
|
||||
4. **备份/恢复错误消息全语言本地化。** 备份/恢复边界场景(加锁失败、校验和不匹配、文件缺失)的新错误字符串已在五种支持语言(en、es、ru、zh-CN、zh-TW)中完成本地化,同时改进了备份浏览器刷新场景下的界面提示。提交:[4cd9ad0](https://github.com/shuaiplus/nodewarden/commit/4cd9ad0)。
|
||||
|
||||
5. **更新项目 Wiki 链接并移除过时安全脚本。** 议题模板中的 Wiki 链接已指向正确 URL;老旧的本地安全扫描脚本和工作流已移除,改用 GitHub 原生安全自动化(CodeQL、security-extra 工作流)。提交:[e31f82c](https://github.com/shuaiplus/nodewarden/commit/e31f82c)。
|
||||
|
||||
6. **安全自动化和依赖加固。** 新增 GitHub 原生 CodeQL 和 security-extra 工作流;覆盖了 `ws` 的已知漏洞;将 CI Action 升级到钉死的主要版本(checkout v7、setup-node v6、create-pull-request v8)。依赖升级包括 TypeScript 6.0、`@types/node` 26、`lucide-preact` 1.x,以及 npm 和 GitHub Actions 的多项更新。提交:[64f26e7](https://github.com/shuaiplus/nodewarden/commit/64f26e7)、[32b3d2a](https://github.com/shuaiplus/nodewarden/commit/32b3d2a)、[5dd9dff](https://github.com/shuaiplus/nodewarden/commit/5dd9dff)、[8d292ca](https://github.com/shuaiplus/nodewarden/commit/8d292ca)、[5bd7dab](https://github.com/shuaiplus/nodewarden/commit/5bd7dab)、[99f2d7f](https://github.com/shuaiplus/nodewarden/commit/99f2d7f)、[fb9a2ae](https://github.com/shuaiplus/nodewarden/commit/fb9a2ae)、[c87e6ac](https://github.com/shuaiplus/nodewarden/commit/c87e6ac)。
|
||||
|
||||
### 修复
|
||||
|
||||
1. **CSV 导入截断多行字段值。** `parseBitwardenCsvFieldLines` 之前会丢弃任何不包含 `: ` 分隔符的行,导致 SSH 私钥等多行内容被静默截断为仅第一行。解析器现已正确累积后续行,使私钥等完整内容能够通过 CSV 导出-导入周期完好保留。提交:[68c42a0](https://github.com/shuaiplus/nodewarden/commit/68c42a0)。
|
||||
|
||||
# v1.7.1 - Security Hardening Update
|
||||
|
||||
Thanks to GN998 for responsibly reporting security issues addressed in this release.
|
||||
|
||||
### Added
|
||||
|
||||
1. **No new user-facing features.** This patch release intentionally focuses on security fixes and defensive hardening rather than new product functionality. Commits: [7279668](https://github.com/shuaiplus/nodewarden/commit/7279668), [850fe0f](https://github.com/shuaiplus/nodewarden/commit/850fe0f), [a2a8f1c](https://github.com/shuaiplus/nodewarden/commit/a2a8f1c), [23b23f3](https://github.com/shuaiplus/nodewarden/commit/23b23f3).
|
||||
|
||||
### Improved
|
||||
|
||||
1. **Stronger security defaults.** NodeWarden now applies more conservative handling around sensitive authentication, backup, and file-delivery flows while keeping existing clients compatible. Upgrade is recommended for all deployments. Commits: [7279668](https://github.com/shuaiplus/nodewarden/commit/7279668), [850fe0f](https://github.com/shuaiplus/nodewarden/commit/850fe0f), [a2a8f1c](https://github.com/shuaiplus/nodewarden/commit/a2a8f1c), [23b23f3](https://github.com/shuaiplus/nodewarden/commit/23b23f3).
|
||||
|
||||
### Fixed
|
||||
|
||||
1. **High-priority security fixes.** This release closes multiple reported security issues across sensitive server-side flows and response hardening without exposing operational details in the public notes. Commits: [7279668](https://github.com/shuaiplus/nodewarden/commit/7279668), [850fe0f](https://github.com/shuaiplus/nodewarden/commit/850fe0f), [a2a8f1c](https://github.com/shuaiplus/nodewarden/commit/a2a8f1c), [23b23f3](https://github.com/shuaiplus/nodewarden/commit/23b23f3).
|
||||
|
||||
2. **Security dependency overrides.** Package overrides were added for selected transitive dependencies so installs resolve to patched versions where applicable. Commit: [0daad46](https://github.com/shuaiplus/nodewarden/commit/0daad46).
|
||||
|
||||
---
|
||||
|
||||
### 新增
|
||||
|
||||
感谢 GN998 负责任地报告了本次发布中修复的安全问题。
|
||||
|
||||
1. **没有新增面向用户的功能。** 本次补丁发布刻意专注于安全修复和防护加固,不包含新的产品功能。提交:[7279668](https://github.com/shuaiplus/nodewarden/commit/7279668)、[850fe0f](https://github.com/shuaiplus/nodewarden/commit/850fe0f)、[a2a8f1c](https://github.com/shuaiplus/nodewarden/commit/a2a8f1c)、[23b23f3](https://github.com/shuaiplus/nodewarden/commit/23b23f3)。
|
||||
|
||||
### 改进
|
||||
|
||||
1. **更稳妥的安全默认行为。** NodeWarden 对敏感认证、备份和文件响应流程采用了更保守的处理方式,同时保持现有客户端兼容。建议所有部署尽快升级。提交:[7279668](https://github.com/shuaiplus/nodewarden/commit/7279668)、[850fe0f](https://github.com/shuaiplus/nodewarden/commit/850fe0f)、[a2a8f1c](https://github.com/shuaiplus/nodewarden/commit/a2a8f1c)、[23b23f3](https://github.com/shuaiplus/nodewarden/commit/23b23f3)。
|
||||
|
||||
### 修复
|
||||
|
||||
1. **高优先级安全修复。** 本次发布修复了多项已报告的安全问题,覆盖敏感服务端流程和响应加固;公开说明中不会展开可操作的攻击细节。提交:[7279668](https://github.com/shuaiplus/nodewarden/commit/7279668)、[850fe0f](https://github.com/shuaiplus/nodewarden/commit/850fe0f)、[a2a8f1c](https://github.com/shuaiplus/nodewarden/commit/a2a8f1c)、[23b23f3](https://github.com/shuaiplus/nodewarden/commit/23b23f3)。
|
||||
|
||||
2. **安全依赖覆盖。** 为部分传递依赖添加了版本覆盖,让安装时尽可能解析到已修复版本。提交:[0daad46](https://github.com/shuaiplus/nodewarden/commit/0daad46)。
|
||||
|
||||
# v1.7.0 - Faster Multi-Device Sync, Mobile Push, and a Smoother Vault
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
1. **Resource-level realtime sync.** NodeWarden now sends Bitwarden-style notifications for cipher, folder, and Send create, update, and delete events. The web app can refresh only the affected resource instead of reloading the full vault every time, and state-changing operations such as attachment uploads, attachment deletes, public Send access counts, and Send file downloads also emit the right updates. Commits: [fe0c66c](https://github.com/shuaiplus/nodewarden/commit/fe0c66c), [42b765b](https://github.com/shuaiplus/nodewarden/commit/42b765b), [045b23f](https://github.com/shuaiplus/nodewarden/commit/045b23f), [46ba8b9](https://github.com/shuaiplus/nodewarden/commit/46ba8b9), [f096681](https://github.com/shuaiplus/nodewarden/commit/f096681).
|
||||
|
||||
2. **Bitwarden mobile push relay support.** Devices can now store `push_uuid` and `push_token`, register or unregister through the Bitwarden push relay, and receive mobile push notifications when vault resources change. The database schema includes the new push fields and indexes needed to detect push-capable devices. Commit: [79ed7c9](https://github.com/shuaiplus/nodewarden/commit/79ed7c9).
|
||||
|
||||
3. **Bitwarden CSV export.** The web app can now export a Bitwarden-compatible CSV file alongside the existing JSON, encrypted JSON, and attachment ZIP formats. Multiple login URIs are serialized safely, and non-login item types such as cards, identities, and SSH keys are preserved as clearly as possible in field text. Commits: [b024226](https://github.com/shuaiplus/nodewarden/commit/b024226), [a06cb0e](https://github.com/shuaiplus/nodewarden/commit/a06cb0e).
|
||||
|
||||
4. **More duplicate detection modes.** Duplicate search can now compare exact item content, login site plus credentials, username plus password, or password alone. Duplicate groups are color-coded, and the mobile vault UI includes the new duplicate mode selector and improved filter controls. Commits: [7b3be2c](https://github.com/shuaiplus/nodewarden/commit/7b3be2c), [b444c0f](https://github.com/shuaiplus/nodewarden/commit/b444c0f).
|
||||
|
||||
5. **S3 addressing style selection.** Backup destinations can now choose between path-style and virtual-hosted-style S3 URLs, improving compatibility with more S3-compatible providers and self-hosted object storage. Commit: [a818316](https://github.com/shuaiplus/nodewarden/commit/a818316).
|
||||
|
||||
### Improved
|
||||
|
||||
1. **Web vault updates feel immediate.** Creating, editing, deleting, archiving, restoring, and moving items; creating or deleting folders; and creating, updating, or deleting Sends now update the local encrypted snapshot, decrypted lists, and revision timestamp directly. This reduces visible lag after successful actions and makes cached vault validation work better with resource-level sync. Commits: [42b765b](https://github.com/shuaiplus/nodewarden/commit/42b765b), [045b23f](https://github.com/shuaiplus/nodewarden/commit/045b23f).
|
||||
|
||||
2. **Better Bitwarden client compatibility.** Profile and sync responses now include fields such as `organizationsNew`, `policiesNew`, and `V2UpgradeToken`; `/api/accounts/keys` supports GET; password change and password verification accept newer `authenticationData` and `unlockData` request shapes; and device routes work with both `/api/devices` and `/devices`. Cipher responses also preserve stored `edit`, `viewPassword`, and `permissions` flags instead of resetting them. Commits: [add921b](https://github.com/shuaiplus/nodewarden/commit/add921b), [f9fe532](https://github.com/shuaiplus/nodewarden/commit/f9fe532).
|
||||
|
||||
3. **Cleaner mobile and narrow-screen UI.** Topbar controls, network status, theme switching, and lock buttons now share more consistent sizing and styling. The vault list search, sorting, filtering, create button, and bulk selection toolbar are more compact on mobile, and mobile filter menus can switch between all items, favorites, archive, trash, duplicates, types, and folders. Commits: [7e0406f](https://github.com/shuaiplus/nodewarden/commit/7e0406f), [16bde22](https://github.com/shuaiplus/nodewarden/commit/16bde22), [cd2ec82](https://github.com/shuaiplus/nodewarden/commit/cd2ec82), [c1f5795](https://github.com/shuaiplus/nodewarden/commit/c1f5795).
|
||||
|
||||
4. **More tolerant TOTP handling.** TOTP codes are grouped more naturally for 5-digit, 6-digit, 8-digit, and other lengths, and the TOTP list no longer overflows narrow screens because of fixed column widths. `otpauth://` parsing is also more tolerant of unusual parameter encoding, with more stable Steam-code detection. Commits: [9e0908f](https://github.com/shuaiplus/nodewarden/commit/9e0908f), [d5c2ab2](https://github.com/shuaiplus/nodewarden/commit/d5c2ab2).
|
||||
|
||||
5. **Less jumpy network status.** The web app no longer switches offline after one short failed probe. It uses a longer timeout, waits for repeated failures, and lets normal API successes or failures update the network state, reducing false offline unlock fallbacks when the service is reachable but slow. Commit: [b4dfb04](https://github.com/shuaiplus/nodewarden/commit/b4dfb04).
|
||||
|
||||
6. **More complete backups.** Full instance backups now include trusted two-factor device tokens and restore them during import. The importer validates token ownership, device identifiers, expiration times, and duplicates, so remembered two-factor devices can survive a full migration. Commit: [f6169b7](https://github.com/shuaiplus/nodewarden/commit/f6169b7).
|
||||
|
||||
### Fixed
|
||||
|
||||
1. **Realtime notification correctness.** Resource notification type numbers now match Bitwarden semantics, while NodeWarden-specific device status and backup progress notifications use internal values to avoid conflicts with official Send update types. SignalR MessagePack invocations now include `streamIds`, pending auth request notifications refresh the login request list, and the web app ignores notifications sent by the current device to avoid redundant refreshes. Commits: [fe0c66c](https://github.com/shuaiplus/nodewarden/commit/fe0c66c), [9a21504](https://github.com/shuaiplus/nodewarden/commit/9a21504), [4900de0](https://github.com/shuaiplus/nodewarden/commit/4900de0).
|
||||
|
||||
2. **Attachment and Send download details.** Public attachment and Send file downloads now include `Content-Disposition` filenames and `X-Content-Type-Options: nosniff`, making browser downloads keep better filenames and reducing content sniffing issues. Attachment delete responses now include both uppercase and lowercase field forms for broader client compatibility. Commit: [add921b](https://github.com/shuaiplus/nodewarden/commit/add921b).
|
||||
|
||||
3. **Deleted item and bulk action edge cases.** Vault paging now detects deleted items from both database columns and older JSON payload fields, preventing old deleted items from appearing in the normal vault list. Bulk archive skips deleted items, and duplicate detection now uses decrypted password history instead of encrypted stored text. Commits: [add921b](https://github.com/shuaiplus/nodewarden/commit/add921b), [b444c0f](https://github.com/shuaiplus/nodewarden/commit/b444c0f).
|
||||
|
||||
4. **Export, dialog, and toast polish.** CSV export now escapes login URIs correctly inside a single CSV cell; some dialog dismissal behavior is more stable; login and unlock success toasts are less noisy; and the toast close button now uses a styled SVG icon. Commits: [b024226](https://github.com/shuaiplus/nodewarden/commit/b024226), [a06cb0e](https://github.com/shuaiplus/nodewarden/commit/a06cb0e), [8f2704f](https://github.com/shuaiplus/nodewarden/commit/8f2704f), [907126d](https://github.com/shuaiplus/nodewarden/commit/907126d).
|
||||
|
||||
5. **S3 backup URL construction.** Virtual-hosted-style backup operations now use the `bucket.endpoint` form for upload, download, delete, and existence checks, while avoiding duplicate bucket names when the endpoint already includes the bucket. Path-style mode keeps the existing `endpoint/bucket` behavior. Commit: [a818316](https://github.com/shuaiplus/nodewarden/commit/a818316).
|
||||
|
||||
---
|
||||
|
||||
### 新增
|
||||
|
||||
1. **资源级实时同步。** NodeWarden 现在会按 Bitwarden 风格发送密码条目、文件夹、Send 的新增、更新和删除通知。Web 端收到通知后可以只刷新受影响的资源,而不是每次都重新同步整个保险库;附件上传、附件删除、公开 Send 访问计数、Send 文件下载等会改变状态的操作,也会触发对应更新。提交:[fe0c66c](https://github.com/shuaiplus/nodewarden/commit/fe0c66c)、[42b765b](https://github.com/shuaiplus/nodewarden/commit/42b765b)、[045b23f](https://github.com/shuaiplus/nodewarden/commit/045b23f)、[46ba8b9](https://github.com/shuaiplus/nodewarden/commit/46ba8b9)、[f096681](https://github.com/shuaiplus/nodewarden/commit/f096681)。
|
||||
|
||||
2. **Bitwarden 移动端推送中继支持。** 设备现在可以保存 `push_uuid` 和 `push_token`,通过 Bitwarden push relay 注册或注销,并在保险库资源变化时尝试接收移动端推送。数据库结构也补上了推送字段和索引,用于识别哪些设备可以被推送。提交:[79ed7c9](https://github.com/shuaiplus/nodewarden/commit/79ed7c9)。
|
||||
|
||||
3. **Bitwarden CSV 导出。** 除了原有 JSON、加密 JSON 和带附件 ZIP 导出,现在 Web 端可以直接导出 Bitwarden 兼容 CSV。多个登录 URI 会按 CSV 规则安全序列化,卡片、身份、SSH Key 等非登录类型也会尽量保留到字段文本中,方便迁移或人工整理。提交:[b024226](https://github.com/shuaiplus/nodewarden/commit/b024226)、[a06cb0e](https://github.com/shuaiplus/nodewarden/commit/a06cb0e)。
|
||||
|
||||
4. **更多重复项检测模式。** 重复项现在可以按完全一致、登录站点加凭据、用户名加密码、单独密码等方式判断。重复组会用颜色辅助区分,移动端保险库也补上了重复项模式选择和更完整的筛选入口。提交:[7b3be2c](https://github.com/shuaiplus/nodewarden/commit/7b3be2c)、[b444c0f](https://github.com/shuaiplus/nodewarden/commit/b444c0f)。
|
||||
|
||||
5. **S3 地址样式选择。** 远程备份目标现在可以选择 path-style 或 virtual-hosted-style,兼容更多 S3 服务和自建对象存储。提交:[a818316](https://github.com/shuaiplus/nodewarden/commit/a818316)。
|
||||
|
||||
### 改进
|
||||
|
||||
1. **Web 保险库操作反馈更及时。** 创建、编辑、删除、归档、恢复、移动条目,创建或删除文件夹,以及创建、更新、删除 Send 时,前端会直接更新本地加密快照、解密列表和修订时间。这样操作成功后列表更快跟上,也让资源级同步下的本地缓存校验更稳定。提交:[42b765b](https://github.com/shuaiplus/nodewarden/commit/42b765b)、[045b23f](https://github.com/shuaiplus/nodewarden/commit/045b23f)。
|
||||
|
||||
2. **Bitwarden 客户端兼容性更好。** 账户资料和同步响应补齐了 `organizationsNew`、`policiesNew`、`V2UpgradeToken` 等字段;`/api/accounts/keys` 支持 GET;改密和校验密码接口兼容较新的 `authenticationData`、`unlockData` 请求结构;设备路由同时兼容 `/api/devices` 和 `/devices`。密码条目响应也会保留已存储的 `edit`、`viewPassword` 和 `permissions`,避免跨客户端编辑时权限标记被重置。提交:[add921b](https://github.com/shuaiplus/nodewarden/commit/add921b)、[f9fe532](https://github.com/shuaiplus/nodewarden/commit/f9fe532)。
|
||||
|
||||
3. **移动端和小屏界面更顺手。** 顶部栏按钮、网络状态、主题切换、锁定按钮的尺寸和样式更统一。保险库列表里的搜索、排序、筛选、创建按钮和批量选择工具栏在移动端更紧凑;移动筛选菜单可以直接切换全部、收藏、归档、回收站、重复项、类型和文件夹。提交:[7e0406f](https://github.com/shuaiplus/nodewarden/commit/7e0406f)、[16bde22](https://github.com/shuaiplus/nodewarden/commit/16bde22)、[cd2ec82](https://github.com/shuaiplus/nodewarden/commit/cd2ec82)、[c1f5795](https://github.com/shuaiplus/nodewarden/commit/c1f5795)。
|
||||
|
||||
4. **TOTP 展示和解析更稳。** 验证码会按 5 位、6 位、8 位等不同长度更自然地分组,列表在窄屏下不会再被固定列宽撑破。`otpauth://` 解析也更能容忍特殊参数编码,Steam 类验证码识别更稳定。提交:[9e0908f](https://github.com/shuaiplus/nodewarden/commit/9e0908f)、[d5c2ab2](https://github.com/shuaiplus/nodewarden/commit/d5c2ab2)。
|
||||
|
||||
5. **网络状态不再过度敏感。** Web 端不会因为一次短暂探测失败就立刻判定离线,而是延长探测超时并等待连续失败;普通 API 请求成功或失败也会反向更新网络状态。在线但网络较慢时,不容易误进入离线解锁流程。提交:[b4dfb04](https://github.com/shuaiplus/nodewarden/commit/b4dfb04)。
|
||||
|
||||
6. **备份内容更完整。** 完整实例备份现在会导出和还原可信二步验证设备令牌。导入时会校验令牌所属用户、设备标识、过期时间和重复项,让“记住此设备”的二步验证状态在完整迁移后也能保留下来。提交:[f6169b7](https://github.com/shuaiplus/nodewarden/commit/f6169b7)。
|
||||
|
||||
### 修复
|
||||
|
||||
1. **实时通知类型和刷新逻辑。** 资源通知的类型编号调整为与 Bitwarden 官方语义一致,NodeWarden 自定义的设备状态和备份进度通知改用内部编号,避免和官方 Send 更新类型冲突。SignalR MessagePack 调用补齐了 `streamIds`,认证请求通知会刷新待处理登录请求列表,Web 端也会忽略当前设备自己发出的通知,避免重复刷新。提交:[fe0c66c](https://github.com/shuaiplus/nodewarden/commit/fe0c66c)、[9a21504](https://github.com/shuaiplus/nodewarden/commit/9a21504)、[4900de0](https://github.com/shuaiplus/nodewarden/commit/4900de0)。
|
||||
|
||||
2. **附件和 Send 文件下载细节。** 公开附件和 Send 文件下载现在会带上 `Content-Disposition` 文件名和 `X-Content-Type-Options: nosniff`,浏览器保存文件时更接近原文件名,也减少类型嗅探问题。删除附件的响应同时提供大小写两套字段,兼容不同客户端读取方式。提交:[add921b](https://github.com/shuaiplus/nodewarden/commit/add921b)。
|
||||
|
||||
3. **已删除条目和批量操作边界。** 保险库分页查询现在会同时识别数据库列和历史 JSON 数据里的删除时间,避免旧数据中已删除条目出现在正常列表。批量归档会跳过已删除条目,重复项判断也会使用已解密的密码历史,避免加密文本影响结果。提交:[add921b](https://github.com/shuaiplus/nodewarden/commit/add921b)、[b444c0f](https://github.com/shuaiplus/nodewarden/commit/b444c0f)。
|
||||
|
||||
4. **导出、弹窗和提示细节。** CSV 导出中的登录 URI 会按单行 CSV 单元格正确转义;部分弹窗关闭行为更稳定;登录或解锁成功后的 toast 更克制,避免重复提示;toast 关闭按钮换成了 SVG 图标并调整了样式。提交:[b024226](https://github.com/shuaiplus/nodewarden/commit/b024226)、[a06cb0e](https://github.com/shuaiplus/nodewarden/commit/a06cb0e)、[8f2704f](https://github.com/shuaiplus/nodewarden/commit/8f2704f)、[907126d](https://github.com/shuaiplus/nodewarden/commit/907126d)。
|
||||
|
||||
5. **S3 备份地址拼接。** 选择 virtual-hosted-style 时,备份上传、下载、删除和存在性检查会使用 `bucket.endpoint` 形式;如果 endpoint 已经带有 bucket,也不会重复拼接 bucket。path-style 仍保持原有 `endpoint/bucket` 形式。提交:[a818316](https://github.com/shuaiplus/nodewarden/commit/a818316)。
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Thank you for helping keep NodeWarden safe.
|
||||
|
||||
Please **do not report security vulnerabilities through public GitHub issues, discussions, pull requests, or chat groups**.
|
||||
|
||||
Use GitHub Private Vulnerability Reporting instead:
|
||||
|
||||
1. Open the NodeWarden repository on GitHub.
|
||||
2. Go to **Security and quality**.
|
||||
3. Click **Report a vulnerability**.
|
||||
4. Submit the report privately.
|
||||
|
||||
NodeWarden is independent from Bitwarden. Please do not report NodeWarden-specific issues to the official Bitwarden team.
|
||||
|
||||
## What to Include
|
||||
|
||||
Please include as much detail as possible:
|
||||
|
||||
* A clear description of the vulnerability.
|
||||
* Steps to reproduce.
|
||||
* Affected version, commit, or deployment method.
|
||||
* Affected area, such as login, sync, vault data, attachments, Send, import/export, backup/restore, Passkey, WebAuthn, or API routes.
|
||||
* Expected behavior and actual behavior.
|
||||
* Security impact, such as authentication bypass, authorization bypass, replay, cross-user access, token misuse, data leakage, or secret exposure.
|
||||
* Proof of concept, logs, screenshots, or request examples, if safe to share privately.
|
||||
|
||||
Please redact real passwords, tokens, private keys, recovery keys, vault data, and other secrets before submitting.
|
||||
|
||||
## Scope
|
||||
|
||||
Security reports are welcome for issues affecting NodeWarden itself, including:
|
||||
|
||||
* Authentication and session handling.
|
||||
* User authorization and cross-user access.
|
||||
* Vault data, cipher sync, attachments, and Send.
|
||||
* Import, export, backup, and restore.
|
||||
* Passkey, WebAuthn, and two-factor authentication.
|
||||
* Secret handling and provider credentials.
|
||||
* Cloudflare Workers, D1, R2, KV, WebDAV, or S3 behavior caused by NodeWarden code or documentation.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
The following are usually out of scope:
|
||||
|
||||
* Issues only affecting third-party services or user infrastructure.
|
||||
* Misconfigured personal deployments not caused by NodeWarden defaults.
|
||||
* Social engineering or phishing.
|
||||
* Denial-of-service testing.
|
||||
* Scanner-only reports without a practical exploit path.
|
||||
* Reports that only mention outdated dependencies without showing real impact.
|
||||
|
||||
## Response
|
||||
|
||||
NodeWarden is maintained on a best-effort basis.
|
||||
|
||||
We aim to acknowledge valid private reports within 72 hours, investigate the issue, and release a fix or mitigation when appropriate.
|
||||
|
||||
Please do not publicly disclose vulnerability details before a fix or mitigation is available.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security fixes are generally provided for the latest release and the latest code on the default branch.
|
||||
|
||||
| Version | Supported |
|
||||
| -------------- | ---------------------- |
|
||||
| Latest release | Yes |
|
||||
| `main` branch | Yes |
|
||||
| Older releases | Best effort |
|
||||
| Modified forks | Not directly supported |
|
||||
|
||||
## Rewards
|
||||
|
||||
NodeWarden does not currently operate a paid bug bounty program.
|
||||
+205
-8
@@ -1,5 +1,15 @@
|
||||
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
|
||||
@@ -9,6 +19,7 @@ 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,
|
||||
@@ -18,10 +29,25 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
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 0,
|
||||
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,
|
||||
@@ -42,11 +68,15 @@ CREATE TABLE IF NOT EXISTS ciphers (
|
||||
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,
|
||||
@@ -69,14 +99,189 @@ CREATE TABLE IF NOT EXISTS attachments (
|
||||
);
|
||||
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,
|
||||
security_stamp TEXT,
|
||||
created_at INTEGER,
|
||||
last_used_at INTEGER,
|
||||
absolute_expires_at INTEGER,
|
||||
client_type 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,
|
||||
category TEXT NOT NULL DEFAULT 'system',
|
||||
level TEXT NOT NULL DEFAULT 'info',
|
||||
target_type TEXT,
|
||||
target_id TEXT,
|
||||
metadata TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_category_created ON audit_logs(category, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
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,
|
||||
push_uuid TEXT,
|
||||
push_token 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 INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
organization_id TEXT,
|
||||
type INTEGER NOT NULL,
|
||||
request_device_identifier TEXT NOT NULL,
|
||||
request_device_type INTEGER NOT NULL,
|
||||
request_ip_address TEXT,
|
||||
request_country_name TEXT,
|
||||
response_device_identifier TEXT,
|
||||
access_code TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
key TEXT,
|
||||
master_password_hash TEXT,
|
||||
approved INTEGER,
|
||||
creation_date TEXT NOT NULL,
|
||||
response_date TEXT,
|
||||
authentication_date TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_requests_user_created
|
||||
ON auth_requests(user_id, creation_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_requests_user_pending
|
||||
ON auth_requests(user_id, approved, response_date, authentication_date, creation_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_requests_device_pending
|
||||
ON auth_requests(user_id, request_device_identifier, creation_date);
|
||||
|
||||
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 totp_login_replays (
|
||||
user_id TEXT NOT NULL,
|
||||
time_counter INTEGER NOT NULL,
|
||||
consumed_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, time_counter),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at
|
||||
ON totp_login_replays(consumed_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL DEFAULT 'login',
|
||||
name TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
credential_id TEXT NOT NULL,
|
||||
counter INTEGER NOT NULL DEFAULT 0,
|
||||
type TEXT,
|
||||
aa_guid TEXT,
|
||||
transports TEXT,
|
||||
encrypted_user_key TEXT,
|
||||
encrypted_public_key TEXT,
|
||||
encrypted_private_key TEXT,
|
||||
supports_prf INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_credential_id
|
||||
ON webauthn_credentials(credential_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user
|
||||
ON webauthn_credentials(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated
|
||||
ON webauthn_credentials(user_id, updated_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webauthn_challenges (
|
||||
challenge_hash TEXT PRIMARY KEY,
|
||||
scope TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
expires_at INTEGER NOT NULL,
|
||||
used_at INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires
|
||||
ON webauthn_challenges(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user_scope
|
||||
ON webauthn_challenges(user_id, scope);
|
||||
|
||||
-- Rate limiting
|
||||
CREATE TABLE IF NOT EXISTS login_attempts_ip (
|
||||
ip TEXT PRIMARY KEY,
|
||||
@@ -85,14 +290,6 @@ CREATE TABLE IF NOT EXISTS login_attempts_ip (
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_rate_limits (
|
||||
identifier TEXT NOT NULL,
|
||||
window_start INTEGER NOT NULL,
|
||||
count INTEGER NOT NULL,
|
||||
PRIMARY KEY (identifier, window_start)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (
|
||||
jti TEXT PRIMARY KEY,
|
||||
expires_at INTEGER NOT NULL
|
||||
|
||||
Generated
+3829
-374
File diff suppressed because it is too large
Load Diff
+50
-9
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nodewarden",
|
||||
"version": "1.0.0",
|
||||
"version": "1.8.0",
|
||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||
"author": "shuaiplus",
|
||||
"license": "LGPL-3.0",
|
||||
@@ -8,8 +8,21 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev -c wrangler.toml",
|
||||
"deploymy": "wrangler deploy -c wrangler.my.toml",
|
||||
"deploy": "wrangler deploy"
|
||||
"dev:kv": "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",
|
||||
"test:config-compatibility": "tsx --test scripts/config-compatibility.test.ts",
|
||||
"test:web-crypto": "tsx --test scripts/web-crypto-availability.test.ts",
|
||||
"test:webauthn-mobile": "node --test scripts/webauthn-mobile-connector.test.mjs",
|
||||
"test:webauthn-connector": "node --test scripts/webauthn-connector.test.mjs && tsx --test scripts/webauthn-connector-headers.test.ts",
|
||||
"test:webauthn-connectors": "node --test scripts/webauthn-mobile-connector.test.mjs scripts/webauthn-connector.test.mjs && tsx --test scripts/webauthn-connector-headers.test.ts",
|
||||
"deploy": "wrangler deploy",
|
||||
"deploy:kv": "node scripts/ensure-kv.cjs && wrangler deploy -c wrangler.kv.toml",
|
||||
"deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo"
|
||||
},
|
||||
"keywords": [
|
||||
"bitwarden",
|
||||
@@ -21,21 +34,49 @@
|
||||
"cloudflare": {
|
||||
"bindings": {
|
||||
"JWT_SECRET": {
|
||||
"description": "Secret used to sign JWTs. Use a strong random string (32+ characters recommended)"
|
||||
"description": "Use a strong random string (32+ characters recommended)"
|
||||
},
|
||||
"DB": {
|
||||
"description": "D1 database for storing vault data"
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
"description": "R2 bucket for storing file attachments"
|
||||
},
|
||||
"ATTACHMENTS_KV": {
|
||||
"description": "Optional KV namespace fallback for attachment/send-file storage"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": {
|
||||
"undici": ">=7.28.0",
|
||||
"@babel/core": ">=7.29.6",
|
||||
"esbuild": ">=0.28.1",
|
||||
"ws": "8.21.0",
|
||||
"sharp": "0.35.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260131.0",
|
||||
"@types/node": "^25.2.3",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"wrangler": "^4.61.1"
|
||||
"@cloudflare/workers-types": "^5.20260723.1",
|
||||
"@preact/preset-vite": "^2.10.5",
|
||||
"@types/node": "^26.0.1",
|
||||
"autoprefixer": "^10.5.2",
|
||||
"opencc-js": "^1.3.2",
|
||||
"postcss": "^8.5.16",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.1.3",
|
||||
"wrangler": "^4.105.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@simplewebauthn/server": "^13.3.2",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@zip.js/zip.js": "^2.8.26",
|
||||
"fflate": "^0.8.3",
|
||||
"jsqr": "1.4.0",
|
||||
"lucide-preact": "^1.22.0",
|
||||
"preact": "^10.29.3",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"wouter": "^3.10.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildConfigResponse } from '../src/config-response';
|
||||
|
||||
test('config enables the official Bitwarden desktop settings dialog', () => {
|
||||
const body = buildConfigResponse('https://vault.example.test');
|
||||
|
||||
assert.equal(body.featureStates['desktop-ui-settings-dialog'], true);
|
||||
assert.equal(body.environment.vault, 'https://vault.example.test');
|
||||
assert.equal(body.object, 'config');
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Make `deploy:kv` idempotent across repeated builds.
|
||||
*
|
||||
* KV namespaces are referenced in wrangler config by account-scoped `id`, not
|
||||
* by name. The template ships without an id so fresh accounts can provision one
|
||||
* on first deploy. In non-interactive builds, wrangler may try to create the
|
||||
* same namespace again on later builds and fail with code 10014.
|
||||
*/
|
||||
const { execSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const CONFIG = path.resolve(__dirname, '..', 'wrangler.kv.toml');
|
||||
const BINDING = 'ATTACHMENTS_KV';
|
||||
|
||||
const wrangler = (args) =>
|
||||
execSync(`npx wrangler ${args}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] });
|
||||
|
||||
function bindingBlockHasId(toml) {
|
||||
const blocks = toml.match(/\[\[kv_namespaces\]\][^[]*/g) || [];
|
||||
const block = blocks.find((entry) => new RegExp(`binding\\s*=\\s*"${BINDING}"`).test(entry));
|
||||
return block ? /^\s*id\s*=/m.test(block) : false;
|
||||
}
|
||||
|
||||
function expectedTitle(toml) {
|
||||
const name = (toml.match(/^\s*name\s*=\s*"([^"]+)"/m) || [])[1] || 'worker';
|
||||
return `${name}-${BINDING.toLowerCase().replace(/_/g, '-')}`;
|
||||
}
|
||||
|
||||
function resolveId(title) {
|
||||
const list = JSON.parse(wrangler('kv namespace list'));
|
||||
const hit =
|
||||
list.find((namespace) => namespace.title === title) ||
|
||||
list.find((namespace) => typeof namespace.title === 'string' && namespace.title.endsWith('attachments-kv'));
|
||||
if (hit) {
|
||||
console.log(`[ensure-kv] reusing existing namespace "${hit.title}" (${hit.id})`);
|
||||
return hit.id;
|
||||
}
|
||||
|
||||
const out = wrangler(`kv namespace create "${title}"`);
|
||||
const id = (out.match(/id\s*=\s*"([0-9a-fA-F]{32})"/) || [])[1];
|
||||
if (!id) throw new Error(`[ensure-kv] could not parse new namespace id from:\n${out}`);
|
||||
console.log(`[ensure-kv] created namespace "${title}" (${id})`);
|
||||
return id;
|
||||
}
|
||||
|
||||
function main() {
|
||||
let toml = fs.readFileSync(CONFIG, 'utf8');
|
||||
if (bindingBlockHasId(toml)) {
|
||||
console.log(`[ensure-kv] ${BINDING} already pinned in wrangler.kv.toml; nothing to do`);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = resolveId(expectedTitle(toml));
|
||||
toml = toml.replace(
|
||||
new RegExp(`(\\[\\[kv_namespaces\\]\\]\\s*\\n\\s*binding\\s*=\\s*"${BINDING}")`),
|
||||
`$1\nid = "${id}"`
|
||||
);
|
||||
fs.writeFileSync(CONFIG, toml);
|
||||
console.log('[ensure-kv] pinned id into wrangler.kv.toml for this build');
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,49 @@
|
||||
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'],
|
||||
['fi', 'fi.ts', 'fi', 'Finnish'],
|
||||
['de', 'de.ts', 'de', 'German'],
|
||||
['fr', 'fr.ts', 'fr', 'French'],
|
||||
['it', 'it.ts', 'it', 'Italian'],
|
||||
['sv', 'sv.ts', 'sv', 'Swedish'],
|
||||
];
|
||||
|
||||
function readLocale(fileName, variableName) {
|
||||
let code = fs.readFileSync(path.join(localeDir, fileName), 'utf8');
|
||||
code = code
|
||||
.replace(/const (\w+): Record<string, string> =/g, 'const $1 =')
|
||||
.replace(/export default \w+;\s*$/m, '');
|
||||
code += `\nresult = ${variableName};`;
|
||||
const sandbox = { result: null };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(code, sandbox, { filename: fileName });
|
||||
return sandbox.result;
|
||||
}
|
||||
|
||||
function writeLocale(fileName, variableName, table, header) {
|
||||
const body = JSON.stringify(table, null, 2);
|
||||
fs.writeFileSync(
|
||||
path.join(localeDir, fileName),
|
||||
`${header}\nconst ${variableName}: Record<string, string> = ${body};\n\nexport default ${variableName};\n`,
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
localeFiles,
|
||||
localeDir,
|
||||
readLocale,
|
||||
writeLocale,
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
const { localeFiles, readLocale } = require('./i18n-utils.cjs');
|
||||
|
||||
// CONTRACT:
|
||||
// This is the authoritative locale consistency gate. It checks key parity,
|
||||
// placeholder parity, and accidental mostly-English locale files. Run after any
|
||||
// user-facing text or locale-file change.
|
||||
const locales = Object.fromEntries(
|
||||
localeFiles.map(([locale, fileName, variableName]) => [locale, readLocale(fileName, variableName)])
|
||||
);
|
||||
const base = locales.en;
|
||||
const baseKeys = Object.keys(base).sort();
|
||||
const placeholderRe = /\{\w+\}/g;
|
||||
const errors = [];
|
||||
const intentionallyEnglishKeys = new Set([
|
||||
'txt_backup_destination_detail_note',
|
||||
'txt_backup_protocol_webdav',
|
||||
'txt_backup_protocol_s3',
|
||||
'txt_backup_recommend_group_webdav',
|
||||
'txt_backup_recommend_group_s3',
|
||||
'txt_backup_destination_name_default_webdav',
|
||||
'txt_backup_destination_name_default_s3',
|
||||
'txt_dash',
|
||||
'txt_text_3',
|
||||
]);
|
||||
const intentionallyEnglishPrefixes = [
|
||||
'txt_log_action_',
|
||||
'txt_log_meta_',
|
||||
'txt_log_reason_',
|
||||
'txt_log_target_type_',
|
||||
'txt_log_trigger_',
|
||||
];
|
||||
|
||||
function isIntentionallyEnglishKey(key) {
|
||||
return intentionallyEnglishKeys.has(key) || intentionallyEnglishPrefixes.some((prefix) => key.startsWith(prefix));
|
||||
}
|
||||
|
||||
for (const [locale, table] of Object.entries(locales)) {
|
||||
const keys = Object.keys(table).sort();
|
||||
const missing = baseKeys.filter((key) => !(key in table));
|
||||
const extra = keys.filter((key) => !baseKeys.includes(key));
|
||||
if (missing.length || extra.length) {
|
||||
errors.push({ locale, missing, extra });
|
||||
}
|
||||
|
||||
for (const key of baseKeys) {
|
||||
const basePlaceholders = Array.from(String(base[key]).matchAll(placeholderRe), (match) => match[0]).sort().join('|');
|
||||
const localePlaceholders = Array.from(String(table[key]).matchAll(placeholderRe), (match) => match[0]).sort().join('|');
|
||||
if (basePlaceholders !== localePlaceholders) {
|
||||
errors.push({ locale, key, basePlaceholders, localePlaceholders });
|
||||
}
|
||||
}
|
||||
|
||||
if (locale !== 'en') {
|
||||
const sameAsEnglish = baseKeys.filter((key) => table[key] === base[key] && !isIntentionallyEnglishKey(key));
|
||||
if (sameAsEnglish.length > 40) {
|
||||
errors.push({
|
||||
locale,
|
||||
sameAsEnglishCount: sameAsEnglish.length,
|
||||
sameAsEnglishSample: sameAsEnglish.slice(0, 25),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
counts: Object.fromEntries(Object.entries(locales).map(([locale, table]) => [locale, Object.keys(table).length])),
|
||||
errors,
|
||||
}, null, 2));
|
||||
|
||||
if (errors.length) {
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const distDir = path.resolve(__dirname, '..', 'dist');
|
||||
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(distDir, '_redirects'), '/* /index.html 200\n');
|
||||
@@ -0,0 +1,128 @@
|
||||
import { handleGetApiKey, handleRotateApiKey } from '../src/handlers/accounts.ts';
|
||||
import { hashApiKey, verifyApiKey } from '../src/utils/api-key.ts';
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function createUserRow(apiKey) {
|
||||
return {
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
name: 'User',
|
||||
master_password_hint: null,
|
||||
master_password_hash: 'master-proof',
|
||||
key: 'wrapped-user-key',
|
||||
private_key: null,
|
||||
public_key: null,
|
||||
kdf_type: 0,
|
||||
kdf_iterations: 600000,
|
||||
kdf_memory: null,
|
||||
kdf_parallelism: null,
|
||||
security_stamp: 'security-stamp-original',
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
verify_devices: 0,
|
||||
totp_secret: null,
|
||||
totp_recovery_code: null,
|
||||
yubikey_key1: null,
|
||||
yubikey_key2: null,
|
||||
yubikey_key3: null,
|
||||
yubikey_key4: null,
|
||||
yubikey_key5: null,
|
||||
yubikey_nfc: 0,
|
||||
api_key: apiKey,
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
updated_at: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
function createDb(apiKey) {
|
||||
const state = {
|
||||
user: createUserRow(apiKey),
|
||||
userWrites: 0,
|
||||
refreshDeletes: 0,
|
||||
auditActions: [],
|
||||
};
|
||||
const db = {
|
||||
prepare(sql) {
|
||||
let bindings = [];
|
||||
const statement = {
|
||||
bind(...values) {
|
||||
bindings = values;
|
||||
return statement;
|
||||
},
|
||||
async first() {
|
||||
if (/FROM users WHERE id = \?/i.test(sql)) return { ...state.user };
|
||||
return null;
|
||||
},
|
||||
async all() {
|
||||
return { results: [] };
|
||||
},
|
||||
async run() {
|
||||
if (/INSERT INTO users\(/i.test(sql)) {
|
||||
state.userWrites += 1;
|
||||
state.user.security_stamp = bindings[12];
|
||||
state.user.api_key = bindings[24];
|
||||
state.user.updated_at = bindings[26];
|
||||
}
|
||||
if (/DELETE FROM refresh_tokens/i.test(sql)) state.refreshDeletes += 1;
|
||||
if (/INSERT INTO audit_logs/i.test(sql)) state.auditActions.push(bindings[2]);
|
||||
return { meta: { changes: 1 } };
|
||||
},
|
||||
};
|
||||
return statement;
|
||||
},
|
||||
async batch(statements) {
|
||||
return statements.map(() => ({ success: true, meta: { changes: 1 } }));
|
||||
},
|
||||
};
|
||||
return { db, state };
|
||||
}
|
||||
|
||||
function request() {
|
||||
return new Request('https://nodewarden.example/api/accounts/api-key', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterPasswordHash: 'master-proof' }),
|
||||
});
|
||||
}
|
||||
|
||||
function env(db) {
|
||||
return { DB: db, JWT_SECRET: 'test-secret-at-least-thirty-two-characters' };
|
||||
}
|
||||
|
||||
const view = createDb('ExistingReadableApiKey1234567');
|
||||
const viewResponse = await handleGetApiKey(request(), env(view.db), 'user-1');
|
||||
const viewBody = await viewResponse.json();
|
||||
assert(viewResponse.status === 200, 'Viewing an existing readable API key failed');
|
||||
assert(viewBody.apiKey === 'ExistingReadableApiKey1234567', 'View did not return the existing API key');
|
||||
assert(view.state.userWrites === 0, 'View unexpectedly rewrote the user');
|
||||
assert(view.state.refreshDeletes === 0, 'View unexpectedly revoked refresh tokens');
|
||||
assert(view.state.auditActions.includes('account.api_key.view'), 'View audit action is missing');
|
||||
|
||||
const rotate = createDb('ExistingReadableApiKey1234567');
|
||||
const rotateResponse = await handleRotateApiKey(request(), env(rotate.db), 'user-1');
|
||||
const rotateBody = await rotateResponse.json();
|
||||
assert(rotateResponse.status === 200, 'API key rotation failed');
|
||||
assert(rotateBody.apiKey !== 'ExistingReadableApiKey1234567', 'Rotation returned the old API key');
|
||||
assert(rotate.state.user.api_key === rotateBody.apiKey, 'Rotation did not persist the returned API key');
|
||||
assert(rotate.state.user.security_stamp === 'security-stamp-original', 'Rotation changed securityStamp');
|
||||
assert(rotate.state.refreshDeletes === 0, 'Rotation revoked unrelated refresh tokens');
|
||||
assert(!(await verifyApiKey('ExistingReadableApiKey1234567', rotate.state.user.api_key)), 'Old API key still authenticates');
|
||||
assert(await verifyApiKey(rotateBody.apiKey, rotate.state.user.api_key), 'Rotated API key does not authenticate');
|
||||
|
||||
const legacyPlain = 'LegacyHashedApiKey123456789';
|
||||
const legacy = createDb(await hashApiKey(legacyPlain));
|
||||
const legacyResponse = await handleGetApiKey(request(), env(legacy.db), 'user-1');
|
||||
assert(legacyResponse.status === 409, 'Legacy hashed key view should require explicit rotation');
|
||||
assert(legacy.state.userWrites === 0, 'Legacy hashed key was silently rotated');
|
||||
assert(await verifyApiKey(legacyPlain, legacy.state.user.api_key), 'Legacy hashed API key stopped authenticating');
|
||||
|
||||
const missing = createDb(null);
|
||||
const missingResponse = await handleGetApiKey(request(), env(missing.db), 'user-1');
|
||||
const missingBody = await missingResponse.json();
|
||||
assert(missingResponse.status === 200 && !!missingBody.apiKey, 'Missing legacy API key was not initialized');
|
||||
assert(missing.state.userWrites === 1, 'Missing legacy API key initialization was not persisted');
|
||||
|
||||
console.log('Bitwarden-compatible API key view and rotation semantics: PASS');
|
||||
@@ -0,0 +1,138 @@
|
||||
import { unzipSync, zipSync } from 'fflate';
|
||||
import {
|
||||
buildBackupArchive,
|
||||
parseBackupArchive,
|
||||
validateBackupPayloadContents,
|
||||
} from '../src/services/backup-archive.ts';
|
||||
import { importBackupArchiveBytes } from '../src/services/backup-import.ts';
|
||||
|
||||
const forbiddenRuntimeTables = [
|
||||
'devices',
|
||||
'refresh_tokens',
|
||||
'auth_requests',
|
||||
'trusted_two_factor_device_tokens',
|
||||
'account_passkey_challenges',
|
||||
'used_attachment_download_tokens',
|
||||
];
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function sqlTouchesTable(sql, table) {
|
||||
return new RegExp(`\\b(?:from|into|table)\\s+[\"']?${table}\\b`, 'i').test(sql);
|
||||
}
|
||||
|
||||
function emptyBackupDb(extra = {}) {
|
||||
return {
|
||||
config: [],
|
||||
users: [],
|
||||
domain_settings: [],
|
||||
user_revisions: [],
|
||||
folders: [],
|
||||
ciphers: [],
|
||||
attachments: [],
|
||||
webauthn_credentials: [],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function archiveBytes(db, tableCounts = {}) {
|
||||
const encoder = new TextEncoder();
|
||||
return zipSync({
|
||||
'manifest.json': encoder.encode(JSON.stringify({
|
||||
formatVersion: 1,
|
||||
exportedAt: new Date(0).toISOString(),
|
||||
appVersion: 'test',
|
||||
storageKind: null,
|
||||
tableCounts,
|
||||
includes: { attachments: false },
|
||||
blobSummary: { attachmentFiles: 0, totalBytes: 0, largestObjectBytes: 0 },
|
||||
attachmentBlobs: [],
|
||||
})),
|
||||
'db.json': encoder.encode(JSON.stringify(db)),
|
||||
}, { level: 0 });
|
||||
}
|
||||
|
||||
function createD1Mock({ exportMode = false } = {}) {
|
||||
const preparedSql = [];
|
||||
const db = {
|
||||
prepare(sql) {
|
||||
preparedSql.push(sql);
|
||||
let bindings = [];
|
||||
const statement = {
|
||||
sql,
|
||||
bind(...values) {
|
||||
bindings = values;
|
||||
return statement;
|
||||
},
|
||||
async all() {
|
||||
if (exportMode) return { results: [] };
|
||||
return { results: [] };
|
||||
},
|
||||
async first() {
|
||||
if (/SELECT sql FROM sqlite_master/i.test(sql)) {
|
||||
const table = String(bindings[0] || '').trim();
|
||||
return { sql: `CREATE TABLE ${table} (id TEXT)` };
|
||||
}
|
||||
if (/SELECT COUNT\(\*\).*FROM config__restore/i.test(sql)) return { count: 1 };
|
||||
if (/SELECT COUNT\(\*\)/i.test(sql)) return { count: 0 };
|
||||
return null;
|
||||
},
|
||||
async run() {
|
||||
return { meta: { changes: 0 } };
|
||||
},
|
||||
};
|
||||
return statement;
|
||||
},
|
||||
async batch(statements) {
|
||||
return statements.map(() => ({ success: true, meta: { changes: 0 } }));
|
||||
},
|
||||
};
|
||||
return { db, preparedSql };
|
||||
}
|
||||
|
||||
const exportMock = createD1Mock({ exportMode: true });
|
||||
const exported = await buildBackupArchive({ DB: exportMock.db }, new Date(0), { includeAttachments: false });
|
||||
const exportedZip = unzipSync(exported.bytes);
|
||||
const exportedManifest = JSON.parse(new TextDecoder().decode(exportedZip['manifest.json']));
|
||||
const exportedDb = JSON.parse(new TextDecoder().decode(exportedZip['db.json']));
|
||||
|
||||
for (const table of forbiddenRuntimeTables) {
|
||||
assert(!(table in exportedDb), `Export contains forbidden runtime table: ${table}`);
|
||||
assert(!(table in exportedManifest.tableCounts), `Manifest counts forbidden runtime table: ${table}`);
|
||||
assert(!exportMock.preparedSql.some((sql) => sqlTouchesTable(sql, table)), `Export queried forbidden runtime table: ${table}`);
|
||||
}
|
||||
|
||||
const legacyDb = emptyBackupDb({
|
||||
devices: [{ device_identifier: 'device-secret' }],
|
||||
refresh_tokens: [{ token: 'refresh-secret' }],
|
||||
auth_requests: [{ access_code: 'approval-secret' }],
|
||||
trusted_two_factor_device_tokens: [{ token: 'remember-secret' }],
|
||||
account_passkey_challenges: [{ challenge_hash: 'challenge-secret' }],
|
||||
used_attachment_download_tokens: [{ token_hash: 'download-secret' }],
|
||||
});
|
||||
const legacyArchive = archiveBytes(legacyDb, {
|
||||
devices: 1,
|
||||
refresh_tokens: 1,
|
||||
auth_requests: 1,
|
||||
trusted_two_factor_device_tokens: 1,
|
||||
account_passkey_challenges: 1,
|
||||
used_attachment_download_tokens: 1,
|
||||
});
|
||||
const parsedLegacy = parseBackupArchive(legacyArchive);
|
||||
validateBackupPayloadContents(parsedLegacy.payload, parsedLegacy.files);
|
||||
for (const table of forbiddenRuntimeTables) {
|
||||
assert(!(table in parsedLegacy.payload.db), `Legacy runtime table was not ignored: ${table}`);
|
||||
}
|
||||
|
||||
const restoreMock = createD1Mock();
|
||||
await importBackupArchiveBytes(legacyArchive, { DB: restoreMock.db }, 'actor', false);
|
||||
for (const table of forbiddenRuntimeTables) {
|
||||
assert(
|
||||
!restoreMock.preparedSql.some((sql) => sqlTouchesTable(sql, table)),
|
||||
`Restore touched forbidden runtime table: ${table}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log('backup runtime authentication state exclusion: PASS');
|
||||
@@ -0,0 +1,38 @@
|
||||
import { normalizeBackupEndpointUrl } from '../src/services/backup-config.ts';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const scratch = process.env.SCRATCH || '.';
|
||||
const cases = [
|
||||
'http://127.0.0.1',
|
||||
'http://169.254.169.254',
|
||||
'http://[::1]',
|
||||
'http://[0:0:0:0:0:0:0:1]',
|
||||
'http://[::2]',
|
||||
'http://[::]',
|
||||
'http://[fe80::1]',
|
||||
'http://[fc00::1]',
|
||||
'https://example.com',
|
||||
];
|
||||
|
||||
const out = [];
|
||||
for (const url of cases) {
|
||||
try {
|
||||
const normalized = normalizeBackupEndpointUrl(url, 'WebDAV server URL');
|
||||
out.push({ url, allowed: true, normalized });
|
||||
} catch (e) {
|
||||
out.push({ url, allowed: false, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
const path = `${scratch}/poc-normalizeBackupEndpointUrl.json`;
|
||||
fs.writeFileSync(path, JSON.stringify(out, null, 2));
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
|
||||
// Security expectation: IPv6 loopback must NOT be allowed.
|
||||
const loopback = out.find((row) => row.url === 'http://[::1]');
|
||||
if (loopback?.allowed) {
|
||||
console.error('FINDING_CONFIRMED: normalizeBackupEndpointUrl accepts http://[::1]');
|
||||
process.exitCode = 2;
|
||||
} else {
|
||||
console.log('IPv6 loopback rejected as expected');
|
||||
}
|
||||
@@ -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,84 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { registerAccount } from '../webapp/src/lib/api/auth';
|
||||
import {
|
||||
requireWebCrypto,
|
||||
WebCryptoUnavailableError,
|
||||
} from '../webapp/src/lib/crypto';
|
||||
|
||||
const supportedCrypto = {
|
||||
subtle: {
|
||||
importKey: () => Promise.reject(new Error('not used by capability checks')),
|
||||
},
|
||||
getRandomValues: <T>(array: T): T => array,
|
||||
} as unknown as Crypto;
|
||||
|
||||
function restoreGlobalProperty(name: string, descriptor: PropertyDescriptor | undefined): void {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(globalThis, name, descriptor);
|
||||
return;
|
||||
}
|
||||
delete (globalThis as unknown as Record<string, unknown>)[name];
|
||||
}
|
||||
|
||||
test('Web Crypto guard rejects insecure browser contexts', () => {
|
||||
assert.throws(
|
||||
() => requireWebCrypto({ crypto: supportedCrypto, isSecureContext: false }),
|
||||
WebCryptoUnavailableError
|
||||
);
|
||||
});
|
||||
|
||||
test('Web Crypto guard rejects secure contexts without SubtleCrypto', () => {
|
||||
const cryptoWithoutSubtle = {
|
||||
getRandomValues: <T>(array: T): T => array,
|
||||
} as unknown as Crypto;
|
||||
|
||||
assert.throws(
|
||||
() => requireWebCrypto({ crypto: cryptoWithoutSubtle, isSecureContext: true }),
|
||||
WebCryptoUnavailableError
|
||||
);
|
||||
});
|
||||
|
||||
test('Web Crypto guard accepts a secure supported browser', () => {
|
||||
assert.equal(
|
||||
requireWebCrypto({ crypto: supportedCrypto, isSecureContext: true }),
|
||||
supportedCrypto
|
||||
);
|
||||
});
|
||||
|
||||
test('registration returns an actionable error without contacting the backend', async () => {
|
||||
const cryptoDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto');
|
||||
const secureContextDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'isSecureContext');
|
||||
const fetchDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'fetch');
|
||||
let fetchCalled = false;
|
||||
|
||||
Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true });
|
||||
Object.defineProperty(globalThis, 'isSecureContext', { value: false, configurable: true });
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
configurable: true,
|
||||
value: async () => {
|
||||
fetchCalled = true;
|
||||
return new Response(null, { status: 500 });
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await registerAccount({
|
||||
email: 'first@example.test',
|
||||
name: 'First Admin',
|
||||
password: 'correct horse battery staple',
|
||||
fallbackIterations: 600_000,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: false,
|
||||
message: 'Secure browser cryptography is unavailable. Open NodeWarden over HTTPS in a supported browser.',
|
||||
});
|
||||
assert.equal(fetchCalled, false);
|
||||
} finally {
|
||||
restoreGlobalProperty('crypto', cryptoDescriptor);
|
||||
restoreGlobalProperty('isSecureContext', secureContextDescriptor);
|
||||
restoreGlobalProperty('fetch', fetchDescriptor);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { Env } from '../src/types';
|
||||
import { getConfiguredWebAuthnAllowedOrigins } from '../src/utils/origins';
|
||||
import { applyCors, handleCors } from '../src/utils/response';
|
||||
|
||||
const env = {} as Env;
|
||||
|
||||
test('only the iframe connector drops anti-framing headers', () => {
|
||||
const connectorRequest = new Request('https://vault.example.test/webauthn-connector.html');
|
||||
const connector = applyCors(connectorRequest, new Response('<!doctype html>'), env);
|
||||
assert.equal(connector.headers.get('X-Frame-Options'), null);
|
||||
assert.doesNotMatch(connector.headers.get('Content-Security-Policy') || '', /frame-ancestors/);
|
||||
assert.match(connector.headers.get('Content-Security-Policy') || '', /script-src 'self'/);
|
||||
|
||||
for (const path of ['/', '/webauthn-fallback-connector.html', '/webauthn-mobile-connector.html']) {
|
||||
const request = new Request(`https://vault.example.test${path}`);
|
||||
const response = applyCors(request, new Response('<!doctype html>'), env);
|
||||
assert.equal(response.headers.get('X-Frame-Options'), 'DENY');
|
||||
assert.match(response.headers.get('Content-Security-Policy') || '', /frame-ancestors 'none'/);
|
||||
}
|
||||
});
|
||||
|
||||
test('official Bitwarden desktop origin receives credentialed CORS', () => {
|
||||
assert.ok(getConfiguredWebAuthnAllowedOrigins(env).includes('bw-desktop-file://bundle'));
|
||||
const preflight = handleCors(new Request('https://vault.example.test/api/sync', {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
Origin: 'bw-desktop-file://bundle',
|
||||
'Access-Control-Request-Headers': 'authorization, content-type',
|
||||
},
|
||||
}), env);
|
||||
assert.equal(preflight.headers.get('Access-Control-Allow-Origin'), 'bw-desktop-file://bundle');
|
||||
assert.equal(preflight.headers.get('Access-Control-Allow-Credentials'), 'true');
|
||||
});
|
||||
|
||||
test('Worker assets preserve exact official connector .html paths', async () => {
|
||||
for (const configUrl of [
|
||||
new URL('../wrangler.toml', import.meta.url),
|
||||
new URL('../wrangler.kv.toml', import.meta.url),
|
||||
]) {
|
||||
const config = await readFile(configUrl, 'utf8');
|
||||
const assetsSection = config.match(/\[assets\]([\s\S]*?)(?=\n\[|$)/)?.[1] || '';
|
||||
assert.match(assetsSection, /^\s*html_handling\s*=\s*"none"\s*$/m);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildCredentialData,
|
||||
normalizePublicKeyOptions,
|
||||
parseConnectorRequest,
|
||||
resolveParentChannel,
|
||||
} from '../webapp/public/webauthn-connector.js';
|
||||
|
||||
function encodeBase64Utf8(value) {
|
||||
return Buffer.from(value, 'utf8').toString('base64');
|
||||
}
|
||||
|
||||
const publicKeyOptions = {
|
||||
challenge: 'AQID',
|
||||
allowCredentials: [{ id: 'BAUG', type: 'public-key', transports: ['usb'] }],
|
||||
timeout: 60000,
|
||||
rpId: 'vault.example.test',
|
||||
};
|
||||
|
||||
test('parses the official desktop/browser V1 connector request', () => {
|
||||
const params = new URLSearchParams({
|
||||
data: encodeBase64Utf8(JSON.stringify(publicKeyOptions)),
|
||||
parent: encodeURIComponent('file:///C:/Program Files/Bitwarden/resources/app/index.html'),
|
||||
btnText: encodeURIComponent('Read security key'),
|
||||
btnAwaitingInteractionText: encodeURIComponent('Awaiting security key interaction...'),
|
||||
v: '1',
|
||||
});
|
||||
const request = parseConnectorRequest(params);
|
||||
assert.equal(request.parentUrl, 'file:///C:/Program Files/Bitwarden/resources/app/index.html');
|
||||
assert.equal(request.parentProtocol, 'file:');
|
||||
assert.deepEqual(JSON.parse(request.webauthnJson), publicKeyOptions);
|
||||
assert.equal(request.buttonText, 'Read security key');
|
||||
assert.equal(request.awaitingText, 'Awaiting security key interaction...');
|
||||
});
|
||||
|
||||
test('keeps V2 parsing compatible with the shared official connector protocol', () => {
|
||||
const params = new URLSearchParams({
|
||||
data: encodeBase64Utf8(JSON.stringify({ data: JSON.stringify(publicKeyOptions) })),
|
||||
parent: encodeURIComponent('chrome-extension://nngceckbapebfimnlniiiahkandclblb/popup/index.html'),
|
||||
v: '2',
|
||||
});
|
||||
assert.deepEqual(JSON.parse(parseConnectorRequest(params).webauthnJson), publicKeyOptions);
|
||||
});
|
||||
|
||||
test('normalizes WebAuthn challenge and allowed credential IDs', () => {
|
||||
const normalized = normalizePublicKeyOptions(JSON.stringify(publicKeyOptions));
|
||||
assert.deepEqual(Array.from(normalized.challenge), [1, 2, 3]);
|
||||
assert.deepEqual(Array.from(normalized.allowCredentials[0].id), [4, 5, 6]);
|
||||
});
|
||||
|
||||
test('emits the exact assertion shape consumed by official Bitwarden clients', () => {
|
||||
const output = JSON.parse(buildCredentialData({
|
||||
id: 'credential-id',
|
||||
rawId: Uint8Array.from([1, 2, 3]).buffer,
|
||||
type: 'public-key',
|
||||
getClientExtensionResults: () => ({ appid: false }),
|
||||
response: {
|
||||
authenticatorData: Uint8Array.from([4, 5]).buffer,
|
||||
clientDataJSON: Uint8Array.from([6, 7]).buffer,
|
||||
signature: Uint8Array.from([8, 9]).buffer,
|
||||
},
|
||||
}));
|
||||
assert.deepEqual(output, {
|
||||
id: 'credential-id',
|
||||
rawId: 'AQID',
|
||||
type: 'public-key',
|
||||
extensions: { appid: false },
|
||||
response: {
|
||||
authenticatorData: 'BAU',
|
||||
clientDataJson: 'Bgc',
|
||||
signature: 'CAk',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('accepts legacy file and current official desktop parent origins', () => {
|
||||
assert.deepEqual(resolveParentChannel({
|
||||
parentProtocol: 'file:',
|
||||
parentUrl: 'file:///C:/Bitwarden/index.html',
|
||||
}, 'https://vault.example.test'), {
|
||||
eventOrigin: 'null',
|
||||
targetOrigin: 'file:///C:/Bitwarden/index.html',
|
||||
});
|
||||
assert.deepEqual(resolveParentChannel({
|
||||
parentProtocol: 'bw-desktop-file:',
|
||||
parentUrl: 'bw-desktop-file://bundle/index.html',
|
||||
}, 'https://vault.example.test'), {
|
||||
eventOrigin: 'bw-desktop-file://bundle',
|
||||
targetOrigin: 'bw-desktop-file://bundle/index.html',
|
||||
});
|
||||
});
|
||||
|
||||
test('accepts configured official extension origins and rejects arbitrary parents', () => {
|
||||
const extension = 'chrome-extension://nngceckbapebfimnlniiiahkandclblb';
|
||||
assert.deepEqual(resolveParentChannel({
|
||||
parentProtocol: 'chrome-extension:',
|
||||
parentUrl: `${extension}/popup/index.html`,
|
||||
}, 'https://vault.example.test', [extension]), {
|
||||
eventOrigin: extension,
|
||||
targetOrigin: extension,
|
||||
});
|
||||
assert.throws(() => resolveParentChannel({
|
||||
parentProtocol: 'https:',
|
||||
parentUrl: 'https://attacker.example/frame',
|
||||
}, 'https://vault.example.test', []), /Untrusted parent/);
|
||||
});
|
||||
|
||||
test('uses the official postMessage message contract and iframe-sized fallback styling', async () => {
|
||||
const [html, source, viteConfig] = await Promise.all([
|
||||
readFile(new URL('../webapp/public/webauthn-connector.html', import.meta.url), 'utf8'),
|
||||
readFile(new URL('../webapp/public/webauthn-connector.js', import.meta.url), 'utf8'),
|
||||
readFile(new URL('../webapp/vite.config.ts', import.meta.url), 'utf8'),
|
||||
]);
|
||||
assert.match(html, /id="webauthn-button"/);
|
||||
assert.match(html, /min-height:\s*40px/);
|
||||
assert.match(html, /background:\s*#2563eb/);
|
||||
assert.match(source, /post\('info\|ready'\)/);
|
||||
assert.match(source, /post\(`success\|\$\{buildCredentialData\(credential\)\}`\)/);
|
||||
assert.match(source, /post\(`error\|\$\{browserErrorMessage\(error\)\}`\)/);
|
||||
assert.match(source, /event\.data === 'stop'/);
|
||||
assert.match(source, /event\.data === 'start'/);
|
||||
assert.match(viteConfig, /endsWith\('-connector\.html'\)/);
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
base64UrlFromBuffer,
|
||||
buildCallbackUrl,
|
||||
buildCredentialData,
|
||||
decodeBase64Utf8,
|
||||
normalizePublicKeyOptions,
|
||||
parseConnectorRequest,
|
||||
resolveMobileCallbackUri,
|
||||
} from '../webapp/public/webauthn-mobile-connector.js';
|
||||
|
||||
function encodeBase64Utf8(value) {
|
||||
return Buffer.from(value, 'utf8').toString('base64');
|
||||
}
|
||||
|
||||
function v2Search(payload, extra = '') {
|
||||
return `?data=${encodeURIComponent(encodeBase64Utf8(JSON.stringify(payload)))}&parent=bitwarden%3A__webauthn-callback&v=2${extra}`;
|
||||
}
|
||||
|
||||
const assertionOptions = {
|
||||
challenge: 'AQID-v8',
|
||||
rpId: 'vault.example.com',
|
||||
timeout: 60000,
|
||||
userVerification: 'preferred',
|
||||
allowCredentials: [{ id: 'BAUGBwg', type: 'public-key', transports: ['internal'] }],
|
||||
};
|
||||
|
||||
test('parses the current Bitwarden Android V2 connector payload', () => {
|
||||
const request = parseConnectorRequest(v2Search({
|
||||
btnReturnText: 'Return to app', btnText: 'Authenticate', data: JSON.stringify(assertionOptions),
|
||||
headerText: 'Verify your identity', mobile: true,
|
||||
}, '&client=mobile&deeplinkScheme=bitwarden'), 'vault.example.com');
|
||||
assert.equal(request.callbackUri, 'bitwarden://webauthn-callback');
|
||||
assert.equal(request.headerText, 'Verify your identity');
|
||||
assert.equal(request.buttonText, 'Authenticate');
|
||||
assert.equal(request.returnButtonText, 'Return to app');
|
||||
assert.deepEqual(JSON.parse(request.webauthnJson), assertionOptions);
|
||||
});
|
||||
|
||||
test('uses callbackUri only as a signal and never as the redirect target', () => {
|
||||
const trustedLooking = parseConnectorRequest(v2Search({
|
||||
callbackUri: 'https://bitwarden.eu/webauthn-callback', data: assertionOptions,
|
||||
}).replace('&parent=bitwarden%3A__webauthn-callback', ''));
|
||||
const attacker = parseConnectorRequest(v2Search({
|
||||
callbackUri: 'https://attacker.example/capture', data: assertionOptions,
|
||||
}).replace('&parent=bitwarden%3A__webauthn-callback', ''));
|
||||
assert.equal(trustedLooking.callbackUri, 'bitwarden://webauthn-callback');
|
||||
assert.equal(attacker.callbackUri, 'bitwarden://webauthn-callback');
|
||||
});
|
||||
|
||||
test('treats any non-HTTPS deeplinkScheme as the fixed Bitwarden custom scheme', () => {
|
||||
const request = parseConnectorRequest(v2Search({ mobile: true, data: assertionOptions }, '&deeplinkScheme=untrusted'));
|
||||
assert.equal(request.callbackUri, 'bitwarden://webauthn-callback');
|
||||
});
|
||||
|
||||
test('supports Android custom-scheme and official HTTPS App Link callbacks', () => {
|
||||
const payload = { mobile: true, data: assertionOptions };
|
||||
const custom = parseConnectorRequest(v2Search(payload, '&client=mobile&deeplinkScheme=bitwarden'));
|
||||
const eu = parseConnectorRequest(v2Search(payload, '&client=mobile&deeplinkScheme=https'), 'vault.bitwarden.eu');
|
||||
const selfHosted = parseConnectorRequest(v2Search(payload, '&client=mobile&deeplinkScheme=https'), 'vault.example.com');
|
||||
assert.equal(custom.callbackUri, 'bitwarden://webauthn-callback');
|
||||
assert.equal(eu.callbackUri, 'https://bitwarden.eu/webauthn-callback');
|
||||
assert.equal(selfHosted.callbackUri, 'https://bitwarden.com/webauthn-callback');
|
||||
});
|
||||
|
||||
test('supports V1 mobile requests and requires a recognized mobile signal', () => {
|
||||
const encoded = encodeURIComponent(encodeBase64Utf8(JSON.stringify(assertionOptions)));
|
||||
assert.equal(parseConnectorRequest(`?data=${encoded}&v=1&client=mobile`).callbackUri, 'bitwarden://webauthn-callback');
|
||||
assert.equal(resolveMobileCallbackUri({ payload: {}, hostname: 'vault.example.com' }), null);
|
||||
assert.throws(() => parseConnectorRequest(`?data=${encoded}&v=1`), /return target/i);
|
||||
});
|
||||
|
||||
test('decodes UTF-8 and normalizes WebAuthn binary fields without mutation', () => {
|
||||
assert.equal(decodeBase64Utf8(encodeBase64Utf8('验证身份')), '验证身份');
|
||||
const original = structuredClone(assertionOptions);
|
||||
const normalized = normalizePublicKeyOptions(original);
|
||||
assert.deepEqual(Array.from(normalized.challenge), [1, 2, 3, 250, 255]);
|
||||
assert.deepEqual(Array.from(normalized.allowCredentials[0].id), [4, 5, 6, 7, 8]);
|
||||
assert.deepEqual(original, assertionOptions);
|
||||
});
|
||||
|
||||
test('serializes the exact assertion shape emitted by Bitwarden common-webauthn', () => {
|
||||
const serialized = JSON.parse(buildCredentialData({
|
||||
id: 'credential-id', rawId: Uint8Array.from([1, 2, 255]).buffer, type: 'public-key',
|
||||
getClientExtensionResults: () => ({ appid: false }),
|
||||
response: {
|
||||
authenticatorData: Uint8Array.from([3, 4]).buffer,
|
||||
clientDataJSON: Uint8Array.from([5, 6]).buffer,
|
||||
signature: Uint8Array.from([7, 8]).buffer,
|
||||
userHandle: Uint8Array.from([9, 10]).buffer,
|
||||
},
|
||||
}));
|
||||
assert.deepEqual(serialized, {
|
||||
id: 'credential-id',
|
||||
rawId: 'AQL_',
|
||||
type: 'public-key',
|
||||
extensions: { appid: false },
|
||||
response: { authenticatorData: 'AwQ', clientDataJson: 'BQY', signature: 'Bwg' },
|
||||
});
|
||||
assert.equal(base64UrlFromBuffer(Uint8Array.from([251, 255])), '-_8');
|
||||
});
|
||||
|
||||
test('encodes success and error callbacks safely', () => {
|
||||
assert.equal(buildCallbackUrl('bitwarden://webauthn-callback', 'data', '{"id":"a+b"}'), 'bitwarden://webauthn-callback?data=%7B%22id%22%3A%22a%2Bb%22%7D');
|
||||
assert.equal(buildCallbackUrl('bitwarden://webauthn-callback?source=nodewarden', 'error', 'Not allowed'), 'bitwarden://webauthn-callback?source=nodewarden&error=Not%20allowed');
|
||||
});
|
||||
|
||||
test('HTML matches the fallback connector visual structure', async () => {
|
||||
const html = await readFile(new URL('../webapp/public/webauthn-mobile-connector.html', import.meta.url), 'utf8');
|
||||
assert.match(html, /id="webauthn-header"/);
|
||||
assert.match(html, /id="webauthn-button"/);
|
||||
assert.match(html, /class="connector-card"/);
|
||||
assert.match(html, /class="brand"/);
|
||||
assert.match(html, /class="form"/);
|
||||
assert.match(html, /class="msg"/);
|
||||
assert.match(html, /src="\/nodewarden-logo\.svg"/);
|
||||
assert.match(html, /src="\/webauthn-mobile-connector\.js"/);
|
||||
assert.match(html, /default-src 'none'/);
|
||||
});
|
||||
|
||||
test('runtime uses Bitwarden-compatible replacement navigation', async () => {
|
||||
const source = await readFile(new URL('../webapp/public/webauthn-mobile-connector.js', import.meta.url), 'utf8');
|
||||
assert.match(source, /window\.location\.replace\(uri\)/);
|
||||
assert.doesNotMatch(source, /location\.assign/);
|
||||
assert.doesNotMatch(source, /safeCallbackFromPayload/);
|
||||
});
|
||||
|
||||
test('Service Worker keeps connector navigations out of the SPA shell', async () => {
|
||||
const config = await readFile(new URL('../webapp/vite.config.ts', import.meta.url), 'utf8');
|
||||
assert.match(config, /url\.pathname\.endsWith\('-connector\.html'\)/);
|
||||
assert.match(config, /connectorNavigation\(request\)/);
|
||||
assert.match(config, /WebAuthn connector is unavailable while offline/);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export const APP_VERSION = '1.8.0';
|
||||
@@ -0,0 +1,163 @@
|
||||
// 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_S3_ROOT_PATH = '';
|
||||
export const BACKUP_DEFAULT_WEBDAV_REMOTE_PATH = 'nodewarden';
|
||||
export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
|
||||
export const BACKUP_DEFAULT_START_TIME = '03:00';
|
||||
|
||||
export type BackupDestinationType = 's3' | 'webdav';
|
||||
export type S3BackupAddressingStyle = 'path-style' | 'virtual-hosted-style';
|
||||
|
||||
export interface S3BackupDestination {
|
||||
endpoint: string;
|
||||
bucket: string;
|
||||
addressingStyle: S3BackupAddressingStyle;
|
||||
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: '',
|
||||
addressingStyle: 'path-style',
|
||||
region: BACKUP_DEFAULT_S3_REGION,
|
||||
accessKeyId: '',
|
||||
secretAccessKey: '',
|
||||
rootPath: BACKUP_DEFAULT_S3_ROOT_PATH,
|
||||
};
|
||||
}
|
||||
return {
|
||||
baseUrl: '',
|
||||
username: '',
|
||||
password: '',
|
||||
remotePath: BACKUP_DEFAULT_WEBDAV_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,52 @@
|
||||
import { LIMITS } from './config/limits';
|
||||
|
||||
function buildIconServiceTemplate(origin: string): string {
|
||||
return `${origin}/icons/{}/icon.png`;
|
||||
}
|
||||
|
||||
function buildIconServiceCsp(origin: string): string {
|
||||
return `img-src 'self' data: ${origin}`;
|
||||
}
|
||||
|
||||
export function buildConfigResponse(origin: string) {
|
||||
const fillAssistBase = `${origin}/fill-assist/`;
|
||||
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: fillAssistBase,
|
||||
},
|
||||
push: {
|
||||
pushTechnology: 0,
|
||||
vapidPublicKey: null,
|
||||
},
|
||||
communication: null,
|
||||
settings: {
|
||||
disableUserRegistration: false,
|
||||
},
|
||||
_icon_service_url: buildIconServiceTemplate(origin),
|
||||
_icon_service_csp: buildIconServiceCsp(origin),
|
||||
featureStates: {
|
||||
'cipher-key-encryption': LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled,
|
||||
'desktop-ui-settings-dialog': true,
|
||||
'duo-redirect': true,
|
||||
'email-verification': true,
|
||||
'fill-assist-targeting-rules': true,
|
||||
'pm-19051-send-email-verification': false,
|
||||
'pm-19148-innovation-archive': true,
|
||||
'pm-4516-devices-add-last-activity-date': true,
|
||||
'pm-30529-webauthn-related-origins': true,
|
||||
'unauth-ui-refresh': true,
|
||||
'web-push': false,
|
||||
},
|
||||
object: 'config',
|
||||
};
|
||||
}
|
||||
+86
-17
@@ -3,35 +3,73 @@
|
||||
// Access token lifetime in seconds.
|
||||
// 访问令牌有效期(秒)。
|
||||
accessTokenTtlSeconds: 7200,
|
||||
// Refresh token lifetime in milliseconds.
|
||||
// 刷新令牌有效期(毫秒)。
|
||||
refreshTokenTtlMs: 30 * 24 * 60 * 60 * 1000,
|
||||
// Refresh sessions use a reusable opaque token with a sliding idle lifetime.
|
||||
// 刷新会话使用可复用的随机令牌,并按客户端采用滑动空闲期限。
|
||||
refreshTokenWebSlidingTtlMs: 30 * 24 * 60 * 60 * 1000,
|
||||
refreshTokenDefaultSlidingTtlMs: 30 * 24 * 60 * 60 * 1000,
|
||||
refreshTokenMobileSlidingTtlMs: 90 * 24 * 60 * 60 * 1000,
|
||||
// Hard upper bound for one login session, regardless of sliding refreshes.
|
||||
// 单次登录会话的绝对最长寿命,不因滑动续期突破该上限。
|
||||
refreshTokenAbsoluteTtlMs: 365 * 24 * 60 * 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: 5,
|
||||
loginMaxAttempts: 10,
|
||||
// Login lock duration in minutes.
|
||||
// 登录锁定时长(分钟)。
|
||||
loginLockoutMinutes: 2,
|
||||
// Write API request budget per minute.
|
||||
// 写操作 API 每分钟请求配额。
|
||||
apiWriteRequestsPerMinute: 120,
|
||||
// /api/sync read request budget per minute.
|
||||
// /api/sync 读请求每分钟配额。
|
||||
syncReadRequestsPerMinute: 1000,
|
||||
// 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,
|
||||
// Public website icon proxy budget per IP per minute.
|
||||
// 公开网站图标代理每 IP 每分钟请求配额。
|
||||
publicIconRequestsPerMinute: 500,
|
||||
// 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,
|
||||
// Coarser IP budget; the per-session budget above remains the primary guard.
|
||||
// 更宽松的 IP 总预算;主要保护仍由每个 refresh session 的预算承担。
|
||||
refreshTokenRequestsPerIpMinute: 300,
|
||||
// Passwordless/auth-request creation budget per IP/email/device per minute.
|
||||
// 免密/设备审批请求创建接口每 IP/邮箱/设备每分钟配额。
|
||||
authRequestRequestsPerMinute: 5,
|
||||
// Fixed window size for API rate limiting in seconds.
|
||||
// API 限流固定窗口大小(秒)。
|
||||
apiWindowSeconds: 60,
|
||||
@@ -41,15 +79,9 @@
|
||||
// Minimum interval between login-attempt cleanup runs.
|
||||
// 登录尝试表清理的最小间隔。
|
||||
loginIpCleanupIntervalMs: 10 * 60 * 1000,
|
||||
// Minimum interval between API-window cleanup runs.
|
||||
// API 窗口计数清理的最小间隔。
|
||||
apiWindowCleanupIntervalMs: 5 * 60 * 1000,
|
||||
// Retention window for login IP records.
|
||||
// 登录 IP 记录保留时长。
|
||||
loginIpRetentionMs: 30 * 24 * 60 * 60 * 1000,
|
||||
// Number of historical API windows to keep.
|
||||
// 保留的历史 API 窗口数量。
|
||||
apiWindowRetentionWindows: 120,
|
||||
},
|
||||
cleanup: {
|
||||
// Minimum interval between refresh-token cleanup runs.
|
||||
@@ -67,6 +99,14 @@
|
||||
// 附件上传大小上限(字节)。
|
||||
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 时的默认分页大小。
|
||||
@@ -87,6 +127,12 @@
|
||||
// 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,
|
||||
@@ -95,10 +141,33 @@
|
||||
// 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',
|
||||
bitwardenServerVersion: '2026.4.1',
|
||||
// Official 2026.4.x clients need this flag to receive and use cipher.key.
|
||||
// Hiding existing item keys makes item-key encrypted vault data unreadable.
|
||||
// 官方 2026.4.x 客户端需要该开关来接收并使用 cipher.key。
|
||||
// 隐藏已有逐项密钥会导致逐项密钥加密的密码库数据无法解密。
|
||||
cipherKeyEncryptionFeatureEnabled: true,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function getRefreshTokenSlidingTtlMs(clientType?: string | null): number {
|
||||
const normalized = String(clientType || '').trim().toLowerCase();
|
||||
if (normalized === 'web') return LIMITS.auth.refreshTokenWebSlidingTtlMs;
|
||||
if (normalized === 'mobile') return LIMITS.auth.refreshTokenMobileSlidingTtlMs;
|
||||
return LIMITS.auth.refreshTokenDefaultSlidingTtlMs;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
import type { Env } from '../types';
|
||||
import type { BackupDestinationRecord } from '../services/backup-config';
|
||||
import {
|
||||
BACKUP_SCHEDULER_WINDOW_MINUTES,
|
||||
requireBackupDestination,
|
||||
hasBackupSlotBetween,
|
||||
isBackupDueNow,
|
||||
loadBackupSettings,
|
||||
} from '../services/backup-config';
|
||||
import {
|
||||
createRemoteBackupTransferSession,
|
||||
downloadRemoteBackupFile,
|
||||
ensureRemoteRestoreCandidate,
|
||||
} from '../services/backup-uploader';
|
||||
import { getBlobObject } from '../services/blob-store';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from './notifications-hub';
|
||||
import {
|
||||
executeConfiguredBackup,
|
||||
importAndAuditRemoteBackupFile,
|
||||
} from '../handlers/backup';
|
||||
import { isSafeBackupAttachmentBlobName, verifyBackupArchiveFileNameChecksum } from '../services/backup-archive';
|
||||
import { zipSync } from 'fflate';
|
||||
|
||||
const BACKUP_JOB_STATE_KEY = 'backup.job.state.v1';
|
||||
const BACKUP_JOB_LEASE_MS = 10 * 60 * 1000;
|
||||
const BACKUP_JOB_HEARTBEAT_MS = 30 * 1000;
|
||||
|
||||
interface BackupJobState {
|
||||
token: string;
|
||||
reason: string;
|
||||
acquiredAt: string;
|
||||
touchedAt: string;
|
||||
expiresAtMs: number;
|
||||
}
|
||||
|
||||
interface RemoteAttachmentChunkRequest {
|
||||
destination: BackupDestinationRecord;
|
||||
attachments: Array<{
|
||||
blobName: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface RemoteAttachmentDownloadRequest {
|
||||
destination: BackupDestinationRecord;
|
||||
blobName?: string | null;
|
||||
}
|
||||
|
||||
interface RemoteAttachmentBatchDownloadRequest {
|
||||
destination: BackupDestinationRecord;
|
||||
blobNames?: string[] | null;
|
||||
}
|
||||
|
||||
interface ConfiguredBackupRunRequest {
|
||||
actorUserId?: string | null;
|
||||
auditMetadata?: Record<string, unknown> | null;
|
||||
destinationId?: string | null;
|
||||
targetDeviceIdentifier?: string | null;
|
||||
trigger?: 'manual' | 'scheduled';
|
||||
}
|
||||
|
||||
interface RemoteBackupRestoreRequest {
|
||||
actorUserId?: string | null;
|
||||
allowChecksumMismatch?: boolean;
|
||||
auditMetadata?: Record<string, unknown> | null;
|
||||
destinationId?: string | null;
|
||||
path?: string | null;
|
||||
replaceExisting?: boolean;
|
||||
targetDeviceIdentifier?: string | null;
|
||||
}
|
||||
|
||||
function badRequest(message: string, status: number = 400): Response {
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export class BackupTransferRunner {
|
||||
private lastHeartbeatAt = 0;
|
||||
|
||||
constructor(
|
||||
private readonly state: DurableObjectState,
|
||||
private readonly env: Env
|
||||
) {
|
||||
}
|
||||
|
||||
private async acquireJob(reason: string): Promise<string | null> {
|
||||
const nowMs = Date.now();
|
||||
const current = await this.state.storage.get<BackupJobState>(BACKUP_JOB_STATE_KEY);
|
||||
if (current?.expiresAtMs && current.expiresAtMs > nowMs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = crypto.randomUUID();
|
||||
const nowIso = new Date(nowMs).toISOString();
|
||||
await this.state.storage.put<BackupJobState>(BACKUP_JOB_STATE_KEY, {
|
||||
token,
|
||||
reason,
|
||||
acquiredAt: nowIso,
|
||||
touchedAt: nowIso,
|
||||
expiresAtMs: nowMs + BACKUP_JOB_LEASE_MS,
|
||||
});
|
||||
this.lastHeartbeatAt = 0;
|
||||
return token;
|
||||
}
|
||||
|
||||
private async touchJob(token: string): Promise<void> {
|
||||
const nowMs = Date.now();
|
||||
if (nowMs - this.lastHeartbeatAt < BACKUP_JOB_HEARTBEAT_MS) return;
|
||||
this.lastHeartbeatAt = nowMs;
|
||||
|
||||
const current = await this.state.storage.get<BackupJobState>(BACKUP_JOB_STATE_KEY);
|
||||
if (current?.token !== token) return;
|
||||
|
||||
await this.state.storage.put<BackupJobState>(BACKUP_JOB_STATE_KEY, {
|
||||
...current,
|
||||
touchedAt: new Date(nowMs).toISOString(),
|
||||
expiresAtMs: nowMs + BACKUP_JOB_LEASE_MS,
|
||||
});
|
||||
}
|
||||
|
||||
private async releaseJob(token: string): Promise<void> {
|
||||
const current = await this.state.storage.get<BackupJobState>(BACKUP_JOB_STATE_KEY);
|
||||
if (current?.token === token) {
|
||||
await this.state.storage.delete(BACKUP_JOB_STATE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
private async runConfiguredBackup(request: Request): Promise<Response> {
|
||||
let body: ConfiguredBackupRunRequest;
|
||||
try {
|
||||
body = await request.json<ConfiguredBackupRunRequest>();
|
||||
} catch {
|
||||
return badRequest('Backup run payload is invalid');
|
||||
}
|
||||
|
||||
const trigger = body.trigger === 'scheduled' ? 'scheduled' : 'manual';
|
||||
const actorUserId = String(body.actorUserId || '').trim() || null;
|
||||
if (trigger === 'manual' && !actorUserId) {
|
||||
return badRequest('Manual backup run requires an actor');
|
||||
}
|
||||
|
||||
const token = await this.acquireJob(`${trigger}:${actorUserId || 'system'}`);
|
||||
if (!token) {
|
||||
return badRequest('Another backup run is already in progress', 409);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.touchJob(token);
|
||||
const storage = new StorageService(this.env.DB);
|
||||
const progress = actorUserId
|
||||
? async (event: {
|
||||
operation: 'backup-remote-run';
|
||||
step: string;
|
||||
fileName: string;
|
||||
stageTitle: string;
|
||||
stageDetail: string;
|
||||
done?: boolean;
|
||||
ok?: boolean;
|
||||
error?: string | null;
|
||||
}) => {
|
||||
await notifyUserBackupProgress(
|
||||
this.env,
|
||||
actorUserId,
|
||||
event,
|
||||
String(body.targetDeviceIdentifier || '').trim() || null
|
||||
);
|
||||
}
|
||||
: null;
|
||||
|
||||
const result = await executeConfiguredBackup(
|
||||
this.env,
|
||||
storage,
|
||||
actorUserId,
|
||||
trigger,
|
||||
body.destinationId || null,
|
||||
() => this.touchJob(token),
|
||||
progress,
|
||||
body.auditMetadata || null
|
||||
);
|
||||
const settings = await loadBackupSettings(storage, this.env, 'UTC');
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
object: 'backup-runner-result',
|
||||
result,
|
||||
settings,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : 'Backup run failed', 500);
|
||||
} finally {
|
||||
await this.releaseJob(token);
|
||||
}
|
||||
}
|
||||
|
||||
private async runScheduledBackups(): Promise<Response> {
|
||||
const token = await this.acquireJob('scheduled');
|
||||
if (!token) {
|
||||
return badRequest('Another backup run is already in progress', 409);
|
||||
}
|
||||
|
||||
let completed = 0;
|
||||
const failures: Array<{ destinationId: string; error: string }> = [];
|
||||
try {
|
||||
await this.touchJob(token);
|
||||
const storage = new StorageService(this.env.DB);
|
||||
let scanStartMs = Date.now();
|
||||
|
||||
while (true) {
|
||||
await this.touchJob(token);
|
||||
const settings = await loadBackupSettings(storage, this.env, 'UTC');
|
||||
const now = new Date();
|
||||
const dueDestinations = settings.destinations.filter((destination) =>
|
||||
isBackupDueNow(destination, now, BACKUP_SCHEDULER_WINDOW_MINUTES)
|
||||
|| hasBackupSlotBetween(destination, new Date(scanStartMs), now)
|
||||
);
|
||||
|
||||
if (!dueDestinations.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
scanStartMs = now.getTime();
|
||||
for (const destination of dueDestinations) {
|
||||
await this.touchJob(token);
|
||||
try {
|
||||
await executeConfiguredBackup(
|
||||
this.env,
|
||||
storage,
|
||||
null,
|
||||
'scheduled',
|
||||
destination.id,
|
||||
() => this.touchJob(token)
|
||||
);
|
||||
completed += 1;
|
||||
} catch (error) {
|
||||
failures.push({
|
||||
destinationId: destination.id,
|
||||
error: error instanceof Error ? error.message : 'Scheduled backup failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
completed,
|
||||
failed: failures.length,
|
||||
failures,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : 'Scheduled backup failed', 500);
|
||||
} finally {
|
||||
await this.releaseJob(token);
|
||||
}
|
||||
}
|
||||
|
||||
private async restoreRemoteBackup(request: Request): Promise<Response> {
|
||||
let body: RemoteBackupRestoreRequest;
|
||||
try {
|
||||
body = await request.json<RemoteBackupRestoreRequest>();
|
||||
} catch {
|
||||
return badRequest('Remote restore payload is invalid');
|
||||
}
|
||||
|
||||
const actorUserId = String(body.actorUserId || '').trim() || null;
|
||||
if (!actorUserId) {
|
||||
return badRequest('Remote restore requires an actor');
|
||||
}
|
||||
|
||||
const token = await this.acquireJob(`restore:${actorUserId}`);
|
||||
if (!token) {
|
||||
return badRequest('Another backup or restore run is already in progress', 409);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.touchJob(token);
|
||||
const storage = new StorageService(this.env.DB);
|
||||
const settings = await loadBackupSettings(storage, this.env, 'UTC');
|
||||
const destination = requireBackupDestination(settings, body.destinationId || null);
|
||||
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
||||
const restoreFileNameFromPath = path.split('/').pop() || path;
|
||||
const targetDeviceIdentifier = String(body.targetDeviceIdentifier || '').trim() || null;
|
||||
const replaceExisting = !!body.replaceExisting;
|
||||
|
||||
await notifyUserBackupRestoreProgress(
|
||||
this.env,
|
||||
actorUserId,
|
||||
{
|
||||
operation: 'backup-restore',
|
||||
source: 'remote',
|
||||
step: 'remote_fetch_archive',
|
||||
fileName: restoreFileNameFromPath,
|
||||
stageTitle: 'txt_backup_restore_progress_remote_fetch_title',
|
||||
stageDetail: 'txt_backup_restore_progress_remote_fetch_detail',
|
||||
replaceExisting,
|
||||
},
|
||||
targetDeviceIdentifier
|
||||
);
|
||||
|
||||
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
||||
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
|
||||
if (!checksumOk && !body.allowChecksumMismatch) {
|
||||
return badRequest('Remote backup file checksum does not match its filename');
|
||||
}
|
||||
|
||||
const result = await importAndAuditRemoteBackupFile(
|
||||
this.env,
|
||||
storage,
|
||||
actorUserId,
|
||||
remoteFile,
|
||||
destination,
|
||||
path,
|
||||
replaceExisting,
|
||||
!checksumOk,
|
||||
body.auditMetadata || null,
|
||||
targetDeviceIdentifier,
|
||||
() => this.touchJob(token)
|
||||
);
|
||||
|
||||
return new Response(JSON.stringify(result.result), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : 'Remote backup restore failed', 500);
|
||||
} finally {
|
||||
await this.releaseJob(token);
|
||||
}
|
||||
}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
if (request.method !== 'POST') {
|
||||
return badRequest('Not found', 404);
|
||||
}
|
||||
|
||||
if (url.pathname === '/internal/run-configured-backup') {
|
||||
return this.runConfiguredBackup(request);
|
||||
}
|
||||
|
||||
if (url.pathname === '/internal/run-scheduled-backups') {
|
||||
return this.runScheduledBackups();
|
||||
}
|
||||
|
||||
if (url.pathname === '/internal/restore-remote-backup') {
|
||||
return this.restoreRemoteBackup(request);
|
||||
}
|
||||
|
||||
if (url.pathname === '/internal/download-remote-attachment') {
|
||||
let body: RemoteAttachmentDownloadRequest;
|
||||
try {
|
||||
body = await request.json<RemoteAttachmentDownloadRequest>();
|
||||
} catch {
|
||||
return badRequest('Remote attachment download payload is invalid');
|
||||
}
|
||||
const blobName = String(body?.blobName || '').trim();
|
||||
if (!body?.destination || !isSafeBackupAttachmentBlobName(blobName)) {
|
||||
return badRequest('Remote attachment download payload is invalid');
|
||||
}
|
||||
const file = await downloadRemoteBackupFile(body.destination, `attachments/${blobName}`).catch(() => null);
|
||||
if (!file) {
|
||||
return badRequest('Remote attachment not found', 404);
|
||||
}
|
||||
return new Response(file.bytes, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': file.contentType || 'application/octet-stream',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === '/internal/download-remote-attachment-batch') {
|
||||
let body: RemoteAttachmentBatchDownloadRequest;
|
||||
try {
|
||||
body = await request.json<RemoteAttachmentBatchDownloadRequest>();
|
||||
} catch {
|
||||
return badRequest('Remote attachment batch download payload is invalid');
|
||||
}
|
||||
const blobNames = Array.from(new Set(
|
||||
(Array.isArray(body?.blobNames) ? body.blobNames : [])
|
||||
.map((blobName) => String(blobName || '').trim())
|
||||
.filter(isSafeBackupAttachmentBlobName)
|
||||
));
|
||||
if (!body?.destination || !blobNames.length || blobNames.length > 40) {
|
||||
return badRequest('Remote attachment batch download payload is invalid');
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const entries: Array<{ blobName: string; path: string }> = [];
|
||||
const files: Record<string, Uint8Array> = {};
|
||||
for (let i = 0; i < blobNames.length; i += 1) {
|
||||
const blobName = blobNames[i];
|
||||
const file = await downloadRemoteBackupFile(body.destination, `attachments/${blobName}`).catch(() => null);
|
||||
if (!file) continue;
|
||||
const path = `files/${i}.bin`;
|
||||
entries.push({ blobName, path });
|
||||
files[path] = file.bytes;
|
||||
}
|
||||
files['manifest.json'] = encoder.encode(JSON.stringify({ version: 1, entries }));
|
||||
|
||||
return new Response(zipSync(files), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname !== '/internal/upload-attachment-chunk') {
|
||||
return badRequest('Not found', 404);
|
||||
}
|
||||
|
||||
let body: RemoteAttachmentChunkRequest;
|
||||
try {
|
||||
body = await request.json<RemoteAttachmentChunkRequest>();
|
||||
} catch {
|
||||
return badRequest('Attachment chunk payload is invalid');
|
||||
}
|
||||
|
||||
if (!body?.destination || !Array.isArray(body.attachments)) {
|
||||
return badRequest('Attachment chunk payload is invalid');
|
||||
}
|
||||
|
||||
const remoteSession = createRemoteBackupTransferSession(body.destination);
|
||||
let uploaded = 0;
|
||||
|
||||
for (const attachment of body.attachments) {
|
||||
const blobName = String(attachment?.blobName || '').trim();
|
||||
if (!isSafeBackupAttachmentBlobName(blobName)) {
|
||||
return badRequest('Attachment chunk payload is invalid');
|
||||
}
|
||||
|
||||
const object = await getBlobObject(this.env, blobName);
|
||||
if (!object) {
|
||||
return badRequest(`Attachment blob missing for ${blobName}`, 409);
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(await new Response(object.body).arrayBuffer());
|
||||
await remoteSession.putFile(`attachments/${blobName}`, bytes, {
|
||||
contentType: object.contentType,
|
||||
});
|
||||
uploaded += 1;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
uploaded,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,850 @@
|
||||
import { DurableObject, waitUntil } from 'cloudflare:workers';
|
||||
import type { Env } from '../types';
|
||||
import { notifyMobilePush } from '../services/push-relay';
|
||||
|
||||
const SIGNALR_RECORD_SEPARATOR = 0x1e;
|
||||
const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARATOR]);
|
||||
const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_UPDATE = 0;
|
||||
const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_CREATE = 1;
|
||||
const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_DELETE = 3;
|
||||
const SIGNALR_UPDATE_TYPE_SYNC_CIPHERS = 4;
|
||||
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
||||
const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_CREATE = 7;
|
||||
const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_UPDATE = 8;
|
||||
const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_DELETE = 9;
|
||||
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
||||
const SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE = 12;
|
||||
const SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE = 13;
|
||||
const SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE = 14;
|
||||
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST = 15;
|
||||
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16;
|
||||
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102;
|
||||
|
||||
type HubProtocol = 'json' | 'messagepack';
|
||||
type HubKind = 'user' | 'anonymous-auth-request';
|
||||
|
||||
interface WsAttachment {
|
||||
kind: HubKind;
|
||||
userId: string | null;
|
||||
authRequestId: string | null;
|
||||
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,
|
||||
target: string = 'ReceiveMessage'
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
type: 1,
|
||||
target,
|
||||
arguments: [
|
||||
{
|
||||
ContextId: contextId,
|
||||
Type: updateType,
|
||||
Payload: payload,
|
||||
},
|
||||
],
|
||||
}) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
|
||||
}
|
||||
|
||||
function buildSignalRMessagePackInvocation(
|
||||
updateType: number,
|
||||
messagePayload: Record<string, unknown>,
|
||||
contextId: string | null,
|
||||
target: string = 'ReceiveMessage'
|
||||
): Uint8Array {
|
||||
// SignalR MessagePack hub protocol uses an array-based invocation shape:
|
||||
// [type, headers, invocationId, target, arguments, streamIds]
|
||||
const encodedPayload = encodeMsgPack([
|
||||
1,
|
||||
{},
|
||||
null,
|
||||
target,
|
||||
[
|
||||
{
|
||||
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 rawUpdateType = body?.updateType;
|
||||
const parsedUpdateType = typeof rawUpdateType === 'number' ? rawUpdateType : Number(rawUpdateType);
|
||||
const updateType = Number.isFinite(parsedUpdateType) ? parsedUpdateType : 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/auth-request-response' && request.method === 'POST') {
|
||||
const body = (await request.json().catch(() => null)) as {
|
||||
userId?: string;
|
||||
authRequestId?: string;
|
||||
contextId?: string | null;
|
||||
} | null;
|
||||
const userId = String(body?.userId || '').trim();
|
||||
const authRequestId = String(body?.authRequestId || '').trim();
|
||||
if (!userId || !authRequestId) return new Response('Invalid auth request notification', { status: 400 });
|
||||
|
||||
this.broadcastAuthRequestResponse(userId, authRequestId, String(body?.contextId || '').trim() || null);
|
||||
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' && url.pathname !== '/notifications/anonymous-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;
|
||||
const requestAuthRequestId = String(url.searchParams.get('nw_auth_request_id') || '').trim() || null;
|
||||
const isAnonymousAuthRequestHub = url.pathname === '/notifications/anonymous-hub';
|
||||
|
||||
if (!isAnonymousAuthRequestHub && !requestUserId) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
if (isAnonymousAuthRequestHub && !requestAuthRequestId) {
|
||||
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({
|
||||
kind: isAnonymousAuthRequestHub ? 'anonymous-auth-request' : 'user',
|
||||
userId: isAnonymousAuthRequestHub ? null : requestUserId,
|
||||
authRequestId: requestAuthRequestId,
|
||||
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);
|
||||
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> {
|
||||
void ws;
|
||||
void code;
|
||||
void reason;
|
||||
void wasClean;
|
||||
}
|
||||
|
||||
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
|
||||
void ws;
|
||||
void error;
|
||||
}
|
||||
|
||||
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.kind !== 'user' || !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 broadcastAuthRequestResponse(userId: string, authRequestId: string, contextId: string | null): void {
|
||||
for (const ws of this.ctx.getWebSockets()) {
|
||||
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||
if (
|
||||
!attachment?.handshakeComplete ||
|
||||
attachment.kind !== 'anonymous-auth-request' ||
|
||||
attachment.authRequestId !== authRequestId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
UserId: userId,
|
||||
Id: authRequestId,
|
||||
};
|
||||
try {
|
||||
if (attachment.protocol === 'json') {
|
||||
ws.send(buildSignalRJsonInvocation(
|
||||
SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE,
|
||||
payload,
|
||||
contextId,
|
||||
'AuthRequestResponseRecieved'
|
||||
));
|
||||
} else {
|
||||
ws.send(buildSignalRMessagePackInvocation(
|
||||
SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE,
|
||||
payload,
|
||||
contextId,
|
||||
'AuthRequestResponseRecieved'
|
||||
));
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
ws.close(1011, 'Notification send failed');
|
||||
} catch {
|
||||
// ignore close races
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 notifyUserCiphersSync(
|
||||
env: Env,
|
||||
userId: string,
|
||||
revisionDate: string,
|
||||
contextId?: string | null
|
||||
): void {
|
||||
waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_CIPHERS, revisionDate, contextId ?? null, null));
|
||||
}
|
||||
|
||||
export function notifyUserCipherCreate(
|
||||
env: Env,
|
||||
payload: {
|
||||
userId: string;
|
||||
cipherId: string;
|
||||
revisionDate: string;
|
||||
organizationId?: string | null;
|
||||
collectionIds?: string[] | null;
|
||||
contextId?: string | null;
|
||||
}
|
||||
): void {
|
||||
waitUntil(notifyUserUpdate(
|
||||
env,
|
||||
payload.userId,
|
||||
SIGNALR_UPDATE_TYPE_SYNC_CIPHER_CREATE,
|
||||
payload.revisionDate,
|
||||
payload.contextId ?? null,
|
||||
null,
|
||||
{
|
||||
UserId: payload.userId,
|
||||
Id: payload.cipherId,
|
||||
OrganizationId: payload.organizationId ?? null,
|
||||
CollectionIds: Array.isArray(payload.collectionIds) ? payload.collectionIds : null,
|
||||
RevisionDate: payload.revisionDate,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
export function notifyUserCipherUpdate(
|
||||
env: Env,
|
||||
payload: {
|
||||
userId: string;
|
||||
cipherId: string;
|
||||
revisionDate: string;
|
||||
organizationId?: string | null;
|
||||
collectionIds?: string[] | null;
|
||||
contextId?: string | null;
|
||||
}
|
||||
): void {
|
||||
waitUntil(notifyUserUpdate(
|
||||
env,
|
||||
payload.userId,
|
||||
SIGNALR_UPDATE_TYPE_SYNC_CIPHER_UPDATE,
|
||||
payload.revisionDate,
|
||||
payload.contextId ?? null,
|
||||
null,
|
||||
{
|
||||
UserId: payload.userId,
|
||||
Id: payload.cipherId,
|
||||
OrganizationId: payload.organizationId ?? null,
|
||||
CollectionIds: Array.isArray(payload.collectionIds) ? payload.collectionIds : null,
|
||||
RevisionDate: payload.revisionDate,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
export function notifyUserCipherDelete(
|
||||
env: Env,
|
||||
payload: {
|
||||
userId: string;
|
||||
cipherId: string;
|
||||
revisionDate: string;
|
||||
organizationId?: string | null;
|
||||
collectionIds?: string[] | null;
|
||||
contextId?: string | null;
|
||||
}
|
||||
): void {
|
||||
waitUntil(notifyUserUpdate(
|
||||
env,
|
||||
payload.userId,
|
||||
SIGNALR_UPDATE_TYPE_SYNC_CIPHER_DELETE,
|
||||
payload.revisionDate,
|
||||
payload.contextId ?? null,
|
||||
null,
|
||||
{
|
||||
UserId: payload.userId,
|
||||
Id: payload.cipherId,
|
||||
OrganizationId: payload.organizationId ?? null,
|
||||
CollectionIds: Array.isArray(payload.collectionIds) ? payload.collectionIds : null,
|
||||
RevisionDate: payload.revisionDate,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
export function notifyUserFolderCreate(
|
||||
env: Env,
|
||||
payload: {
|
||||
userId: string;
|
||||
folderId: string;
|
||||
revisionDate: string;
|
||||
contextId?: string | null;
|
||||
}
|
||||
): void {
|
||||
waitUntil(notifyUserUpdate(
|
||||
env,
|
||||
payload.userId,
|
||||
SIGNALR_UPDATE_TYPE_SYNC_FOLDER_CREATE,
|
||||
payload.revisionDate,
|
||||
payload.contextId ?? null,
|
||||
null,
|
||||
{
|
||||
UserId: payload.userId,
|
||||
Id: payload.folderId,
|
||||
RevisionDate: payload.revisionDate,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
export function notifyUserFolderUpdate(
|
||||
env: Env,
|
||||
payload: {
|
||||
userId: string;
|
||||
folderId: string;
|
||||
revisionDate: string;
|
||||
contextId?: string | null;
|
||||
}
|
||||
): void {
|
||||
waitUntil(notifyUserUpdate(
|
||||
env,
|
||||
payload.userId,
|
||||
SIGNALR_UPDATE_TYPE_SYNC_FOLDER_UPDATE,
|
||||
payload.revisionDate,
|
||||
payload.contextId ?? null,
|
||||
null,
|
||||
{
|
||||
UserId: payload.userId,
|
||||
Id: payload.folderId,
|
||||
RevisionDate: payload.revisionDate,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
export function notifyUserFolderDelete(
|
||||
env: Env,
|
||||
payload: {
|
||||
userId: string;
|
||||
folderId: string;
|
||||
revisionDate: string;
|
||||
contextId?: string | null;
|
||||
}
|
||||
): void {
|
||||
waitUntil(notifyUserUpdate(
|
||||
env,
|
||||
payload.userId,
|
||||
SIGNALR_UPDATE_TYPE_SYNC_FOLDER_DELETE,
|
||||
payload.revisionDate,
|
||||
payload.contextId ?? null,
|
||||
null,
|
||||
{
|
||||
UserId: payload.userId,
|
||||
Id: payload.folderId,
|
||||
RevisionDate: payload.revisionDate,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
export function notifyUserSendCreate(
|
||||
env: Env,
|
||||
payload: {
|
||||
userId: string;
|
||||
sendId: string;
|
||||
revisionDate: string;
|
||||
contextId?: string | null;
|
||||
}
|
||||
): void {
|
||||
waitUntil(notifyUserUpdate(
|
||||
env,
|
||||
payload.userId,
|
||||
SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE,
|
||||
payload.revisionDate,
|
||||
payload.contextId ?? null,
|
||||
null,
|
||||
{
|
||||
UserId: payload.userId,
|
||||
Id: payload.sendId,
|
||||
RevisionDate: payload.revisionDate,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
export function notifyUserSendUpdate(
|
||||
env: Env,
|
||||
payload: {
|
||||
userId: string;
|
||||
sendId: string;
|
||||
revisionDate: string;
|
||||
contextId?: string | null;
|
||||
}
|
||||
): void {
|
||||
waitUntil(notifyUserUpdate(
|
||||
env,
|
||||
payload.userId,
|
||||
SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE,
|
||||
payload.revisionDate,
|
||||
payload.contextId ?? null,
|
||||
null,
|
||||
{
|
||||
UserId: payload.userId,
|
||||
Id: payload.sendId,
|
||||
RevisionDate: payload.revisionDate,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
export function notifyUserSendDelete(
|
||||
env: Env,
|
||||
payload: {
|
||||
userId: string;
|
||||
sendId: string;
|
||||
revisionDate: string;
|
||||
contextId?: string | null;
|
||||
}
|
||||
): void {
|
||||
waitUntil(notifyUserUpdate(
|
||||
env,
|
||||
payload.userId,
|
||||
SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE,
|
||||
payload.revisionDate,
|
||||
payload.contextId ?? null,
|
||||
null,
|
||||
{
|
||||
UserId: payload.userId,
|
||||
Id: payload.sendId,
|
||||
RevisionDate: payload.revisionDate,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyAuthRequestResponse(
|
||||
env: Env,
|
||||
userId: string,
|
||||
authRequestId: string,
|
||||
contextId?: string | null
|
||||
): Promise<void> {
|
||||
try {
|
||||
const id = env.NOTIFICATIONS_HUB.idFromName(authRequestId);
|
||||
const stub = env.NOTIFICATIONS_HUB.get(id);
|
||||
await stub.fetch('https://notifications/internal/auth-request-response', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userId,
|
||||
authRequestId,
|
||||
contextId: contextId || null,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to broadcast auth request response notification:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function notifyUserAuthRequest(
|
||||
env: Env,
|
||||
userId: string,
|
||||
authRequestId: string,
|
||||
contextId?: string | null
|
||||
): void {
|
||||
waitUntil(notifyUserUpdate(
|
||||
env,
|
||||
userId,
|
||||
SIGNALR_UPDATE_TYPE_AUTH_REQUEST,
|
||||
new Date().toISOString(),
|
||||
contextId ?? null,
|
||||
null,
|
||||
{
|
||||
UserId: userId,
|
||||
Id: authRequestId,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
async function notifyUserUpdate(
|
||||
env: Env,
|
||||
userId: string,
|
||||
updateType: number,
|
||||
revisionDate: string,
|
||||
contextId: string | null,
|
||||
targetDeviceIdentifier: string | null,
|
||||
payloadOverride?: Record<string, unknown> | 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: payloadOverride || {
|
||||
UserId: userId,
|
||||
Date: revisionDate,
|
||||
},
|
||||
}),
|
||||
});
|
||||
await notifyMobilePush(env, {
|
||||
userId,
|
||||
updateType,
|
||||
revisionDate,
|
||||
contextId,
|
||||
payload: payloadOverride || {
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,793 @@
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from '@simplewebauthn/server';
|
||||
import type { AccountPasskeyChallengeScope, AccountPasskeyCredential, Env, User } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { AuthService } from '../services/auth';
|
||||
import { errorResponse, identityErrorResponse, jsonResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { bytesToBase64Url, parseClientDataJSON } from '../utils/passkey';
|
||||
import {
|
||||
accountPasskeyCredentialToResponse,
|
||||
accountPasskeyPrfStatus,
|
||||
accountPasskeyTokenTtlMs,
|
||||
buildWebAuthnPrfOption,
|
||||
createAccountPasskeyToken,
|
||||
getAccountPasskeyRpConfig,
|
||||
isSerializedEncString,
|
||||
normalizeAccountPasskeyName,
|
||||
normalizeAuthenticationResponse,
|
||||
normalizeRegistrationResponse,
|
||||
normalizeTransports,
|
||||
sha256Base64Url,
|
||||
toSimpleWebAuthnCredential,
|
||||
userHandleToUserId,
|
||||
userIdToWebAuthnUserId,
|
||||
verifyAccountPasskeyToken,
|
||||
} from '../utils/account-passkeys';
|
||||
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
|
||||
import { createRecoveryCode } from '../utils/recovery-code';
|
||||
|
||||
const MAX_ACCOUNT_PASSKEYS = 5;
|
||||
const MAX_TWO_FACTOR_PASSKEYS = 5;
|
||||
|
||||
function parseBodyObject(body: unknown): Record<string, any> {
|
||||
return body && typeof body === 'object' ? body as Record<string, any> : {};
|
||||
}
|
||||
|
||||
async function readJsonBody(request: Request): Promise<Record<string, any> | null> {
|
||||
try {
|
||||
return parseBodyObject(await request.json());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyUserSecret(
|
||||
env: Env,
|
||||
user: User,
|
||||
body: Record<string, any>
|
||||
): Promise<boolean> {
|
||||
const secret = String(body.masterPasswordHash || body.master_password_hash || body.secret || body.password || '').trim();
|
||||
if (!secret) return false;
|
||||
const storedHash = String(user.masterPasswordHash || '').trim();
|
||||
if (!storedHash) return false;
|
||||
const auth = new AuthService(env);
|
||||
return auth.verifyPassword(secret, storedHash, user.email);
|
||||
}
|
||||
|
||||
function logAccountPasskeyHandlerError(stage: string, error: unknown, details: Record<string, unknown> = {}): void {
|
||||
const err = error instanceof Error ? error : null;
|
||||
console.error('Account passkey handler failed', {
|
||||
stage,
|
||||
name: err?.name || typeof error,
|
||||
message: err?.message || String(error),
|
||||
stack: err?.stack,
|
||||
...details,
|
||||
});
|
||||
}
|
||||
|
||||
function passkeySetupStageMessage(stage: string): string {
|
||||
if (stage === 'verify_master_password') return 'verifying master password';
|
||||
if (stage === 'load_existing_credentials') return 'loading existing passkeys';
|
||||
if (stage === 'generate_options') return 'generating passkey options';
|
||||
if (stage === 'save_challenge') return 'saving passkey challenge';
|
||||
if (stage === 'create_token') return 'creating passkey challenge token';
|
||||
return 'preparing passkey setup';
|
||||
}
|
||||
|
||||
function hasCompletePrfKeySet(body: Record<string, any>): boolean {
|
||||
return !!(body.encryptedUserKey && body.encryptedPublicKey && body.encryptedPrivateKey);
|
||||
}
|
||||
|
||||
function twoFactorWebAuthnResponse(credentials: AccountPasskeyCredential[]): Record<string, unknown> {
|
||||
return {
|
||||
Enabled: credentials.length > 0,
|
||||
enabled: credentials.length > 0,
|
||||
Keys: credentials.map((credential, index) => ({
|
||||
Id: index + 1,
|
||||
id: index + 1,
|
||||
Name: credential.name,
|
||||
name: credential.name,
|
||||
Migrated: false,
|
||||
migrated: false,
|
||||
})),
|
||||
keys: credentials.map((credential, index) => ({
|
||||
Id: index + 1,
|
||||
id: index + 1,
|
||||
Name: credential.name,
|
||||
name: credential.name,
|
||||
Migrated: false,
|
||||
migrated: false,
|
||||
})),
|
||||
Object: 'twoFactorWebAuthn',
|
||||
object: 'twoFactorWebAuthn',
|
||||
};
|
||||
}
|
||||
|
||||
function readRegistrationChallenge(response: ReturnType<typeof normalizeRegistrationResponse>): string | null {
|
||||
if (!response) return null;
|
||||
const clientData = parseClientDataJSON(response.response.clientDataJSON);
|
||||
return String(clientData?.challenge || '').trim() || null;
|
||||
}
|
||||
|
||||
function readAuthenticationChallenge(response: ReturnType<typeof normalizeAuthenticationResponse>): string | null {
|
||||
if (!response) return null;
|
||||
const clientData = parseClientDataJSON(response.response.clientDataJSON);
|
||||
return String(clientData?.challenge || '').trim() || null;
|
||||
}
|
||||
|
||||
function readPrfKeySet(body: Record<string, any>): {
|
||||
encryptedUserKey: string | null;
|
||||
encryptedPublicKey: string | null;
|
||||
encryptedPrivateKey: string | null;
|
||||
} {
|
||||
if (!hasCompletePrfKeySet(body)) {
|
||||
return { encryptedUserKey: null, encryptedPublicKey: null, encryptedPrivateKey: null };
|
||||
}
|
||||
const encryptedUserKey = String(body.encryptedUserKey).trim();
|
||||
const encryptedPublicKey = String(body.encryptedPublicKey).trim();
|
||||
const encryptedPrivateKey = String(body.encryptedPrivateKey).trim();
|
||||
if (!isSerializedEncString(encryptedUserKey) || !isSerializedEncString(encryptedPublicKey) || !isSerializedEncString(encryptedPrivateKey)) {
|
||||
throw new Error('Invalid encrypted key set');
|
||||
}
|
||||
return { encryptedUserKey, encryptedPublicKey, encryptedPrivateKey };
|
||||
}
|
||||
|
||||
async function saveChallenge(
|
||||
storage: StorageService,
|
||||
scope: AccountPasskeyChallengeScope,
|
||||
challenge: string,
|
||||
userId: string | null
|
||||
): Promise<void> {
|
||||
const now = Date.now();
|
||||
await storage.saveAccountPasskeyChallenge({
|
||||
challengeHash: await sha256Base64Url(challenge),
|
||||
scope,
|
||||
userId,
|
||||
expiresAt: now + accountPasskeyTokenTtlMs(scope),
|
||||
usedAt: null,
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleGetAccountPasskeyAssertionOptions(request: Request, env: Env): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const { rpId } = getAccountPasskeyRpConfig(request, env);
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rpId,
|
||||
allowCredentials: [],
|
||||
userVerification: 'required',
|
||||
timeout: 60000,
|
||||
});
|
||||
await saveChallenge(storage, 'Authentication', options.challenge, null);
|
||||
const token = await createAccountPasskeyToken(env, {
|
||||
scope: 'Authentication',
|
||||
challenge: options.challenge,
|
||||
userId: null,
|
||||
rpId,
|
||||
});
|
||||
return jsonResponse({ options, token, object: 'webAuthnLoginAssertionOptions', Object: 'webAuthnLoginAssertionOptions' });
|
||||
}
|
||||
|
||||
export async function assertAccountPasskeyCredential(
|
||||
request: Request,
|
||||
env: Env,
|
||||
storage: StorageService,
|
||||
input: {
|
||||
token: string;
|
||||
deviceResponse: unknown;
|
||||
scope: 'Authentication' | 'UpdateKeySet';
|
||||
expectedUserId?: string | null;
|
||||
}
|
||||
): Promise<{ user: User; credential: AccountPasskeyCredential }> {
|
||||
const payload = await verifyAccountPasskeyToken(env, input.token, input.scope);
|
||||
if (!payload) {
|
||||
throw new Error('Passkey challenge token is invalid or expired');
|
||||
}
|
||||
if (input.expectedUserId !== undefined && payload.userId !== input.expectedUserId) {
|
||||
throw new Error('Passkey challenge token does not match this user');
|
||||
}
|
||||
|
||||
const response = normalizeAuthenticationResponse(input.deviceResponse);
|
||||
if (!response) {
|
||||
throw new Error('Invalid passkey assertion response');
|
||||
}
|
||||
|
||||
const challengeHash = await sha256Base64Url(payload.challenge);
|
||||
const consumed = await storage.consumeAccountPasskeyChallenge(
|
||||
challengeHash,
|
||||
input.scope,
|
||||
payload.userId,
|
||||
Date.now()
|
||||
);
|
||||
if (!consumed) {
|
||||
throw new Error('Passkey challenge has expired or was already used');
|
||||
}
|
||||
|
||||
const credential = await storage.getAccountPasskeyCredentialByCredentialId(response.rawId);
|
||||
if (!credential) {
|
||||
throw new Error('Passkey is not registered for this server');
|
||||
}
|
||||
if (payload.userId && credential.userId !== payload.userId) {
|
||||
throw new Error('Passkey does not belong to this user');
|
||||
}
|
||||
if (credential.purpose !== 'login') {
|
||||
throw new Error('Passkey is not registered for login');
|
||||
}
|
||||
|
||||
const userHandleUserId = userHandleToUserId(response.response.userHandle);
|
||||
const resolvedUserId = payload.userId || userHandleUserId || credential.userId;
|
||||
if (!resolvedUserId || resolvedUserId !== credential.userId) {
|
||||
throw new Error('Passkey user handle does not match this credential');
|
||||
}
|
||||
|
||||
const user = await storage.getUserById(resolvedUserId);
|
||||
if (!user || user.status !== 'active') {
|
||||
throw new Error('Passkey user is not available');
|
||||
}
|
||||
|
||||
const { origins } = getAccountPasskeyRpConfig(request, env);
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: payload.challenge,
|
||||
expectedOrigin: origins,
|
||||
expectedRPID: payload.rpId,
|
||||
credential: toSimpleWebAuthnCredential(credential),
|
||||
requireUserVerification: true,
|
||||
advancedFIDOConfig: { userVerification: 'required' },
|
||||
});
|
||||
if (!verification.verified || !verification.authenticationInfo.userVerified) {
|
||||
throw new Error('Passkey assertion could not be verified');
|
||||
}
|
||||
|
||||
await storage.updateAccountPasskeyCounter(
|
||||
credential.userId,
|
||||
credential.credentialId,
|
||||
verification.authenticationInfo.newCounter,
|
||||
new Date().toISOString()
|
||||
);
|
||||
credential.counter = verification.authenticationInfo.newCounter;
|
||||
return { user, credential };
|
||||
}
|
||||
|
||||
export async function handleGetAccountPasskeyCredentials(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId);
|
||||
return jsonResponse({
|
||||
data: credentials.map(accountPasskeyCredentialToResponse),
|
||||
Data: credentials.map(accountPasskeyCredentialToResponse),
|
||||
object: 'list',
|
||||
Object: 'list',
|
||||
continuationToken: null,
|
||||
ContinuationToken: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildTwoFactorPasskeyAssertionOptions(
|
||||
request: Request,
|
||||
env: Env,
|
||||
storage: StorageService,
|
||||
user: User
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
|
||||
if (!credentials.length) return null;
|
||||
|
||||
const { rpId } = getAccountPasskeyRpConfig(request, env);
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rpId,
|
||||
allowCredentials: credentials.map((credential) => ({
|
||||
id: credential.credentialId,
|
||||
transports: (credential.transports || undefined) as any,
|
||||
})),
|
||||
userVerification: 'discouraged',
|
||||
timeout: 60000,
|
||||
});
|
||||
await saveChallenge(storage, 'TwoFactorAuthentication', options.challenge, user.id);
|
||||
return options as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function assertTwoFactorPasskeyCredential(
|
||||
request: Request,
|
||||
env: Env,
|
||||
storage: StorageService,
|
||||
user: User,
|
||||
deviceResponse: unknown
|
||||
): Promise<AccountPasskeyCredential> {
|
||||
const response = normalizeAuthenticationResponse(deviceResponse);
|
||||
if (!response) {
|
||||
throw new Error('Invalid passkey assertion response');
|
||||
}
|
||||
|
||||
const credential = await storage.getAccountPasskeyCredentialByCredentialId(response.rawId);
|
||||
if (!credential || credential.userId !== user.id || credential.purpose !== 'twoFactor') {
|
||||
throw new Error('Passkey is not registered for two-step login');
|
||||
}
|
||||
|
||||
const challenge = readAuthenticationChallenge(response);
|
||||
if (!challenge) {
|
||||
throw new Error('Passkey assertion challenge is missing');
|
||||
}
|
||||
const consumed = await storage.consumeAccountPasskeyChallenge(
|
||||
await sha256Base64Url(challenge),
|
||||
'TwoFactorAuthentication',
|
||||
user.id,
|
||||
Date.now()
|
||||
);
|
||||
if (!consumed) {
|
||||
throw new Error('Passkey challenge has expired or was already used');
|
||||
}
|
||||
|
||||
const { origins, rpId } = getAccountPasskeyRpConfig(request, env);
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: origins,
|
||||
expectedRPID: rpId,
|
||||
credential: toSimpleWebAuthnCredential(credential),
|
||||
requireUserVerification: false,
|
||||
});
|
||||
if (!verification.verified) {
|
||||
throw new Error('Passkey assertion could not be verified');
|
||||
}
|
||||
|
||||
await storage.updateAccountPasskeyCounter(
|
||||
credential.userId,
|
||||
credential.credentialId,
|
||||
verification.authenticationInfo.newCounter,
|
||||
new Date().toISOString()
|
||||
);
|
||||
credential.counter = verification.authenticationInfo.newCounter;
|
||||
return credential;
|
||||
}
|
||||
|
||||
export async function handleGetTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
if (!(await verifyUserSecret(env, user, body))) {
|
||||
return errorResponse('User verification failed.', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||
return jsonResponse(twoFactorWebAuthnResponse(credentials));
|
||||
}
|
||||
|
||||
export async function handleGetTwoFactorWebAuthnChallenge(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
if (!(await verifyUserSecret(env, user, body))) {
|
||||
return errorResponse('User verification failed.', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||
if (credentials.length >= MAX_TWO_FACTOR_PASSKEYS) {
|
||||
return errorResponse('Maximum WebAuthn credential count reached.', 400);
|
||||
}
|
||||
|
||||
const { rpId, rpName } = getAccountPasskeyRpConfig(request, env);
|
||||
const options = await generateRegistrationOptions({
|
||||
rpID: rpId,
|
||||
rpName,
|
||||
userID: Uint8Array.from(userIdToWebAuthnUserId(user.id)),
|
||||
userName: user.email,
|
||||
userDisplayName: user.name || user.email,
|
||||
attestationType: 'none',
|
||||
timeout: 60000,
|
||||
excludeCredentials: credentials.map((credential) => ({
|
||||
id: credential.credentialId,
|
||||
transports: (credential.transports || undefined) as any,
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
residentKey: 'discouraged',
|
||||
requireResidentKey: false,
|
||||
userVerification: 'discouraged',
|
||||
},
|
||||
});
|
||||
await saveChallenge(storage, 'TwoFactorCreate', options.challenge, userId);
|
||||
return jsonResponse(options);
|
||||
}
|
||||
|
||||
export async function handlePutTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
if (!(await verifyUserSecret(env, user, body))) {
|
||||
return errorResponse('User verification failed.', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const currentCount = await storage.countAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||
if (currentCount >= MAX_TWO_FACTOR_PASSKEYS) {
|
||||
return errorResponse('Maximum WebAuthn credential count reached.', 400);
|
||||
}
|
||||
|
||||
const registrationResponse = normalizeRegistrationResponse(body.deviceResponse);
|
||||
if (!registrationResponse) {
|
||||
return errorResponse('Invalid passkey registration response', 400);
|
||||
}
|
||||
const challenge = readRegistrationChallenge(registrationResponse);
|
||||
if (!challenge) {
|
||||
return errorResponse('Passkey challenge is missing', 400);
|
||||
}
|
||||
const consumed = await storage.consumeAccountPasskeyChallenge(
|
||||
await sha256Base64Url(challenge),
|
||||
'TwoFactorCreate',
|
||||
userId,
|
||||
Date.now()
|
||||
);
|
||||
if (!consumed) {
|
||||
return errorResponse('Passkey challenge has expired or was already used', 400);
|
||||
}
|
||||
|
||||
const { origins, rpId } = getAccountPasskeyRpConfig(request, env);
|
||||
let verification: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response: registrationResponse,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: origins,
|
||||
expectedRPID: rpId,
|
||||
requireUserPresence: true,
|
||||
requireUserVerification: false,
|
||||
});
|
||||
} catch {
|
||||
return errorResponse('Passkey registration could not be verified', 400);
|
||||
}
|
||||
if (!verification.verified) {
|
||||
return errorResponse('Passkey registration could not be verified', 400);
|
||||
}
|
||||
|
||||
const existing = await storage.getAccountPasskeyCredentialByCredentialId(verification.registrationInfo.credential.id);
|
||||
if (existing) {
|
||||
return errorResponse('Passkey is already registered', 409);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const transports = normalizeTransports(registrationResponse.response.transports);
|
||||
await storage.saveAccountPasskeyCredential({
|
||||
id: generateUUID(),
|
||||
userId,
|
||||
purpose: 'twoFactor',
|
||||
name: normalizeAccountPasskeyName(body.name || `Passkey ${currentCount + 1}`),
|
||||
publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey),
|
||||
credentialId: verification.registrationInfo.credential.id,
|
||||
counter: verification.registrationInfo.credential.counter,
|
||||
type: verification.registrationInfo.credentialType || 'public-key',
|
||||
aaGuid: verification.registrationInfo.aaguid || null,
|
||||
transports,
|
||||
encryptedUserKey: null,
|
||||
encryptedPublicKey: null,
|
||||
encryptedPrivateKey: null,
|
||||
supportsPrf: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
if (!user.totpRecoveryCode) {
|
||||
user.totpRecoveryCode = createRecoveryCode();
|
||||
user.updatedAt = now;
|
||||
await storage.saveUser(user);
|
||||
}
|
||||
await storage.deleteRefreshTokensByUserId(userId);
|
||||
AuthService.invalidateUserCache(userId);
|
||||
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: userId,
|
||||
action: 'account.webauthn_2fa.enable',
|
||||
category: 'security',
|
||||
level: 'security',
|
||||
targetType: 'accountPasskey',
|
||||
targetId: null,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||
return jsonResponse(twoFactorWebAuthnResponse(credentials));
|
||||
}
|
||||
|
||||
export async function handleDeleteTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
if (!(await verifyUserSecret(env, user, body))) {
|
||||
return errorResponse('User verification failed.', 400);
|
||||
}
|
||||
|
||||
const requestedId = Number(body.id ?? body.Id);
|
||||
if (!Number.isInteger(requestedId) || requestedId <= 0) {
|
||||
return errorResponse('Invalid key id', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||
if (credentials.length < 2) {
|
||||
return errorResponse('Unable to delete WebAuthn credential.', 400);
|
||||
}
|
||||
const credential = credentials[requestedId - 1];
|
||||
if (!credential) {
|
||||
return errorResponse('Unable to delete WebAuthn credential.', 400);
|
||||
}
|
||||
|
||||
const deleted = await storage.deleteAccountPasskeyCredential(userId, credential.id, 'twoFactor');
|
||||
if (!deleted) return errorResponse('Unable to delete WebAuthn credential.', 400);
|
||||
await storage.deleteRefreshTokensByUserId(userId);
|
||||
AuthService.invalidateUserCache(userId);
|
||||
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: userId,
|
||||
action: 'account.webauthn_2fa.delete',
|
||||
category: 'security',
|
||||
level: 'security',
|
||||
targetType: 'accountPasskey',
|
||||
targetId: credential.id,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
|
||||
return jsonResponse(twoFactorWebAuthnResponse(await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor')));
|
||||
}
|
||||
|
||||
export async function handleGetAccountPasskeyAttestationOptions(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
|
||||
let stage = 'verify_master_password';
|
||||
try {
|
||||
if (!(await verifyUserSecret(env, user, body))) {
|
||||
return errorResponse('Master password verification failed', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
stage = 'load_existing_credentials';
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId);
|
||||
if (credentials.length >= MAX_ACCOUNT_PASSKEYS) {
|
||||
return errorResponse('Maximum passkey count reached', 400);
|
||||
}
|
||||
|
||||
const { rpId, rpName } = getAccountPasskeyRpConfig(request, env);
|
||||
stage = 'generate_options';
|
||||
const options = await generateRegistrationOptions({
|
||||
rpID: rpId,
|
||||
rpName,
|
||||
userID: Uint8Array.from(userIdToWebAuthnUserId(user.id)),
|
||||
userName: user.email,
|
||||
userDisplayName: user.name || user.email,
|
||||
attestationType: 'none',
|
||||
timeout: 60000,
|
||||
excludeCredentials: credentials.map((credential) => ({
|
||||
id: credential.credentialId,
|
||||
transports: (credential.transports || undefined) as any,
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
residentKey: 'required',
|
||||
requireResidentKey: true,
|
||||
userVerification: 'required',
|
||||
},
|
||||
});
|
||||
(options as any).extensions = {
|
||||
...((options as any).extensions || {}),
|
||||
prf: {},
|
||||
};
|
||||
stage = 'save_challenge';
|
||||
await saveChallenge(storage, 'CreateCredential', options.challenge, userId);
|
||||
stage = 'create_token';
|
||||
const token = await createAccountPasskeyToken(env, {
|
||||
scope: 'CreateCredential',
|
||||
challenge: options.challenge,
|
||||
userId,
|
||||
rpId,
|
||||
});
|
||||
return jsonResponse({ options, token, object: 'webauthnCredentialCreateOptions', Object: 'webauthnCredentialCreateOptions' });
|
||||
} catch (error) {
|
||||
logAccountPasskeyHandlerError(stage, error, { userId });
|
||||
return errorResponse(`Passkey setup failed while ${passkeySetupStageMessage(stage)}`, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGetAccountPasskeyUpdateAssertionOptions(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
if (!(await verifyUserSecret(env, user, body))) {
|
||||
return errorResponse('Master password verification failed', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
let credentials = await storage.getAccountPasskeyCredentialsByUserId(userId);
|
||||
const requestedId = String(body.credentialId || body.id || '').trim();
|
||||
if (requestedId) {
|
||||
credentials = credentials.filter((credential) => credential.id === requestedId);
|
||||
if (!credentials.length) return errorResponse('Account passkey not found', 404);
|
||||
}
|
||||
if (!credentials.length) return errorResponse('No account passkeys registered', 404);
|
||||
|
||||
const { rpId } = getAccountPasskeyRpConfig(request, env);
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rpId,
|
||||
allowCredentials: credentials.map((credential) => ({
|
||||
id: credential.credentialId,
|
||||
transports: (credential.transports || undefined) as any,
|
||||
})),
|
||||
userVerification: 'required',
|
||||
timeout: 60000,
|
||||
});
|
||||
await saveChallenge(storage, 'UpdateKeySet', options.challenge, userId);
|
||||
const token = await createAccountPasskeyToken(env, {
|
||||
scope: 'UpdateKeySet',
|
||||
challenge: options.challenge,
|
||||
userId,
|
||||
rpId,
|
||||
});
|
||||
return jsonResponse({ options, token, object: 'webAuthnLoginAssertionOptions', Object: 'webAuthnLoginAssertionOptions' });
|
||||
}
|
||||
|
||||
export async function handleCreateAccountPasskeyCredential(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const payload = await verifyAccountPasskeyToken(env, String(body.token || ''), 'CreateCredential');
|
||||
if (!payload || payload.userId !== userId) {
|
||||
return errorResponse('Passkey challenge token is invalid or expired', 400);
|
||||
}
|
||||
|
||||
const challengeHash = await sha256Base64Url(payload.challenge);
|
||||
const consumed = await storage.consumeAccountPasskeyChallenge(challengeHash, 'CreateCredential', userId, Date.now());
|
||||
if (!consumed) {
|
||||
return errorResponse('Passkey challenge has expired or was already used', 400);
|
||||
}
|
||||
|
||||
const currentCount = await storage.countAccountPasskeyCredentialsByUserId(userId);
|
||||
if (currentCount >= MAX_ACCOUNT_PASSKEYS) {
|
||||
return errorResponse('Maximum passkey count reached', 400);
|
||||
}
|
||||
|
||||
let prfKeySet: ReturnType<typeof readPrfKeySet>;
|
||||
try {
|
||||
prfKeySet = readPrfKeySet(body);
|
||||
} catch {
|
||||
return errorResponse('Invalid encrypted passkey key set', 400);
|
||||
}
|
||||
|
||||
const registrationResponse = normalizeRegistrationResponse(body.deviceResponse);
|
||||
if (!registrationResponse) {
|
||||
return errorResponse('Invalid passkey registration response', 400);
|
||||
}
|
||||
|
||||
const { origins } = getAccountPasskeyRpConfig(request, env);
|
||||
let verification: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response: registrationResponse,
|
||||
expectedChallenge: payload.challenge,
|
||||
expectedOrigin: origins,
|
||||
expectedRPID: payload.rpId,
|
||||
requireUserPresence: true,
|
||||
requireUserVerification: true,
|
||||
});
|
||||
} catch {
|
||||
return errorResponse('Passkey registration could not be verified', 400);
|
||||
}
|
||||
if (!verification.verified) {
|
||||
return errorResponse('Passkey registration could not be verified', 400);
|
||||
}
|
||||
|
||||
const existing = await storage.getAccountPasskeyCredentialByCredentialId(verification.registrationInfo.credential.id);
|
||||
if (existing) {
|
||||
return errorResponse('Passkey is already registered', 409);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const supportsPrf = !!body.supportsPrf || hasCompletePrfKeySet(body);
|
||||
const transports = normalizeTransports(registrationResponse.response.transports);
|
||||
const credential: AccountPasskeyCredential = {
|
||||
id: generateUUID(),
|
||||
userId,
|
||||
purpose: 'login',
|
||||
name: normalizeAccountPasskeyName(body.name),
|
||||
publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey),
|
||||
credentialId: verification.registrationInfo.credential.id,
|
||||
counter: verification.registrationInfo.credential.counter,
|
||||
type: verification.registrationInfo.credentialType || 'public-key',
|
||||
aaGuid: verification.registrationInfo.aaguid || null,
|
||||
transports,
|
||||
encryptedUserKey: prfKeySet.encryptedUserKey,
|
||||
encryptedPublicKey: prfKeySet.encryptedPublicKey,
|
||||
encryptedPrivateKey: prfKeySet.encryptedPrivateKey,
|
||||
supportsPrf,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await storage.saveAccountPasskeyCredential(credential);
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: userId,
|
||||
action: 'account.passkey.create',
|
||||
category: 'security',
|
||||
level: 'info',
|
||||
targetType: 'accountPasskey',
|
||||
targetId: credential.id,
|
||||
metadata: {
|
||||
prfStatus: accountPasskeyPrfStatus(credential),
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
|
||||
return jsonResponse(accountPasskeyCredentialToResponse(credential));
|
||||
}
|
||||
|
||||
export async function handleUpdateAccountPasskeyEncryption(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
|
||||
let prfKeySet: ReturnType<typeof readPrfKeySet>;
|
||||
try {
|
||||
prfKeySet = readPrfKeySet(body);
|
||||
} catch {
|
||||
return errorResponse('Invalid encrypted passkey key set', 400);
|
||||
}
|
||||
if (!prfKeySet.encryptedUserKey || !prfKeySet.encryptedPublicKey || !prfKeySet.encryptedPrivateKey) {
|
||||
return errorResponse('Encrypted passkey key set is required', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
let assertion: Awaited<ReturnType<typeof assertAccountPasskeyCredential>>;
|
||||
try {
|
||||
assertion = await assertAccountPasskeyCredential(request, env, storage, {
|
||||
token: String(body.token || ''),
|
||||
deviceResponse: body.deviceResponse,
|
||||
scope: 'UpdateKeySet',
|
||||
expectedUserId: userId,
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error instanceof Error ? error.message : 'Passkey assertion failed', 400);
|
||||
}
|
||||
|
||||
const updated = await storage.updateAccountPasskeyEncryption(
|
||||
userId,
|
||||
assertion.credential.credentialId,
|
||||
prfKeySet.encryptedUserKey,
|
||||
prfKeySet.encryptedPublicKey,
|
||||
prfKeySet.encryptedPrivateKey
|
||||
);
|
||||
if (!updated) return errorResponse('Passkey not found', 404);
|
||||
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: userId,
|
||||
action: 'account.passkey.encryption.enable',
|
||||
category: 'security',
|
||||
level: 'info',
|
||||
targetType: 'accountPasskey',
|
||||
targetId: assertion.credential.id,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
return jsonResponse({ success: true });
|
||||
}
|
||||
|
||||
export async function handleDeleteAccountPasskeyCredential(request: Request, env: Env, userId: string, credentialId: string, user: User): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
if (!(await verifyUserSecret(env, user, body))) {
|
||||
return errorResponse('Master password verification failed', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const deleted = await storage.deleteAccountPasskeyCredential(userId, credentialId);
|
||||
if (!deleted) return errorResponse('Passkey not found', 404);
|
||||
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: userId,
|
||||
action: 'account.passkey.delete',
|
||||
category: 'security',
|
||||
level: 'info',
|
||||
targetType: 'accountPasskey',
|
||||
targetId: credentialId,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
return jsonResponse({ success: true });
|
||||
}
|
||||
|
||||
export function buildAccountPasskeyTokenUserDecryptionOption(credential: AccountPasskeyCredential) {
|
||||
return buildWebAuthnPrfOption(credential);
|
||||
}
|
||||
+1455
-81
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,446 @@
|
||||
import { Env, User, Invite } from '../types';
|
||||
import { AuthService } from '../services/auth';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { deleteBlobObject, getAttachmentObjectKey, getSendFileObjectKey } from '../services/blob-store';
|
||||
import { auditRequestMetadata, getAuditLogSettings, normalizeAuditLogSettings, saveAuditLogSettings, writeAuditEvent } from '../services/audit-events';
|
||||
|
||||
function isAdmin(user: User): boolean {
|
||||
return user.role === 'admin' && user.status === 'active';
|
||||
}
|
||||
|
||||
async function requireMasterPasswordHash(
|
||||
env: Env,
|
||||
actorUser: User,
|
||||
masterPasswordHash: unknown
|
||||
): Promise<Response | null> {
|
||||
const normalized = String(masterPasswordHash || '').trim();
|
||||
if (!normalized) {
|
||||
return errorResponse('masterPasswordHash is required', 400);
|
||||
}
|
||||
const auth = new AuthService(env);
|
||||
const valid = await auth.verifyPassword(normalized, actorUser.masterPasswordHash, actorUser.email);
|
||||
if (!valid) {
|
||||
return errorResponse('Invalid password', 400);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readJsonBody(request: Request): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const body = await request.json();
|
||||
return body && typeof body === 'object' && !Array.isArray(body)
|
||||
? body as Record<string, unknown>
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
request?: Request
|
||||
): Promise<void> {
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId,
|
||||
action,
|
||||
targetType,
|
||||
targetId,
|
||||
category: action.startsWith('admin.user.') ? 'security' : 'system',
|
||||
level: action.startsWith('admin.user.') ? 'security' : 'info',
|
||||
metadata: {
|
||||
...(metadata || {}),
|
||||
...(request ? auditRequestMetadata(request) : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
const data = await Promise.all(users.map(async user => {
|
||||
const hasTwoFactorPasskey = await storage.countAccountPasskeyCredentialsByUserId(user.id, 'twoFactor') > 0;
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
twoFactorEnabled: !!user.totpSecret || Boolean(user.yubikeyKey1 || user.yubikeyKey2 || user.yubikeyKey3 || user.yubikeyKey4 || user.yubikeyKey5) || hasTwoFactorPasskey,
|
||||
creationDate: user.createdAt,
|
||||
revisionDate: user.updatedAt,
|
||||
object: 'user',
|
||||
};
|
||||
}));
|
||||
return jsonResponse({
|
||||
data,
|
||||
object: 'list',
|
||||
continuationToken: null,
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/admin/logs
|
||||
export async function handleAdminListAuditLogs(
|
||||
request: Request,
|
||||
env: Env,
|
||||
actorUser: User
|
||||
): Promise<Response> {
|
||||
if (!isAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const limit = Math.max(1, Math.min(200, Number(url.searchParams.get('limit') || 50)));
|
||||
const offset = Math.max(0, Number(url.searchParams.get('offset') || 0));
|
||||
const category = String(url.searchParams.get('category') || '').trim() || null;
|
||||
const level = String(url.searchParams.get('level') || '').trim() || null;
|
||||
const q = String(url.searchParams.get('q') || '').trim().toLowerCase() || null;
|
||||
const from = String(url.searchParams.get('from') || '').trim() || null;
|
||||
const to = String(url.searchParams.get('to') || '').trim() || null;
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const result = await storage.listAuditLogs({ limit, offset, category, level, q, from, to });
|
||||
return jsonResponse({
|
||||
data: result.logs.map(log => ({
|
||||
id: log.id,
|
||||
actorUserId: log.actorUserId,
|
||||
actorEmail: log.actorEmail,
|
||||
action: log.action,
|
||||
category: log.category,
|
||||
level: log.level,
|
||||
targetType: log.targetType,
|
||||
targetId: log.targetId,
|
||||
targetUserEmail: log.targetUserEmail,
|
||||
metadata: log.metadata,
|
||||
createdAt: log.createdAt,
|
||||
object: 'auditLog',
|
||||
})),
|
||||
total: result.total,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: result.hasMore,
|
||||
object: 'list',
|
||||
continuationToken: result.hasMore ? String(offset + result.logs.length) : null,
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/admin/logs/settings
|
||||
export async function handleAdminGetAuditLogSettings(
|
||||
request: Request,
|
||||
env: Env,
|
||||
actorUser: User
|
||||
): Promise<Response> {
|
||||
void request;
|
||||
if (!isAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
const storage = new StorageService(env.DB);
|
||||
return jsonResponse({
|
||||
object: 'auditLogSettings',
|
||||
...await getAuditLogSettings(storage),
|
||||
});
|
||||
}
|
||||
|
||||
// PUT /api/admin/logs/settings
|
||||
export async function handleAdminUpdateAuditLogSettings(
|
||||
request: Request,
|
||||
env: Env,
|
||||
actorUser: User
|
||||
): Promise<Response> {
|
||||
if (!isAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
const storage = new StorageService(env.DB);
|
||||
const settings = await saveAuditLogSettings(storage, normalizeAuditLogSettings(body));
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.audit.settings.update', 'auditLog', null, { ...settings }, request);
|
||||
return jsonResponse({
|
||||
object: 'auditLogSettings',
|
||||
...settings,
|
||||
});
|
||||
}
|
||||
|
||||
// DELETE /api/admin/logs
|
||||
export async function handleAdminClearAuditLogs(
|
||||
request: Request,
|
||||
env: Env,
|
||||
actorUser: User
|
||||
): Promise<Response> {
|
||||
if (!isAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
const storage = new StorageService(env.DB);
|
||||
const deleted = await storage.clearAuditLogs();
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.audit.clear', 'auditLog', null, {
|
||||
deleted,
|
||||
}, request);
|
||||
return jsonResponse({ object: 'auditLogClear', deleted });
|
||||
}
|
||||
|
||||
// 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);
|
||||
const body = await readJsonBody(request);
|
||||
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
|
||||
if (passwordError) return passwordError;
|
||||
|
||||
const expiresInHours = Number.isFinite(Number(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', null, {
|
||||
expiresInHours,
|
||||
}, request);
|
||||
|
||||
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 handleAdminDeleteInvite(
|
||||
request: Request,
|
||||
env: Env,
|
||||
actorUser: User,
|
||||
code: string
|
||||
): Promise<Response> {
|
||||
if (!isAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
|
||||
if (passwordError) return passwordError;
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const deleted = await storage.deleteInvite(code);
|
||||
if (!deleted) {
|
||||
return errorResponse('Invite not found', 404);
|
||||
}
|
||||
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete', 'invite', null, {
|
||||
code,
|
||||
}, request);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
// DELETE /api/admin/invites
|
||||
export async function handleAdminDeleteAllInvites(
|
||||
request: Request,
|
||||
env: Env,
|
||||
actorUser: User
|
||||
): Promise<Response> {
|
||||
if (!isAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
|
||||
if (passwordError) return passwordError;
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.get('scope') === 'invalid') {
|
||||
const deleted = await storage.deleteInvalidInvites();
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_invalid', 'invite', null, {
|
||||
deleted,
|
||||
}, request);
|
||||
|
||||
return jsonResponse({ deleted }, 200);
|
||||
}
|
||||
|
||||
const deleted = await storage.deleteAllInvites();
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_all', 'invite', null, {
|
||||
deleted,
|
||||
}, request);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
|
||||
if (passwordError) return passwordError;
|
||||
|
||||
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);
|
||||
}
|
||||
AuthService.invalidateUserCache(target.id);
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.user.status', 'user', target.id, {
|
||||
status: nextStatus,
|
||||
}, request);
|
||||
|
||||
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> {
|
||||
if (!isAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
if (targetUserId === actorUser.id) {
|
||||
return errorResponse('You cannot delete yourself', 400);
|
||||
}
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
|
||||
if (passwordError) return passwordError;
|
||||
|
||||
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);
|
||||
AuthService.invalidateUserCache(target.id);
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.user.delete', 'user', target.id, {
|
||||
targetEmail: target.email,
|
||||
}, request);
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
+308
-91
@@ -1,10 +1,89 @@
|
||||
import { Env, Attachment, DEFAULT_DEV_SECRET } from '../types';
|
||||
import { Env, Attachment, Cipher } from '../types';
|
||||
import { notifyUserCipherUpdate, 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 { cipherToResponse } from './ciphers';
|
||||
import { sanitizeDownloadContentType } from '../utils/content-type';
|
||||
import {
|
||||
createAttachmentUploadToken,
|
||||
createFileDownloadToken,
|
||||
verifyAttachmentUploadToken,
|
||||
verifyFileDownloadToken,
|
||||
} from '../utils/jwt';
|
||||
import { applyCipherEmbeddedAttachmentMetadata, cipherToResponse } from './ciphers';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { readActingDeviceIdentifier } from '../utils/device';
|
||||
import {
|
||||
deleteBlobObject,
|
||||
getAttachmentObjectKey,
|
||||
getBlobObject,
|
||||
getBlobStorageMaxBytes,
|
||||
putBlobObject,
|
||||
} from '../services/blob-store';
|
||||
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||
|
||||
function notifyVaultSyncForRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
revisionDate: string
|
||||
): void {
|
||||
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
}
|
||||
|
||||
function normalizeOptionalId(value: unknown): string | null {
|
||||
if (value == null) return null;
|
||||
const normalized = String(value).trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function notifyCipherUpdateForRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
cipher: Cipher,
|
||||
revisionDate: string
|
||||
): void {
|
||||
notifyUserCipherUpdate(env, {
|
||||
userId: cipher.userId,
|
||||
cipherId: cipher.id,
|
||||
revisionDate,
|
||||
organizationId: normalizeOptionalId((cipher as any).organizationId ?? null),
|
||||
collectionIds: Array.isArray((cipher as any).collectionIds)
|
||||
? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean)
|
||||
: null,
|
||||
contextId: readActingDeviceIdentifier(request),
|
||||
});
|
||||
}
|
||||
|
||||
function contentDispositionAttachment(fileName: string | null | undefined): string {
|
||||
const fallback = 'attachment';
|
||||
const value = String(fileName || fallback)
|
||||
.replace(/[\r\n"]/g, '_')
|
||||
.trim() || fallback;
|
||||
return `attachment; filename="${value}"`;
|
||||
}
|
||||
|
||||
async function writeAttachmentAudit(
|
||||
storage: StorageService,
|
||||
request: Request,
|
||||
userId: string,
|
||||
action: string,
|
||||
metadata: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action,
|
||||
category: 'data',
|
||||
level: action.includes('delete') ? 'security' : 'info',
|
||||
targetType: 'attachment',
|
||||
targetId: typeof metadata.id === 'string' ? metadata.id : null,
|
||||
metadata: {
|
||||
...metadata,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Format file size to human readable
|
||||
function formatSize(bytes: number): string {
|
||||
@@ -14,9 +93,71 @@ 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,
|
||||
cipher: Cipher,
|
||||
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);
|
||||
if (await getBlobObject(env, path)) {
|
||||
return errorResponse('Attachment file has already been uploaded', 409);
|
||||
}
|
||||
|
||||
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);
|
||||
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 201 });
|
||||
}
|
||||
|
||||
// POST /api/ciphers/{cipherId}/attachment/v2
|
||||
@@ -30,7 +171,7 @@ export async function handleCreateAttachment(
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
// Verify cipher exists and belongs to user
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
const cipher = await storage.getCipherForUser(cipherId, userId);
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
@@ -68,27 +209,33 @@ export async function handleCreateAttachment(
|
||||
await storage.saveAttachment(attachment);
|
||||
|
||||
// Add attachment to cipher
|
||||
await storage.addAttachmentToCipher(cipherId, attachmentId);
|
||||
await storage.addAttachmentToCipherForUser(cipherId, attachmentId, userId);
|
||||
|
||||
// Update cipher revision date
|
||||
await storage.updateCipherRevisionDate(cipherId);
|
||||
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||
if (revisionInfo) {
|
||||
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
|
||||
}
|
||||
|
||||
// Get updated cipher for response
|
||||
const updatedCipher = await storage.getCipher(cipherId);
|
||||
const updatedCipher = await storage.getCipherForUser(cipherId, userId);
|
||||
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
|
||||
url: buildDirectUploadUrl(request, `/api/ciphers/${cipherId}/attachment/${attachmentId}`, uploadToken),
|
||||
fileUploadType: 1,
|
||||
cipherResponse: cipherToResponse(updatedCipher!, attachments),
|
||||
});
|
||||
}
|
||||
|
||||
// Maximum file size: 100MB
|
||||
const MAX_FILE_SIZE = LIMITS.attachment.maxFileSizeBytes;
|
||||
|
||||
// POST /api/ciphers/{cipherId}/attachment/{attachmentId}
|
||||
// Upload attachment file content
|
||||
export async function handleUploadAttachment(
|
||||
@@ -101,65 +248,56 @@ export async function handleUploadAttachment(
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
// Verify cipher exists and belongs to user
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
const cipher = await storage.getCipherForUser(cipherId, userId);
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
// Verify attachment exists
|
||||
const attachment = await storage.getAttachment(attachmentId);
|
||||
const attachment = await storage.getAttachmentForUser(attachmentId, userId);
|
||||
if (!attachment || attachment.cipherId !== cipherId) {
|
||||
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, cipher, 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.getCipherForUser(cipherId, claims.userId);
|
||||
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.getAttachmentForUser(attachmentId, claims.userId);
|
||||
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, cipher, attachment, cipherId);
|
||||
}
|
||||
|
||||
// GET /api/ciphers/{cipherId}/attachment/{attachmentId}
|
||||
@@ -174,16 +312,17 @@ export async function handleGetAttachment(
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
// Verify cipher exists and belongs to user
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
const cipher = await storage.getCipherForUser(cipherId, userId);
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
// Verify attachment exists
|
||||
const attachment = await storage.getAttachment(attachmentId);
|
||||
const attachment = await storage.getAttachmentForUser(attachmentId, userId);
|
||||
if (!attachment || attachment.cipherId !== cipherId) {
|
||||
return errorResponse('Attachment not found', 404);
|
||||
}
|
||||
const responseAttachment = applyCipherEmbeddedAttachmentMetadata(cipher, [attachment])[0] || attachment;
|
||||
|
||||
// Generate short-lived download token
|
||||
const token = await createFileDownloadToken(cipherId, attachmentId, env.JWT_SECRET);
|
||||
@@ -194,11 +333,70 @@ export async function handleGetAttachment(
|
||||
|
||||
return jsonResponse({
|
||||
object: 'attachment',
|
||||
id: attachment.id,
|
||||
id: responseAttachment.id,
|
||||
url: downloadUrl,
|
||||
fileName: responseAttachment.fileName,
|
||||
key: responseAttachment.key,
|
||||
size: String(Number(responseAttachment.size) || 0),
|
||||
sizeName: responseAttachment.sizeName,
|
||||
});
|
||||
}
|
||||
|
||||
// PUT /api/ciphers/{cipherId}/attachment/{attachmentId}/metadata
|
||||
// 修正旧附件的加密元数据,供官方客户端按当前 Bitwarden 契约解密。
|
||||
export async function handleUpdateAttachmentMetadata(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
cipherId: string,
|
||||
attachmentId: string
|
||||
): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
const cipher = await storage.getCipherForUser(cipherId, userId);
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
const attachment = await storage.getAttachmentForUser(attachmentId, userId);
|
||||
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);
|
||||
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
object: 'attachment',
|
||||
id: attachment.id,
|
||||
fileName: attachment.fileName,
|
||||
key: attachment.key,
|
||||
size: Number(attachment.size) || 0,
|
||||
size: String(Number(attachment.size) || 0),
|
||||
sizeName: attachment.sizeName,
|
||||
});
|
||||
}
|
||||
@@ -211,10 +409,8 @@ 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 secret = getSafeJwtSecret(env);
|
||||
if (!secret) return errorResponse('Server configuration error', 500);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const token = url.searchParams.get('token');
|
||||
@@ -224,7 +420,7 @@ export async function handlePublicDownloadAttachment(
|
||||
}
|
||||
|
||||
// Verify token
|
||||
const claims = await verifyFileDownloadToken(token, env.JWT_SECRET);
|
||||
const claims = await verifyFileDownloadToken(token, secret);
|
||||
if (!claims) {
|
||||
return errorResponse('Invalid or expired token', 401);
|
||||
}
|
||||
@@ -242,24 +438,24 @@ 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);
|
||||
|
||||
if (!object) {
|
||||
return errorResponse('Attachment file not found', 404);
|
||||
}
|
||||
|
||||
const path = getAttachmentObjectKey(cipherId, attachmentId);
|
||||
const firstUse = await storage.consumeAttachmentDownloadToken(claims.jti, claims.exp);
|
||||
if (!firstUse) {
|
||||
return errorResponse('Invalid or expired token', 401);
|
||||
}
|
||||
|
||||
const object = await getBlobObject(env, path);
|
||||
if (!object) {
|
||||
return errorResponse('Attachment file not found', 404);
|
||||
}
|
||||
|
||||
return new Response(object.body, {
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Type': sanitizeDownloadContentType(object.contentType),
|
||||
'Content-Length': String(object.size),
|
||||
'Content-Disposition': contentDispositionAttachment(attachment.fileName),
|
||||
'Cache-Control': 'private, no-cache',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -276,36 +472,45 @@ export async function handleDeleteAttachment(
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
// Verify cipher exists and belongs to user
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
const cipher = await storage.getCipherForUser(cipherId, userId);
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
// Verify attachment exists
|
||||
const attachment = await storage.getAttachment(attachmentId);
|
||||
const attachment = await storage.getAttachmentForUser(attachmentId, userId);
|
||||
if (!attachment || attachment.cipherId !== cipherId) {
|
||||
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);
|
||||
await storage.deleteAttachmentForUser(attachmentId, userId);
|
||||
|
||||
// Update cipher revision date
|
||||
await storage.updateCipherRevisionDate(cipherId);
|
||||
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||
if (revisionInfo) {
|
||||
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
|
||||
await writeAttachmentAudit(storage, request, revisionInfo.userId, 'attachment.delete', {
|
||||
id: attachmentId,
|
||||
cipherId,
|
||||
size: attachment.size,
|
||||
});
|
||||
}
|
||||
|
||||
// Get updated cipher for response
|
||||
const updatedCipher = await storage.getCipher(cipherId);
|
||||
const updatedCipher = await storage.getCipherForUser(cipherId, userId);
|
||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||
const cipherResponse = cipherToResponse(updatedCipher!, attachments);
|
||||
|
||||
return jsonResponse({
|
||||
cipher: cipherToResponse(updatedCipher!, attachments),
|
||||
Cipher: cipherResponse,
|
||||
cipher: cipherResponse,
|
||||
Object: 'deleteAttachment',
|
||||
object: 'deleteAttachment',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -314,12 +519,24 @@ export async function deleteAllAttachmentsForCipher(
|
||||
env: Env,
|
||||
cipherId: string
|
||||
): Promise<void> {
|
||||
await deleteAllAttachmentsForCiphers(env, [cipherId]);
|
||||
}
|
||||
|
||||
export async function deleteAllAttachmentsForCiphers(
|
||||
env: Env,
|
||||
cipherIds: string[]
|
||||
): Promise<void> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||
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);
|
||||
});
|
||||
|
||||
for (const attachment of attachments) {
|
||||
const path = getAttachmentPath(cipherId, attachment.id);
|
||||
await env.ATTACHMENTS.delete(path);
|
||||
await storage.deleteAttachment(attachment.id);
|
||||
}
|
||||
await storage.bulkDeleteAttachmentsByIds(attachments.map(({ attachment }) => attachment.id));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import type { AuthRequestRecord, AuthRequestType, Env } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { readAuthRequestDeviceInfo, readActingDeviceIdentifier } from '../utils/device';
|
||||
import { errorResponse, jsonResponse } from '../utils/response';
|
||||
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
|
||||
import { notifyAuthRequestResponse, notifyUserAuthRequest } from '../durable/notifications-hub';
|
||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||
import { LIMITS } from '../config/limits';
|
||||
|
||||
const AUTH_REQUEST_TYPE_AUTHENTICATE_AND_UNLOCK = 0;
|
||||
const AUTH_REQUEST_TYPE_UNLOCK = 1;
|
||||
const AUTH_REQUEST_TYPE_ADMIN_APPROVAL = 2;
|
||||
|
||||
function normalizeText(value: unknown, maxLength: number): string {
|
||||
return String(value ?? '').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function isSerializedEncString(value: unknown): value is string {
|
||||
const text = String(value || '').trim();
|
||||
if (!text) return false;
|
||||
const parts = text.split('.');
|
||||
if (parts.length !== 2) return false;
|
||||
const type = Number(parts[0]);
|
||||
const bodyParts = parts[1].split('|');
|
||||
if (type === 2) return bodyParts.length === 3 && bodyParts.every(Boolean);
|
||||
if (type === 3 || type === 4) return bodyParts.length === 1 && !!bodyParts[0];
|
||||
if (type === 5 || type === 6) return bodyParts.length === 2 && bodyParts.every(Boolean);
|
||||
return false;
|
||||
}
|
||||
|
||||
function getClientIp(request: Request): string | null {
|
||||
return (
|
||||
request.headers.get('CF-Connecting-IP') ||
|
||||
request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim() ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function getCountryName(request: Request): string | null {
|
||||
return request.headers.get('CF-IPCountry') || null;
|
||||
}
|
||||
|
||||
function deviceTypeName(type: number): string {
|
||||
const names: Record<number, string> = {
|
||||
0: 'Android',
|
||||
1: 'iOS',
|
||||
2: 'Chrome Extension',
|
||||
3: 'Firefox Extension',
|
||||
4: 'Opera Extension',
|
||||
5: 'Edge Extension',
|
||||
6: 'Windows Desktop',
|
||||
7: 'macOS Desktop',
|
||||
8: 'Linux Desktop',
|
||||
9: 'Chrome',
|
||||
10: 'Firefox',
|
||||
11: 'Opera',
|
||||
12: 'Edge',
|
||||
13: 'Internet Explorer',
|
||||
14: 'Unknown Browser',
|
||||
15: 'Android',
|
||||
16: 'Windows UWP',
|
||||
17: 'Safari',
|
||||
18: 'Vivaldi',
|
||||
19: 'Vivaldi Extension',
|
||||
20: 'Safari Extension',
|
||||
21: 'SDK',
|
||||
22: 'Server',
|
||||
23: 'Windows CLI',
|
||||
24: 'macOS CLI',
|
||||
25: 'Linux CLI',
|
||||
26: 'DuckDuckGo',
|
||||
};
|
||||
return names[type] || `Device ${type}`;
|
||||
}
|
||||
|
||||
function buildOrigin(request: Request): string {
|
||||
return new URL(request.url).host;
|
||||
}
|
||||
|
||||
function toAuthRequestResponse(request: Request, authRequest: AuthRequestRecord, requestDeviceId?: string | null) {
|
||||
return {
|
||||
id: authRequest.id,
|
||||
Id: authRequest.id,
|
||||
publicKey: authRequest.publicKey,
|
||||
PublicKey: authRequest.publicKey,
|
||||
requestDeviceIdentifier: authRequest.requestDeviceIdentifier,
|
||||
RequestDeviceIdentifier: authRequest.requestDeviceIdentifier,
|
||||
requestDeviceTypeValue: authRequest.requestDeviceType,
|
||||
RequestDeviceTypeValue: authRequest.requestDeviceType,
|
||||
requestDeviceType: deviceTypeName(authRequest.requestDeviceType),
|
||||
RequestDeviceType: deviceTypeName(authRequest.requestDeviceType),
|
||||
requestIpAddress: authRequest.requestIpAddress,
|
||||
RequestIpAddress: authRequest.requestIpAddress,
|
||||
requestCountryName: authRequest.requestCountryName,
|
||||
RequestCountryName: authRequest.requestCountryName,
|
||||
key: authRequest.key,
|
||||
Key: authRequest.key,
|
||||
masterPasswordHash: null,
|
||||
MasterPasswordHash: null,
|
||||
creationDate: authRequest.creationDate,
|
||||
CreationDate: authRequest.creationDate,
|
||||
responseDate: authRequest.responseDate,
|
||||
ResponseDate: authRequest.responseDate,
|
||||
requestApproved: authRequest.approved ?? false,
|
||||
RequestApproved: authRequest.approved ?? false,
|
||||
requestDeviceId: requestDeviceId ?? null,
|
||||
RequestDeviceId: requestDeviceId ?? null,
|
||||
origin: buildOrigin(request),
|
||||
Origin: buildOrigin(request),
|
||||
object: 'auth-request',
|
||||
Object: 'auth-request',
|
||||
};
|
||||
}
|
||||
|
||||
function listResponse<T>(data: T[]) {
|
||||
return {
|
||||
data,
|
||||
Data: data,
|
||||
object: 'list',
|
||||
Object: 'list',
|
||||
continuationToken: null,
|
||||
ContinuationToken: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function readJsonBody(request: Request): Promise<Record<string, any> | null> {
|
||||
try {
|
||||
const body = await request.json();
|
||||
return body && typeof body === 'object' ? body as Record<string, any> : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function enforceAuthRequestCreateRateLimit(
|
||||
request: Request,
|
||||
env: Env,
|
||||
email: string,
|
||||
deviceIdentifier: string
|
||||
): Promise<Response | null> {
|
||||
const clientIdentifier = getClientIdentifier(request);
|
||||
if (!clientIdentifier) return errorResponse('Client IP is required', 403);
|
||||
|
||||
const rateLimit = new RateLimitService(env.DB);
|
||||
const limit = LIMITS.rateLimit.authRequestRequestsPerMinute;
|
||||
const encodedEmail = encodeURIComponent(email || 'missing');
|
||||
const encodedDevice = encodeURIComponent(deviceIdentifier || 'missing');
|
||||
const budgets = await Promise.all([
|
||||
rateLimit.consumeStrictBudget(`auth-request:ip:${clientIdentifier}`, limit),
|
||||
rateLimit.consumeStrictBudget(`auth-request:email:${encodedEmail}`, limit),
|
||||
rateLimit.consumeStrictBudget(`auth-request:device:${encodedDevice}`, limit),
|
||||
]);
|
||||
const blocked = budgets.find((budget) => !budget.allowed);
|
||||
if (!blocked) return null;
|
||||
|
||||
return errorResponse('Too many authentication requests. Try again later.', 429);
|
||||
}
|
||||
|
||||
function readBodyValue(body: Record<string, any>, names: string[]): unknown {
|
||||
for (const name of names) {
|
||||
if (body[name] !== undefined) return body[name];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isSupportedAuthRequestType(value: number): value is AuthRequestType {
|
||||
return value === AUTH_REQUEST_TYPE_AUTHENTICATE_AND_UNLOCK || value === AUTH_REQUEST_TYPE_UNLOCK || value === AUTH_REQUEST_TYPE_ADMIN_APPROVAL;
|
||||
}
|
||||
|
||||
export async function handleCreateAuthRequest(request: Request, env: Env): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
|
||||
const email = normalizeText(readBodyValue(body, ['email', 'Email']), 320).toLowerCase();
|
||||
const publicKey = normalizeText(readBodyValue(body, ['publicKey', 'PublicKey']), 8192);
|
||||
const accessCode = normalizeText(readBodyValue(body, ['accessCode', 'AccessCode']), 25);
|
||||
const requestedType = Number(readBodyValue(body, ['type', 'Type']));
|
||||
const type = Number.isFinite(requestedType) ? requestedType : AUTH_REQUEST_TYPE_AUTHENTICATE_AND_UNLOCK;
|
||||
const deviceInfo = readAuthRequestDeviceInfo(
|
||||
{
|
||||
deviceIdentifier: normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128),
|
||||
deviceName: normalizeText(readBodyValue(body, ['deviceName', 'DeviceName']), 128),
|
||||
deviceType: String(readBodyValue(body, ['deviceType', 'DeviceType']) ?? ''),
|
||||
},
|
||||
request
|
||||
);
|
||||
|
||||
if (!email || !publicKey || !accessCode || !deviceInfo.deviceIdentifier) {
|
||||
return errorResponse('Email, public key, device identifier, and access code are required.', 400);
|
||||
}
|
||||
const rateLimitResponse = await enforceAuthRequestCreateRateLimit(request, env, email, deviceInfo.deviceIdentifier);
|
||||
if (rateLimitResponse) return rateLimitResponse;
|
||||
if (!isSupportedAuthRequestType(type) || type === AUTH_REQUEST_TYPE_ADMIN_APPROVAL) {
|
||||
return errorResponse('Invalid auth request type.', 400);
|
||||
}
|
||||
|
||||
const user = await storage.getUser(email);
|
||||
if (!user || user.status !== 'active') {
|
||||
return errorResponse('User or known device not found.', 400);
|
||||
}
|
||||
|
||||
await storage.pruneExpiredAuthRequests();
|
||||
const now = new Date().toISOString();
|
||||
const authRequest: AuthRequestRecord = {
|
||||
id: generateUUID(),
|
||||
userId: user.id,
|
||||
organizationId: null,
|
||||
type,
|
||||
requestDeviceIdentifier: deviceInfo.deviceIdentifier,
|
||||
requestDeviceType: deviceInfo.deviceType,
|
||||
requestIpAddress: getClientIp(request),
|
||||
requestCountryName: getCountryName(request),
|
||||
responseDeviceIdentifier: null,
|
||||
accessCode,
|
||||
publicKey,
|
||||
key: null,
|
||||
masterPasswordHash: null,
|
||||
approved: null,
|
||||
creationDate: now,
|
||||
responseDate: null,
|
||||
authenticationDate: null,
|
||||
};
|
||||
await storage.createAuthRequest(authRequest);
|
||||
notifyUserAuthRequest(env, user.id, authRequest.id, deviceInfo.deviceIdentifier);
|
||||
return jsonResponse(toAuthRequestResponse(request, authRequest));
|
||||
}
|
||||
|
||||
export async function handleCreateAdminAuthRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
userEmail: string
|
||||
): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
|
||||
const email = normalizeText(readBodyValue(body, ['email', 'Email']), 320).toLowerCase() || userEmail.toLowerCase();
|
||||
const publicKey = normalizeText(readBodyValue(body, ['publicKey', 'PublicKey']), 8192);
|
||||
const accessCode = normalizeText(readBodyValue(body, ['accessCode', 'AccessCode']), 25);
|
||||
const requestedType = Number(readBodyValue(body, ['type', 'Type']));
|
||||
const deviceInfo = readAuthRequestDeviceInfo(
|
||||
{
|
||||
deviceIdentifier: normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128),
|
||||
deviceName: normalizeText(readBodyValue(body, ['deviceName', 'DeviceName']), 128),
|
||||
deviceType: String(readBodyValue(body, ['deviceType', 'DeviceType']) ?? ''),
|
||||
},
|
||||
request
|
||||
);
|
||||
|
||||
if (requestedType !== AUTH_REQUEST_TYPE_ADMIN_APPROVAL) {
|
||||
return errorResponse('Invalid AuthRequestType. Expected AdminApproval.', 400);
|
||||
}
|
||||
if (email !== userEmail.toLowerCase()) {
|
||||
return errorResponse('Email does not match authenticated user.', 400);
|
||||
}
|
||||
if (!publicKey || !accessCode || !deviceInfo.deviceIdentifier) {
|
||||
return errorResponse('Public key, device identifier, and access code are required.', 400);
|
||||
}
|
||||
const rateLimitResponse = await enforceAuthRequestCreateRateLimit(request, env, email, deviceInfo.deviceIdentifier);
|
||||
if (rateLimitResponse) return rateLimitResponse;
|
||||
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user || user.status !== 'active') {
|
||||
return errorResponse('User not found.', 404);
|
||||
}
|
||||
|
||||
await storage.pruneExpiredAuthRequests();
|
||||
const now = new Date().toISOString();
|
||||
const authRequest: AuthRequestRecord = {
|
||||
id: generateUUID(),
|
||||
userId: user.id,
|
||||
organizationId: null,
|
||||
type: AUTH_REQUEST_TYPE_ADMIN_APPROVAL,
|
||||
requestDeviceIdentifier: deviceInfo.deviceIdentifier,
|
||||
requestDeviceType: deviceInfo.deviceType,
|
||||
requestIpAddress: getClientIp(request),
|
||||
requestCountryName: getCountryName(request),
|
||||
responseDeviceIdentifier: null,
|
||||
accessCode,
|
||||
publicKey,
|
||||
key: null,
|
||||
masterPasswordHash: null,
|
||||
approved: null,
|
||||
creationDate: now,
|
||||
responseDate: null,
|
||||
authenticationDate: null,
|
||||
};
|
||||
await storage.createAuthRequest(authRequest);
|
||||
notifyUserAuthRequest(env, user.id, authRequest.id, deviceInfo.deviceIdentifier);
|
||||
return jsonResponse(toAuthRequestResponse(request, authRequest));
|
||||
}
|
||||
|
||||
export async function handleGetAuthRequest(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const authRequest = await storage.getAuthRequestByIdForUser(id, userId);
|
||||
if (!authRequest || authRequest.userId !== userId) return errorResponse('Not found', 404);
|
||||
return jsonResponse(toAuthRequestResponse(request, authRequest));
|
||||
}
|
||||
|
||||
export async function handleGetAuthRequestResponse(request: Request, env: Env, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const url = new URL(request.url);
|
||||
const accessCode = normalizeText(url.searchParams.get('code'), 25);
|
||||
const authRequest = await storage.getAuthRequestById(id);
|
||||
if (!authRequest || authRequest.accessCode !== accessCode || isAuthRequestExpired(authRequest)) {
|
||||
return errorResponse('Not found', 404);
|
||||
}
|
||||
return jsonResponse(toAuthRequestResponse(request, authRequest));
|
||||
}
|
||||
|
||||
export async function handleListAuthRequests(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const authRequests = await storage.listAuthRequestsByUserId(userId);
|
||||
return jsonResponse(listResponse(authRequests.map((authRequest) => toAuthRequestResponse(request, authRequest))));
|
||||
}
|
||||
|
||||
export async function handleListPendingAuthRequests(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
await storage.pruneExpiredAuthRequests();
|
||||
const authRequests = await storage.listPendingAuthRequestsByUserId(userId);
|
||||
const rows = await Promise.all(authRequests.map(async (authRequest) => {
|
||||
const device = await storage.getDevice(userId, authRequest.requestDeviceIdentifier);
|
||||
return toAuthRequestResponse(request, authRequest, device?.deviceIdentifier ?? authRequest.requestDeviceIdentifier);
|
||||
}));
|
||||
return jsonResponse(listResponse(rows));
|
||||
}
|
||||
|
||||
export async function handleUpdateAuthRequest(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
|
||||
const authRequest = await storage.getAuthRequestByIdForUser(id, userId);
|
||||
if (!authRequest || authRequest.userId !== userId || isAuthRequestExpired(authRequest)) {
|
||||
return errorResponse('Not found', 404);
|
||||
}
|
||||
if (authRequest.approved !== null || authRequest.responseDate || authRequest.authenticationDate) {
|
||||
return errorResponse('Auth request has already been answered.', 409);
|
||||
}
|
||||
|
||||
const latestForUser = await storage.listPendingAuthRequestsByUserId(userId);
|
||||
const latestForDevice = latestForUser.find((item) => item.requestDeviceIdentifier === authRequest.requestDeviceIdentifier);
|
||||
if (latestForDevice?.id !== authRequest.id) {
|
||||
return errorResponse('This request is no longer valid. Make sure to approve the most recent request.', 400);
|
||||
}
|
||||
|
||||
const approved = Boolean(readBodyValue(body, ['requestApproved', 'RequestApproved']));
|
||||
const key = normalizeText(readBodyValue(body, ['key', 'Key']), 20000);
|
||||
const responseDeviceIdentifier =
|
||||
normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128) ||
|
||||
readActingDeviceIdentifier(request) ||
|
||||
'web';
|
||||
|
||||
if (approved && !key) {
|
||||
return errorResponse('Encrypted key is required to approve the request.', 400);
|
||||
}
|
||||
if (approved && !isSerializedEncString(key)) {
|
||||
return errorResponse('Encrypted key is not a valid encrypted string.', 400);
|
||||
}
|
||||
|
||||
const updated = await storage.updateAuthRequestResponse(id, userId, {
|
||||
approved,
|
||||
responseDeviceIdentifier,
|
||||
key,
|
||||
masterPasswordHash: null,
|
||||
});
|
||||
if (!updated) return errorResponse('Auth request has already been answered.', 409);
|
||||
const updatedRequest = await storage.getAuthRequestByIdForUser(id, userId);
|
||||
// Match Bitwarden upstream behavior: only approval wakes the originating anonymous
|
||||
// client. Denials are not pushed to avoid leaking that a login attempt was rejected.
|
||||
if (approved) {
|
||||
await notifyAuthRequestResponse(env, userId, id);
|
||||
}
|
||||
return jsonResponse(toAuthRequestResponse(request, updatedRequest || authRequest));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+1287
-54
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,729 @@
|
||||
import type { Device, DevicePendingAuthRequest, DeviceResponse, ProtectedDeviceResponse as ProtectedDeviceWireResponse } from '../types';
|
||||
import { Env } from '../types';
|
||||
import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub';
|
||||
import { AuthService } from '../services/auth';
|
||||
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||
import { registerMobilePushDevice, unregisterMobilePushDevice } from '../services/push-relay';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { errorResponse, jsonResponse } from '../utils/response';
|
||||
import { readAuthRequestDeviceInfo, readKnownDeviceProbe } from '../utils/device';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
|
||||
const PERMANENT_TRUST_EXPIRES_AT_MS = Date.UTC(2099, 11, 31, 23, 59, 59);
|
||||
|
||||
function normalizeIdentifier(value: string | null | undefined): string {
|
||||
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,
|
||||
LastActivityDate: device.lastSeenAt,
|
||||
lastActivityDate: device.lastSeenAt,
|
||||
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);
|
||||
}
|
||||
|
||||
function parseDeviceType(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.floor(value));
|
||||
const parsed = Number.parseInt(String(value ?? ''), 10);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
||||
}
|
||||
|
||||
// POST /api/devices
|
||||
export async function handleRegisterDevice(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
|
||||
const identifier = normalizeIdentifier(body.identifier ?? body.Identifier ?? body.deviceIdentifier ?? body.DeviceIdentifier);
|
||||
const name = parseDeviceName(body.name ?? body.Name ?? body.deviceName ?? body.DeviceName) || 'Unknown device';
|
||||
const type = parseDeviceType(body.type ?? body.Type ?? body.deviceType ?? body.DeviceType);
|
||||
if (!identifier || type == null) return errorResponse('Device identifier and type are required', 400);
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
await storage.upsertDevice(userId, identifier, name, type, undefined, parseKeysBody(body));
|
||||
|
||||
const pushToken = String(body.pushToken ?? body.PushToken ?? '').trim();
|
||||
if (pushToken) {
|
||||
const device = await storage.getDevice(userId, identifier);
|
||||
const pushUuid = device?.pushUuid || generateUUID();
|
||||
const updated = await storage.updateDevicePushToken(userId, identifier, pushUuid, pushToken);
|
||||
if (updated) {
|
||||
await registerMobilePushDevice(env, {
|
||||
userId,
|
||||
deviceIdentifier: identifier,
|
||||
type,
|
||||
pushUuid,
|
||||
pushToken,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const device = await storage.getDevice(userId, identifier);
|
||||
if (!device) return errorResponse('Device registration failed', 500);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action: 'device.register',
|
||||
category: 'device',
|
||||
level: 'info',
|
||||
targetType: 'device',
|
||||
targetId: identifier,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
return jsonResponse(buildDeviceResponse(device));
|
||||
}
|
||||
|
||||
// POST /api/devices/lost-trust
|
||||
export async function handleReportLostTrust(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const body = await readJsonBody(request) || {};
|
||||
const deviceInfo = readAuthRequestDeviceInfo(
|
||||
{
|
||||
deviceIdentifier: String(body.identifier ?? body.Identifier ?? body.deviceIdentifier ?? body.DeviceIdentifier ?? ''),
|
||||
deviceName: String(body.name ?? body.Name ?? body.deviceName ?? body.DeviceName ?? ''),
|
||||
deviceType: String(body.type ?? body.Type ?? body.deviceType ?? body.DeviceType ?? ''),
|
||||
},
|
||||
request
|
||||
);
|
||||
if (!deviceInfo.deviceIdentifier) return errorResponse('Please provide a device identifier', 400);
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action: 'device.lost_trust',
|
||||
category: 'device',
|
||||
level: 'warn',
|
||||
targetType: 'device',
|
||||
targetId: deviceInfo.deviceIdentifier,
|
||||
metadata: {
|
||||
deviceIdentifier: deviceInfo.deviceIdentifier,
|
||||
deviceType: deviceInfo.deviceType,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
// 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,
|
||||
pushUuid: null,
|
||||
pushToken: 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);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action: 'device.trust.revoke',
|
||||
category: 'device',
|
||||
level: 'security',
|
||||
targetType: 'device',
|
||||
targetId: normalized,
|
||||
metadata: { removed, ...auditRequestMetadata(request) },
|
||||
});
|
||||
return jsonResponse({ success: true, removed });
|
||||
}
|
||||
|
||||
// POST /api/devices/authorized/:deviceIdentifier/permanent
|
||||
// Upgrades an existing active 2FA remember-token record to permanent trust.
|
||||
export async function handleTrustDevicePermanently(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
deviceIdentifier: string
|
||||
): Promise<Response> {
|
||||
void request;
|
||||
const normalized = String(deviceIdentifier || '').trim();
|
||||
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const updated = await storage.updateTrustedTwoFactorTokensExpiryByDevice(userId, normalized, PERMANENT_TRUST_EXPIRES_AT_MS);
|
||||
if (!updated) return errorResponse('Device is not currently trusted', 409);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action: 'device.trust.permanent',
|
||||
category: 'device',
|
||||
level: 'security',
|
||||
targetType: 'device',
|
||||
targetId: normalized,
|
||||
metadata: { updated, ...auditRequestMetadata(request) },
|
||||
});
|
||||
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
updated,
|
||||
trustedUntil: new Date(PERMANENT_TRUST_EXPIRES_AT_MS).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// DELETE /api/devices/:deviceIdentifier
|
||||
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);
|
||||
const device = await storage.getDevice(userId, normalized);
|
||||
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
||||
await storage.deleteRefreshTokensByDevice(userId, normalized);
|
||||
const deleted = await storage.deleteDevice(userId, normalized);
|
||||
if (deleted) {
|
||||
await unregisterMobilePushDevice(env, device?.pushUuid);
|
||||
AuthService.invalidateDeviceCache(userId, normalized);
|
||||
notifyUserLogout(env, userId, normalized);
|
||||
}
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action: 'device.delete',
|
||||
category: 'device',
|
||||
level: 'security',
|
||||
targetType: 'device',
|
||||
targetId: normalized,
|
||||
metadata: { deleted, ...auditRequestMetadata(request) },
|
||||
});
|
||||
return jsonResponse({ success: deleted });
|
||||
}
|
||||
|
||||
// PUT /api/devices/:deviceIdentifier/name
|
||||
export async function handleUpdateDeviceName(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
deviceIdentifier: string
|
||||
): Promise<Response> {
|
||||
const normalized = String(deviceIdentifier || '').trim();
|
||||
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
const name = parseDeviceName(body?.name);
|
||||
if (!name) return errorResponse('Device name is required', 400);
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const updated = await storage.updateDeviceName(userId, normalized, name);
|
||||
if (!updated) return errorResponse('Device not found', 404);
|
||||
|
||||
const device = await storage.getDevice(userId, normalized);
|
||||
if (!device) return errorResponse('Device not found', 404);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action: 'device.name.update',
|
||||
category: 'device',
|
||||
level: 'info',
|
||||
targetType: 'device',
|
||||
targetId: normalized,
|
||||
metadata: { name, ...auditRequestMetadata(request) },
|
||||
});
|
||||
return jsonResponse(buildDeviceResponse(device));
|
||||
}
|
||||
|
||||
// DELETE /api/devices
|
||||
export async function handleDeleteAllDevices(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 masterPasswordHash = '';
|
||||
try {
|
||||
const body = await request.json() as { masterPasswordHash?: string };
|
||||
masterPasswordHash = String(body?.masterPasswordHash || '').trim();
|
||||
} catch {
|
||||
masterPasswordHash = '';
|
||||
}
|
||||
if (!masterPasswordHash) {
|
||||
return errorResponse('masterPasswordHash is required', 400);
|
||||
}
|
||||
const auth = new AuthService(env);
|
||||
const passwordValid = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
|
||||
if (!passwordValid) {
|
||||
return errorResponse('Invalid password', 400);
|
||||
}
|
||||
|
||||
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);
|
||||
AuthService.invalidateUserCache(userId);
|
||||
notifyUserLogout(env, userId, null);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action: 'device.delete_all',
|
||||
category: 'device',
|
||||
level: 'security',
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
metadata: { removedTrusted, removedSessions, removedDevices, ...auditRequestMetadata(request) },
|
||||
});
|
||||
return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices });
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action: 'device.trust.revoke_batch',
|
||||
category: 'device',
|
||||
level: 'security',
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
metadata: { requested: devices.length, removed, ...auditRequestMetadata(request) },
|
||||
});
|
||||
return jsonResponse({ success: true, removed });
|
||||
}
|
||||
|
||||
// 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);
|
||||
const device = await storage.getDevice(userId, normalized);
|
||||
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
||||
await storage.deleteRefreshTokensByDevice(userId, normalized);
|
||||
const deleted = await storage.deleteDevice(userId, normalized);
|
||||
if (deleted) {
|
||||
await unregisterMobilePushDevice(env, device?.pushUuid);
|
||||
AuthService.invalidateDeviceCache(userId, normalized);
|
||||
notifyUserLogout(env, userId, normalized);
|
||||
}
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action: 'device.deactivate',
|
||||
category: 'device',
|
||||
level: 'security',
|
||||
targetType: 'device',
|
||||
targetId: normalized,
|
||||
metadata: { deleted, ...auditRequestMetadata(request) },
|
||||
});
|
||||
return jsonResponse({ success: deleted });
|
||||
}
|
||||
|
||||
// PUT /api/devices/identifier/{deviceIdentifier}/token
|
||||
// Bitwarden mobile reports APNs/FCM push token updates to this endpoint.
|
||||
export async function handleUpdateDeviceToken(
|
||||
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 pushToken = String(body?.pushToken ?? body?.PushToken ?? '').trim();
|
||||
if (!pushToken) return errorResponse('Invalid push token', 400);
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const device = await storage.getDevice(userId, normalized);
|
||||
if (!device) return errorResponse('Device not found', 404);
|
||||
|
||||
const pushUuid = device.pushUuid || generateUUID();
|
||||
const updated = await storage.updateDevicePushToken(userId, normalized, pushUuid, pushToken);
|
||||
if (updated) {
|
||||
await registerMobilePushDevice(env, {
|
||||
userId,
|
||||
deviceIdentifier: normalized,
|
||||
type: device.type,
|
||||
pushUuid,
|
||||
pushToken,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
const normalized = normalizeIdentifier(deviceIdentifier);
|
||||
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const cleared = await storage.clearDevicePushToken(userId, normalized);
|
||||
if (cleared?.pushUuid) {
|
||||
await unregisterMobilePushDevice(env, cleared.pushUuid);
|
||||
}
|
||||
|
||||
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
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
const EMPTY_FORMS_FILENAME = 'forms.v1.json';
|
||||
const EMPTY_FORMS_SCHEMA_FILENAME = 'forms.v1.schema.json';
|
||||
const EMPTY_FORMS_CID = 'sha256:189fa7c9bcf8951e65c18b5d9feacf74a5223c75e01667c4235388cbc67091fe';
|
||||
|
||||
const EMPTY_FORMS_BODY = JSON.stringify({
|
||||
schemaVersion: '1.0.0',
|
||||
hosts: {},
|
||||
});
|
||||
|
||||
const EMPTY_FORMS_SCHEMA_BODY = JSON.stringify({
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
title: 'Bitwarden Fill Assist Forms v1',
|
||||
type: 'object',
|
||||
required: ['schemaVersion', 'hosts'],
|
||||
properties: {
|
||||
schemaVersion: { type: 'string' },
|
||||
hosts: { type: 'object' },
|
||||
},
|
||||
additionalProperties: true,
|
||||
});
|
||||
|
||||
const EMPTY_MANIFEST_BODY = JSON.stringify({
|
||||
buildId: 'nodewarden-empty-fill-assist-v1',
|
||||
timestamp: '2026-07-06T00:00:00.000Z',
|
||||
gitSha: 'nodewarden',
|
||||
maps: {
|
||||
forms: {
|
||||
v1: {
|
||||
filename: EMPTY_FORMS_FILENAME,
|
||||
cid: EMPTY_FORMS_CID,
|
||||
schema: EMPTY_FORMS_SCHEMA_FILENAME,
|
||||
deprecated: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const DIGITAL_ASSET_LINK_CHECK_BODY = JSON.stringify({
|
||||
linked: false,
|
||||
maxAge: '86400s',
|
||||
debugString: 'No matching digital asset link policy is configured for this server.',
|
||||
});
|
||||
|
||||
function fillAssistJsonResponse(body: string): Response {
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeFilename(filename: string): string {
|
||||
const raw = String(filename || '').trim();
|
||||
try {
|
||||
return decodeURIComponent(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleFillAssistManifest(): Response {
|
||||
return fillAssistJsonResponse(EMPTY_MANIFEST_BODY);
|
||||
}
|
||||
|
||||
export function handleFillAssistForms(filename: string): Response {
|
||||
const normalized = normalizeFilename(filename);
|
||||
if (normalized === EMPTY_FORMS_FILENAME) {
|
||||
return fillAssistJsonResponse(EMPTY_FORMS_BODY);
|
||||
}
|
||||
if (normalized === EMPTY_FORMS_SCHEMA_FILENAME) {
|
||||
return fillAssistJsonResponse(EMPTY_FORMS_SCHEMA_BODY);
|
||||
}
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
export function handleDigitalAssetLinkCheck(): Response {
|
||||
return fillAssistJsonResponse(DIGITAL_ASSET_LINK_CHECK_BODY);
|
||||
}
|
||||
+110
-6
@@ -1,8 +1,46 @@
|
||||
import { Env, Folder, FolderResponse } from '../types';
|
||||
import {
|
||||
notifyUserFolderCreate,
|
||||
notifyUserFolderDelete,
|
||||
notifyUserFolderUpdate,
|
||||
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';
|
||||
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||
|
||||
function notifyVaultSyncForRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
revisionDate: string
|
||||
): void {
|
||||
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
}
|
||||
|
||||
async function writeFolderAudit(
|
||||
storage: StorageService,
|
||||
request: Request,
|
||||
userId: string,
|
||||
action: string,
|
||||
metadata: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action,
|
||||
category: 'data',
|
||||
level: action.includes('delete') ? 'security' : 'info',
|
||||
targetType: 'folder',
|
||||
targetId: typeof metadata.id === 'string' ? metadata.id : null,
|
||||
metadata: {
|
||||
...metadata,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Convert internal folder to API response format
|
||||
function folderToResponse(folder: Folder): FolderResponse {
|
||||
@@ -10,6 +48,7 @@ function folderToResponse(folder: Folder): FolderResponse {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
revisionDate: folder.updatedAt,
|
||||
creationDate: folder.createdAt,
|
||||
object: 'folder',
|
||||
};
|
||||
}
|
||||
@@ -41,7 +80,7 @@ export async function handleGetFolders(request: Request, env: Env, userId: strin
|
||||
// GET /api/folders/:id
|
||||
export async function handleGetFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const folder = await storage.getFolder(id);
|
||||
const folder = await storage.getFolderForUser(id, userId);
|
||||
|
||||
if (!folder || folder.userId !== userId) {
|
||||
return errorResponse('Folder not found', 404);
|
||||
@@ -75,7 +114,14 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
|
||||
};
|
||||
|
||||
await storage.saveFolder(folder);
|
||||
await storage.updateRevisionDate(userId);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyUserFolderCreate(env, {
|
||||
userId,
|
||||
folderId: folder.id,
|
||||
revisionDate,
|
||||
contextId: readActingDeviceIdentifier(request),
|
||||
});
|
||||
|
||||
return jsonResponse(folderToResponse(folder), 200);
|
||||
}
|
||||
@@ -83,7 +129,7 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
|
||||
// PUT /api/folders/:id
|
||||
export async function handleUpdateFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const folder = await storage.getFolder(id);
|
||||
const folder = await storage.getFolderForUser(id, userId);
|
||||
|
||||
if (!folder || folder.userId !== userId) {
|
||||
return errorResponse('Folder not found', 404);
|
||||
@@ -102,7 +148,14 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
|
||||
folder.updatedAt = new Date().toISOString();
|
||||
|
||||
await storage.saveFolder(folder);
|
||||
await storage.updateRevisionDate(userId);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyUserFolderUpdate(env, {
|
||||
userId,
|
||||
folderId: folder.id,
|
||||
revisionDate,
|
||||
contextId: readActingDeviceIdentifier(request),
|
||||
});
|
||||
|
||||
return jsonResponse(folderToResponse(folder));
|
||||
}
|
||||
@@ -110,7 +163,7 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
|
||||
// DELETE /api/folders/:id
|
||||
export async function handleDeleteFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const folder = await storage.getFolder(id);
|
||||
const folder = await storage.getFolderForUser(id, userId);
|
||||
|
||||
if (!folder || folder.userId !== userId) {
|
||||
return errorResponse('Folder not found', 404);
|
||||
@@ -118,7 +171,58 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str
|
||||
|
||||
await storage.clearFolderFromCiphers(userId, id);
|
||||
await storage.deleteFolder(id, userId);
|
||||
await storage.updateRevisionDate(userId);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyUserFolderDelete(env, {
|
||||
userId,
|
||||
folderId: id,
|
||||
revisionDate,
|
||||
contextId: readActingDeviceIdentifier(request),
|
||||
});
|
||||
await writeFolderAudit(storage, request, userId, 'folder.delete', {
|
||||
id,
|
||||
});
|
||||
|
||||
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 folders = (
|
||||
await Promise.all(ids.map(async (id) => {
|
||||
const folder = await storage.getFolderForUser(id, userId);
|
||||
return folder;
|
||||
}))
|
||||
).filter((folder): folder is Folder => !!folder);
|
||||
const revisionDate = await storage.bulkDeleteFolders(ids, userId);
|
||||
if (revisionDate) {
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
for (const folder of folders) {
|
||||
notifyUserFolderDelete(env, {
|
||||
userId,
|
||||
folderId: folder.id,
|
||||
revisionDate,
|
||||
contextId: readActingDeviceIdentifier(request),
|
||||
});
|
||||
}
|
||||
await writeFolderAudit(storage, request, userId, 'folder.delete.bulk', {
|
||||
count: ids.length,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
+998
-66
File diff suppressed because it is too large
Load Diff
+140
-61
@@ -1,22 +1,35 @@
|
||||
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, validateCipherEncryptedFieldsForCompatibility } 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;
|
||||
bankAccount?: any | null;
|
||||
driversLicense?: any | null;
|
||||
passport?: any | null;
|
||||
key?: string | null;
|
||||
login?: {
|
||||
uris?: Array<{ uri: string | null; match?: number | null }> | null;
|
||||
uris?: Array<{ uri: string | null; uriChecksum?: string | null; match?: number | null }> | null;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
totp?: string | null;
|
||||
autofillOnPageLoad?: boolean | null;
|
||||
uri?: string | null;
|
||||
passwordRevisionDate?: string | null;
|
||||
[key: string]: any;
|
||||
} | null;
|
||||
card?: {
|
||||
cardholderName?: string | null;
|
||||
@@ -57,6 +70,7 @@ interface CiphersImportRequest {
|
||||
password: string;
|
||||
lastUsedDate: string;
|
||||
}> | null;
|
||||
[key: string]: any;
|
||||
}>;
|
||||
folders: Array<{
|
||||
name: string;
|
||||
@@ -71,6 +85,22 @@ 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;
|
||||
}
|
||||
|
||||
function normalizeOptionalId(value: unknown): string | null {
|
||||
if (value == null) return null;
|
||||
const normalized = String(value).trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -81,6 +111,8 @@ async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[
|
||||
// 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.DB);
|
||||
const url = new URL(request.url);
|
||||
const returnCipherMap = url.searchParams.get('returnCipherMap') === '1';
|
||||
|
||||
let importData: CiphersImportRequest;
|
||||
try {
|
||||
@@ -89,9 +121,13 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const folders = importData.folders || [];
|
||||
const ciphers = importData.ciphers || [];
|
||||
const folderRelationships = importData.folderRelationships || [];
|
||||
const folders = Array.isArray(importData.folders) ? importData.folders : [];
|
||||
const ciphers = Array.isArray(importData.ciphers) ? importData.ciphers : [];
|
||||
const folderRelationships = Array.isArray(importData.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;
|
||||
@@ -101,13 +137,14 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
const folderRows: Folder[] = [];
|
||||
|
||||
for (let i = 0; i < folders.length; i++) {
|
||||
const importedFolder = folders[i] && typeof folders[i] === 'object' ? folders[i] : null;
|
||||
const folderId = generateUUID();
|
||||
folderIdMap.set(i, folderId);
|
||||
|
||||
const folder: Folder = {
|
||||
id: folderId,
|
||||
userId: userId,
|
||||
name: folders[i].name,
|
||||
name: typeof importedFolder?.name === 'string' && importedFolder.name ? importedFolder.name : 'Folder',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -130,85 +167,118 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
// Build cipher index -> folder id mapping from relationships
|
||||
const cipherFolderMap = new Map<number, string>();
|
||||
for (const rel of folderRelationships) {
|
||||
if (!rel || typeof rel !== 'object') continue;
|
||||
const folderId = folderIdMap.get(rel.value);
|
||||
if (folderId) {
|
||||
cipherFolderMap.set(rel.key, folderId);
|
||||
}
|
||||
}
|
||||
const existingFolderIds = new Set((await storage.getAllFolders(userId)).map((folder) => folder.id));
|
||||
|
||||
// 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 c = ciphers[i] && typeof ciphers[i] === 'object' ? ciphers[i] : {} as CiphersImportRequest['ciphers'][number];
|
||||
const importedFolderId = normalizeOptionalId(readAliasedImportProp<string | null>(c, ['folderId', 'FolderId']));
|
||||
const folderId = cipherFolderMap.get(i) || (importedFolderId && existingFolderIds.has(importedFolderId) ? importedFolderId : 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 sshKey = readAliasedImportProp<any | null>(c, ['sshKey', 'SshKey']);
|
||||
const bankAccount = readAliasedImportProp<any | null>(c, ['bankAccount', 'BankAccount']);
|
||||
const driversLicense = readAliasedImportProp<any | null>(c, ['driversLicense', 'DriversLicense']);
|
||||
const passport = readAliasedImportProp<any | null>(c, ['passport', 'Passport']);
|
||||
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,
|
||||
uriChecksum: 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: u.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(sshKey ?? null),
|
||||
bankAccount: bankAccount ?? null,
|
||||
driversLicense: driversLicense ?? null,
|
||||
passport: passport ?? null,
|
||||
key: key ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
archivedAt: null,
|
||||
deletedAt: null,
|
||||
};
|
||||
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||
const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher);
|
||||
if (compatibilityError) {
|
||||
return errorResponse(`Cipher ${i + 1}: ${compatibilityError}`, 400);
|
||||
}
|
||||
|
||||
cipherRows.push(cipher);
|
||||
cipherMapRows.push({ index: i, sourceId, id: cipher.id });
|
||||
}
|
||||
|
||||
if (cipherRows.length > 0) {
|
||||
@@ -216,10 +286,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
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, deleted_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'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, deleted_at=excluded.deleted_at'
|
||||
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at'
|
||||
)
|
||||
.bind(
|
||||
cipher.id,
|
||||
@@ -234,6 +304,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
bindNull(cipher.key),
|
||||
cipher.createdAt,
|
||||
cipher.updatedAt,
|
||||
bindNull(cipher.archivedAt),
|
||||
bindNull(cipher.deletedAt)
|
||||
);
|
||||
});
|
||||
@@ -241,7 +312,15 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
}
|
||||
|
||||
// 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,81 @@
|
||||
import { AuthService } from '../services/auth';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
|
||||
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));
|
||||
}
|
||||
|
||||
export async function handleAnonymousNotificationsHub(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const authRequestId = String(url.searchParams.get('Token') || url.searchParams.get('token') || '').trim();
|
||||
if (!authRequestId) return errorResponse('Token is required', 400);
|
||||
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
|
||||
return errorResponse('Expected websocket', 426);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const authRequest = await storage.getAuthRequestById(authRequestId);
|
||||
if (!authRequest || isAuthRequestExpired(authRequest)) {
|
||||
return errorResponse('Not found', 404);
|
||||
}
|
||||
|
||||
const id = env.NOTIFICATIONS_HUB.idFromName(authRequestId);
|
||||
const stub = env.NOTIFICATIONS_HUB.get(id);
|
||||
const forwardedUrl = new URL(request.url);
|
||||
forwardedUrl.searchParams.set('nw_auth_request_id', authRequestId);
|
||||
return stub.fetch(new Request(forwardedUrl.toString(), request));
|
||||
}
|
||||
@@ -0,0 +1,764 @@
|
||||
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,
|
||||
getBlobObject,
|
||||
putBlobObject,
|
||||
deleteBlobObject,
|
||||
} from '../services/blob-store';
|
||||
import { createSendFileUploadToken, verifySendFileUploadToken } from '../utils/jwt';
|
||||
import {
|
||||
formatSize,
|
||||
getAliasedProp,
|
||||
normalizeEmails,
|
||||
notifySendCreateForRequest,
|
||||
notifySendDeleteForRequest,
|
||||
notifySendUpdateForRequest,
|
||||
notifyVaultSyncForRequest,
|
||||
parseDate,
|
||||
parseFileLength,
|
||||
parseInteger,
|
||||
parseMaxAccessCount,
|
||||
parseSendAuthType,
|
||||
parseSendType,
|
||||
parseStoredSendData,
|
||||
sanitizeSendData,
|
||||
sendToResponse,
|
||||
setSendPassword,
|
||||
validateDeletionDate,
|
||||
} from './sends-shared';
|
||||
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||
|
||||
const SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE = 'Send email verification is not supported by this server.';
|
||||
|
||||
async function writeSendAudit(
|
||||
storage: StorageService,
|
||||
request: Request,
|
||||
userId: string,
|
||||
action: string,
|
||||
metadata: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action,
|
||||
category: 'data',
|
||||
level: action.includes('delete') ? 'security' : 'info',
|
||||
targetType: 'send',
|
||||
targetId: typeof metadata.id === 'string' ? metadata.id : null,
|
||||
metadata: {
|
||||
...metadata,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function processSendFileUpload(
|
||||
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;
|
||||
}
|
||||
|
||||
const path = getSendFileObjectKey(send.id, fileId);
|
||||
if (await getBlobObject(env, path)) {
|
||||
return errorResponse('Send file has already been uploaded', 409);
|
||||
}
|
||||
|
||||
try {
|
||||
await putBlobObject(env, path, 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);
|
||||
notifySendUpdateForRequest(request, env, send.id, 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.getSendForUser(sendId, userId);
|
||||
|
||||
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);
|
||||
}
|
||||
if (requestedAuthType === SendAuthType.Email) {
|
||||
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
|
||||
}
|
||||
|
||||
const normalizedEmails = normalizeEmails(emailsRaw.value);
|
||||
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
|
||||
return errorResponse('Invalid emails', 400);
|
||||
}
|
||||
if (normalizedEmails) {
|
||||
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
|
||||
}
|
||||
|
||||
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);
|
||||
notifySendCreateForRequest(request, env, send.id, 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);
|
||||
}
|
||||
if (requestedAuthType === SendAuthType.Email) {
|
||||
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
|
||||
}
|
||||
|
||||
const normalizedEmails = normalizeEmails(emailsRaw.value);
|
||||
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
|
||||
return errorResponse('Invalid emails', 400);
|
||||
}
|
||||
if (normalizedEmails) {
|
||||
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
|
||||
}
|
||||
|
||||
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);
|
||||
notifySendCreateForRequest(request, env, send.id, 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.getSendForUser(sendId, userId);
|
||||
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.getSendForUser(sendId, userId);
|
||||
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.getSendForUser(sendId, claims.userId);
|
||||
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.getSendForUser(sendId, userId);
|
||||
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);
|
||||
}
|
||||
if (parsedAuthType === SendAuthType.Email) {
|
||||
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
|
||||
}
|
||||
send.authType = parsedAuthType;
|
||||
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);
|
||||
}
|
||||
if (normalizedEmails) {
|
||||
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
|
||||
}
|
||||
send.emails = normalizedEmails;
|
||||
if (send.emails) {
|
||||
send.authType = SendAuthType.Email;
|
||||
} else if (Number(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);
|
||||
notifySendUpdateForRequest(request, env, send.id, userId, revisionDate);
|
||||
|
||||
return jsonResponse(sendToResponse(send));
|
||||
}
|
||||
|
||||
export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSendForUser(sendId, userId);
|
||||
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);
|
||||
notifySendDeleteForRequest(request, env, sendId, userId, revisionDate);
|
||||
await writeSendAudit(storage, request, userId, 'send.delete', {
|
||||
id: sendId,
|
||||
type: send.type,
|
||||
});
|
||||
|
||||
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);
|
||||
for (const send of sends) {
|
||||
notifySendDeleteForRequest(request, env, send.id, userId, revisionDate);
|
||||
}
|
||||
await writeSendAudit(storage, request, userId, 'send.delete.bulk', {
|
||||
count: sends.length,
|
||||
requestedCount: body.ids.length,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSendForUser(sendId, userId);
|
||||
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);
|
||||
notifySendUpdateForRequest(request, env, send.id, userId, revisionDate);
|
||||
await writeSendAudit(storage, request, userId, 'send.password.remove', {
|
||||
id: send.id,
|
||||
type: send.type,
|
||||
});
|
||||
|
||||
return jsonResponse(sendToResponse(send));
|
||||
}
|
||||
|
||||
export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSendForUser(sendId, userId);
|
||||
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);
|
||||
notifySendUpdateForRequest(request, env, send.id, userId, revisionDate);
|
||||
await writeSendAudit(storage, request, userId, 'send.auth.remove', {
|
||||
id: send.id,
|
||||
type: send.type,
|
||||
});
|
||||
|
||||
return jsonResponse(sendToResponse(send));
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
import { Env, SendType } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { sanitizeDownloadContentType } from '../utils/content-type';
|
||||
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,
|
||||
notifySendUpdateForRequest,
|
||||
notifyVaultSyncForRequest,
|
||||
parseStoredSendData,
|
||||
resolveSendFromIdOrAccessId,
|
||||
sendPasswordLimitKey,
|
||||
sendPasswordLockedErrorResponse,
|
||||
sendPasswordLockedOAuthResponse,
|
||||
sendToAccessResponse,
|
||||
validatePublicSendAccess,
|
||||
verifySendPassword,
|
||||
verifySendPasswordHashB64,
|
||||
} from './sends-shared';
|
||||
|
||||
function contentDispositionAttachment(fileName: string | null | undefined): string {
|
||||
const fallback = 'send-file';
|
||||
const value = String(fileName || fallback)
|
||||
.replace(/[\r\n"]/g, '_')
|
||||
.trim() || fallback;
|
||||
return `attachment; filename="${value}"`;
|
||||
}
|
||||
|
||||
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, send.id);
|
||||
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);
|
||||
notifySendUpdateForRequest(request, env, send.id, 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 safeSecret = getSafeJwtSecret(env);
|
||||
if (!safeSecret.ok) return safeSecret.response;
|
||||
const { secret } = safeSecret;
|
||||
|
||||
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, send.id);
|
||||
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);
|
||||
notifySendUpdateForRequest(request, env, send.id, 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);
|
||||
notifySendUpdateForRequest(request, env, send.id, 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);
|
||||
notifySendUpdateForRequest(request, env, send.id, 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 send = await storage.getSend(sendId);
|
||||
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 firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp);
|
||||
if (!firstUse) {
|
||||
return errorResponse('Invalid or expired token', 401);
|
||||
}
|
||||
|
||||
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId));
|
||||
if (!object) {
|
||||
return errorResponse('Send file not found', 404);
|
||||
}
|
||||
const fileName = typeof data.fileName === 'string' ? data.fileName : fileId;
|
||||
|
||||
return new Response(object.body, {
|
||||
headers: {
|
||||
'Content-Type': sanitizeDownloadContentType(object.contentType),
|
||||
'Content-Length': String(object.size),
|
||||
'Content-Disposition': contentDispositionAttachment(fileName),
|
||||
'Cache-Control': 'private, no-cache',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function issueSendAccessToken(
|
||||
env: Env,
|
||||
sendIdOrAccessId: string,
|
||||
passwordHashB64?: string | null,
|
||||
password?: string | null,
|
||||
rateLimit?: RateLimitService,
|
||||
clientIdentifier?: 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',
|
||||
},
|
||||
},
|
||||
501
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const sendPasswordLimitIpKey =
|
||||
rateLimit && clientIdentifier ? sendPasswordLimitKey(clientIdentifier, send.id) : null;
|
||||
|
||||
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,513 @@
|
||||
import { Env, Send, SendAuthType, SendResponse, SendType } from '../types';
|
||||
import {
|
||||
notifyUserSendCreate,
|
||||
notifyUserSendDelete,
|
||||
notifyUserSendUpdate,
|
||||
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 notifySendCreateForRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
sendId: string,
|
||||
userId: string,
|
||||
revisionDate: string
|
||||
): void {
|
||||
notifyUserSendCreate(env, {
|
||||
userId,
|
||||
sendId,
|
||||
revisionDate,
|
||||
contextId: readActingDeviceIdentifier(request),
|
||||
});
|
||||
}
|
||||
|
||||
export function notifySendUpdateForRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
sendId: string,
|
||||
userId: string,
|
||||
revisionDate: string
|
||||
): void {
|
||||
notifyUserSendUpdate(env, {
|
||||
userId,
|
||||
sendId,
|
||||
revisionDate,
|
||||
contextId: readActingDeviceIdentifier(request),
|
||||
});
|
||||
}
|
||||
|
||||
export function notifySendDeleteForRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
sendId: string,
|
||||
userId: string,
|
||||
revisionDate: string
|
||||
): void {
|
||||
notifyUserSendDelete(env, {
|
||||
userId,
|
||||
sendId,
|
||||
revisionDate,
|
||||
contextId: 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;
|
||||
let value = raw.trim();
|
||||
if (!/[zZ]$/.test(value) && !/[+\-]\d{2}:?\d{2}$/.test(value)) {
|
||||
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value)) {
|
||||
value += 'Z';
|
||||
} else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(value)) {
|
||||
value = value.replace(' ', 'T') + 'Z';
|
||||
}
|
||||
}
|
||||
const date = new Date(value);
|
||||
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) {
|
||||
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, sendId: string): string {
|
||||
return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}:${String(sendId || '').trim() || 'unknown-send'}`;
|
||||
}
|
||||
|
||||
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 email verification is not supported by this server.', 501),
|
||||
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,57 +0,0 @@
|
||||
import { Env, DEFAULT_DEV_SECRET } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { jsonResponse, errorResponse, htmlResponse } from '../utils/response';
|
||||
import { renderRegisterPageHTML } from '../setup/pageTemplate';
|
||||
import { LIMITS } from '../config/limits';
|
||||
|
||||
type JwtSecretState = 'missing' | 'default' | 'too_short';
|
||||
|
||||
function getJwtSecretState(env: Env): JwtSecretState | null {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
if (!secret) return 'missing';
|
||||
// Block common "forgot to change" sample value (matches .dev.vars.example)
|
||||
if (secret === DEFAULT_DEV_SECRET) return 'default';
|
||||
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleRegisterPage(request: Request, env: Env, jwtState: JwtSecretState | null): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const disabled = await storage.isSetupDisabled();
|
||||
if (disabled) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
return htmlResponse(renderRegisterPageHTML(jwtState));
|
||||
}
|
||||
|
||||
// GET / - Setup page
|
||||
export async function handleSetupPage(request: Request, env: Env): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const disabled = await storage.isSetupDisabled();
|
||||
if (disabled) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
// 引导页内会处理 JWT_SECRET 检测与分流(坏密钥停留在修复步骤)。
|
||||
const jwtState = getJwtSecretState(env);
|
||||
return handleRegisterPage(request, env, jwtState);
|
||||
}
|
||||
|
||||
// GET /setup/status
|
||||
export async function handleSetupStatus(request: Request, env: Env): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const registered = await storage.isRegistered();
|
||||
const disabled = await storage.isSetupDisabled();
|
||||
return jsonResponse({ registered, disabled });
|
||||
}
|
||||
|
||||
// POST /setup/disable
|
||||
export async function handleDisableSetup(request: Request, env: Env): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const registered = await storage.isRegistered();
|
||||
if (!registered) {
|
||||
return errorResponse('Registration required', 403);
|
||||
}
|
||||
await storage.setSetupDisabled();
|
||||
return jsonResponse({ success: true });
|
||||
}
|
||||
+114
-120
@@ -1,39 +1,47 @@
|
||||
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { errorResponse } from '../utils/response';
|
||||
import { cipherToResponse } from './ciphers';
|
||||
import { cipherToResponse, isCipherResponseSyncCompatible, shouldPreserveRepairableCipherUris } from './ciphers';
|
||||
import { sendToResponse } from './sends';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import {
|
||||
buildUserDecryptionCompat,
|
||||
buildUserDecryptionOptions,
|
||||
} from '../utils/user-decryption';
|
||||
import { buildDomainsResponse } from '../services/domain-rules';
|
||||
import { buildWebAuthnPrfOption } from '../utils/account-passkeys';
|
||||
import { buildProfileResponse } from '../utils/profile-response';
|
||||
|
||||
interface SyncCacheEntry {
|
||||
body: string;
|
||||
expiresAt: number;
|
||||
// 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,
|
||||
accountPasskeyCacheTag: string,
|
||||
excludeDomains: boolean,
|
||||
excludeSends: boolean,
|
||||
preserveRepairableUris: boolean
|
||||
): Request {
|
||||
const url = new URL(request.url);
|
||||
const cacheUrl = new URL(
|
||||
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${encodeURIComponent(accountPasskeyCacheTag)}/${excludeDomains ? '1' : '0'}/${excludeSends ? '1' : '0'}/${preserveRepairableUris ? '1' : '0'}`,
|
||||
url.origin
|
||||
);
|
||||
return new Request(cacheUrl.toString(), { method: 'GET' });
|
||||
}
|
||||
|
||||
const syncResponseCache = new Map<string, SyncCacheEntry>();
|
||||
|
||||
function buildSyncCacheKey(userId: string, revisionDate: string, excludeDomains: boolean): string {
|
||||
return `${userId}:${revisionDate}:${excludeDomains ? '1' : '0'}`;
|
||||
}
|
||||
|
||||
function readSyncCache(key: string): string | null {
|
||||
const hit = syncResponseCache.get(key);
|
||||
async function readSyncCache(cacheRequest: Request): Promise<Response | null> {
|
||||
const hit = await caches.default.match(cacheRequest);
|
||||
if (!hit) return null;
|
||||
if (hit.expiresAt <= Date.now()) {
|
||||
syncResponseCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return hit.body;
|
||||
return new Response(hit.body, hit);
|
||||
}
|
||||
|
||||
function writeSyncCache(key: string, body: string): void {
|
||||
if (syncResponseCache.size >= LIMITS.cache.syncResponseMaxEntries) {
|
||||
const oldestKey = syncResponseCache.keys().next().value as string | undefined;
|
||||
if (oldestKey) syncResponseCache.delete(oldestKey);
|
||||
}
|
||||
syncResponseCache.set(key, {
|
||||
body,
|
||||
expiresAt: Date.now() + LIMITS.cache.syncResponseTtlMs,
|
||||
});
|
||||
async function writeSyncCache(cacheRequest: Request, response: Response): Promise<void> {
|
||||
await caches.default.put(cacheRequest, response.clone());
|
||||
}
|
||||
|
||||
// GET /api/sync
|
||||
@@ -42,119 +50,105 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
const url = new URL(request.url);
|
||||
const excludeDomainsParam = url.searchParams.get('excludeDomains');
|
||||
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
||||
|
||||
const excludeSendsParam = url.searchParams.get('excludeSends');
|
||||
const excludeSends = excludeSendsParam !== null && /^(1|true|yes)$/i.test(excludeSendsParam);
|
||||
const preserveRepairableUris = shouldPreserveRepairableCipherUris(request);
|
||||
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) {
|
||||
return errorResponse('User not found', 404);
|
||||
}
|
||||
|
||||
const revisionDate = await storage.getRevisionDate(userId);
|
||||
const cacheKey = buildSyncCacheKey(userId, revisionDate, excludeDomains);
|
||||
const cachedBody = readSyncCache(cacheKey);
|
||||
if (cachedBody) {
|
||||
return new Response(cachedBody, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
const [revisionDate, accountPasskeys] = await Promise.all([
|
||||
storage.getRevisionDate(userId),
|
||||
storage.getAccountPasskeyCredentialsByUserId(userId),
|
||||
]);
|
||||
const accountPasskeyCacheTag = accountPasskeys
|
||||
.map((credential) => [
|
||||
credential.id,
|
||||
credential.updatedAt,
|
||||
credential.supportsPrf ? '1' : '0',
|
||||
credential.encryptedUserKey && credential.encryptedPublicKey && credential.encryptedPrivateKey ? '1' : '0',
|
||||
].join(':'))
|
||||
.join(',');
|
||||
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, accountPasskeyCacheTag, excludeDomains, excludeSends, preserveRepairableUris);
|
||||
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 webAuthnPrfOptions = accountPasskeys
|
||||
.map(buildWebAuthnPrfOption)
|
||||
.filter((option): option is NonNullable<typeof option> => !!option);
|
||||
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null);
|
||||
const validFolderIds = new Set(folders.map((folder) => folder.id));
|
||||
|
||||
const profile: ProfileResponse = buildProfileResponse(user, env);
|
||||
|
||||
const cipherResponses: CipherResponse[] = [];
|
||||
for (const cipher of ciphers) {
|
||||
const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], { preserveRepairableUris, validFolderIds });
|
||||
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',
|
||||
});
|
||||
}
|
||||
|
||||
const ciphers = await storage.getAllCiphers(userId);
|
||||
const folders = await storage.getAllFolders(userId);
|
||||
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId);
|
||||
|
||||
// Build profile response
|
||||
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',
|
||||
};
|
||||
|
||||
// Build cipher responses with attachments
|
||||
const cipherResponses: CipherResponse[] = [];
|
||||
for (const cipher of ciphers) {
|
||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||
cipherResponses.push(cipherToResponse(cipher, attachments));
|
||||
}
|
||||
|
||||
// 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: excludeDomains
|
||||
? null
|
||||
: {
|
||||
equivalentDomains: [],
|
||||
globalEquivalentDomains: [],
|
||||
object: 'domains',
|
||||
},
|
||||
: buildDomainsResponse(
|
||||
domainSettings?.equivalentDomains || [],
|
||||
domainSettings?.customEquivalentDomains || [],
|
||||
domainSettings?.excludedGlobalEquivalentDomains || [],
|
||||
{ omitExcludedGlobals: true }
|
||||
),
|
||||
policies: [],
|
||||
sends: [],
|
||||
// PascalCase for desktop/browser clients
|
||||
UserDecryptionOptions: {
|
||||
HasMasterPassword: true,
|
||||
Object: 'userDecryptionOptions',
|
||||
MasterPasswordUnlock: {
|
||||
Kdf: {
|
||||
KdfType: user.kdfType,
|
||||
Iterations: user.kdfIterations,
|
||||
Memory: user.kdfMemory || null,
|
||||
Parallelism: user.kdfParallelism || null,
|
||||
},
|
||||
MasterKeyEncryptedUserKey: user.key,
|
||||
MasterKeyWrappedUserKey: user.key,
|
||||
Salt: user.email.toLowerCase(),
|
||||
Object: 'masterPasswordUnlock',
|
||||
},
|
||||
},
|
||||
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
||||
userDecryption: {
|
||||
masterPasswordUnlock: {
|
||||
kdf: {
|
||||
kdfType: user.kdfType,
|
||||
iterations: user.kdfIterations,
|
||||
memory: user.kdfMemory || null,
|
||||
parallelism: user.kdfParallelism || null,
|
||||
},
|
||||
masterKeyWrappedUserKey: user.key,
|
||||
masterKeyEncryptedUserKey: user.key,
|
||||
salt: user.email.toLowerCase(),
|
||||
},
|
||||
policiesNew: [],
|
||||
sends: sendResponses,
|
||||
UserDecryption: {
|
||||
MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock,
|
||||
TrustedDeviceOption: null,
|
||||
KeyConnectorOption: null,
|
||||
WebAuthnPrfOption: webAuthnPrfOptions[0] || null,
|
||||
WebAuthnPrfOptions: webAuthnPrfOptions,
|
||||
V2UpgradeToken: null,
|
||||
Object: 'userDecryption',
|
||||
},
|
||||
UserDecryptionOptions: userDecryptionOptions,
|
||||
userDecryption: buildUserDecryptionCompat(user) as SyncResponse['userDecryption'],
|
||||
object: 'sync',
|
||||
};
|
||||
|
||||
const body = JSON.stringify(syncResponse);
|
||||
writeSyncCache(cacheKey, body);
|
||||
|
||||
return new Response(body, {
|
||||
const response = new Response(JSON.stringify(syncResponse), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': `private, max-age=${Math.max(1, Math.floor(LIMITS.cache.syncResponseTtlMs / 1000))}`,
|
||||
},
|
||||
});
|
||||
await writeSyncCache(cacheRequest, response);
|
||||
return response;
|
||||
}
|
||||
|
||||
+103
-18
@@ -1,44 +1,129 @@
|
||||
import { Env } from './types';
|
||||
import { NotificationsHub } from './durable/notifications-hub';
|
||||
import { BackupTransferRunner } from './durable/backup-transfer-runner';
|
||||
import { handleRequest } from './router';
|
||||
import { StorageService } from './services/storage';
|
||||
import { applyCors, jsonResponse } from './utils/response';
|
||||
import { runScheduledBackupIfDue } from './handlers/backup';
|
||||
import {
|
||||
isBackendRequestPath,
|
||||
isWebVaultHidden,
|
||||
webVaultNotFoundResponse,
|
||||
} from './web-vault-visibility';
|
||||
|
||||
// Per-isolate flags. Each Worker isolate may have its own copy of these flags.
|
||||
// initializeDatabase() only validates schema presence, so retries are cheap.
|
||||
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 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 (isBackendRequestPath(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> {
|
||||
// Auto-initialize database on first request
|
||||
if (!dbInitialized) {
|
||||
try {
|
||||
const storage = new StorageService(env.DB);
|
||||
await storage.initializeDatabase();
|
||||
dbInitialized = true;
|
||||
dbInitError = null;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize database:', error);
|
||||
dbInitError = error instanceof Error ? error.message : 'Unknown database initialization error';
|
||||
}
|
||||
void ctx;
|
||||
const normalizedRequest = normalizeRequestUrl(request);
|
||||
const requestPath = new URL(normalizedRequest.url).pathname;
|
||||
|
||||
if (isWebVaultHidden(env) && !isBackendRequestPath(requestPath)) {
|
||||
return webVaultNotFoundResponse(normalizedRequest);
|
||||
}
|
||||
|
||||
const assetResponse = await maybeServeAsset(normalizedRequest, env);
|
||||
if (assetResponse) {
|
||||
return applyCors(normalizedRequest, assetResponse, env);
|
||||
}
|
||||
|
||||
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: dbInitError,
|
||||
error_description: 'Database initialization failed. Check server logs for details.',
|
||||
ErrorModel: {
|
||||
Message: dbInitError,
|
||||
Message: 'Service temporarily unavailable',
|
||||
Object: 'error',
|
||||
},
|
||||
},
|
||||
500
|
||||
);
|
||||
return applyCors(request, resp);
|
||||
return applyCors(normalizedRequest, resp, env);
|
||||
}
|
||||
|
||||
const resp = await handleRequest(request, env);
|
||||
return applyCors(request, resp);
|
||||
const resp = await handleRequest(normalizedRequest, env);
|
||||
return applyCors(normalizedRequest, resp, env);
|
||||
},
|
||||
|
||||
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 };
|
||||
export { BackupTransferRunner };
|
||||
|
||||
@@ -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' || method === 'POST')) {
|
||||
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 === 'POST') {
|
||||
return handleDownloadAdminRemoteBackup(request, env, actorUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/remote/integrity' && method === 'POST') {
|
||||
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,93 @@
|
||||
import type { Env, User } from './types';
|
||||
import {
|
||||
handleAdminListUsers,
|
||||
handleAdminCreateInvite,
|
||||
handleAdminListInvites,
|
||||
handleAdminDeleteAllInvites,
|
||||
handleAdminDeleteInvite,
|
||||
handleAdminSetUserStatus,
|
||||
handleAdminDeleteUser,
|
||||
handleAdminListAuditLogs,
|
||||
handleAdminGetAuditLogSettings,
|
||||
handleAdminUpdateAuditLogSettings,
|
||||
handleAdminClearAuditLogs,
|
||||
} from './handlers/admin';
|
||||
import { handleAdminBackupRoute } from './router-admin-backup';
|
||||
import { errorResponse } from './utils/response';
|
||||
|
||||
function isKnownAdminPath(path: string): boolean {
|
||||
return (
|
||||
path === '/api/admin/users' ||
|
||||
path === '/api/admin/logs' ||
|
||||
path === '/api/admin/logs/settings' ||
|
||||
path === '/api/admin/invites' ||
|
||||
path.startsWith('/api/admin/backup') ||
|
||||
/^\/api\/admin\/invites\/[^/]+$/i.test(path) ||
|
||||
/^\/api\/admin\/users\/[a-f0-9-]+(?:\/status)?$/i.test(path)
|
||||
);
|
||||
}
|
||||
|
||||
function isActiveAdmin(user: User): boolean {
|
||||
return user.role === 'admin' && user.status === 'active';
|
||||
}
|
||||
|
||||
export async function handleAdminRoute(
|
||||
request: Request,
|
||||
env: Env,
|
||||
actorUser: User,
|
||||
path: string,
|
||||
method: string
|
||||
): Promise<Response | null> {
|
||||
if (!isKnownAdminPath(path)) {
|
||||
return null;
|
||||
}
|
||||
if (!isActiveAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/users' && method === 'GET') {
|
||||
return handleAdminListUsers(request, env, actorUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/logs' && method === 'GET') {
|
||||
return handleAdminListAuditLogs(request, env, actorUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/logs' && method === 'DELETE') {
|
||||
return handleAdminClearAuditLogs(request, env, actorUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/logs/settings') {
|
||||
if (method === 'GET') return handleAdminGetAuditLogSettings(request, env, actorUser);
|
||||
if (method === 'PUT' || method === 'POST') return handleAdminUpdateAuditLogSettings(request, env, actorUser);
|
||||
return null;
|
||||
}
|
||||
|
||||
const adminBackupResponse = await handleAdminBackupRoute(request, env, actorUser, path, method);
|
||||
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 handleAdminDeleteInvite(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,480 @@
|
||||
import type { Env, User } from './types';
|
||||
import { errorResponse, jsonResponse, unsupportedResponse } from './utils/response';
|
||||
import {
|
||||
handleGetProfile,
|
||||
handleUpdateProfile,
|
||||
handleGetKeys,
|
||||
handleSetKeys,
|
||||
handleGetRevisionDate,
|
||||
handleVerifyPassword,
|
||||
handleChangePassword,
|
||||
handleSetVerifyDevices,
|
||||
handleGetTotpStatus,
|
||||
handleSetTotpStatus,
|
||||
handleGetTotpRecoveryCode,
|
||||
handleGetTwoFactorProviders,
|
||||
handleGetTwoFactorAuthenticator,
|
||||
handlePutTwoFactorAuthenticator,
|
||||
handleGetTwoFactorYubiKey,
|
||||
handlePutTwoFactorYubiKey,
|
||||
handlePutTwoFactorYubiKeyConfig,
|
||||
handleBootstrapTwoFactorYubiKeyConfig,
|
||||
handleGetDeviceVerificationSettings,
|
||||
handlePutDeviceVerificationSettings,
|
||||
handleDisableTwoFactorProvider,
|
||||
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';
|
||||
import {
|
||||
handleCreateAccountPasskeyCredential,
|
||||
handleDeleteAccountPasskeyCredential,
|
||||
handleDeleteTwoFactorWebAuthn,
|
||||
handleGetAccountPasskeyAttestationOptions,
|
||||
handleGetAccountPasskeyCredentials,
|
||||
handleGetAccountPasskeyUpdateAssertionOptions,
|
||||
handleGetTwoFactorWebAuthn,
|
||||
handleGetTwoFactorWebAuthnChallenge,
|
||||
handlePutTwoFactorWebAuthn,
|
||||
handleUpdateAccountPasskeyEncryption,
|
||||
} from './handlers/account-passkeys';
|
||||
import {
|
||||
handleCreateAdminAuthRequest,
|
||||
handleGetAuthRequest,
|
||||
handleListAuthRequests,
|
||||
handleListPendingAuthRequests,
|
||||
handleUpdateAuthRequest,
|
||||
} from './handlers/auth-requests';
|
||||
|
||||
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/kdf' || path === '/accounts/kdf') && (method === 'POST' || method === 'PUT')) {
|
||||
return unsupportedResponse('KDF changes are not supported by this server.');
|
||||
}
|
||||
|
||||
const mailBackedAccountPaths = new Set([
|
||||
'/api/accounts/email-token',
|
||||
'/accounts/email-token',
|
||||
'/api/accounts/verify-email',
|
||||
'/accounts/verify-email',
|
||||
'/api/accounts/verify-email-token',
|
||||
'/accounts/verify-email-token',
|
||||
'/api/accounts/request-otp',
|
||||
'/accounts/request-otp',
|
||||
'/api/accounts/verify-otp',
|
||||
'/accounts/verify-otp',
|
||||
]);
|
||||
if (mailBackedAccountPaths.has(path) && (method === 'POST' || method === 'PUT')) {
|
||||
return unsupportedResponse('Email delivery is not supported by this server.');
|
||||
}
|
||||
|
||||
const emailTwoFactorPaths = new Set([
|
||||
'/api/two-factor/get-email',
|
||||
'/two-factor/get-email',
|
||||
'/api/two-factor/send-email',
|
||||
'/two-factor/send-email',
|
||||
'/api/two-factor/send-email-login',
|
||||
'/two-factor/send-email-login',
|
||||
'/api/two-factor/email',
|
||||
'/two-factor/email',
|
||||
]);
|
||||
if (emailTwoFactorPaths.has(path) && (method === 'POST' || method === 'PUT' || method === 'DELETE')) {
|
||||
return unsupportedResponse('Email two-step login is not supported by this server.');
|
||||
}
|
||||
|
||||
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') {
|
||||
if (method === 'GET') return handleGetKeys(request, env, userId);
|
||||
if (method === 'POST') return handleSetKeys(request, env, userId);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
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/two-factor') {
|
||||
if (method === 'GET') return handleGetTwoFactorProviders(request, env, userId);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if (path === '/api/two-factor/get-authenticator' && method === 'POST') {
|
||||
return handleGetTwoFactorAuthenticator(request, env, userId);
|
||||
}
|
||||
|
||||
if ((path === '/api/two-factor/get-yubikey' || path === '/api/two-factor/get-yubi-key') && method === 'POST') {
|
||||
return handleGetTwoFactorYubiKey(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/two-factor/get-device-verification-settings' && method === 'POST') {
|
||||
return handleGetDeviceVerificationSettings(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/two-factor/device-verification-settings') {
|
||||
if (method === 'PUT' || method === 'POST') return handlePutDeviceVerificationSettings(request, env, userId);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if (path === '/api/two-factor/get-webauthn' && method === 'POST') {
|
||||
return handleGetTwoFactorWebAuthn(request, env, userId, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/two-factor/get-webauthn-challenge' && method === 'POST') {
|
||||
return handleGetTwoFactorWebAuthnChallenge(request, env, userId, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/two-factor/authenticator') {
|
||||
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorAuthenticator(request, env, userId);
|
||||
if (method === 'DELETE') return handleDisableTwoFactorProvider(request, env, userId);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if ((path === '/api/two-factor/yubikey' || path === '/api/two-factor/yubi-key')) {
|
||||
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorYubiKey(request, env, userId);
|
||||
if (method === 'DELETE') return handleDisableTwoFactorProvider(request, env, userId);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if (path === '/api/two-factor/webauthn') {
|
||||
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorWebAuthn(request, env, userId, currentUser);
|
||||
if (method === 'DELETE') return handleDeleteTwoFactorWebAuthn(request, env, userId, currentUser);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if ((path === '/api/two-factor/yubikey/config' || path === '/api/two-factor/yubi-key/config') && (method === 'PUT' || method === 'POST')) {
|
||||
return handlePutTwoFactorYubiKeyConfig(request, env, userId);
|
||||
}
|
||||
|
||||
if ((path === '/api/two-factor/yubikey/bootstrap' || path === '/api/two-factor/yubi-key/bootstrap') && method === 'POST') {
|
||||
return handleBootstrapTwoFactorYubiKeyConfig(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/two-factor/disable' && (method === 'PUT' || method === 'POST')) {
|
||||
return handleDisableTwoFactorProvider(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/webauthn' || path === '/webauthn') {
|
||||
if (method === 'GET') return handleGetAccountPasskeyCredentials(request, env, userId);
|
||||
if (method === 'POST') return handleCreateAccountPasskeyCredential(request, env, userId);
|
||||
if (method === 'PUT') return handleUpdateAccountPasskeyEncryption(request, env, userId);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if ((path === '/api/webauthn/attestation-options' || path === '/webauthn/attestation-options') && method === 'POST') {
|
||||
return handleGetAccountPasskeyAttestationOptions(request, env, userId, currentUser);
|
||||
}
|
||||
|
||||
if ((path === '/api/webauthn/assertion-options' || path === '/webauthn/assertion-options') && method === 'POST') {
|
||||
return handleGetAccountPasskeyUpdateAssertionOptions(request, env, userId, currentUser);
|
||||
}
|
||||
|
||||
const accountPasskeyDeleteMatch =
|
||||
path.match(/^\/api\/webauthn\/([^/]+)\/delete$/i) ||
|
||||
path.match(/^\/webauthn\/([^/]+)\/delete$/i);
|
||||
if (accountPasskeyDeleteMatch && method === 'POST') {
|
||||
return handleDeleteAccountPasskeyCredential(request, env, userId, accountPasskeyDeleteMatch[1], currentUser);
|
||||
}
|
||||
|
||||
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 === '/api/auth-requests' || path === '/api/auth-requests/' || path === '/auth-requests' || path === '/auth-requests/') {
|
||||
if (method === 'GET') return handleListAuthRequests(request, env, userId);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if (path === '/api/auth-requests/pending' || path === '/auth-requests/pending') {
|
||||
if (method === 'GET') return handleListPendingAuthRequests(request, env, userId);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if (path === '/api/auth-requests/admin-request' || path === '/auth-requests/admin-request') {
|
||||
if (method === 'POST') return handleCreateAdminAuthRequest(request, env, userId, currentUser.email);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
const authRequestMatch = path.match(/^\/(?:api\/)?auth-requests\/([a-f0-9-]+)$/i);
|
||||
if (authRequestMatch) {
|
||||
if (method === 'GET') return handleGetAuthRequest(request, env, userId, authRequestMatch[1]);
|
||||
if (method === 'PUT') return handleUpdateAuthRequest(request, env, userId, authRequestMatch[1]);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
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,132 @@
|
||||
import type { Env } from './types';
|
||||
import {
|
||||
handleGetAuthorizedDevices,
|
||||
handleGetDevice,
|
||||
handleGetDevices,
|
||||
handleGetDeviceByIdentifier,
|
||||
handleUpdateDeviceKeys,
|
||||
handleUpdateDeviceTrust,
|
||||
handleUntrustDevices,
|
||||
handleRetrieveDeviceKeys,
|
||||
handleDeactivateDevice,
|
||||
handleRevokeAllTrustedDevices,
|
||||
handleRevokeTrustedDevice,
|
||||
handleTrustDevicePermanently,
|
||||
handleDeleteAllDevices,
|
||||
handleDeleteDevice,
|
||||
handleUpdateDeviceName,
|
||||
handleUpdateDeviceToken,
|
||||
handleUpdateDeviceWebPushAuth,
|
||||
handleClearDeviceToken,
|
||||
handleRegisterDevice,
|
||||
handleReportLostTrust,
|
||||
} from './handlers/devices';
|
||||
|
||||
function devicesPath(pattern: string): RegExp {
|
||||
return new RegExp(`^/(?:api/)?devices${pattern}$`, 'i');
|
||||
}
|
||||
|
||||
export async function handleAuthenticatedDeviceRoute(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
path: string,
|
||||
method: string
|
||||
): Promise<Response | null> {
|
||||
if (path === '/api/devices' || path === '/devices') {
|
||||
if (method === 'GET') return handleGetDevices(request, env, userId);
|
||||
if (method === 'POST') return handleRegisterDevice(request, env, userId);
|
||||
if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((path === '/api/devices/lost-trust' || path === '/devices/lost-trust') && method === 'POST') {
|
||||
return handleReportLostTrust(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/devices/authorized' || path === '/devices/authorized') {
|
||||
if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId);
|
||||
if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const authorizedDeviceMatch = path.match(devicesPath('/authorized/([^/]+)'));
|
||||
if (authorizedDeviceMatch && method === 'DELETE') {
|
||||
const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]);
|
||||
return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const permanentAuthorizedDeviceMatch = path.match(devicesPath('/authorized/([^/]+)/permanent'));
|
||||
if (permanentAuthorizedDeviceMatch && method === 'POST') {
|
||||
const deviceIdentifier = decodeURIComponent(permanentAuthorizedDeviceMatch[1]);
|
||||
return handleTrustDevicePermanently(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const deleteDeviceMatch = path.match(devicesPath('/([^/]+)'));
|
||||
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(devicesPath('/([^/]+)/name'));
|
||||
if (updateDeviceNameMatch && method === 'PUT') {
|
||||
const deviceIdentifier = decodeURIComponent(updateDeviceNameMatch[1]);
|
||||
return handleUpdateDeviceName(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const identifierMatch = path.match(devicesPath('/identifier/([^/]+)'));
|
||||
if (identifierMatch && method === 'GET') {
|
||||
const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
|
||||
return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const deviceKeysMatch = path.match(devicesPath('/([^/]+)/keys')) || path.match(devicesPath('/identifier/([^/]+)/keys'));
|
||||
if (deviceKeysMatch && (method === 'PUT' || method === 'POST')) {
|
||||
const deviceIdentifier = decodeURIComponent(deviceKeysMatch[1]);
|
||||
return handleUpdateDeviceKeys(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const identifierTokenMatch = path.match(devicesPath('/identifier/([^/]+)/token'));
|
||||
if (identifierTokenMatch && (method === 'PUT' || method === 'POST')) {
|
||||
const deviceIdentifier = decodeURIComponent(identifierTokenMatch[1]);
|
||||
return handleUpdateDeviceToken(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const identifierWebPushMatch = path.match(devicesPath('/identifier/([^/]+)/web-push-auth'));
|
||||
if (identifierWebPushMatch && (method === 'PUT' || method === 'POST')) {
|
||||
const deviceIdentifier = decodeURIComponent(identifierWebPushMatch[1]);
|
||||
return handleUpdateDeviceWebPushAuth(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const identifierClearTokenMatch = path.match(devicesPath('/identifier/([^/]+)/clear-token'));
|
||||
if (identifierClearTokenMatch && (method === 'PUT' || method === 'POST')) {
|
||||
const deviceIdentifier = decodeURIComponent(identifierClearTokenMatch[1]);
|
||||
return handleClearDeviceToken(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const identifierRetrieveKeysMatch = path.match(devicesPath('/([^/]+)/retrieve-keys'));
|
||||
if (identifierRetrieveKeysMatch && method === 'POST') {
|
||||
const deviceIdentifier = decodeURIComponent(identifierRetrieveKeysMatch[1]);
|
||||
return handleRetrieveDeviceKeys(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const identifierDeactivateMatch = path.match(devicesPath('/([^/]+)/deactivate'));
|
||||
if (identifierDeactivateMatch && (method === 'POST' || method === 'DELETE')) {
|
||||
const deviceIdentifier = decodeURIComponent(identifierDeactivateMatch[1]);
|
||||
return handleDeactivateDevice(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
if ((path === '/api/devices/update-trust' || path === '/devices/update-trust') && method === 'POST') {
|
||||
return handleUpdateDeviceTrust(request, env, userId);
|
||||
}
|
||||
|
||||
if ((path === '/api/devices/untrust' || path === '/devices/untrust') && method === 'POST') {
|
||||
return handleUntrustDevices(request, env, userId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
import { LIMITS } from './config/limits';
|
||||
import {
|
||||
handleAccessSend,
|
||||
handleAccessSendFile,
|
||||
handleAccessSendV2,
|
||||
handleAccessSendFileV2,
|
||||
handleDownloadSendFile,
|
||||
} from './handlers/sends';
|
||||
import { handleKnownDevice } from './handlers/devices';
|
||||
import {
|
||||
handleDigitalAssetLinkCheck,
|
||||
handleFillAssistForms,
|
||||
handleFillAssistManifest,
|
||||
} from './handlers/fill-assist';
|
||||
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
|
||||
import { handleGetAccountPasskeyAssertionOptions } from './handlers/account-passkeys';
|
||||
import {
|
||||
handleRegister,
|
||||
handleGetPasswordHint,
|
||||
handleRecoverTwoFactor,
|
||||
} from './handlers/accounts';
|
||||
import {
|
||||
handleCreateAuthRequest,
|
||||
handleGetAuthRequestResponse,
|
||||
} from './handlers/auth-requests';
|
||||
import { handlePublicDownloadAttachment } from './handlers/attachments';
|
||||
import { handlePublicUploadAttachment } from './handlers/attachments';
|
||||
import {
|
||||
handleAnonymousNotificationsHub,
|
||||
handleNotificationsHub,
|
||||
handleNotificationsNegotiate,
|
||||
} from './handlers/notifications';
|
||||
import { handlePublicUploadSendFile } from './handlers/sends';
|
||||
import { isSafeWebsiteIconContentType } from './utils/content-type';
|
||||
import { jsonResponse, unsupportedResponse } from './utils/response';
|
||||
import { StorageService } from './services/storage';
|
||||
import type { Env } from './types';
|
||||
import { getConfiguredWebAuthnAllowedOrigins } from './utils/origins';
|
||||
import { buildConfigResponse } from './config-response';
|
||||
|
||||
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
|
||||
type JwtUnsafeReason = 'missing' | 'too_short' | null;
|
||||
|
||||
export interface WebBootstrapResponse {
|
||||
defaultKdfIterations: number;
|
||||
jwtUnsafeReason: JwtUnsafeReason;
|
||||
jwtSecretMinLength: number;
|
||||
registrationInviteRequired: boolean;
|
||||
webAuthnAllowedOrigins: string[];
|
||||
websiteIconsEnabled: boolean;
|
||||
}
|
||||
|
||||
function isWebsiteIconProxyEnabled(env: Env): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
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 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 ICON_MAX_BUFFER_BYTES = 256 * 1024;
|
||||
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 getPositiveContentLength(headers: Headers): number | null {
|
||||
const raw = headers.get('Content-Length');
|
||||
if (!raw) return null;
|
||||
const value = Number(raw);
|
||||
return Number.isFinite(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
async function readIconBytes(response: Response, maxBytes: number): Promise<ArrayBuffer | null> {
|
||||
if (!response.body) return null;
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let totalBytes = 0;
|
||||
let timedOut = false;
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
void reader.cancel().catch(() => undefined);
|
||||
}, ICON_UPSTREAM_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
|
||||
totalBytes += value.byteLength;
|
||||
if (totalBytes > maxBytes) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
return null;
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
if (timedOut || totalBytes === 0) return null;
|
||||
|
||||
const output = new ArrayBuffer(totalBytes);
|
||||
const bytes = new Uint8Array(output);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
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`,
|
||||
'Content-Security-Policy': "default-src 'none'; img-src 'self' data:; sandbox",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleWebsiteIcon(env: Env, host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
|
||||
if (!isWebsiteIconProxyEnabled(env)) {
|
||||
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||
}
|
||||
|
||||
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 (!isSafeWebsiteIconContentType(contentType)) continue;
|
||||
|
||||
const contentLength = getPositiveContentLength(resp.headers);
|
||||
if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue;
|
||||
|
||||
const bytes = await readIconBytes(resp, ICON_MAX_BUFFER_BYTES);
|
||||
if (!bytes) continue;
|
||||
if (
|
||||
source.rejectImage &&
|
||||
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.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,
|
||||
webAuthnAllowedOrigins: getConfiguredWebAuthnAllowedOrigins(env),
|
||||
websiteIconsEnabled: isWebsiteIconProxyEnabled(env),
|
||||
};
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
if (path === '/fill-assist/manifest.json' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleFillAssistManifest();
|
||||
}
|
||||
|
||||
if ((path === '/v1/assetlinks:check' || path === '/api/v1/assetlinks:check') && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleDigitalAssetLinkCheck();
|
||||
}
|
||||
|
||||
const fillAssistFormsMatch = path.match(/^\/fill-assist\/([^/]+)$/i);
|
||||
if (fillAssistFormsMatch && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleFillAssistForms(fillAssistFormsMatch[1]);
|
||||
}
|
||||
|
||||
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
||||
if (iconMatch && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
|
||||
return handleWebsiteIcon(env, 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 === '/api/auth-requests' || path === '/api/auth-requests/' || path === '/auth-requests' || path === '/auth-requests/') && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleCreateAuthRequest(request, env);
|
||||
}
|
||||
|
||||
const authRequestResponseMatch = path.match(/^\/(?:api\/)?auth-requests\/([a-f0-9-]+)\/response$/i);
|
||||
if (authRequestResponseMatch && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleGetAuthRequestResponse(request, env, authRequestResponseMatch[1]);
|
||||
}
|
||||
|
||||
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/webauthn/assertion-options' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleGetAccountPasskeyAssertionOptions(request, env);
|
||||
}
|
||||
|
||||
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleRecoverTwoFactor(request, env);
|
||||
}
|
||||
|
||||
const publicMailBackedPaths = new Set([
|
||||
'/api/accounts/resend-new-device-otp',
|
||||
'/accounts/resend-new-device-otp',
|
||||
'/api/accounts/register/send-verification-email',
|
||||
'/accounts/register/send-verification-email',
|
||||
'/identity/accounts/register/send-verification-email',
|
||||
'/api/accounts/register/verification-email-clicked',
|
||||
'/accounts/register/verification-email-clicked',
|
||||
'/identity/accounts/register/verification-email-clicked',
|
||||
'/api/accounts/register/finish',
|
||||
'/accounts/register/finish',
|
||||
'/identity/accounts/register/finish',
|
||||
'/api/accounts/verify-email-token',
|
||||
'/accounts/verify-email-token',
|
||||
'/api/two-factor/send-email-login',
|
||||
'/two-factor/send-email-login',
|
||||
]);
|
||||
if (publicMailBackedPaths.has(path) && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return unsupportedResponse('Email delivery is not supported by this server.');
|
||||
}
|
||||
|
||||
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), 200, { 'Cache-Control': 'no-store' });
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
if (path === '/notifications/anonymous-hub' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleAnonymousNotificationsHub(request, env);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+173
-510
@@ -1,553 +1,216 @@
|
||||
import { Env, DEFAULT_DEV_SECRET } from './types';
|
||||
import { 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' | 'too_short' | null {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
if (!secret) return 'missing';
|
||||
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
|
||||
return null;
|
||||
}
|
||||
|
||||
// Account handlers
|
||||
import { handleRegister, handleGetProfile, handleUpdateProfile, handleSetKeys, handleGetRevisionDate, handleVerifyPassword } from './handlers/accounts';
|
||||
|
||||
// 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';
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Require browser-origin evidence for setup/register write operations.
|
||||
function canServeWithUnsafeJwtSecret(path: string, method: string): boolean {
|
||||
if (method === 'OPTIONS') return true;
|
||||
if (method === 'GET' && (path === '/api/web-bootstrap' || path === '/web-bootstrap')) return true;
|
||||
if (method === 'GET' && (path === '/config' || path === '/api/config' || path === '/api/version')) return true;
|
||||
if (method === 'GET' && path === '/.well-known/appspecific/com.chrome.devtools.json') return true;
|
||||
if (method === 'GET' && path === '/fill-assist/manifest.json') return true;
|
||||
if (method === 'GET' && /^\/fill-assist\/[^/]+$/i.test(path)) return true;
|
||||
if (method === 'GET' && (path === '/v1/assetlinks:check' || path === '/api/v1/assetlinks:check')) return true;
|
||||
if (method === 'GET' && /^\/icons\/[^/]+\/icon\.png$/i.test(path)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function getNwIconSvg(): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="NW icon"><rect x="4" y="4" width="88" height="88" rx="20" fill="#111418"/><text x="48" y="60" text-anchor="middle" font-size="36" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-weight="800" letter-spacing="0.5" fill="#FFFFFF">NW</text></svg>`;
|
||||
}
|
||||
function isImportBypassRequest(request: Request, path: string, method: string): boolean {
|
||||
if (request.headers.get('X-NodeWarden-Import') !== '1') return false;
|
||||
|
||||
function handleNwFavicon(): Response {
|
||||
return new Response(getNwIconSvg(), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/svg+xml; charset=utf-8',
|
||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isValidIconHostname(hostname: string): boolean {
|
||||
if (!hostname) return false;
|
||||
if (hostname.length > 253) return false;
|
||||
|
||||
const normalized = hostname.toLowerCase().replace(/\.$/, '');
|
||||
// Slightly relaxed domain validation:
|
||||
// - keep strict label boundaries (no leading/trailing hyphen)
|
||||
// - allow punycode TLD (e.g. xn--...)
|
||||
const domainPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59})$/;
|
||||
const ipv4Pattern = /^(?:\d{1,3}\.){3}\d{1,3}$/;
|
||||
|
||||
if (domainPattern.test(normalized)) return true;
|
||||
if (!ipv4Pattern.test(normalized)) return false;
|
||||
|
||||
const parts = normalized.split('.');
|
||||
return parts.every(p => {
|
||||
const n = Number(p);
|
||||
return Number.isInteger(n) && n >= 0 && n <= 255;
|
||||
});
|
||||
}
|
||||
|
||||
// Icons handler - proxy to Bitwarden's official icon service
|
||||
async function handleGetIcon(request: Request, env: Env, hostname: string): Promise<Response> {
|
||||
try {
|
||||
void env;
|
||||
const normalizedHostname = hostname.toLowerCase();
|
||||
if (!isValidIconHostname(normalizedHostname)) {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
const cache = caches.default;
|
||||
const cacheKey = new Request(`https://nodewarden-icons.local/icons/${normalizedHostname}/icon.png`, { method: 'GET' });
|
||||
const cached = await cache.match(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Use Bitwarden's official icon service
|
||||
const iconUrl = `https://icons.bitwarden.net/${normalizedHostname}/icon.png`;
|
||||
const resp = await fetch(iconUrl, {
|
||||
headers: { 'User-Agent': 'NodeWarden/1.0' },
|
||||
redirect: 'follow',
|
||||
cf: {
|
||||
cacheEverything: true,
|
||||
cacheTtl: LIMITS.cache.iconTtlSeconds,
|
||||
},
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
const body = await resp.arrayBuffer();
|
||||
const iconResponse = new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
|
||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`, // 7 days
|
||||
},
|
||||
});
|
||||
await cache.put(cacheKey, iconResponse.clone());
|
||||
return iconResponse;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const BODY_LIMIT_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
|
||||
function isLargeUploadPath(path: string): boolean {
|
||||
return (
|
||||
/^\/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'
|
||||
);
|
||||
}
|
||||
|
||||
async function enforceRequestBodyLimit(
|
||||
request: Request,
|
||||
path: string,
|
||||
method: string
|
||||
): Promise<Request | Response> {
|
||||
if (!BODY_LIMIT_METHODS.has(method) || isLargeUploadPath(path) || !request.body) {
|
||||
return request;
|
||||
}
|
||||
|
||||
const contentLengthRaw = request.headers.get('Content-Length');
|
||||
if (contentLengthRaw) {
|
||||
const contentLength = Number(contentLengthRaw);
|
||||
if (Number.isFinite(contentLength) && contentLength > LIMITS.request.maxBodyBytes) {
|
||||
return errorResponse('Request body too large', 413);
|
||||
}
|
||||
if (Number.isFinite(contentLength) && contentLength >= 0) {
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
||||
const reader = request.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
total += value.byteLength;
|
||||
if (total > LIMITS.request.maxBodyBytes) {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
// Ignore cancellation races after the oversized body is rejected.
|
||||
}
|
||||
return errorResponse('Request body too large', 413);
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
const body = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
body.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
|
||||
return new Request(request.url, {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body,
|
||||
redirect: request.redirect,
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
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' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const rateLimit = new RateLimitService(env.DB);
|
||||
const shouldUseStrictBudget = category === 'public-sensitive' || category === 'register';
|
||||
const check = shouldUseStrictBudget
|
||||
? await rateLimit.consumeStrictBudget(`${clientId}:${category}`, maxRequests)
|
||||
: await rateLimit.consumeBudget(`${clientId}:${category}`, maxRequests);
|
||||
if (check.allowed) return null;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'Too many requests',
|
||||
error_description: `Rate limit exceeded. Try again in ${check.retryAfterSeconds} seconds.`,
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': String(check.retryAfterSeconds || 60),
|
||||
'X-RateLimit-Remaining': '0',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (method === 'OPTIONS') {
|
||||
return handleCors(request);
|
||||
return handleCors(request, env);
|
||||
}
|
||||
|
||||
// Route matching
|
||||
try {
|
||||
|
||||
// Setup page (root)
|
||||
if (path === '/' && method === 'GET') {
|
||||
return handleSetupPage(request, env);
|
||||
const bodyLimitResult = await enforceRequestBodyLimit(request, path, method);
|
||||
if (bodyLimitResult instanceof Response) {
|
||||
return bodyLimitResult;
|
||||
}
|
||||
request = bodyLimitResult;
|
||||
|
||||
// Setup status
|
||||
if (path === '/setup/status' && method === 'GET') {
|
||||
return handleSetupStatus(request, env);
|
||||
}
|
||||
|
||||
// Disable setup page (one-way)
|
||||
if (path === '/setup/disable' && method === 'POST') {
|
||||
if (!isSameOriginWriteRequest(request)) {
|
||||
return errorResponse('Forbidden origin', 403);
|
||||
}
|
||||
return handleDisableSetup(request, env);
|
||||
}
|
||||
|
||||
// Browser/devtools probe endpoint
|
||||
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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Favicon
|
||||
if ((path === '/favicon.ico' || path === '/favicon.svg') && method === 'GET') {
|
||||
return handleNwFavicon();
|
||||
}
|
||||
|
||||
// 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 Strategy (Plan E) ──────────────────────────────────────
|
||||
// Bitwarden clients use this version for backwards-compatibility feature gating.
|
||||
// Confirmed version-gated features (from client source code):
|
||||
// - Individual cipher key encryption: >= 2024.2.0
|
||||
// (clients/libs/common/src/vault/services/cipher.service.ts: CIPHER_KEY_ENC_MIN_SERVER_VER)
|
||||
// (android/.../FeatureFlagManagerImpl.kt: CIPHER_KEY_ENC_MIN_SERVER_VERSION)
|
||||
// - MasterPasswordUnlockData (mobile): >= 2025.8.0
|
||||
// (documented in Vaultwarden source comments)
|
||||
// There is NO global minimum version that blocks all client functionality.
|
||||
// Keep this aligned with Vaultwarden's reported version to maintain compatibility.
|
||||
// When Vaultwarden bumps their version, update this value accordingly.
|
||||
// Vaultwarden source: src/api/core/mod.rs → fn config()
|
||||
version: LIMITS.compatibility.bitwardenServerVersion,
|
||||
gitHash: 'nodewarden',
|
||||
server: null,
|
||||
environment: {
|
||||
vault: origin,
|
||||
api: origin + '/api',
|
||||
identity: origin + '/identity',
|
||||
notifications: origin + '/notifications',
|
||||
sso: '',
|
||||
},
|
||||
// Feature flags control client behavior. Clients use server-provided values;
|
||||
// flags not listed here fall back to DefaultFeatureFlagValue (all false).
|
||||
// Only enable flags for features we actually support.
|
||||
// Reference: clients/libs/common/src/enums/feature-flag.enum.ts
|
||||
featureStates: {
|
||||
'duo-redirect': true,
|
||||
'email-verification': true,
|
||||
'unauth-ui-refresh': true,
|
||||
},
|
||||
object: 'config',
|
||||
});
|
||||
}
|
||||
|
||||
// Version endpoint (some clients probe this to validate the server)
|
||||
if (path === '/api/version' && method === 'GET') {
|
||||
return jsonResponse(LIMITS.compatibility.bitwardenServerVersion); // Always same value as /config.version
|
||||
}
|
||||
|
||||
// Registration endpoint (no auth required, but only works once)
|
||||
if (path === '/api/accounts/register' && method === 'POST') {
|
||||
if (!isSameOriginWriteRequest(request)) {
|
||||
return errorResponse('Forbidden origin', 403);
|
||||
}
|
||||
return handleRegister(request, env);
|
||||
}
|
||||
|
||||
// If JWT_SECRET is not safely configured, block any other endpoints.
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
|
||||
const secretIssue = jwtSecretUnsafeReason(env);
|
||||
if (secretIssue && !canServeWithUnsafeJwtSecret(path, method)) {
|
||||
return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500);
|
||||
}
|
||||
|
||||
// All other API endpoints require authentication
|
||||
const publicResponse = await handlePublicRoute(request, env, path, method, enforcePublicRateLimit);
|
||||
if (publicResponse) return publicResponse;
|
||||
|
||||
const auth = new AuthService(env);
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
const payload = await auth.verifyAccessToken(authHeader);
|
||||
|
||||
if (!payload) {
|
||||
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 });
|
||||
}
|
||||
|
||||
const userId = payload.sub;
|
||||
const clientId = getClientIdentifier(request);
|
||||
if (currentUser.status !== 'active') {
|
||||
return errorResponse('Account is disabled', 403);
|
||||
}
|
||||
|
||||
// Dedicated read rate limiting for heavy sync endpoint.
|
||||
if (path === '/api/sync' && method === 'GET') {
|
||||
if (!isImportBypassRequest(request, path, method)) {
|
||||
const rateLimit = new RateLimitService(env.DB);
|
||||
const rateLimitCheck = await rateLimit.consumeSyncReadBudget(userId + ':' + clientId + ':sync');
|
||||
|
||||
const rateLimitCheck = await rateLimit.consumeBudget(`${userId}:api`, LIMITS.rateLimit.apiRequestsPerMinute);
|
||||
if (!rateLimitCheck.allowed) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Too many requests',
|
||||
error_description: `Sync rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`,
|
||||
}), {
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': rateLimitCheck.retryAfterSeconds!.toString(),
|
||||
'X-RateLimit-Remaining': '0',
|
||||
},
|
||||
});
|
||||
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',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// API rate limiting only for write operations (keep reads frictionless)
|
||||
const isWriteMethod = method === 'POST' || method === 'PUT' || method === 'DELETE' || method === 'PATCH';
|
||||
if (isWriteMethod) {
|
||||
const rateLimit = new RateLimitService(env.DB);
|
||||
const rateLimitCheck = await rateLimit.consumeApiWriteBudget(userId + ':' + clientId + ':write');
|
||||
const authenticatedResponse = await handleAuthenticatedRoute(request, env, userId, currentUser, path, method);
|
||||
if (authenticatedResponse) return authenticatedResponse;
|
||||
|
||||
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': rateLimitCheck.retryAfterSeconds!.toString(),
|
||||
'X-RateLimit-Remaining': '0',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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('Not implemented in single-user mode', 501);
|
||||
}
|
||||
}
|
||||
|
||||
// Account endpoints
|
||||
if (path === '/api/accounts/profile') {
|
||||
if (method === 'GET') return handleGetProfile(request, env, userId);
|
||||
if (method === 'PUT') return handleUpdateProfile(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/keys' && method === 'POST') {
|
||||
return handleSetKeys(request, env, userId);
|
||||
}
|
||||
|
||||
// Revision date endpoint
|
||||
if (path === '/api/accounts/revision-date' && method === 'GET') {
|
||||
return handleGetRevisionDate(request, env, userId);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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] || '';
|
||||
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { Env } from '../types';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { StorageService } from './storage';
|
||||
|
||||
export type AuditLogCategory = 'auth' | 'security' | 'device' | 'data' | 'system';
|
||||
export type AuditLogLevel = 'info' | 'warn' | 'error' | 'security';
|
||||
|
||||
export interface AuditEventInput {
|
||||
actorUserId?: string | null;
|
||||
action: string;
|
||||
category: AuditLogCategory;
|
||||
level?: AuditLogLevel;
|
||||
targetType?: string | null;
|
||||
targetId?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const SENSITIVE_KEY_RE = /(token|secret|password|key|hash|code|private)/i;
|
||||
const MAX_METADATA_BYTES = 2048;
|
||||
const AUDIT_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||
const AUDIT_CLEANUP_PROBABILITY = 0.02;
|
||||
const AUDIT_LOG_SETTINGS_KEY = 'audit.logs.settings.v1';
|
||||
const DEFAULT_AUDIT_LOG_SETTINGS: AuditLogSettings = {
|
||||
retentionDays: 90,
|
||||
maxEntries: null,
|
||||
};
|
||||
let lastAuditCleanupAt = 0;
|
||||
|
||||
export interface AuditLogSettings {
|
||||
retentionDays: number | null;
|
||||
maxEntries: number | null;
|
||||
}
|
||||
|
||||
const ALLOWED_METADATA_KEYS = new Set([
|
||||
'method',
|
||||
'path',
|
||||
'ip',
|
||||
'userAgent',
|
||||
'email',
|
||||
'targetEmail',
|
||||
'grantType',
|
||||
'webSession',
|
||||
'deviceIdentifier',
|
||||
'deviceType',
|
||||
'reason',
|
||||
'status',
|
||||
'verifyDevices',
|
||||
'changed',
|
||||
'removed',
|
||||
'updated',
|
||||
'deleted',
|
||||
'removedTrusted',
|
||||
'removedSessions',
|
||||
'removedDevices',
|
||||
'requested',
|
||||
'count',
|
||||
'requestedCount',
|
||||
'type',
|
||||
'folderId',
|
||||
'cipherId',
|
||||
'size',
|
||||
'users',
|
||||
'ciphers',
|
||||
'attachments',
|
||||
'skippedAttachments',
|
||||
'skippedReason',
|
||||
'replaceExisting',
|
||||
'provider',
|
||||
'prfStatus',
|
||||
'fileName',
|
||||
'fileBytes',
|
||||
'bytes',
|
||||
'compressedBytes',
|
||||
'includesAttachments',
|
||||
'destinationName',
|
||||
'destinationId',
|
||||
'destinationType',
|
||||
'destinationCount',
|
||||
'scheduledDestinationCount',
|
||||
'retentionDays',
|
||||
'maxEntries',
|
||||
'remotePath',
|
||||
'trigger',
|
||||
'prunedFileCount',
|
||||
'pruneError',
|
||||
'uploadVerificationAttempts',
|
||||
'error',
|
||||
'expiresInHours',
|
||||
'checksumMismatchAccepted',
|
||||
]);
|
||||
|
||||
function normalizePositiveInteger(value: unknown, allowed: readonly number[]): number | null {
|
||||
if (value === null || value === 0 || value === '0' || value === 'forever' || value === 'unlimited') return null;
|
||||
const parsed = Math.floor(Number(value));
|
||||
return allowed.includes(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export function normalizeAuditLogSettings(value: unknown): AuditLogSettings {
|
||||
const input = value && typeof value === 'object' ? value as Record<string, unknown> : {};
|
||||
const retentionDays = normalizePositiveInteger(input.retentionDays, [7, 30, 90, 180, 365]);
|
||||
const maxEntries = normalizePositiveInteger(input.maxEntries, [1_000, 5_000, 10_000, 50_000]);
|
||||
|
||||
if (retentionDays) return { retentionDays, maxEntries: null };
|
||||
if (maxEntries) return { retentionDays: null, maxEntries };
|
||||
if (input.retentionDays === null || input.retentionDays === 0 || input.retentionDays === '0') {
|
||||
return { retentionDays: null, maxEntries: null };
|
||||
}
|
||||
if (input.maxEntries === null || input.maxEntries === 0 || input.maxEntries === '0') {
|
||||
return { retentionDays: null, maxEntries: null };
|
||||
}
|
||||
|
||||
return {
|
||||
...DEFAULT_AUDIT_LOG_SETTINGS,
|
||||
};
|
||||
}
|
||||
|
||||
export function auditRequestMetadata(request: Request): Record<string, unknown> {
|
||||
const url = new URL(request.url);
|
||||
return {
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
ip: request.headers.get('CF-Connecting-IP') || request.headers.get('X-Forwarded-For') || null,
|
||||
userAgent: request.headers.get('User-Agent') || null,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeMetadata(metadata: Record<string, unknown>): Record<string, unknown> {
|
||||
const clean: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(metadata)) {
|
||||
if (!ALLOWED_METADATA_KEYS.has(key)) continue;
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
if (SENSITIVE_KEY_RE.test(key)) continue;
|
||||
if (Array.isArray(value)) {
|
||||
clean[key] = value.length;
|
||||
continue;
|
||||
}
|
||||
if (typeof value === 'object') continue;
|
||||
clean[key] = value;
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
export async function getAuditLogSettings(storage: StorageService): Promise<AuditLogSettings> {
|
||||
const raw = await storage.getConfigValue(AUDIT_LOG_SETTINGS_KEY);
|
||||
if (!raw) return { ...DEFAULT_AUDIT_LOG_SETTINGS };
|
||||
try {
|
||||
return normalizeAuditLogSettings(JSON.parse(raw));
|
||||
} catch {
|
||||
return { ...DEFAULT_AUDIT_LOG_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveAuditLogSettings(storage: StorageService, settings: AuditLogSettings): Promise<AuditLogSettings> {
|
||||
const normalized = normalizeAuditLogSettings(settings);
|
||||
await storage.setConfigValue(AUDIT_LOG_SETTINGS_KEY, JSON.stringify(normalized));
|
||||
await applyAuditLogRetention(storage, normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function applyAuditLogRetention(storage: StorageService, settings?: AuditLogSettings): Promise<void> {
|
||||
const current = settings || await getAuditLogSettings(storage);
|
||||
if (current.retentionDays) {
|
||||
const before = new Date(Date.now() - current.retentionDays * 24 * 60 * 60 * 1000).toISOString();
|
||||
await storage.pruneAuditLogs(before);
|
||||
}
|
||||
if (current.maxEntries) {
|
||||
await storage.pruneAuditLogsToMax(current.maxEntries);
|
||||
}
|
||||
}
|
||||
|
||||
async function maybePruneAuditLogs(storage: StorageService): Promise<void> {
|
||||
const now = Date.now();
|
||||
if (now - lastAuditCleanupAt < AUDIT_CLEANUP_INTERVAL_MS) return;
|
||||
if (Math.random() > AUDIT_CLEANUP_PROBABILITY) return;
|
||||
lastAuditCleanupAt = now;
|
||||
await applyAuditLogRetention(storage);
|
||||
}
|
||||
|
||||
async function insertAuditEvent(storage: StorageService, event: AuditEventInput): Promise<void> {
|
||||
const metadata = sanitizeMetadata(event.metadata || {});
|
||||
let metadataJson = JSON.stringify(metadata);
|
||||
if (new TextEncoder().encode(metadataJson).byteLength > MAX_METADATA_BYTES) {
|
||||
metadataJson = JSON.stringify({ truncated: true });
|
||||
}
|
||||
|
||||
await storage.createAuditLog({
|
||||
id: generateUUID(),
|
||||
actorUserId: event.actorUserId ?? null,
|
||||
action: event.action,
|
||||
category: event.category,
|
||||
level: event.level || 'info',
|
||||
targetType: event.targetType ?? null,
|
||||
targetId: event.targetId ?? null,
|
||||
metadata: metadataJson,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
await maybePruneAuditLogs(storage);
|
||||
}
|
||||
|
||||
export async function writeAuditEvent(storage: StorageService, event: AuditEventInput): Promise<void> {
|
||||
try {
|
||||
await insertAuditEvent(storage, event);
|
||||
} catch (error) {
|
||||
console.error('audit log write failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function safeWriteAuditEvent(env: Env, event: AuditEventInput): Promise<void> {
|
||||
await writeAuditEvent(new StorageService(env.DB), event);
|
||||
}
|
||||
+259
-24
@@ -1,49 +1,218 @@
|
||||
import { Env, JWTPayload, User } from '../types';
|
||||
import { verifyJWT, createJWT, createRefreshToken } from '../utils/jwt';
|
||||
import { getRefreshTokenSlidingTtlMs, LIMITS } from '../config/limits';
|
||||
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 SERVER_HASH_PREFIX = '$s$';
|
||||
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 type RefreshAccessTokenFailureReason =
|
||||
| 'token_not_found_or_expired'
|
||||
| 'user_missing'
|
||||
| 'user_inactive'
|
||||
| 'security_stamp_mismatch'
|
||||
| 'device_missing'
|
||||
| 'device_session_mismatch';
|
||||
|
||||
export type RefreshAccessTokenResult =
|
||||
| { ok: true; accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null; expiresAt: number }
|
||||
| {
|
||||
ok: false;
|
||||
reason: RefreshAccessTokenFailureReason;
|
||||
userId?: string | null;
|
||||
deviceIdentifier?: string | null;
|
||||
};
|
||||
|
||||
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.DB);
|
||||
}
|
||||
|
||||
// Verify password hash (compare with stored hash)
|
||||
async verifyPassword(inputHash: string, storedHash: string): Promise<boolean> {
|
||||
const input = new TextEncoder().encode(inputHash);
|
||||
const stored = new TextEncoder().encode(storedHash);
|
||||
if (input.length !== stored.length) return false;
|
||||
static invalidateUserCache(userId: string): void {
|
||||
const normalizedUserId = String(userId || '').trim();
|
||||
if (!normalizedUserId) return;
|
||||
AuthService.userCache.delete(normalizedUserId);
|
||||
const prefix = `${normalizedUserId}:`;
|
||||
for (const key of AuthService.deviceCache.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
AuthService.deviceCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static invalidateDeviceCache(userId: string, deviceId: string): void {
|
||||
const normalizedUserId = String(userId || '').trim();
|
||||
const normalizedDeviceId = String(deviceId || '').trim();
|
||||
if (!normalizedUserId || !normalizedDeviceId) return;
|
||||
AuthService.deviceCache.delete(`${normalizedUserId}:${normalizedDeviceId}`);
|
||||
}
|
||||
|
||||
private readCachedUser(userId: string): User | null | undefined {
|
||||
const cached = AuthService.userCache.get(userId);
|
||||
if (!cached) return undefined;
|
||||
if (cached.expiresAt <= Date.now()) {
|
||||
AuthService.userCache.delete(userId);
|
||||
return undefined;
|
||||
}
|
||||
return cached.user;
|
||||
}
|
||||
|
||||
private writeCachedUser(userId: string, user: User | null): void {
|
||||
AuthService.userCache.set(userId, {
|
||||
user,
|
||||
expiresAt: Date.now() + AUTH_CONTEXT_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
private async getCachedUser(userId: string): Promise<User | null> {
|
||||
const cached = this.readCachedUser(userId);
|
||||
if (cached !== undefined) return cached;
|
||||
const user = await this.storage.getUserById(userId);
|
||||
this.writeCachedUser(userId, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
private async getFreshUser(userId: string): Promise<User | null> {
|
||||
const user = await this.storage.getUserById(userId);
|
||||
this.writeCachedUser(userId, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
private readCachedDevice(userId: string, deviceId: string) {
|
||||
const cacheKey = `${userId}:${deviceId}`;
|
||||
const cached = AuthService.deviceCache.get(cacheKey);
|
||||
if (!cached) return undefined;
|
||||
if (cached.expiresAt <= Date.now()) {
|
||||
AuthService.deviceCache.delete(cacheKey);
|
||||
return undefined;
|
||||
}
|
||||
return cached.device;
|
||||
}
|
||||
|
||||
private writeCachedDevice(userId: string, deviceId: string, device: Awaited<ReturnType<StorageService['getDevice']>>): void {
|
||||
const cacheKey = `${userId}:${deviceId}`;
|
||||
AuthService.deviceCache.set(cacheKey, {
|
||||
device,
|
||||
expiresAt: Date.now() + AUTH_CONTEXT_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
private async getCachedDevice(userId: string, deviceId: string) {
|
||||
const cached = this.readCachedDevice(userId, deviceId);
|
||||
if (cached !== undefined) return cached;
|
||||
const device = await this.storage.getDevice(userId, deviceId);
|
||||
this.writeCachedDevice(userId, deviceId, device);
|
||||
return device;
|
||||
}
|
||||
|
||||
private async getFreshDevice(userId: string, deviceId: string) {
|
||||
const device = await this.storage.getDevice(userId, deviceId);
|
||||
this.writeCachedDevice(userId, deviceId, device);
|
||||
return device;
|
||||
}
|
||||
|
||||
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
|
||||
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
|
||||
// Result is prefixed to distinguish server-hashed credentials from invalid legacy rows.
|
||||
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 SERVER_HASH_PREFIX + btoa(binary);
|
||||
}
|
||||
|
||||
// Verify password: new rows use server-side hashing; legacy rows store the raw client hash.
|
||||
async verifyPassword(inputHash: string, storedHash: string, email: string): Promise<boolean> {
|
||||
if (!storedHash.startsWith(SERVER_HASH_PREFIX)) {
|
||||
return this.constantTimeEquals(inputHash, storedHash);
|
||||
}
|
||||
const serverHash = await this.hashPasswordServer(inputHash, email);
|
||||
return this.constantTimeEquals(serverHash, 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 < input.length; i++) {
|
||||
diff |= input[i] ^ stored[i];
|
||||
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(
|
||||
user: User,
|
||||
device?: { identifier: string; sessionStamp: string } | null,
|
||||
clientType: string = 'other'
|
||||
): Promise<string> {
|
||||
const token = createRefreshToken();
|
||||
await this.storage.saveRefreshToken(token, userId);
|
||||
const now = Date.now();
|
||||
await this.storage.saveRefreshToken(
|
||||
token,
|
||||
user.id,
|
||||
now + getRefreshTokenSlidingTtlMs(clientType),
|
||||
device?.identifier ?? null,
|
||||
device?.sessionStamp ?? null,
|
||||
user.securityStamp,
|
||||
clientType,
|
||||
now + LIMITS.auth.refreshTokenAbsoluteTtlMs
|
||||
);
|
||||
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(' ');
|
||||
@@ -54,26 +223,92 @@ 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 refreshAccessTokenDetailed(refreshToken: string): Promise<RefreshAccessTokenResult> {
|
||||
const record = await this.storage.getRefreshTokenRecord(refreshToken);
|
||||
if (!record?.userId) return { ok: false, reason: 'token_not_found_or_expired' };
|
||||
|
||||
const user = await this.storage.getUserById(userId);
|
||||
if (!user) return null;
|
||||
const user = await this.storage.getUserById(record.userId);
|
||||
if (!user) {
|
||||
await this.storage.deleteRefreshToken(refreshToken);
|
||||
return { ok: false, reason: 'user_missing', userId: record.userId, deviceIdentifier: record.deviceIdentifier };
|
||||
}
|
||||
if (user.status !== 'active') {
|
||||
await this.storage.deleteRefreshToken(refreshToken);
|
||||
return { ok: false, reason: 'user_inactive', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
||||
}
|
||||
|
||||
const accessToken = await this.generateAccessToken(user);
|
||||
return { accessToken, user };
|
||||
if (record.securityStamp && record.securityStamp !== user.securityStamp) {
|
||||
await this.storage.deleteRefreshToken(refreshToken);
|
||||
return { ok: false, reason: 'security_stamp_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
||||
}
|
||||
if (!record.securityStamp) {
|
||||
await this.storage.bindRefreshTokenSecurityStamp(refreshToken, user.securityStamp);
|
||||
}
|
||||
|
||||
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 { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
||||
}
|
||||
if (record.deviceSessionStamp && boundDevice.sessionStamp !== record.deviceSessionStamp) {
|
||||
await this.storage.deleteRefreshToken(refreshToken);
|
||||
return { ok: false, reason: 'device_session_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
||||
}
|
||||
if (!record.deviceSessionStamp) {
|
||||
await this.storage.bindRefreshTokenDeviceStamp(refreshToken, boundDevice.sessionStamp);
|
||||
}
|
||||
device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const expiresAt = Math.min(
|
||||
now + getRefreshTokenSlidingTtlMs(record.clientType),
|
||||
record.absoluteExpiresAt || (now + LIMITS.auth.refreshTokenAbsoluteTtlMs)
|
||||
);
|
||||
const extended = await this.storage.extendRefreshTokenExpiry(refreshToken, expiresAt, now);
|
||||
if (!extended) {
|
||||
return { ok: false, reason: 'token_not_found_or_expired', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
||||
}
|
||||
const accessToken = await this.generateAccessToken(user, device);
|
||||
return { ok: true, accessToken, user, device, expiresAt };
|
||||
}
|
||||
|
||||
async refreshAccessToken(
|
||||
refreshToken: string
|
||||
): Promise<{ accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } | null> {
|
||||
const result = await this.refreshAccessTokenDetailed(refreshToken);
|
||||
return result.ok ? result : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
import { zipSync, unzipSync, type UnzipFileInfo } from 'fflate';
|
||||
import type { Env } from '../types';
|
||||
import { APP_VERSION } from '../../shared/app-version';
|
||||
import { BACKUP_SETTINGS_CONFIG_KEY } from './backup-config';
|
||||
import { YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY } from './yubico-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.
|
||||
// - Runtime authentication state (devices, sessions, auth requests, remembered
|
||||
// 2FA devices, and one-time tokens) must never enter an instance backup.
|
||||
// - 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;
|
||||
export 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;
|
||||
const MAX_BACKUP_PATH_SEGMENT_LENGTH = 128;
|
||||
|
||||
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[];
|
||||
webauthn_credentials?: 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 || key === YUBICO_BOOTSTRAP_CLAIM_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 isSafeBackupPathSegment(value: string): boolean {
|
||||
if (!value || value.length > MAX_BACKUP_PATH_SEGMENT_LENGTH) return false;
|
||||
if (value === '.' || value === '..') return false;
|
||||
return /^[A-Za-z0-9._-]+$/.test(value);
|
||||
}
|
||||
|
||||
export function isSafeBackupAttachmentBlobName(value: unknown): boolean {
|
||||
const normalized = String(value ?? '').trim();
|
||||
const parts = normalized.split('/');
|
||||
return parts.length === 2 && parts.every(isSafeBackupPathSegment);
|
||||
}
|
||||
|
||||
function isSafeBackupAttachmentEntryName(value: string): boolean {
|
||||
if (!value.startsWith('attachments/') || !value.endsWith('.bin')) return false;
|
||||
const relative = value.slice('attachments/'.length, -'.bin'.length);
|
||||
return isSafeBackupAttachmentBlobName(relative);
|
||||
}
|
||||
|
||||
function validateBackupEntryName(name: string): void {
|
||||
const normalized = String(name || '').trim();
|
||||
if (normalized !== name || !normalized) {
|
||||
throw new Error('Backup archive contains an invalid file name');
|
||||
}
|
||||
if (normalized.includes('\\') || normalized.includes('\0') || normalized.startsWith('/') || normalized.includes('//')) {
|
||||
throw new Error(`Backup archive contains an unsafe file name: ${normalized}`);
|
||||
}
|
||||
if (normalized !== 'manifest.json' && normalized !== 'db.json' && !isSafeBackupAttachmentEntryName(normalized)) {
|
||||
throw new Error(`Backup archive contains an unsupported file: ${normalized}`);
|
||||
}
|
||||
}
|
||||
|
||||
function createBackupUnzipFilter(): (file: UnzipFileInfo) => boolean {
|
||||
let entryCount = 0;
|
||||
let totalOriginalBytes = 0;
|
||||
return (file: UnzipFileInfo): boolean => {
|
||||
entryCount += 1;
|
||||
if (entryCount > MAX_BACKUP_ARCHIVE_ENTRY_COUNT) {
|
||||
throw new Error('Backup archive contains too many files');
|
||||
}
|
||||
validateBackupEntryName(file.name);
|
||||
const originalSize = Number(file.originalSize);
|
||||
if (!Number.isFinite(originalSize) || originalSize < 0) {
|
||||
throw new Error(`Backup archive contains an invalid file size: ${file.name}`);
|
||||
}
|
||||
if (file.name === 'db.json' && originalSize > MAX_BACKUP_DB_JSON_BYTES) {
|
||||
throw new Error('Backup archive database payload is too large');
|
||||
}
|
||||
totalOriginalBytes += originalSize;
|
||||
if (totalOriginalBytes > MAX_BACKUP_EXTRACTED_BYTES) {
|
||||
throw new Error('Backup archive expands beyond the current restore limit');
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
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 normalizeParsedBackupDb(value: unknown): BackupPayload['db'] {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Backup archive database payload is invalid');
|
||||
}
|
||||
const source = value as Record<string, unknown>;
|
||||
// Restore uses an explicit allowlist. Extra tables from old or modified
|
||||
// archives, especially runtime authentication state, are intentionally ignored.
|
||||
return {
|
||||
config: source.config as SqlRow[],
|
||||
users: source.users as SqlRow[],
|
||||
domain_settings: source.domain_settings as SqlRow[],
|
||||
user_revisions: source.user_revisions as SqlRow[],
|
||||
folders: source.folders as SqlRow[],
|
||||
ciphers: source.ciphers as SqlRow[],
|
||||
attachments: source.attachments as SqlRow[],
|
||||
webauthn_credentials: source.webauthn_credentials as SqlRow[] | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
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, { filter: createBackupUnzipFilter() });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('Backup archive ')) {
|
||||
throw error;
|
||||
}
|
||||
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) {
|
||||
validateBackupEntryName(entry);
|
||||
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 rawDb: unknown;
|
||||
try {
|
||||
manifest = JSON.parse(decoder.decode(manifestBytes)) as BackupManifest;
|
||||
rawDb = JSON.parse(decoder.decode(dbBytes));
|
||||
} catch {
|
||||
throw new Error('Backup archive contains invalid JSON metadata');
|
||||
}
|
||||
|
||||
if (manifest?.formatVersion !== BACKUP_FORMAT_VERSION) {
|
||||
throw new Error('Unsupported backup format version');
|
||||
}
|
||||
const db = normalizeParsedBackupDb(rawDb);
|
||||
|
||||
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 accountPasskeyRows = ensureRowArray(payload.db.webauthn_credentials || [], 'webauthn_credentials');
|
||||
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 || !isSafeBackupPathSegment(id) || !isSafeBackupPathSegment(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`);
|
||||
}
|
||||
}
|
||||
|
||||
const accountPasskeyIds = new Set<string>();
|
||||
const accountPasskeyCredentialIds = new Set<string>();
|
||||
for (const row of accountPasskeyRows) {
|
||||
const id = String(row.id || '').trim();
|
||||
const userId = String(row.user_id || '').trim();
|
||||
const purpose = row.purpose == null ? 'login' : String(row.purpose || '').trim();
|
||||
const credentialId = String(row.credential_id || '').trim();
|
||||
const publicKey = String(row.public_key || '').trim();
|
||||
if (!id || !userIds.has(userId) || !credentialId || !publicKey || (purpose !== 'login' && purpose !== 'twoFactor')) {
|
||||
throw new Error('Backup archive contains an invalid account passkey row');
|
||||
}
|
||||
if (accountPasskeyIds.has(id)) throw new Error(`Backup archive contains duplicate account passkey id: ${id}`);
|
||||
if (accountPasskeyCredentialIds.has(credentialId)) throw new Error(`Backup archive contains duplicate account passkey credential id: ${credentialId}`);
|
||||
accountPasskeyIds.add(id);
|
||||
accountPasskeyCredentialIds.add(credentialId);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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, accountPasskeyRows] = 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, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, 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'),
|
||||
queryRows(env.DB, 'SELECT id, user_id, purpose, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at 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,
|
||||
webauthn_credentials: accountPasskeyRows.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,
|
||||
webauthn_credentials: accountPasskeyRows,
|
||||
}, 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,938 @@
|
||||
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 S3BackupAddressingStyle,
|
||||
type S3BackupDestination,
|
||||
type WebDavBackupDestination,
|
||||
createBackupRandomId,
|
||||
createDefaultBackupDestinationName,
|
||||
createDefaultBackupScheduleConfig,
|
||||
createDefaultBackupSettings as createSharedDefaultBackupSettings,
|
||||
} from '../../shared/backup-schema';
|
||||
|
||||
export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1';
|
||||
const BACKUP_RUNTIME_CONFIG_KEY = 'backup.runtime.v1';
|
||||
export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5;
|
||||
export const REDACTED_BACKUP_SECRET = '********';
|
||||
const MAX_BACKUP_DESTINATIONS = 24;
|
||||
|
||||
export type {
|
||||
BackupDestinationConfig,
|
||||
BackupDestinationRecord,
|
||||
BackupDestinationType,
|
||||
BackupRuntimeState,
|
||||
BackupScheduleConfig,
|
||||
BackupSettings,
|
||||
S3BackupAddressingStyle,
|
||||
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 normalizeHostnameForPolicy(hostname: string): string {
|
||||
return hostname.trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
||||
}
|
||||
|
||||
function parseIpv4Address(hostname: string): number[] | null {
|
||||
const parts = hostname.split('.');
|
||||
if (parts.length !== 4) return null;
|
||||
const octets = parts.map((part) => {
|
||||
if (!/^\d{1,3}$/.test(part)) return -1;
|
||||
const value = Number(part);
|
||||
return Number.isInteger(value) && value >= 0 && value <= 255 ? value : -1;
|
||||
});
|
||||
return octets.every((value) => value >= 0) ? octets : null;
|
||||
}
|
||||
|
||||
function isBlockedIpv4Address(octets: number[]): boolean {
|
||||
const [a, b, c] = octets;
|
||||
return (
|
||||
a === 0 ||
|
||||
a === 10 ||
|
||||
a === 127 ||
|
||||
(a === 100 && b >= 64 && b <= 127) ||
|
||||
(a === 169 && b === 254) ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && (b === 0 || b === 168)) ||
|
||||
(a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
|
||||
(a === 203 && b === 0 && c === 113) ||
|
||||
a >= 224
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a hostname-form IPv6 literal to eight 4-digit hextets.
|
||||
* Needed so compressed forms like "::1" are not misclassified by a naive
|
||||
* "first non-empty hextet" check (which would read "1" and miss loopback).
|
||||
*/
|
||||
function expandIpv6Address(hostname: string): string[] | null {
|
||||
const normalized = hostname.trim().toLowerCase().replace(/^\[|\]$/g, '');
|
||||
if (!normalized.includes(':')) return null;
|
||||
if (normalized.includes('.')) {
|
||||
// IPv4-embedded forms are handled separately by the caller.
|
||||
return null;
|
||||
}
|
||||
if ((normalized.match(/::/g) || []).length > 1) return null;
|
||||
|
||||
const sides = normalized.split('::');
|
||||
const left = sides[0] ? sides[0].split(':').filter((part) => part.length > 0) : [];
|
||||
const right = sides.length > 1 && sides[1] ? sides[1].split(':').filter((part) => part.length > 0) : [];
|
||||
if (left.length + right.length > 8) return null;
|
||||
if (sides.length === 1 && left.length !== 8) return null;
|
||||
|
||||
const missing = 8 - left.length - right.length;
|
||||
if (sides.length > 1 && missing < 0) return null;
|
||||
const middle = sides.length > 1 ? Array.from({ length: missing }, () => '0') : [];
|
||||
const parts = [...left, ...middle, ...right];
|
||||
if (parts.length !== 8) return null;
|
||||
|
||||
const hextets: string[] = [];
|
||||
for (const part of parts) {
|
||||
if (!/^[0-9a-f]{1,4}$/i.test(part)) return null;
|
||||
hextets.push(part.padStart(4, '0'));
|
||||
}
|
||||
return hextets;
|
||||
}
|
||||
|
||||
function isBlockedIpv6Address(hostname: string): boolean {
|
||||
if (!hostname.includes(':')) return false;
|
||||
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
|
||||
// IPv4-mapped dotted form: ::ffff:127.0.0.1
|
||||
const mappedIpv4 = normalized.match(/::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i);
|
||||
if (mappedIpv4) {
|
||||
const octets = parseIpv4Address(mappedIpv4[1]);
|
||||
return !octets || isBlockedIpv4Address(octets);
|
||||
}
|
||||
|
||||
// IPv4-mapped hex form produced by some URL parsers: ::ffff:7f00:1
|
||||
const mappedHex = normalized.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
|
||||
if (mappedHex) {
|
||||
const hi = Number.parseInt(mappedHex[1], 16);
|
||||
const lo = Number.parseInt(mappedHex[2], 16);
|
||||
if (!Number.isFinite(hi) || !Number.isFinite(lo)) return true;
|
||||
const octets = [(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff];
|
||||
return isBlockedIpv4Address(octets);
|
||||
}
|
||||
|
||||
const hextets = expandIpv6Address(normalized);
|
||||
if (!hextets) return true;
|
||||
const firstHextet = Number.parseInt(hextets[0], 16);
|
||||
if (!Number.isFinite(firstHextet)) return true;
|
||||
// After expansion, loopback (::1) and unspecified (::) have first hextet 0.
|
||||
return (
|
||||
firstHextet === 0 ||
|
||||
(firstHextet & 0xfe00) === 0xfc00 ||
|
||||
(firstHextet & 0xffc0) === 0xfe80 ||
|
||||
(firstHextet & 0xff00) === 0xff00 ||
|
||||
hextets.join(':').startsWith('2001:0db8:')
|
||||
);
|
||||
}
|
||||
|
||||
function assertBackupEndpointHostAllowed(hostname: string, label: string): void {
|
||||
const normalized = normalizeHostnameForPolicy(hostname);
|
||||
if (!normalized) throw new Error(`${label} host is required`);
|
||||
if (
|
||||
normalized === 'localhost' ||
|
||||
normalized === 'localhost.localdomain' ||
|
||||
normalized.endsWith('.localhost.localdomain') ||
|
||||
normalized.endsWith('.localhost') ||
|
||||
normalized.endsWith('.local') ||
|
||||
normalized.endsWith('.home.arpa') ||
|
||||
normalized.endsWith('.internal') ||
|
||||
normalized.endsWith('.lan') ||
|
||||
normalized === 'metadata.google.internal' ||
|
||||
normalized === 'localtest.me' ||
|
||||
normalized.endsWith('.localtest.me') ||
|
||||
normalized === 'lvh.me' ||
|
||||
normalized.endsWith('.lvh.me') ||
|
||||
normalized === 'vcap.me' ||
|
||||
normalized.endsWith('.vcap.me') ||
|
||||
normalized === 'nip.io' ||
|
||||
normalized.endsWith('.nip.io') ||
|
||||
normalized === 'sslip.io' ||
|
||||
normalized.endsWith('.sslip.io') ||
|
||||
normalized === 'xip.io' ||
|
||||
normalized.endsWith('.xip.io')
|
||||
) {
|
||||
throw new Error(`${label} host is not allowed`);
|
||||
}
|
||||
const ipv4 = parseIpv4Address(normalized);
|
||||
if (ipv4 && isBlockedIpv4Address(ipv4)) {
|
||||
throw new Error(`${label} host is not allowed`);
|
||||
}
|
||||
if (isBlockedIpv6Address(normalized)) {
|
||||
throw new Error(`${label} host is not allowed`);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeBackupEndpointUrl(value: string, label: string): string {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new Error(`${label} must be a valid URL`);
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error(`${label} must start with http:// or https://`);
|
||||
}
|
||||
if (parsed.username || parsed.password) {
|
||||
throw new Error(`${label} must not include credentials`);
|
||||
}
|
||||
if (parsed.search || parsed.hash) {
|
||||
throw new Error(`${label} must not include query or fragment`);
|
||||
}
|
||||
assertBackupEndpointHostAllowed(parsed.hostname, label);
|
||||
return parsed.toString().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
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 addressingStyleRaw = asTrimmedString(source.addressingStyle);
|
||||
const addressingStyle: S3BackupAddressingStyle =
|
||||
addressingStyleRaw === 'virtual-hosted-style' ? 'virtual-hosted-style' : 'path-style';
|
||||
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');
|
||||
normalizeBackupEndpointUrl(endpoint, 'S3 endpoint');
|
||||
}
|
||||
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 ? normalizeBackupEndpointUrl(endpoint, 'S3 endpoint') : '',
|
||||
bucket,
|
||||
addressingStyle,
|
||||
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');
|
||||
normalizeBackupEndpointUrl(baseUrl, 'WebDAV server URL');
|
||||
}
|
||||
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 ? normalizeBackupEndpointUrl(baseUrl, 'WebDAV server URL') : '',
|
||||
username,
|
||||
password,
|
||||
remotePath,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDestination(
|
||||
destinationType: BackupDestinationType,
|
||||
destination: unknown,
|
||||
allowIncomplete = false
|
||||
): BackupDestinationConfig {
|
||||
if (destinationType === 's3') return normalizeS3Destination(destination, allowIncomplete);
|
||||
return normalizeWebDavDestination(destination, allowIncomplete);
|
||||
}
|
||||
|
||||
function shouldPreserveBackupSecret(value: unknown): boolean {
|
||||
if (value === undefined || value === null) return true;
|
||||
const raw = String(value);
|
||||
return raw === '' || raw === REDACTED_BACKUP_SECRET;
|
||||
}
|
||||
|
||||
function withPreservedDestinationSecret(
|
||||
destinationType: BackupDestinationType,
|
||||
inputDestination: unknown,
|
||||
previous: BackupDestinationRecord | undefined
|
||||
): unknown {
|
||||
const source = isPlainObject(inputDestination) ? { ...inputDestination } : {};
|
||||
if (destinationType === 's3') {
|
||||
const previousDestination = previous?.type === 's3' ? previous.destination as S3BackupDestination : null;
|
||||
if (shouldPreserveBackupSecret(source.secretAccessKey)) {
|
||||
source.secretAccessKey = previousDestination?.secretAccessKey || '';
|
||||
}
|
||||
} else {
|
||||
const previousDestination = previous?.type === 'webdav' ? previous.destination as WebDavBackupDestination : null;
|
||||
if (shouldPreserveBackupSecret(source.password)) {
|
||||
source.password = previousDestination?.password || '';
|
||||
}
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
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,
|
||||
withPreservedDestinationSecret(type, input.destination, previous),
|
||||
!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]));
|
||||
}
|
||||
|
||||
function stripRuntimeFromSettings(settings: BackupSettings): BackupSettings {
|
||||
return {
|
||||
destinations: settings.destinations.map((destination) => ({
|
||||
...destination,
|
||||
runtime: normalizeRuntime(null),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeRuntimeState(settings: BackupSettings): string {
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
destinations: Object.fromEntries(
|
||||
settings.destinations.map((destination) => [destination.id, normalizeRuntime(destination.runtime)])
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
async function loadBackupRuntimeStates(storage: StorageService): Promise<Map<string, BackupRuntimeState>> {
|
||||
const raw = await storage.getConfigValue(BACKUP_RUNTIME_CONFIG_KEY);
|
||||
if (!raw) return new Map();
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { destinations?: Record<string, unknown> };
|
||||
const entries = Object.entries(parsed.destinations || {})
|
||||
.filter(([id]) => !!asTrimmedString(id))
|
||||
.map(([id, runtime]) => [id, normalizeRuntime(runtime)] as const);
|
||||
return new Map(entries);
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
function mergeRuntimeStates(settings: BackupSettings, runtimes: Map<string, BackupRuntimeState>): BackupSettings {
|
||||
return {
|
||||
destinations: settings.destinations.map((destination) => ({
|
||||
...destination,
|
||||
runtime: runtimes.get(destination.id) || normalizeRuntime(destination.runtime),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
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(stripRuntimeFromSettings(settings));
|
||||
}
|
||||
|
||||
export function redactBackupSettingsSecrets(settings: BackupSettings): BackupSettings {
|
||||
return {
|
||||
destinations: settings.destinations.map((destination) => {
|
||||
if (destination.type === 's3') {
|
||||
const config = destination.destination as S3BackupDestination;
|
||||
return {
|
||||
...destination,
|
||||
destination: {
|
||||
...config,
|
||||
secretAccessKey: config.secretAccessKey ? REDACTED_BACKUP_SECRET : '',
|
||||
},
|
||||
};
|
||||
}
|
||||
const config = destination.destination as WebDavBackupDestination;
|
||||
return {
|
||||
...destination,
|
||||
destination: {
|
||||
...config,
|
||||
password: config.password ? REDACTED_BACKUP_SECRET : '',
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettings> {
|
||||
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
|
||||
const mergeRuntime = async (settings: BackupSettings): Promise<BackupSettings> => (
|
||||
mergeRuntimeStates(settings, await loadBackupRuntimeStates(storage))
|
||||
);
|
||||
if (!raw) {
|
||||
const settings = getDefaultBackupSettings(fallbackTimezone);
|
||||
await saveBackupSettings(storage, env, settings);
|
||||
return mergeRuntime(settings);
|
||||
}
|
||||
|
||||
const envelope = parseBackupSettingsEnvelope(raw);
|
||||
if (!envelope) {
|
||||
const settings = parseBackupSettings(raw, fallbackTimezone);
|
||||
await saveBackupSettings(storage, env, settings);
|
||||
return mergeRuntime(settings);
|
||||
}
|
||||
|
||||
try {
|
||||
const decrypted = await decryptBackupSettingsRuntime(raw, env);
|
||||
return mergeRuntime(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 encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
||||
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted);
|
||||
await saveBackupRuntimeStates(storage, settings);
|
||||
}
|
||||
|
||||
export async function saveBackupRuntimeStates(storage: StorageService, settings: BackupSettings): Promise<void> {
|
||||
await storage.setConfigValue(BACKUP_RUNTIME_CONFIG_KEY, serializeRuntimeState(settings));
|
||||
}
|
||||
|
||||
export async function updateBackupDestinationRuntime(
|
||||
storage: StorageService,
|
||||
destinationId: string,
|
||||
mutator: (runtime: BackupRuntimeState) => BackupRuntimeState
|
||||
): Promise<BackupRuntimeState> {
|
||||
const runtimes = await loadBackupRuntimeStates(storage);
|
||||
const current = runtimes.get(destinationId) || normalizeRuntime(null);
|
||||
const next = normalizeRuntime(mutator(current));
|
||||
runtimes.set(destinationId, next);
|
||||
await storage.setConfigValue(BACKUP_RUNTIME_CONFIG_KEY, JSON.stringify({
|
||||
version: 1,
|
||||
destinations: Object.fromEntries(runtimes.entries()),
|
||||
}));
|
||||
return next;
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
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 lastSuccessAt = destination.runtime.lastSuccessAt ? new Date(destination.runtime.lastSuccessAt) : null;
|
||||
const lastSuccessMs = lastSuccessAt && Number.isFinite(lastSuccessAt.getTime())
|
||||
? lastSuccessAt.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 (lastSuccessMs >= 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 lastSuccessAt = destination.runtime.lastSuccessAt ? new Date(destination.runtime.lastSuccessAt) : null;
|
||||
const lastSuccessMs = lastSuccessAt && Number.isFinite(lastSuccessAt.getTime())
|
||||
? lastSuccessAt.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 (lastSuccessMs >= slotStartMs) return false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,979 @@
|
||||
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 { YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY } from './yubico-config';
|
||||
import {
|
||||
type BackupManifestAttachmentBlob,
|
||||
type BackupPayload,
|
||||
isSafeBackupAttachmentBlobName,
|
||||
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.
|
||||
// - Do not import, clear, or replace runtime authentication state such as
|
||||
// devices, sessions, auth requests, or remembered 2FA device tokens.
|
||||
type SqlRow = Record<string, string | number | null>;
|
||||
type BackupTableName =
|
||||
| 'config'
|
||||
| 'users'
|
||||
| 'domain_settings'
|
||||
| 'user_revisions'
|
||||
| 'webauthn_credentials'
|
||||
| 'folders'
|
||||
| 'ciphers'
|
||||
| 'attachments';
|
||||
|
||||
const BACKUP_TABLES: BackupTableName[] = [
|
||||
'config',
|
||||
'users',
|
||||
'domain_settings',
|
||||
'user_revisions',
|
||||
'webauthn_credentials',
|
||||
'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;
|
||||
webauthnCredentials: 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 webauthn_credentials',
|
||||
'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 normalizeAccountPasskeyPurpose(value: unknown): 'login' | 'twoFactor' {
|
||||
return value == null ? 'login' : String(value).trim() === 'twoFactor' ? 'twoFactor' : 'login';
|
||||
}
|
||||
|
||||
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 || []).filter(
|
||||
(row) => String(row.key || '').trim() !== YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY
|
||||
);
|
||||
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 ?? 0,
|
||||
yubikey_nfc: row.yubikey_nfc ?? 0,
|
||||
})),
|
||||
domain_settings: cloneRows(payload.domain_settings || []),
|
||||
user_revisions: cloneRows(payload.user_revisions || []),
|
||||
webauthn_credentials: cloneRows(payload.webauthn_credentials || []).map((row) => ({
|
||||
...row,
|
||||
purpose: normalizeAccountPasskeyPurpose(row.purpose),
|
||||
})),
|
||||
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> {
|
||||
const lookup = new Map<string, BackupManifestAttachmentBlob>();
|
||||
for (const item of manifest.attachmentBlobs || []) {
|
||||
const cipherId = String(item.cipherId || '').trim();
|
||||
const attachmentId = String(item.attachmentId || '').trim();
|
||||
const blobName = String(item.blobName || '').trim();
|
||||
if (!cipherId || !attachmentId || !isSafeBackupAttachmentBlobName(blobName)) continue;
|
||||
lookup.set(`${cipherId}/${attachmentId}`, {
|
||||
...item,
|
||||
cipherId,
|
||||
attachmentId,
|
||||
blobName,
|
||||
});
|
||||
}
|
||||
return lookup;
|
||||
}
|
||||
|
||||
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', 'yubikey_key1', 'yubikey_key2', 'yubikey_key3', 'yubikey_key4', 'yubikey_key5', 'yubikey_nfc', '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('webauthn_credentials'),
|
||||
buildInsertStatements(
|
||||
db,
|
||||
tableName('webauthn_credentials'),
|
||||
['id', 'user_id', 'purpose', 'name', 'public_key', 'credential_id', 'counter', 'type', 'aa_guid', 'transports', 'encrypted_user_key', 'encrypted_public_key', 'encrypted_private_key', 'supports_prf', 'created_at', 'updated_at'],
|
||||
payload.webauthn_credentials || []
|
||||
)
|
||||
);
|
||||
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,
|
||||
webauthn_credentials: (db.webauthn_credentials || []).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,
|
||||
webauthn_credentials: (db.webauthn_credentials || []).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,
|
||||
webauthnCredentials: (db.webauthn_credentials || []).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,
|
||||
webauthn_credentials: (db.webauthn_credentials || []).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,
|
||||
webauthn_credentials: (db.webauthn_credentials || []).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,
|
||||
webauthnCredentials: (db.webauthn_credentials || []).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,253 @@
|
||||
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.
|
||||
// Historical/imported databases may not have usable admin public keys; in that
|
||||
// case portable.wraps is empty but the runtime ciphertext is still encrypted.
|
||||
//
|
||||
// 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);
|
||||
|
||||
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) {
|
||||
try {
|
||||
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),
|
||||
});
|
||||
} catch {
|
||||
// Keep runtime settings usable even if an imported admin key is malformed.
|
||||
}
|
||||
}
|
||||
|
||||
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,848 @@
|
||||
import {
|
||||
BackupDestinationRecord,
|
||||
BackupDestinationType,
|
||||
S3BackupDestination,
|
||||
WebDavBackupDestination,
|
||||
normalizeBackupEndpointUrl,
|
||||
} 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 RemoteBackupFileStat {
|
||||
provider: BackupDestinationType;
|
||||
remotePath: string;
|
||||
size: number | null;
|
||||
modifiedAt: string | null;
|
||||
}
|
||||
|
||||
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');
|
||||
normalizeBackupEndpointUrl(String(config.baseUrl || '').trim(), 'WebDAV server URL');
|
||||
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');
|
||||
normalizeBackupEndpointUrl(String(config.endpoint || '').trim(), 'S3 endpoint');
|
||||
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, 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, 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> {
|
||||
return (await statWebDavFile(config, relativePath)) !== null;
|
||||
}
|
||||
|
||||
async function statWebDavFile(config: WebDavBackupDestination, relativePath: string): Promise<RemoteBackupFileStat | null> {
|
||||
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 null;
|
||||
if (!response.ok) {
|
||||
throw new Error(`WebDAV existence check failed: ${response.status}`);
|
||||
}
|
||||
const size = Number(response.headers.get('Content-Length') || '');
|
||||
return {
|
||||
provider: 'webdav',
|
||||
remotePath: normalizeRelativePath(relativePath),
|
||||
size: Number.isFinite(size) ? size : null,
|
||||
modifiedAt: parseHttpDate(response.headers.get('Last-Modified') || ''),
|
||||
};
|
||||
}
|
||||
|
||||
function isBucketHostedS3Endpoint(endpoint: URL, bucket: string): boolean {
|
||||
const hostname = endpoint.hostname.toLowerCase();
|
||||
const bucketName = bucket.trim().toLowerCase();
|
||||
return !!bucketName && (hostname === bucketName || hostname.startsWith(`${bucketName}.`));
|
||||
}
|
||||
|
||||
function s3BucketBaseUrl(config: S3BackupDestination): URL {
|
||||
const endpoint = new URL(config.endpoint.replace(/\/+$/, ''));
|
||||
const bucket = config.bucket.trim();
|
||||
|
||||
if (config.addressingStyle === 'virtual-hosted-style') {
|
||||
if (isBucketHostedS3Endpoint(endpoint, bucket)) return endpoint;
|
||||
endpoint.hostname = `${bucket}.${endpoint.hostname}`;
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
return new URL(`${endpoint.toString().replace(/\/+$/, '')}/${encodeURIComponent(bucket)}`);
|
||||
}
|
||||
|
||||
function s3ObjectUrl(config: S3BackupDestination, objectKey: string): URL {
|
||||
return new URL(`${s3BucketBaseUrl(config).toString().replace(/\/+$/, '')}/${encodePathSegments(objectKey)}`);
|
||||
}
|
||||
|
||||
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, {
|
||||
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 = s3ObjectUrl(config, 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 rootPrefix = trimSlashes(config.rootPath);
|
||||
const items: RemoteBackupItem[] = [];
|
||||
let continuationToken = '';
|
||||
|
||||
do {
|
||||
const url = s3BucketBaseUrl(config);
|
||||
url.searchParams.set('list-type', '2');
|
||||
url.searchParams.set('delimiter', '/');
|
||||
if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
|
||||
if (continuationToken) url.searchParams.set('continuation-token', continuationToken);
|
||||
|
||||
const response = await signedS3Request(config, 'GET', url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`S3 listing failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const xml = await response.text();
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
continuationToken = extractXmlFirst(xml, 'NextContinuationToken') || '';
|
||||
} while (continuationToken);
|
||||
|
||||
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 = s3ObjectUrl(config, 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 = s3ObjectUrl(config, 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> {
|
||||
return (await statS3File(config, relativePath)) !== null;
|
||||
}
|
||||
|
||||
async function statS3File(config: S3BackupDestination, relativePath: string): Promise<RemoteBackupFileStat | null> {
|
||||
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||
const url = s3ObjectUrl(config, objectKey);
|
||||
const response = await signedS3Request(config, 'HEAD', url);
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) {
|
||||
throw new Error(`S3 existence check failed: ${response.status}`);
|
||||
}
|
||||
const size = Number(response.headers.get('Content-Length') || '');
|
||||
return {
|
||||
provider: 's3',
|
||||
remotePath: normalizeRelativePath(relativePath),
|
||||
size: Number.isFinite(size) ? size : null,
|
||||
modifiedAt: parseHttpDate(response.headers.get('Last-Modified') || ''),
|
||||
};
|
||||
}
|
||||
|
||||
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>;
|
||||
stat: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFileStat | null>;
|
||||
}
|
||||
|
||||
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>;
|
||||
stat(relativePath: string): Promise<RemoteBackupFileStat | null>;
|
||||
}
|
||||
|
||||
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),
|
||||
stat: (config, relativePath) => statWebDavFile(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),
|
||||
stat: (config, relativePath) => statS3File(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)),
|
||||
stat: async (relativePath: string) => adapter.stat(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',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import type { Env } from '../types';
|
||||
import {
|
||||
setConfigValue as saveConfigValue,
|
||||
} from './storage-config-repo';
|
||||
|
||||
const PUSH_RELAY_URI = 'https://push.bitwarden.com';
|
||||
const PUSH_IDENTITY_URI = 'https://identity.bitwarden.com';
|
||||
const INSTALLATIONS_URI = 'https://api.bitwarden.com/installations';
|
||||
const PUSH_INSTALLATION_ID_KEY = 'push.installation.id';
|
||||
const PUSH_INSTALLATION_KEY_KEY = 'push.installation.key';
|
||||
const PUSH_REQUEST_TIMEOUT_MS = 5000;
|
||||
|
||||
interface CachedPushAccessToken {
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
let cachedPushAccessToken: CachedPushAccessToken | null = null;
|
||||
|
||||
async function fetchPushEndpoint(url: string, init: RequestInit, errorMessage: string): Promise<Response | null> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), PUSH_REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal });
|
||||
} catch (error) {
|
||||
console.error(errorMessage, error);
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function randomInstallationEmail(): string {
|
||||
const bytes = new Uint8Array(10);
|
||||
crypto.getRandomValues(bytes);
|
||||
const localPart = Array.from(bytes, (byte) => (byte % 36).toString(36)).join('');
|
||||
return `${localPart}@nodewarden.app`;
|
||||
}
|
||||
|
||||
async function getConfigKeyPresence(db: D1Database, key: string): Promise<string | null> {
|
||||
const row = await db.prepare('SELECT value FROM config WHERE key = ? LIMIT 1').bind(key).first<{ value: string }>();
|
||||
return typeof row?.value === 'string' ? row.value : null;
|
||||
}
|
||||
|
||||
async function getPushInstallationCredentials(db: D1Database): Promise<{ id: string; key: string } | null> {
|
||||
const [id, key] = await Promise.all([
|
||||
getConfigKeyPresence(db, PUSH_INSTALLATION_ID_KEY),
|
||||
getConfigKeyPresence(db, PUSH_INSTALLATION_KEY_KEY),
|
||||
]);
|
||||
const normalizedId = String(id || '').trim();
|
||||
const normalizedKey = String(key || '').trim();
|
||||
return normalizedId && normalizedKey ? { id: normalizedId, key: normalizedKey } : null;
|
||||
}
|
||||
|
||||
export async function ensurePushInstallationCredentials(db: D1Database): Promise<{ id: string; key: string } | null> {
|
||||
const existing = await getPushInstallationCredentials(db);
|
||||
if (existing) return existing;
|
||||
|
||||
const response = await fetchPushEndpoint(
|
||||
INSTALLATIONS_URI,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: randomInstallationEmail(),
|
||||
}),
|
||||
},
|
||||
'Failed to request Bitwarden push installation:'
|
||||
);
|
||||
if (!response) return null;
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to request Bitwarden push installation:', response.status, await response.text().catch(() => ''));
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = (await response.json().catch(() => null)) as { id?: string; Id?: string; key?: string; Key?: string; enabled?: boolean; Enabled?: boolean } | null;
|
||||
const id = String(body?.id || body?.Id || '').trim();
|
||||
const key = String(body?.key || body?.Key || '').trim();
|
||||
if (!id || !key) {
|
||||
console.error('Bitwarden push installation response did not include id/key');
|
||||
return null;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
saveConfigValue(db, PUSH_INSTALLATION_ID_KEY, id),
|
||||
saveConfigValue(db, PUSH_INSTALLATION_KEY_KEY, key),
|
||||
]);
|
||||
return { id, key };
|
||||
}
|
||||
|
||||
async function getPushAccessToken(env: Env): Promise<string | null> {
|
||||
const credentials = await ensurePushInstallationCredentials(env.DB);
|
||||
if (!credentials) return null;
|
||||
|
||||
const now = Date.now();
|
||||
if (cachedPushAccessToken && cachedPushAccessToken.expiresAt > now + 30_000) {
|
||||
return cachedPushAccessToken.token;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
scope: 'api.push',
|
||||
client_id: `installation.${credentials.id}`,
|
||||
client_secret: credentials.key,
|
||||
});
|
||||
|
||||
const response = await fetchPushEndpoint(
|
||||
`${PUSH_IDENTITY_URI}/connect/token`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: params.toString(),
|
||||
},
|
||||
'Failed to get Bitwarden push relay token:'
|
||||
);
|
||||
if (!response) return null;
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to get Bitwarden push relay token:', response.status, await response.text().catch(() => ''));
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = (await response.json().catch(() => null)) as { access_token?: string; expires_in?: number } | null;
|
||||
const token = String(body?.access_token || '').trim();
|
||||
if (!token) {
|
||||
console.error('Bitwarden push relay token response did not include an access_token');
|
||||
return null;
|
||||
}
|
||||
|
||||
const expiresInSeconds = Math.max(60, Number(body?.expires_in || 3600));
|
||||
cachedPushAccessToken = {
|
||||
token,
|
||||
expiresAt: now + Math.floor(expiresInSeconds * 500),
|
||||
};
|
||||
return token;
|
||||
}
|
||||
|
||||
async function postToPushRelay(env: Env, path: string, body?: unknown): Promise<boolean> {
|
||||
const token = await getPushAccessToken(env);
|
||||
if (!token) return false;
|
||||
|
||||
const response = await fetchPushEndpoint(
|
||||
`${PUSH_RELAY_URI}${path}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
},
|
||||
`Bitwarden push relay request failed: ${path}`
|
||||
);
|
||||
if (!response) return false;
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Bitwarden push relay request failed:', path, response.status, await response.text().catch(() => ''));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function mobilePayloadFromSignalR(updateType: number, userId: string, revisionDate: string, payload: Record<string, unknown> | null | undefined): Record<string, unknown> {
|
||||
const source = payload || {};
|
||||
const id = source.Id ?? source.id;
|
||||
const organizationId = source.OrganizationId ?? source.organizationId ?? null;
|
||||
const collectionIds = source.CollectionIds ?? source.collectionIds ?? null;
|
||||
|
||||
if (id != null) {
|
||||
return {
|
||||
id,
|
||||
userId: source.UserId ?? source.userId ?? userId,
|
||||
organizationId,
|
||||
collectionIds,
|
||||
revisionDate: source.RevisionDate ?? source.revisionDate ?? revisionDate,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
userId: source.UserId ?? source.userId ?? userId,
|
||||
date: source.Date ?? source.date ?? revisionDate,
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerMobilePushDevice(
|
||||
env: Env,
|
||||
input: {
|
||||
userId: string;
|
||||
deviceIdentifier: string;
|
||||
type: number;
|
||||
pushUuid: string;
|
||||
pushToken: string;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
const credentials = await ensurePushInstallationCredentials(env.DB);
|
||||
if (!credentials) return false;
|
||||
|
||||
return postToPushRelay(env, '/push/register', {
|
||||
deviceId: input.pushUuid,
|
||||
pushToken: input.pushToken,
|
||||
userId: input.userId,
|
||||
type: input.type,
|
||||
identifier: input.deviceIdentifier,
|
||||
installationId: credentials.id,
|
||||
});
|
||||
}
|
||||
|
||||
export async function unregisterMobilePushDevice(env: Env, pushUuid: string | null | undefined): Promise<boolean> {
|
||||
const normalized = String(pushUuid || '').trim();
|
||||
if (!normalized) return false;
|
||||
return postToPushRelay(env, '/push/delete', { id: normalized });
|
||||
}
|
||||
|
||||
export async function notifyMobilePush(
|
||||
env: Env,
|
||||
input: {
|
||||
userId: string;
|
||||
updateType: number;
|
||||
revisionDate: string;
|
||||
contextId: string | null;
|
||||
payload: Record<string, unknown> | null | undefined;
|
||||
}
|
||||
): Promise<void> {
|
||||
const hasPushDevice = await env.DB
|
||||
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND push_token IS NOT NULL AND push_token <> ? LIMIT 1')
|
||||
.bind(input.userId, '')
|
||||
.first<{ '1': number }>();
|
||||
if (!hasPushDevice) return;
|
||||
|
||||
let actingPushUuid: string | null = null;
|
||||
if (input.contextId) {
|
||||
const row = await env.DB
|
||||
.prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
|
||||
.bind(input.userId, input.contextId)
|
||||
.first<{ push_uuid: string | null }>();
|
||||
actingPushUuid = row?.push_uuid ?? null;
|
||||
}
|
||||
|
||||
await postToPushRelay(env, '/push/send', {
|
||||
userId: input.userId,
|
||||
organizationId: null,
|
||||
deviceId: actingPushUuid,
|
||||
identifier: input.contextId,
|
||||
type: input.updateType,
|
||||
payload: mobilePayloadFromSignalR(input.updateType, input.userId, input.revisionDate, input.payload),
|
||||
clientType: null,
|
||||
installationId: null,
|
||||
});
|
||||
}
|
||||
+279
-71
@@ -1,33 +1,26 @@
|
||||
import { LIMITS } from '../config/limits';
|
||||
|
||||
// D1-backed rate limiting.
|
||||
// Notes:
|
||||
// - Login attempts are tracked per client IP.
|
||||
// - API rate is tracked per identifier per fixed window.
|
||||
// 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).
|
||||
// - Strict budgets: D1-backed fixed windows for low-volume anonymous sensitive endpoints.
|
||||
|
||||
// Rate limit configuration
|
||||
const CONFIG = {
|
||||
// Friendly default: short cooldown instead of long lockouts.
|
||||
LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts,
|
||||
LOGIN_LOCKOUT_MINUTES: LIMITS.rateLimit.loginLockoutMinutes,
|
||||
|
||||
// Write operations only (POST/PUT/DELETE/PATCH) should use this budget.
|
||||
API_WRITE_REQUESTS_PER_MINUTE: LIMITS.rateLimit.apiWriteRequestsPerMinute,
|
||||
// Dedicated budget for GET /api/sync reads.
|
||||
SYNC_READ_REQUESTS_PER_MINUTE: LIMITS.rateLimit.syncReadRequestsPerMinute,
|
||||
API_WINDOW_SECONDS: LIMITS.rateLimit.apiWindowSeconds,
|
||||
};
|
||||
|
||||
export class RateLimitService {
|
||||
private static loginIpTableReady = false;
|
||||
private static strictBudgetTableReady = false;
|
||||
private static lastLoginIpCleanupAt = 0;
|
||||
private static lastApiWindowCleanupAt = 0;
|
||||
private static lastStrictBudgetCleanupAt = 0;
|
||||
|
||||
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.rateLimit.cleanupProbability;
|
||||
private static readonly LOGIN_IP_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs;
|
||||
private static readonly API_WINDOW_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.apiWindowCleanupIntervalMs;
|
||||
private static readonly LOGIN_IP_RETENTION_MS = LIMITS.rateLimit.loginIpRetentionMs;
|
||||
private static readonly API_WINDOW_RETENTION_WINDOWS = LIMITS.rateLimit.apiWindowRetentionWindows;
|
||||
private static readonly STRICT_BUDGET_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs;
|
||||
|
||||
constructor(private db: D1Database) {}
|
||||
|
||||
@@ -52,16 +45,6 @@ export class RateLimitService {
|
||||
RateLimitService.lastLoginIpCleanupAt = nowMs;
|
||||
}
|
||||
|
||||
private async maybeCleanupApiWindows(windowStart: number, windowSeconds: number): Promise<void> {
|
||||
if (!this.shouldRunCleanup(RateLimitService.lastApiWindowCleanupAt, RateLimitService.API_WINDOW_CLEANUP_INTERVAL_MS)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cutoff = windowStart - (windowSeconds * RateLimitService.API_WINDOW_RETENTION_WINDOWS);
|
||||
await this.db.prepare('DELETE FROM api_rate_limits WHERE window_start < ?').bind(cutoff).run();
|
||||
RateLimitService.lastApiWindowCleanupAt = Date.now();
|
||||
}
|
||||
|
||||
private async ensureLoginIpTable(): Promise<void> {
|
||||
if (RateLimitService.loginIpTableReady) return;
|
||||
|
||||
@@ -79,6 +62,35 @@ export class RateLimitService {
|
||||
RateLimitService.loginIpTableReady = true;
|
||||
}
|
||||
|
||||
private async ensureStrictBudgetTable(): Promise<void> {
|
||||
if (RateLimitService.strictBudgetTableReady) return;
|
||||
|
||||
await this.db
|
||||
.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS rate_limit_buckets (' +
|
||||
'bucket_key TEXT PRIMARY KEY, ' +
|
||||
'count INTEGER NOT NULL, ' +
|
||||
'expires_at INTEGER NOT NULL, ' +
|
||||
'updated_at INTEGER NOT NULL' +
|
||||
')'
|
||||
)
|
||||
.run();
|
||||
|
||||
await this.db
|
||||
.prepare('CREATE INDEX IF NOT EXISTS idx_rate_limit_buckets_expires ON rate_limit_buckets(expires_at)')
|
||||
.run();
|
||||
RateLimitService.strictBudgetTableReady = true;
|
||||
}
|
||||
|
||||
private async maybeCleanupStrictBudgets(nowMs: number): Promise<void> {
|
||||
if (!this.shouldRunCleanup(RateLimitService.lastStrictBudgetCleanupAt, RateLimitService.STRICT_BUDGET_CLEANUP_INTERVAL_MS)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.db.prepare('DELETE FROM rate_limit_buckets WHERE expires_at < ?').bind(nowMs).run();
|
||||
RateLimitService.lastStrictBudgetCleanupAt = nowMs;
|
||||
}
|
||||
|
||||
async checkLoginAttempt(ip: string): Promise<{
|
||||
allowed: boolean;
|
||||
remainingAttempts: number;
|
||||
@@ -158,8 +170,9 @@ export class RateLimitService {
|
||||
await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run();
|
||||
}
|
||||
|
||||
// Atomically consume one budget unit for the current fixed window.
|
||||
// Uses SQLite UPSERT-with-WHERE so requests at/over limit do not increment.
|
||||
// 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,
|
||||
@@ -168,68 +181,263 @@ export class RateLimitService {
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const windowStart = nowSec - (nowSec % windowSeconds);
|
||||
const windowEnd = windowStart + windowSeconds;
|
||||
await this.maybeCleanupApiWindows(windowStart, windowSeconds);
|
||||
const ttl = Math.max(1, windowEnd - nowSec);
|
||||
|
||||
const writeResult = await this.db
|
||||
const cache = await caches.open('rate-limit');
|
||||
const cacheKey = new Request(`https://rl/${identifier}/${windowStart}`);
|
||||
|
||||
const cached = await cache.match(cacheKey);
|
||||
let count = 0;
|
||||
if (cached) {
|
||||
count = parseInt(await cached.text(), 10) || 0;
|
||||
}
|
||||
|
||||
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) };
|
||||
}
|
||||
|
||||
async consumeStrictBudget(
|
||||
identifier: string,
|
||||
maxRequests: number
|
||||
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
||||
return this.consumeStrictBudgetWithWindow(identifier, maxRequests, CONFIG.API_WINDOW_SECONDS);
|
||||
}
|
||||
|
||||
async consumeStrictBudgetWithWindow(
|
||||
identifier: string,
|
||||
maxRequests: number,
|
||||
windowSeconds: number
|
||||
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
||||
await this.ensureStrictBudgetTable();
|
||||
|
||||
const key = String(identifier || '').trim() || 'unknown';
|
||||
const max = Math.max(1, Math.floor(maxRequests));
|
||||
const windowSize = Math.max(1, Math.floor(windowSeconds));
|
||||
const nowMs = Date.now();
|
||||
const nowSec = Math.floor(nowMs / 1000);
|
||||
const windowStart = nowSec - (nowSec % windowSize);
|
||||
const windowEndMs = (windowStart + windowSize) * 1000;
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil((windowEndMs - nowMs) / 1000));
|
||||
const bucketKey = `${key}:${windowStart}`;
|
||||
|
||||
await this.maybeCleanupStrictBudgets(nowMs);
|
||||
await this.db
|
||||
.prepare(
|
||||
'INSERT INTO api_rate_limits(identifier, window_start, count) VALUES(?, ?, 1) ' +
|
||||
'ON CONFLICT(identifier, window_start) DO UPDATE SET count = count + 1 ' +
|
||||
'WHERE api_rate_limits.count < ?'
|
||||
'INSERT OR IGNORE INTO rate_limit_buckets(bucket_key, count, expires_at, updated_at) VALUES(?, 0, ?, ?)'
|
||||
)
|
||||
.bind(identifier, windowStart, maxRequests)
|
||||
.bind(bucketKey, windowEndMs, nowMs)
|
||||
.run();
|
||||
|
||||
// No changed row means conflict happened and WHERE prevented increment:
|
||||
// current count is already at/above configured limit.
|
||||
if ((writeResult.meta.changes ?? 0) === 0) {
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
retryAfterSeconds: windowEnd - nowSec,
|
||||
};
|
||||
}
|
||||
const update = await this.db
|
||||
.prepare(
|
||||
'UPDATE rate_limit_buckets SET count = count + 1, expires_at = ?, updated_at = ? ' +
|
||||
'WHERE bucket_key = ? AND count < ?'
|
||||
)
|
||||
.bind(windowEndMs, nowMs, bucketKey, max)
|
||||
.run();
|
||||
|
||||
const allowed = Number(update.meta?.changes ?? 0) > 0;
|
||||
const row = await this.db
|
||||
.prepare('SELECT count FROM api_rate_limits WHERE identifier = ? AND window_start = ?')
|
||||
.bind(identifier, windowStart)
|
||||
.prepare('SELECT count FROM rate_limit_buckets WHERE bucket_key = ?')
|
||||
.bind(bucketKey)
|
||||
.first<{ count: number }>();
|
||||
const count = Math.max(0, Number(row?.count || 0));
|
||||
|
||||
if (!row) {
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: 0,
|
||||
};
|
||||
if (!allowed) {
|
||||
return { allowed: false, remaining: 0, retryAfterSeconds };
|
||||
}
|
||||
|
||||
const remaining = Math.max(0, maxRequests - row.count);
|
||||
return { allowed: true, remaining };
|
||||
return { allowed: true, remaining: Math.max(0, max - count) };
|
||||
}
|
||||
|
||||
// Write budget for POST/PUT/DELETE/PATCH requests.
|
||||
async consumeApiWriteBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
||||
return this.consumeFixedWindowBudget(
|
||||
identifier,
|
||||
CONFIG.API_WRITE_REQUESTS_PER_MINUTE,
|
||||
CONFIG.API_WINDOW_SECONDS
|
||||
);
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Read budget for GET /api/sync.
|
||||
async consumeSyncReadBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
||||
return this.consumeFixedWindowBudget(
|
||||
identifier,
|
||||
CONFIG.SYNC_READ_REQUESTS_PER_MINUTE,
|
||||
CONFIG.API_WINDOW_SECONDS
|
||||
);
|
||||
async consumeBudgetWithWindow(
|
||||
identifier: string,
|
||||
maxRequests: number,
|
||||
windowSeconds: number
|
||||
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
||||
return this.consumeFixedWindowBudget(identifier, maxRequests, windowSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
export function getClientIdentifier(request: Request): string {
|
||||
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;
|
||||
|
||||
const forwardedFor = request.headers.get('X-Forwarded-For');
|
||||
if (forwardedFor) return forwardedFor.split(',')[0].trim();
|
||||
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;
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
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,338 @@
|
||||
import type { AccountPasskeyChallenge, AccountPasskeyChallengeScope, AccountPasskeyCredential } from '../types';
|
||||
|
||||
type SafeBindFn = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
|
||||
|
||||
let accountPasskeySchemaReady = false;
|
||||
|
||||
const ACCOUNT_PASSKEY_CREDENTIAL_COLUMN_DEFS = [
|
||||
{ name: 'id', sql: 'id TEXT' },
|
||||
{ name: 'user_id', sql: "user_id TEXT NOT NULL DEFAULT ''" },
|
||||
{ name: 'purpose', sql: "purpose TEXT NOT NULL DEFAULT 'login'" },
|
||||
{ name: 'name', sql: "name TEXT NOT NULL DEFAULT 'Account passkey'" },
|
||||
{ name: 'public_key', sql: "public_key TEXT NOT NULL DEFAULT ''" },
|
||||
{ name: 'credential_id', sql: "credential_id TEXT NOT NULL DEFAULT ''" },
|
||||
{ name: 'counter', sql: 'counter INTEGER NOT NULL DEFAULT 0' },
|
||||
{ name: 'type', sql: 'type TEXT' },
|
||||
{ name: 'aa_guid', sql: 'aa_guid TEXT' },
|
||||
{ name: 'transports', sql: 'transports TEXT' },
|
||||
{ name: 'encrypted_user_key', sql: 'encrypted_user_key TEXT' },
|
||||
{ name: 'encrypted_public_key', sql: 'encrypted_public_key TEXT' },
|
||||
{ name: 'encrypted_private_key', sql: 'encrypted_private_key TEXT' },
|
||||
{ name: 'supports_prf', sql: 'supports_prf INTEGER NOT NULL DEFAULT 0' },
|
||||
{ name: 'created_at', sql: "created_at TEXT NOT NULL DEFAULT ''" },
|
||||
{ name: 'updated_at', sql: "updated_at TEXT NOT NULL DEFAULT ''" },
|
||||
] as const;
|
||||
|
||||
const ACCOUNT_PASSKEY_CHALLENGE_COLUMNS = [
|
||||
'challenge_hash',
|
||||
'scope',
|
||||
'user_id',
|
||||
'expires_at',
|
||||
'used_at',
|
||||
'created_at',
|
||||
] as const;
|
||||
|
||||
async function tableColumns(db: D1Database, tableName: 'webauthn_credentials' | 'webauthn_challenges'): Promise<Set<string>> {
|
||||
const result = await db.prepare(`PRAGMA table_info(${tableName})`).all<{ name: string }>();
|
||||
return new Set((result.results || []).map((row) => String(row.name || '').trim()).filter(Boolean));
|
||||
}
|
||||
|
||||
async function ensureAccountPasskeySchema(db: D1Database): Promise<void> {
|
||||
if (accountPasskeySchemaReady) return;
|
||||
|
||||
await db
|
||||
.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
|
||||
"id TEXT PRIMARY KEY, user_id TEXT NOT NULL, purpose TEXT NOT NULL DEFAULT 'login', name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, " +
|
||||
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)'
|
||||
)
|
||||
.run();
|
||||
let credentialColumns = await tableColumns(db, 'webauthn_credentials');
|
||||
for (const column of ACCOUNT_PASSKEY_CREDENTIAL_COLUMN_DEFS) {
|
||||
if (!credentialColumns.has(column.name)) {
|
||||
await db.prepare(`ALTER TABLE webauthn_credentials ADD COLUMN ${column.sql}`).run();
|
||||
}
|
||||
}
|
||||
credentialColumns = await tableColumns(db, 'webauthn_credentials');
|
||||
if (!credentialColumns.has('credential_id')) {
|
||||
throw new Error('webauthn_credentials schema is missing credential_id');
|
||||
}
|
||||
await db.prepare('CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_id ON webauthn_credentials(id)').run();
|
||||
await db.prepare('CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_credential_id ON webauthn_credentials(credential_id)').run();
|
||||
await db.prepare('CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)').run();
|
||||
await db.prepare('CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated ON webauthn_credentials(user_id, updated_at)').run();
|
||||
|
||||
await db
|
||||
.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS webauthn_challenges (' +
|
||||
'challenge_hash TEXT PRIMARY KEY, scope TEXT NOT NULL, user_id TEXT, expires_at INTEGER NOT NULL, used_at INTEGER, created_at INTEGER NOT NULL)'
|
||||
)
|
||||
.run();
|
||||
const challengeColumns = await tableColumns(db, 'webauthn_challenges');
|
||||
const challengeSchemaComplete = ACCOUNT_PASSKEY_CHALLENGE_COLUMNS.every((column) => challengeColumns.has(column));
|
||||
if (!challengeSchemaComplete) {
|
||||
await db.prepare('DROP TABLE IF EXISTS webauthn_challenges').run();
|
||||
await db
|
||||
.prepare(
|
||||
'CREATE TABLE webauthn_challenges (' +
|
||||
'challenge_hash TEXT PRIMARY KEY, scope TEXT NOT NULL, user_id TEXT, expires_at INTEGER NOT NULL, used_at INTEGER, created_at INTEGER NOT NULL)'
|
||||
)
|
||||
.run();
|
||||
}
|
||||
await db.prepare('CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at)').run();
|
||||
await db.prepare('CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user_scope ON webauthn_challenges(user_id, scope)').run();
|
||||
|
||||
accountPasskeySchemaReady = true;
|
||||
}
|
||||
|
||||
function parseTransports(value: string | null): string[] | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
return parsed.map((item) => String(item || '').trim()).filter(Boolean);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mapCredentialRow(row: {
|
||||
id: string;
|
||||
user_id: string;
|
||||
purpose?: string | null;
|
||||
name: string;
|
||||
public_key: string;
|
||||
credential_id: string;
|
||||
counter: number;
|
||||
type: string | null;
|
||||
aa_guid: string | null;
|
||||
transports: string | null;
|
||||
encrypted_user_key: string | null;
|
||||
encrypted_public_key: string | null;
|
||||
encrypted_private_key: string | null;
|
||||
supports_prf: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}): AccountPasskeyCredential {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
purpose: row.purpose === 'twoFactor' ? 'twoFactor' : 'login',
|
||||
name: row.name,
|
||||
publicKey: row.public_key,
|
||||
credentialId: row.credential_id,
|
||||
counter: Number(row.counter || 0),
|
||||
type: row.type ?? null,
|
||||
aaGuid: row.aa_guid ?? null,
|
||||
transports: parseTransports(row.transports),
|
||||
encryptedUserKey: row.encrypted_user_key ?? null,
|
||||
encryptedPublicKey: row.encrypted_public_key ?? null,
|
||||
encryptedPrivateKey: row.encrypted_private_key ?? null,
|
||||
supportsPrf: !!row.supports_prf,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function mapChallengeRow(row: {
|
||||
challenge_hash: string;
|
||||
scope: AccountPasskeyChallengeScope;
|
||||
user_id: string | null;
|
||||
expires_at: number;
|
||||
used_at: number | null;
|
||||
created_at: number;
|
||||
}): AccountPasskeyChallenge {
|
||||
return {
|
||||
challengeHash: row.challenge_hash,
|
||||
scope: row.scope,
|
||||
userId: row.user_id ?? null,
|
||||
expiresAt: Number(row.expires_at || 0),
|
||||
usedAt: row.used_at == null ? null : Number(row.used_at),
|
||||
createdAt: Number(row.created_at || 0),
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveAccountPasskeyCredential(
|
||||
db: D1Database,
|
||||
safeBind: SafeBindFn,
|
||||
credential: AccountPasskeyCredential
|
||||
): Promise<void> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
await safeBind(
|
||||
db.prepare(
|
||||
'INSERT INTO webauthn_credentials(' +
|
||||
'id, user_id, purpose, name, public_key, credential_id, counter, type, aa_guid, transports, ' +
|
||||
'encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at' +
|
||||
') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||
'purpose=excluded.purpose, name=excluded.name, public_key=excluded.public_key, credential_id=excluded.credential_id, counter=excluded.counter, ' +
|
||||
'type=excluded.type, aa_guid=excluded.aa_guid, transports=excluded.transports, encrypted_user_key=excluded.encrypted_user_key, ' +
|
||||
'encrypted_public_key=excluded.encrypted_public_key, encrypted_private_key=excluded.encrypted_private_key, supports_prf=excluded.supports_prf, updated_at=excluded.updated_at'
|
||||
),
|
||||
credential.id,
|
||||
credential.userId,
|
||||
credential.purpose,
|
||||
credential.name,
|
||||
credential.publicKey,
|
||||
credential.credentialId,
|
||||
credential.counter,
|
||||
credential.type,
|
||||
credential.aaGuid,
|
||||
credential.transports ? JSON.stringify(credential.transports) : null,
|
||||
credential.encryptedUserKey,
|
||||
credential.encryptedPublicKey,
|
||||
credential.encryptedPrivateKey,
|
||||
credential.supportsPrf ? 1 : 0,
|
||||
credential.createdAt,
|
||||
credential.updatedAt
|
||||
).run();
|
||||
}
|
||||
|
||||
export async function listAccountPasskeyCredentialsByUserId(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||
): Promise<AccountPasskeyCredential[]> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
const rows = await db
|
||||
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? AND purpose = ? ORDER BY created_at ASC')
|
||||
.bind(userId, purpose)
|
||||
.all<any>();
|
||||
return (rows.results || []).map(mapCredentialRow);
|
||||
}
|
||||
|
||||
export async function getAccountPasskeyCredentialById(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
id: string
|
||||
): Promise<AccountPasskeyCredential | null> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
const row = await db
|
||||
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? AND id = ? LIMIT 1')
|
||||
.bind(userId, id)
|
||||
.first<any>();
|
||||
return row ? mapCredentialRow(row) : null;
|
||||
}
|
||||
|
||||
export async function getAccountPasskeyCredentialByCredentialId(
|
||||
db: D1Database,
|
||||
credentialId: string
|
||||
): Promise<AccountPasskeyCredential | null> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
const row = await db
|
||||
.prepare('SELECT * FROM webauthn_credentials WHERE credential_id = ? LIMIT 1')
|
||||
.bind(credentialId)
|
||||
.first<any>();
|
||||
return row ? mapCredentialRow(row) : null;
|
||||
}
|
||||
|
||||
export async function countAccountPasskeyCredentialsByUserId(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||
): Promise<number> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
const row = await db
|
||||
.prepare('SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ? AND purpose = ?')
|
||||
.bind(userId, purpose)
|
||||
.first<{ count: number }>();
|
||||
return Number(row?.count || 0);
|
||||
}
|
||||
|
||||
export async function updateAccountPasskeyCounter(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
credentialId: string,
|
||||
counter: number,
|
||||
updatedAt: string
|
||||
): Promise<void> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
await db
|
||||
.prepare('UPDATE webauthn_credentials SET counter = ?, updated_at = ? WHERE user_id = ? AND credential_id = ?')
|
||||
.bind(counter, updatedAt, userId, credentialId)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function updateAccountPasskeyEncryption(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
credentialId: string,
|
||||
encryptedUserKey: string,
|
||||
encryptedPublicKey: string,
|
||||
encryptedPrivateKey: string,
|
||||
updatedAt: string
|
||||
): Promise<boolean> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
const result = await db
|
||||
.prepare(
|
||||
'UPDATE webauthn_credentials SET encrypted_user_key = ?, encrypted_public_key = ?, encrypted_private_key = ?, supports_prf = 1, updated_at = ? ' +
|
||||
"WHERE user_id = ? AND credential_id = ? AND purpose = 'login'"
|
||||
)
|
||||
.bind(encryptedUserKey, encryptedPublicKey, encryptedPrivateKey, updatedAt, userId, credentialId)
|
||||
.run();
|
||||
return Number(result.meta.changes || 0) > 0;
|
||||
}
|
||||
|
||||
export async function deleteAccountPasskeyCredential(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
id: string,
|
||||
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||
): Promise<boolean> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
const result = await db
|
||||
.prepare('DELETE FROM webauthn_credentials WHERE user_id = ? AND id = ? AND purpose = ?')
|
||||
.bind(userId, id, purpose)
|
||||
.run();
|
||||
return Number(result.meta.changes || 0) > 0;
|
||||
}
|
||||
|
||||
export async function saveAccountPasskeyChallenge(
|
||||
db: D1Database,
|
||||
challenge: AccountPasskeyChallenge
|
||||
): Promise<void> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
await db.prepare('DELETE FROM webauthn_challenges WHERE expires_at < ? OR used_at IS NOT NULL').bind(Date.now()).run();
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO webauthn_challenges(challenge_hash, scope, user_id, expires_at, used_at, created_at) VALUES(?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(challenge_hash) DO UPDATE SET scope=excluded.scope, user_id=excluded.user_id, expires_at=excluded.expires_at, used_at=excluded.used_at, created_at=excluded.created_at'
|
||||
)
|
||||
.bind(
|
||||
challenge.challengeHash,
|
||||
challenge.scope,
|
||||
challenge.userId,
|
||||
challenge.expiresAt,
|
||||
challenge.usedAt,
|
||||
challenge.createdAt
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function consumeAccountPasskeyChallenge(
|
||||
db: D1Database,
|
||||
challengeHash: string,
|
||||
scope: AccountPasskeyChallengeScope,
|
||||
userId: string | null,
|
||||
nowMs: number
|
||||
): Promise<AccountPasskeyChallenge | null> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
const row = await db
|
||||
.prepare('SELECT * FROM webauthn_challenges WHERE challenge_hash = ? AND scope = ? LIMIT 1')
|
||||
.bind(challengeHash, scope)
|
||||
.first<any>();
|
||||
if (!row) return null;
|
||||
const challenge = mapChallengeRow(row);
|
||||
if (challenge.usedAt != null || challenge.expiresAt < nowMs) return null;
|
||||
if (userId !== null && challenge.userId !== userId) return null;
|
||||
if (userId === null && challenge.userId !== null) return null;
|
||||
|
||||
const result = await db
|
||||
.prepare('UPDATE webauthn_challenges SET used_at = ? WHERE challenge_hash = ? AND used_at IS NULL')
|
||||
.bind(nowMs, challengeHash)
|
||||
.run();
|
||||
if (Number(result.meta.changes || 0) <= 0) return null;
|
||||
return { ...challenge, usedAt: nowMs };
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import type { AuditLog, Invite } from '../types';
|
||||
|
||||
export interface AuditLogListOptions {
|
||||
limit: number;
|
||||
offset: number;
|
||||
category?: string | null;
|
||||
level?: string | null;
|
||||
q?: string | null;
|
||||
from?: string | null;
|
||||
to?: string | null;
|
||||
}
|
||||
|
||||
export interface AuditLogListResult {
|
||||
logs: AuditLog[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
function auditLogFromRow(row: any): AuditLog {
|
||||
return {
|
||||
id: row.id,
|
||||
actorUserId: row.actor_user_id ?? null,
|
||||
actorEmail: row.actor_email ?? null,
|
||||
action: row.action,
|
||||
category: row.category || 'system',
|
||||
level: row.level || 'info',
|
||||
targetType: row.target_type ?? null,
|
||||
targetId: row.target_id ?? null,
|
||||
targetUserEmail: row.target_user_email ?? null,
|
||||
metadata: row.metadata ?? null,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAuditWhere(options: AuditLogListOptions): { where: string; params: unknown[] } {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (options.from) {
|
||||
conditions.push('l.created_at >= ?');
|
||||
params.push(options.from);
|
||||
}
|
||||
if (options.to) {
|
||||
conditions.push('l.created_at <= ?');
|
||||
params.push(options.to);
|
||||
}
|
||||
if (options.category) {
|
||||
conditions.push('l.category = ?');
|
||||
params.push(options.category);
|
||||
}
|
||||
if (options.level) {
|
||||
conditions.push('l.level = ?');
|
||||
params.push(options.level);
|
||||
}
|
||||
if (options.q) {
|
||||
const q = options.q.toLowerCase().slice(0, 48);
|
||||
const like = `%${q}%`;
|
||||
conditions.push(
|
||||
'(LOWER(l.action) LIKE ? OR LOWER(COALESCE(l.actor_user_id, \'\')) LIKE ? OR LOWER(COALESCE(l.target_type, \'\')) LIKE ? OR LOWER(COALESCE(l.target_id, \'\')) LIKE ? OR LOWER(COALESCE(actor.email, \'\')) LIKE ? OR LOWER(COALESCE(target.email, \'\')) LIKE ?)'
|
||||
);
|
||||
params.push(like, like, like, like, like, like);
|
||||
}
|
||||
|
||||
return {
|
||||
where: conditions.length ? `WHERE ${conditions.join(' AND ')}` : '',
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createInvite(db: D1Database, invite: Invite): Promise<void> {
|
||||
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> {
|
||||
void userId;
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare(
|
||||
"UPDATE invites SET status = 'used', used_by = NULL, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?"
|
||||
)
|
||||
.bind(now, code, now)
|
||||
.run();
|
||||
return (result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function assignInviteUsedBy(db: D1Database, code: string, userId: string): Promise<boolean> {
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare(
|
||||
"UPDATE invites SET used_by = ?, updated_at = ? WHERE code = ? AND status = 'used' AND used_by IS NULL"
|
||||
)
|
||||
.bind(userId, now, code)
|
||||
.run();
|
||||
return (result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function revertInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
|
||||
void userId;
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare(
|
||||
"UPDATE invites SET status = 'active', used_by = NULL, updated_at = ? WHERE code = ? AND status = 'used' AND used_by IS NULL"
|
||||
)
|
||||
.bind(now, code)
|
||||
.run();
|
||||
return (result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function deleteInvite(db: D1Database, code: string): Promise<boolean> {
|
||||
const result = await db
|
||||
.prepare('DELETE FROM invites WHERE code = ?')
|
||||
.bind(code)
|
||||
.run();
|
||||
return (result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function deleteInvalidInvites(db: D1Database): Promise<number> {
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare("DELETE FROM invites WHERE status != 'active' OR expires_at <= ?")
|
||||
.bind(now)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 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, category, level, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
.bind(log.id, log.actorUserId, log.action, log.category, log.level, log.targetType, log.targetId, log.metadata, log.createdAt)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function pruneAuditLogs(db: D1Database, beforeIso: string): Promise<number> {
|
||||
const result = await db
|
||||
.prepare('DELETE FROM audit_logs WHERE created_at < ?')
|
||||
.bind(beforeIso)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0);
|
||||
}
|
||||
|
||||
export async function pruneAuditLogsToMax(db: D1Database, maxEntries: number): Promise<number> {
|
||||
const limit = Math.max(1, Math.floor(maxEntries));
|
||||
const result = await db
|
||||
.prepare(
|
||||
'DELETE FROM audit_logs WHERE id IN (' +
|
||||
'SELECT id FROM audit_logs ORDER BY created_at DESC LIMIT -1 OFFSET ?' +
|
||||
')'
|
||||
)
|
||||
.bind(limit)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0);
|
||||
}
|
||||
|
||||
export async function clearAuditLogs(db: D1Database): Promise<number> {
|
||||
const result = await db.prepare('DELETE FROM audit_logs').run();
|
||||
return Number(result.meta.changes ?? 0);
|
||||
}
|
||||
|
||||
export async function listAuditLogs(db: D1Database, options: AuditLogListOptions): Promise<AuditLogListResult> {
|
||||
const limit = Math.max(1, Math.min(200, Math.floor(options.limit || 50)));
|
||||
const offset = Math.max(0, Math.floor(options.offset || 0));
|
||||
const { where, params } = buildAuditWhere(options);
|
||||
|
||||
const rows = await db
|
||||
.prepare(
|
||||
'SELECT l.id, l.actor_user_id, actor.email AS actor_email, l.action, l.category, l.level, l.target_type, l.target_id, target.email AS target_user_email, l.metadata, l.created_at ' +
|
||||
'FROM audit_logs l ' +
|
||||
'LEFT JOIN users actor ON actor.id = l.actor_user_id ' +
|
||||
"LEFT JOIN users target ON l.target_type = 'user' AND target.id = l.target_id " +
|
||||
`${where} ORDER BY l.created_at DESC LIMIT ? OFFSET ?`
|
||||
)
|
||||
.bind(...params, limit + 1, offset)
|
||||
.all<any>();
|
||||
const results = rows.results || [];
|
||||
const logs = results.slice(0, limit).map(auditLogFromRow);
|
||||
const hasMore = results.length > limit;
|
||||
|
||||
return {
|
||||
logs,
|
||||
total: offset + logs.length + (hasMore ? 1 : 0),
|
||||
hasMore,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
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 getAttachmentForUser(db: D1Database, id: string, userId: string): Promise<Attachment | null> {
|
||||
const row = 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 a.id = ? AND c.user_id = ?`
|
||||
)
|
||||
.bind(id, userId)
|
||||
.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 ' +
|
||||
'WHERE EXISTS (' +
|
||||
'SELECT 1 FROM ciphers current_cipher INNER JOIN ciphers next_cipher ON next_cipher.id = excluded.cipher_id ' +
|
||||
'WHERE current_cipher.id = attachments.cipher_id AND current_cipher.user_id = next_cipher.user_id' +
|
||||
')'
|
||||
);
|
||||
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 deleteAttachmentForUser(db: D1Database, id: string, userId: string): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
`DELETE FROM attachments
|
||||
WHERE id = ?
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM ciphers c
|
||||
WHERE c.id = attachments.cipher_id AND c.user_id = ?
|
||||
)`
|
||||
)
|
||||
.bind(id, userId)
|
||||
.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 addAttachmentToCipherForUser(
|
||||
db: D1Database,
|
||||
cipherId: string,
|
||||
attachmentId: string,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE attachments
|
||||
SET cipher_id = ?
|
||||
WHERE id = ?
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM ciphers target_cipher
|
||||
WHERE target_cipher.id = ? AND target_cipher.user_id = ?
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM ciphers current_cipher
|
||||
WHERE current_cipher.id = attachments.cipher_id AND current_cipher.user_id = ?
|
||||
)`
|
||||
)
|
||||
.bind(cipherId, attachmentId, cipherId, userId, userId)
|
||||
.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,144 @@
|
||||
import type { AuthRequestRecord, AuthRequestType } from '../types';
|
||||
|
||||
const AUTH_REQUEST_EXPIRATION_MS = 15 * 60 * 1000;
|
||||
|
||||
function mapAuthRequestRow(row: any): AuthRequestRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
organizationId: row.organization_id ?? null,
|
||||
type: Number(row.type) as AuthRequestType,
|
||||
requestDeviceIdentifier: row.request_device_identifier,
|
||||
requestDeviceType: Number(row.request_device_type ?? 14),
|
||||
requestIpAddress: row.request_ip_address ?? null,
|
||||
requestCountryName: row.request_country_name ?? null,
|
||||
responseDeviceIdentifier: row.response_device_identifier ?? null,
|
||||
accessCode: row.access_code,
|
||||
publicKey: row.public_key,
|
||||
key: row.key ?? null,
|
||||
masterPasswordHash: row.master_password_hash ?? null,
|
||||
approved: row.approved == null ? null : Number(row.approved) === 1,
|
||||
creationDate: row.creation_date,
|
||||
responseDate: row.response_date ?? null,
|
||||
authenticationDate: row.authentication_date ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function isAuthRequestExpired(request: AuthRequestRecord, nowMs: number = Date.now()): boolean {
|
||||
return new Date(request.creationDate).getTime() + AUTH_REQUEST_EXPIRATION_MS <= nowMs;
|
||||
}
|
||||
|
||||
const AUTH_REQUEST_SELECT =
|
||||
'SELECT id, user_id, organization_id, type, request_device_identifier, request_device_type, request_ip_address, request_country_name, ' +
|
||||
'response_device_identifier, access_code, public_key, key, master_password_hash, approved, creation_date, response_date, authentication_date ' +
|
||||
'FROM auth_requests';
|
||||
|
||||
export async function createAuthRequest(db: D1Database, request: AuthRequestRecord): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO auth_requests(' +
|
||||
'id, user_id, organization_id, type, request_device_identifier, request_device_type, request_ip_address, request_country_name, ' +
|
||||
'response_device_identifier, access_code, public_key, key, master_password_hash, approved, creation_date, response_date, authentication_date' +
|
||||
') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
.bind(
|
||||
request.id,
|
||||
request.userId,
|
||||
request.organizationId,
|
||||
request.type,
|
||||
request.requestDeviceIdentifier,
|
||||
request.requestDeviceType,
|
||||
request.requestIpAddress,
|
||||
request.requestCountryName,
|
||||
request.responseDeviceIdentifier,
|
||||
request.accessCode,
|
||||
request.publicKey,
|
||||
request.key,
|
||||
request.masterPasswordHash,
|
||||
request.approved == null ? null : (request.approved ? 1 : 0),
|
||||
request.creationDate,
|
||||
request.responseDate,
|
||||
request.authenticationDate
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function getAuthRequestById(db: D1Database, id: string): Promise<AuthRequestRecord | null> {
|
||||
const row = await db.prepare(`${AUTH_REQUEST_SELECT} WHERE id = ? LIMIT 1`).bind(id).first<any>();
|
||||
return row ? mapAuthRequestRow(row) : null;
|
||||
}
|
||||
|
||||
export async function getAuthRequestByIdForUser(db: D1Database, id: string, userId: string): Promise<AuthRequestRecord | null> {
|
||||
const row = await db.prepare(`${AUTH_REQUEST_SELECT} WHERE id = ? AND user_id = ? LIMIT 1`).bind(id, userId).first<any>();
|
||||
return row ? mapAuthRequestRow(row) : null;
|
||||
}
|
||||
|
||||
export async function listAuthRequestsByUserId(db: D1Database, userId: string): Promise<AuthRequestRecord[]> {
|
||||
const res = await db.prepare(`${AUTH_REQUEST_SELECT} WHERE user_id = ? ORDER BY creation_date DESC`).bind(userId).all<any>();
|
||||
return (res.results || []).map(mapAuthRequestRow);
|
||||
}
|
||||
|
||||
export async function listPendingAuthRequestsByUserId(db: D1Database, userId: string, nowMs: number = Date.now()): Promise<AuthRequestRecord[]> {
|
||||
const cutoff = new Date(nowMs - AUTH_REQUEST_EXPIRATION_MS).toISOString();
|
||||
const res = await db
|
||||
.prepare(
|
||||
'SELECT ar.id, ar.user_id, ar.organization_id, ar.type, ar.request_device_identifier, ar.request_device_type, ar.request_ip_address, ar.request_country_name, ' +
|
||||
'ar.response_device_identifier, ar.access_code, ar.public_key, ar.key, ar.master_password_hash, ar.approved, ar.creation_date, ar.response_date, ar.authentication_date ' +
|
||||
'FROM auth_requests ar ' +
|
||||
'JOIN (' +
|
||||
' SELECT request_device_identifier, MAX(creation_date) AS latest_creation_date ' +
|
||||
' FROM auth_requests ' +
|
||||
' WHERE user_id = ? AND type IN (0, 1) AND approved IS NULL AND response_date IS NULL AND authentication_date IS NULL AND creation_date >= ? ' +
|
||||
' GROUP BY request_device_identifier' +
|
||||
') latest ON latest.request_device_identifier = ar.request_device_identifier AND latest.latest_creation_date = ar.creation_date ' +
|
||||
'WHERE ar.user_id = ? AND ar.type IN (0, 1) AND ar.approved IS NULL AND ar.response_date IS NULL AND ar.authentication_date IS NULL ' +
|
||||
'ORDER BY ar.creation_date DESC'
|
||||
)
|
||||
.bind(userId, cutoff, userId)
|
||||
.all<any>();
|
||||
return (res.results || []).map(mapAuthRequestRow).filter((request) => !isAuthRequestExpired(request, nowMs));
|
||||
}
|
||||
|
||||
export async function updateAuthRequestResponse(
|
||||
db: D1Database,
|
||||
id: string,
|
||||
userId: string,
|
||||
update: {
|
||||
approved: boolean;
|
||||
responseDeviceIdentifier: string;
|
||||
key?: string | null;
|
||||
masterPasswordHash?: string | null;
|
||||
responseDate?: string;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
const result = await db
|
||||
.prepare(
|
||||
'UPDATE auth_requests SET approved = ?, response_device_identifier = ?, key = ?, master_password_hash = ?, response_date = ? ' +
|
||||
'WHERE id = ? AND user_id = ? AND approved IS NULL AND response_date IS NULL AND authentication_date IS NULL'
|
||||
)
|
||||
.bind(
|
||||
update.approved ? 1 : 0,
|
||||
update.responseDeviceIdentifier,
|
||||
update.approved ? (update.key ?? null) : null,
|
||||
update.approved ? (update.masterPasswordHash ?? null) : null,
|
||||
update.responseDate || new Date().toISOString(),
|
||||
id,
|
||||
userId
|
||||
)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function markAuthRequestAuthenticated(db: D1Database, id: string, authenticationDate: string = new Date().toISOString()): Promise<boolean> {
|
||||
const result = await db
|
||||
.prepare('UPDATE auth_requests SET authentication_date = ? WHERE id = ? AND authentication_date IS NULL')
|
||||
.bind(authenticationDate, id)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function pruneExpiredAuthRequests(db: D1Database, nowMs: number = Date.now()): Promise<number> {
|
||||
const cutoff = new Date(nowMs - AUTH_REQUEST_EXPIRATION_MS).toISOString();
|
||||
const result = await db.prepare('DELETE FROM auth_requests WHERE creation_date < ?').bind(cutoff).run();
|
||||
return Number(result.meta.changes ?? 0);
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
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',
|
||||
'attachments',
|
||||
'Attachments',
|
||||
'attachments2',
|
||||
'Attachments2',
|
||||
'createdAt',
|
||||
'created_at',
|
||||
'creationDate',
|
||||
'updatedAt',
|
||||
'updated_at',
|
||||
'revisionDate',
|
||||
'archivedAt',
|
||||
'archived_at',
|
||||
'archivedDate',
|
||||
'deletedAt',
|
||||
'deleted_at',
|
||||
'deletedDate',
|
||||
]);
|
||||
|
||||
function buildCipherData(cipher: Cipher, folderId: string | null): string {
|
||||
const payload: Record<string, unknown> = {
|
||||
...cipher,
|
||||
folderId,
|
||||
};
|
||||
for (const key of CIPHER_SCALAR_DATA_KEYS) {
|
||||
delete payload[key];
|
||||
}
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
|
||||
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 ?? parsed.deletedAt ?? parsed.deletedDate ?? 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 getCipherForUser(db: D1Database, id: string, userId: string): Promise<Cipher | null> {
|
||||
const row = await db
|
||||
.prepare(`SELECT ${selectCipherColumns()} FROM ciphers WHERE id = ? AND user_id = ?`)
|
||||
.bind(id, userId)
|
||||
.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 ' +
|
||||
'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 ' +
|
||||
'WHERE user_id=excluded.user_id'
|
||||
);
|
||||
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 AND json_extract(data, '$.deletedAt') IS NULL AND json_extract(data, '$.deletedDate') 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
|
||||
AND json_extract(data, '$.deletedAt') IS NULL
|
||||
AND json_extract(data, '$.deletedDate') 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,368 @@
|
||||
import type { Device, TrustedDeviceTokenSummary, User } from '../types';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
|
||||
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,
|
||||
pushUuid: row.push_uuid ?? null,
|
||||
pushToken: row.push_token ?? 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();
|
||||
const effectivePushUuid = String(existingDevice?.pushUuid || '').trim() || generateUUID();
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, 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=CASE WHEN devices.session_stamp IS NULL OR devices.session_stamp = ? THEN excluded.session_stamp ELSE devices.session_stamp END, ' +
|
||||
'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), ' +
|
||||
'push_uuid=COALESCE(push_uuid, excluded.push_uuid), ' +
|
||||
'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,
|
||||
effectivePushUuid,
|
||||
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 rotateDeviceSessionStamp(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
deviceIdentifier: string,
|
||||
sessionStamp: string
|
||||
): Promise<boolean> {
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare('UPDATE devices SET session_stamp = ?, updated_at = ? WHERE user_id = ? AND device_identifier = ?')
|
||||
.bind(sessionStamp, 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, push_uuid, push_token, 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, push_uuid, push_token, 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 updateDevicePushToken(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
deviceIdentifier: string,
|
||||
pushUuid: string,
|
||||
pushToken: string
|
||||
): Promise<boolean> {
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare(
|
||||
'UPDATE devices SET push_uuid = ?, push_token = ?, updated_at = ? ' +
|
||||
'WHERE user_id = ? AND device_identifier = ?'
|
||||
)
|
||||
.bind(pushUuid, pushToken, now, userId, deviceIdentifier)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function clearDevicePushToken(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
deviceIdentifier: string
|
||||
): Promise<{ pushUuid: string | null } | null> {
|
||||
const existing = await db
|
||||
.prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
|
||||
.bind(userId, deviceIdentifier)
|
||||
.first<{ push_uuid: string | null }>();
|
||||
if (!existing) return null;
|
||||
|
||||
await db
|
||||
.prepare('UPDATE devices SET push_token = NULL, updated_at = ? WHERE user_id = ? AND device_identifier = ?')
|
||||
.bind(new Date().toISOString(), userId, deviceIdentifier)
|
||||
.run();
|
||||
|
||||
return { pushUuid: existing.push_uuid ?? null };
|
||||
}
|
||||
|
||||
export async function getDevicePushUuid(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
deviceIdentifier: string
|
||||
): Promise<string | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
|
||||
.bind(userId, deviceIdentifier)
|
||||
.first<{ push_uuid: string | null }>();
|
||||
return row?.push_uuid ?? null;
|
||||
}
|
||||
|
||||
export async function userHasPushDevice(db: D1Database, userId: string): Promise<boolean> {
|
||||
const row = await db
|
||||
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND push_token IS NOT NULL AND push_token <> ? LIMIT 1')
|
||||
.bind(userId, '')
|
||||
.first<{ '1': number }>();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
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 updateTrustedTwoFactorTokensExpiryByDevice(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
deviceIdentifier: string,
|
||||
expiresAtMs: number
|
||||
): Promise<number> {
|
||||
const now = Date.now();
|
||||
await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(now).run();
|
||||
const result = await db
|
||||
.prepare('UPDATE trusted_two_factor_device_tokens SET expires_at = ? WHERE user_id = ? AND device_identifier = ? AND expires_at >= ?')
|
||||
.bind(expiresAtMs, userId, deviceIdentifier, now)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0);
|
||||
}
|
||||
|
||||
export async function saveTrustedTwoFactorDeviceToken(
|
||||
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,126 @@
|
||||
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 getFolderForUser(db: D1Database, id: string, userId: string): Promise<Folder | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE id = ? AND user_id = ?')
|
||||
.bind(id, userId)
|
||||
.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 name=excluded.name, updated_at=excluded.updated_at WHERE user_id=excluded.user_id'
|
||||
)
|
||||
.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 = ?
|
||||
OR json_extract(data, '$.folderId') = ?
|
||||
OR json_extract(data, '$.folder_id') = ?
|
||||
)`
|
||||
)
|
||||
.bind(now, userId, folderId, folderId, folderId)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function bulkDeleteFolders(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
ids: string[],
|
||||
sqlChunkSize: (fixedBindCount: number, bindCountPerItem?: 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();
|
||||
// Each folder ID is bound in all three compatibility predicates below.
|
||||
const chunkSize = sqlChunkSize(2, 3);
|
||||
const statements: D1PreparedStatement[] = [];
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
statements.push(
|
||||
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})
|
||||
OR json_extract(data, '$.folderId') IN (${placeholders})
|
||||
OR json_extract(data, '$.folder_id') IN (${placeholders})
|
||||
)`
|
||||
)
|
||||
.bind(now, userId, ...chunk, ...chunk, ...chunk)
|
||||
);
|
||||
statements.push(
|
||||
db.prepare(`DELETE FROM folders WHERE user_id = ? AND id IN (${placeholders})`)
|
||||
.bind(userId, ...chunk)
|
||||
);
|
||||
}
|
||||
|
||||
await db.batch(statements);
|
||||
|
||||
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,157 @@
|
||||
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,
|
||||
securityStamp?: string | null,
|
||||
clientType?: string | null,
|
||||
absoluteExpiresAtMs?: number | null
|
||||
): Promise<void> {
|
||||
await maybeCleanupExpiredRefreshTokens(Date.now());
|
||||
const tokenKey = await refreshTokenKey(token);
|
||||
const now = Date.now();
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO refresh_tokens(token, user_id, expires_at, device_identifier, device_session_stamp, security_stamp, created_at, last_used_at, absolute_expires_at, client_type) ' +
|
||||
'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, security_stamp=excluded.security_stamp, last_used_at=excluded.last_used_at, ' +
|
||||
'absolute_expires_at=excluded.absolute_expires_at, client_type=excluded.client_type'
|
||||
)
|
||||
.bind(
|
||||
tokenKey,
|
||||
userId,
|
||||
expiresAtMs,
|
||||
deviceIdentifier ?? null,
|
||||
deviceSessionStamp ?? null,
|
||||
securityStamp ?? null,
|
||||
now,
|
||||
now,
|
||||
absoluteExpiresAtMs ?? null,
|
||||
clientType ?? null
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function getRefreshTokenRecord(
|
||||
db: D1Database,
|
||||
refreshTokenKey: RefreshTokenKeyFn,
|
||||
maybeCleanupExpiredRefreshTokens: CleanupExpiredFn,
|
||||
deleteRefreshTokenRecord: (token: string) => Promise<void>,
|
||||
token: string
|
||||
): Promise<RefreshTokenRecord | null> {
|
||||
const now = Date.now();
|
||||
await maybeCleanupExpiredRefreshTokens(now);
|
||||
const tokenKey = await refreshTokenKey(token);
|
||||
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT user_id, expires_at, device_identifier, device_session_stamp, security_stamp, created_at, last_used_at, absolute_expires_at, client_type ' +
|
||||
'FROM refresh_tokens WHERE token = ?'
|
||||
)
|
||||
.bind(tokenKey)
|
||||
.first<{
|
||||
user_id: string;
|
||||
expires_at: number;
|
||||
device_identifier: string | null;
|
||||
device_session_stamp: string | null;
|
||||
security_stamp: string | null;
|
||||
created_at: number | null;
|
||||
last_used_at: number | null;
|
||||
absolute_expires_at: number | null;
|
||||
client_type: string | null;
|
||||
}>();
|
||||
|
||||
if (!row) return null;
|
||||
if ((row.expires_at && row.expires_at < now) || (row.absolute_expires_at && row.absolute_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,
|
||||
securityStamp: row.security_stamp ?? null,
|
||||
createdAt: row.created_at ?? null,
|
||||
lastUsedAt: row.last_used_at ?? null,
|
||||
absoluteExpiresAt: row.absolute_expires_at ?? null,
|
||||
clientType: row.client_type ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function extendRefreshTokenExpiry(
|
||||
db: D1Database,
|
||||
refreshTokenKey: RefreshTokenKeyFn,
|
||||
token: string,
|
||||
requestedExpiresAtMs: number,
|
||||
nowMs: number
|
||||
): Promise<boolean> {
|
||||
const tokenKey = await refreshTokenKey(token);
|
||||
const result = await db
|
||||
.prepare(
|
||||
'UPDATE refresh_tokens SET ' +
|
||||
'expires_at = CASE ' +
|
||||
'WHEN absolute_expires_at IS NOT NULL AND absolute_expires_at < ? THEN absolute_expires_at ' +
|
||||
'ELSE ? END, ' +
|
||||
'last_used_at = ? ' +
|
||||
'WHERE token = ? AND expires_at >= ? AND (absolute_expires_at IS NULL OR absolute_expires_at >= ?)'
|
||||
)
|
||||
.bind(requestedExpiresAtMs, requestedExpiresAtMs, nowMs, tokenKey, nowMs, nowMs)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function bindRefreshTokenSecurityStamp(
|
||||
db: D1Database,
|
||||
refreshTokenKey: RefreshTokenKeyFn,
|
||||
token: string,
|
||||
securityStamp: string
|
||||
): Promise<void> {
|
||||
const tokenKey = await refreshTokenKey(token);
|
||||
await db
|
||||
.prepare('UPDATE refresh_tokens SET security_stamp = ? WHERE token = ? AND (security_stamp IS NULL OR security_stamp = ?)')
|
||||
.bind(securityStamp, tokenKey, '')
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function bindRefreshTokenDeviceStamp(
|
||||
db: D1Database,
|
||||
refreshTokenKey: RefreshTokenKeyFn,
|
||||
token: string,
|
||||
deviceSessionStamp: string
|
||||
): Promise<void> {
|
||||
const tokenKey = await refreshTokenKey(token);
|
||||
await db
|
||||
.prepare('UPDATE refresh_tokens SET device_session_stamp = ? WHERE token = ? AND (device_session_stamp IS NULL OR device_session_stamp = ?)')
|
||||
.bind(deviceSessionStamp, tokenKey, '')
|
||||
.run();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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,209 @@
|
||||
// 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 0, totp_secret TEXT, totp_recovery_code TEXT, yubikey_key1 TEXT, yubikey_key2 TEXT, yubikey_key3 TEXT, yubikey_key4 TEXT, yubikey_key5 TEXT, yubikey_nfc INTEGER NOT NULL DEFAULT 0, 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 0',
|
||||
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
|
||||
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key1 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key2 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key3 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key4 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key5 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_nfc INTEGER NOT NULL DEFAULT 0',
|
||||
'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, security_stamp TEXT, created_at INTEGER, last_used_at INTEGER, absolute_expires_at INTEGER, client_type 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',
|
||||
'ALTER TABLE refresh_tokens ADD COLUMN security_stamp TEXT',
|
||||
'ALTER TABLE refresh_tokens ADD COLUMN created_at INTEGER',
|
||||
'ALTER TABLE refresh_tokens ADD COLUMN last_used_at INTEGER',
|
||||
'ALTER TABLE refresh_tokens ADD COLUMN absolute_expires_at INTEGER',
|
||||
'ALTER TABLE refresh_tokens ADD COLUMN client_type TEXT',
|
||||
"UPDATE refresh_tokens SET security_stamp = (SELECT users.security_stamp FROM users WHERE users.id = refresh_tokens.user_id) WHERE security_stamp IS NULL OR security_stamp = ''",
|
||||
"UPDATE refresh_tokens SET created_at = CAST(strftime('%s','now') AS INTEGER) * 1000 WHERE created_at IS NULL",
|
||||
"UPDATE refresh_tokens SET last_used_at = created_at WHERE last_used_at IS NULL",
|
||||
'UPDATE refresh_tokens SET absolute_expires_at = expires_at WHERE absolute_expires_at IS NULL',
|
||||
|
||||
'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)',
|
||||
'ALTER TABLE invites ADD COLUMN used_by TEXT',
|
||||
'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, category TEXT NOT NULL DEFAULT \'system\', level TEXT NOT NULL DEFAULT \'info\', target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL)',
|
||||
'ALTER TABLE audit_logs ADD COLUMN category TEXT NOT NULL DEFAULT \'system\'',
|
||||
'ALTER TABLE audit_logs ADD COLUMN level TEXT NOT NULL DEFAULT \'info\'',
|
||||
'UPDATE audit_logs SET category = json_extract(metadata, \'$.category\') WHERE json_valid(metadata) AND json_extract(metadata, \'$.category\') IN (\'auth\', \'security\', \'device\', \'data\', \'system\')',
|
||||
'UPDATE audit_logs SET level = json_extract(metadata, \'$.level\') WHERE json_valid(metadata) AND json_extract(metadata, \'$.level\') IN (\'info\', \'warn\', \'error\', \'security\')',
|
||||
'CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_audit_logs_category_created ON audit_logs(category, created_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS devices (' +
|
||||
'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, push_uuid TEXT, push_token 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 push_uuid TEXT',
|
||||
'ALTER TABLE devices ADD COLUMN push_token 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 INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token)',
|
||||
"UPDATE refresh_tokens SET device_session_stamp = (SELECT devices.session_stamp FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier) WHERE device_identifier IS NOT NULL AND (device_session_stamp IS NULL OR device_session_stamp = '') AND EXISTS (SELECT 1 FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier)",
|
||||
"UPDATE refresh_tokens SET client_type = CASE WHEN EXISTS (SELECT 1 FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier AND devices.type IN (0, 1)) THEN 'mobile' WHEN EXISTS (SELECT 1 FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier AND devices.type = 14) THEN 'web' ELSE 'other' END WHERE client_type IS NULL OR client_type = ''",
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS auth_requests (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, type INTEGER NOT NULL, request_device_identifier TEXT NOT NULL, request_device_type INTEGER NOT NULL, ' +
|
||||
'request_ip_address TEXT, request_country_name TEXT, response_device_identifier TEXT, access_code TEXT NOT NULL, public_key TEXT NOT NULL, key TEXT, master_password_hash TEXT, ' +
|
||||
'approved INTEGER, creation_date TEXT NOT NULL, response_date TEXT, authentication_date TEXT, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_auth_requests_user_created ON auth_requests(user_id, creation_date)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_auth_requests_user_pending ON auth_requests(user_id, approved, response_date, authentication_date, creation_date)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_auth_requests_device_pending ON auth_requests(user_id, request_device_identifier, creation_date)',
|
||||
|
||||
'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 totp_login_replays (' +
|
||||
'user_id TEXT NOT NULL, time_counter INTEGER NOT NULL, consumed_at INTEGER NOT NULL, ' +
|
||||
'PRIMARY KEY (user_id, time_counter), ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at ON totp_login_replays(consumed_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, purpose TEXT NOT NULL DEFAULT \'login\', name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
|
||||
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'ALTER TABLE webauthn_credentials ADD COLUMN purpose TEXT NOT NULL DEFAULT \'login\'',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_credential_id ON webauthn_credentials(credential_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated ON webauthn_credentials(user_id, updated_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS webauthn_challenges (' +
|
||||
'challenge_hash TEXT PRIMARY KEY, scope TEXT NOT NULL, user_id TEXT, expires_at INTEGER NOT NULL, used_at INTEGER, created_at INTEGER NOT NULL)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user_scope ON webauthn_challenges(user_id, scope)',
|
||||
|
||||
'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,179 @@
|
||||
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 getSendForUser(db: D1Database, id: string, userId: 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 = ? AND user_id = ?'
|
||||
)
|
||||
.bind(id, userId)
|
||||
.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 ' +
|
||||
'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 ' +
|
||||
'WHERE user_id=excluded.user_id'
|
||||
);
|
||||
|
||||
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 disabled = 0 ' +
|
||||
'AND (max_access_count IS NULL OR access_count < max_access_count) ' +
|
||||
'AND (expiration_date IS NULL OR expiration_date > ?) ' +
|
||||
'AND deletion_date > ?'
|
||||
)
|
||||
.bind(now, sendId, now, now)
|
||||
.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,35 @@
|
||||
type ShouldRunPeriodicCleanup = (lastRunAt: number, intervalMs: number) => boolean;
|
||||
|
||||
export async function consumeTotpLoginCounter(
|
||||
db: D1Database,
|
||||
shouldRunPeriodicCleanup: ShouldRunPeriodicCleanup,
|
||||
lastCleanupAt: number,
|
||||
cleanupIntervalMs: number,
|
||||
userId: string,
|
||||
timeCounter: number,
|
||||
consumedAtMs: number,
|
||||
markerTtlMs: number
|
||||
): Promise<{ consumed: boolean; cleanedUpAt: number | null }> {
|
||||
let cleanedUpAt: number | null = null;
|
||||
|
||||
if (shouldRunPeriodicCleanup(lastCleanupAt, cleanupIntervalMs)) {
|
||||
await db
|
||||
.prepare('DELETE FROM totp_login_replays WHERE consumed_at < ?')
|
||||
.bind(consumedAtMs - markerTtlMs)
|
||||
.run();
|
||||
cleanedUpAt = consumedAtMs;
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.prepare(
|
||||
'INSERT INTO totp_login_replays(user_id, time_counter, consumed_at) VALUES(?, ?, ?) ' +
|
||||
'ON CONFLICT(user_id, time_counter) DO NOTHING'
|
||||
)
|
||||
.bind(userId, timeCounter, consumedAtMs)
|
||||
.run();
|
||||
|
||||
return {
|
||||
consumed: (result.meta.changes ?? 0) > 0,
|
||||
cleanedUpAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
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, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, 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 ? false : !!row.verify_devices,
|
||||
totpSecret: row.totp_secret ?? null,
|
||||
totpRecoveryCode: row.totp_recovery_code ?? null,
|
||||
yubikeyKey1: row.yubikey_key1 ?? null,
|
||||
yubikeyKey2: row.yubikey_key2 ?? null,
|
||||
yubikeyKey3: row.yubikey_key3 ?? null,
|
||||
yubikeyKey4: row.yubikey_key4 ?? null,
|
||||
yubikeyKey5: row.yubikey_key5 ?? null,
|
||||
yubikeyNfc: !!row.yubikey_nfc,
|
||||
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, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, 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, yubikey_key1=excluded.yubikey_key1, yubikey_key2=excluded.yubikey_key2, yubikey_key3=excluded.yubikey_key3, yubikey_key4=excluded.yubikey_key4, yubikey_key5=excluded.yubikey_key5, yubikey_nfc=excluded.yubikey_nfc, 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.yubikeyKey1,
|
||||
user.yubikeyKey2,
|
||||
user.yubikeyKey3,
|
||||
user.yubikeyKey4,
|
||||
user.yubikeyKey5,
|
||||
user.yubikeyNfc ? 1 : 0,
|
||||
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, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, 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.yubikeyKey1,
|
||||
user.yubikeyKey2,
|
||||
user.yubikeyKey3,
|
||||
user.yubikeyKey4,
|
||||
user.yubikeyKey5,
|
||||
user.yubikeyNfc ? 1 : 0,
|
||||
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;
|
||||
}
|
||||
+775
-490
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user