mirror of
https://github.com/usememos/memos.git
synced 2025-06-05 22:09:59 +02:00
Compare commits
995 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0916ec35da | ||
|
|
9f4f2e8e27 | ||
|
|
82009d3147 | ||
|
|
0127e08a28 | ||
|
|
d275713aff | ||
|
|
c50f4f4cb4 | ||
|
|
fa34a7af4b | ||
|
|
77b75aa6c4 | ||
|
|
9faee68dab | ||
|
|
4f05c972d5 | ||
|
|
abda6ad041 | ||
|
|
7fc7b19d64 | ||
|
|
b2d898dc15 | ||
|
|
15425093af | ||
|
|
b02aa2d5e5 | ||
|
|
c68bfcc3b9 | ||
|
|
2964cf93ab | ||
|
|
787cf2a9fe | ||
|
|
ed190cd41e | ||
|
|
33dda9bf87 | ||
|
|
fa6693a7ae | ||
|
|
055a327b5e | ||
|
|
aff1b47072 | ||
|
|
5f86769255 | ||
|
|
9c18960f47 | ||
|
|
484efbbfe2 | ||
|
|
e83d483454 | ||
|
|
b944418257 | ||
|
|
4ddd3caec7 | ||
|
|
c1f55abaeb | ||
|
|
fff42ebc0d | ||
|
|
2437419b7f | ||
|
|
e53cedaf14 | ||
|
|
6d469fd997 | ||
|
|
9552cddc93 | ||
|
|
34181243e5 | ||
|
|
a0eb891132 | ||
|
|
e136355408 | ||
|
|
5069476dcc | ||
|
|
f950750d56 | ||
|
|
0026f9e54f | ||
|
|
f8f73d117b | ||
|
|
8586ebf098 | ||
|
|
472afce98f | ||
|
|
a12844f5db | ||
|
|
bc965f6afa | ||
|
|
db95b94c9a | ||
|
|
1a5bce49c2 | ||
|
|
436eb0e591 | ||
|
|
e016244aba | ||
|
|
d317b03832 | ||
|
|
3e138405b3 | ||
|
|
0dd0714ad0 | ||
|
|
45d7d0d5f6 | ||
|
|
c3db4ee236 | ||
|
|
91296257fc | ||
|
|
e5f660a006 | ||
|
|
c0628ef95b | ||
|
|
c0b5070e46 | ||
|
|
b1128fc786 | ||
|
|
bcd8a5a7a9 | ||
|
|
0cf280fa9a | ||
|
|
b11653658d | ||
|
|
adf96a47bb | ||
|
|
6529375a8b | ||
|
|
e7e83874cd | ||
|
|
7ef125e3af | ||
|
|
dfa14689e4 | ||
|
|
ec2995d64a | ||
|
|
94c71cb834 | ||
|
|
7f7ddf77b8 | ||
|
|
089cd3de87 | ||
|
|
2d34615eac | ||
|
|
4da3c1d5e5 | ||
|
|
4f222bca5c | ||
|
|
0bb0407f46 | ||
|
|
8bc117bce9 | ||
|
|
afd0e72e37 | ||
|
|
d758ba2702 | ||
|
|
0f126ec217 | ||
|
|
c1a6dc9bac | ||
|
|
6ee95a2c0c | ||
|
|
6814915c88 | ||
|
|
52fdf8bccd | ||
|
|
f67757f606 | ||
|
|
38f05fd6f2 | ||
|
|
65022beb0d | ||
|
|
5d81338aca | ||
|
|
c288d49138 | ||
|
|
0ea0645258 | ||
|
|
9227ca5b5b | ||
|
|
eb6b0ddead | ||
|
|
dca90fb5d2 | ||
|
|
172e27016b | ||
|
|
6da2ff7ffb | ||
|
|
6c433b452f | ||
|
|
65a34ee41a | ||
|
|
5ff0234c71 | ||
|
|
e76509a577 | ||
|
|
4499f45b67 | ||
|
|
504d1768f2 | ||
|
|
caea065594 | ||
|
|
2ee426386a | ||
|
|
9df05fe0fa | ||
|
|
184f79ef8e | ||
|
|
35f0861d6e | ||
|
|
c4d27e7a78 | ||
|
|
7e545533cf | ||
|
|
32cafbff9b | ||
|
|
9c4f72c96e | ||
|
|
5e4493b227 | ||
|
|
834b58fbbd | ||
|
|
342d1aeefb | ||
|
|
363c107359 | ||
|
|
0458269e15 | ||
|
|
64d4db81ca | ||
|
|
865cc997a4 | ||
|
|
981bfe0464 | ||
|
|
695fb1e0ca | ||
|
|
21ad6cc871 | ||
|
|
c24181b2be | ||
|
|
39a0e69b04 | ||
|
|
e60e47f76f | ||
|
|
e67820cabe | ||
|
|
3266c3a58a | ||
|
|
ef820a1138 | ||
|
|
137e64b0dd | ||
|
|
982b0285c9 | ||
|
|
405fc2b4d2 | ||
|
|
eacd3e1c17 | ||
|
|
a62f1e15a6 | ||
|
|
8b0083ffc5 | ||
|
|
5d69d89627 | ||
|
|
b966c16dd5 | ||
|
|
97190645cc | ||
|
|
c26417de70 | ||
|
|
f5c1e79195 | ||
|
|
d02105ca30 | ||
|
|
44e50797ca | ||
|
|
7058f0c8c2 | ||
|
|
f532ccdf94 | ||
|
|
a6fcdfce05 | ||
|
|
dca712d273 | ||
|
|
ac81d856f6 | ||
|
|
88fb79e458 | ||
|
|
480c53d7a2 | ||
|
|
2b7d7c95a5 | ||
|
|
0ee938c38b | ||
|
|
3c36cc2953 | ||
|
|
79bb3253b6 | ||
|
|
18107248aa | ||
|
|
4f1bb55e55 | ||
|
|
20d3abb99a | ||
|
|
1b34119e60 | ||
|
|
9d2b785be6 | ||
|
|
36b4ba33fa | ||
|
|
625ebbea1a | ||
|
|
0f4e5857f0 | ||
|
|
76d955a69a | ||
|
|
e41ea445c9 | ||
|
|
c952651dc1 | ||
|
|
58e771a1d7 | ||
|
|
e876ed3717 | ||
|
|
67d2e4ebcb | ||
|
|
4ea78fa1a2 | ||
|
|
93b8e2211c | ||
|
|
052216c471 | ||
|
|
e5978a70f5 | ||
|
|
59f0ee862d | ||
|
|
215981dfde | ||
|
|
bfdb33f26b | ||
|
|
5b3af827e1 | ||
|
|
9859d77cba | ||
|
|
064c930aed | ||
|
|
043357d7dc | ||
|
|
3a5deefe11 | ||
|
|
2c71371b29 | ||
|
|
222e6b28b7 | ||
|
|
496cde87b2 | ||
|
|
79bbe4b82a | ||
|
|
035d71e07c | ||
|
|
82effea070 | ||
|
|
331f4dcc1b | ||
|
|
055b246857 | ||
|
|
8fc9a318a4 | ||
|
|
9aed80a4fd | ||
|
|
c31f306b5b | ||
|
|
bfd2dbfee2 | ||
|
|
c42af95dd3 | ||
|
|
89a073adc0 | ||
|
|
1c2d82a62f | ||
|
|
02f7a36fa4 | ||
|
|
a76f762196 | ||
|
|
2c2955a229 | ||
|
|
d06d01cef2 | ||
|
|
4b91738f21 | ||
|
|
12fd8f34be | ||
|
|
c2ab05d422 | ||
|
|
7b25b8c1e1 | ||
|
|
af7c0a76d0 | ||
|
|
664c9c4a7c | ||
|
|
fd5d51ee54 | ||
|
|
1b105db958 | ||
|
|
a541e8d3e3 | ||
|
|
6f2ca6c87a | ||
|
|
952539f817 | ||
|
|
c87b679f41 | ||
|
|
e6b20c5246 | ||
|
|
0bfcff676c | ||
|
|
37601e5d03 | ||
|
|
21c70e7993 | ||
|
|
22d331d6c4 | ||
|
|
9bfb2d60b9 | ||
|
|
203b2d9181 | ||
|
|
ddc4566dcb | ||
|
|
1755c9dc79 | ||
|
|
a5df36eff2 | ||
|
|
e30d0c2dd0 | ||
|
|
213c2ea71b | ||
|
|
73f59eaf09 | ||
|
|
c999d71455 | ||
|
|
5359d5a66d | ||
|
|
c58820fa64 | ||
|
|
cfc5599334 | ||
|
|
c02f3c0a7d | ||
|
|
dd83358850 | ||
|
|
d95a6ce898 | ||
|
|
7e80e14f16 | ||
|
|
219304e38d | ||
|
|
20e5597104 | ||
|
|
ed2e299797 | ||
|
|
bacc529391 | ||
|
|
ed1ff11e80 | ||
|
|
a0f8e6987c | ||
|
|
d3e32f0d5a | ||
|
|
95bfcb8055 | ||
|
|
096c489eb3 | ||
|
|
b6425f9004 | ||
|
|
ab2c86640b | ||
|
|
1489feb054 | ||
|
|
acdeabb861 | ||
|
|
6bb6c043e5 | ||
|
|
fa2bba51c1 | ||
|
|
3822c26e32 | ||
|
|
425b43b3bb | ||
|
|
c00dac1bbf | ||
|
|
3ff4d19782 | ||
|
|
31997936d6 | ||
|
|
287f1beb90 | ||
|
|
7680be1a2f | ||
|
|
55e0fbf24e | ||
|
|
eaac17a236 | ||
|
|
1fbd568dfe | ||
|
|
c0619ef4a4 | ||
|
|
b2aa66b4fd | ||
|
|
dfaf2ee29c | ||
|
|
b938c8d7b6 | ||
|
|
553de3cc7e | ||
|
|
73980e9644 | ||
|
|
087e631dd8 | ||
|
|
76fb280720 | ||
|
|
6ffc09d86a | ||
|
|
125c9c92eb | ||
|
|
15eb95f964 | ||
|
|
ed96d65645 | ||
|
|
9c2f87ec2e | ||
|
|
19f6ed5530 | ||
|
|
57c5a92427 | ||
|
|
9410570195 | ||
|
|
c0422dea5b | ||
|
|
7791fb10d8 | ||
|
|
a6ee61e96d | ||
|
|
99d9bd2d75 | ||
|
|
e7aeca736b | ||
|
|
e85f325eb2 | ||
|
|
7dcc5cbaf1 | ||
|
|
1cdd70e008 | ||
|
|
6a11fc571d | ||
|
|
771fe394fd | ||
|
|
d474d1abd0 | ||
|
|
576111741b | ||
|
|
9ac44dfbd9 | ||
|
|
110b53b899 | ||
|
|
42e8d51550 | ||
|
|
fd7043ea40 | ||
|
|
34ae9b0687 | ||
|
|
077bf95425 | ||
|
|
e465b2f0e8 | ||
|
|
01ff3f73f8 | ||
|
|
8aae0d00cd | ||
|
|
16dad8b00d | ||
|
|
7dc4bc5714 | ||
|
|
2e82ba50f2 | ||
|
|
1542f3172a | ||
|
|
69d575fd5b | ||
|
|
607fecf437 | ||
|
|
91f7839b31 | ||
|
|
078bc164d5 | ||
|
|
70f464e6f2 | ||
|
|
e40621eb0f | ||
|
|
fd395e5661 | ||
|
|
be046cae8e | ||
|
|
922de07751 | ||
|
|
7549c807ac | ||
|
|
de5eccf9d6 | ||
|
|
952225d1da | ||
|
|
a928c4f845 | ||
|
|
73e189ea61 | ||
|
|
8168fb71a8 | ||
|
|
87ddeb2c79 | ||
|
|
255254eb69 | ||
|
|
c72f221fc0 | ||
|
|
4ca2b551f5 | ||
|
|
5b6b2f0528 | ||
|
|
fbbfb11916 | ||
|
|
c54febd024 | ||
|
|
5ebf920a61 | ||
|
|
5121e9f954 | ||
|
|
ca98367a0a | ||
|
|
9abf294eed | ||
|
|
9ce22e849c | ||
|
|
58b84f83d1 | ||
|
|
acbde4fb2d | ||
|
|
53090a7273 | ||
|
|
71ee299de7 | ||
|
|
9d1c9fc505 | ||
|
|
03a0972712 | ||
|
|
0bddbba00e | ||
|
|
6007f48b7d | ||
|
|
4f10198ec0 | ||
|
|
7722c41680 | ||
|
|
7cdc5c711c | ||
|
|
4180cc3a3d | ||
|
|
d6789550a0 | ||
|
|
d68da34eec | ||
|
|
63b55c4f65 | ||
|
|
96395b6d75 | ||
|
|
d3a6fa50d6 | ||
|
|
47f22a20ba | ||
|
|
14ec524805 | ||
|
|
fcba3ffa26 | ||
|
|
41eba71f0f | ||
|
|
85ed0202d8 | ||
|
|
745902e8b1 | ||
|
|
ad3487a9ac | ||
|
|
8c2f89edc5 | ||
|
|
6cff920f0c | ||
|
|
27f3f6fbf0 | ||
|
|
89c24415a6 | ||
|
|
0d803bf45f | ||
|
|
d4e54f343d | ||
|
|
08a81e79dd | ||
|
|
cad789e948 | ||
|
|
4de18cfab1 | ||
|
|
5cec1a71da | ||
|
|
ae1e22931f | ||
|
|
a60d4dee41 | ||
|
|
7da10cd367 | ||
|
|
6d45616dbe | ||
|
|
ad326147f1 | ||
|
|
465b173b36 | ||
|
|
9bf1979fa8 | ||
|
|
0a811e19ba | ||
|
|
e119acb0e9 | ||
|
|
0f1e87bd93 | ||
|
|
f9f2f549af | ||
|
|
d665adf78b | ||
|
|
1c27824e58 | ||
|
|
14adcb56da | ||
|
|
b452d63fa6 | ||
|
|
e2b82929ab | ||
|
|
8fbd33be09 | ||
|
|
bff41a8957 | ||
|
|
2375001453 | ||
|
|
462f10ab60 | ||
|
|
58026c52ea | ||
|
|
97b434722c | ||
|
|
b22d236b19 | ||
|
|
cc809a5c06 | ||
|
|
cd0ea6558d | ||
|
|
9eb077c4af | ||
|
|
6eeee6b704 | ||
|
|
b13042d644 | ||
|
|
d09e3c3658 | ||
|
|
72ca4e74ee | ||
|
|
d5c1706e9c | ||
|
|
3a1f82effa | ||
|
|
a3d7cc9392 | ||
|
|
178a5c0130 | ||
|
|
b233eaea97 | ||
|
|
51137e01ef | ||
|
|
fb1490c183 | ||
|
|
4424c8a231 | ||
|
|
723e6bcdae | ||
|
|
d1156aa755 | ||
|
|
4e49d3cb22 | ||
|
|
13c7871d20 | ||
|
|
137c8f8a07 | ||
|
|
0c0c72c3ca | ||
|
|
e6a90a8be8 | ||
|
|
e65282dcc5 | ||
|
|
28a1888163 | ||
|
|
8824ee9b9d | ||
|
|
936fe5ac9d | ||
|
|
f5802a7d82 | ||
|
|
33d9c13b7e | ||
|
|
42bd9b194b | ||
|
|
41e26f56e9 | ||
|
|
14aa3224ce | ||
|
|
8a796d12b4 | ||
|
|
c87df8791b | ||
|
|
f0f42aea9f | ||
|
|
626ff5e3a7 | ||
|
|
36209eaef1 | ||
|
|
d63715d4d9 | ||
|
|
9600fbb609 | ||
|
|
04595a5fb1 | ||
|
|
9a0ada6756 | ||
|
|
416e07cb1f | ||
|
|
58429f88a0 | ||
|
|
439d88f06b | ||
|
|
d165ad187c | ||
|
|
319f679e30 | ||
|
|
b6d1ded668 | ||
|
|
3ad0832516 | ||
|
|
93f062d0b9 | ||
|
|
866937787c | ||
|
|
ca336af4fa | ||
|
|
7ec5d07cb8 | ||
|
|
2e79fe12e2 | ||
|
|
3df550927d | ||
|
|
44be7201c0 | ||
|
|
0d50f5bd08 | ||
|
|
8b1f7c52aa | ||
|
|
9987337eca | ||
|
|
87a1d4633e | ||
|
|
c2aeec20b7 | ||
|
|
a5b3fb2a6a | ||
|
|
c67a69629e | ||
|
|
18fb02a1ec | ||
|
|
ad1822d308 | ||
|
|
4af0d03e93 | ||
|
|
8c312e647d | ||
|
|
d3bd3ddab0 | ||
|
|
6c01e84099 | ||
|
|
b9b795bf0e | ||
|
|
19e7731abb | ||
|
|
609b24f2ba | ||
|
|
735cfda768 | ||
|
|
3f82729e9f | ||
|
|
077cfeb831 | ||
|
|
95588542f9 | ||
|
|
dd529f845a | ||
|
|
9f8a0a8dd3 | ||
|
|
e266d88edd | ||
|
|
0bb5f7f972 | ||
|
|
012a5f4907 | ||
|
|
409d686f7d | ||
|
|
c835231d32 | ||
|
|
723c444910 | ||
|
|
35f2d399e2 | ||
|
|
4491c75135 | ||
|
|
513002ff60 | ||
|
|
9693940010 | ||
|
|
8747c58c7d | ||
|
|
0fd791c02d | ||
|
|
3a804ce012 | ||
|
|
f864ec3730 | ||
|
|
9503f73115 | ||
|
|
f9d1080a7d | ||
|
|
4d3e4358e8 | ||
|
|
843850675f | ||
|
|
726300394b | ||
|
|
5d5d8de9fe | ||
|
|
e097e8331e | ||
|
|
7189ba40d3 | ||
|
|
218159bf83 | ||
|
|
238f896907 | ||
|
|
270a529948 | ||
|
|
cc400da44e | ||
|
|
3df9da91b4 | ||
|
|
57dd1fc49f | ||
|
|
7c5296cf35 | ||
|
|
cbe27923b3 | ||
|
|
aa26cc30d7 | ||
|
|
1ce82ba0d6 | ||
|
|
d1b0b0da10 | ||
|
|
11abc45440 | ||
|
|
b5a6f1f997 | ||
|
|
d114b630d2 | ||
|
|
5f819fc86f | ||
|
|
cc3a47fc65 | ||
|
|
5d3ea57d82 | ||
|
|
c1cbfd5766 | ||
|
|
9ef0f8a901 | ||
|
|
470fe1df49 | ||
|
|
2107ac08d7 | ||
|
|
89ba2a6540 | ||
|
|
9cedb3cc6c | ||
|
|
d0cfb62f35 | ||
|
|
9abf0eca1b | ||
|
|
a6a1898c41 | ||
|
|
f5793c142c | ||
|
|
8f37c77dff | ||
|
|
28aecd86d3 | ||
|
|
95675cdf07 | ||
|
|
8328b5dd4a | ||
|
|
d8d6de9fca | ||
|
|
56c321aeaa | ||
|
|
756e6a150c | ||
|
|
828984c8ec | ||
|
|
9da0ca5cb3 | ||
|
|
dc5f82ac9c | ||
|
|
d000083b41 | ||
|
|
a9eb605b0f | ||
|
|
5604129105 | ||
|
|
04b7a26c03 | ||
|
|
28203bbaf9 | ||
|
|
9138ab8095 | ||
|
|
4231ec5a1a | ||
|
|
55975a46d8 | ||
|
|
1182545448 | ||
|
|
9f3c3ae094 | ||
|
|
4c33d8d762 | ||
|
|
c8961ad489 | ||
|
|
f91f09adea | ||
|
|
336b32004d | ||
|
|
7b5c13b712 | ||
|
|
8bcc2bd715 | ||
|
|
83b771d5cd | ||
|
|
8dbc63ed56 | ||
|
|
8c61531671 | ||
|
|
b5d4b8eae8 | ||
|
|
e36e5823cd | ||
|
|
4ac63ba1f0 | ||
|
|
589b104671 | ||
|
|
220cba84ae | ||
|
|
032509ddba | ||
|
|
054ef3dc8d | ||
|
|
2effacd0a6 | ||
|
|
01f4780655 | ||
|
|
49dd90578b | ||
|
|
1780225da5 | ||
|
|
5e20094386 | ||
|
|
40a30d46af | ||
|
|
6b17a27a13 | ||
|
|
2a7104e564 | ||
|
|
8ca2dac184 | ||
|
|
d9b3501fae | ||
|
|
39351970d0 | ||
|
|
06dbd87311 | ||
|
|
c5a1f4c839 | ||
|
|
f074bb1be2 | ||
|
|
90e7d02e35 | ||
|
|
437e05bd2f | ||
|
|
934f57c92f | ||
|
|
3093f80d68 | ||
|
|
11aa01ee2e | ||
|
|
d8b6e92813 | ||
|
|
6adbb7419c | ||
|
|
d4b88c6c86 | ||
|
|
698380f940 | ||
|
|
dcac442ebf | ||
|
|
da70917b08 | ||
|
|
0292f472e0 | ||
|
|
7e391bd53d | ||
|
|
2157651d17 | ||
|
|
0e05c62a3b | ||
|
|
a7573d5705 | ||
|
|
1fa9f162a5 | ||
|
|
5ea561af3d | ||
|
|
2033b0c8fa | ||
|
|
1c07ae2650 | ||
|
|
5b6c98582e | ||
|
|
0af14fc81a | ||
|
|
833fd23820 | ||
|
|
d46126c5c3 | ||
|
|
811c3e8844 | ||
|
|
223404a240 | ||
|
|
31373be172 | ||
|
|
66e65e4dc1 | ||
|
|
b84ecc4574 | ||
|
|
9a8d43bf88 | ||
|
|
ca770c87d6 | ||
|
|
c83fd1de38 | ||
|
|
db8b8f0d58 | ||
|
|
30fae208c2 | ||
|
|
5fe644a3b6 | ||
|
|
a108f5e212 | ||
|
|
c9aa2eeb98 | ||
|
|
63d6b6f9f9 | ||
|
|
847b4605f4 | ||
|
|
6b3748e2a3 | ||
|
|
6a78887f1d | ||
|
|
7226a9ad47 | ||
|
|
b44f2b5ffb | ||
|
|
07e82c3f4a | ||
|
|
b34aded376 | ||
|
|
4ed9a3a0ea | ||
|
|
f1d85eeaec | ||
|
|
1b0efc5548 | ||
|
|
6dca551164 | ||
|
|
7694597728 | ||
|
|
4d59689126 | ||
|
|
8f7001cd9f | ||
|
|
781b1f7b3a | ||
|
|
e6c9f2a00e | ||
|
|
5d06c8093c | ||
|
|
c396cc9649 | ||
|
|
ac5d8b47ca | ||
|
|
77e8e9ebbd | ||
|
|
c14c6b3786 | ||
|
|
c27c6cea13 | ||
|
|
11a385cda6 | ||
|
|
32e2f1d339 | ||
|
|
69225b507b | ||
|
|
2f905eed0d | ||
|
|
55cf19aa2e | ||
|
|
dd8c10743d | ||
|
|
25ce36e495 | ||
|
|
d205a7683a | ||
|
|
7e4d71cf58 | ||
|
|
97df1a82c7 | ||
|
|
845297ec03 | ||
|
|
ddf4cae537 | ||
|
|
ce64894abe | ||
|
|
beb4d8ccb9 | ||
|
|
e0e59c5831 | ||
|
|
826541a714 | ||
|
|
c40aeb91e6 | ||
|
|
2e34ce90a1 | ||
|
|
93d608f050 | ||
|
|
ec26a9702d | ||
|
|
dbe8aa1d3a | ||
|
|
8628d1e4b2 | ||
|
|
4ea5426e18 | ||
|
|
1282fe732e | ||
|
|
a07d11e820 | ||
|
|
dbc85fe7e4 | ||
|
|
523ef2bba5 | ||
|
|
de8014dfe8 | ||
|
|
b42e5c3213 | ||
|
|
ea728d232d | ||
|
|
43819b021e | ||
|
|
e69f7c735b | ||
|
|
5e792236af | ||
|
|
45c119627b | ||
|
|
65890bc257 | ||
|
|
42c653e1a4 | ||
|
|
8c34be92a6 | ||
|
|
fa53a2550a | ||
|
|
616b8b0ee6 | ||
|
|
d24632682f | ||
|
|
3b1bab651a | ||
|
|
0cea5ebaeb | ||
|
|
1e4a867a9a | ||
|
|
6bb0b4cd47 | ||
|
|
56c6f603aa | ||
|
|
98b3a371f4 | ||
|
|
ba8e1e5dc2 | ||
|
|
467f9080a1 | ||
|
|
0894bf13d2 | ||
|
|
1d7627dd72 | ||
|
|
d80aa67c97 | ||
|
|
ae1d9adf65 | ||
|
|
b40571095d | ||
|
|
04124a2ace | ||
|
|
2730b90512 | ||
|
|
34913cfc83 | ||
|
|
88799d469c | ||
|
|
a07d5d38d6 | ||
|
|
ca5859296a | ||
|
|
1a8310f027 | ||
|
|
041be46732 | ||
|
|
9eafb6bfb5 | ||
|
|
668a9e88c6 | ||
|
|
cd2bdab683 | ||
|
|
d72b4e9a98 | ||
|
|
2cc5691efd | ||
|
|
7726ed4245 | ||
|
|
921d4b996d | ||
|
|
96021e518a | ||
|
|
5c5199920e | ||
|
|
e1c809d6f1 | ||
|
|
218009a5ec | ||
|
|
5340008ad7 | ||
|
|
700fe6b0e4 | ||
|
|
9b8d69b2dd | ||
|
|
84546ff11c | ||
|
|
885a0ddad0 | ||
|
|
3b76c6792c | ||
|
|
4605349bdc | ||
|
|
ff447ad22b | ||
|
|
c081030d61 | ||
|
|
e3496ac1a2 | ||
|
|
8911ea1619 | ||
|
|
34700a4c52 | ||
|
|
b6564bcd77 | ||
|
|
6e6aae6649 | ||
|
|
b98f85d8a7 | ||
|
|
3314fe8b0e | ||
|
|
f12163bc94 | ||
|
|
f7a1680f72 | ||
|
|
4603f414db | ||
|
|
884dca20b3 | ||
|
|
dbb544dc92 | ||
|
|
3fad718807 | ||
|
|
fab8a71fd2 | ||
|
|
7776a6b7c6 | ||
|
|
cd6ab61c2d | ||
|
|
00f69d683a | ||
|
|
0e70de4003 | ||
|
|
35efa927b6 | ||
|
|
1ff03e87c2 | ||
|
|
edf934efbb | ||
|
|
d0815f586e | ||
|
|
685a23bce8 | ||
|
|
0aa7085303 | ||
|
|
994d5dd891 | ||
|
|
e62a94c05a | ||
|
|
2b83572641 | ||
|
|
5f8aae69e4 | ||
|
|
73b8d1dd99 | ||
|
|
58fa00079b | ||
|
|
3060dafb45 | ||
|
|
5cb436174d | ||
|
|
541fd9c044 | ||
|
|
7d6934d00c | ||
|
|
2c328a4540 | ||
|
|
648634d376 | ||
|
|
a654a1cb88 | ||
|
|
ef02519e72 | ||
|
|
557278fac0 | ||
|
|
5652bb76d4 | ||
|
|
d0c40490a7 | ||
|
|
630d84348e | ||
|
|
81d4f01b7f | ||
|
|
b5c665cb7e | ||
|
|
836387cada | ||
|
|
0020498c10 | ||
|
|
66ed43cbcb | ||
|
|
c6e1d139f8 | ||
|
|
ef7381f032 | ||
|
|
df30304d00 | ||
|
|
91a24ef9ce | ||
|
|
d11083d3b9 | ||
|
|
680b8ede6c | ||
|
|
4e023e2500 | ||
|
|
3eac19d258 | ||
|
|
ab867b68d3 | ||
|
|
8cdc662745 | ||
|
|
204c03e772 | ||
|
|
d0ddac296f | ||
|
|
609366da6e | ||
|
|
f48d91539e | ||
|
|
cc23f69f66 | ||
|
|
6ceafc1827 | ||
|
|
8c2224ae39 | ||
|
|
6ff7cfddda | ||
|
|
5361f76b11 | ||
|
|
bdc00d67b2 | ||
|
|
5caa8cdec5 | ||
|
|
9ede3da882 | ||
|
|
836e496ee0 | ||
|
|
5aa4ba32c9 | ||
|
|
4419b4d4ae | ||
|
|
1cab30f32f | ||
|
|
c9a5df81ce | ||
|
|
4f2adfef7b | ||
|
|
8a33290722 | ||
|
|
11cd9b21de | ||
|
|
41c50e758a | ||
|
|
d71bfce1a0 | ||
|
|
1ea65c0b60 | ||
|
|
c7a57191bd | ||
|
|
e3fc23ccf9 | ||
|
|
0baf6b0e19 | ||
|
|
0cddb358c1 | ||
|
|
741eeb7835 | ||
|
|
424f10e180 | ||
|
|
fab3dac70a | ||
|
|
89ab57d738 | ||
|
|
b03778fa73 | ||
|
|
d21abfc60c | ||
|
|
8eed9c267c | ||
|
|
3c2578f666 | ||
|
|
526fbbba45 | ||
|
|
993ea024fd | ||
|
|
bc595b40e7 | ||
|
|
e7ee181a91 | ||
|
|
6b703c4678 | ||
|
|
dbb095fff4 | ||
|
|
adf01ed511 | ||
|
|
17ca97ebd1 | ||
|
|
7d89fcc892 | ||
|
|
e84d562146 | ||
|
|
2e14561bfc | ||
|
|
166e57f1ef | ||
|
|
eeff159a2d | ||
|
|
547f25178b | ||
|
|
9c0a3ff83c | ||
|
|
2ba54c9168 | ||
|
|
026fb3e50e | ||
|
|
af3d3c2c9b | ||
|
|
27a1792e78 | ||
|
|
63e0716457 | ||
|
|
f3090b115d | ||
|
|
a21ff5c2e3 | ||
|
|
ff8851fd9f | ||
|
|
573f07ec82 | ||
|
|
8b20cb9fd2 | ||
|
|
7529296dd5 | ||
|
|
7f44a73fd0 | ||
|
|
eb835948b7 | ||
|
|
70e32637b0 | ||
|
|
c04a31dcda | ||
|
|
e526cef754 | ||
|
|
2ba0dbf50b | ||
|
|
4ee8cf08c6 | ||
|
|
f1f9140afc | ||
|
|
c189654cd9 | ||
|
|
0a66c5c269 | ||
|
|
e129b122a4 | ||
|
|
7f30e2e6ff | ||
|
|
29f784cc20 | ||
|
|
28242d3268 | ||
|
|
8d88477538 | ||
|
|
89053e86b3 | ||
|
|
50f36e3ed5 | ||
|
|
ca6839f593 | ||
|
|
e5cbb8cd56 | ||
|
|
7c92805aac | ||
|
|
a9218ed5f0 | ||
|
|
f3f0efba1e | ||
|
|
ccdcd3d154 | ||
|
|
8c774316ae | ||
|
|
25da3c073b | ||
|
|
6866b6c30d | ||
|
|
f86816fea2 | ||
|
|
df6b4b0607 | ||
|
|
2428e6e190 | ||
|
|
2ff0e71272 | ||
|
|
93609ca731 | ||
|
|
70a187cc18 | ||
|
|
390e29f850 | ||
|
|
3a466ad2a1 | ||
|
|
ccf6af4dc3 | ||
|
|
ce7564a91b | ||
|
|
483c1d5782 | ||
|
|
65850dfd03 | ||
|
|
d1bafd66c8 | ||
|
|
daa1e9edfb | ||
|
|
008d6a0c81 | ||
|
|
7c5fae68fe | ||
|
|
c57cea1aaa | ||
|
|
595dbdb0ec | ||
|
|
003161ea54 | ||
|
|
fd99c5461c | ||
|
|
37366dc2e1 | ||
|
|
c1903df374 | ||
|
|
54374bca05 | ||
|
|
ddf1eb0219 | ||
|
|
8c5ba63f8c | ||
|
|
f7cd039819 | ||
|
|
4335897367 | ||
|
|
6201dcf1aa | ||
|
|
5d24fe189d | ||
|
|
6d9ead80b2 | ||
|
|
bf46a9af68 | ||
|
|
c6d43581f9 | ||
|
|
31399fe475 | ||
|
|
e150599274 | ||
|
|
b70117e9f4 | ||
|
|
df04e852bf | ||
|
|
dd625d8edc | ||
|
|
6ab58f294e | ||
|
|
9d4bb5b3af | ||
|
|
e062c9b4a7 | ||
|
|
1b0629bf0f | ||
|
|
e83ea7fd76 | ||
|
|
6dab43523d | ||
|
|
4a59965d7a | ||
|
|
71de6613d3 | ||
|
|
e43e04b478 | ||
|
|
4ab32d4c2c | ||
|
|
1f05b52c4e | ||
|
|
3e7fbac926 | ||
|
|
eda27a60be | ||
|
|
107a2dbe90 | ||
|
|
9577f6dbe8 | ||
|
|
ae61ade2b1 | ||
|
|
977e7f55e5 | ||
|
|
c399ff86e0 | ||
|
|
d43b806c5e | ||
|
|
7b7061846c | ||
|
|
d81cf5cc1b | ||
|
|
4284fd0469 | ||
|
|
039b6b247a | ||
|
|
a09b2c4eea | ||
|
|
50a99e9120 | ||
|
|
57479b250a | ||
|
|
e64245099c | ||
|
|
d6e4b5e889 | ||
|
|
6e2e7ac782 | ||
|
|
904a6bd97f | ||
|
|
c24b7097fa | ||
|
|
cc23d5cafe | ||
|
|
9c5b44d070 | ||
|
|
84fb8b2288 | ||
|
|
6d2d322140 | ||
|
|
1517688076 | ||
|
|
29124f56bb | ||
|
|
42d849abfc | ||
|
|
d1b307b18f | ||
|
|
f6d347c5e4 | ||
|
|
4fe8476169 | ||
|
|
bbc5ac9f0e | ||
|
|
29b5c393d1 | ||
|
|
b8cc0b1270 | ||
|
|
b145d8b8a2 | ||
|
|
ffe1073292 | ||
|
|
afaaec8492 | ||
|
|
d0b8b076cf | ||
|
|
708049bb89 | ||
|
|
65aa51d525 | ||
|
|
cbbd284e7a | ||
|
|
852903bdbd | ||
|
|
19efacef9c | ||
|
|
0f57629d25 | ||
|
|
69726c3925 | ||
|
|
37f9c7c8d6 | ||
|
|
bcee0bbf3a | ||
|
|
096a71c58b | ||
|
|
a538b9789b | ||
|
|
c6e525b06f | ||
|
|
d29c40dc71 | ||
|
|
4f5f541efe | ||
|
|
caf054bae7 | ||
|
|
7e8011ba34 | ||
|
|
e46f77681d | ||
|
|
0de0a5aa87 | ||
|
|
3394380ffa | ||
|
|
2493bb0fb7 | ||
|
|
4641e89c17 | ||
|
|
28405f6d24 | ||
|
|
5455cb3164 | ||
|
|
1e4a81dea9 | ||
|
|
cbc3373e8e | ||
|
|
870559046f | ||
|
|
a997e1d10d | ||
|
|
c28d35d8f7 | ||
|
|
b92da8f123 | ||
|
|
bdf0c44246 | ||
|
|
799fb058b4 | ||
|
|
11924ad4c5 | ||
|
|
b11d2130a0 | ||
|
|
e0f4cb06b3 | ||
|
|
aad97c4c54 | ||
|
|
3590d3f8b6 | ||
|
|
6e5be6ba75 | ||
|
|
b366ce7594 | ||
|
|
1eacf5367d | ||
|
|
f74d1b7bf8 | ||
|
|
a004dcf320 | ||
|
|
5df59a48b7 | ||
|
|
989208eb45 | ||
|
|
bec1558488 | ||
|
|
6ff79c5d5c | ||
|
|
168c4f6950 | ||
|
|
3e40b9df66 | ||
|
|
94f97208e3 | ||
|
|
bd9003c24b | ||
|
|
26700a1ff0 | ||
|
|
8b92021b1a | ||
|
|
7cd474dbb7 | ||
|
|
9bf869767d | ||
|
|
9e818cddce | ||
|
|
d6fe180ca1 | ||
|
|
99cac7cac0 | ||
|
|
81f2166912 | ||
|
|
4de65ab55d | ||
|
|
771ef44d82 | ||
|
|
76c42c6c9f | ||
|
|
003887d4e0 | ||
|
|
89743bd1e6 | ||
|
|
1ace332152 | ||
|
|
2d14047c73 | ||
|
|
42cd93cf33 | ||
|
|
4a7b764ab3 | ||
|
|
1bdb0d465c | ||
|
|
930b54fabd | ||
|
|
5b0a54bfb7 | ||
|
|
6c3ff6de63 | ||
|
|
dd5a23e36e | ||
|
|
848ecd99ee |
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@@ -1,2 +1 @@
|
||||
github: usememos
|
||||
ko_fi: stevenlgtm
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -5,7 +5,7 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to suggest an idea for Memos!
|
||||
Thanks for taking the time to suggest an idea for memos!
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Is your feature request related to a problem?
|
||||
|
||||
23
.github/workflows/backend-tests.yml
vendored
23
.github/workflows/backend-tests.yml
vendored
@@ -1,38 +1,45 @@
|
||||
name: Backend Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- "release/*.*.*"
|
||||
paths:
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "**.go"
|
||||
|
||||
jobs:
|
||||
go-static-checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.19
|
||||
go-version: 1.21
|
||||
check-latest: true
|
||||
cache: true
|
||||
- name: Verify go.mod is tidy
|
||||
run: |
|
||||
go mod tidy -go=1.19
|
||||
go mod tidy -go=1.21
|
||||
git diff --exit-code
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
with:
|
||||
args: -v
|
||||
version: v1.54.1
|
||||
args: --verbose --timeout=3m
|
||||
skip-cache: true
|
||||
|
||||
go-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.19
|
||||
go-version: 1.21
|
||||
check-latest: true
|
||||
cache: true
|
||||
- name: Run all tests
|
||||
|
||||
@@ -9,11 +9,14 @@ on:
|
||||
jobs:
|
||||
build-and-push-release-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Extract build args
|
||||
# Extract version from branch name
|
||||
@@ -22,23 +25,44 @@ jobs:
|
||||
echo "VERSION=${GITHUB_REF_NAME#release/}" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: neosmemo
|
||||
password: ${{ secrets.DOCKER_NEOSMEMO_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
install: true
|
||||
version: v0.9.1
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
neosmemo/memos
|
||||
ghcr.io/usememos/memos
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=semver,pattern={{version}},value=${{ env.VERSION }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ env.VERSION }}
|
||||
|
||||
- name: Build and Push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: neosmemo/memos:latest, neosmemo/memos:${{ env.VERSION }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
36
.github/workflows/build-and-push-test-image.yml
vendored
36
.github/workflows/build-and-push-test-image.yml
vendored
@@ -7,30 +7,54 @@ on:
|
||||
jobs:
|
||||
build-and-push-test-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: neosmemo
|
||||
password: ${{ secrets.DOCKER_NEOSMEMO_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
install: true
|
||||
version: v0.9.1
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
neosmemo/memos
|
||||
ghcr.io/usememos/memos
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=raw,value=test
|
||||
|
||||
- name: Build and Push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: neosmemo/memos:test
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
14
.github/workflows/codeql.yml
vendored
14
.github/workflows/codeql.yml
vendored
@@ -17,6 +17,12 @@ on:
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [main]
|
||||
paths:
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "**.go"
|
||||
- "proto/**"
|
||||
- "web/**"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
@@ -36,11 +42,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -51,7 +57,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -65,4 +71,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
uses: github/codeql-action/analyze@v2
|
||||
|
||||
53
.github/workflows/frontend-tests.yml
vendored
53
.github/workflows/frontend-tests.yml
vendored
@@ -1,53 +1,52 @@
|
||||
name: Frontend Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- "release/*.*.*"
|
||||
paths:
|
||||
- "web/**"
|
||||
|
||||
jobs:
|
||||
eslint-checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v2.4.0
|
||||
with:
|
||||
version: 8
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
cache: yarn
|
||||
cache-dependency-path: "web/yarn.lock"
|
||||
- run: yarn
|
||||
cache: pnpm
|
||||
cache-dependency-path: "web/pnpm-lock.yaml"
|
||||
- run: pnpm install
|
||||
working-directory: web
|
||||
- run: pnpm type-gen
|
||||
working-directory: web
|
||||
- name: Run eslint check
|
||||
run: yarn lint
|
||||
working-directory: web
|
||||
|
||||
jest-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "18"
|
||||
cache: yarn
|
||||
cache-dependency-path: "web/yarn.lock"
|
||||
- run: yarn
|
||||
working-directory: web
|
||||
- name: Run jest
|
||||
run: yarn test
|
||||
run: pnpm lint
|
||||
working-directory: web
|
||||
|
||||
frontend-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v2.4.0
|
||||
with:
|
||||
version: 8
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
cache: yarn
|
||||
cache-dependency-path: "web/yarn.lock"
|
||||
- run: yarn
|
||||
cache: pnpm
|
||||
cache-dependency-path: "web/pnpm-lock.yaml"
|
||||
- run: pnpm install
|
||||
working-directory: web
|
||||
- run: pnpm type-gen
|
||||
working-directory: web
|
||||
- name: Run frontend build
|
||||
run: yarn build
|
||||
run: pnpm build
|
||||
working-directory: web
|
||||
|
||||
34
.github/workflows/proto-linter.yml
vendored
Normal file
34
.github/workflows/proto-linter.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
name: Proto linter
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- "release/*.*.*"
|
||||
paths:
|
||||
- "proto/**"
|
||||
|
||||
jobs:
|
||||
lint-protos:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup buf
|
||||
uses: bufbuild/buf-setup-action@v1
|
||||
with:
|
||||
github_token: ${{ github.token }}
|
||||
- name: buf lint
|
||||
uses: bufbuild/buf-lint-action@v1
|
||||
with:
|
||||
input: "proto"
|
||||
- name: buf format
|
||||
run: |
|
||||
if [[ $(buf format -d) ]]; then
|
||||
echo "Run 'buf format -w'"
|
||||
exit 1
|
||||
fi
|
||||
97
.github/workflows/uffizzi-build.yml
vendored
97
.github/workflows/uffizzi-build.yml
vendored
@@ -1,97 +0,0 @@
|
||||
name: Build PR Image
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, closed]
|
||||
|
||||
jobs:
|
||||
build-memos:
|
||||
name: Build and push `Memos`
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
if: ${{ github.event.action != 'closed' }}
|
||||
steps:
|
||||
- name: Checkout git repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Generate UUID image name
|
||||
id: uuid
|
||||
run: echo "UUID_WORKER=$(uuidgen)" >> $GITHUB_ENV
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: registry.uffizzi.com/${{ env.UUID_WORKER }}
|
||||
tags: |
|
||||
type=raw,value=60d
|
||||
|
||||
- name: Build and Push Image to registry.uffizzi.com - Uffizzi's ephemeral Registry
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ./
|
||||
file: Dockerfile
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha, mode=max
|
||||
|
||||
render-compose-file:
|
||||
name: Render Docker Compose File
|
||||
# Pass output of this workflow to another triggered by `workflow_run` event.
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-memos
|
||||
outputs:
|
||||
compose-file-cache-key: ${{ steps.hash.outputs.hash }}
|
||||
steps:
|
||||
- name: Checkout git repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Render Compose File
|
||||
run: |
|
||||
MEMOS_IMAGE=${{ needs.build-memos.outputs.tags }}
|
||||
export MEMOS_IMAGE
|
||||
# Render simple template from environment variables.
|
||||
envsubst < docker-compose.uffizzi.yml > docker-compose.rendered.yml
|
||||
cat docker-compose.rendered.yml
|
||||
- name: Upload Rendered Compose File as Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: preview-spec
|
||||
path: docker-compose.rendered.yml
|
||||
retention-days: 2
|
||||
- name: Serialize PR Event to File
|
||||
run: |
|
||||
cat << EOF > event.json
|
||||
${{ toJSON(github.event) }}
|
||||
|
||||
EOF
|
||||
- name: Upload PR Event as Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: preview-spec
|
||||
path: event.json
|
||||
retention-days: 2
|
||||
|
||||
delete-preview:
|
||||
name: Call for Preview Deletion
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.action == 'closed' }}
|
||||
steps:
|
||||
# If this PR is closing, we will not render a compose file nor pass it to the next workflow.
|
||||
- name: Serialize PR Event to File
|
||||
run: |
|
||||
cat << EOF > event.json
|
||||
${{ toJSON(github.event) }}
|
||||
|
||||
EOF
|
||||
- name: Upload PR Event as Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: preview-spec
|
||||
path: event.json
|
||||
retention-days: 2
|
||||
86
.github/workflows/uffizzi-preview.yml
vendored
86
.github/workflows/uffizzi-preview.yml
vendored
@@ -1,86 +0,0 @@
|
||||
name: Deploy Uffizzi Preview
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- "Build PR Image"
|
||||
types:
|
||||
- completed
|
||||
|
||||
|
||||
jobs:
|
||||
cache-compose-file:
|
||||
name: Cache Compose File
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
compose-file-cache-key: ${{ env.HASH }}
|
||||
pr-number: ${{ env.PR_NUMBER }}
|
||||
steps:
|
||||
- name: 'Download artifacts'
|
||||
# Fetch output (zip archive) from the workflow run that triggered this workflow.
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: context.payload.workflow_run.id,
|
||||
});
|
||||
let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => {
|
||||
return artifact.name == "preview-spec"
|
||||
})[0];
|
||||
let download = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: matchArtifact.id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
let fs = require('fs');
|
||||
fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/preview-spec.zip`, Buffer.from(download.data));
|
||||
|
||||
- name: 'Unzip artifact'
|
||||
run: unzip preview-spec.zip
|
||||
- name: Read Event into ENV
|
||||
run: |
|
||||
echo 'EVENT_JSON<<EOF' >> $GITHUB_ENV
|
||||
cat event.json >> $GITHUB_ENV
|
||||
echo 'EOF' >> $GITHUB_ENV
|
||||
|
||||
- name: Hash Rendered Compose File
|
||||
id: hash
|
||||
# If the previous workflow was triggered by a PR close event, we will not have a compose file artifact.
|
||||
if: ${{ fromJSON(env.EVENT_JSON).action != 'closed' }}
|
||||
run: echo "HASH=$(md5sum docker-compose.rendered.yml | awk '{ print $1 }')" >> $GITHUB_ENV
|
||||
- name: Cache Rendered Compose File
|
||||
if: ${{ fromJSON(env.EVENT_JSON).action != 'closed' }}
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: docker-compose.rendered.yml
|
||||
key: ${{ env.HASH }}
|
||||
|
||||
- name: Read PR Number From Event Object
|
||||
id: pr
|
||||
run: echo "PR_NUMBER=${{ fromJSON(env.EVENT_JSON).number }}" >> $GITHUB_ENV
|
||||
- name: DEBUG - Print Job Outputs
|
||||
if: ${{ runner.debug }}
|
||||
run: |
|
||||
echo "PR number: ${{ env.PR_NUMBER }}"
|
||||
echo "Compose file hash: ${{ env.HASH }}"
|
||||
cat event.json
|
||||
|
||||
deploy-uffizzi-preview:
|
||||
name: Use Remote Workflow to Preview on Uffizzi
|
||||
needs:
|
||||
- cache-compose-file
|
||||
uses: UffizziCloud/preview-action/.github/workflows/reusable.yaml@v2
|
||||
with:
|
||||
# If this workflow was triggered by a PR close event, cache-key will be an empty string
|
||||
# and this reusable workflow will delete the preview deployment.
|
||||
compose-file-cache-key: ${{ needs.cache-compose-file.outputs.compose-file-cache-key }}
|
||||
compose-file-cache-path: docker-compose.rendered.yml
|
||||
server: https://app.uffizzi.com
|
||||
pr-number: ${{ needs.cache-compose-file.outputs.pr-number }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -6,6 +6,7 @@ tmp
|
||||
|
||||
# Frontend asset
|
||||
web/dist
|
||||
server/dist
|
||||
|
||||
# build folder
|
||||
build
|
||||
@@ -15,7 +16,4 @@ build
|
||||
# Jetbrains
|
||||
.idea
|
||||
|
||||
# vscode
|
||||
.vscode
|
||||
|
||||
bin/air
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
run:
|
||||
timeout: 10m
|
||||
linters:
|
||||
enable:
|
||||
- errcheck
|
||||
- goimports
|
||||
- revive
|
||||
- govet
|
||||
@@ -10,17 +13,30 @@ linters:
|
||||
- rowserrcheck
|
||||
- nilerr
|
||||
- godot
|
||||
- forbidigo
|
||||
- mirror
|
||||
- bodyclose
|
||||
|
||||
issues:
|
||||
include:
|
||||
# https://golangci-lint.run/usage/configuration/#command-line-options
|
||||
exclude:
|
||||
- Rollback
|
||||
- logger.Sync
|
||||
- pgInstance.Stop
|
||||
- fmt.Printf
|
||||
- fmt.Print
|
||||
- Enter(.*)_(.*)
|
||||
- Exit(.*)_(.*)
|
||||
|
||||
linters-settings:
|
||||
goimports:
|
||||
# Put imports beginning with prefix after 3rd-party packages.
|
||||
local-prefixes: github.com/usememos/memos
|
||||
revive:
|
||||
# Default to run all linters so that new rules in the future could automatically be added to the static check.
|
||||
enable-all-rules: true
|
||||
rules:
|
||||
# The following rules are too strict and make coding harder. We do not enable them for now.
|
||||
- name: file-header
|
||||
disabled: true
|
||||
- name: line-length-limit
|
||||
@@ -56,9 +72,14 @@ linters-settings:
|
||||
- ifElseChain
|
||||
govet:
|
||||
settings:
|
||||
printf:
|
||||
funcs:
|
||||
printf: # The name of the analyzer, run `go tool vet help` to see the list of all analyzers
|
||||
funcs: # Run `go tool vet help printf` to see the full configuration of `printf`.
|
||||
- common.Errorf
|
||||
enable-all: true
|
||||
disable:
|
||||
- fieldalignment
|
||||
- shadow
|
||||
forbidigo:
|
||||
forbid:
|
||||
- 'fmt\.Errorf(# Please use errors\.Wrap\|Wrapf\|Errorf instead)?'
|
||||
- 'ioutil\.ReadDir(# Please use os\.ReadDir)?'
|
||||
|
||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["golang.go"]
|
||||
}
|
||||
12
.vscode/project.code-workspace
vendored
Normal file
12
.vscode/project.code-workspace
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"name": "server",
|
||||
"path": "../"
|
||||
},
|
||||
{
|
||||
"name": "web",
|
||||
"path": "../web"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"json.schemaDownload.enable":true,
|
||||
"go.lintOnSave": "workspace",
|
||||
"go.lintTool": "golangci-lint"
|
||||
"go.lintTool": "golangci-lint",
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
# These owners will be the default owners for everything in the repo.
|
||||
* @boojack @lqwakeup
|
||||
* @boojack
|
||||
|
||||
31
Dockerfile
31
Dockerfile
@@ -1,29 +1,40 @@
|
||||
# Build frontend dist.
|
||||
FROM node:18.12.1-alpine3.16 AS frontend
|
||||
FROM node:18-alpine AS frontend
|
||||
WORKDIR /frontend-build
|
||||
|
||||
COPY ./web/ .
|
||||
COPY . .
|
||||
|
||||
RUN yarn && yarn build
|
||||
WORKDIR /frontend-build/web
|
||||
|
||||
RUN corepack enable && pnpm i --frozen-lockfile && pnpm type-gen
|
||||
|
||||
RUN pnpm build
|
||||
|
||||
# Build backend exec file.
|
||||
FROM golang:1.19.3-alpine3.16 AS backend
|
||||
FROM golang:1.21-alpine AS backend
|
||||
WORKDIR /backend-build
|
||||
|
||||
RUN apk update && apk add --no-cache gcc musl-dev
|
||||
|
||||
COPY . .
|
||||
COPY --from=frontend /frontend-build/dist ./server/dist
|
||||
COPY --from=frontend /frontend-build/web/dist ./server/dist
|
||||
|
||||
RUN go build -o memos ./bin/server/main.go
|
||||
RUN CGO_ENABLED=0 go build -o memos ./main.go
|
||||
|
||||
# Make workspace with above generated files.
|
||||
FROM alpine:3.16 AS monolithic
|
||||
FROM alpine:latest AS monolithic
|
||||
WORKDIR /usr/local/memos
|
||||
|
||||
RUN apk add --no-cache tzdata
|
||||
ENV TZ="UTC"
|
||||
|
||||
COPY --from=backend /backend-build/memos /usr/local/memos/
|
||||
|
||||
EXPOSE 5230
|
||||
|
||||
# Directory to store the data, which can be referenced as the mounting point.
|
||||
RUN mkdir -p /var/opt/memos
|
||||
VOLUME /var/opt/memos
|
||||
|
||||
ENTRYPOINT ["./memos", "--mode", "prod", "--port", "5230"]
|
||||
ENV MEMOS_MODE="prod"
|
||||
ENV MEMOS_PORT="5230"
|
||||
|
||||
ENTRYPOINT ["./memos"]
|
||||
|
||||
94
README.md
94
README.md
@@ -1,83 +1,61 @@
|
||||
<p align="center"><a href="https://usememos.com"><img height="64px" src="https://raw.githubusercontent.com/usememos/memos/main/resources/logo-full.webp" alt="✍️ memos" /></a></p>
|
||||
<img height="56px" src="https://www.usememos.com/full-logo-landscape.png" alt="Memos" />
|
||||
|
||||
<p align="center">An open-source, self-hosted memo hub with knowledge management and socialization.</p>
|
||||
A privacy-first, lightweight note-taking service. Easily capture and share your great thoughts.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/usememos/memos/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/usememos/memos" /></a>
|
||||
<a href="https://hub.docker.com/r/neosmemo/memos"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/neosmemo/memos.svg" /></a>
|
||||
<a href="https://www.usememos.com">Home Page</a> •
|
||||
<a href="https://www.usememos.com/blog">Blogs</a> •
|
||||
<a href="https://www.usememos.com/docs">Docs</a> •
|
||||
<a href="https://demo.usememos.com/">Live Demo</a>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/usememos/memos/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/usememos/memos?logo=github" /></a>
|
||||
<a href="https://hub.docker.com/r/neosmemo/memos"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/neosmemo/memos.svg"/></a>
|
||||
<a href="https://hosted.weblate.org/engage/memos-i18n/"><img src="https://hosted.weblate.org/widget/memos-i18n/english/svg-badge.svg" alt="Translation status" /></a>
|
||||
<a href="https://discord.gg/tfPJa4UmAv"><img alt="Discord" src="https://img.shields.io/badge/discord-chat-5865f2?logo=discord&logoColor=f5f5f5" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://demo.usememos.com/">Live Demo</a> •
|
||||
Discuss in <a href="https://t.me/+-_tNF1k70UU4ZTc9">Telegram</a> / <b><a href="https://discord.gg/tfPJa4UmAv">Discord 🏂</a></b>
|
||||
</p>
|
||||

|
||||
|
||||

|
||||
## Key points
|
||||
|
||||
## Features
|
||||
|
||||
- 🦄 Open source and free forever;
|
||||
- 🚀 Support for self-hosting with `Docker` in seconds;
|
||||
- 📜 Plain textarea first and support some useful Markdown syntax;
|
||||
- 👥 Set memo private or public to others;
|
||||
- 🧑💻 RESTful API for self-service.
|
||||
- **Open source and free forever**. Embrace a future where creativity knows no boundaries with our open-source solution – free today, tomorrow, and always.
|
||||
- **Self-hosting with Docker in just seconds**. Enjoy the flexibility, scalability, and ease of setup that Docker provides, allowing you to have full control over your data and privacy.
|
||||
- **Pure text with added Markdown support.** Say goodbye to the overwhelming mental burden of rich formatting and embrace a minimalist approach.
|
||||
- **Customize and share your notes effortlessly**. With our intuitive sharing features, you can easily collaborate and distribute your notes with others.
|
||||
- **RESTful API for third-party services.** Embrace the power of integration and unleash new possibilities with our RESTful API support.
|
||||
|
||||
## Deploy with Docker in seconds
|
||||
|
||||
### Docker Run
|
||||
|
||||
```docker
|
||||
docker run -d --name memos -p 5230:5230 -v ~/.memos/:/var/opt/memos neosmemo/memos:latest
|
||||
```bash
|
||||
docker run -d --name memos -p 5230:5230 -v ~/.memos/:/var/opt/memos ghcr.io/usememos/memos:latest
|
||||
```
|
||||
|
||||
> The `~/.memos/` will be used as the data directory in your machine. And `/var/opt/memos` is the directory of the volume in docker and should not be modified.
|
||||
> The `~/.memos/` directory will be used as the data directory on your local machine, while `/var/opt/memos` is the directory of the volume in Docker and should not be modified.
|
||||
|
||||
If the `~/.memos/` does not have a `memos_prod.db` file, then memos will auto generate it. Memos will be running at [http://localhost:5230](http://localhost:5230).
|
||||
Learn more about [other installation methods](https://www.usememos.com/docs/install).
|
||||
|
||||
### Docker Compose
|
||||
## Contribution
|
||||
|
||||
Example Compose YAML file: [`docker-compose.yaml`](./docker-compose.yaml).
|
||||
Contributions are what make the open-source community such an amazing place to learn, inspire, and create. We greatly appreciate any contributions you make. Thank you for being a part of our community! 🥰
|
||||
|
||||
If you want to upgrade the version of memos, use the following command.
|
||||
<a href="https://github.com/usememos/memos/graphs/contributors">
|
||||
<img src="https://contri-graphy.yourselfhosted.com/graph?repo=usememos/memos&format=svg" />
|
||||
</a>
|
||||
|
||||
```sh
|
||||
docker-compose down && docker image rm neosmemo/memos:latest && docker-compose up -d
|
||||
```
|
||||
|
||||
### Other guides
|
||||
|
||||
- [Deploy on render.com](./docs/deploy-with-render.md)
|
||||
- [Deploy on fly.io](https://github.com/hu3rror/memos-on-fly)
|
||||
|
||||
## Contribute
|
||||
|
||||
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated. 🥰
|
||||
|
||||
See more in [development guide](./docs/development.md).
|
||||
|
||||
## Products made by Community
|
||||
---
|
||||
|
||||
- [Moe Memos](https://memos.moe/) - Third party client for iOS and Android
|
||||
- [lmm214/memos-bber](https://github.com/lmm214/memos-bber) - Chrome extension
|
||||
- [Rabithua/memos_wmp](https://github.com/Rabithua/memos_wmp) - WeChat MiniProgram
|
||||
- [qazxcdswe123/telegramMemoBot](https://github.com/qazxcdswe123/telegramMemoBot) - Telegram bot
|
||||
- [eallion/memos.top](https://github.com/eallion/memos.top) - A static page rendered with the Memos API
|
||||
- [eindex/logseq-memos-sync](https://github.com/EINDEX/logseq-memos-sync) - A Logseq plugin
|
||||
|
||||
## User stories
|
||||
|
||||
- [Memos - A Twitter Like Notes App You can Self Host](https://noted.lol/memos/)
|
||||
|
||||
### Join the community to build memos together!
|
||||
|
||||
<a href="https://github.com/usememos/memos/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=usememos/memos" />
|
||||
</a>
|
||||
|
||||
## License
|
||||
|
||||
This project is open source and available under the [MIT License](https://github.com/usememos/memos/blob/main/LICENSE).
|
||||
- [eallion/memos.top](https://github.com/eallion/memos.top) - Static page rendered with the Memos API
|
||||
- [eindex/logseq-memos-sync](https://github.com/EINDEX/logseq-memos-sync) - Logseq plugin
|
||||
- [quanru/obsidian-periodic-para](https://github.com/quanru/obsidian-periodic-para#daily-record) - Obsidian plugin
|
||||
- [JakeLaoyu/memos-import-from-flomo](https://github.com/JakeLaoyu/memos-import-from-flomo) - Import data. Support from flomo, wechat reading
|
||||
- [Quick Memo](https://www.icloud.com/shortcuts/1eaef307112843ed9f91d256f5ee7ad9) - Shortcuts (iOS, iPadOS or macOS)
|
||||
- [Memos Raycast Extension](https://www.raycast.com/JakeYu/memos) - Raycast extension
|
||||
- [Memos Desktop](https://github.com/xudaolong/memos-desktop) - Third party client for MacOS and Windows
|
||||
- [MemosGallery](https://github.com/BarryYangi/MemosGallery) - A static Gallery rendered with the Memos API
|
||||
|
||||
## Star history
|
||||
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
|
||||
Report security bugs via GitHub [issues](https://github.com/usememos/memos/issues).
|
||||
|
||||
For more information, please contact [stevenlgtm@gmail.com](stevenlgtm@gmail.com).
|
||||
For more information, please contact [usememos@gmail.com](usememos@gmail.com).
|
||||
|
||||
137
api/activity.go
137
api/activity.go
@@ -1,137 +0,0 @@
|
||||
package api
|
||||
|
||||
import "github.com/usememos/memos/server/profile"
|
||||
|
||||
// ActivityType is the type for an activity.
|
||||
type ActivityType string
|
||||
|
||||
const (
|
||||
// User related.
|
||||
|
||||
// ActivityUserCreate is the type for creating users.
|
||||
ActivityUserCreate ActivityType = "user.create"
|
||||
// ActivityUserUpdate is the type for updating users.
|
||||
ActivityUserUpdate ActivityType = "user.update"
|
||||
// ActivityUserDelete is the type for deleting users.
|
||||
ActivityUserDelete ActivityType = "user.delete"
|
||||
// ActivityUserAuthSignIn is the type for user signin.
|
||||
ActivityUserAuthSignIn ActivityType = "user.auth.signin"
|
||||
// ActivityUserAuthSignUp is the type for user signup.
|
||||
ActivityUserAuthSignUp ActivityType = "user.auth.signup"
|
||||
// ActivityUserSettingUpdate is the type for updating user settings.
|
||||
ActivityUserSettingUpdate ActivityType = "user.setting.update"
|
||||
|
||||
// Memo related.
|
||||
|
||||
// ActivityMemoCreate is the type for creating memos.
|
||||
ActivityMemoCreate ActivityType = "memo.create"
|
||||
// ActivityMemoUpdate is the type for updating memos.
|
||||
ActivityMemoUpdate ActivityType = "memo.update"
|
||||
// ActivityMemoDelete is the type for deleting memos.
|
||||
ActivityMemoDelete ActivityType = "memo.delete"
|
||||
|
||||
// Shortcut related.
|
||||
|
||||
// ActivityShortcutCreate is the type for creating shortcuts.
|
||||
ActivityShortcutCreate ActivityType = "shortcut.create"
|
||||
// ActivityShortcutUpdate is the type for updating shortcuts.
|
||||
ActivityShortcutUpdate ActivityType = "shortcut.update"
|
||||
// ActivityShortcutDelete is the type for deleting shortcuts.
|
||||
ActivityShortcutDelete ActivityType = "shortcut.delete"
|
||||
|
||||
// Resource related.
|
||||
|
||||
// ActivityResourceCreate is the type for creating resources.
|
||||
ActivityResourceCreate ActivityType = "resource.create"
|
||||
// ActivityResourceDelete is the type for deleting resources.
|
||||
ActivityResourceDelete ActivityType = "resource.delete"
|
||||
|
||||
// Tag related.
|
||||
|
||||
// ActivityTagCreate is the type for creating tags.
|
||||
ActivityTagCreate ActivityType = "tag.create"
|
||||
// ActivityTagDelete is the type for deleting tags.
|
||||
ActivityTagDelete ActivityType = "tag.delete"
|
||||
|
||||
// Server related.
|
||||
|
||||
// ActivityServerStart is the type for starting server.
|
||||
ActivityServerStart ActivityType = "server.start"
|
||||
)
|
||||
|
||||
// ActivityLevel is the level of activities.
|
||||
type ActivityLevel string
|
||||
|
||||
const (
|
||||
// ActivityInfo is the INFO level of activities.
|
||||
ActivityInfo ActivityLevel = "INFO"
|
||||
// ActivityWarn is the WARN level of activities.
|
||||
ActivityWarn ActivityLevel = "WARN"
|
||||
// ActivityError is the ERROR level of activities.
|
||||
ActivityError ActivityLevel = "ERROR"
|
||||
)
|
||||
|
||||
type ActivityUserCreatePayload struct {
|
||||
UserID int `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
Role Role `json:"role"`
|
||||
}
|
||||
|
||||
type ActivityUserAuthSignInPayload struct {
|
||||
UserID int `json:"userId"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
type ActivityUserAuthSignUpPayload struct {
|
||||
Username string `json:"username"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
type ActivityMemoCreatePayload struct {
|
||||
Content string `json:"content"`
|
||||
Visibility string `json:"visibility"`
|
||||
}
|
||||
|
||||
type ActivityShortcutCreatePayload struct {
|
||||
Title string `json:"title"`
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
|
||||
type ActivityResourceCreatePayload struct {
|
||||
Filename string `json:"filename"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
type ActivityTagCreatePayload struct {
|
||||
TagName string `json:"tagName"`
|
||||
}
|
||||
|
||||
type ActivityServerStartPayload struct {
|
||||
ServerID string `json:"serverId"`
|
||||
Profile *profile.Profile `json:"profile"`
|
||||
}
|
||||
|
||||
type Activity struct {
|
||||
ID int `json:"id"`
|
||||
|
||||
// Standard fields
|
||||
CreatorID int `json:"creatorId"`
|
||||
CreatedTs int64 `json:"createdTs"`
|
||||
|
||||
// Domain specific fields
|
||||
Type ActivityType `json:"type"`
|
||||
Level ActivityLevel `json:"level"`
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
|
||||
// ActivityCreate is the API message for creating an activity.
|
||||
type ActivityCreate struct {
|
||||
// Standard fields
|
||||
CreatorID int
|
||||
|
||||
// Domain specific fields
|
||||
Type ActivityType `json:"type"`
|
||||
Level ActivityLevel
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
12
api/auth.go
12
api/auth.go
@@ -1,12 +0,0 @@
|
||||
package api
|
||||
|
||||
type SignIn struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type SignUp struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Role Role `json:"role"`
|
||||
}
|
||||
63
api/auth/auth.go
Normal file
63
api/auth/auth.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
// issuer is the issuer of the jwt token.
|
||||
Issuer = "memos"
|
||||
// Signing key section. For now, this is only used for signing, not for verifying since we only
|
||||
// have 1 version. But it will be used to maintain backward compatibility if we change the signing mechanism.
|
||||
KeyID = "v1"
|
||||
// AccessTokenAudienceName is the audience name of the access token.
|
||||
AccessTokenAudienceName = "user.access-token"
|
||||
AccessTokenDuration = 7 * 24 * time.Hour
|
||||
|
||||
// CookieExpDuration expires slightly earlier than the jwt expiration. Client would be logged out if the user
|
||||
// cookie expires, thus the client would always logout first before attempting to make a request with the expired jwt.
|
||||
CookieExpDuration = AccessTokenDuration - 1*time.Minute
|
||||
// AccessTokenCookieName is the cookie name of access token.
|
||||
AccessTokenCookieName = "memos.access-token"
|
||||
)
|
||||
|
||||
type ClaimsMessage struct {
|
||||
Name string `json:"name"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// GenerateAccessToken generates an access token.
|
||||
func GenerateAccessToken(username string, userID int32, expirationTime time.Time, secret []byte) (string, error) {
|
||||
return generateToken(username, userID, AccessTokenAudienceName, expirationTime, secret)
|
||||
}
|
||||
|
||||
// generateToken generates a jwt token.
|
||||
func generateToken(username string, userID int32, audience string, expirationTime time.Time, secret []byte) (string, error) {
|
||||
registeredClaims := jwt.RegisteredClaims{
|
||||
Issuer: Issuer,
|
||||
Audience: jwt.ClaimStrings{audience},
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Subject: fmt.Sprint(userID),
|
||||
}
|
||||
if !expirationTime.IsZero() {
|
||||
registeredClaims.ExpiresAt = jwt.NewNumericDate(expirationTime)
|
||||
}
|
||||
|
||||
// Declare the token with the HS256 algorithm used for signing, and the claims.
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, &ClaimsMessage{
|
||||
Name: username,
|
||||
RegisteredClaims: registeredClaims,
|
||||
})
|
||||
token.Header["kid"] = KeyID
|
||||
|
||||
// Create the JWT string.
|
||||
tokenString, err := token.SignedString(secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return tokenString, nil
|
||||
}
|
||||
22
api/cache.go
22
api/cache.go
@@ -1,22 +0,0 @@
|
||||
package api
|
||||
|
||||
// CacheNamespace is the type of a cache.
|
||||
type CacheNamespace string
|
||||
|
||||
const (
|
||||
// UserCache is the cache type of users.
|
||||
UserCache CacheNamespace = "u"
|
||||
// MemoCache is the cache type of memos.
|
||||
MemoCache CacheNamespace = "m"
|
||||
// ShortcutCache is the cache type of shortcuts.
|
||||
ShortcutCache CacheNamespace = "s"
|
||||
// ResourceCache is the cache type of resources.
|
||||
ResourceCache CacheNamespace = "r"
|
||||
)
|
||||
|
||||
// CacheService is the service for caches.
|
||||
type CacheService interface {
|
||||
FindCache(namespace CacheNamespace, id int, entry interface{}) (bool, error)
|
||||
UpsertCache(namespace CacheNamespace, id int, entry interface{}) error
|
||||
DeleteCache(namespace CacheNamespace, id int)
|
||||
}
|
||||
94
api/memo.go
94
api/memo.go
@@ -1,94 +0,0 @@
|
||||
package api
|
||||
|
||||
// Visibility is the type of a visibility.
|
||||
type Visibility string
|
||||
|
||||
const (
|
||||
// Public is the PUBLIC visibility.
|
||||
Public Visibility = "PUBLIC"
|
||||
// Protected is the PROTECTED visibility.
|
||||
Protected Visibility = "PROTECTED"
|
||||
// Private is the PRIVATE visibility.
|
||||
Private Visibility = "PRIVATE"
|
||||
)
|
||||
|
||||
func (e Visibility) String() string {
|
||||
switch e {
|
||||
case Public:
|
||||
return "PUBLIC"
|
||||
case Protected:
|
||||
return "PROTECTED"
|
||||
case Private:
|
||||
return "PRIVATE"
|
||||
}
|
||||
return "PRIVATE"
|
||||
}
|
||||
|
||||
type Memo struct {
|
||||
ID int `json:"id"`
|
||||
|
||||
// Standard fields
|
||||
RowStatus RowStatus `json:"rowStatus"`
|
||||
CreatorID int `json:"creatorId"`
|
||||
CreatedTs int64 `json:"createdTs"`
|
||||
UpdatedTs int64 `json:"updatedTs"`
|
||||
|
||||
// Domain specific fields
|
||||
Content string `json:"content"`
|
||||
Visibility Visibility `json:"visibility"`
|
||||
Pinned bool `json:"pinned"`
|
||||
DisplayTs int64 `json:"displayTs"`
|
||||
|
||||
// Related fields
|
||||
Creator *User `json:"creator"`
|
||||
ResourceList []*Resource `json:"resourceList"`
|
||||
}
|
||||
|
||||
type MemoCreate struct {
|
||||
// Standard fields
|
||||
CreatorID int `json:"-"`
|
||||
|
||||
// Domain specific fields
|
||||
Visibility Visibility `json:"visibility"`
|
||||
Content string `json:"content"`
|
||||
|
||||
// Related fields
|
||||
ResourceIDList []int `json:"resourceIdList"`
|
||||
}
|
||||
|
||||
type MemoPatch struct {
|
||||
ID int `json:"-"`
|
||||
|
||||
// Standard fields
|
||||
CreatedTs *int64 `json:"createdTs"`
|
||||
UpdatedTs *int64
|
||||
RowStatus *RowStatus `json:"rowStatus"`
|
||||
|
||||
// Domain specific fields
|
||||
Content *string `json:"content"`
|
||||
Visibility *Visibility `json:"visibility"`
|
||||
|
||||
// Related fields
|
||||
ResourceIDList []int `json:"resourceIdList"`
|
||||
}
|
||||
|
||||
type MemoFind struct {
|
||||
ID *int
|
||||
|
||||
// Standard fields
|
||||
RowStatus *RowStatus
|
||||
CreatorID *int
|
||||
|
||||
// Domain specific fields
|
||||
Pinned *bool
|
||||
ContentSearch *string
|
||||
VisibilityList []Visibility
|
||||
|
||||
// Pagination
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
type MemoDelete struct {
|
||||
ID int
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package api
|
||||
|
||||
type MemoOrganizer struct {
|
||||
ID int
|
||||
|
||||
// Domain specific fields
|
||||
MemoID int
|
||||
UserID int
|
||||
Pinned bool
|
||||
}
|
||||
|
||||
type MemoOrganizerUpsert struct {
|
||||
MemoID int `json:"-"`
|
||||
UserID int `json:"-"`
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
|
||||
type MemoOrganizerFind struct {
|
||||
MemoID int
|
||||
UserID int
|
||||
}
|
||||
|
||||
type MemoOrganizerDelete struct {
|
||||
MemoID *int
|
||||
UserID *int
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package api
|
||||
|
||||
type MemoResource struct {
|
||||
MemoID int
|
||||
ResourceID int
|
||||
CreatedTs int64
|
||||
UpdatedTs int64
|
||||
}
|
||||
|
||||
type MemoResourceUpsert struct {
|
||||
MemoID int `json:"-"`
|
||||
ResourceID int
|
||||
UpdatedTs *int64
|
||||
}
|
||||
|
||||
type MemoResourceFind struct {
|
||||
MemoID *int
|
||||
ResourceID *int
|
||||
}
|
||||
|
||||
type MemoResourceDelete struct {
|
||||
MemoID *int
|
||||
ResourceID *int
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package api
|
||||
|
||||
type Resource struct {
|
||||
ID int `json:"id"`
|
||||
|
||||
// Standard fields
|
||||
CreatorID int `json:"creatorId"`
|
||||
CreatedTs int64 `json:"createdTs"`
|
||||
UpdatedTs int64 `json:"updatedTs"`
|
||||
|
||||
// Domain specific fields
|
||||
Filename string `json:"filename"`
|
||||
Blob []byte `json:"-"`
|
||||
ExternalLink string `json:"externalLink"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
|
||||
// Related fields
|
||||
LinkedMemoAmount int `json:"linkedMemoAmount"`
|
||||
}
|
||||
|
||||
type ResourceCreate struct {
|
||||
// Standard fields
|
||||
CreatorID int `json:"-"`
|
||||
|
||||
// Domain specific fields
|
||||
Filename string `json:"filename"`
|
||||
Blob []byte `json:"-"`
|
||||
ExternalLink string `json:"externalLink"`
|
||||
Type string `json:"-"`
|
||||
Size int64 `json:"-"`
|
||||
}
|
||||
|
||||
type ResourceFind struct {
|
||||
ID *int `json:"id"`
|
||||
|
||||
// Standard fields
|
||||
CreatorID *int `json:"creatorId"`
|
||||
|
||||
// Domain specific fields
|
||||
Filename *string `json:"filename"`
|
||||
MemoID *int
|
||||
}
|
||||
|
||||
type ResourcePatch struct {
|
||||
ID int `json:"-"`
|
||||
|
||||
// Standard fields
|
||||
UpdatedTs *int64
|
||||
|
||||
// Domain specific fields
|
||||
Filename *string `json:"filename"`
|
||||
}
|
||||
|
||||
type ResourceDelete struct {
|
||||
ID int
|
||||
}
|
||||
165
api/resource/resource.go
Normal file
165
api/resource/resource.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/usememos/memos/internal/log"
|
||||
"github.com/usememos/memos/internal/util"
|
||||
"github.com/usememos/memos/server/profile"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// The key name used to store user id in the context
|
||||
// user id is extracted from the jwt token subject field.
|
||||
userIDContextKey = "user-id"
|
||||
// thumbnailImagePath is the directory to store image thumbnails.
|
||||
thumbnailImagePath = ".thumbnail_cache"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
Profile *profile.Profile
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
func NewService(profile *profile.Profile, store *store.Store) *Service {
|
||||
return &Service{
|
||||
Profile: profile,
|
||||
Store: store,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) RegisterResourcePublicRoutes(g *echo.Group) {
|
||||
g.GET("/r/:resourceId", s.streamResource)
|
||||
g.GET("/r/:resourceId/*", s.streamResource)
|
||||
}
|
||||
|
||||
func (s *Service) streamResource(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
resourceID, err := util.ConvertStringToInt32(c.Param("resourceId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
resource, err := s.Store.GetResource(ctx, &store.FindResource{
|
||||
ID: &resourceID,
|
||||
GetBlob: true,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find resource by ID: %v", resourceID)).SetInternal(err)
|
||||
}
|
||||
if resource == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource not found: %d", resourceID))
|
||||
}
|
||||
// Check the related memo visibility.
|
||||
if resource.MemoID != nil {
|
||||
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
|
||||
ID: resource.MemoID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find memo by ID: %v", resource.MemoID)).SetInternal(err)
|
||||
}
|
||||
if memo != nil && memo.Visibility != store.Public {
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok || (memo.Visibility == store.Private && userID != resource.CreatorID) {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Resource visibility not match")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
blob := resource.Blob
|
||||
if resource.InternalPath != "" {
|
||||
resourcePath := resource.InternalPath
|
||||
src, err := os.Open(resourcePath)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to open the local resource: %s", resourcePath)).SetInternal(err)
|
||||
}
|
||||
defer src.Close()
|
||||
blob, err = io.ReadAll(src)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to read the local resource: %s", resourcePath)).SetInternal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if c.QueryParam("thumbnail") == "1" && util.HasPrefixes(resource.Type, "image/png", "image/jpeg") {
|
||||
ext := filepath.Ext(resource.Filename)
|
||||
thumbnailPath := filepath.Join(s.Profile.Data, thumbnailImagePath, fmt.Sprintf("%d%s", resource.ID, ext))
|
||||
thumbnailBlob, err := getOrGenerateThumbnailImage(blob, thumbnailPath)
|
||||
if err != nil {
|
||||
log.Warn(fmt.Sprintf("failed to get or generate local thumbnail with path %s", thumbnailPath), zap.Error(err))
|
||||
} else {
|
||||
blob = thumbnailBlob
|
||||
}
|
||||
}
|
||||
|
||||
c.Response().Writer.Header().Set(echo.HeaderCacheControl, "max-age=3600")
|
||||
c.Response().Writer.Header().Set(echo.HeaderContentSecurityPolicy, "default-src 'none'; script-src 'none'; img-src 'self'; media-src 'self'; sandbox;")
|
||||
c.Response().Writer.Header().Set("Content-Disposition", fmt.Sprintf(`filename="%s"`, resource.Filename))
|
||||
resourceType := strings.ToLower(resource.Type)
|
||||
if strings.HasPrefix(resourceType, "text") {
|
||||
resourceType = echo.MIMETextPlainCharsetUTF8
|
||||
} else if strings.HasPrefix(resourceType, "video") || strings.HasPrefix(resourceType, "audio") {
|
||||
http.ServeContent(c.Response(), c.Request(), resource.Filename, time.Unix(resource.UpdatedTs, 0), bytes.NewReader(blob))
|
||||
return nil
|
||||
}
|
||||
return c.Stream(http.StatusOK, resourceType, bytes.NewReader(blob))
|
||||
}
|
||||
|
||||
var availableGeneratorAmount int32 = 32
|
||||
|
||||
func getOrGenerateThumbnailImage(srcBlob []byte, dstPath string) ([]byte, error) {
|
||||
if _, err := os.Stat(dstPath); err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, errors.Wrap(err, "failed to check thumbnail image stat")
|
||||
}
|
||||
|
||||
if atomic.LoadInt32(&availableGeneratorAmount) <= 0 {
|
||||
return nil, errors.New("not enough available generator amount")
|
||||
}
|
||||
atomic.AddInt32(&availableGeneratorAmount, -1)
|
||||
defer func() {
|
||||
atomic.AddInt32(&availableGeneratorAmount, 1)
|
||||
}()
|
||||
|
||||
reader := bytes.NewReader(srcBlob)
|
||||
src, err := imaging.Decode(reader, imaging.AutoOrientation(true))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to decode thumbnail image")
|
||||
}
|
||||
thumbnailImage := imaging.Resize(src, 512, 0, imaging.Lanczos)
|
||||
|
||||
dstDir := path.Dir(dstPath)
|
||||
if err := os.MkdirAll(dstDir, os.ModePerm); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to create thumbnail dir")
|
||||
}
|
||||
|
||||
if err := imaging.Save(thumbnailImage, dstPath); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to resize thumbnail image")
|
||||
}
|
||||
}
|
||||
|
||||
dstFile, err := os.Open(dstPath)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to open the local resource")
|
||||
}
|
||||
defer dstFile.Close()
|
||||
dstBlob, err := io.ReadAll(dstFile)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to read the local resource")
|
||||
}
|
||||
return dstBlob, nil
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package api
|
||||
|
||||
type Shortcut struct {
|
||||
ID int `json:"id"`
|
||||
|
||||
// Standard fields
|
||||
RowStatus RowStatus `json:"rowStatus"`
|
||||
CreatorID int `json:"creatorId"`
|
||||
CreatedTs int64 `json:"createdTs"`
|
||||
UpdatedTs int64 `json:"updatedTs"`
|
||||
|
||||
// Domain specific fields
|
||||
Title string `json:"title"`
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
|
||||
type ShortcutCreate struct {
|
||||
// Standard fields
|
||||
CreatorID int `json:"-"`
|
||||
|
||||
// Domain specific fields
|
||||
Title string `json:"title"`
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
|
||||
type ShortcutPatch struct {
|
||||
ID int `json:"-"`
|
||||
|
||||
// Standard fields
|
||||
UpdatedTs *int64
|
||||
RowStatus *RowStatus `json:"rowStatus"`
|
||||
|
||||
// Domain specific fields
|
||||
Title *string `json:"title"`
|
||||
Payload *string `json:"payload"`
|
||||
}
|
||||
|
||||
type ShortcutFind struct {
|
||||
ID *int
|
||||
|
||||
// Standard fields
|
||||
CreatorID *int
|
||||
|
||||
// Domain specific fields
|
||||
Title *string `json:"title"`
|
||||
}
|
||||
|
||||
type ShortcutDelete struct {
|
||||
ID *int
|
||||
|
||||
// Standard fields
|
||||
CreatorID *int
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package api
|
||||
|
||||
import "github.com/usememos/memos/server/profile"
|
||||
|
||||
type SystemStatus struct {
|
||||
Host *User `json:"host"`
|
||||
Profile profile.Profile `json:"profile"`
|
||||
DBSize int64 `json:"dbSize"`
|
||||
|
||||
// System settings
|
||||
// Allow sign up.
|
||||
AllowSignUp bool `json:"allowSignUp"`
|
||||
// Additional style.
|
||||
AdditionalStyle string `json:"additionalStyle"`
|
||||
// Additional script.
|
||||
AdditionalScript string `json:"additionalScript"`
|
||||
// Customized server profile, including server name and external url.
|
||||
CustomizedProfile CustomizedProfile `json:"customizedProfile"`
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
type SystemSettingName string
|
||||
|
||||
const (
|
||||
// SystemSettingServerID is the key type of server id.
|
||||
SystemSettingServerID SystemSettingName = "serverId"
|
||||
// SystemSettingSecretSessionName is the key type of secret session name.
|
||||
SystemSettingSecretSessionName SystemSettingName = "secretSessionName"
|
||||
// SystemSettingAllowSignUpName is the key type of allow signup setting.
|
||||
SystemSettingAllowSignUpName SystemSettingName = "allowSignUp"
|
||||
// SystemSettingAdditionalStyleName is the key type of additional style.
|
||||
SystemSettingAdditionalStyleName SystemSettingName = "additionalStyle"
|
||||
// SystemSettingAdditionalScriptName is the key type of additional script.
|
||||
SystemSettingAdditionalScriptName SystemSettingName = "additionalScript"
|
||||
// SystemSettingCustomizedProfileName is the key type of customized server profile.
|
||||
SystemSettingCustomizedProfileName SystemSettingName = "customizedProfile"
|
||||
)
|
||||
|
||||
// CustomizedProfile is the struct definition for SystemSettingCustomizedProfileName system setting item.
|
||||
type CustomizedProfile struct {
|
||||
// Name is the server name, default is `memos`
|
||||
Name string `json:"name"`
|
||||
// LogoURL is the url of logo image.
|
||||
LogoURL string `json:"logoUrl"`
|
||||
// Description is the server description.
|
||||
Description string `json:"description"`
|
||||
// Locale is the server default locale.
|
||||
Locale string `json:"locale"`
|
||||
// Appearance is the server default appearance.
|
||||
Appearance string `json:"appearance"`
|
||||
// ExternalURL is the external url of server. e.g. https://usermemos.com
|
||||
ExternalURL string `json:"externalUrl"`
|
||||
}
|
||||
|
||||
func (key SystemSettingName) String() string {
|
||||
switch key {
|
||||
case SystemSettingServerID:
|
||||
return "serverId"
|
||||
case SystemSettingSecretSessionName:
|
||||
return "secretSessionName"
|
||||
case SystemSettingAllowSignUpName:
|
||||
return "allowSignUp"
|
||||
case SystemSettingAdditionalStyleName:
|
||||
return "additionalStyle"
|
||||
case SystemSettingAdditionalScriptName:
|
||||
return "additionalScript"
|
||||
case SystemSettingCustomizedProfileName:
|
||||
return "customizedProfile"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var (
|
||||
SystemSettingAllowSignUpValue = []bool{true, false}
|
||||
)
|
||||
|
||||
type SystemSetting struct {
|
||||
Name SystemSettingName
|
||||
// Value is a JSON string with basic value.
|
||||
Value string
|
||||
Description string
|
||||
}
|
||||
|
||||
type SystemSettingUpsert struct {
|
||||
Name SystemSettingName `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (upsert SystemSettingUpsert) Validate() error {
|
||||
if upsert.Name == SystemSettingServerID {
|
||||
return errors.New("update server id is not allowed")
|
||||
} else if upsert.Name == SystemSettingAllowSignUpName {
|
||||
value := false
|
||||
err := json.Unmarshal([]byte(upsert.Value), &value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal system setting allow signup value")
|
||||
}
|
||||
|
||||
invalid := true
|
||||
for _, v := range SystemSettingAllowSignUpValue {
|
||||
if value == v {
|
||||
invalid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if invalid {
|
||||
return fmt.Errorf("invalid system setting allow signup value")
|
||||
}
|
||||
} else if upsert.Name == SystemSettingAdditionalStyleName {
|
||||
value := ""
|
||||
err := json.Unmarshal([]byte(upsert.Value), &value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal system setting additional style value")
|
||||
}
|
||||
} else if upsert.Name == SystemSettingAdditionalScriptName {
|
||||
value := ""
|
||||
err := json.Unmarshal([]byte(upsert.Value), &value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal system setting additional script value")
|
||||
}
|
||||
} else if upsert.Name == SystemSettingCustomizedProfileName {
|
||||
customizedProfile := CustomizedProfile{
|
||||
Name: "memos",
|
||||
LogoURL: "",
|
||||
Description: "",
|
||||
Locale: "en",
|
||||
Appearance: "system",
|
||||
ExternalURL: "",
|
||||
}
|
||||
err := json.Unmarshal([]byte(upsert.Value), &customizedProfile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal system setting customized profile value")
|
||||
}
|
||||
if !slices.Contains(UserSettingLocaleValue, customizedProfile.Locale) {
|
||||
return fmt.Errorf("invalid locale value")
|
||||
}
|
||||
if !slices.Contains(UserSettingAppearanceValue, customizedProfile.Appearance) {
|
||||
return fmt.Errorf("invalid appearance value")
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("invalid system setting name")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type SystemSettingFind struct {
|
||||
Name *SystemSettingName `json:"name"`
|
||||
}
|
||||
20
api/tag.go
20
api/tag.go
@@ -1,20 +0,0 @@
|
||||
package api
|
||||
|
||||
type Tag struct {
|
||||
Name string
|
||||
CreatorID int
|
||||
}
|
||||
|
||||
type TagUpsert struct {
|
||||
Name string
|
||||
CreatorID int `json:"-"`
|
||||
}
|
||||
|
||||
type TagFind struct {
|
||||
CreatorID int
|
||||
}
|
||||
|
||||
type TagDelete struct {
|
||||
Name string
|
||||
CreatorID int
|
||||
}
|
||||
145
api/user.go
145
api/user.go
@@ -1,145 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/usememos/memos/common"
|
||||
)
|
||||
|
||||
// Role is the type of a role.
|
||||
type Role string
|
||||
|
||||
const (
|
||||
// Host is the HOST role.
|
||||
Host Role = "HOST"
|
||||
// Admin is the ADMIN role.
|
||||
Admin Role = "ADMIN"
|
||||
// NormalUser is the USER role.
|
||||
NormalUser Role = "USER"
|
||||
)
|
||||
|
||||
func (e Role) String() string {
|
||||
switch e {
|
||||
case Host:
|
||||
return "HOST"
|
||||
case Admin:
|
||||
return "ADMIN"
|
||||
case NormalUser:
|
||||
return "USER"
|
||||
}
|
||||
return "USER"
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int `json:"id"`
|
||||
|
||||
// Standard fields
|
||||
RowStatus RowStatus `json:"rowStatus"`
|
||||
CreatedTs int64 `json:"createdTs"`
|
||||
UpdatedTs int64 `json:"updatedTs"`
|
||||
|
||||
// Domain specific fields
|
||||
Username string `json:"username"`
|
||||
Role Role `json:"role"`
|
||||
Email string `json:"email"`
|
||||
Nickname string `json:"nickname"`
|
||||
PasswordHash string `json:"-"`
|
||||
OpenID string `json:"openId"`
|
||||
UserSettingList []*UserSetting `json:"userSettingList"`
|
||||
}
|
||||
|
||||
type UserCreate struct {
|
||||
// Domain specific fields
|
||||
Username string `json:"username"`
|
||||
Role Role `json:"role"`
|
||||
Email string `json:"email"`
|
||||
Nickname string `json:"nickname"`
|
||||
Password string `json:"password"`
|
||||
PasswordHash string
|
||||
OpenID string
|
||||
}
|
||||
|
||||
func (create UserCreate) Validate() error {
|
||||
if len(create.Username) < 4 {
|
||||
return fmt.Errorf("username is too short, minimum length is 4")
|
||||
}
|
||||
if len(create.Username) > 32 {
|
||||
return fmt.Errorf("username is too long, maximum length is 32")
|
||||
}
|
||||
if len(create.Password) < 4 {
|
||||
return fmt.Errorf("password is too short, minimum length is 4")
|
||||
}
|
||||
if len(create.Nickname) > 64 {
|
||||
return fmt.Errorf("nickname is too long, maximum length is 64")
|
||||
}
|
||||
if create.Email != "" {
|
||||
if len(create.Email) > 256 {
|
||||
return fmt.Errorf("email is too long, maximum length is 256")
|
||||
}
|
||||
if !common.ValidateEmail(create.Email) {
|
||||
return fmt.Errorf("invalid email format")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type UserPatch struct {
|
||||
ID int `json:"-"`
|
||||
|
||||
// Standard fields
|
||||
UpdatedTs *int64
|
||||
RowStatus *RowStatus `json:"rowStatus"`
|
||||
|
||||
// Domain specific fields
|
||||
Username *string `json:"username"`
|
||||
Email *string `json:"email"`
|
||||
Nickname *string `json:"nickname"`
|
||||
Password *string `json:"password"`
|
||||
ResetOpenID *bool `json:"resetOpenId"`
|
||||
PasswordHash *string
|
||||
OpenID *string
|
||||
}
|
||||
|
||||
func (patch UserPatch) Validate() error {
|
||||
if patch.Username != nil && len(*patch.Username) < 4 {
|
||||
return fmt.Errorf("username is too short, minimum length is 4")
|
||||
}
|
||||
if patch.Username != nil && len(*patch.Username) > 32 {
|
||||
return fmt.Errorf("username is too long, maximum length is 32")
|
||||
}
|
||||
if patch.Password != nil && len(*patch.Password) < 4 {
|
||||
return fmt.Errorf("password is too short, minimum length is 4")
|
||||
}
|
||||
if patch.Nickname != nil && len(*patch.Nickname) > 64 {
|
||||
return fmt.Errorf("nickname is too long, maximum length is 64")
|
||||
}
|
||||
if patch.Email != nil {
|
||||
if len(*patch.Email) > 256 {
|
||||
return fmt.Errorf("email is too long, maximum length is 256")
|
||||
}
|
||||
if !common.ValidateEmail(*patch.Email) {
|
||||
return fmt.Errorf("invalid email format")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type UserFind struct {
|
||||
ID *int `json:"id"`
|
||||
|
||||
// Standard fields
|
||||
RowStatus *RowStatus `json:"rowStatus"`
|
||||
|
||||
// Domain specific fields
|
||||
Username *string `json:"username"`
|
||||
Role *Role
|
||||
Email *string `json:"email"`
|
||||
Nickname *string `json:"nickname"`
|
||||
OpenID *string
|
||||
}
|
||||
|
||||
type UserDelete struct {
|
||||
ID int
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
type UserSettingKey string
|
||||
|
||||
const (
|
||||
// UserSettingLocaleKey is the key type for user locale.
|
||||
UserSettingLocaleKey UserSettingKey = "locale"
|
||||
// UserSettingAppearanceKey is the key type for user appearance.
|
||||
UserSettingAppearanceKey UserSettingKey = "appearance"
|
||||
// UserSettingMemoVisibilityKey is the key type for user preference memo default visibility.
|
||||
UserSettingMemoVisibilityKey UserSettingKey = "memoVisibility"
|
||||
// UserSettingMemoDisplayTsOptionKey is the key type for memo display ts option.
|
||||
UserSettingMemoDisplayTsOptionKey UserSettingKey = "memoDisplayTsOption"
|
||||
)
|
||||
|
||||
// String returns the string format of UserSettingKey type.
|
||||
func (key UserSettingKey) String() string {
|
||||
switch key {
|
||||
case UserSettingLocaleKey:
|
||||
return "locale"
|
||||
case UserSettingAppearanceKey:
|
||||
return "appearance"
|
||||
case UserSettingMemoVisibilityKey:
|
||||
return "memoVisibility"
|
||||
case UserSettingMemoDisplayTsOptionKey:
|
||||
return "memoDisplayTsOption"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var (
|
||||
UserSettingLocaleValue = []string{"en", "zh", "vi", "fr", "nl", "sv", "de", "es", "uk", "ru", "it"}
|
||||
UserSettingAppearanceValue = []string{"system", "light", "dark"}
|
||||
UserSettingMemoVisibilityValue = []Visibility{Private, Protected, Public}
|
||||
UserSettingMemoDisplayTsOptionKeyValue = []string{"created_ts", "updated_ts"}
|
||||
)
|
||||
|
||||
type UserSetting struct {
|
||||
UserID int
|
||||
Key UserSettingKey `json:"key"`
|
||||
// Value is a JSON string with basic value
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type UserSettingUpsert struct {
|
||||
UserID int `json:"-"`
|
||||
Key UserSettingKey `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
func (upsert UserSettingUpsert) Validate() error {
|
||||
if upsert.Key == UserSettingLocaleKey {
|
||||
localeValue := "en"
|
||||
err := json.Unmarshal([]byte(upsert.Value), &localeValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal user setting locale value")
|
||||
}
|
||||
if !slices.Contains(UserSettingLocaleValue, localeValue) {
|
||||
return fmt.Errorf("invalid user setting locale value")
|
||||
}
|
||||
} else if upsert.Key == UserSettingAppearanceKey {
|
||||
appearanceValue := "system"
|
||||
err := json.Unmarshal([]byte(upsert.Value), &appearanceValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal user setting appearance value")
|
||||
}
|
||||
if !slices.Contains(UserSettingAppearanceValue, appearanceValue) {
|
||||
return fmt.Errorf("invalid user setting appearance value")
|
||||
}
|
||||
} else if upsert.Key == UserSettingMemoVisibilityKey {
|
||||
memoVisibilityValue := Private
|
||||
err := json.Unmarshal([]byte(upsert.Value), &memoVisibilityValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal user setting memo visibility value")
|
||||
}
|
||||
if !slices.Contains(UserSettingMemoVisibilityValue, memoVisibilityValue) {
|
||||
return fmt.Errorf("invalid user setting memo visibility value")
|
||||
}
|
||||
} else if upsert.Key == UserSettingMemoDisplayTsOptionKey {
|
||||
memoDisplayTsOption := "created_ts"
|
||||
err := json.Unmarshal([]byte(upsert.Value), &memoDisplayTsOption)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal user setting memo display ts option")
|
||||
}
|
||||
if !slices.Contains(UserSettingMemoDisplayTsOptionKeyValue, memoDisplayTsOption) {
|
||||
return fmt.Errorf("invalid user setting memo display ts option value")
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("invalid user setting key")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type UserSettingFind struct {
|
||||
UserID int
|
||||
|
||||
Key *UserSettingKey `json:"key"`
|
||||
}
|
||||
|
||||
type UserSettingDelete struct {
|
||||
UserID int
|
||||
}
|
||||
399
api/v1/auth.go
Normal file
399
api/v1/auth.go
Normal file
@@ -0,0 +1,399 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/usememos/memos/api/auth"
|
||||
"github.com/usememos/memos/internal/util"
|
||||
"github.com/usememos/memos/plugin/idp"
|
||||
"github.com/usememos/memos/plugin/idp/oauth2"
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
var (
|
||||
usernameMatcher = regexp.MustCompile("^[a-z0-9]([a-z0-9-]{1,30}[a-z0-9])$")
|
||||
)
|
||||
|
||||
type SignIn struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Remember bool `json:"remember"`
|
||||
}
|
||||
|
||||
type SSOSignIn struct {
|
||||
IdentityProviderID int32 `json:"identityProviderId"`
|
||||
Code string `json:"code"`
|
||||
RedirectURI string `json:"redirectUri"`
|
||||
}
|
||||
|
||||
type SignUp struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerAuthRoutes(g *echo.Group) {
|
||||
g.POST("/auth/signin", s.SignIn)
|
||||
g.POST("/auth/signin/sso", s.SignInSSO)
|
||||
g.POST("/auth/signout", s.SignOut)
|
||||
g.POST("/auth/signup", s.SignUp)
|
||||
}
|
||||
|
||||
// SignIn godoc
|
||||
//
|
||||
// @Summary Sign-in to memos.
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body SignIn true "Sign-in object"
|
||||
// @Success 200 {object} store.User "User information"
|
||||
// @Failure 400 {object} nil "Malformatted signin request"
|
||||
// @Failure 401 {object} nil "Password login is deactivated | Incorrect login credentials, please try again"
|
||||
// @Failure 403 {object} nil "User has been archived with username %s"
|
||||
// @Failure 500 {object} nil "Failed to find system setting | Failed to unmarshal system setting | Incorrect login credentials, please try again | Failed to generate tokens | Failed to create activity"
|
||||
// @Router /api/v1/auth/signin [POST]
|
||||
func (s *APIV1Service) SignIn(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
signin := &SignIn{}
|
||||
|
||||
disablePasswordLoginSystemSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
|
||||
Name: SystemSettingDisablePasswordLoginName.String(),
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting").SetInternal(err)
|
||||
}
|
||||
if disablePasswordLoginSystemSetting != nil {
|
||||
disablePasswordLogin := false
|
||||
err = json.Unmarshal([]byte(disablePasswordLoginSystemSetting.Value), &disablePasswordLogin)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting").SetInternal(err)
|
||||
}
|
||||
if disablePasswordLogin {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Password login is deactivated")
|
||||
}
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signin request").SetInternal(err)
|
||||
}
|
||||
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
Username: &signin.Username,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Incorrect login credentials, please try again")
|
||||
}
|
||||
if user == nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Incorrect login credentials, please try again")
|
||||
} else if user.RowStatus == store.Archived {
|
||||
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with username %s", signin.Username))
|
||||
}
|
||||
|
||||
// Compare the stored hashed password, with the hashed version of the password that was received.
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(signin.Password)); err != nil {
|
||||
// If the two passwords don't match, return a 401 status.
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Incorrect login credentials, please try again")
|
||||
}
|
||||
|
||||
var expireAt time.Time
|
||||
// Set cookie expiration to 100 years to make it persistent.
|
||||
cookieExp := time.Now().AddDate(100, 0, 0)
|
||||
if !signin.Remember {
|
||||
expireAt = time.Now().Add(auth.AccessTokenDuration)
|
||||
cookieExp = time.Now().Add(auth.CookieExpDuration)
|
||||
}
|
||||
|
||||
accessToken, err := auth.GenerateAccessToken(user.Username, user.ID, expireAt, []byte(s.Secret))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert access token, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
||||
userMessage := convertUserFromStore(user)
|
||||
return c.JSON(http.StatusOK, userMessage)
|
||||
}
|
||||
|
||||
// SignInSSO godoc
|
||||
//
|
||||
// @Summary Sign-in to memos using SSO.
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body SSOSignIn true "SSO sign-in object"
|
||||
// @Success 200 {object} store.User "User information"
|
||||
// @Failure 400 {object} nil "Malformatted signin request"
|
||||
// @Failure 401 {object} nil "Access denied, identifier does not match the filter."
|
||||
// @Failure 403 {object} nil "User has been archived with username {username}"
|
||||
// @Failure 404 {object} nil "Identity provider not found"
|
||||
// @Failure 500 {object} nil "Failed to find identity provider | Failed to create identity provider instance | Failed to exchange token | Failed to get user info | Failed to compile identifier filter | Incorrect login credentials, please try again | Failed to generate random password | Failed to generate password hash | Failed to create user | Failed to generate tokens | Failed to create activity"
|
||||
// @Router /api/v1/auth/signin/sso [POST]
|
||||
func (s *APIV1Service) SignInSSO(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
signin := &SSOSignIn{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signin request").SetInternal(err)
|
||||
}
|
||||
|
||||
identityProvider, err := s.Store.GetIdentityProvider(ctx, &store.FindIdentityProvider{
|
||||
ID: &signin.IdentityProviderID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find identity provider").SetInternal(err)
|
||||
}
|
||||
if identityProvider == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "Identity provider not found")
|
||||
}
|
||||
|
||||
var userInfo *idp.IdentityProviderUserInfo
|
||||
if identityProvider.Type == store.IdentityProviderOAuth2Type {
|
||||
oauth2IdentityProvider, err := oauth2.NewIdentityProvider(identityProvider.Config.OAuth2Config)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create identity provider instance").SetInternal(err)
|
||||
}
|
||||
token, err := oauth2IdentityProvider.ExchangeToken(ctx, signin.RedirectURI, signin.Code)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to exchange token").SetInternal(err)
|
||||
}
|
||||
userInfo, err = oauth2IdentityProvider.UserInfo(token)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get user info").SetInternal(err)
|
||||
}
|
||||
}
|
||||
|
||||
identifierFilter := identityProvider.IdentifierFilter
|
||||
if identifierFilter != "" {
|
||||
identifierFilterRegex, err := regexp.Compile(identifierFilter)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compile identifier filter").SetInternal(err)
|
||||
}
|
||||
if !identifierFilterRegex.MatchString(userInfo.Identifier) {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Access denied, identifier does not match the filter.").SetInternal(err)
|
||||
}
|
||||
}
|
||||
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
Username: &userInfo.Identifier,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Incorrect login credentials, please try again")
|
||||
}
|
||||
if user == nil {
|
||||
allowSignUpSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
|
||||
Name: SystemSettingAllowSignUpName.String(),
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting").SetInternal(err)
|
||||
}
|
||||
|
||||
allowSignUpSettingValue := true
|
||||
if allowSignUpSetting != nil {
|
||||
err = json.Unmarshal([]byte(allowSignUpSetting.Value), &allowSignUpSettingValue)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting allow signup").SetInternal(err)
|
||||
}
|
||||
}
|
||||
if !allowSignUpSettingValue {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "signup is disabled").SetInternal(err)
|
||||
}
|
||||
|
||||
userCreate := &store.User{
|
||||
Username: userInfo.Identifier,
|
||||
// The new signup user should be normal user by default.
|
||||
Role: store.RoleUser,
|
||||
Nickname: userInfo.DisplayName,
|
||||
Email: userInfo.Email,
|
||||
}
|
||||
password, err := util.RandomString(20)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate random password").SetInternal(err)
|
||||
}
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
|
||||
}
|
||||
userCreate.PasswordHash = string(passwordHash)
|
||||
user, err = s.Store.CreateUser(ctx, userCreate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
|
||||
}
|
||||
}
|
||||
if user.RowStatus == store.Archived {
|
||||
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with username %s", userInfo.Identifier))
|
||||
}
|
||||
|
||||
accessToken, err := auth.GenerateAccessToken(user.Username, user.ID, time.Now().Add(auth.AccessTokenDuration), []byte(s.Secret))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert access token, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
cookieExp := time.Now().Add(auth.CookieExpDuration)
|
||||
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
||||
userMessage := convertUserFromStore(user)
|
||||
return c.JSON(http.StatusOK, userMessage)
|
||||
}
|
||||
|
||||
// SignOut godoc
|
||||
//
|
||||
// @Summary Sign-out from memos.
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Success 200 {boolean} true "Sign-out success"
|
||||
// @Router /api/v1/auth/signout [POST]
|
||||
func (s *APIV1Service) SignOut(c echo.Context) error {
|
||||
accessToken := findAccessToken(c)
|
||||
userID, _ := getUserIDFromAccessToken(accessToken, s.Secret)
|
||||
|
||||
err := removeAccessTokenAndCookies(c, s.Store, userID, accessToken)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to remove access token, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, true)
|
||||
}
|
||||
|
||||
// SignUp godoc
|
||||
//
|
||||
// @Summary Sign-up to memos.
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body SignUp true "Sign-up object"
|
||||
// @Success 200 {object} store.User "User information"
|
||||
// @Failure 400 {object} nil "Malformatted signup request | Failed to find users"
|
||||
// @Failure 401 {object} nil "signup is disabled"
|
||||
// @Failure 403 {object} nil "Forbidden"
|
||||
// @Failure 404 {object} nil "Not found"
|
||||
// @Failure 500 {object} nil "Failed to find system setting | Failed to unmarshal system setting allow signup | Failed to generate password hash | Failed to create user | Failed to generate tokens | Failed to create activity"
|
||||
// @Router /api/v1/auth/signup [POST]
|
||||
func (s *APIV1Service) SignUp(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
signup := &SignUp{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(signup); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signup request").SetInternal(err)
|
||||
}
|
||||
|
||||
hostUserType := store.RoleHost
|
||||
existedHostUsers, err := s.Store.ListUsers(ctx, &store.FindUser{
|
||||
Role: &hostUserType,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Failed to find users").SetInternal(err)
|
||||
}
|
||||
if !usernameMatcher.MatchString(strings.ToLower(signup.Username)) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid username %s", signup.Username)).SetInternal(err)
|
||||
}
|
||||
|
||||
userCreate := &store.User{
|
||||
Username: signup.Username,
|
||||
// The new signup user should be normal user by default.
|
||||
Role: store.RoleUser,
|
||||
Nickname: signup.Username,
|
||||
}
|
||||
if len(existedHostUsers) == 0 {
|
||||
// Change the default role to host if there is no host user.
|
||||
userCreate.Role = store.RoleHost
|
||||
} else {
|
||||
allowSignUpSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
|
||||
Name: SystemSettingAllowSignUpName.String(),
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting").SetInternal(err)
|
||||
}
|
||||
|
||||
allowSignUpSettingValue := true
|
||||
if allowSignUpSetting != nil {
|
||||
err = json.Unmarshal([]byte(allowSignUpSetting.Value), &allowSignUpSettingValue)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting allow signup").SetInternal(err)
|
||||
}
|
||||
}
|
||||
if !allowSignUpSettingValue {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "signup is disabled").SetInternal(err)
|
||||
}
|
||||
}
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(signup.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
|
||||
}
|
||||
|
||||
userCreate.PasswordHash = string(passwordHash)
|
||||
user, err := s.Store.CreateUser(ctx, userCreate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
|
||||
}
|
||||
accessToken, err := auth.GenerateAccessToken(user.Username, user.ID, time.Now().Add(auth.AccessTokenDuration), []byte(s.Secret))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert access token, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
cookieExp := time.Now().Add(auth.CookieExpDuration)
|
||||
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
||||
userMessage := convertUserFromStore(user)
|
||||
return c.JSON(http.StatusOK, userMessage)
|
||||
}
|
||||
|
||||
func (s *APIV1Service) UpsertAccessTokenToStore(ctx context.Context, user *store.User, accessToken string) error {
|
||||
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to get user access tokens")
|
||||
}
|
||||
userAccessToken := storepb.AccessTokensUserSetting_AccessToken{
|
||||
AccessToken: accessToken,
|
||||
Description: "Account sign in",
|
||||
}
|
||||
userAccessTokens = append(userAccessTokens, &userAccessToken)
|
||||
if _, err := s.Store.UpsertUserSettingV1(ctx, &storepb.UserSetting{
|
||||
UserId: user.ID,
|
||||
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
||||
Value: &storepb.UserSetting_AccessTokens{
|
||||
AccessTokens: &storepb.AccessTokensUserSetting{
|
||||
AccessTokens: userAccessTokens,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert user setting, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeAccessTokenAndCookies removes the jwt token from the cookies.
|
||||
func removeAccessTokenAndCookies(c echo.Context, s *store.Store, userID int32, token string) error {
|
||||
err := s.RemoveUserAccessToken(c.Request().Context(), userID, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cookieExp := time.Now().Add(-1 * time.Hour)
|
||||
setTokenCookie(c, auth.AccessTokenCookieName, "", cookieExp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// setTokenCookie sets the token to the cookie.
|
||||
func setTokenCookie(c echo.Context, name, token string, expiration time.Time) {
|
||||
cookie := new(http.Cookie)
|
||||
cookie.Name = name
|
||||
cookie.Value = token
|
||||
cookie.Expires = expiration
|
||||
cookie.Path = "/"
|
||||
// Http-only helps mitigate the risk of client side script accessing the protected cookie.
|
||||
cookie.HttpOnly = true
|
||||
cookie.SameSite = http.SameSiteStrictMode
|
||||
c.SetCookie(cookie)
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
package api
|
||||
|
||||
// UnknownID is the ID for unknowns.
|
||||
const UnknownID = -1
|
||||
package v1
|
||||
|
||||
// RowStatus is the status for a row.
|
||||
type RowStatus string
|
||||
@@ -13,12 +10,6 @@ const (
|
||||
Archived RowStatus = "ARCHIVED"
|
||||
)
|
||||
|
||||
func (e RowStatus) String() string {
|
||||
switch e {
|
||||
case Normal:
|
||||
return "NORMAL"
|
||||
case Archived:
|
||||
return "ARCHIVED"
|
||||
}
|
||||
return ""
|
||||
func (r RowStatus) String() string {
|
||||
return string(r)
|
||||
}
|
||||
3015
api/v1/docs.go
Normal file
3015
api/v1/docs.go
Normal file
File diff suppressed because it is too large
Load Diff
49
api/v1/http_getter.go
Normal file
49
api/v1/http_getter.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
getter "github.com/usememos/memos/plugin/http-getter"
|
||||
)
|
||||
|
||||
func (*APIV1Service) registerGetterPublicRoutes(g *echo.Group) {
|
||||
// GET /get/image?url={url} - Get image.
|
||||
g.GET("/get/image", GetImage)
|
||||
}
|
||||
|
||||
// GetImage godoc
|
||||
//
|
||||
// @Summary Get GetImage from URL
|
||||
// @Tags image-url
|
||||
// @Produce GetImage/*
|
||||
// @Param url query string true "Image url"
|
||||
// @Success 200 {object} nil "Image"
|
||||
// @Failure 400 {object} nil "Missing GetImage url | Wrong url | Failed to get GetImage url: %s"
|
||||
// @Failure 500 {object} nil "Failed to write GetImage blob"
|
||||
// @Router /o/get/GetImage [GET]
|
||||
func GetImage(c echo.Context) error {
|
||||
urlStr := c.QueryParam("url")
|
||||
if urlStr == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Missing image url")
|
||||
}
|
||||
if _, err := url.Parse(urlStr); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Wrong url").SetInternal(err)
|
||||
}
|
||||
|
||||
image, err := getter.GetImage(urlStr)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to get image url: %s", urlStr)).SetInternal(err)
|
||||
}
|
||||
|
||||
c.Response().Writer.WriteHeader(http.StatusOK)
|
||||
c.Response().Writer.Header().Set("Content-Type", image.Mediatype)
|
||||
c.Response().Writer.Header().Set(echo.HeaderCacheControl, "max-age=31536000, immutable")
|
||||
if _, err := c.Response().Writer.Write(image.Blob); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to write image blob").SetInternal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
349
api/v1/idp.go
Normal file
349
api/v1/idp.go
Normal file
@@ -0,0 +1,349 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"github.com/usememos/memos/internal/util"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
type IdentityProviderType string
|
||||
|
||||
const (
|
||||
IdentityProviderOAuth2Type IdentityProviderType = "OAUTH2"
|
||||
)
|
||||
|
||||
func (t IdentityProviderType) String() string {
|
||||
return string(t)
|
||||
}
|
||||
|
||||
type IdentityProviderConfig struct {
|
||||
OAuth2Config *IdentityProviderOAuth2Config `json:"oauth2Config"`
|
||||
}
|
||||
|
||||
type IdentityProviderOAuth2Config struct {
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
AuthURL string `json:"authUrl"`
|
||||
TokenURL string `json:"tokenUrl"`
|
||||
UserInfoURL string `json:"userInfoUrl"`
|
||||
Scopes []string `json:"scopes"`
|
||||
FieldMapping *FieldMapping `json:"fieldMapping"`
|
||||
}
|
||||
|
||||
type FieldMapping struct {
|
||||
Identifier string `json:"identifier"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type IdentityProvider struct {
|
||||
ID int32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type IdentityProviderType `json:"type"`
|
||||
IdentifierFilter string `json:"identifierFilter"`
|
||||
Config *IdentityProviderConfig `json:"config"`
|
||||
}
|
||||
|
||||
type CreateIdentityProviderRequest struct {
|
||||
Name string `json:"name"`
|
||||
Type IdentityProviderType `json:"type"`
|
||||
IdentifierFilter string `json:"identifierFilter"`
|
||||
Config *IdentityProviderConfig `json:"config"`
|
||||
}
|
||||
|
||||
type UpdateIdentityProviderRequest struct {
|
||||
ID int32 `json:"-"`
|
||||
Type IdentityProviderType `json:"type"`
|
||||
Name *string `json:"name"`
|
||||
IdentifierFilter *string `json:"identifierFilter"`
|
||||
Config *IdentityProviderConfig `json:"config"`
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerIdentityProviderRoutes(g *echo.Group) {
|
||||
g.GET("/idp", s.GetIdentityProviderList)
|
||||
g.POST("/idp", s.CreateIdentityProvider)
|
||||
g.GET("/idp/:idpId", s.GetIdentityProvider)
|
||||
g.PATCH("/idp/:idpId", s.UpdateIdentityProvider)
|
||||
g.DELETE("/idp/:idpId", s.DeleteIdentityProvider)
|
||||
}
|
||||
|
||||
// GetIdentityProviderList godoc
|
||||
//
|
||||
// @Summary Get a list of identity providers
|
||||
// @Description *clientSecret is only available for host user
|
||||
// @Tags idp
|
||||
// @Produce json
|
||||
// @Success 200 {object} []IdentityProvider "List of available identity providers"
|
||||
// @Failure 500 {object} nil "Failed to find identity provider list | Failed to find user"
|
||||
// @Router /api/v1/idp [GET]
|
||||
func (s *APIV1Service) GetIdentityProviderList(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
list, err := s.Store.ListIdentityProviders(ctx, &store.FindIdentityProvider{})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find identity provider list").SetInternal(err)
|
||||
}
|
||||
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
isHostUser := false
|
||||
if ok {
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||
}
|
||||
if user == nil || user.Role == store.RoleHost {
|
||||
isHostUser = true
|
||||
}
|
||||
}
|
||||
|
||||
identityProviderList := []*IdentityProvider{}
|
||||
for _, item := range list {
|
||||
identityProvider := convertIdentityProviderFromStore(item)
|
||||
// data desensitize
|
||||
if !isHostUser {
|
||||
identityProvider.Config.OAuth2Config.ClientSecret = ""
|
||||
}
|
||||
identityProviderList = append(identityProviderList, identityProvider)
|
||||
}
|
||||
return c.JSON(http.StatusOK, identityProviderList)
|
||||
}
|
||||
|
||||
// CreateIdentityProvider godoc
|
||||
//
|
||||
// @Summary Create Identity Provider
|
||||
// @Tags idp
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body CreateIdentityProviderRequest true "Identity provider information"
|
||||
// @Success 200 {object} store.IdentityProvider "Identity provider information"
|
||||
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
|
||||
// @Failure 400 {object} nil "Malformatted post identity provider request"
|
||||
// @Failure 500 {object} nil "Failed to find user | Failed to create identity provider"
|
||||
// @Router /api/v1/idp [POST]
|
||||
func (s *APIV1Service) CreateIdentityProvider(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||
}
|
||||
if user == nil || user.Role != store.RoleHost {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
identityProviderCreate := &CreateIdentityProviderRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(identityProviderCreate); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post identity provider request").SetInternal(err)
|
||||
}
|
||||
|
||||
identityProvider, err := s.Store.CreateIdentityProvider(ctx, &store.IdentityProvider{
|
||||
Name: identityProviderCreate.Name,
|
||||
Type: store.IdentityProviderType(identityProviderCreate.Type),
|
||||
IdentifierFilter: identityProviderCreate.IdentifierFilter,
|
||||
Config: convertIdentityProviderConfigToStore(identityProviderCreate.Config),
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create identity provider").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, convertIdentityProviderFromStore(identityProvider))
|
||||
}
|
||||
|
||||
// GetIdentityProvider godoc
|
||||
//
|
||||
// @Summary Get an identity provider by ID
|
||||
// @Tags idp
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param idpId path int true "Identity provider ID"
|
||||
// @Success 200 {object} store.IdentityProvider "Requested identity provider"
|
||||
// @Failure 400 {object} nil "ID is not a number: %s"
|
||||
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
|
||||
// @Failure 404 {object} nil "Identity provider not found"
|
||||
// @Failure 500 {object} nil "Failed to find identity provider list | Failed to find user"
|
||||
// @Router /api/v1/idp/{idpId} [GET]
|
||||
func (s *APIV1Service) GetIdentityProvider(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||
}
|
||||
if user == nil || user.Role != store.RoleHost {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
identityProviderID, err := util.ConvertStringToInt32(c.Param("idpId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("idpId"))).SetInternal(err)
|
||||
}
|
||||
identityProvider, err := s.Store.GetIdentityProvider(ctx, &store.FindIdentityProvider{
|
||||
ID: &identityProviderID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get identity provider").SetInternal(err)
|
||||
}
|
||||
if identityProvider == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "Identity provider not found")
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, convertIdentityProviderFromStore(identityProvider))
|
||||
}
|
||||
|
||||
// DeleteIdentityProvider godoc
|
||||
//
|
||||
// @Summary Delete an identity provider by ID
|
||||
// @Tags idp
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param idpId path int true "Identity Provider ID"
|
||||
// @Success 200 {boolean} true "Identity Provider deleted"
|
||||
// @Failure 400 {object} nil "ID is not a number: %s | Malformatted patch identity provider request"
|
||||
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
|
||||
// @Failure 500 {object} nil "Failed to find user | Failed to patch identity provider"
|
||||
// @Router /api/v1/idp/{idpId} [DELETE]
|
||||
func (s *APIV1Service) DeleteIdentityProvider(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||
}
|
||||
if user == nil || user.Role != store.RoleHost {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
identityProviderID, err := util.ConvertStringToInt32(c.Param("idpId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("idpId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
if err = s.Store.DeleteIdentityProvider(ctx, &store.DeleteIdentityProvider{ID: identityProviderID}); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete identity provider").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, true)
|
||||
}
|
||||
|
||||
// UpdateIdentityProvider godoc
|
||||
//
|
||||
// @Summary Update an identity provider by ID
|
||||
// @Tags idp
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param idpId path int true "Identity Provider ID"
|
||||
// @Param body body UpdateIdentityProviderRequest true "Patched identity provider information"
|
||||
// @Success 200 {object} store.IdentityProvider "Patched identity provider"
|
||||
// @Failure 400 {object} nil "ID is not a number: %s | Malformatted patch identity provider request"
|
||||
// @Failure 401 {object} nil "Missing user in session | Unauthorized
|
||||
// @Failure 500 {object} nil "Failed to find user | Failed to patch identity provider"
|
||||
// @Router /api/v1/idp/{idpId} [PATCH]
|
||||
func (s *APIV1Service) UpdateIdentityProvider(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||
}
|
||||
if user == nil || user.Role != store.RoleHost {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
identityProviderID, err := util.ConvertStringToInt32(c.Param("idpId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("idpId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
identityProviderPatch := &UpdateIdentityProviderRequest{
|
||||
ID: identityProviderID,
|
||||
}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(identityProviderPatch); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch identity provider request").SetInternal(err)
|
||||
}
|
||||
|
||||
identityProvider, err := s.Store.UpdateIdentityProvider(ctx, &store.UpdateIdentityProvider{
|
||||
ID: identityProviderPatch.ID,
|
||||
Type: store.IdentityProviderType(identityProviderPatch.Type),
|
||||
Name: identityProviderPatch.Name,
|
||||
IdentifierFilter: identityProviderPatch.IdentifierFilter,
|
||||
Config: convertIdentityProviderConfigToStore(identityProviderPatch.Config),
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch identity provider").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, convertIdentityProviderFromStore(identityProvider))
|
||||
}
|
||||
|
||||
func convertIdentityProviderFromStore(identityProvider *store.IdentityProvider) *IdentityProvider {
|
||||
return &IdentityProvider{
|
||||
ID: identityProvider.ID,
|
||||
Name: identityProvider.Name,
|
||||
Type: IdentityProviderType(identityProvider.Type),
|
||||
IdentifierFilter: identityProvider.IdentifierFilter,
|
||||
Config: convertIdentityProviderConfigFromStore(identityProvider.Config),
|
||||
}
|
||||
}
|
||||
|
||||
func convertIdentityProviderConfigFromStore(config *store.IdentityProviderConfig) *IdentityProviderConfig {
|
||||
return &IdentityProviderConfig{
|
||||
OAuth2Config: &IdentityProviderOAuth2Config{
|
||||
ClientID: config.OAuth2Config.ClientID,
|
||||
ClientSecret: config.OAuth2Config.ClientSecret,
|
||||
AuthURL: config.OAuth2Config.AuthURL,
|
||||
TokenURL: config.OAuth2Config.TokenURL,
|
||||
UserInfoURL: config.OAuth2Config.UserInfoURL,
|
||||
Scopes: config.OAuth2Config.Scopes,
|
||||
FieldMapping: &FieldMapping{
|
||||
Identifier: config.OAuth2Config.FieldMapping.Identifier,
|
||||
DisplayName: config.OAuth2Config.FieldMapping.DisplayName,
|
||||
Email: config.OAuth2Config.FieldMapping.Email,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func convertIdentityProviderConfigToStore(config *IdentityProviderConfig) *store.IdentityProviderConfig {
|
||||
return &store.IdentityProviderConfig{
|
||||
OAuth2Config: &store.IdentityProviderOAuth2Config{
|
||||
ClientID: config.OAuth2Config.ClientID,
|
||||
ClientSecret: config.OAuth2Config.ClientSecret,
|
||||
AuthURL: config.OAuth2Config.AuthURL,
|
||||
TokenURL: config.OAuth2Config.TokenURL,
|
||||
UserInfoURL: config.OAuth2Config.UserInfoURL,
|
||||
Scopes: config.OAuth2Config.Scopes,
|
||||
FieldMapping: &store.FieldMapping{
|
||||
Identifier: config.OAuth2Config.FieldMapping.Identifier,
|
||||
DisplayName: config.OAuth2Config.FieldMapping.DisplayName,
|
||||
Email: config.OAuth2Config.FieldMapping.Email,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
156
api/v1/jwt.go
Normal file
156
api/v1/jwt.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/usememos/memos/api/auth"
|
||||
"github.com/usememos/memos/internal/log"
|
||||
"github.com/usememos/memos/internal/util"
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// The key name used to store user id in the context
|
||||
// user id is extracted from the jwt token subject field.
|
||||
userIDContextKey = "user-id"
|
||||
)
|
||||
|
||||
func extractTokenFromHeader(c echo.Context) (string, error) {
|
||||
authHeader := c.Request().Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
authHeaderParts := strings.Fields(authHeader)
|
||||
if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" {
|
||||
return "", errors.New("Authorization header format must be Bearer {token}")
|
||||
}
|
||||
|
||||
return authHeaderParts[1], nil
|
||||
}
|
||||
|
||||
func findAccessToken(c echo.Context) string {
|
||||
// Check the HTTP request header first.
|
||||
accessToken, _ := extractTokenFromHeader(c)
|
||||
if accessToken == "" {
|
||||
// Check the cookie.
|
||||
cookie, _ := c.Cookie(auth.AccessTokenCookieName)
|
||||
if cookie != nil {
|
||||
accessToken = cookie.Value
|
||||
}
|
||||
}
|
||||
return accessToken
|
||||
}
|
||||
|
||||
// JWTMiddleware validates the access token.
|
||||
func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
path := c.Request().URL.Path
|
||||
method := c.Request().Method
|
||||
|
||||
if server.defaultAuthSkipper(c) {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Skip validation for server status endpoints.
|
||||
if util.HasPrefixes(path, "/api/v1/ping", "/api/v1/status") && method == http.MethodGet {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
accessToken := findAccessToken(c)
|
||||
if accessToken == "" {
|
||||
// Allow the user to access the public endpoints.
|
||||
if util.HasPrefixes(path, "/o") {
|
||||
return next(c)
|
||||
}
|
||||
// When the request is not authenticated, we allow the user to access the memo endpoints for those public memos.
|
||||
if util.HasPrefixes(path, "/api/v1/idp", "/api/v1/memo", "/api/v1/user") && path != "/api/v1/user" && method == http.MethodGet {
|
||||
return next(c)
|
||||
}
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing access token")
|
||||
}
|
||||
|
||||
userID, err := getUserIDFromAccessToken(accessToken, secret)
|
||||
if err != nil {
|
||||
err = removeAccessTokenAndCookies(c, server.Store, userID, accessToken)
|
||||
if err != nil {
|
||||
log.Error("fail to remove AccessToken and Cookies", zap.Error(err))
|
||||
}
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid or expired access token")
|
||||
}
|
||||
|
||||
accessTokens, err := server.Store.GetUserAccessTokens(ctx, userID)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get user access tokens.").WithInternal(err)
|
||||
}
|
||||
if !validateAccessToken(accessToken, accessTokens) {
|
||||
err = removeAccessTokenAndCookies(c, server.Store, userID, accessToken)
|
||||
if err != nil {
|
||||
log.Error("fail to remove AccessToken and Cookies", zap.Error(err))
|
||||
}
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid access token.")
|
||||
}
|
||||
|
||||
// Even if there is no error, we still need to make sure the user still exists.
|
||||
user, err := server.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to find user ID: %d", userID)).SetInternal(err)
|
||||
}
|
||||
if user == nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Failed to find user ID: %d", userID))
|
||||
}
|
||||
|
||||
// Stores userID into context.
|
||||
c.Set(userIDContextKey, userID)
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
func getUserIDFromAccessToken(accessToken, secret string) (int32, error) {
|
||||
claims := &auth.ClaimsMessage{}
|
||||
_, err := jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (any, error) {
|
||||
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
||||
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
||||
}
|
||||
if kid, ok := t.Header["kid"].(string); ok {
|
||||
if kid == "v1" {
|
||||
return []byte(secret), nil
|
||||
}
|
||||
}
|
||||
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
|
||||
})
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "Invalid or expired access token")
|
||||
}
|
||||
// We either have a valid access token or we will attempt to generate new access token.
|
||||
userID, err := util.ConvertStringToInt32(claims.Subject)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "Malformed ID in the token")
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func (*APIV1Service) defaultAuthSkipper(c echo.Context) bool {
|
||||
path := c.Path()
|
||||
return util.HasPrefixes(path, "/api/v1/auth")
|
||||
}
|
||||
|
||||
func validateAccessToken(accessTokenString string, userAccessTokens []*storepb.AccessTokensUserSetting_AccessToken) bool {
|
||||
for _, userAccessToken := range userAccessTokens {
|
||||
if accessTokenString == userAccessToken.AccessToken {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
1047
api/v1/memo.go
Normal file
1047
api/v1/memo.go
Normal file
File diff suppressed because it is too large
Load Diff
97
api/v1/memo_organizer.go
Normal file
97
api/v1/memo_organizer.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"github.com/usememos/memos/internal/util"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
type MemoOrganizer struct {
|
||||
MemoID int32 `json:"memoId"`
|
||||
UserID int32 `json:"userId"`
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
|
||||
type UpsertMemoOrganizerRequest struct {
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerMemoOrganizerRoutes(g *echo.Group) {
|
||||
g.POST("/memo/:memoId/organizer", s.CreateMemoOrganizer)
|
||||
}
|
||||
|
||||
// CreateMemoOrganizer godoc
|
||||
//
|
||||
// @Summary Organize memo (pin/unpin)
|
||||
// @Tags memo-organizer
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param memoId path int true "ID of memo to organize"
|
||||
// @Param body body UpsertMemoOrganizerRequest true "Memo organizer object"
|
||||
// @Success 200 {object} store.Memo "Memo information"
|
||||
// @Failure 400 {object} nil "ID is not a number: %s | Malformatted post memo organizer request"
|
||||
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
|
||||
// @Failure 404 {object} nil "Memo not found: %v"
|
||||
// @Failure 500 {object} nil "Failed to find memo | Failed to upsert memo organizer | Failed to find memo by ID: %v | Failed to compose memo response"
|
||||
// @Router /api/v1/memo/{memoId}/organizer [POST]
|
||||
func (s *APIV1Service) CreateMemoOrganizer(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
memoID, err := util.ConvertStringToInt32(c.Param("memoId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
|
||||
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
|
||||
ID: &memoID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err)
|
||||
}
|
||||
if memo == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %v", memoID))
|
||||
}
|
||||
if memo.CreatorID != userID {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
request := &UpsertMemoOrganizerRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo organizer request").SetInternal(err)
|
||||
}
|
||||
|
||||
upsert := &store.MemoOrganizer{
|
||||
MemoID: memoID,
|
||||
UserID: userID,
|
||||
Pinned: request.Pinned,
|
||||
}
|
||||
_, err = s.Store.UpsertMemoOrganizer(ctx, upsert)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo organizer").SetInternal(err)
|
||||
}
|
||||
|
||||
memo, err = s.Store.GetMemo(ctx, &store.FindMemo{
|
||||
ID: &memoID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find memo by ID: %v", memoID)).SetInternal(err)
|
||||
}
|
||||
if memo == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %v", memoID))
|
||||
}
|
||||
|
||||
memoResponse, err := s.convertMemoFromStore(ctx, memo)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, memoResponse)
|
||||
}
|
||||
156
api/v1/memo_relation.go
Normal file
156
api/v1/memo_relation.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"github.com/usememos/memos/internal/util"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
type MemoRelationType string
|
||||
|
||||
const (
|
||||
MemoRelationReference MemoRelationType = "REFERENCE"
|
||||
MemoRelationComment MemoRelationType = "COMMENT"
|
||||
)
|
||||
|
||||
func (t MemoRelationType) String() string {
|
||||
return string(t)
|
||||
}
|
||||
|
||||
type MemoRelation struct {
|
||||
MemoID int32 `json:"memoId"`
|
||||
RelatedMemoID int32 `json:"relatedMemoId"`
|
||||
Type MemoRelationType `json:"type"`
|
||||
}
|
||||
|
||||
type UpsertMemoRelationRequest struct {
|
||||
RelatedMemoID int32 `json:"relatedMemoId"`
|
||||
Type MemoRelationType `json:"type"`
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerMemoRelationRoutes(g *echo.Group) {
|
||||
g.GET("/memo/:memoId/relation", s.GetMemoRelationList)
|
||||
g.POST("/memo/:memoId/relation", s.CreateMemoRelation)
|
||||
g.DELETE("/memo/:memoId/relation/:relatedMemoId/type/:relationType", s.DeleteMemoRelation)
|
||||
}
|
||||
|
||||
// GetMemoRelationList godoc
|
||||
//
|
||||
// @Summary Get a list of Memo Relations
|
||||
// @Tags memo-relation
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param memoId path int true "ID of memo to find relations"
|
||||
// @Success 200 {object} []store.MemoRelation "Memo relation information list"
|
||||
// @Failure 400 {object} nil "ID is not a number: %s"
|
||||
// @Failure 500 {object} nil "Failed to list memo relations"
|
||||
// @Router /api/v1/memo/{memoId}/relation [GET]
|
||||
func (s *APIV1Service) GetMemoRelationList(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
memoID, err := util.ConvertStringToInt32(c.Param("memoId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
memoRelationList, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{
|
||||
MemoID: &memoID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to list memo relations").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, memoRelationList)
|
||||
}
|
||||
|
||||
// CreateMemoRelation godoc
|
||||
//
|
||||
// @Summary Create Memo Relation
|
||||
// @Description Create a relation between two memos
|
||||
// @Tags memo-relation
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param memoId path int true "ID of memo to relate"
|
||||
// @Param body body UpsertMemoRelationRequest true "Memo relation object"
|
||||
// @Success 200 {object} store.MemoRelation "Memo relation information"
|
||||
// @Failure 400 {object} nil "ID is not a number: %s | Malformatted post memo relation request"
|
||||
// @Failure 500 {object} nil "Failed to upsert memo relation"
|
||||
// @Router /api/v1/memo/{memoId}/relation [POST]
|
||||
//
|
||||
// NOTES:
|
||||
// - Currently not secured
|
||||
// - It's possible to create relations to memos that doesn't exist, which will trigger 404 errors when the frontend tries to load them.
|
||||
// - It's possible to create multiple relations, though the interface only shows first.
|
||||
func (s *APIV1Service) CreateMemoRelation(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
memoID, err := util.ConvertStringToInt32(c.Param("memoId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
request := &UpsertMemoRelationRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo relation request").SetInternal(err)
|
||||
}
|
||||
|
||||
memoRelation, err := s.Store.UpsertMemoRelation(ctx, &store.MemoRelation{
|
||||
MemoID: memoID,
|
||||
RelatedMemoID: request.RelatedMemoID,
|
||||
Type: store.MemoRelationType(request.Type),
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo relation").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, memoRelation)
|
||||
}
|
||||
|
||||
// DeleteMemoRelation godoc
|
||||
//
|
||||
// @Summary Delete a Memo Relation
|
||||
// @Description Removes a relation between two memos
|
||||
// @Tags memo-relation
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param memoId path int true "ID of memo to find relations"
|
||||
// @Param relatedMemoId path int true "ID of memo to remove relation to"
|
||||
// @Param relationType path MemoRelationType true "Type of relation to remove"
|
||||
// @Success 200 {boolean} true "Memo relation deleted"
|
||||
// @Failure 400 {object} nil "Memo ID is not a number: %s | Related memo ID is not a number: %s"
|
||||
// @Failure 500 {object} nil "Failed to delete memo relation"
|
||||
// @Router /api/v1/memo/{memoId}/relation/{relatedMemoId}/type/{relationType} [DELETE]
|
||||
//
|
||||
// NOTES:
|
||||
// - Currently not secured.
|
||||
// - Will always return true, even if the relation doesn't exist.
|
||||
func (s *APIV1Service) DeleteMemoRelation(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
memoID, err := util.ConvertStringToInt32(c.Param("memoId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Memo ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||
}
|
||||
relatedMemoID, err := util.ConvertStringToInt32(c.Param("relatedMemoId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Related memo ID is not a number: %s", c.Param("relatedMemoId"))).SetInternal(err)
|
||||
}
|
||||
relationType := store.MemoRelationType(c.Param("relationType"))
|
||||
|
||||
if err := s.Store.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{
|
||||
MemoID: &memoID,
|
||||
RelatedMemoID: &relatedMemoID,
|
||||
Type: &relationType,
|
||||
}); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete memo relation").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, true)
|
||||
}
|
||||
|
||||
func convertMemoRelationFromStore(memoRelation *store.MemoRelation) *MemoRelation {
|
||||
return &MemoRelation{
|
||||
MemoID: memoRelation.MemoID,
|
||||
RelatedMemoID: memoRelation.RelatedMemoID,
|
||||
Type: MemoRelationType(memoRelation.Type),
|
||||
}
|
||||
}
|
||||
487
api/v1/resource.go
Normal file
487
api/v1/resource.go
Normal file
@@ -0,0 +1,487 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/usememos/memos/internal/log"
|
||||
"github.com/usememos/memos/internal/util"
|
||||
"github.com/usememos/memos/plugin/storage/s3"
|
||||
"github.com/usememos/memos/server/service/metric"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
type Resource struct {
|
||||
ID int32 `json:"id"`
|
||||
|
||||
// Standard fields
|
||||
CreatorID int32 `json:"creatorId"`
|
||||
CreatedTs int64 `json:"createdTs"`
|
||||
UpdatedTs int64 `json:"updatedTs"`
|
||||
|
||||
// Domain specific fields
|
||||
Filename string `json:"filename"`
|
||||
Blob []byte `json:"-"`
|
||||
InternalPath string `json:"-"`
|
||||
ExternalLink string `json:"externalLink"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
type CreateResourceRequest struct {
|
||||
Filename string `json:"filename"`
|
||||
ExternalLink string `json:"externalLink"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type FindResourceRequest struct {
|
||||
ID *int32 `json:"id"`
|
||||
CreatorID *int32 `json:"creatorId"`
|
||||
Filename *string `json:"filename"`
|
||||
}
|
||||
|
||||
type UpdateResourceRequest struct {
|
||||
Filename *string `json:"filename"`
|
||||
}
|
||||
|
||||
const (
|
||||
// The upload memory buffer is 32 MiB.
|
||||
// It should be kept low, so RAM usage doesn't get out of control.
|
||||
// This is unrelated to maximum upload size limit, which is now set through system setting.
|
||||
maxUploadBufferSizeBytes = 32 << 20
|
||||
MebiByte = 1024 * 1024
|
||||
)
|
||||
|
||||
var fileKeyPattern = regexp.MustCompile(`\{[a-z]{1,9}\}`)
|
||||
|
||||
func (s *APIV1Service) registerResourceRoutes(g *echo.Group) {
|
||||
g.GET("/resource", s.GetResourceList)
|
||||
g.POST("/resource", s.CreateResource)
|
||||
g.POST("/resource/blob", s.UploadResource)
|
||||
g.PATCH("/resource/:resourceId", s.UpdateResource)
|
||||
g.DELETE("/resource/:resourceId", s.DeleteResource)
|
||||
}
|
||||
|
||||
// GetResourceList godoc
|
||||
//
|
||||
// @Summary Get a list of resources
|
||||
// @Tags resource
|
||||
// @Produce json
|
||||
// @Param limit query int false "Limit"
|
||||
// @Param offset query int false "Offset"
|
||||
// @Success 200 {object} []store.Resource "Resource list"
|
||||
// @Failure 401 {object} nil "Missing user in session"
|
||||
// @Failure 500 {object} nil "Failed to fetch resource list"
|
||||
// @Router /api/v1/resource [GET]
|
||||
func (s *APIV1Service) GetResourceList(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
find := &store.FindResource{
|
||||
CreatorID: &userID,
|
||||
}
|
||||
if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
|
||||
find.Limit = &limit
|
||||
}
|
||||
if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil {
|
||||
find.Offset = &offset
|
||||
}
|
||||
|
||||
list, err := s.Store.ListResources(ctx, find)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
|
||||
}
|
||||
resourceMessageList := []*Resource{}
|
||||
for _, resource := range list {
|
||||
resourceMessageList = append(resourceMessageList, convertResourceFromStore(resource))
|
||||
}
|
||||
return c.JSON(http.StatusOK, resourceMessageList)
|
||||
}
|
||||
|
||||
// CreateResource godoc
|
||||
//
|
||||
// @Summary Create resource
|
||||
// @Tags resource
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body CreateResourceRequest true "Request object."
|
||||
// @Success 200 {object} store.Resource "Created resource"
|
||||
// @Failure 400 {object} nil "Malformatted post resource request | Invalid external link | Invalid external link scheme | Failed to request %s | Failed to read %s | Failed to read mime from %s"
|
||||
// @Failure 401 {object} nil "Missing user in session"
|
||||
// @Failure 500 {object} nil "Failed to save resource | Failed to create resource | Failed to create activity"
|
||||
// @Router /api/v1/resource [POST]
|
||||
func (s *APIV1Service) CreateResource(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
|
||||
request := &CreateResourceRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post resource request").SetInternal(err)
|
||||
}
|
||||
|
||||
create := &store.Resource{
|
||||
CreatorID: userID,
|
||||
Filename: request.Filename,
|
||||
ExternalLink: request.ExternalLink,
|
||||
Type: request.Type,
|
||||
}
|
||||
if request.ExternalLink != "" {
|
||||
// Only allow those external links scheme with http/https
|
||||
linkURL, err := url.Parse(request.ExternalLink)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid external link").SetInternal(err)
|
||||
}
|
||||
if linkURL.Scheme != "http" && linkURL.Scheme != "https" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid external link scheme")
|
||||
}
|
||||
}
|
||||
|
||||
resource, err := s.Store.CreateResource(ctx, create)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err)
|
||||
}
|
||||
metric.Enqueue("resource create")
|
||||
return c.JSON(http.StatusOK, convertResourceFromStore(resource))
|
||||
}
|
||||
|
||||
// UploadResource godoc
|
||||
//
|
||||
// @Summary Upload resource
|
||||
// @Tags resource
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param file formData file true "File to upload"
|
||||
// @Success 200 {object} store.Resource "Created resource"
|
||||
// @Failure 400 {object} nil "Upload file not found | File size exceeds allowed limit of %d MiB | Failed to parse upload data"
|
||||
// @Failure 401 {object} nil "Missing user in session"
|
||||
// @Failure 500 {object} nil "Failed to get uploading file | Failed to open file | Failed to save resource | Failed to create resource | Failed to create activity"
|
||||
// @Router /api/v1/resource/blob [POST]
|
||||
func (s *APIV1Service) UploadResource(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
|
||||
// This is the backend default max upload size limit.
|
||||
maxUploadSetting := s.Store.GetSystemSettingValueWithDefault(ctx, SystemSettingMaxUploadSizeMiBName.String(), "32")
|
||||
var settingMaxUploadSizeBytes int
|
||||
if settingMaxUploadSizeMiB, err := strconv.Atoi(maxUploadSetting); err == nil {
|
||||
settingMaxUploadSizeBytes = settingMaxUploadSizeMiB * MebiByte
|
||||
} else {
|
||||
log.Warn("Failed to parse max upload size", zap.Error(err))
|
||||
settingMaxUploadSizeBytes = 0
|
||||
}
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get uploading file").SetInternal(err)
|
||||
}
|
||||
if file == nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Upload file not found").SetInternal(err)
|
||||
}
|
||||
|
||||
if file.Size > int64(settingMaxUploadSizeBytes) {
|
||||
message := fmt.Sprintf("File size exceeds allowed limit of %d MiB", settingMaxUploadSizeBytes/MebiByte)
|
||||
return echo.NewHTTPError(http.StatusBadRequest, message).SetInternal(err)
|
||||
}
|
||||
if err := c.Request().ParseMultipartForm(maxUploadBufferSizeBytes); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Failed to parse upload data").SetInternal(err)
|
||||
}
|
||||
|
||||
sourceFile, err := file.Open()
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to open file").SetInternal(err)
|
||||
}
|
||||
defer sourceFile.Close()
|
||||
|
||||
create := &store.Resource{
|
||||
CreatorID: userID,
|
||||
Filename: file.Filename,
|
||||
Type: file.Header.Get("Content-Type"),
|
||||
Size: file.Size,
|
||||
}
|
||||
err = SaveResourceBlob(ctx, s.Store, create, sourceFile)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to save resource").SetInternal(err)
|
||||
}
|
||||
|
||||
resource, err := s.Store.CreateResource(ctx, create)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, convertResourceFromStore(resource))
|
||||
}
|
||||
|
||||
// DeleteResource godoc
|
||||
//
|
||||
// @Summary Delete a resource
|
||||
// @Tags resource
|
||||
// @Produce json
|
||||
// @Param resourceId path int true "Resource ID"
|
||||
// @Success 200 {boolean} true "Resource deleted"
|
||||
// @Failure 400 {object} nil "ID is not a number: %s"
|
||||
// @Failure 401 {object} nil "Missing user in session"
|
||||
// @Failure 404 {object} nil "Resource not found: %d"
|
||||
// @Failure 500 {object} nil "Failed to find resource | Failed to delete resource"
|
||||
// @Router /api/v1/resource/{resourceId} [DELETE]
|
||||
func (s *APIV1Service) DeleteResource(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
|
||||
resourceID, err := util.ConvertStringToInt32(c.Param("resourceId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
resource, err := s.Store.GetResource(ctx, &store.FindResource{
|
||||
ID: &resourceID,
|
||||
CreatorID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find resource").SetInternal(err)
|
||||
}
|
||||
if resource == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource not found: %d", resourceID))
|
||||
}
|
||||
|
||||
if err := s.Store.DeleteResource(ctx, &store.DeleteResource{
|
||||
ID: resourceID,
|
||||
}); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete resource").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, true)
|
||||
}
|
||||
|
||||
// UpdateResource godoc
|
||||
//
|
||||
// @Summary Update a resource
|
||||
// @Tags resource
|
||||
// @Produce json
|
||||
// @Param resourceId path int true "Resource ID"
|
||||
// @Param patch body UpdateResourceRequest true "Patch resource request"
|
||||
// @Success 200 {object} store.Resource "Updated resource"
|
||||
// @Failure 400 {object} nil "ID is not a number: %s | Malformatted patch resource request"
|
||||
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
|
||||
// @Failure 404 {object} nil "Resource not found: %d"
|
||||
// @Failure 500 {object} nil "Failed to find resource | Failed to patch resource"
|
||||
// @Router /api/v1/resource/{resourceId} [PATCH]
|
||||
func (s *APIV1Service) UpdateResource(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
|
||||
resourceID, err := util.ConvertStringToInt32(c.Param("resourceId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
resource, err := s.Store.GetResource(ctx, &store.FindResource{
|
||||
ID: &resourceID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find resource").SetInternal(err)
|
||||
}
|
||||
if resource == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource not found: %d", resourceID))
|
||||
}
|
||||
if resource.CreatorID != userID {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
request := &UpdateResourceRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch resource request").SetInternal(err)
|
||||
}
|
||||
|
||||
currentTs := time.Now().Unix()
|
||||
update := &store.UpdateResource{
|
||||
ID: resourceID,
|
||||
UpdatedTs: ¤tTs,
|
||||
}
|
||||
if request.Filename != nil && *request.Filename != "" {
|
||||
update.Filename = request.Filename
|
||||
}
|
||||
|
||||
resource, err = s.Store.UpdateResource(ctx, update)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch resource").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, convertResourceFromStore(resource))
|
||||
}
|
||||
|
||||
func replacePathTemplate(path, filename string) string {
|
||||
t := time.Now()
|
||||
path = fileKeyPattern.ReplaceAllStringFunc(path, func(s string) string {
|
||||
switch s {
|
||||
case "{filename}":
|
||||
return filename
|
||||
case "{timestamp}":
|
||||
return fmt.Sprintf("%d", t.Unix())
|
||||
case "{year}":
|
||||
return fmt.Sprintf("%d", t.Year())
|
||||
case "{month}":
|
||||
return fmt.Sprintf("%02d", t.Month())
|
||||
case "{day}":
|
||||
return fmt.Sprintf("%02d", t.Day())
|
||||
case "{hour}":
|
||||
return fmt.Sprintf("%02d", t.Hour())
|
||||
case "{minute}":
|
||||
return fmt.Sprintf("%02d", t.Minute())
|
||||
case "{second}":
|
||||
return fmt.Sprintf("%02d", t.Second())
|
||||
}
|
||||
return s
|
||||
})
|
||||
return path
|
||||
}
|
||||
|
||||
func convertResourceFromStore(resource *store.Resource) *Resource {
|
||||
return &Resource{
|
||||
ID: resource.ID,
|
||||
CreatorID: resource.CreatorID,
|
||||
CreatedTs: resource.CreatedTs,
|
||||
UpdatedTs: resource.UpdatedTs,
|
||||
Filename: resource.Filename,
|
||||
Blob: resource.Blob,
|
||||
InternalPath: resource.InternalPath,
|
||||
ExternalLink: resource.ExternalLink,
|
||||
Type: resource.Type,
|
||||
Size: resource.Size,
|
||||
}
|
||||
}
|
||||
|
||||
// SaveResourceBlob save the blob of resource based on the storage config
|
||||
//
|
||||
// Depend on the storage config, some fields of *store.ResourceCreate will be changed:
|
||||
// 1. *DatabaseStorage*: `create.Blob`.
|
||||
// 2. *LocalStorage*: `create.InternalPath`.
|
||||
// 3. Others( external service): `create.ExternalLink`.
|
||||
func SaveResourceBlob(ctx context.Context, s *store.Store, create *store.Resource, r io.Reader) error {
|
||||
systemSettingStorageServiceID, err := s.GetSystemSetting(ctx, &store.FindSystemSetting{Name: SystemSettingStorageServiceIDName.String()})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed to find SystemSettingStorageServiceIDName")
|
||||
}
|
||||
|
||||
storageServiceID := DefaultStorage
|
||||
if systemSettingStorageServiceID != nil {
|
||||
err = json.Unmarshal([]byte(systemSettingStorageServiceID.Value), &storageServiceID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed to unmarshal storage service id")
|
||||
}
|
||||
}
|
||||
|
||||
// `DatabaseStorage` means store blob into database
|
||||
if storageServiceID == DatabaseStorage {
|
||||
fileBytes, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed to read file")
|
||||
}
|
||||
create.Blob = fileBytes
|
||||
return nil
|
||||
} else if storageServiceID == LocalStorage {
|
||||
// `LocalStorage` means save blob into local disk
|
||||
systemSettingLocalStoragePath, err := s.GetSystemSetting(ctx, &store.FindSystemSetting{Name: SystemSettingLocalStoragePathName.String()})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed to find SystemSettingLocalStoragePathName")
|
||||
}
|
||||
localStoragePath := "assets/{timestamp}_{filename}"
|
||||
if systemSettingLocalStoragePath != nil && systemSettingLocalStoragePath.Value != "" {
|
||||
err = json.Unmarshal([]byte(systemSettingLocalStoragePath.Value), &localStoragePath)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed to unmarshal SystemSettingLocalStoragePathName")
|
||||
}
|
||||
}
|
||||
filePath := filepath.FromSlash(localStoragePath)
|
||||
if !strings.Contains(filePath, "{filename}") {
|
||||
filePath = filepath.Join(filePath, "{filename}")
|
||||
}
|
||||
filePath = filepath.Join(s.Profile.Data, replacePathTemplate(filePath, create.Filename))
|
||||
|
||||
dir := filepath.Dir(filePath)
|
||||
if err = os.MkdirAll(dir, os.ModePerm); err != nil {
|
||||
return errors.Wrap(err, "Failed to create directory")
|
||||
}
|
||||
dst, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed to create file")
|
||||
}
|
||||
defer dst.Close()
|
||||
_, err = io.Copy(dst, r)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed to copy file")
|
||||
}
|
||||
|
||||
create.InternalPath = filePath
|
||||
return nil
|
||||
}
|
||||
|
||||
// Others: store blob into external service, such as S3
|
||||
storage, err := s.GetStorage(ctx, &store.FindStorage{ID: &storageServiceID})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed to find StorageServiceID")
|
||||
}
|
||||
if storage == nil {
|
||||
return errors.Errorf("Storage %d not found", storageServiceID)
|
||||
}
|
||||
storageMessage, err := ConvertStorageFromStore(storage)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed to ConvertStorageFromStore")
|
||||
}
|
||||
|
||||
if storageMessage.Type != StorageS3 {
|
||||
return errors.Errorf("Unsupported storage type: %s", storageMessage.Type)
|
||||
}
|
||||
|
||||
s3Config := storageMessage.Config.S3Config
|
||||
s3Client, err := s3.NewClient(ctx, &s3.Config{
|
||||
AccessKey: s3Config.AccessKey,
|
||||
SecretKey: s3Config.SecretKey,
|
||||
EndPoint: s3Config.EndPoint,
|
||||
Region: s3Config.Region,
|
||||
Bucket: s3Config.Bucket,
|
||||
URLPrefix: s3Config.URLPrefix,
|
||||
URLSuffix: s3Config.URLSuffix,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed to create s3 client")
|
||||
}
|
||||
|
||||
filePath := s3Config.Path
|
||||
if !strings.Contains(filePath, "{filename}") {
|
||||
filePath = filepath.Join(filePath, "{filename}")
|
||||
}
|
||||
filePath = replacePathTemplate(filePath, create.Filename)
|
||||
|
||||
link, err := s3Client.UploadFile(ctx, filePath, create.Type, r)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed to upload via s3 client")
|
||||
}
|
||||
|
||||
create.ExternalLink = link
|
||||
return nil
|
||||
}
|
||||
204
api/v1/rss.go
Normal file
204
api/v1/rss.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/feeds"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/yuin/goldmark"
|
||||
|
||||
"github.com/usememos/memos/internal/util"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
const maxRSSItemCount = 100
|
||||
const maxRSSItemTitleLength = 100
|
||||
|
||||
func (s *APIV1Service) registerRSSRoutes(g *echo.Group) {
|
||||
g.GET("/explore/rss.xml", s.GetExploreRSS)
|
||||
g.GET("/u/:id/rss.xml", s.GetUserRSS)
|
||||
}
|
||||
|
||||
// GetExploreRSS godoc
|
||||
//
|
||||
// @Summary Get RSS
|
||||
// @Tags rss
|
||||
// @Produce xml
|
||||
// @Success 200 {object} nil "RSS"
|
||||
// @Failure 500 {object} nil "Failed to get system customized profile | Failed to find memo list | Failed to generate rss"
|
||||
// @Router /explore/rss.xml [GET]
|
||||
func (s *APIV1Service) GetExploreRSS(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
systemCustomizedProfile, err := s.getSystemCustomizedProfile(ctx)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get system customized profile").SetInternal(err)
|
||||
}
|
||||
|
||||
normalStatus := store.Normal
|
||||
memoFind := store.FindMemo{
|
||||
RowStatus: &normalStatus,
|
||||
VisibilityList: []store.Visibility{store.Public},
|
||||
}
|
||||
memoList, err := s.Store.ListMemos(ctx, &memoFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
|
||||
}
|
||||
|
||||
baseURL := c.Scheme() + "://" + c.Request().Host
|
||||
rss, err := s.generateRSSFromMemoList(ctx, memoList, baseURL, systemCustomizedProfile)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate rss").SetInternal(err)
|
||||
}
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationXMLCharsetUTF8)
|
||||
return c.String(http.StatusOK, rss)
|
||||
}
|
||||
|
||||
// GetUserRSS godoc
|
||||
//
|
||||
// @Summary Get RSS for a user
|
||||
// @Tags rss
|
||||
// @Produce xml
|
||||
// @Param id path int true "User ID"
|
||||
// @Success 200 {object} nil "RSS"
|
||||
// @Failure 400 {object} nil "User id is not a number"
|
||||
// @Failure 500 {object} nil "Failed to get system customized profile | Failed to find memo list | Failed to generate rss"
|
||||
// @Router /u/{id}/rss.xml [GET]
|
||||
func (s *APIV1Service) GetUserRSS(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
id, err := util.ConvertStringToInt32(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "User id is not a number").SetInternal(err)
|
||||
}
|
||||
|
||||
systemCustomizedProfile, err := s.getSystemCustomizedProfile(ctx)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get system customized profile").SetInternal(err)
|
||||
}
|
||||
|
||||
normalStatus := store.Normal
|
||||
memoFind := store.FindMemo{
|
||||
CreatorID: &id,
|
||||
RowStatus: &normalStatus,
|
||||
VisibilityList: []store.Visibility{store.Public},
|
||||
}
|
||||
memoList, err := s.Store.ListMemos(ctx, &memoFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
|
||||
}
|
||||
|
||||
baseURL := c.Scheme() + "://" + c.Request().Host
|
||||
rss, err := s.generateRSSFromMemoList(ctx, memoList, baseURL, systemCustomizedProfile)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate rss").SetInternal(err)
|
||||
}
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationXMLCharsetUTF8)
|
||||
return c.String(http.StatusOK, rss)
|
||||
}
|
||||
|
||||
func (s *APIV1Service) generateRSSFromMemoList(ctx context.Context, memoList []*store.Memo, baseURL string, profile *CustomizedProfile) (string, error) {
|
||||
feed := &feeds.Feed{
|
||||
Title: profile.Name,
|
||||
Link: &feeds.Link{Href: baseURL},
|
||||
Description: profile.Description,
|
||||
Created: time.Now(),
|
||||
}
|
||||
|
||||
var itemCountLimit = util.Min(len(memoList), maxRSSItemCount)
|
||||
feed.Items = make([]*feeds.Item, itemCountLimit)
|
||||
for i := 0; i < itemCountLimit; i++ {
|
||||
memoMessage, err := s.convertMemoFromStore(ctx, memoList[i])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
feed.Items[i] = &feeds.Item{
|
||||
Title: getRSSItemTitle(memoMessage.Content),
|
||||
Link: &feeds.Link{Href: baseURL + "/m/" + fmt.Sprintf("%d", memoMessage.ID)},
|
||||
Description: getRSSItemDescription(memoMessage.Content),
|
||||
Created: time.Unix(memoMessage.CreatedTs, 0),
|
||||
Enclosure: &feeds.Enclosure{Url: baseURL + "/m/" + fmt.Sprintf("%d", memoMessage.ID) + "/image"},
|
||||
}
|
||||
if len(memoMessage.ResourceList) > 0 {
|
||||
resource := memoMessage.ResourceList[0]
|
||||
enclosure := feeds.Enclosure{}
|
||||
if resource.ExternalLink != "" {
|
||||
enclosure.Url = resource.ExternalLink
|
||||
} else {
|
||||
enclosure.Url = baseURL + "/o/r/" + fmt.Sprintf("%d", resource.ID)
|
||||
}
|
||||
enclosure.Length = strconv.Itoa(int(resource.Size))
|
||||
enclosure.Type = resource.Type
|
||||
feed.Items[i].Enclosure = &enclosure
|
||||
}
|
||||
}
|
||||
|
||||
rss, err := feed.ToRss()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return rss, nil
|
||||
}
|
||||
|
||||
func (s *APIV1Service) getSystemCustomizedProfile(ctx context.Context) (*CustomizedProfile, error) {
|
||||
systemSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
|
||||
Name: SystemSettingCustomizedProfileName.String(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
customizedProfile := &CustomizedProfile{
|
||||
Name: "memos",
|
||||
LogoURL: "",
|
||||
Description: "",
|
||||
Locale: "en",
|
||||
Appearance: "system",
|
||||
ExternalURL: "",
|
||||
}
|
||||
if systemSetting != nil {
|
||||
if err := json.Unmarshal([]byte(systemSetting.Value), customizedProfile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return customizedProfile, nil
|
||||
}
|
||||
|
||||
func getRSSItemTitle(content string) string {
|
||||
var title string
|
||||
if isTitleDefined(content) {
|
||||
title = strings.Split(content, "\n")[0][2:]
|
||||
} else {
|
||||
title = strings.Split(content, "\n")[0]
|
||||
var titleLengthLimit = util.Min(len(title), maxRSSItemTitleLength)
|
||||
if titleLengthLimit < len(title) {
|
||||
title = title[:titleLengthLimit] + "..."
|
||||
}
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
func getRSSItemDescription(content string) string {
|
||||
var description string
|
||||
if isTitleDefined(content) {
|
||||
var firstLineEnd = strings.Index(content, "\n")
|
||||
description = strings.Trim(content[firstLineEnd+1:], " ")
|
||||
} else {
|
||||
description = content
|
||||
}
|
||||
|
||||
// TODO: use our `./plugin/gomark` parser to handle markdown-like content.
|
||||
var buf bytes.Buffer
|
||||
if err := goldmark.Convert([]byte(description), &buf); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func isTitleDefined(content string) bool {
|
||||
return strings.HasPrefix(content, "# ")
|
||||
}
|
||||
315
api/v1/storage.go
Normal file
315
api/v1/storage.go
Normal file
@@ -0,0 +1,315 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"github.com/usememos/memos/internal/util"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// LocalStorage means the storage service is local file system.
|
||||
LocalStorage int32 = -1
|
||||
// DatabaseStorage means the storage service is database.
|
||||
DatabaseStorage int32 = 0
|
||||
// Default storage service is database.
|
||||
DefaultStorage int32 = DatabaseStorage
|
||||
)
|
||||
|
||||
type StorageType string
|
||||
|
||||
const (
|
||||
StorageS3 StorageType = "S3"
|
||||
)
|
||||
|
||||
func (t StorageType) String() string {
|
||||
return string(t)
|
||||
}
|
||||
|
||||
type StorageConfig struct {
|
||||
S3Config *StorageS3Config `json:"s3Config"`
|
||||
}
|
||||
|
||||
type StorageS3Config struct {
|
||||
EndPoint string `json:"endPoint"`
|
||||
Path string `json:"path"`
|
||||
Region string `json:"region"`
|
||||
AccessKey string `json:"accessKey"`
|
||||
SecretKey string `json:"secretKey"`
|
||||
Bucket string `json:"bucket"`
|
||||
URLPrefix string `json:"urlPrefix"`
|
||||
URLSuffix string `json:"urlSuffix"`
|
||||
}
|
||||
|
||||
type Storage struct {
|
||||
ID int32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type StorageType `json:"type"`
|
||||
Config *StorageConfig `json:"config"`
|
||||
}
|
||||
|
||||
type CreateStorageRequest struct {
|
||||
Name string `json:"name"`
|
||||
Type StorageType `json:"type"`
|
||||
Config *StorageConfig `json:"config"`
|
||||
}
|
||||
|
||||
type UpdateStorageRequest struct {
|
||||
Type StorageType `json:"type"`
|
||||
Name *string `json:"name"`
|
||||
Config *StorageConfig `json:"config"`
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerStorageRoutes(g *echo.Group) {
|
||||
g.GET("/storage", s.GetStorageList)
|
||||
g.POST("/storage", s.CreateStorage)
|
||||
g.PATCH("/storage/:storageId", s.UpdateStorage)
|
||||
g.DELETE("/storage/:storageId", s.DeleteStorage)
|
||||
}
|
||||
|
||||
// GetStorageList godoc
|
||||
//
|
||||
// @Summary Get a list of storages
|
||||
// @Tags storage
|
||||
// @Produce json
|
||||
// @Success 200 {object} []store.Storage "List of storages"
|
||||
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
|
||||
// @Failure 500 {object} nil "Failed to find user | Failed to convert storage"
|
||||
// @Router /api/v1/storage [GET]
|
||||
func (s *APIV1Service) GetStorageList(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||
}
|
||||
// We should only show storage list to host user.
|
||||
if user == nil || user.Role != store.RoleHost {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
list, err := s.Store.ListStorages(ctx, &store.FindStorage{})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find storage list").SetInternal(err)
|
||||
}
|
||||
|
||||
storageList := []*Storage{}
|
||||
for _, storage := range list {
|
||||
storageMessage, err := ConvertStorageFromStore(storage)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to convert storage").SetInternal(err)
|
||||
}
|
||||
storageList = append(storageList, storageMessage)
|
||||
}
|
||||
return c.JSON(http.StatusOK, storageList)
|
||||
}
|
||||
|
||||
// CreateStorage godoc
|
||||
//
|
||||
// @Summary Create storage
|
||||
// @Tags storage
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body CreateStorageRequest true "Request object."
|
||||
// @Success 200 {object} store.Storage "Created storage"
|
||||
// @Failure 400 {object} nil "Malformatted post storage request"
|
||||
// @Failure 401 {object} nil "Missing user in session"
|
||||
// @Failure 500 {object} nil "Failed to find user | Failed to create storage | Failed to convert storage"
|
||||
// @Router /api/v1/storage [POST]
|
||||
func (s *APIV1Service) CreateStorage(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||
}
|
||||
if user == nil || user.Role != store.RoleHost {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
create := &CreateStorageRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(create); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post storage request").SetInternal(err)
|
||||
}
|
||||
|
||||
configString := ""
|
||||
if create.Type == StorageS3 && create.Config.S3Config != nil {
|
||||
configBytes, err := json.Marshal(create.Config.S3Config)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post storage request").SetInternal(err)
|
||||
}
|
||||
configString = string(configBytes)
|
||||
}
|
||||
|
||||
storage, err := s.Store.CreateStorage(ctx, &store.Storage{
|
||||
Name: create.Name,
|
||||
Type: create.Type.String(),
|
||||
Config: configString,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create storage").SetInternal(err)
|
||||
}
|
||||
storageMessage, err := ConvertStorageFromStore(storage)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to convert storage").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, storageMessage)
|
||||
}
|
||||
|
||||
// DeleteStorage godoc
|
||||
//
|
||||
// @Summary Delete a storage
|
||||
// @Tags storage
|
||||
// @Produce json
|
||||
// @Param storageId path int true "Storage ID"
|
||||
// @Success 200 {boolean} true "Storage deleted"
|
||||
// @Failure 400 {object} nil "ID is not a number: %s | Storage service %d is using"
|
||||
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
|
||||
// @Failure 500 {object} nil "Failed to find user | Failed to find storage | Failed to unmarshal storage service id | Failed to delete storage"
|
||||
// @Router /api/v1/storage/{storageId} [DELETE]
|
||||
//
|
||||
// NOTES:
|
||||
// - error message "Storage service %d is using" probably should be "Storage service %d is in use".
|
||||
func (s *APIV1Service) DeleteStorage(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||
}
|
||||
if user == nil || user.Role != store.RoleHost {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
storageID, err := util.ConvertStringToInt32(c.Param("storageId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("storageId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
systemSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{Name: SystemSettingStorageServiceIDName.String()})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find storage").SetInternal(err)
|
||||
}
|
||||
if systemSetting != nil {
|
||||
storageServiceID := DefaultStorage
|
||||
err = json.Unmarshal([]byte(systemSetting.Value), &storageServiceID)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal storage service id").SetInternal(err)
|
||||
}
|
||||
if storageServiceID == storageID {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Storage service %d is using", storageID))
|
||||
}
|
||||
}
|
||||
|
||||
if err = s.Store.DeleteStorage(ctx, &store.DeleteStorage{ID: storageID}); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete storage").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, true)
|
||||
}
|
||||
|
||||
// UpdateStorage godoc
|
||||
//
|
||||
// @Summary Update a storage
|
||||
// @Tags storage
|
||||
// @Produce json
|
||||
// @Param storageId path int true "Storage ID"
|
||||
// @Param patch body UpdateStorageRequest true "Patch request"
|
||||
// @Success 200 {object} store.Storage "Updated resource"
|
||||
// @Failure 400 {object} nil "ID is not a number: %s | Malformatted patch storage request | Malformatted post storage request"
|
||||
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
|
||||
// @Failure 500 {object} nil "Failed to find user | Failed to patch storage | Failed to convert storage"
|
||||
// @Router /api/v1/storage/{storageId} [PATCH]
|
||||
func (s *APIV1Service) UpdateStorage(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||
}
|
||||
if user == nil || user.Role != store.RoleHost {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
storageID, err := util.ConvertStringToInt32(c.Param("storageId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("storageId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
update := &UpdateStorageRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(update); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch storage request").SetInternal(err)
|
||||
}
|
||||
storageUpdate := &store.UpdateStorage{
|
||||
ID: storageID,
|
||||
}
|
||||
if update.Name != nil {
|
||||
storageUpdate.Name = update.Name
|
||||
}
|
||||
if update.Config != nil {
|
||||
if update.Type == StorageS3 {
|
||||
configBytes, err := json.Marshal(update.Config.S3Config)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post storage request").SetInternal(err)
|
||||
}
|
||||
configString := string(configBytes)
|
||||
storageUpdate.Config = &configString
|
||||
}
|
||||
}
|
||||
|
||||
storage, err := s.Store.UpdateStorage(ctx, storageUpdate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch storage").SetInternal(err)
|
||||
}
|
||||
storageMessage, err := ConvertStorageFromStore(storage)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to convert storage").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, storageMessage)
|
||||
}
|
||||
|
||||
func ConvertStorageFromStore(storage *store.Storage) (*Storage, error) {
|
||||
storageMessage := &Storage{
|
||||
ID: storage.ID,
|
||||
Name: storage.Name,
|
||||
Type: StorageType(storage.Type),
|
||||
Config: &StorageConfig{},
|
||||
}
|
||||
if storageMessage.Type == StorageS3 {
|
||||
s3Config := &StorageS3Config{}
|
||||
if err := json.Unmarshal([]byte(storage.Config), s3Config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
storageMessage.Config = &StorageConfig{
|
||||
S3Config: s3Config,
|
||||
}
|
||||
}
|
||||
return storageMessage, nil
|
||||
}
|
||||
2033
api/v1/swagger.yaml
Normal file
2033
api/v1/swagger.yaml
Normal file
File diff suppressed because it is too large
Load Diff
183
api/v1/system.go
Normal file
183
api/v1/system.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/usememos/memos/internal/log"
|
||||
"github.com/usememos/memos/server/profile"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
type SystemStatus struct {
|
||||
Host *User `json:"host"`
|
||||
Profile profile.Profile `json:"profile"`
|
||||
DBSize int64 `json:"dbSize"`
|
||||
|
||||
// System settings
|
||||
// Allow sign up.
|
||||
AllowSignUp bool `json:"allowSignUp"`
|
||||
// Disable password login.
|
||||
DisablePasswordLogin bool `json:"disablePasswordLogin"`
|
||||
// Disable public memos.
|
||||
DisablePublicMemos bool `json:"disablePublicMemos"`
|
||||
// Max upload size.
|
||||
MaxUploadSizeMiB int `json:"maxUploadSizeMiB"`
|
||||
// Auto Backup Interval.
|
||||
AutoBackupInterval int `json:"autoBackupInterval"`
|
||||
// Additional style.
|
||||
AdditionalStyle string `json:"additionalStyle"`
|
||||
// Additional script.
|
||||
AdditionalScript string `json:"additionalScript"`
|
||||
// Customized server profile, including server name and external url.
|
||||
CustomizedProfile CustomizedProfile `json:"customizedProfile"`
|
||||
// Storage service ID.
|
||||
StorageServiceID int32 `json:"storageServiceId"`
|
||||
// Local storage path.
|
||||
LocalStoragePath string `json:"localStoragePath"`
|
||||
// Memo display with updated timestamp.
|
||||
MemoDisplayWithUpdatedTs bool `json:"memoDisplayWithUpdatedTs"`
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerSystemRoutes(g *echo.Group) {
|
||||
g.GET("/ping", s.PingSystem)
|
||||
g.GET("/status", s.GetSystemStatus)
|
||||
g.POST("/system/vacuum", s.ExecVacuum)
|
||||
}
|
||||
|
||||
// PingSystem godoc
|
||||
//
|
||||
// @Summary Ping the system
|
||||
// @Tags system
|
||||
// @Produce json
|
||||
// @Success 200 {boolean} true "If succeed to ping the system"
|
||||
// @Router /api/v1/ping [GET]
|
||||
func (*APIV1Service) PingSystem(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, true)
|
||||
}
|
||||
|
||||
// GetSystemStatus godoc
|
||||
//
|
||||
// @Summary Get system GetSystemStatus
|
||||
// @Tags system
|
||||
// @Produce json
|
||||
// @Success 200 {object} SystemStatus "System GetSystemStatus"
|
||||
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
|
||||
// @Failure 500 {object} nil "Failed to find host user | Failed to find system setting list | Failed to unmarshal system setting customized profile value"
|
||||
// @Router /api/v1/status [GET]
|
||||
func (s *APIV1Service) GetSystemStatus(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
|
||||
systemStatus := SystemStatus{
|
||||
Profile: profile.Profile{
|
||||
Mode: s.Profile.Mode,
|
||||
Version: s.Profile.Version,
|
||||
},
|
||||
// Allow sign up by default.
|
||||
AllowSignUp: true,
|
||||
MaxUploadSizeMiB: 32,
|
||||
CustomizedProfile: CustomizedProfile{
|
||||
Name: "memos",
|
||||
Locale: "en",
|
||||
Appearance: "system",
|
||||
},
|
||||
StorageServiceID: DefaultStorage,
|
||||
LocalStoragePath: "assets/{timestamp}_{filename}",
|
||||
}
|
||||
|
||||
hostUserType := store.RoleHost
|
||||
hostUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
Role: &hostUserType,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find host user").SetInternal(err)
|
||||
}
|
||||
if hostUser != nil {
|
||||
systemStatus.Host = &User{ID: hostUser.ID}
|
||||
}
|
||||
|
||||
systemSettingList, err := s.Store.ListSystemSettings(ctx, &store.FindSystemSetting{})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting list").SetInternal(err)
|
||||
}
|
||||
for _, systemSetting := range systemSettingList {
|
||||
if systemSetting.Name == SystemSettingServerIDName.String() || systemSetting.Name == SystemSettingSecretSessionName.String() || systemSetting.Name == SystemSettingTelegramBotTokenName.String() {
|
||||
continue
|
||||
}
|
||||
|
||||
var baseValue any
|
||||
err := json.Unmarshal([]byte(systemSetting.Value), &baseValue)
|
||||
if err != nil {
|
||||
log.Warn("Failed to unmarshal system setting value", zap.String("setting name", systemSetting.Name))
|
||||
continue
|
||||
}
|
||||
|
||||
switch systemSetting.Name {
|
||||
case SystemSettingAllowSignUpName.String():
|
||||
systemStatus.AllowSignUp = baseValue.(bool)
|
||||
case SystemSettingDisablePasswordLoginName.String():
|
||||
systemStatus.DisablePasswordLogin = baseValue.(bool)
|
||||
case SystemSettingDisablePublicMemosName.String():
|
||||
systemStatus.DisablePublicMemos = baseValue.(bool)
|
||||
case SystemSettingMaxUploadSizeMiBName.String():
|
||||
systemStatus.MaxUploadSizeMiB = int(baseValue.(float64))
|
||||
case SystemSettingAutoBackupIntervalName.String():
|
||||
systemStatus.AutoBackupInterval = int(baseValue.(float64))
|
||||
case SystemSettingAdditionalStyleName.String():
|
||||
systemStatus.AdditionalStyle = baseValue.(string)
|
||||
case SystemSettingAdditionalScriptName.String():
|
||||
systemStatus.AdditionalScript = baseValue.(string)
|
||||
case SystemSettingCustomizedProfileName.String():
|
||||
customizedProfile := CustomizedProfile{}
|
||||
if err := json.Unmarshal([]byte(systemSetting.Value), &customizedProfile); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting customized profile value").SetInternal(err)
|
||||
}
|
||||
systemStatus.CustomizedProfile = customizedProfile
|
||||
case SystemSettingStorageServiceIDName.String():
|
||||
systemStatus.StorageServiceID = int32(baseValue.(float64))
|
||||
case SystemSettingLocalStoragePathName.String():
|
||||
systemStatus.LocalStoragePath = baseValue.(string)
|
||||
case SystemSettingMemoDisplayWithUpdatedTsName.String():
|
||||
systemStatus.MemoDisplayWithUpdatedTs = baseValue.(bool)
|
||||
default:
|
||||
log.Warn("Unknown system setting name", zap.String("setting name", systemSetting.Name))
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, systemStatus)
|
||||
}
|
||||
|
||||
// ExecVacuum godoc
|
||||
//
|
||||
// @Summary Vacuum the database
|
||||
// @Tags system
|
||||
// @Produce json
|
||||
// @Success 200 {boolean} true "Database vacuumed"
|
||||
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
|
||||
// @Failure 500 {object} nil "Failed to find user | Failed to ExecVacuum database"
|
||||
// @Router /api/v1/system/vacuum [POST]
|
||||
func (s *APIV1Service) ExecVacuum(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||
}
|
||||
if user == nil || user.Role != store.RoleHost {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
if err := s.Store.Vacuum(ctx); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to vacuum database").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, true)
|
||||
}
|
||||
286
api/v1/system_setting.go
Normal file
286
api/v1/system_setting.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
type SystemSettingName string
|
||||
|
||||
const (
|
||||
// SystemSettingServerIDName is the name of server id.
|
||||
SystemSettingServerIDName SystemSettingName = "server-id"
|
||||
// SystemSettingSecretSessionName is the name of secret session.
|
||||
SystemSettingSecretSessionName SystemSettingName = "secret-session"
|
||||
// SystemSettingAllowSignUpName is the name of allow signup setting.
|
||||
SystemSettingAllowSignUpName SystemSettingName = "allow-signup"
|
||||
// SystemSettingDisablePasswordLoginName is the name of disable password login setting.
|
||||
SystemSettingDisablePasswordLoginName SystemSettingName = "disable-password-login"
|
||||
// SystemSettingDisablePublicMemosName is the name of disable public memos setting.
|
||||
SystemSettingDisablePublicMemosName SystemSettingName = "disable-public-memos"
|
||||
// SystemSettingMaxUploadSizeMiBName is the name of max upload size setting.
|
||||
SystemSettingMaxUploadSizeMiBName SystemSettingName = "max-upload-size-mib"
|
||||
// SystemSettingAdditionalStyleName is the name of additional style.
|
||||
SystemSettingAdditionalStyleName SystemSettingName = "additional-style"
|
||||
// SystemSettingAdditionalScriptName is the name of additional script.
|
||||
SystemSettingAdditionalScriptName SystemSettingName = "additional-script"
|
||||
// SystemSettingCustomizedProfileName is the name of customized server profile.
|
||||
SystemSettingCustomizedProfileName SystemSettingName = "customized-profile"
|
||||
// SystemSettingStorageServiceIDName is the name of storage service ID.
|
||||
SystemSettingStorageServiceIDName SystemSettingName = "storage-service-id"
|
||||
// SystemSettingLocalStoragePathName is the name of local storage path.
|
||||
SystemSettingLocalStoragePathName SystemSettingName = "local-storage-path"
|
||||
// SystemSettingTelegramBotTokenName is the name of Telegram Bot Token.
|
||||
SystemSettingTelegramBotTokenName SystemSettingName = "telegram-bot-token"
|
||||
// SystemSettingMemoDisplayWithUpdatedTsName is the name of memo display with updated ts.
|
||||
SystemSettingMemoDisplayWithUpdatedTsName SystemSettingName = "memo-display-with-updated-ts"
|
||||
// SystemSettingAutoBackupIntervalName is the name of auto backup interval as seconds.
|
||||
SystemSettingAutoBackupIntervalName SystemSettingName = "auto-backup-interval"
|
||||
)
|
||||
const systemSettingUnmarshalError = `failed to unmarshal value from system setting "%v"`
|
||||
|
||||
// CustomizedProfile is the struct definition for SystemSettingCustomizedProfileName system setting item.
|
||||
type CustomizedProfile struct {
|
||||
// Name is the server name, default is `memos`
|
||||
Name string `json:"name"`
|
||||
// LogoURL is the url of logo image.
|
||||
LogoURL string `json:"logoUrl"`
|
||||
// Description is the server description.
|
||||
Description string `json:"description"`
|
||||
// Locale is the server default locale.
|
||||
Locale string `json:"locale"`
|
||||
// Appearance is the server default appearance.
|
||||
Appearance string `json:"appearance"`
|
||||
// ExternalURL is the external url of server. e.g. https://usermemos.com
|
||||
ExternalURL string `json:"externalUrl"`
|
||||
}
|
||||
|
||||
func (key SystemSettingName) String() string {
|
||||
return string(key)
|
||||
}
|
||||
|
||||
type SystemSetting struct {
|
||||
Name SystemSettingName `json:"name"`
|
||||
// Value is a JSON string with basic value.
|
||||
Value string `json:"value"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type UpsertSystemSettingRequest struct {
|
||||
Name SystemSettingName `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerSystemSettingRoutes(g *echo.Group) {
|
||||
g.GET("/system/setting", s.GetSystemSettingList)
|
||||
g.POST("/system/setting", s.CreateSystemSetting)
|
||||
}
|
||||
|
||||
// GetSystemSettingList godoc
|
||||
//
|
||||
// @Summary Get a list of system settings
|
||||
// @Tags system-setting
|
||||
// @Produce json
|
||||
// @Success 200 {object} []SystemSetting "System setting list"
|
||||
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
|
||||
// @Failure 500 {object} nil "Failed to find user | Failed to find system setting list"
|
||||
// @Router /api/v1/system/setting [GET]
|
||||
func (s *APIV1Service) GetSystemSettingList(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||
}
|
||||
if user == nil || user.Role != store.RoleHost {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
list, err := s.Store.ListSystemSettings(ctx, &store.FindSystemSetting{})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting list").SetInternal(err)
|
||||
}
|
||||
|
||||
systemSettingList := make([]*SystemSetting, 0, len(list))
|
||||
for _, systemSetting := range list {
|
||||
systemSettingList = append(systemSettingList, convertSystemSettingFromStore(systemSetting))
|
||||
}
|
||||
return c.JSON(http.StatusOK, systemSettingList)
|
||||
}
|
||||
|
||||
// CreateSystemSetting godoc
|
||||
//
|
||||
// @Summary Create system setting
|
||||
// @Tags system-setting
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body UpsertSystemSettingRequest true "Request object."
|
||||
// @Success 200 {object} store.SystemSetting "Created system setting"
|
||||
// @Failure 400 {object} nil "Malformatted post system setting request | invalid system setting"
|
||||
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
|
||||
// @Failure 403 {object} nil "Cannot disable passwords if no SSO identity provider is configured."
|
||||
// @Failure 500 {object} nil "Failed to find user | Failed to upsert system setting"
|
||||
// @Router /api/v1/system/setting [POST]
|
||||
func (s *APIV1Service) CreateSystemSetting(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||
}
|
||||
if user == nil || user.Role != store.RoleHost {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
systemSettingUpsert := &UpsertSystemSettingRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(systemSettingUpsert); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post system setting request").SetInternal(err)
|
||||
}
|
||||
if err := systemSettingUpsert.Validate(); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid system setting").SetInternal(err)
|
||||
}
|
||||
if systemSettingUpsert.Name == SystemSettingDisablePasswordLoginName {
|
||||
var disablePasswordLogin bool
|
||||
if err := json.Unmarshal([]byte(systemSettingUpsert.Value), &disablePasswordLogin); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid system setting").SetInternal(err)
|
||||
}
|
||||
|
||||
identityProviderList, err := s.Store.ListIdentityProviders(ctx, &store.FindIdentityProvider{})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert system setting").SetInternal(err)
|
||||
}
|
||||
if disablePasswordLogin && len(identityProviderList) == 0 {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "Cannot disable passwords if no SSO identity provider is configured.")
|
||||
}
|
||||
}
|
||||
|
||||
systemSetting, err := s.Store.UpsertSystemSetting(ctx, &store.SystemSetting{
|
||||
Name: systemSettingUpsert.Name.String(),
|
||||
Value: systemSettingUpsert.Value,
|
||||
Description: systemSettingUpsert.Description,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert system setting").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, convertSystemSettingFromStore(systemSetting))
|
||||
}
|
||||
|
||||
func (upsert UpsertSystemSettingRequest) Validate() error {
|
||||
switch settingName := upsert.Name; settingName {
|
||||
case SystemSettingServerIDName:
|
||||
return errors.Errorf("updating %v is not allowed", settingName)
|
||||
case SystemSettingAllowSignUpName:
|
||||
var value bool
|
||||
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||
return errors.Errorf(systemSettingUnmarshalError, settingName)
|
||||
}
|
||||
case SystemSettingDisablePasswordLoginName:
|
||||
var value bool
|
||||
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||
return errors.Errorf(systemSettingUnmarshalError, settingName)
|
||||
}
|
||||
case SystemSettingDisablePublicMemosName:
|
||||
var value bool
|
||||
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||
return errors.Errorf(systemSettingUnmarshalError, settingName)
|
||||
}
|
||||
case SystemSettingMaxUploadSizeMiBName:
|
||||
var value int
|
||||
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||
return errors.Errorf(systemSettingUnmarshalError, settingName)
|
||||
}
|
||||
case SystemSettingAdditionalStyleName:
|
||||
var value string
|
||||
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||
return errors.Errorf(systemSettingUnmarshalError, settingName)
|
||||
}
|
||||
case SystemSettingAdditionalScriptName:
|
||||
var value string
|
||||
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||
return errors.Errorf(systemSettingUnmarshalError, settingName)
|
||||
}
|
||||
case SystemSettingCustomizedProfileName:
|
||||
customizedProfile := CustomizedProfile{
|
||||
Name: "memos",
|
||||
LogoURL: "",
|
||||
Description: "",
|
||||
Locale: "en",
|
||||
Appearance: "system",
|
||||
ExternalURL: "",
|
||||
}
|
||||
if err := json.Unmarshal([]byte(upsert.Value), &customizedProfile); err != nil {
|
||||
return errors.Errorf(systemSettingUnmarshalError, settingName)
|
||||
}
|
||||
case SystemSettingStorageServiceIDName:
|
||||
// Note: 0 is the default value(database) for storage service ID.
|
||||
value := 0
|
||||
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||
return errors.Errorf(systemSettingUnmarshalError, settingName)
|
||||
}
|
||||
return nil
|
||||
case SystemSettingLocalStoragePathName:
|
||||
value := ""
|
||||
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||
return errors.Errorf(systemSettingUnmarshalError, settingName)
|
||||
}
|
||||
case SystemSettingAutoBackupIntervalName:
|
||||
var value int
|
||||
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||
return errors.Errorf(systemSettingUnmarshalError, settingName)
|
||||
}
|
||||
if value < 0 {
|
||||
return errors.New("must be positive")
|
||||
}
|
||||
case SystemSettingTelegramBotTokenName:
|
||||
if upsert.Value == "" {
|
||||
return nil
|
||||
}
|
||||
// Bot Token with Reverse Proxy shoule like `http.../bot<token>`
|
||||
if strings.HasPrefix(upsert.Value, "http") {
|
||||
slashIndex := strings.LastIndexAny(upsert.Value, "/")
|
||||
if strings.HasPrefix(upsert.Value[slashIndex:], "/bot") {
|
||||
return nil
|
||||
}
|
||||
return errors.New("token start with `http` must end with `/bot<token>`")
|
||||
}
|
||||
fragments := strings.Split(upsert.Value, ":")
|
||||
if len(fragments) != 2 {
|
||||
return errors.Errorf(systemSettingUnmarshalError, settingName)
|
||||
}
|
||||
case SystemSettingMemoDisplayWithUpdatedTsName:
|
||||
var value bool
|
||||
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||
return errors.Errorf(systemSettingUnmarshalError, settingName)
|
||||
}
|
||||
default:
|
||||
return errors.New("invalid system setting name")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func convertSystemSettingFromStore(systemSetting *store.SystemSetting) *SystemSetting {
|
||||
return &SystemSetting{
|
||||
Name: SystemSettingName(systemSetting.Name),
|
||||
Value: systemSetting.Value,
|
||||
Description: systemSetting.Description,
|
||||
}
|
||||
}
|
||||
218
api/v1/tag.go
Normal file
218
api/v1/tag.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"golang.org/x/exp/slices"
|
||||
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
type Tag struct {
|
||||
Name string
|
||||
CreatorID int32
|
||||
}
|
||||
|
||||
type UpsertTagRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type DeleteTagRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerTagRoutes(g *echo.Group) {
|
||||
g.GET("/tag", s.GetTagList)
|
||||
g.POST("/tag", s.CreateTag)
|
||||
g.GET("/tag/suggestion", s.GetTagSuggestion)
|
||||
g.POST("/tag/delete", s.DeleteTag)
|
||||
}
|
||||
|
||||
// GetTagList godoc
|
||||
//
|
||||
// @Summary Get a list of tags
|
||||
// @Tags tag
|
||||
// @Produce json
|
||||
// @Success 200 {object} []string "Tag list"
|
||||
// @Failure 400 {object} nil "Missing user id to find tag"
|
||||
// @Failure 500 {object} nil "Failed to find tag list"
|
||||
// @Router /api/v1/tag [GET]
|
||||
func (s *APIV1Service) GetTagList(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find tag")
|
||||
}
|
||||
|
||||
list, err := s.Store.ListTags(ctx, &store.FindTag{
|
||||
CreatorID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find tag list").SetInternal(err)
|
||||
}
|
||||
|
||||
tagNameList := []string{}
|
||||
for _, tag := range list {
|
||||
tagNameList = append(tagNameList, tag.Name)
|
||||
}
|
||||
return c.JSON(http.StatusOK, tagNameList)
|
||||
}
|
||||
|
||||
// CreateTag godoc
|
||||
//
|
||||
// @Summary Create a tag
|
||||
// @Tags tag
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body UpsertTagRequest true "Request object."
|
||||
// @Success 200 {object} string "Created tag name"
|
||||
// @Failure 400 {object} nil "Malformatted post tag request | Tag name shouldn't be empty"
|
||||
// @Failure 401 {object} nil "Missing user in session"
|
||||
// @Failure 500 {object} nil "Failed to upsert tag | Failed to create activity"
|
||||
// @Router /api/v1/tag [POST]
|
||||
func (s *APIV1Service) CreateTag(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
|
||||
tagUpsert := &UpsertTagRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(tagUpsert); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post tag request").SetInternal(err)
|
||||
}
|
||||
if tagUpsert.Name == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Tag name shouldn't be empty")
|
||||
}
|
||||
|
||||
tag, err := s.Store.UpsertTag(ctx, &store.Tag{
|
||||
Name: tagUpsert.Name,
|
||||
CreatorID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert tag").SetInternal(err)
|
||||
}
|
||||
tagMessage := convertTagFromStore(tag)
|
||||
return c.JSON(http.StatusOK, tagMessage.Name)
|
||||
}
|
||||
|
||||
// DeleteTag godoc
|
||||
//
|
||||
// @Summary Delete a tag
|
||||
// @Tags tag
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body DeleteTagRequest true "Request object."
|
||||
// @Success 200 {boolean} true "Tag deleted"
|
||||
// @Failure 400 {object} nil "Malformatted post tag request | Tag name shouldn't be empty"
|
||||
// @Failure 401 {object} nil "Missing user in session"
|
||||
// @Failure 500 {object} nil "Failed to delete tag name: %v"
|
||||
// @Router /api/v1/tag/delete [POST]
|
||||
func (s *APIV1Service) DeleteTag(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
|
||||
tagDelete := &DeleteTagRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(tagDelete); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post tag request").SetInternal(err)
|
||||
}
|
||||
if tagDelete.Name == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Tag name shouldn't be empty")
|
||||
}
|
||||
|
||||
err := s.Store.DeleteTag(ctx, &store.DeleteTag{
|
||||
Name: tagDelete.Name,
|
||||
CreatorID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete tag name: %v", tagDelete.Name)).SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, true)
|
||||
}
|
||||
|
||||
// GetTagSuggestion godoc
|
||||
//
|
||||
// @Summary Get a list of tags suggested from other memos contents
|
||||
// @Tags tag
|
||||
// @Produce json
|
||||
// @Success 200 {object} []string "Tag list"
|
||||
// @Failure 400 {object} nil "Missing user session"
|
||||
// @Failure 500 {object} nil "Failed to find memo list | Failed to find tag list"
|
||||
// @Router /api/v1/tag/suggestion [GET]
|
||||
func (s *APIV1Service) GetTagSuggestion(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Missing user session")
|
||||
}
|
||||
normalRowStatus := store.Normal
|
||||
memoFind := &store.FindMemo{
|
||||
CreatorID: &userID,
|
||||
ContentSearch: []string{"#"},
|
||||
RowStatus: &normalRowStatus,
|
||||
}
|
||||
|
||||
memoMessageList, err := s.Store.ListMemos(ctx, memoFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
|
||||
}
|
||||
|
||||
list, err := s.Store.ListTags(ctx, &store.FindTag{
|
||||
CreatorID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find tag list").SetInternal(err)
|
||||
}
|
||||
tagNameList := []string{}
|
||||
for _, tag := range list {
|
||||
tagNameList = append(tagNameList, tag.Name)
|
||||
}
|
||||
|
||||
tagMapSet := make(map[string]bool)
|
||||
for _, memo := range memoMessageList {
|
||||
for _, tag := range findTagListFromMemoContent(memo.Content) {
|
||||
if !slices.Contains(tagNameList, tag) {
|
||||
tagMapSet[tag] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
tagList := []string{}
|
||||
for tag := range tagMapSet {
|
||||
tagList = append(tagList, tag)
|
||||
}
|
||||
sort.Strings(tagList)
|
||||
return c.JSON(http.StatusOK, tagList)
|
||||
}
|
||||
|
||||
func convertTagFromStore(tag *store.Tag) *Tag {
|
||||
return &Tag{
|
||||
Name: tag.Name,
|
||||
CreatorID: tag.CreatorID,
|
||||
}
|
||||
}
|
||||
|
||||
var tagRegexp = regexp.MustCompile(`#([^\s#,]+)`)
|
||||
|
||||
func findTagListFromMemoContent(memoContent string) []string {
|
||||
tagMapSet := make(map[string]bool)
|
||||
matches := tagRegexp.FindAllStringSubmatch(memoContent, -1)
|
||||
for _, v := range matches {
|
||||
tagName := v[1]
|
||||
tagMapSet[tagName] = true
|
||||
}
|
||||
|
||||
tagList := []string{}
|
||||
for tag := range tagMapSet {
|
||||
tagList = append(tagList, tag)
|
||||
}
|
||||
sort.Strings(tagList)
|
||||
return tagList
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package server
|
||||
package v1
|
||||
|
||||
import (
|
||||
"testing"
|
||||
484
api/v1/user.go
Normal file
484
api/v1/user.go
Normal file
@@ -0,0 +1,484 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/usememos/memos/internal/util"
|
||||
"github.com/usememos/memos/server/service/metric"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
// Role is the type of a role.
|
||||
type Role string
|
||||
|
||||
const (
|
||||
// RoleHost is the HOST role.
|
||||
RoleHost Role = "HOST"
|
||||
// RoleAdmin is the ADMIN role.
|
||||
RoleAdmin Role = "ADMIN"
|
||||
// RoleUser is the USER role.
|
||||
RoleUser Role = "USER"
|
||||
)
|
||||
|
||||
func (role Role) String() string {
|
||||
return string(role)
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int32 `json:"id"`
|
||||
|
||||
// Standard fields
|
||||
RowStatus RowStatus `json:"rowStatus"`
|
||||
CreatedTs int64 `json:"createdTs"`
|
||||
UpdatedTs int64 `json:"updatedTs"`
|
||||
|
||||
// Domain specific fields
|
||||
Username string `json:"username"`
|
||||
Role Role `json:"role"`
|
||||
Email string `json:"email"`
|
||||
Nickname string `json:"nickname"`
|
||||
PasswordHash string `json:"-"`
|
||||
AvatarURL string `json:"avatarUrl"`
|
||||
}
|
||||
|
||||
type CreateUserRequest struct {
|
||||
Username string `json:"username"`
|
||||
Role Role `json:"role"`
|
||||
Email string `json:"email"`
|
||||
Nickname string `json:"nickname"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
RowStatus *RowStatus `json:"rowStatus"`
|
||||
Username *string `json:"username"`
|
||||
Email *string `json:"email"`
|
||||
Nickname *string `json:"nickname"`
|
||||
Password *string `json:"password"`
|
||||
AvatarURL *string `json:"avatarUrl"`
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
||||
g.GET("/user", s.GetUserList)
|
||||
g.POST("/user", s.CreateUser)
|
||||
g.GET("/user/me", s.GetCurrentUser)
|
||||
// NOTE: This should be moved to /api/v2/user/:username
|
||||
g.GET("/user/name/:username", s.GetUserByUsername)
|
||||
g.GET("/user/:id", s.GetUserByID)
|
||||
g.PATCH("/user/:id", s.UpdateUser)
|
||||
g.DELETE("/user/:id", s.DeleteUser)
|
||||
}
|
||||
|
||||
// GetUserList godoc
|
||||
//
|
||||
// @Summary Get a list of users
|
||||
// @Tags user
|
||||
// @Produce json
|
||||
// @Success 200 {object} []store.User "User list"
|
||||
// @Failure 500 {object} nil "Failed to fetch user list"
|
||||
// @Router /api/v1/user [GET]
|
||||
func (s *APIV1Service) GetUserList(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
|
||||
}
|
||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by id").SetInternal(err)
|
||||
}
|
||||
if currentUser == nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
|
||||
}
|
||||
if currentUser.Role != store.RoleHost && currentUser.Role != store.RoleAdmin {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized to list users")
|
||||
}
|
||||
|
||||
list, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch user list").SetInternal(err)
|
||||
}
|
||||
|
||||
userMessageList := make([]*User, 0, len(list))
|
||||
for _, user := range list {
|
||||
userMessage := convertUserFromStore(user)
|
||||
// data desensitize
|
||||
userMessage.Email = ""
|
||||
userMessageList = append(userMessageList, userMessage)
|
||||
}
|
||||
return c.JSON(http.StatusOK, userMessageList)
|
||||
}
|
||||
|
||||
// CreateUser godoc
|
||||
//
|
||||
// @Summary Create a user
|
||||
// @Tags user
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body CreateUserRequest true "Request object"
|
||||
// @Success 200 {object} store.User "Created user"
|
||||
// @Failure 400 {object} nil "Malformatted post user request | Invalid user create format"
|
||||
// @Failure 401 {object} nil "Missing auth session | Unauthorized to create user"
|
||||
// @Failure 403 {object} nil "Could not create host user"
|
||||
// @Failure 500 {object} nil "Failed to find user by id | Failed to generate password hash | Failed to create user | Failed to create activity"
|
||||
// @Router /api/v1/user [POST]
|
||||
func (s *APIV1Service) CreateUser(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
|
||||
}
|
||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by id").SetInternal(err)
|
||||
}
|
||||
if currentUser == nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
|
||||
}
|
||||
if currentUser.Role != store.RoleHost {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized to create user")
|
||||
}
|
||||
|
||||
userCreate := &CreateUserRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(userCreate); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user request").SetInternal(err)
|
||||
}
|
||||
if err := userCreate.Validate(); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format").SetInternal(err)
|
||||
}
|
||||
if !usernameMatcher.MatchString(strings.ToLower(userCreate.Username)) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid username %s", userCreate.Username)).SetInternal(err)
|
||||
}
|
||||
// Disallow host user to be created.
|
||||
if userCreate.Role == RoleHost {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "Could not create host user")
|
||||
}
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(userCreate.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
|
||||
}
|
||||
|
||||
user, err := s.Store.CreateUser(ctx, &store.User{
|
||||
Username: userCreate.Username,
|
||||
Role: store.Role(userCreate.Role),
|
||||
Email: userCreate.Email,
|
||||
Nickname: userCreate.Nickname,
|
||||
PasswordHash: string(passwordHash),
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
|
||||
}
|
||||
|
||||
userMessage := convertUserFromStore(user)
|
||||
metric.Enqueue("user create")
|
||||
return c.JSON(http.StatusOK, userMessage)
|
||||
}
|
||||
|
||||
// GetCurrentUser godoc
|
||||
//
|
||||
// @Summary Get current user
|
||||
// @Tags user
|
||||
// @Produce json
|
||||
// @Success 200 {object} store.User "Current user"
|
||||
// @Failure 401 {object} nil "Missing auth session"
|
||||
// @Failure 500 {object} nil "Failed to find user | Failed to find userSettingList"
|
||||
// @Router /api/v1/user/me [GET]
|
||||
func (s *APIV1Service) GetCurrentUser(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
|
||||
}
|
||||
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{ID: &userID})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||
}
|
||||
if user == nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
|
||||
}
|
||||
|
||||
userMessage := convertUserFromStore(user)
|
||||
return c.JSON(http.StatusOK, userMessage)
|
||||
}
|
||||
|
||||
// GetUserByUsername godoc
|
||||
//
|
||||
// @Summary Get user by username
|
||||
// @Tags user
|
||||
// @Produce json
|
||||
// @Param username path string true "Username"
|
||||
// @Success 200 {object} store.User "Requested user"
|
||||
// @Failure 404 {object} nil "User not found"
|
||||
// @Failure 500 {object} nil "Failed to find user"
|
||||
// @Router /api/v1/user/name/{username} [GET]
|
||||
func (s *APIV1Service) GetUserByUsername(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
username := c.Param("username")
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{Username: &username})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||
}
|
||||
if user == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "User not found")
|
||||
}
|
||||
|
||||
userMessage := convertUserFromStore(user)
|
||||
// data desensitize
|
||||
userMessage.Email = ""
|
||||
return c.JSON(http.StatusOK, userMessage)
|
||||
}
|
||||
|
||||
// GetUserByID godoc
|
||||
//
|
||||
// @Summary Get user by id
|
||||
// @Tags user
|
||||
// @Produce json
|
||||
// @Param id path int true "User ID"
|
||||
// @Success 200 {object} store.User "Requested user"
|
||||
// @Failure 400 {object} nil "Malformatted user id"
|
||||
// @Failure 404 {object} nil "User not found"
|
||||
// @Failure 500 {object} nil "Failed to find user"
|
||||
// @Router /api/v1/user/{id} [GET]
|
||||
func (s *APIV1Service) GetUserByID(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
id, err := util.ConvertStringToInt32(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted user id").SetInternal(err)
|
||||
}
|
||||
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{ID: &id})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||
}
|
||||
if user == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "User not found")
|
||||
}
|
||||
|
||||
userMessage := convertUserFromStore(user)
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok || userID != user.ID {
|
||||
// Data desensitize.
|
||||
userMessage.Email = ""
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, userMessage)
|
||||
}
|
||||
|
||||
// DeleteUser godoc
|
||||
//
|
||||
// @Summary Delete a user
|
||||
// @Tags user
|
||||
// @Produce json
|
||||
// @Param id path string true "User ID"
|
||||
// @Success 200 {boolean} true "User deleted"
|
||||
// @Failure 400 {object} nil "ID is not a number: %s | Current session user not found with ID: %d"
|
||||
// @Failure 401 {object} nil "Missing user in session"
|
||||
// @Failure 403 {object} nil "Unauthorized to delete user"
|
||||
// @Failure 500 {object} nil "Failed to find user | Failed to delete user"
|
||||
// @Router /api/v1/user/{id} [DELETE]
|
||||
func (s *APIV1Service) DeleteUser(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
currentUserID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: ¤tUserID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||
}
|
||||
if currentUser == nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Current session user not found with ID: %d", currentUserID)).SetInternal(err)
|
||||
} else if currentUser.Role != store.RoleHost {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "Unauthorized to delete user").SetInternal(err)
|
||||
}
|
||||
|
||||
userID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||
}
|
||||
|
||||
userDelete := &store.DeleteUser{
|
||||
ID: userID,
|
||||
}
|
||||
if err := s.Store.DeleteUser(ctx, userDelete); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete user").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, true)
|
||||
}
|
||||
|
||||
// UpdateUser godoc
|
||||
//
|
||||
// @Summary Update a user
|
||||
// @Tags user
|
||||
// @Produce json
|
||||
// @Param id path string true "User ID"
|
||||
// @Param patch body UpdateUserRequest true "Patch request"
|
||||
// @Success 200 {object} store.User "Updated user"
|
||||
// @Failure 400 {object} nil "ID is not a number: %s | Current session user not found with ID: %d | Malformatted patch user request | Invalid update user request"
|
||||
// @Failure 401 {object} nil "Missing user in session"
|
||||
// @Failure 403 {object} nil "Unauthorized to update user"
|
||||
// @Failure 500 {object} nil "Failed to find user | Failed to generate password hash | Failed to patch user | Failed to find userSettingList"
|
||||
// @Router /api/v1/user/{id} [PATCH]
|
||||
func (s *APIV1Service) UpdateUser(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||
}
|
||||
|
||||
currentUserID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{ID: ¤tUserID})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||
}
|
||||
if currentUser == nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Current session user not found with ID: %d", currentUserID)).SetInternal(err)
|
||||
} else if currentUser.Role != store.RoleHost && currentUserID != userID {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "Unauthorized to update user").SetInternal(err)
|
||||
}
|
||||
|
||||
request := &UpdateUserRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch user request").SetInternal(err)
|
||||
}
|
||||
if err := request.Validate(); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid update user request").SetInternal(err)
|
||||
}
|
||||
|
||||
currentTs := time.Now().Unix()
|
||||
userUpdate := &store.UpdateUser{
|
||||
ID: userID,
|
||||
UpdatedTs: ¤tTs,
|
||||
}
|
||||
if request.RowStatus != nil {
|
||||
rowStatus := store.RowStatus(request.RowStatus.String())
|
||||
userUpdate.RowStatus = &rowStatus
|
||||
}
|
||||
if request.Username != nil {
|
||||
if !usernameMatcher.MatchString(strings.ToLower(*request.Username)) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid username %s", *request.Username)).SetInternal(err)
|
||||
}
|
||||
userUpdate.Username = request.Username
|
||||
}
|
||||
if request.Email != nil {
|
||||
userUpdate.Email = request.Email
|
||||
}
|
||||
if request.Nickname != nil {
|
||||
userUpdate.Nickname = request.Nickname
|
||||
}
|
||||
if request.Password != nil {
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(*request.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
|
||||
}
|
||||
|
||||
passwordHashStr := string(passwordHash)
|
||||
userUpdate.PasswordHash = &passwordHashStr
|
||||
}
|
||||
if request.AvatarURL != nil {
|
||||
userUpdate.AvatarURL = request.AvatarURL
|
||||
}
|
||||
|
||||
user, err := s.Store.UpdateUser(ctx, userUpdate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch user").SetInternal(err)
|
||||
}
|
||||
|
||||
userMessage := convertUserFromStore(user)
|
||||
return c.JSON(http.StatusOK, userMessage)
|
||||
}
|
||||
|
||||
func (create CreateUserRequest) Validate() error {
|
||||
if len(create.Username) < 3 {
|
||||
return errors.New("username is too short, minimum length is 3")
|
||||
}
|
||||
if len(create.Username) > 32 {
|
||||
return errors.New("username is too long, maximum length is 32")
|
||||
}
|
||||
if len(create.Password) < 3 {
|
||||
return errors.New("password is too short, minimum length is 3")
|
||||
}
|
||||
if len(create.Password) > 512 {
|
||||
return errors.New("password is too long, maximum length is 512")
|
||||
}
|
||||
if len(create.Nickname) > 64 {
|
||||
return errors.New("nickname is too long, maximum length is 64")
|
||||
}
|
||||
if create.Email != "" {
|
||||
if len(create.Email) > 256 {
|
||||
return errors.New("email is too long, maximum length is 256")
|
||||
}
|
||||
if !util.ValidateEmail(create.Email) {
|
||||
return errors.New("invalid email format")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (update UpdateUserRequest) Validate() error {
|
||||
if update.Username != nil && len(*update.Username) < 3 {
|
||||
return errors.New("username is too short, minimum length is 3")
|
||||
}
|
||||
if update.Username != nil && len(*update.Username) > 32 {
|
||||
return errors.New("username is too long, maximum length is 32")
|
||||
}
|
||||
if update.Password != nil && len(*update.Password) < 3 {
|
||||
return errors.New("password is too short, minimum length is 3")
|
||||
}
|
||||
if update.Password != nil && len(*update.Password) > 512 {
|
||||
return errors.New("password is too long, maximum length is 512")
|
||||
}
|
||||
if update.Nickname != nil && len(*update.Nickname) > 64 {
|
||||
return errors.New("nickname is too long, maximum length is 64")
|
||||
}
|
||||
if update.AvatarURL != nil {
|
||||
if len(*update.AvatarURL) > 2<<20 {
|
||||
return errors.New("avatar is too large, maximum is 2MB")
|
||||
}
|
||||
}
|
||||
if update.Email != nil && *update.Email != "" {
|
||||
if len(*update.Email) > 256 {
|
||||
return errors.New("email is too long, maximum length is 256")
|
||||
}
|
||||
if !util.ValidateEmail(*update.Email) {
|
||||
return errors.New("invalid email format")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func convertUserFromStore(user *store.User) *User {
|
||||
return &User{
|
||||
ID: user.ID,
|
||||
RowStatus: RowStatus(user.RowStatus),
|
||||
CreatedTs: user.CreatedTs,
|
||||
UpdatedTs: user.UpdatedTs,
|
||||
Username: user.Username,
|
||||
Role: Role(user.Role),
|
||||
Email: user.Email,
|
||||
Nickname: user.Nickname,
|
||||
PasswordHash: user.PasswordHash,
|
||||
AvatarURL: user.AvatarURL,
|
||||
}
|
||||
}
|
||||
94
api/v1/v1.go
Normal file
94
api/v1/v1.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
|
||||
"github.com/usememos/memos/api/resource"
|
||||
"github.com/usememos/memos/plugin/telegram"
|
||||
"github.com/usememos/memos/server/profile"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
type APIV1Service struct {
|
||||
Secret string
|
||||
Profile *profile.Profile
|
||||
Store *store.Store
|
||||
telegramBot *telegram.Bot
|
||||
}
|
||||
|
||||
// @title memos API
|
||||
// @version 1.0
|
||||
// @description A privacy-first, lightweight note-taking service.
|
||||
//
|
||||
// @contact.name API Support
|
||||
// @contact.url https://github.com/orgs/usememos/discussions
|
||||
//
|
||||
// @license.name MIT License
|
||||
// @license.url https://github.com/usememos/memos/blob/main/LICENSE
|
||||
//
|
||||
// @BasePath /
|
||||
//
|
||||
// @externalDocs.url https://usememos.com/
|
||||
// @externalDocs.description Find out more about Memos.
|
||||
func NewAPIV1Service(secret string, profile *profile.Profile, store *store.Store, telegramBot *telegram.Bot) *APIV1Service {
|
||||
return &APIV1Service{
|
||||
Secret: secret,
|
||||
Profile: profile,
|
||||
Store: store,
|
||||
telegramBot: telegramBot,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *APIV1Service) Register(rootGroup *echo.Group) {
|
||||
// Register RSS routes.
|
||||
s.registerRSSRoutes(rootGroup)
|
||||
|
||||
// Register API v1 routes.
|
||||
apiV1Group := rootGroup.Group("/api/v1")
|
||||
apiV1Group.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
|
||||
Store: middleware.NewRateLimiterMemoryStoreWithConfig(
|
||||
middleware.RateLimiterMemoryStoreConfig{Rate: 30, Burst: 100, ExpiresIn: 3 * time.Minute},
|
||||
),
|
||||
IdentifierExtractor: func(ctx echo.Context) (string, error) {
|
||||
id := ctx.RealIP()
|
||||
return id, nil
|
||||
},
|
||||
ErrorHandler: func(context echo.Context, err error) error {
|
||||
return context.JSON(http.StatusForbidden, nil)
|
||||
},
|
||||
DenyHandler: func(context echo.Context, identifier string, err error) error {
|
||||
return context.JSON(http.StatusTooManyRequests, nil)
|
||||
},
|
||||
}))
|
||||
apiV1Group.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return JWTMiddleware(s, next, s.Secret)
|
||||
})
|
||||
s.registerSystemRoutes(apiV1Group)
|
||||
s.registerSystemSettingRoutes(apiV1Group)
|
||||
s.registerAuthRoutes(apiV1Group)
|
||||
s.registerIdentityProviderRoutes(apiV1Group)
|
||||
s.registerUserRoutes(apiV1Group)
|
||||
s.registerTagRoutes(apiV1Group)
|
||||
s.registerStorageRoutes(apiV1Group)
|
||||
s.registerResourceRoutes(apiV1Group)
|
||||
s.registerMemoRoutes(apiV1Group)
|
||||
s.registerMemoOrganizerRoutes(apiV1Group)
|
||||
s.registerMemoRelationRoutes(apiV1Group)
|
||||
|
||||
// Register public routes.
|
||||
publicGroup := rootGroup.Group("/o")
|
||||
publicGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return JWTMiddleware(s, next, s.Secret)
|
||||
})
|
||||
s.registerGetterPublicRoutes(publicGroup)
|
||||
// Create and register resource public routes.
|
||||
resourceService := resource.NewService(s.Profile, s.Store)
|
||||
resourceService.RegisterResourcePublicRoutes(publicGroup)
|
||||
|
||||
// programmatically set API version same as the server version
|
||||
SwaggerInfo.Version = s.Profile.Version
|
||||
}
|
||||
162
api/v2/acl.go
Normal file
162
api/v2/acl.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/pkg/errors"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/usememos/memos/api/auth"
|
||||
"github.com/usememos/memos/internal/util"
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
// ContextKey is the key type of context value.
|
||||
type ContextKey int
|
||||
|
||||
const (
|
||||
// The key name used to store username in the context
|
||||
// user id is extracted from the jwt token subject field.
|
||||
usernameContextKey ContextKey = iota
|
||||
)
|
||||
|
||||
// GRPCAuthInterceptor is the auth interceptor for gRPC server.
|
||||
type GRPCAuthInterceptor struct {
|
||||
Store *store.Store
|
||||
secret string
|
||||
}
|
||||
|
||||
// NewGRPCAuthInterceptor returns a new API auth interceptor.
|
||||
func NewGRPCAuthInterceptor(store *store.Store, secret string) *GRPCAuthInterceptor {
|
||||
return &GRPCAuthInterceptor{
|
||||
Store: store,
|
||||
secret: secret,
|
||||
}
|
||||
}
|
||||
|
||||
// AuthenticationInterceptor is the unary interceptor for gRPC API.
|
||||
func (in *GRPCAuthInterceptor) AuthenticationInterceptor(ctx context.Context, request any, serverInfo *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "failed to parse metadata from incoming context")
|
||||
}
|
||||
accessToken, err := getTokenFromMetadata(md)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Unauthenticated, err.Error())
|
||||
}
|
||||
|
||||
username, err := in.authenticate(ctx, accessToken)
|
||||
if err != nil {
|
||||
if isUnauthorizeAllowedMethod(serverInfo.FullMethod) {
|
||||
return handler(ctx, request)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
user, err := in.Store.GetUser(ctx, &store.FindUser{
|
||||
Username: &username,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to get user")
|
||||
}
|
||||
if user == nil {
|
||||
return nil, errors.Errorf("user %q not exists", username)
|
||||
}
|
||||
if user.RowStatus == store.Archived {
|
||||
return nil, errors.Errorf("user %q is archived", username)
|
||||
}
|
||||
if isOnlyForAdminAllowedMethod(serverInfo.FullMethod) && user.Role != store.RoleHost && user.Role != store.RoleAdmin {
|
||||
return nil, errors.Errorf("user %q is not admin", username)
|
||||
}
|
||||
|
||||
// Stores userID into context.
|
||||
childCtx := context.WithValue(ctx, usernameContextKey, username)
|
||||
return handler(childCtx, request)
|
||||
}
|
||||
|
||||
func (in *GRPCAuthInterceptor) authenticate(ctx context.Context, accessToken string) (string, error) {
|
||||
if accessToken == "" {
|
||||
return "", status.Errorf(codes.Unauthenticated, "access token not found")
|
||||
}
|
||||
claims := &auth.ClaimsMessage{}
|
||||
_, err := jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (any, error) {
|
||||
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
||||
}
|
||||
if kid, ok := t.Header["kid"].(string); ok {
|
||||
if kid == "v1" {
|
||||
return []byte(in.secret), nil
|
||||
}
|
||||
}
|
||||
return nil, status.Errorf(codes.Unauthenticated, "unexpected access token kid=%v", t.Header["kid"])
|
||||
})
|
||||
if err != nil {
|
||||
return "", status.Errorf(codes.Unauthenticated, "Invalid or expired access token")
|
||||
}
|
||||
|
||||
// We either have a valid access token or we will attempt to generate new access token.
|
||||
userID, err := util.ConvertStringToInt32(claims.Subject)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "malformed ID in the token")
|
||||
}
|
||||
user, err := in.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "failed to get user")
|
||||
}
|
||||
if user == nil {
|
||||
return "", errors.Errorf("user %q not exists", userID)
|
||||
}
|
||||
if user.RowStatus == store.Archived {
|
||||
return "", errors.Errorf("user %q is archived", userID)
|
||||
}
|
||||
|
||||
accessTokens, err := in.Store.GetUserAccessTokens(ctx, user.ID)
|
||||
if err != nil {
|
||||
return "", errors.Wrapf(err, "failed to get user access tokens")
|
||||
}
|
||||
if !validateAccessToken(accessToken, accessTokens) {
|
||||
return "", status.Errorf(codes.Unauthenticated, "invalid access token")
|
||||
}
|
||||
|
||||
return user.Username, nil
|
||||
}
|
||||
|
||||
func getTokenFromMetadata(md metadata.MD) (string, error) {
|
||||
// Check the HTTP request header first.
|
||||
authorizationHeaders := md.Get("Authorization")
|
||||
if len(md.Get("Authorization")) > 0 {
|
||||
authHeaderParts := strings.Fields(authorizationHeaders[0])
|
||||
if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" {
|
||||
return "", errors.New("authorization header format must be Bearer {token}")
|
||||
}
|
||||
return authHeaderParts[1], nil
|
||||
}
|
||||
// Check the cookie header.
|
||||
var accessToken string
|
||||
for _, t := range append(md.Get("grpcgateway-cookie"), md.Get("cookie")...) {
|
||||
header := http.Header{}
|
||||
header.Add("Cookie", t)
|
||||
request := http.Request{Header: header}
|
||||
if v, _ := request.Cookie(auth.AccessTokenCookieName); v != nil {
|
||||
accessToken = v.Value
|
||||
}
|
||||
}
|
||||
return accessToken, nil
|
||||
}
|
||||
|
||||
func validateAccessToken(accessTokenString string, userAccessTokens []*storepb.AccessTokensUserSetting_AccessToken) bool {
|
||||
for _, userAccessToken := range userAccessTokens {
|
||||
if accessTokenString == userAccessToken.AccessToken {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
27
api/v2/acl_config.go
Normal file
27
api/v2/acl_config.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package v2
|
||||
|
||||
import "strings"
|
||||
|
||||
var authenticationAllowlistMethods = map[string]bool{
|
||||
"/memos.api.v2.SystemService/GetSystemInfo": true,
|
||||
"/memos.api.v2.AuthService/GetAuthStatus": true,
|
||||
"/memos.api.v2.UserService/GetUser": true,
|
||||
"/memos.api.v2.MemoService/ListMemos": true,
|
||||
}
|
||||
|
||||
// isUnauthorizeAllowedMethod returns whether the method is exempted from authentication.
|
||||
func isUnauthorizeAllowedMethod(fullMethodName string) bool {
|
||||
if strings.HasPrefix(fullMethodName, "/grpc.reflection") {
|
||||
return true
|
||||
}
|
||||
return authenticationAllowlistMethods[fullMethodName]
|
||||
}
|
||||
|
||||
var allowedMethodsOnlyForAdmin = map[string]bool{
|
||||
"/memos.api.v2.UserService/CreateUser": true,
|
||||
}
|
||||
|
||||
// isOnlyForAdminAllowedMethod returns true if the method is allowed to be called only by admin.
|
||||
func isOnlyForAdminAllowedMethod(methodName string) bool {
|
||||
return allowedMethodsOnlyForAdmin[methodName]
|
||||
}
|
||||
58
api/v2/activity_service .go
Normal file
58
api/v2/activity_service .go
Normal file
@@ -0,0 +1,58 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
func (s *APIV2Service) GetActivity(ctx context.Context, request *apiv2pb.GetActivityRequest) (*apiv2pb.GetActivityResponse, error) {
|
||||
activity, err := s.Store.GetActivity(ctx, &store.FindActivity{
|
||||
ID: &request.Id,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get activity: %v", err)
|
||||
}
|
||||
|
||||
activityMessage, err := s.convertActivityFromStore(ctx, activity)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to convert activity from store: %v", err)
|
||||
}
|
||||
return &apiv2pb.GetActivityResponse{
|
||||
Activity: activityMessage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (*APIV2Service) convertActivityFromStore(_ context.Context, activity *store.Activity) (*apiv2pb.Activity, error) {
|
||||
return &apiv2pb.Activity{
|
||||
Id: activity.ID,
|
||||
CreatorId: activity.CreatorID,
|
||||
Type: activity.Type.String(),
|
||||
Level: activity.Level.String(),
|
||||
CreateTime: timestamppb.New(time.Unix(activity.CreatedTs, 0)),
|
||||
Payload: convertActivityPayloadFromStore(activity.Payload),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func convertActivityPayloadFromStore(payload *storepb.ActivityPayload) *apiv2pb.ActivityPayload {
|
||||
v2Payload := &apiv2pb.ActivityPayload{}
|
||||
if payload.MemoComment != nil {
|
||||
v2Payload.MemoComment = &apiv2pb.ActivityMemoCommentPayload{
|
||||
MemoId: payload.MemoComment.MemoId,
|
||||
RelatedMemoId: payload.MemoComment.RelatedMemoId,
|
||||
}
|
||||
}
|
||||
if payload.VersionUpdate != nil {
|
||||
v2Payload.VersionUpdate = &apiv2pb.ActivityVersionUpdatePayload{
|
||||
Version: payload.VersionUpdate.Version,
|
||||
}
|
||||
}
|
||||
return v2Payload
|
||||
}
|
||||
23
api/v2/auth_service.go
Normal file
23
api/v2/auth_service.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
|
||||
)
|
||||
|
||||
func (s *APIV2Service) GetAuthStatus(ctx context.Context, _ *apiv2pb.GetAuthStatusRequest) (*apiv2pb.GetAuthStatusResponse, error) {
|
||||
user, err := getCurrentUser(ctx, s.Store)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "failed to get current user: %v", err)
|
||||
}
|
||||
if user == nil {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "user not found")
|
||||
}
|
||||
return &apiv2pb.GetAuthStatusResponse{
|
||||
User: convertUserFromStore(user),
|
||||
}, nil
|
||||
}
|
||||
44
api/v2/common.go
Normal file
44
api/v2/common.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
func convertRowStatusFromStore(rowStatus store.RowStatus) apiv2pb.RowStatus {
|
||||
switch rowStatus {
|
||||
case store.Normal:
|
||||
return apiv2pb.RowStatus_ACTIVE
|
||||
case store.Archived:
|
||||
return apiv2pb.RowStatus_ARCHIVED
|
||||
default:
|
||||
return apiv2pb.RowStatus_ROW_STATUS_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func convertRowStatusToStore(rowStatus apiv2pb.RowStatus) store.RowStatus {
|
||||
switch rowStatus {
|
||||
case apiv2pb.RowStatus_ACTIVE:
|
||||
return store.Normal
|
||||
case apiv2pb.RowStatus_ARCHIVED:
|
||||
return store.Archived
|
||||
default:
|
||||
return store.Normal
|
||||
}
|
||||
}
|
||||
|
||||
func getCurrentUser(ctx context.Context, s *store.Store) (*store.User, error) {
|
||||
username, ok := ctx.Value(usernameContextKey).(string)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
user, err := s.GetUser(ctx, &store.FindUser{
|
||||
Username: &username,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
138
api/v2/inbox_service .go
Normal file
138
api/v2/inbox_service .go
Normal file
@@ -0,0 +1,138 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
func (s *APIV2Service) ListInboxes(ctx context.Context, _ *apiv2pb.ListInboxesRequest) (*apiv2pb.ListInboxesResponse, error) {
|
||||
user, err := getCurrentUser(ctx, s.Store)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get user")
|
||||
}
|
||||
|
||||
inboxes, err := s.Store.ListInboxes(ctx, &store.FindInbox{
|
||||
ReceiverID: &user.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list inbox: %v", err)
|
||||
}
|
||||
|
||||
response := &apiv2pb.ListInboxesResponse{
|
||||
Inboxes: []*apiv2pb.Inbox{},
|
||||
}
|
||||
for _, inbox := range inboxes {
|
||||
inboxMessage, err := s.convertInboxFromStore(ctx, inbox)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to convert inbox from store: %v", err)
|
||||
}
|
||||
response.Inboxes = append(response.Inboxes, inboxMessage)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) UpdateInbox(ctx context.Context, request *apiv2pb.UpdateInboxRequest) (*apiv2pb.UpdateInboxResponse, error) {
|
||||
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "update mask is required")
|
||||
}
|
||||
|
||||
inboxID, err := ExtractInboxIDFromName(request.Inbox.Name)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid inbox name: %v", err)
|
||||
}
|
||||
update := &store.UpdateInbox{
|
||||
ID: inboxID,
|
||||
}
|
||||
for _, field := range request.UpdateMask.Paths {
|
||||
if field == "status" {
|
||||
if request.Inbox.Status == apiv2pb.Inbox_STATUS_UNSPECIFIED {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "status is required")
|
||||
}
|
||||
update.Status = convertInboxStatusToStore(request.Inbox.Status)
|
||||
}
|
||||
}
|
||||
|
||||
inbox, err := s.Store.UpdateInbox(ctx, update)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to update inbox: %v", err)
|
||||
}
|
||||
|
||||
inboxMessage, err := s.convertInboxFromStore(ctx, inbox)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to convert inbox from store: %v", err)
|
||||
}
|
||||
return &apiv2pb.UpdateInboxResponse{
|
||||
Inbox: inboxMessage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) DeleteInbox(ctx context.Context, request *apiv2pb.DeleteInboxRequest) (*apiv2pb.DeleteInboxResponse, error) {
|
||||
inboxID, err := ExtractInboxIDFromName(request.Name)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid inbox name: %v", err)
|
||||
}
|
||||
|
||||
if err := s.Store.DeleteInbox(ctx, &store.DeleteInbox{
|
||||
ID: inboxID,
|
||||
}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to update inbox: %v", err)
|
||||
}
|
||||
return &apiv2pb.DeleteInboxResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) convertInboxFromStore(ctx context.Context, inbox *store.Inbox) (*apiv2pb.Inbox, error) {
|
||||
sender, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &inbox.SenderID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to get sender")
|
||||
}
|
||||
receiver, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &inbox.ReceiverID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to get receiver")
|
||||
}
|
||||
|
||||
return &apiv2pb.Inbox{
|
||||
Name: fmt.Sprintf("inboxes/%d", inbox.ID),
|
||||
Sender: fmt.Sprintf("users/%s", sender.Username),
|
||||
Receiver: fmt.Sprintf("users/%s", receiver.Username),
|
||||
Status: convertInboxStatusFromStore(inbox.Status),
|
||||
CreateTime: timestamppb.New(time.Unix(inbox.CreatedTs, 0)),
|
||||
Type: apiv2pb.Inbox_Type(inbox.Message.Type),
|
||||
ActivityId: inbox.Message.ActivityId,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func convertInboxStatusFromStore(status store.InboxStatus) apiv2pb.Inbox_Status {
|
||||
switch status {
|
||||
case store.UNREAD:
|
||||
return apiv2pb.Inbox_UNREAD
|
||||
case store.ARCHIVED:
|
||||
return apiv2pb.Inbox_ARCHIVED
|
||||
default:
|
||||
return apiv2pb.Inbox_STATUS_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func convertInboxStatusToStore(status apiv2pb.Inbox_Status) store.InboxStatus {
|
||||
switch status {
|
||||
case apiv2pb.Inbox_UNREAD:
|
||||
return store.UNREAD
|
||||
case apiv2pb.Inbox_ARCHIVED:
|
||||
return store.ARCHIVED
|
||||
default:
|
||||
return store.UNREAD
|
||||
}
|
||||
}
|
||||
266
api/v2/memo_service.go
Normal file
266
api/v2/memo_service.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/cel-go/cel"
|
||||
"github.com/pkg/errors"
|
||||
v1alpha1 "google.golang.org/genproto/googleapis/api/expr/v1alpha1"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
func (s *APIV2Service) CreateMemo(ctx context.Context, request *apiv2pb.CreateMemoRequest) (*apiv2pb.CreateMemoResponse, error) {
|
||||
user, err := getCurrentUser(ctx, s.Store)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get user")
|
||||
}
|
||||
if user == nil {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
|
||||
create := &store.Memo{
|
||||
CreatorID: user.ID,
|
||||
Content: request.Content,
|
||||
Visibility: store.Visibility(request.Visibility.String()),
|
||||
}
|
||||
memo, err := s.Store.CreateMemo(ctx, create)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := &apiv2pb.CreateMemoResponse{
|
||||
Memo: convertMemoFromStore(memo),
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) ListMemos(ctx context.Context, request *apiv2pb.ListMemosRequest) (*apiv2pb.ListMemosResponse, error) {
|
||||
memoFind := &store.FindMemo{}
|
||||
if request.Filter != "" {
|
||||
filter, err := parseListMemosFilter(request.Filter)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid filter: %v", err)
|
||||
}
|
||||
if filter.Visibility != nil {
|
||||
memoFind.VisibilityList = []store.Visibility{*filter.Visibility}
|
||||
}
|
||||
if filter.CreatedTsBefore != nil {
|
||||
memoFind.CreatedTsBefore = filter.CreatedTsBefore
|
||||
}
|
||||
if filter.CreatedTsAfter != nil {
|
||||
memoFind.CreatedTsAfter = filter.CreatedTsAfter
|
||||
}
|
||||
}
|
||||
user, _ := getCurrentUser(ctx, s.Store)
|
||||
// If the user is not authenticated, only public memos are visible.
|
||||
if user == nil {
|
||||
memoFind.VisibilityList = []store.Visibility{store.Public}
|
||||
}
|
||||
|
||||
if request.CreatorId != nil {
|
||||
memoFind.CreatorID = request.CreatorId
|
||||
}
|
||||
|
||||
// Remove the private memos from the list if the user is not the creator.
|
||||
if user != nil && request.CreatorId != nil && *request.CreatorId != user.ID {
|
||||
var filteredVisibility []store.Visibility
|
||||
for _, v := range memoFind.VisibilityList {
|
||||
if v != store.Private {
|
||||
filteredVisibility = append(filteredVisibility, v)
|
||||
}
|
||||
}
|
||||
memoFind.VisibilityList = filteredVisibility
|
||||
}
|
||||
|
||||
if request.PageSize != 0 {
|
||||
offset := int(request.Page * request.PageSize)
|
||||
limit := int(request.PageSize)
|
||||
memoFind.Offset = &offset
|
||||
memoFind.Limit = &limit
|
||||
}
|
||||
memos, err := s.Store.ListMemos(ctx, memoFind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
memoMessages := make([]*apiv2pb.Memo, len(memos))
|
||||
for i, memo := range memos {
|
||||
memoMessages[i] = convertMemoFromStore(memo)
|
||||
}
|
||||
|
||||
response := &apiv2pb.ListMemosResponse{
|
||||
Memos: memoMessages,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) GetMemo(ctx context.Context, request *apiv2pb.GetMemoRequest) (*apiv2pb.GetMemoResponse, error) {
|
||||
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
|
||||
ID: &request.Id,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if memo == nil {
|
||||
return nil, status.Errorf(codes.NotFound, "memo not found")
|
||||
}
|
||||
if memo.Visibility != store.Public {
|
||||
user, err := getCurrentUser(ctx, s.Store)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get user")
|
||||
}
|
||||
if user == nil {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
if memo.Visibility == store.Private && memo.CreatorID != user.ID {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
}
|
||||
|
||||
response := &apiv2pb.GetMemoResponse{
|
||||
Memo: convertMemoFromStore(memo),
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) CreateMemoComment(ctx context.Context, request *apiv2pb.CreateMemoCommentRequest) (*apiv2pb.CreateMemoCommentResponse, error) {
|
||||
// Create the comment memo first.
|
||||
createMemoResponse, err := s.CreateMemo(ctx, request.Create)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to create memo")
|
||||
}
|
||||
|
||||
// Build the relation between the comment memo and the original memo.
|
||||
memo := createMemoResponse.Memo
|
||||
_, err = s.Store.UpsertMemoRelation(ctx, &store.MemoRelation{
|
||||
MemoID: memo.Id,
|
||||
RelatedMemoID: request.Id,
|
||||
Type: store.MemoRelationComment,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to create memo relation")
|
||||
}
|
||||
|
||||
response := &apiv2pb.CreateMemoCommentResponse{
|
||||
Memo: memo,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) ListMemoComments(ctx context.Context, request *apiv2pb.ListMemoCommentsRequest) (*apiv2pb.ListMemoCommentsResponse, error) {
|
||||
memoRelationComment := store.MemoRelationComment
|
||||
memoRelations, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{
|
||||
RelatedMemoID: &request.Id,
|
||||
Type: &memoRelationComment,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list memo relations")
|
||||
}
|
||||
|
||||
var memos []*apiv2pb.Memo
|
||||
for _, memoRelation := range memoRelations {
|
||||
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
|
||||
ID: &memoRelation.MemoID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get memo")
|
||||
}
|
||||
if memo != nil {
|
||||
memos = append(memos, convertMemoFromStore(memo))
|
||||
}
|
||||
}
|
||||
|
||||
response := &apiv2pb.ListMemoCommentsResponse{
|
||||
Memos: memos,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ListMemosFilterCELAttributes are the CEL attributes for ListMemosFilter.
|
||||
var ListMemosFilterCELAttributes = []cel.EnvOption{
|
||||
cel.Variable("visibility", cel.StringType),
|
||||
cel.Variable("created_ts_before", cel.IntType),
|
||||
cel.Variable("created_ts_after", cel.IntType),
|
||||
}
|
||||
|
||||
type ListMemosFilter struct {
|
||||
Visibility *store.Visibility
|
||||
CreatedTsBefore *int64
|
||||
CreatedTsAfter *int64
|
||||
}
|
||||
|
||||
func parseListMemosFilter(expression string) (*ListMemosFilter, error) {
|
||||
e, err := cel.NewEnv(ListMemosFilterCELAttributes...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ast, issues := e.Compile(expression)
|
||||
if issues != nil {
|
||||
return nil, errors.Errorf("found issue %v", issues)
|
||||
}
|
||||
filter := &ListMemosFilter{}
|
||||
expr, err := cel.AstToParsedExpr(ast)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
callExpr := expr.GetExpr().GetCallExpr()
|
||||
findField(callExpr, filter)
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
func findField(callExpr *v1alpha1.Expr_Call, filter *ListMemosFilter) {
|
||||
if len(callExpr.Args) == 2 {
|
||||
idExpr := callExpr.Args[0].GetIdentExpr()
|
||||
if idExpr != nil {
|
||||
if idExpr.Name == "visibility" {
|
||||
visibility := store.Visibility(callExpr.Args[1].GetConstExpr().GetStringValue())
|
||||
filter.Visibility = &visibility
|
||||
}
|
||||
if idExpr.Name == "created_ts_before" {
|
||||
createdTsBefore := callExpr.Args[1].GetConstExpr().GetInt64Value()
|
||||
filter.CreatedTsBefore = &createdTsBefore
|
||||
}
|
||||
if idExpr.Name == "created_ts_after" {
|
||||
createdTsAfter := callExpr.Args[1].GetConstExpr().GetInt64Value()
|
||||
filter.CreatedTsAfter = &createdTsAfter
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, arg := range callExpr.Args {
|
||||
callExpr := arg.GetCallExpr()
|
||||
if callExpr != nil {
|
||||
findField(callExpr, filter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func convertMemoFromStore(memo *store.Memo) *apiv2pb.Memo {
|
||||
return &apiv2pb.Memo{
|
||||
Id: int32(memo.ID),
|
||||
RowStatus: convertRowStatusFromStore(memo.RowStatus),
|
||||
CreatedTs: memo.CreatedTs,
|
||||
UpdatedTs: memo.UpdatedTs,
|
||||
CreatorId: int32(memo.CreatorID),
|
||||
Content: memo.Content,
|
||||
Visibility: convertVisibilityFromStore(memo.Visibility),
|
||||
Pinned: memo.Pinned,
|
||||
}
|
||||
}
|
||||
|
||||
func convertVisibilityFromStore(visibility store.Visibility) apiv2pb.Visibility {
|
||||
switch visibility {
|
||||
case store.Private:
|
||||
return apiv2pb.Visibility_PRIVATE
|
||||
case store.Protected:
|
||||
return apiv2pb.Visibility_PROTECTED
|
||||
case store.Public:
|
||||
return apiv2pb.Visibility_PUBLIC
|
||||
default:
|
||||
return apiv2pb.Visibility_VISIBILITY_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
57
api/v2/resource_name.go
Normal file
57
api/v2/resource_name.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/usememos/memos/internal/util"
|
||||
)
|
||||
|
||||
const (
|
||||
UserNamePrefix = "users/"
|
||||
InboxNamePrefix = "inboxes/"
|
||||
)
|
||||
|
||||
// GetNameParentTokens returns the tokens from a resource name.
|
||||
func GetNameParentTokens(name string, tokenPrefixes ...string) ([]string, error) {
|
||||
parts := strings.Split(name, "/")
|
||||
if len(parts) != 2*len(tokenPrefixes) {
|
||||
return nil, errors.Errorf("invalid request %q", name)
|
||||
}
|
||||
|
||||
var tokens []string
|
||||
for i, tokenPrefix := range tokenPrefixes {
|
||||
if fmt.Sprintf("%s/", parts[2*i]) != tokenPrefix {
|
||||
return nil, errors.Errorf("invalid prefix %q in request %q", tokenPrefix, name)
|
||||
}
|
||||
if parts[2*i+1] == "" {
|
||||
return nil, errors.Errorf("invalid request %q with empty prefix %q", name, tokenPrefix)
|
||||
}
|
||||
tokens = append(tokens, parts[2*i+1])
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// ExtractUsernameFromName returns the username from a resource name.
|
||||
func ExtractUsernameFromName(name string) (string, error) {
|
||||
tokens, err := GetNameParentTokens(name, UserNamePrefix)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tokens[0], nil
|
||||
}
|
||||
|
||||
// ExtractInboxIDFromName returns the inbox ID from a resource name.
|
||||
func ExtractInboxIDFromName(name string) (int32, error) {
|
||||
tokens, err := GetNameParentTokens(name, InboxNamePrefix)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
id, err := util.ConvertStringToInt32(tokens[0])
|
||||
if err != nil {
|
||||
return 0, errors.Errorf("invalid inbox ID %q", tokens[0])
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
105
api/v2/resource_service.go
Normal file
105
api/v2/resource_service.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
func (s *APIV2Service) ListResources(ctx context.Context, _ *apiv2pb.ListResourcesRequest) (*apiv2pb.ListResourcesResponse, error) {
|
||||
user, err := getCurrentUser(ctx, s.Store)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
|
||||
}
|
||||
resources, err := s.Store.ListResources(ctx, &store.FindResource{
|
||||
CreatorID: &user.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list resources: %v", err)
|
||||
}
|
||||
|
||||
response := &apiv2pb.ListResourcesResponse{}
|
||||
for _, resource := range resources {
|
||||
response.Resources = append(response.Resources, s.convertResourceFromStore(ctx, resource))
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) UpdateResource(ctx context.Context, request *apiv2pb.UpdateResourceRequest) (*apiv2pb.UpdateResourceResponse, error) {
|
||||
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "update mask is required")
|
||||
}
|
||||
|
||||
currentTs := time.Now().Unix()
|
||||
update := &store.UpdateResource{
|
||||
ID: request.Resource.Id,
|
||||
UpdatedTs: ¤tTs,
|
||||
}
|
||||
for _, field := range request.UpdateMask.Paths {
|
||||
if field == "filename" {
|
||||
update.Filename = &request.Resource.Filename
|
||||
} else if field == "memo_id" {
|
||||
update.MemoID = request.Resource.MemoId
|
||||
}
|
||||
}
|
||||
|
||||
resource, err := s.Store.UpdateResource(ctx, update)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to update resource: %v", err)
|
||||
}
|
||||
return &apiv2pb.UpdateResourceResponse{
|
||||
Resource: s.convertResourceFromStore(ctx, resource),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) DeleteResource(ctx context.Context, request *apiv2pb.DeleteResourceRequest) (*apiv2pb.DeleteResourceResponse, error) {
|
||||
user, err := getCurrentUser(ctx, s.Store)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
|
||||
}
|
||||
resource, err := s.Store.GetResource(ctx, &store.FindResource{
|
||||
ID: &request.Id,
|
||||
CreatorID: &user.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to find resource: %v", err)
|
||||
}
|
||||
if resource == nil {
|
||||
return nil, status.Errorf(codes.NotFound, "resource not found")
|
||||
}
|
||||
// Delete the resource from the database.
|
||||
if err := s.Store.DeleteResource(ctx, &store.DeleteResource{
|
||||
ID: resource.ID,
|
||||
}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to delete resource: %v", err)
|
||||
}
|
||||
return &apiv2pb.DeleteResourceResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) convertResourceFromStore(ctx context.Context, resource *store.Resource) *apiv2pb.Resource {
|
||||
var memoID *int32
|
||||
if resource.MemoID != nil {
|
||||
memo, _ := s.Store.GetMemo(ctx, &store.FindMemo{
|
||||
ID: resource.MemoID,
|
||||
})
|
||||
if memo != nil {
|
||||
memoID = &memo.ID
|
||||
}
|
||||
}
|
||||
|
||||
return &apiv2pb.Resource{
|
||||
Id: resource.ID,
|
||||
CreatedTs: timestamppb.New(time.Unix(resource.CreatedTs, 0)),
|
||||
Filename: resource.Filename,
|
||||
ExternalLink: resource.ExternalLink,
|
||||
Type: resource.Type,
|
||||
Size: resource.Size,
|
||||
MemoId: memoID,
|
||||
}
|
||||
}
|
||||
92
api/v2/system_service.go
Normal file
92
api/v2/system_service.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
func (s *APIV2Service) GetSystemInfo(ctx context.Context, _ *apiv2pb.GetSystemInfoRequest) (*apiv2pb.GetSystemInfoResponse, error) {
|
||||
defaultSystemInfo := &apiv2pb.SystemInfo{}
|
||||
|
||||
// Get the database size if the user is a host.
|
||||
currentUser, err := getCurrentUser(ctx, s.Store)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
|
||||
}
|
||||
if currentUser != nil && currentUser.Role == store.RoleHost {
|
||||
size, err := s.Store.GetCurrentDBSize(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get db size: %v", err)
|
||||
}
|
||||
defaultSystemInfo.DbSize = size
|
||||
}
|
||||
|
||||
response := &apiv2pb.GetSystemInfoResponse{
|
||||
SystemInfo: defaultSystemInfo,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) UpdateSystemInfo(ctx context.Context, request *apiv2pb.UpdateSystemInfoRequest) (*apiv2pb.UpdateSystemInfoResponse, error) {
|
||||
user, err := getCurrentUser(ctx, s.Store)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
|
||||
}
|
||||
if user.Role != store.RoleHost {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "update mask is required")
|
||||
}
|
||||
|
||||
// Update system settings.
|
||||
for _, field := range request.UpdateMask.Paths {
|
||||
if field == "allow_registration" {
|
||||
_, err := s.Store.UpsertSystemSetting(ctx, &store.SystemSetting{
|
||||
Name: "allow-signup",
|
||||
Value: strconv.FormatBool(request.SystemInfo.AllowRegistration),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to update allow_registration system setting: %v", err)
|
||||
}
|
||||
} else if field == "disable_password_login" {
|
||||
_, err := s.Store.UpsertSystemSetting(ctx, &store.SystemSetting{
|
||||
Name: "disable-password-login",
|
||||
Value: strconv.FormatBool(request.SystemInfo.DisablePasswordLogin),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to update disable_password_login system setting: %v", err)
|
||||
}
|
||||
} else if field == "additional_script" {
|
||||
_, err := s.Store.UpsertSystemSetting(ctx, &store.SystemSetting{
|
||||
Name: "additional-script",
|
||||
Value: request.SystemInfo.AdditionalScript,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to update additional_script system setting: %v", err)
|
||||
}
|
||||
} else if field == "additional_style" {
|
||||
_, err := s.Store.UpsertSystemSetting(ctx, &store.SystemSetting{
|
||||
Name: "additional-style",
|
||||
Value: request.SystemInfo.AdditionalStyle,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to update additional_style system setting: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
systemInfo, err := s.GetSystemInfo(ctx, &apiv2pb.GetSystemInfoRequest{})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get system info: %v", err)
|
||||
}
|
||||
return &apiv2pb.UpdateSystemInfoResponse{
|
||||
SystemInfo: systemInfo.SystemInfo,
|
||||
}, nil
|
||||
}
|
||||
181
api/v2/tag_service.go
Normal file
181
api/v2/tag_service.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/exp/slices"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
func (s *APIV2Service) UpsertTag(ctx context.Context, request *apiv2pb.UpsertTagRequest) (*apiv2pb.UpsertTagResponse, error) {
|
||||
user, err := getCurrentUser(ctx, s.Store)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get user")
|
||||
}
|
||||
|
||||
tag, err := s.Store.UpsertTag(ctx, &store.Tag{
|
||||
Name: request.Name,
|
||||
CreatorID: user.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to upsert tag: %v", err)
|
||||
}
|
||||
|
||||
t, err := s.convertTagFromStore(ctx, tag)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to convert tag: %v", err)
|
||||
}
|
||||
return &apiv2pb.UpsertTagResponse{
|
||||
Tag: t,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) ListTags(ctx context.Context, request *apiv2pb.ListTagsRequest) (*apiv2pb.ListTagsResponse, error) {
|
||||
username, err := ExtractUsernameFromName(request.User)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid username: %v", err)
|
||||
}
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
Username: &username,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
|
||||
}
|
||||
if user == nil {
|
||||
return nil, status.Errorf(codes.NotFound, "user not found")
|
||||
}
|
||||
tags, err := s.Store.ListTags(ctx, &store.FindTag{
|
||||
CreatorID: user.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list tags: %v", err)
|
||||
}
|
||||
|
||||
response := &apiv2pb.ListTagsResponse{}
|
||||
for _, tag := range tags {
|
||||
t, err := s.convertTagFromStore(ctx, tag)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to convert tag: %v", err)
|
||||
}
|
||||
response.Tags = append(response.Tags, t)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) DeleteTag(ctx context.Context, request *apiv2pb.DeleteTagRequest) (*apiv2pb.DeleteTagResponse, error) {
|
||||
username, err := ExtractUsernameFromName(request.Tag.Creator)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid username: %v", err)
|
||||
}
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
Username: &username,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
|
||||
}
|
||||
if user == nil {
|
||||
return nil, status.Errorf(codes.NotFound, "user not found")
|
||||
}
|
||||
if err := s.Store.DeleteTag(ctx, &store.DeleteTag{
|
||||
Name: request.Tag.Name,
|
||||
CreatorID: user.ID,
|
||||
}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to delete tag: %v", err)
|
||||
}
|
||||
|
||||
return &apiv2pb.DeleteTagResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) GetTagSuggestions(ctx context.Context, request *apiv2pb.GetTagSuggestionsRequest) (*apiv2pb.GetTagSuggestionsResponse, error) {
|
||||
username, err := ExtractUsernameFromName(request.User)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid username: %v", err)
|
||||
}
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
Username: &username,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
|
||||
}
|
||||
if user == nil {
|
||||
return nil, status.Errorf(codes.NotFound, "user not found")
|
||||
}
|
||||
normalRowStatus := store.Normal
|
||||
memoFind := &store.FindMemo{
|
||||
CreatorID: &user.ID,
|
||||
ContentSearch: []string{"#"},
|
||||
RowStatus: &normalRowStatus,
|
||||
}
|
||||
memoList, err := s.Store.ListMemos(ctx, memoFind)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list memos: %v", err)
|
||||
}
|
||||
|
||||
tagList, err := s.Store.ListTags(ctx, &store.FindTag{
|
||||
CreatorID: user.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list tags: %v", err)
|
||||
}
|
||||
|
||||
tagNameList := []string{}
|
||||
for _, tag := range tagList {
|
||||
tagNameList = append(tagNameList, tag.Name)
|
||||
}
|
||||
tagMapSet := make(map[string]bool)
|
||||
for _, memo := range memoList {
|
||||
for _, tag := range findTagListFromMemoContent(memo.Content) {
|
||||
if !slices.Contains(tagNameList, tag) {
|
||||
tagMapSet[tag] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
suggestions := []string{}
|
||||
for tag := range tagMapSet {
|
||||
suggestions = append(suggestions, tag)
|
||||
}
|
||||
sort.Strings(suggestions)
|
||||
|
||||
return &apiv2pb.GetTagSuggestionsResponse{
|
||||
Tags: suggestions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) convertTagFromStore(ctx context.Context, tag *store.Tag) (*apiv2pb.Tag, error) {
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &tag.CreatorID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to get user")
|
||||
}
|
||||
return &apiv2pb.Tag{
|
||||
Name: tag.Name,
|
||||
Creator: fmt.Sprintf("%s%s", UserNamePrefix, user.Username),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var tagRegexp = regexp.MustCompile(`#([^\s#,]+)`)
|
||||
|
||||
func findTagListFromMemoContent(memoContent string) []string {
|
||||
tagMapSet := make(map[string]bool)
|
||||
matches := tagRegexp.FindAllStringSubmatch(memoContent, -1)
|
||||
for _, v := range matches {
|
||||
tagName := v[1]
|
||||
tagMapSet[tagName] = true
|
||||
}
|
||||
|
||||
tagList := []string{}
|
||||
for tag := range tagMapSet {
|
||||
tagList = append(tagList, tag)
|
||||
}
|
||||
sort.Strings(tagList)
|
||||
return tagList
|
||||
}
|
||||
511
api/v2/user_service.go
Normal file
511
api/v2/user_service.go
Normal file
@@ -0,0 +1,511 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/exp/slices"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/usememos/memos/api/auth"
|
||||
apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
var (
|
||||
usernameMatcher = regexp.MustCompile("^[a-z0-9]([a-z0-9-]{1,30}[a-z0-9])$")
|
||||
)
|
||||
|
||||
func (s *APIV2Service) GetUser(ctx context.Context, request *apiv2pb.GetUserRequest) (*apiv2pb.GetUserResponse, error) {
|
||||
username, err := ExtractUsernameFromName(request.Name)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "name is required")
|
||||
}
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
Username: &username,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
|
||||
}
|
||||
if user == nil {
|
||||
return nil, status.Errorf(codes.NotFound, "user not found")
|
||||
}
|
||||
|
||||
userMessage := convertUserFromStore(user)
|
||||
response := &apiv2pb.GetUserResponse{
|
||||
User: userMessage,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) CreateUser(ctx context.Context, request *apiv2pb.CreateUserRequest) (*apiv2pb.CreateUserResponse, error) {
|
||||
currentUser, err := getCurrentUser(ctx, s.Store)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
|
||||
}
|
||||
if currentUser.Role != store.RoleHost {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
|
||||
username, err := ExtractUsernameFromName(request.User.Name)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "name is required")
|
||||
}
|
||||
if !usernameMatcher.MatchString(strings.ToLower(username)) {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid username: %s", username)
|
||||
}
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.User.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to generate password hash").SetInternal(err)
|
||||
}
|
||||
|
||||
user, err := s.Store.CreateUser(ctx, &store.User{
|
||||
Username: username,
|
||||
Role: convertUserRoleToStore(request.User.Role),
|
||||
Email: request.User.Email,
|
||||
Nickname: request.User.Nickname,
|
||||
PasswordHash: string(passwordHash),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to create user: %v", err)
|
||||
}
|
||||
|
||||
response := &apiv2pb.CreateUserResponse{
|
||||
User: convertUserFromStore(user),
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) UpdateUser(ctx context.Context, request *apiv2pb.UpdateUserRequest) (*apiv2pb.UpdateUserResponse, error) {
|
||||
username, err := ExtractUsernameFromName(request.User.Name)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "name is required")
|
||||
}
|
||||
currentUser, err := getCurrentUser(ctx, s.Store)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
|
||||
}
|
||||
if currentUser.Username != username && currentUser.Role != store.RoleAdmin && currentUser.Role != store.RoleHost {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "update mask is empty")
|
||||
}
|
||||
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{Username: &username})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
|
||||
}
|
||||
if user == nil {
|
||||
return nil, status.Errorf(codes.NotFound, "user not found")
|
||||
}
|
||||
|
||||
currentTs := time.Now().Unix()
|
||||
update := &store.UpdateUser{
|
||||
ID: user.ID,
|
||||
UpdatedTs: ¤tTs,
|
||||
}
|
||||
for _, field := range request.UpdateMask.Paths {
|
||||
if field == "username" {
|
||||
if !usernameMatcher.MatchString(strings.ToLower(username)) {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid username: %s", username)
|
||||
}
|
||||
update.Username = &username
|
||||
} else if field == "nickname" {
|
||||
update.Nickname = &request.User.Nickname
|
||||
} else if field == "email" {
|
||||
update.Email = &request.User.Email
|
||||
} else if field == "avatar_url" {
|
||||
update.AvatarURL = &request.User.AvatarUrl
|
||||
} else if field == "role" {
|
||||
role := convertUserRoleToStore(request.User.Role)
|
||||
update.Role = &role
|
||||
} else if field == "password" {
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.User.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to generate password hash").SetInternal(err)
|
||||
}
|
||||
passwordHashStr := string(passwordHash)
|
||||
update.PasswordHash = &passwordHashStr
|
||||
} else if field == "row_status" {
|
||||
rowStatus := convertRowStatusToStore(request.User.RowStatus)
|
||||
update.RowStatus = &rowStatus
|
||||
} else {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid update path: %s", field)
|
||||
}
|
||||
}
|
||||
|
||||
updatedUser, err := s.Store.UpdateUser(ctx, update)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to update user: %v", err)
|
||||
}
|
||||
|
||||
response := &apiv2pb.UpdateUserResponse{
|
||||
User: convertUserFromStore(updatedUser),
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) DeleteUser(ctx context.Context, request *apiv2pb.DeleteUserRequest) (*apiv2pb.DeleteUserResponse, error) {
|
||||
username, err := ExtractUsernameFromName(request.Name)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "name is required")
|
||||
}
|
||||
currentUser, err := getCurrentUser(ctx, s.Store)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
|
||||
}
|
||||
if currentUser.Username != username && currentUser.Role != store.RoleAdmin && currentUser.Role != store.RoleHost {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{Username: &username})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
|
||||
}
|
||||
if user == nil {
|
||||
return nil, status.Errorf(codes.NotFound, "user not found")
|
||||
}
|
||||
|
||||
if err := s.Store.DeleteUser(ctx, &store.DeleteUser{
|
||||
ID: user.ID,
|
||||
}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to delete user: %v", err)
|
||||
}
|
||||
|
||||
return &apiv2pb.DeleteUserResponse{}, nil
|
||||
}
|
||||
|
||||
func getDefaultUserSetting() *apiv2pb.UserSetting {
|
||||
return &apiv2pb.UserSetting{
|
||||
Locale: "en",
|
||||
Appearance: "system",
|
||||
MemoVisibility: "PRIVATE",
|
||||
}
|
||||
}
|
||||
|
||||
func (s *APIV2Service) GetUserSetting(ctx context.Context, _ *apiv2pb.GetUserSettingRequest) (*apiv2pb.GetUserSettingResponse, error) {
|
||||
user, err := getCurrentUser(ctx, s.Store)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
|
||||
}
|
||||
|
||||
userSettings, err := s.Store.ListUserSettingsV1(ctx, &store.FindUserSetting{
|
||||
UserID: &user.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list user settings: %v", err)
|
||||
}
|
||||
userSettingMessage := getDefaultUserSetting()
|
||||
for _, setting := range userSettings {
|
||||
if setting.Key == storepb.UserSettingKey_USER_SETTING_LOCALE {
|
||||
userSettingMessage.Locale = setting.GetLocale()
|
||||
} else if setting.Key == storepb.UserSettingKey_USER_SETTING_APPEARANCE {
|
||||
userSettingMessage.Appearance = setting.GetAppearance()
|
||||
} else if setting.Key == storepb.UserSettingKey_USER_SETTING_MEMO_VISIBILITY {
|
||||
userSettingMessage.MemoVisibility = setting.GetMemoVisibility()
|
||||
} else if setting.Key == storepb.UserSettingKey_USER_SETTING_TELEGRAM_USER_ID {
|
||||
userSettingMessage.TelegramUserId = setting.GetTelegramUserId()
|
||||
}
|
||||
}
|
||||
return &apiv2pb.GetUserSettingResponse{
|
||||
Setting: userSettingMessage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) UpdateUserSetting(ctx context.Context, request *apiv2pb.UpdateUserSettingRequest) (*apiv2pb.UpdateUserSettingResponse, error) {
|
||||
user, err := getCurrentUser(ctx, s.Store)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
|
||||
}
|
||||
|
||||
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "update mask is empty")
|
||||
}
|
||||
|
||||
for _, field := range request.UpdateMask.Paths {
|
||||
if field == "locale" {
|
||||
if _, err := s.Store.UpsertUserSettingV1(ctx, &storepb.UserSetting{
|
||||
UserId: user.ID,
|
||||
Key: storepb.UserSettingKey_USER_SETTING_LOCALE,
|
||||
Value: &storepb.UserSetting_Locale{
|
||||
Locale: request.Setting.Locale,
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to upsert user setting: %v", err)
|
||||
}
|
||||
} else if field == "appearance" {
|
||||
if _, err := s.Store.UpsertUserSettingV1(ctx, &storepb.UserSetting{
|
||||
UserId: user.ID,
|
||||
Key: storepb.UserSettingKey_USER_SETTING_APPEARANCE,
|
||||
Value: &storepb.UserSetting_Appearance{
|
||||
Appearance: request.Setting.Appearance,
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to upsert user setting: %v", err)
|
||||
}
|
||||
} else if field == "memo_visibility" {
|
||||
if _, err := s.Store.UpsertUserSettingV1(ctx, &storepb.UserSetting{
|
||||
UserId: user.ID,
|
||||
Key: storepb.UserSettingKey_USER_SETTING_MEMO_VISIBILITY,
|
||||
Value: &storepb.UserSetting_MemoVisibility{
|
||||
MemoVisibility: request.Setting.MemoVisibility,
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to upsert user setting: %v", err)
|
||||
}
|
||||
} else if field == "telegram_user_id" {
|
||||
if _, err := s.Store.UpsertUserSettingV1(ctx, &storepb.UserSetting{
|
||||
UserId: user.ID,
|
||||
Key: storepb.UserSettingKey_USER_SETTING_TELEGRAM_USER_ID,
|
||||
Value: &storepb.UserSetting_TelegramUserId{
|
||||
TelegramUserId: request.Setting.TelegramUserId,
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to upsert user setting: %v", err)
|
||||
}
|
||||
} else {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid update path: %s", field)
|
||||
}
|
||||
}
|
||||
|
||||
userSettingResponse, err := s.GetUserSetting(ctx, &apiv2pb.GetUserSettingRequest{})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get user setting: %v", err)
|
||||
}
|
||||
return &apiv2pb.UpdateUserSettingResponse{
|
||||
Setting: userSettingResponse.Setting,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) ListUserAccessTokens(ctx context.Context, request *apiv2pb.ListUserAccessTokensRequest) (*apiv2pb.ListUserAccessTokensResponse, error) {
|
||||
user, err := getCurrentUser(ctx, s.Store)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
|
||||
}
|
||||
if user == nil {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
|
||||
userID := user.ID
|
||||
username, err := ExtractUsernameFromName(request.Name)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "name is required")
|
||||
}
|
||||
// List access token for other users need to be verified.
|
||||
if user.Username != username {
|
||||
// Normal users can only list their access tokens.
|
||||
if user.Role == store.RoleUser {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
|
||||
// The request user must be exist.
|
||||
requestUser, err := s.Store.GetUser(ctx, &store.FindUser{Username: &username})
|
||||
if requestUser == nil || err != nil {
|
||||
return nil, status.Errorf(codes.NotFound, "fail to find user %s", username)
|
||||
}
|
||||
userID = requestUser.ID
|
||||
}
|
||||
|
||||
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list access tokens: %v", err)
|
||||
}
|
||||
|
||||
accessTokens := []*apiv2pb.UserAccessToken{}
|
||||
for _, userAccessToken := range userAccessTokens {
|
||||
claims := &auth.ClaimsMessage{}
|
||||
_, err := jwt.ParseWithClaims(userAccessToken.AccessToken, claims, func(t *jwt.Token) (any, error) {
|
||||
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
||||
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
||||
}
|
||||
if kid, ok := t.Header["kid"].(string); ok {
|
||||
if kid == "v1" {
|
||||
return []byte(s.Secret), nil
|
||||
}
|
||||
}
|
||||
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
|
||||
})
|
||||
if err != nil {
|
||||
// If the access token is invalid or expired, just ignore it.
|
||||
continue
|
||||
}
|
||||
|
||||
userAccessToken := &apiv2pb.UserAccessToken{
|
||||
AccessToken: userAccessToken.AccessToken,
|
||||
Description: userAccessToken.Description,
|
||||
IssuedAt: timestamppb.New(claims.IssuedAt.Time),
|
||||
}
|
||||
if claims.ExpiresAt != nil {
|
||||
userAccessToken.ExpiresAt = timestamppb.New(claims.ExpiresAt.Time)
|
||||
}
|
||||
accessTokens = append(accessTokens, userAccessToken)
|
||||
}
|
||||
|
||||
// Sort by issued time in descending order.
|
||||
slices.SortFunc(accessTokens, func(i, j *apiv2pb.UserAccessToken) int {
|
||||
return int(i.IssuedAt.Seconds - j.IssuedAt.Seconds)
|
||||
})
|
||||
response := &apiv2pb.ListUserAccessTokensResponse{
|
||||
AccessTokens: accessTokens,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) CreateUserAccessToken(ctx context.Context, request *apiv2pb.CreateUserAccessTokenRequest) (*apiv2pb.CreateUserAccessTokenResponse, error) {
|
||||
user, err := getCurrentUser(ctx, s.Store)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
|
||||
}
|
||||
|
||||
expiresAt := time.Time{}
|
||||
if request.ExpiresAt != nil {
|
||||
expiresAt = request.ExpiresAt.AsTime()
|
||||
}
|
||||
|
||||
accessToken, err := auth.GenerateAccessToken(user.Username, user.ID, expiresAt, []byte(s.Secret))
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to generate access token: %v", err)
|
||||
}
|
||||
|
||||
claims := &auth.ClaimsMessage{}
|
||||
_, err = jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (any, error) {
|
||||
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
||||
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
||||
}
|
||||
if kid, ok := t.Header["kid"].(string); ok {
|
||||
if kid == "v1" {
|
||||
return []byte(s.Secret), nil
|
||||
}
|
||||
}
|
||||
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to parse access token: %v", err)
|
||||
}
|
||||
|
||||
// Upsert the access token to user setting store.
|
||||
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken, request.Description); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to upsert access token to store: %v", err)
|
||||
}
|
||||
|
||||
userAccessToken := &apiv2pb.UserAccessToken{
|
||||
AccessToken: accessToken,
|
||||
Description: request.Description,
|
||||
IssuedAt: timestamppb.New(claims.IssuedAt.Time),
|
||||
}
|
||||
if claims.ExpiresAt != nil {
|
||||
userAccessToken.ExpiresAt = timestamppb.New(claims.ExpiresAt.Time)
|
||||
}
|
||||
response := &apiv2pb.CreateUserAccessTokenResponse{
|
||||
AccessToken: userAccessToken,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) DeleteUserAccessToken(ctx context.Context, request *apiv2pb.DeleteUserAccessTokenRequest) (*apiv2pb.DeleteUserAccessTokenResponse, error) {
|
||||
user, err := getCurrentUser(ctx, s.Store)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
|
||||
}
|
||||
|
||||
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list access tokens: %v", err)
|
||||
}
|
||||
updatedUserAccessTokens := []*storepb.AccessTokensUserSetting_AccessToken{}
|
||||
for _, userAccessToken := range userAccessTokens {
|
||||
if userAccessToken.AccessToken == request.AccessToken {
|
||||
continue
|
||||
}
|
||||
updatedUserAccessTokens = append(updatedUserAccessTokens, userAccessToken)
|
||||
}
|
||||
if _, err := s.Store.UpsertUserSettingV1(ctx, &storepb.UserSetting{
|
||||
UserId: user.ID,
|
||||
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
||||
Value: &storepb.UserSetting_AccessTokens{
|
||||
AccessTokens: &storepb.AccessTokensUserSetting{
|
||||
AccessTokens: updatedUserAccessTokens,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to upsert user setting: %v", err)
|
||||
}
|
||||
|
||||
return &apiv2pb.DeleteUserAccessTokenResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) UpsertAccessTokenToStore(ctx context.Context, user *store.User, accessToken, description string) error {
|
||||
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to get user access tokens")
|
||||
}
|
||||
userAccessToken := storepb.AccessTokensUserSetting_AccessToken{
|
||||
AccessToken: accessToken,
|
||||
Description: description,
|
||||
}
|
||||
userAccessTokens = append(userAccessTokens, &userAccessToken)
|
||||
if _, err := s.Store.UpsertUserSettingV1(ctx, &storepb.UserSetting{
|
||||
UserId: user.ID,
|
||||
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
||||
Value: &storepb.UserSetting_AccessTokens{
|
||||
AccessTokens: &storepb.AccessTokensUserSetting{
|
||||
AccessTokens: userAccessTokens,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
return errors.Wrap(err, "failed to upsert user setting")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func convertUserFromStore(user *store.User) *apiv2pb.User {
|
||||
return &apiv2pb.User{
|
||||
Name: fmt.Sprintf("%s%s", UserNamePrefix, user.Username),
|
||||
Id: user.ID,
|
||||
RowStatus: convertRowStatusFromStore(user.RowStatus),
|
||||
CreateTime: timestamppb.New(time.Unix(user.CreatedTs, 0)),
|
||||
UpdateTime: timestamppb.New(time.Unix(user.UpdatedTs, 0)),
|
||||
Role: convertUserRoleFromStore(user.Role),
|
||||
Email: user.Email,
|
||||
Nickname: user.Nickname,
|
||||
AvatarUrl: user.AvatarURL,
|
||||
}
|
||||
}
|
||||
|
||||
func convertUserRoleFromStore(role store.Role) apiv2pb.User_Role {
|
||||
switch role {
|
||||
case store.RoleHost:
|
||||
return apiv2pb.User_HOST
|
||||
case store.RoleAdmin:
|
||||
return apiv2pb.User_ADMIN
|
||||
case store.RoleUser:
|
||||
return apiv2pb.User_USER
|
||||
default:
|
||||
return apiv2pb.User_ROLE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func convertUserRoleToStore(role apiv2pb.User_Role) store.Role {
|
||||
switch role {
|
||||
case apiv2pb.User_HOST:
|
||||
return store.RoleHost
|
||||
case apiv2pb.User_ADMIN:
|
||||
return store.RoleAdmin
|
||||
case apiv2pb.User_USER:
|
||||
return store.RoleUser
|
||||
default:
|
||||
return store.RoleUser
|
||||
}
|
||||
}
|
||||
126
api/v2/v2.go
Normal file
126
api/v2/v2.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||
"github.com/improbable-eng/grpc-web/go/grpcweb"
|
||||
"github.com/labstack/echo/v4"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/reflection"
|
||||
|
||||
apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
|
||||
"github.com/usememos/memos/server/profile"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
type APIV2Service struct {
|
||||
apiv2pb.UnimplementedSystemServiceServer
|
||||
apiv2pb.UnimplementedAuthServiceServer
|
||||
apiv2pb.UnimplementedUserServiceServer
|
||||
apiv2pb.UnimplementedMemoServiceServer
|
||||
apiv2pb.UnimplementedResourceServiceServer
|
||||
apiv2pb.UnimplementedTagServiceServer
|
||||
apiv2pb.UnimplementedInboxServiceServer
|
||||
apiv2pb.UnimplementedActivityServiceServer
|
||||
apiv2pb.UnimplementedWebhookServiceServer
|
||||
|
||||
Secret string
|
||||
Profile *profile.Profile
|
||||
Store *store.Store
|
||||
|
||||
grpcServer *grpc.Server
|
||||
grpcServerPort int
|
||||
}
|
||||
|
||||
func NewAPIV2Service(secret string, profile *profile.Profile, store *store.Store, grpcServerPort int) *APIV2Service {
|
||||
grpc.EnableTracing = true
|
||||
authProvider := NewGRPCAuthInterceptor(store, secret)
|
||||
grpcServer := grpc.NewServer(
|
||||
grpc.ChainUnaryInterceptor(
|
||||
authProvider.AuthenticationInterceptor,
|
||||
),
|
||||
)
|
||||
apiv2Service := &APIV2Service{
|
||||
Secret: secret,
|
||||
Profile: profile,
|
||||
Store: store,
|
||||
grpcServer: grpcServer,
|
||||
grpcServerPort: grpcServerPort,
|
||||
}
|
||||
|
||||
apiv2pb.RegisterSystemServiceServer(grpcServer, apiv2Service)
|
||||
apiv2pb.RegisterAuthServiceServer(grpcServer, apiv2Service)
|
||||
apiv2pb.RegisterUserServiceServer(grpcServer, apiv2Service)
|
||||
apiv2pb.RegisterMemoServiceServer(grpcServer, apiv2Service)
|
||||
apiv2pb.RegisterTagServiceServer(grpcServer, apiv2Service)
|
||||
apiv2pb.RegisterResourceServiceServer(grpcServer, apiv2Service)
|
||||
apiv2pb.RegisterInboxServiceServer(grpcServer, apiv2Service)
|
||||
apiv2pb.RegisterActivityServiceServer(grpcServer, apiv2Service)
|
||||
apiv2pb.RegisterWebhookServiceServer(grpcServer, apiv2Service)
|
||||
reflection.Register(grpcServer)
|
||||
|
||||
return apiv2Service
|
||||
}
|
||||
|
||||
func (s *APIV2Service) GetGRPCServer() *grpc.Server {
|
||||
return s.grpcServer
|
||||
}
|
||||
|
||||
// RegisterGateway registers the gRPC-Gateway with the given Echo instance.
|
||||
func (s *APIV2Service) RegisterGateway(ctx context.Context, e *echo.Echo) error {
|
||||
// Create a client connection to the gRPC Server we just started.
|
||||
// This is where the gRPC-Gateway proxies the requests.
|
||||
conn, err := grpc.DialContext(
|
||||
ctx,
|
||||
fmt.Sprintf(":%d", s.grpcServerPort),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gwMux := runtime.NewServeMux()
|
||||
if err := apiv2pb.RegisterSystemServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := apiv2pb.RegisterAuthServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := apiv2pb.RegisterUserServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := apiv2pb.RegisterMemoServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := apiv2pb.RegisterTagServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := apiv2pb.RegisterResourceServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := apiv2pb.RegisterInboxServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := apiv2pb.RegisterActivityServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := apiv2pb.RegisterWebhookServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
e.Any("/api/v2/*", echo.WrapHandler(gwMux))
|
||||
|
||||
// GRPC web proxy.
|
||||
options := []grpcweb.Option{
|
||||
grpcweb.WithCorsForRegisteredEndpointsOnly(false),
|
||||
grpcweb.WithOriginFunc(func(origin string) bool {
|
||||
return true
|
||||
}),
|
||||
}
|
||||
wrappedGrpc := grpcweb.WrapServer(s.grpcServer, options...)
|
||||
e.Any("/memos.api.v2.*", echo.WrapHandler(wrappedGrpc))
|
||||
|
||||
return nil
|
||||
}
|
||||
120
api/v2/webhook_service.go
Normal file
120
api/v2/webhook_service.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
func (s *APIV2Service) CreateWebhook(ctx context.Context, request *apiv2pb.CreateWebhookRequest) (*apiv2pb.CreateWebhookResponse, error) {
|
||||
currentUser, err := getCurrentUser(ctx, s.Store)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
|
||||
}
|
||||
|
||||
webhook, err := s.Store.CreateWebhook(ctx, &storepb.Webhook{
|
||||
CreatorId: currentUser.ID,
|
||||
Name: request.Name,
|
||||
Url: request.Url,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to create webhook, error: %+v", err)
|
||||
}
|
||||
return &apiv2pb.CreateWebhookResponse{
|
||||
Webhook: convertWebhookFromStore(webhook),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) ListWebhooks(ctx context.Context, request *apiv2pb.ListWebhooksRequest) (*apiv2pb.ListWebhooksResponse, error) {
|
||||
webhooks, err := s.Store.ListWebhooks(ctx, &store.FindWebhook{
|
||||
CreatorID: &request.CreatorId,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list webhooks, error: %+v", err)
|
||||
}
|
||||
|
||||
response := &apiv2pb.ListWebhooksResponse{
|
||||
Webhooks: []*apiv2pb.Webhook{},
|
||||
}
|
||||
for _, webhook := range webhooks {
|
||||
response.Webhooks = append(response.Webhooks, convertWebhookFromStore(webhook))
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) GetWebhook(ctx context.Context, request *apiv2pb.GetWebhookRequest) (*apiv2pb.GetWebhookResponse, error) {
|
||||
currentUser, err := getCurrentUser(ctx, s.Store)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
|
||||
}
|
||||
|
||||
webhook, err := s.Store.GetWebhooks(ctx, &store.FindWebhook{
|
||||
ID: &request.Id,
|
||||
CreatorID: ¤tUser.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get webhook, error: %+v", err)
|
||||
}
|
||||
if webhook == nil {
|
||||
return nil, status.Errorf(codes.NotFound, "webhook not found")
|
||||
}
|
||||
return &apiv2pb.GetWebhookResponse{
|
||||
Webhook: convertWebhookFromStore(webhook),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) UpdateWebhook(ctx context.Context, request *apiv2pb.UpdateWebhookRequest) (*apiv2pb.UpdateWebhookResponse, error) {
|
||||
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "update_mask is required")
|
||||
}
|
||||
|
||||
update := &store.UpdateWebhook{}
|
||||
for _, field := range request.UpdateMask.Paths {
|
||||
switch field {
|
||||
case "row_status":
|
||||
rowStatus := storepb.RowStatus(storepb.RowStatus_value[request.Webhook.RowStatus.String()])
|
||||
update.RowStatus = &rowStatus
|
||||
case "name":
|
||||
update.Name = &request.Webhook.Name
|
||||
case "url":
|
||||
update.URL = &request.Webhook.Url
|
||||
}
|
||||
}
|
||||
|
||||
webhook, err := s.Store.UpdateWebhook(ctx, update)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to update webhook, error: %+v", err)
|
||||
}
|
||||
return &apiv2pb.UpdateWebhookResponse{
|
||||
Webhook: convertWebhookFromStore(webhook),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) DeleteWebhook(ctx context.Context, request *apiv2pb.DeleteWebhookRequest) (*apiv2pb.DeleteWebhookResponse, error) {
|
||||
err := s.Store.DeleteWebhook(ctx, &store.DeleteWebhook{
|
||||
ID: request.Id,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to delete webhook, error: %+v", err)
|
||||
}
|
||||
return &apiv2pb.DeleteWebhookResponse{}, nil
|
||||
}
|
||||
|
||||
func convertWebhookFromStore(webhook *storepb.Webhook) *apiv2pb.Webhook {
|
||||
return &apiv2pb.Webhook{
|
||||
Id: webhook.Id,
|
||||
CreatedTime: timestamppb.New(time.Unix(webhook.CreatedTs, 0)),
|
||||
UpdatedTime: timestamppb.New(time.Unix(webhook.UpdatedTs, 0)),
|
||||
RowStatus: apiv2pb.RowStatus(webhook.RowStatus),
|
||||
CreatorId: webhook.CreatorId,
|
||||
Name: webhook.Name,
|
||||
Url: webhook.Url,
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/usememos/memos/server"
|
||||
"github.com/usememos/memos/server/profile"
|
||||
)
|
||||
|
||||
const (
|
||||
greetingBanner = `
|
||||
███╗ ███╗███████╗███╗ ███╗ ██████╗ ███████╗
|
||||
████╗ ████║██╔════╝████╗ ████║██╔═══██╗██╔════╝
|
||||
██╔████╔██║█████╗ ██╔████╔██║██║ ██║███████╗
|
||||
██║╚██╔╝██║██╔══╝ ██║╚██╔╝██║██║ ██║╚════██║
|
||||
██║ ╚═╝ ██║███████╗██║ ╚═╝ ██║╚██████╔╝███████║
|
||||
╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝
|
||||
`
|
||||
)
|
||||
|
||||
func run() error {
|
||||
ctx := context.Background()
|
||||
profile, err := profile.GetProfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
println("---")
|
||||
println("profile")
|
||||
println("mode:", profile.Mode)
|
||||
println("port:", profile.Port)
|
||||
println("dsn:", profile.DSN)
|
||||
println("version:", profile.Version)
|
||||
println("---")
|
||||
|
||||
serverInstance, err := server.NewServer(ctx, profile)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to start server")
|
||||
}
|
||||
|
||||
println(greetingBanner)
|
||||
fmt.Printf("Version %s has started at :%d\n", profile.Version, profile.Port)
|
||||
return serverInstance.Run(ctx)
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Printf("error: %+v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
386
cmd/copydb.go
Normal file
386
cmd/copydb.go
Normal file
@@ -0,0 +1,386 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
_profile "github.com/usememos/memos/server/profile"
|
||||
"github.com/usememos/memos/store"
|
||||
"github.com/usememos/memos/store/db"
|
||||
)
|
||||
|
||||
var (
|
||||
copydbCmdFlagFrom = "from"
|
||||
copydbCmd = &cobra.Command{
|
||||
Use: "copydb", // `copydb` is a shortened for 'copy database'
|
||||
Short: "Copy data between db drivers",
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
s, err := cmd.Flags().GetString(copydbCmdFlagFrom)
|
||||
if err != nil {
|
||||
println("fail to get from driver DSN")
|
||||
println(err)
|
||||
return
|
||||
}
|
||||
ss := strings.Split(s, "://")
|
||||
if len(ss) != 2 {
|
||||
println("fail to parse from driver DSN, should be like 'sqlite://memos_prod.db' or 'mysql://user:pass@tcp(host)/memos'")
|
||||
return
|
||||
}
|
||||
|
||||
fromProfile := &_profile.Profile{Driver: ss[0], DSN: ss[1]}
|
||||
|
||||
err = copydb(fromProfile, profile)
|
||||
if err != nil {
|
||||
fmt.Printf("fail to copydb: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
println("done")
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
copydbCmd.Flags().String(copydbCmdFlagFrom, "sqlite://memos_prod.db", "From driver DSN")
|
||||
|
||||
rootCmd.AddCommand(copydbCmd)
|
||||
}
|
||||
|
||||
func copydb(fromProfile, toProfile *_profile.Profile) error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
toDriver, err := db.NewDBDriver(toProfile)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "fail to create `to` driver")
|
||||
}
|
||||
|
||||
// Check if `to` driver has been created before
|
||||
if history, err := toDriver.FindMigrationHistoryList(ctx, nil); err != nil {
|
||||
return errors.New("fail to check migration history of `to` driver")
|
||||
} else if len(history) == 0 {
|
||||
return errors.New("migration history of `to` driver should not be empty")
|
||||
}
|
||||
|
||||
if err := toDriver.Migrate(ctx); err != nil {
|
||||
return errors.Wrap(err, "fail to migrate db")
|
||||
}
|
||||
|
||||
fromDriver, err := db.NewDBDriver(fromProfile)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "fail to create `from` driver")
|
||||
}
|
||||
|
||||
// Register here if any table is added
|
||||
copyMap := map[string]func(context.Context, store.Driver, store.Driver) error{
|
||||
"activity": copyActivity,
|
||||
"idp": copyIdp,
|
||||
"memo": copyMemo,
|
||||
"memo_organizer": copyMemoOrganizer,
|
||||
"memo_relation": copyMemoRelation,
|
||||
"resource": copyResource,
|
||||
"storage": copyStorage,
|
||||
"system_setting": copySystemSettings,
|
||||
"tag": copyTag,
|
||||
"user": copyUser,
|
||||
"user_setting": copyUserSettings,
|
||||
}
|
||||
|
||||
toDb := toDriver.GetDB()
|
||||
for table := range copyMap {
|
||||
println("Checking " + table + "...")
|
||||
var cnt int
|
||||
err := toDb.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+table).Scan(&cnt)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "fail to check '%s'", table)
|
||||
}
|
||||
if cnt > 0 && table != "system_setting" {
|
||||
return errors.Errorf("table '%s' is not empty", table)
|
||||
}
|
||||
}
|
||||
|
||||
for _, f := range copyMap {
|
||||
err = f(ctx, fromDriver, toDriver)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "fail to copy data")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyActivity(ctx context.Context, fromDriver, toDriver store.Driver) error {
|
||||
println("Copying Activity...")
|
||||
list, err := fromDriver.ListActivities(ctx, &store.FindActivity{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("\tTotal %d records\n", len(list))
|
||||
for _, item := range list {
|
||||
_, err := toDriver.CreateActivity(ctx, &store.Activity{
|
||||
ID: item.ID,
|
||||
CreatorID: item.CreatorID,
|
||||
CreatedTs: item.CreatedTs,
|
||||
Level: item.Level,
|
||||
Type: item.Type,
|
||||
Payload: item.Payload,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
println("\tDONE")
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyIdp(ctx context.Context, fromDriver, toDriver store.Driver) error {
|
||||
println("Copying IdentityProvider...")
|
||||
list, err := fromDriver.ListIdentityProviders(ctx, &store.FindIdentityProvider{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("\tTotal %d records\n", len(list))
|
||||
for _, item := range list {
|
||||
_, err := toDriver.CreateIdentityProvider(ctx, &store.IdentityProvider{
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
Type: item.Type,
|
||||
IdentifierFilter: item.IdentifierFilter,
|
||||
Config: item.Config,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
println("\tDONE")
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyMemo(ctx context.Context, fromDriver, toDriver store.Driver) error {
|
||||
println("Copying Memo...")
|
||||
list, err := fromDriver.ListMemos(ctx, &store.FindMemo{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("\tTotal %d records\n", len(list))
|
||||
for _, item := range list {
|
||||
_, err := toDriver.CreateMemo(ctx, &store.Memo{
|
||||
ID: item.ID,
|
||||
CreatorID: item.CreatorID,
|
||||
CreatedTs: item.CreatedTs,
|
||||
UpdatedTs: item.UpdatedTs,
|
||||
RowStatus: item.RowStatus,
|
||||
Content: item.Content,
|
||||
Visibility: item.Visibility,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
println("\tDONE")
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyMemoOrganizer(ctx context.Context, fromDriver, toDriver store.Driver) error {
|
||||
println("Copying MemoOrganizer...")
|
||||
list, err := fromDriver.ListMemoOrganizer(ctx, &store.FindMemoOrganizer{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("\tTotal %d records\n", len(list))
|
||||
for _, item := range list {
|
||||
_, err := toDriver.UpsertMemoOrganizer(ctx, &store.MemoOrganizer{
|
||||
MemoID: item.MemoID,
|
||||
UserID: item.UserID,
|
||||
Pinned: item.Pinned,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
println("\tDONE")
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyMemoRelation(ctx context.Context, fromDriver, toDriver store.Driver) error {
|
||||
println("Copying MemoRelation...")
|
||||
list, err := fromDriver.ListMemoRelations(ctx, &store.FindMemoRelation{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("\tTotal %d records\n", len(list))
|
||||
for _, item := range list {
|
||||
_, err := toDriver.UpsertMemoRelation(ctx, &store.MemoRelation{
|
||||
MemoID: item.MemoID,
|
||||
RelatedMemoID: item.RelatedMemoID,
|
||||
Type: item.Type,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
println("\tDONE")
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyResource(ctx context.Context, fromDriver, toDriver store.Driver) error {
|
||||
println("Copying Resource...")
|
||||
list, err := fromDriver.ListResources(ctx, &store.FindResource{GetBlob: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("\tTotal %d records\n", len(list))
|
||||
for _, item := range list {
|
||||
_, err := toDriver.CreateResource(ctx, &store.Resource{
|
||||
ID: item.ID,
|
||||
CreatorID: item.CreatorID,
|
||||
CreatedTs: item.CreatedTs,
|
||||
UpdatedTs: item.UpdatedTs,
|
||||
Filename: item.Filename,
|
||||
Blob: item.Blob,
|
||||
ExternalLink: item.ExternalLink,
|
||||
Type: item.Type,
|
||||
Size: item.Size,
|
||||
InternalPath: item.InternalPath,
|
||||
MemoID: item.MemoID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
println("\tDONE")
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyStorage(ctx context.Context, fromDriver, toDriver store.Driver) error {
|
||||
println("Copying Storage...")
|
||||
list, err := fromDriver.ListStorages(ctx, &store.FindStorage{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("\tTotal %d records\n", len(list))
|
||||
for _, item := range list {
|
||||
_, err := toDriver.CreateStorage(ctx, &store.Storage{
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
Type: item.Type,
|
||||
Config: item.Config,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
println("\tDONE")
|
||||
return nil
|
||||
}
|
||||
|
||||
func copySystemSettings(ctx context.Context, fromDriver, toDriver store.Driver) error {
|
||||
println("Copying SystemSettings...")
|
||||
list, err := fromDriver.ListSystemSettings(ctx, &store.FindSystemSetting{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("\tTotal %d records\n", len(list))
|
||||
for _, item := range list {
|
||||
_, err := toDriver.UpsertSystemSetting(ctx, &store.SystemSetting{
|
||||
Name: item.Name,
|
||||
Value: item.Value,
|
||||
Description: item.Description,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
println("\tDONE")
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyTag(ctx context.Context, fromDriver, toDriver store.Driver) error {
|
||||
println("Copying Tag...")
|
||||
list, err := fromDriver.ListTags(ctx, &store.FindTag{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("\tTotal %d records\n", len(list))
|
||||
for _, item := range list {
|
||||
_, err := toDriver.UpsertTag(ctx, &store.Tag{
|
||||
Name: item.Name,
|
||||
CreatorID: item.CreatorID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
println("\tDONE")
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyUser(ctx context.Context, fromDriver, toDriver store.Driver) error {
|
||||
println("Copying User...")
|
||||
list, err := fromDriver.ListUsers(ctx, &store.FindUser{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("\tTotal %d records\n", len(list))
|
||||
for _, item := range list {
|
||||
_, err := toDriver.CreateUser(ctx, &store.User{
|
||||
ID: item.ID,
|
||||
CreatedTs: item.CreatedTs,
|
||||
UpdatedTs: item.UpdatedTs,
|
||||
RowStatus: item.RowStatus,
|
||||
Username: item.Username,
|
||||
Role: item.Role,
|
||||
Email: item.Email,
|
||||
Nickname: item.Nickname,
|
||||
PasswordHash: item.PasswordHash,
|
||||
AvatarURL: item.AvatarURL,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
println("\tDONE")
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyUserSettings(ctx context.Context, fromDriver, toDriver store.Driver) error {
|
||||
println("Copying UserSettings...")
|
||||
list, err := fromDriver.ListUserSettings(ctx, &store.FindUserSetting{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("\tTotal %d records\n", len(list))
|
||||
for _, item := range list {
|
||||
_, err := toDriver.UpsertUserSetting(ctx, item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
println("\tDONE")
|
||||
return nil
|
||||
}
|
||||
188
cmd/memos.go
Normal file
188
cmd/memos.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/usememos/memos/internal/log"
|
||||
"github.com/usememos/memos/server"
|
||||
_profile "github.com/usememos/memos/server/profile"
|
||||
"github.com/usememos/memos/server/service/metric"
|
||||
"github.com/usememos/memos/store"
|
||||
"github.com/usememos/memos/store/db"
|
||||
)
|
||||
|
||||
const (
|
||||
greetingBanner = `
|
||||
███╗ ███╗███████╗███╗ ███╗ ██████╗ ███████╗
|
||||
████╗ ████║██╔════╝████╗ ████║██╔═══██╗██╔════╝
|
||||
██╔████╔██║█████╗ ██╔████╔██║██║ ██║███████╗
|
||||
██║╚██╔╝██║██╔══╝ ██║╚██╔╝██║██║ ██║╚════██║
|
||||
██║ ╚═╝ ██║███████╗██║ ╚═╝ ██║╚██████╔╝███████║
|
||||
╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝
|
||||
`
|
||||
)
|
||||
|
||||
var (
|
||||
profile *_profile.Profile
|
||||
mode string
|
||||
addr string
|
||||
port int
|
||||
data string
|
||||
driver string
|
||||
dsn string
|
||||
enableMetric bool
|
||||
|
||||
rootCmd = &cobra.Command{
|
||||
Use: "memos",
|
||||
Short: `An open-source, self-hosted memo hub with knowledge management and social networking.`,
|
||||
Run: func(_cmd *cobra.Command, _args []string) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
dbDriver, err := db.NewDBDriver(profile)
|
||||
if err != nil {
|
||||
cancel()
|
||||
log.Error("failed to create db driver", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if err := dbDriver.Migrate(ctx); err != nil {
|
||||
cancel()
|
||||
log.Error("failed to migrate db", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
store := store.New(dbDriver, profile)
|
||||
s, err := server.NewServer(ctx, profile, store)
|
||||
if err != nil {
|
||||
cancel()
|
||||
log.Error("failed to create server", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
if profile.Metric {
|
||||
// nolint
|
||||
metric.NewMetricClient(s.ID, *profile)
|
||||
}
|
||||
|
||||
c := make(chan os.Signal, 1)
|
||||
// Trigger graceful shutdown on SIGINT or SIGTERM.
|
||||
// The default signal sent by the `kill` command is SIGTERM,
|
||||
// which is taken as the graceful shutdown signal for many systems, eg., Kubernetes, Gunicorn.
|
||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
||||
go func() {
|
||||
sig := <-c
|
||||
log.Info(fmt.Sprintf("%s received.\n", sig.String()))
|
||||
s.Shutdown(ctx)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
printGreetings()
|
||||
|
||||
if err := s.Start(ctx); err != nil {
|
||||
if err != http.ErrServerClosed {
|
||||
log.Error("failed to start server", zap.Error(err))
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for CTRL-C.
|
||||
<-ctx.Done()
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func Execute() error {
|
||||
defer log.Sync()
|
||||
return rootCmd.Execute()
|
||||
}
|
||||
|
||||
func init() {
|
||||
cobra.OnInitialize(initConfig)
|
||||
|
||||
rootCmd.PersistentFlags().StringVarP(&mode, "mode", "m", "demo", `mode of server, can be "prod" or "dev" or "demo"`)
|
||||
rootCmd.PersistentFlags().StringVarP(&addr, "addr", "a", "", "address of server")
|
||||
rootCmd.PersistentFlags().IntVarP(&port, "port", "p", 8081, "port of server")
|
||||
rootCmd.PersistentFlags().StringVarP(&data, "data", "d", "", "data directory")
|
||||
rootCmd.PersistentFlags().StringVarP(&driver, "driver", "", "", "database driver")
|
||||
rootCmd.PersistentFlags().StringVarP(&dsn, "dsn", "", "", "database source name(aka. DSN)")
|
||||
rootCmd.PersistentFlags().BoolVarP(&enableMetric, "metric", "", true, "allow metric collection")
|
||||
|
||||
err := viper.BindPFlag("mode", rootCmd.PersistentFlags().Lookup("mode"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = viper.BindPFlag("addr", rootCmd.PersistentFlags().Lookup("addr"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = viper.BindPFlag("port", rootCmd.PersistentFlags().Lookup("port"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = viper.BindPFlag("data", rootCmd.PersistentFlags().Lookup("data"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = viper.BindPFlag("driver", rootCmd.PersistentFlags().Lookup("driver"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = viper.BindPFlag("dsn", rootCmd.PersistentFlags().Lookup("dsn"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = viper.BindPFlag("metric", rootCmd.PersistentFlags().Lookup("metric"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
viper.SetDefault("mode", "demo")
|
||||
viper.SetDefault("driver", "sqlite")
|
||||
viper.SetDefault("addr", "")
|
||||
viper.SetDefault("port", 8081)
|
||||
viper.SetDefault("metric", true)
|
||||
viper.SetEnvPrefix("memos")
|
||||
}
|
||||
|
||||
func initConfig() {
|
||||
viper.AutomaticEnv()
|
||||
var err error
|
||||
profile, err = _profile.GetProfile()
|
||||
if err != nil {
|
||||
fmt.Printf("failed to get profile, error: %+v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
println("---")
|
||||
println("Server profile")
|
||||
println("data:", profile.Data)
|
||||
println("dsn:", profile.DSN)
|
||||
println("addr:", profile.Addr)
|
||||
println("port:", profile.Port)
|
||||
println("mode:", profile.Mode)
|
||||
println("driver:", profile.Driver)
|
||||
println("version:", profile.Version)
|
||||
println("metric:", profile.Metric)
|
||||
println("---")
|
||||
}
|
||||
|
||||
func printGreetings() {
|
||||
print(greetingBanner)
|
||||
if len(profile.Addr) == 0 {
|
||||
fmt.Printf("Version %s has been started on port %d\n", profile.Version, profile.Port)
|
||||
} else {
|
||||
fmt.Printf("Version %s has been started on address '%s' and port %d\n", profile.Version, profile.Addr, profile.Port)
|
||||
}
|
||||
println("---")
|
||||
println("See more in:")
|
||||
fmt.Printf("👉Website: %s\n", "https://usememos.com")
|
||||
fmt.Printf("👉GitHub: %s\n", "https://github.com/usememos/memos")
|
||||
println("---")
|
||||
}
|
||||
99
cmd/mvrss.go
Normal file
99
cmd/mvrss.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/usememos/memos/store"
|
||||
"github.com/usememos/memos/store/db/sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
mvrssCmdFlagFrom = "from"
|
||||
mvrssCmdFlagTo = "to"
|
||||
mvrssCmd = &cobra.Command{
|
||||
Use: "mvrss", // `mvrss` is a shortened for 'means move resource'
|
||||
Short: "Move resource between storage",
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
from, err := cmd.Flags().GetString(mvrssCmdFlagFrom)
|
||||
if err != nil {
|
||||
fmt.Printf("failed to get from storage, error: %+v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
to, err := cmd.Flags().GetString(mvrssCmdFlagTo)
|
||||
if err != nil {
|
||||
fmt.Printf("failed to get to storage, error: %+v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if from != "local" || to != "db" {
|
||||
fmt.Printf("only local=>db be supported currently\n")
|
||||
return
|
||||
}
|
||||
|
||||
driver, err := sqlite.NewDB(profile)
|
||||
if err != nil {
|
||||
fmt.Printf("failed to create db driver, error: %+v\n", err)
|
||||
return
|
||||
}
|
||||
if err := driver.Migrate(ctx); err != nil {
|
||||
fmt.Printf("failed to migrate db, error: %+v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
s := store.New(driver, profile)
|
||||
resources, err := s.ListResources(ctx, &store.FindResource{})
|
||||
if err != nil {
|
||||
fmt.Printf("failed to list resources, error: %+v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
var emptyString string
|
||||
for _, res := range resources {
|
||||
if res.InternalPath == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
buf, err := os.ReadFile(res.InternalPath)
|
||||
if err != nil {
|
||||
fmt.Printf("Resource %5d failed to read file: %s\n", res.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(buf) != int(res.Size) {
|
||||
fmt.Printf("Resource %5d size of file %d != %d\n", res.ID, len(buf), res.Size)
|
||||
continue
|
||||
}
|
||||
|
||||
update := store.UpdateResource{
|
||||
ID: res.ID,
|
||||
Blob: buf,
|
||||
InternalPath: &emptyString,
|
||||
}
|
||||
_, err = s.UpdateResource(ctx, &update)
|
||||
if err != nil {
|
||||
fmt.Printf("Resource %5d failed to update: %s\n", res.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("Resource %5d copy %12d bytes from %s\n", res.ID, len(buf), res.InternalPath)
|
||||
}
|
||||
println("done")
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
mvrssCmd.Flags().String(mvrssCmdFlagFrom, "local", "From storage")
|
||||
mvrssCmd.Flags().String(mvrssCmdFlagTo, "db", "To Storage")
|
||||
|
||||
rootCmd.AddCommand(mvrssCmd)
|
||||
}
|
||||
142
cmd/setup.go
Normal file
142
cmd/setup.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/usememos/memos/internal/util"
|
||||
"github.com/usememos/memos/store"
|
||||
"github.com/usememos/memos/store/db/sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
setupCmdFlagHostUsername = "host-username"
|
||||
setupCmdFlagHostPassword = "host-password"
|
||||
setupCmd = &cobra.Command{
|
||||
Use: "setup",
|
||||
Short: "Make initial setup for memos",
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
hostUsername, err := cmd.Flags().GetString(setupCmdFlagHostUsername)
|
||||
if err != nil {
|
||||
fmt.Printf("failed to get owner username, error: %+v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
hostPassword, err := cmd.Flags().GetString(setupCmdFlagHostPassword)
|
||||
if err != nil {
|
||||
fmt.Printf("failed to get owner password, error: %+v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
driver, err := sqlite.NewDB(profile)
|
||||
if err != nil {
|
||||
fmt.Printf("failed to create db driver, error: %+v\n", err)
|
||||
return
|
||||
}
|
||||
if err := driver.Migrate(ctx); err != nil {
|
||||
fmt.Printf("failed to migrate db, error: %+v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
store := store.New(driver, profile)
|
||||
if err := ExecuteSetup(ctx, store, hostUsername, hostPassword); err != nil {
|
||||
fmt.Printf("failed to setup, error: %+v\n", err)
|
||||
return
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
setupCmd.Flags().String(setupCmdFlagHostUsername, "", "Owner username")
|
||||
setupCmd.Flags().String(setupCmdFlagHostPassword, "", "Owner password")
|
||||
|
||||
rootCmd.AddCommand(setupCmd)
|
||||
}
|
||||
|
||||
func ExecuteSetup(ctx context.Context, store *store.Store, hostUsername, hostPassword string) error {
|
||||
s := setupService{store: store}
|
||||
return s.Setup(ctx, hostUsername, hostPassword)
|
||||
}
|
||||
|
||||
type setupService struct {
|
||||
store *store.Store
|
||||
}
|
||||
|
||||
func (s setupService) Setup(ctx context.Context, hostUsername, hostPassword string) error {
|
||||
if err := s.makeSureHostUserNotExists(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.createUser(ctx, hostUsername, hostPassword); err != nil {
|
||||
return errors.Wrap(err, "create user")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s setupService) makeSureHostUserNotExists(ctx context.Context) error {
|
||||
hostUserType := store.RoleHost
|
||||
existedHostUsers, err := s.store.ListUsers(ctx, &store.FindUser{Role: &hostUserType})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "find user list")
|
||||
}
|
||||
|
||||
if len(existedHostUsers) != 0 {
|
||||
return errors.New("host user already exists")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s setupService) createUser(ctx context.Context, hostUsername, hostPassword string) error {
|
||||
userCreate := &store.User{
|
||||
Username: hostUsername,
|
||||
// The new signup user should be normal user by default.
|
||||
Role: store.RoleHost,
|
||||
Nickname: hostUsername,
|
||||
}
|
||||
|
||||
if len(userCreate.Username) < 3 {
|
||||
return errors.New("username is too short, minimum length is 3")
|
||||
}
|
||||
if len(userCreate.Username) > 32 {
|
||||
return errors.New("username is too long, maximum length is 32")
|
||||
}
|
||||
if len(hostPassword) < 3 {
|
||||
return errors.New("password is too short, minimum length is 3")
|
||||
}
|
||||
if len(hostPassword) > 512 {
|
||||
return errors.New("password is too long, maximum length is 512")
|
||||
}
|
||||
if len(userCreate.Nickname) > 64 {
|
||||
return errors.New("nickname is too long, maximum length is 64")
|
||||
}
|
||||
if userCreate.Email != "" {
|
||||
if len(userCreate.Email) > 256 {
|
||||
return errors.New("email is too long, maximum length is 256")
|
||||
}
|
||||
if !util.ValidateEmail(userCreate.Email) {
|
||||
return errors.New("invalid email format")
|
||||
}
|
||||
}
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(hostPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to hash password")
|
||||
}
|
||||
|
||||
userCreate.PasswordHash = string(passwordHash)
|
||||
if _, err := s.store.CreateUser(ctx, userCreate); err != nil {
|
||||
return errors.Wrap(err, "failed to create user")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Code is the error code.
|
||||
type Code int
|
||||
|
||||
// Application error codes.
|
||||
const (
|
||||
// 0 ~ 99 general error.
|
||||
Ok Code = 0
|
||||
Internal Code = 1
|
||||
NotAuthorized Code = 2
|
||||
Invalid Code = 3
|
||||
NotFound Code = 4
|
||||
Conflict Code = 5
|
||||
NotImplemented Code = 6
|
||||
)
|
||||
|
||||
// Error represents an application-specific error. Application errors can be
|
||||
// unwrapped by the caller to extract out the code & message.
|
||||
//
|
||||
// Any non-application error (such as a disk error) should be reported as an
|
||||
// Internal error and the human user should only see "Internal error" as the
|
||||
// message. These low-level internal error details should only be logged and
|
||||
// reported to the operator of the application (not the end user).
|
||||
type Error struct {
|
||||
// Machine-readable error code.
|
||||
Code Code
|
||||
|
||||
// Embedded error.
|
||||
Err error
|
||||
}
|
||||
|
||||
// Error implements the error interface. Not used by the application otherwise.
|
||||
func (e *Error) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
// ErrorCode unwraps an application error and returns its code.
|
||||
// Non-application errors always return EINTERNAL.
|
||||
func ErrorCode(err error) Code {
|
||||
var e *Error
|
||||
if err == nil {
|
||||
return Ok
|
||||
} else if errors.As(err, &e) {
|
||||
return e.Code
|
||||
}
|
||||
return Internal
|
||||
}
|
||||
|
||||
// ErrorMessage unwraps an application error and returns its message.
|
||||
// Non-application errors always return "Internal error".
|
||||
func ErrorMessage(err error) string {
|
||||
var e *Error
|
||||
if err == nil {
|
||||
return ""
|
||||
} else if errors.As(err, &e) {
|
||||
return e.Err.Error()
|
||||
}
|
||||
return "Internal error."
|
||||
}
|
||||
|
||||
// Errorf is a helper function to return an Error with a given code and error.
|
||||
func Errorf(code Code, err error) *Error {
|
||||
return &Error{
|
||||
Code: code,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// HasPrefixes returns true if the string s has any of the given prefixes.
|
||||
func HasPrefixes(src string, prefixes ...string) bool {
|
||||
for _, prefix := range prefixes {
|
||||
if strings.HasPrefix(src, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidateEmail validates the email.
|
||||
func ValidateEmail(email string) bool {
|
||||
if _, err := mail.ParseAddress(email); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func GenUUID() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
func Min(x, y int) int {
|
||||
if x < y {
|
||||
return x
|
||||
}
|
||||
return y
|
||||
}
|
||||
70
docker-compose.dev.yaml
Normal file
70
docker-compose.dev.yaml
Normal file
@@ -0,0 +1,70 @@
|
||||
services:
|
||||
db:
|
||||
image: mysql
|
||||
volumes:
|
||||
- ./.air/mysql:/var/lib/mysql
|
||||
api:
|
||||
image: cosmtrek/air
|
||||
working_dir: /work
|
||||
command: ["-c", "./scripts/.air.toml"]
|
||||
environment:
|
||||
- "MEMOS_DSN=root@tcp(db)/memos"
|
||||
- "MEMOS_DRIVER=mysql"
|
||||
volumes:
|
||||
- .:/work/
|
||||
- .air/go-build:/root/.cache/go-build
|
||||
- $HOME/go/pkg/:/go/pkg/ # Cache for go mod shared with the host
|
||||
web:
|
||||
image: node:18-alpine
|
||||
working_dir: /work
|
||||
depends_on: ["api"]
|
||||
ports: ["3001:3001"]
|
||||
environment: ["DEV_PROXY_SERVER=http://api:8081/"]
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command: ["corepack enable && pnpm install && pnpm dev"]
|
||||
volumes:
|
||||
- ./web:/work
|
||||
- ./.air/node_modules/:/work/node_modules/ # Cache for Node Modules
|
||||
|
||||
# Services below are used for developers to run once
|
||||
#
|
||||
# You can just run `docker compose run --rm SERVICE_NAME` to use
|
||||
# For example:
|
||||
# To regenerate typescript code of gRPC proto
|
||||
# Just run `docker compose run --rm buf`
|
||||
#
|
||||
# All of theses services belongs to profile 'tools'
|
||||
# This will prevent to launch by normally `docker compose up` unexpectly
|
||||
|
||||
# Generate typescript code of gRPC proto
|
||||
buf:
|
||||
profiles: ["tools"]
|
||||
image: bufbuild/buf
|
||||
working_dir: /work/proto
|
||||
command: generate
|
||||
volumes:
|
||||
- ./proto:/work/proto
|
||||
- ./web/src/types/:/work/web/src/types/
|
||||
|
||||
# Do golang static code check before create PR
|
||||
golangci-lint:
|
||||
profiles: ["tools"]
|
||||
image: golangci/golangci-lint:v1.54.2
|
||||
working_dir: /work/
|
||||
entrypoint: golangci-lint
|
||||
command: run -v
|
||||
volumes:
|
||||
- $HOME/go/pkg/:/go/pkg/ # Cache for go mod shared with the host
|
||||
- .air/go-build:/root/.cache/go-build
|
||||
- .:/work/
|
||||
|
||||
# run npm
|
||||
npm:
|
||||
profiles: ["tools"]
|
||||
image: node:18-alpine
|
||||
working_dir: /work
|
||||
environment: ["NPM_CONFIG_UPDATE_NOTIFIER=false"]
|
||||
entrypoint: "npm"
|
||||
volumes:
|
||||
- ./web:/work
|
||||
- ./.air/node_modules/:/work/node_modules/
|
||||
@@ -1,18 +0,0 @@
|
||||
version: "3.0"
|
||||
|
||||
# uffizzi integration
|
||||
x-uffizzi:
|
||||
ingress:
|
||||
service: memos
|
||||
port: 5230
|
||||
|
||||
services:
|
||||
memos:
|
||||
image: "${MEMOS_IMAGE}"
|
||||
volumes:
|
||||
- memos_volume:/var/opt/memos
|
||||
command: ["--mode", "dev"]
|
||||
|
||||
volumes:
|
||||
memos_volume:
|
||||
|
||||
1607
docs/api/v1.md
Normal file
1607
docs/api/v1.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,133 +0,0 @@
|
||||
# A Beginner's Guide to Deploying Memos on Render.com
|
||||
|
||||
written by [AJ](https://memos.ajstephens.website/) (also a noob)
|
||||
|
||||
<img height="64px" src="https://raw.githubusercontent.com/usememos/memos/main/resources/logo-full.webp" alt="✍️ memos" />
|
||||
|
||||
[Live Demo](https://demo.usememos.com) • [Official Website](https://usememos.com) • [Source Code](https://github.com/usememos/memos)
|
||||
|
||||
## Who is this guide for?
|
||||
|
||||
Someone who...
|
||||
|
||||
- doesn't have much experience with self hosting
|
||||
- has a minimal understanding of docker
|
||||
|
||||
Someone who wants...
|
||||
|
||||
- to use memos
|
||||
- to support the memos project
|
||||
- a cost effective and simple way to host it on the cloud with reliablity and persistance
|
||||
- to share memos with friends
|
||||
|
||||
## Requirements
|
||||
|
||||
- Can follow instructions
|
||||
- Have 7ish USD a month on a debit/credit card
|
||||
|
||||
## Guide
|
||||
|
||||
Create an account at [Render](https://dashboard.render.com/register)
|
||||

|
||||
|
||||
1. Go to your dashboard
|
||||
|
||||
[https://dashboard.render.com/](https://dashboard.render.com/)
|
||||
|
||||
2. Select New Web Service
|
||||
|
||||

|
||||
|
||||
3. Scroll down to "Public Git repository"
|
||||
|
||||
4. Paste in the link for the public git repository for memos (https://github.com/usememos/memos) and press continue
|
||||
|
||||

|
||||
|
||||
5. Render will pre-fill most of the fields but you will need to create a unique name for your web service
|
||||
|
||||
6. Adjust region if you want to
|
||||
|
||||
7. Don't touch the "branch", "root directory", and "environment" fields
|
||||
|
||||

|
||||
|
||||
8. Click "enter your payment information" and do so
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
9. Select the starter plan ($7 a month - a requirement for persistant data - render's free instances spin down when inactive and lose all data)
|
||||
|
||||
10. Click "Create Web Service"
|
||||
|
||||

|
||||
|
||||
11. Wait patiently while the _magic_ happens 🤷♂️
|
||||
|
||||

|
||||
|
||||
12. After some time (~ 6 min for me) the build will finish and you will see the web service is live
|
||||
|
||||

|
||||
|
||||
13. Now it's time to add the disk so your data won't dissappear when the webservice redeploys (redeploys happen automatically when the public repo is updated)
|
||||
|
||||
14. Select the "Disks" tab on the left menu and then click "Add Disk"
|
||||
|
||||

|
||||
|
||||
15. Name your disk (can be whatever)
|
||||
|
||||
16. Set the "Mount Path" to `/var/opt/memos`
|
||||
|
||||
17. Set the disk size (default is 10GB but 1GB is plenty and can be increased at any time)
|
||||
|
||||
18. Click "Save"
|
||||
|
||||

|
||||
|
||||
19. Wait...again...while the webservice redeploys with the persistant disk
|
||||
|
||||

|
||||
|
||||
20. aaaand....we're back online!
|
||||
|
||||

|
||||
|
||||
21. Time to test! We're going to make sure everything is working correctly.
|
||||
|
||||
22. Click the link in the top left, it should look like `https://the-name-you-chose.onrender.com` - this is your self hosted memos link!
|
||||
|
||||

|
||||
|
||||
23. Create a Username and Password (remember these) then click "Sign up as Host"
|
||||
|
||||

|
||||
|
||||
24. Create a test memo then click save
|
||||
|
||||

|
||||
|
||||
25. Sign out of your self-hosted memos
|
||||
|
||||

|
||||
|
||||
26. Return to your Render dashboard, click the "Manual Deploy" dropdown button and click "Deploy latest commit" and wait until the webservice is live again (This is to test that your data is persistant)
|
||||
|
||||

|
||||
|
||||
27. Once the webservice is live go back to your self-hosted memos page and sign in! (If your memos screen looks different then something went wrong)
|
||||
|
||||
28. Once you're logged in, verify your test memo is still there after the redeploy
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 🎉Celebrate!🎉
|
||||
|
||||
You did it! Enjoy using memos!
|
||||
|
||||
Want to learn more or need more guidance? Join the community on [telegram](https://t.me/+-_tNF1k70UU4ZTc9) and [discord](https://discord.gg/tfPJa4UmAv).
|
||||
90
docs/development-windows.md
Normal file
90
docs/development-windows.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Development in Windows
|
||||
|
||||
Memos is built with a curated tech stack. It is optimized for developer experience and is very easy to start working on the code:
|
||||
|
||||
1. It has no external dependency.
|
||||
2. It requires zero config.
|
||||
3. 1 command to start backend and 1 command to start frontend, both with live reload support.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Frontend | Backend |
|
||||
| ---------------------------------------- | --------------------------------- |
|
||||
| [React](https://react.dev/) | [Go](https://go.dev/) |
|
||||
| [Tailwind CSS](https://tailwindcss.com/) | [SQLite](https://www.sqlite.org/) |
|
||||
| [Vite](https://vitejs.dev/) | |
|
||||
| [pnpm](https://pnpm.io/) | |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Go](https://golang.org/doc/install)
|
||||
- [Air](https://github.com/cosmtrek/air#installation) for backend live reload
|
||||
- [Node.js](https://nodejs.org/)
|
||||
- [pnpm](https://pnpm.io/installation)
|
||||
|
||||
## Steps
|
||||
|
||||
(Using PowerShell)
|
||||
|
||||
1. pull source code
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/usememos/memos
|
||||
# or
|
||||
gh repo clone usememos/memos
|
||||
```
|
||||
|
||||
2. cd into the project root directory
|
||||
|
||||
```powershell
|
||||
cd memos
|
||||
```
|
||||
|
||||
3. start backend using air (with live reload)
|
||||
|
||||
```powershell
|
||||
air -c .\scripts\.air-windows.toml
|
||||
```
|
||||
|
||||
4. start frontend dev server
|
||||
|
||||
```powershell
|
||||
cd web; pnpm i; pnpm dev
|
||||
```
|
||||
|
||||
Memos should now be running at [http://localhost:3001](http://localhost:3001) and changing either frontend or backend code would trigger live reload.
|
||||
|
||||
## Building
|
||||
|
||||
Frontend must be built before backend. The built frontend must be placed in the backend ./server/dist directory. Otherwise, you will get a "No frontend embeded" error.
|
||||
|
||||
### Frontend
|
||||
|
||||
```powershell
|
||||
Move-Item "./server/dist" "./server/dist.bak"
|
||||
cd web; pnpm i --frozen-lockfile; pnpm build; cd ..;
|
||||
Move-Item "./web/dist" "./server/" -Force
|
||||
```
|
||||
|
||||
### Backend
|
||||
|
||||
```powershell
|
||||
go build -o ./build/memos.exe ./main.go
|
||||
```
|
||||
|
||||
## ❕ Notes
|
||||
|
||||
- Start development servers easier by running the provided `start.ps1` script.
|
||||
This will start both backend and frontend in detached PowerShell windows:
|
||||
|
||||
```powershell
|
||||
.\scripts\start.ps1
|
||||
```
|
||||
|
||||
- Produce a local build easier using the provided `build.ps1` script to build both frontend and backend:
|
||||
|
||||
```powershell
|
||||
.\scripts\build.ps1
|
||||
```
|
||||
|
||||
This will produce a memos.exe file in the ./build directory.
|
||||
@@ -6,35 +6,37 @@ Memos is built with a curated tech stack. It is optimized for developer experien
|
||||
2. It requires zero config.
|
||||
3. 1 command to start backend and 1 command to start frontend, both with live reload support.
|
||||
|
||||
## Tech Stack
|
||||
|
||||

|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Go](https://golang.org/doc/install)
|
||||
- [Air](https://github.com/cosmtrek/air#installation) for backend live reload
|
||||
- [Node.js](https://nodejs.org/)
|
||||
- [yarn](https://yarnpkg.com/getting-started/install)
|
||||
- [pnpm](https://pnpm.io/installation)
|
||||
|
||||
## Steps
|
||||
|
||||
1. pull source code
|
||||
1. Pull the source code
|
||||
|
||||
```bash
|
||||
git clone https://github.com/usememos/memos
|
||||
```
|
||||
|
||||
2. start backend using air(with live reload)
|
||||
2. Start backend server with `air`(with live reload)
|
||||
|
||||
```bash
|
||||
air -c scripts/.air.toml
|
||||
```
|
||||
|
||||
3. start frontend dev server
|
||||
3. Install frontend dependencies and generate TypeScript code from protobuf
|
||||
|
||||
```
|
||||
cd web && pnpm i && pnpm type-gen
|
||||
```
|
||||
|
||||
4. Start the dev server of frontend
|
||||
|
||||
```bash
|
||||
cd web && yarn && yarn dev
|
||||
cd web && pnpm dev
|
||||
```
|
||||
|
||||
Memos should now be running at [http://localhost:3001](http://localhost:3001) and change either frontend or backend code would trigger live reload.
|
||||
|
||||
113
docs/documenting-the-api.md
Normal file
113
docs/documenting-the-api.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Documenting the API
|
||||
|
||||
## Principles
|
||||
|
||||
- The documentation is generated by [swaggo/swag](https://github.com/swaggo/swag) from comments in the API code.
|
||||
|
||||
- Documentation is written using [Declarative Comments Format](https://github.com/swaggo/swag#declarative-comments-format).
|
||||
|
||||
- The documentation is generated in the `./api/v1` folder as `docs.go`.
|
||||
|
||||
- [echo-swagger](https://github.com/swaggo/echo-swagger) is used to integrate with Echo framework and serve the documentation with [Swagger-UI](https://swagger.io/tools/swagger-ui/) at `http://memos.host:5230/api/index.html`
|
||||
|
||||
## Updating the documentation
|
||||
|
||||
1. Update or add API-related comments in the code. Make sure to follow the [Declarative Comments Format](https://github.com/swaggo/swag#declarative-comments-format):
|
||||
|
||||
```go
|
||||
// signIn godoc
|
||||
//
|
||||
// @Summary Sign-in to memos.
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body SignIn true "Sign-in object"
|
||||
// @Success 200 {object} store.User "User information"
|
||||
// @Failure 400 {object} nil "Malformatted signin request"
|
||||
// @Failure 401 {object} nil "Password login is deactivated | Incorrect login credentials, please try again"
|
||||
// @Failure 403 {object} nil "User has been archived with username {username}"
|
||||
// @Failure 500 {object} nil "Failed to find system setting | Failed to unmarshal system setting | Incorrect login credentials, please try again | Failed to generate tokens | Failed to create activity"
|
||||
// @Router /api/v1/auth/signin [POST]
|
||||
func (s *APIV1Service) signIn(c echo.Context) error {
|
||||
...
|
||||
```
|
||||
|
||||
> Sample from [api/v1/auth.go](https://github.com/usememos/memos/tree/main/api/v1/auth.go)
|
||||
> You can check existing comments at [api/v1](https://github.com/usememos/memos/tree/main/api/v1)
|
||||
|
||||
2. Run one of the following provided scripts:
|
||||
|
||||
- Linux: `./scripts/gen-api-v1-docs.sh` (remember to `chmod +x` the script first)
|
||||
- Windows: `./scripts/gen-api-v1-docs.ps1`
|
||||
|
||||
> The scripts will install swag if needed (via go install), then run `swag fmt` and `swag init` commands.
|
||||
|
||||
3. That's it! The documentation is updated. You can check it at `http://memos.host:5230/api/index.html`
|
||||
|
||||
### Extra tips
|
||||
|
||||
- If you reference a custom Go struct from outside the API file, use a relative definition, like `store.IdentityProvider`. This works because `./` is passed to swag at `--dir` argument. If swag can't resolve the reference, it will fail.
|
||||
|
||||
- If the API grows or you need to reference some type from another location, remember to update ./scripts/gen-api-v1-docs.cfg file with the new paths.
|
||||
|
||||
- It's possible to list multiple errors for the same code using enum-like structs, that will show a proper, spec-conformant model with all entries at Swagger-UI. The drawback is that this approach requires a major refactoring and will add a lot of boilerplate code, as there are inconsistencies between API methods error responses.
|
||||
|
||||
```go
|
||||
type signInInternalServerError string
|
||||
|
||||
const signInErrorFailedToFindSystemSetting signInInternalServerError = "Failed to find system setting"
|
||||
const signInErrorFailedToUnmarshalSystemSetting signInInternalServerError = "Failed to unmarshal system setting"
|
||||
const signInErrorIncorrectLoginCredentials signInInternalServerError = "Incorrect login credentials, please try again"
|
||||
const signInErrorFailedToGenerateTokens signInInternalServerError = "Failed to generate tokens"
|
||||
const signInErrorFailedToCreateActivity signInInternalServerError = "Failed to create activity"
|
||||
|
||||
type signInUnauthorized string
|
||||
|
||||
const signInErrorPasswordLoginDeactivated signInUnauthorized = "Password login is deactivated"
|
||||
const signInErrorIncorrectCredentials signInUnauthorized = "Incorrect login credentials, please try again"
|
||||
|
||||
// signIn godoc
|
||||
//
|
||||
// @Summary Sign-in to memos.
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body SignIn true "Sign-in object"
|
||||
// @Success 200 {object} store.User "User information"
|
||||
// @Failure 400 {object} nil "Malformatted signin request"
|
||||
// @Failure 401 {object} signInUnauthorized
|
||||
// @Failure 403 {object} nil "User has been archived with username {username}"
|
||||
// @Failure 500 {object} signInInternalServerError
|
||||
// @Router /api/v1/auth/signin [POST]
|
||||
func (s *APIV1Service) signIn(c echo.Context) error {
|
||||
...
|
||||
```
|
||||
|
||||
### Step-by-step (no scripts)
|
||||
|
||||
#### Required tools
|
||||
|
||||
```bash
|
||||
# Swag v1.8.12 or newer
|
||||
# Also updates swag if needed
|
||||
$ go install github.com/swaggo/swag/cmd/swag@latest
|
||||
```
|
||||
|
||||
If `$HOME/go/bin` is not in your `PATH`, you can call `swag` directly at `$HOME/go/bin/swag`.
|
||||
|
||||
#### Generate the documentation
|
||||
|
||||
1. Run `swag fmt` to format the comments
|
||||
|
||||
```bash
|
||||
swag fmt --dir ./api/v1 && go fmt
|
||||
```
|
||||
|
||||
2. Run `swag init` to generate the documentation
|
||||
|
||||
```bash
|
||||
cd <project-root>
|
||||
swag init --output ./api/v1 --generalInfo ./api/v1/v1.go --dir ./,./api/v1
|
||||
```
|
||||
|
||||
> If the API gets a new version, you'll need to add the file system path to swag's `--dir` parameter.
|
||||
98
docs/windows-service.md
Normal file
98
docs/windows-service.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# Installing memos as a service on Windows
|
||||
|
||||
While memos first-class support is for Docker, you may also install memos as a Windows service. It will run under SYSTEM account and start automatically at system boot.
|
||||
|
||||
❗ All service management methods requires admin privileges. Use [gsudo](https://gerardog.github.io/gsudo/docs/install), or open a new PowerShell terminal as admin:
|
||||
|
||||
```powershell
|
||||
Start-Process powershell -Verb RunAs
|
||||
```
|
||||
|
||||
## Choose one of the following methods
|
||||
|
||||
### 1. Using [NSSM](https://nssm.cc/download)
|
||||
|
||||
NSSM is a lightweight service wrapper.
|
||||
|
||||
You may put `nssm.exe` in the same directory as `memos.exe`, or add its directory to your system PATH. Prefer the latest 64-bit version of `nssm.exe`.
|
||||
|
||||
```powershell
|
||||
# Install memos as a service
|
||||
nssm install memos "C:\path\to\memos.exe" --mode prod --port 5230
|
||||
|
||||
# Delay auto start
|
||||
nssm set memos DisplayName "memos service"
|
||||
|
||||
# Configure extra service parameters
|
||||
nssm set memos Description "A lightweight, self-hosted memo hub. https://usememos.com/"
|
||||
|
||||
# Delay auto start
|
||||
nssm set memos Start SERVICE_DELAYED_AUTO_START
|
||||
|
||||
# Edit service using NSSM GUI
|
||||
nssm edit memos
|
||||
|
||||
# Start the service
|
||||
nssm start memos
|
||||
|
||||
# Remove the service, if ever needed
|
||||
nssm remove memos confirm
|
||||
```
|
||||
|
||||
### 2. Using [WinSW](https://github.com/winsw/winsw)
|
||||
|
||||
Find the latest release tag and download the asset `WinSW-net46x.exe`. Then, put it in the same directory as `memos.exe` and rename it to `memos-service.exe`.
|
||||
|
||||
Now, in the same directory, create the service configuration file `memos-service.xml`:
|
||||
|
||||
```xml
|
||||
<service>
|
||||
<id>memos</id>
|
||||
<name>memos service</name>
|
||||
<description>A lightweight, self-hosted memo hub. https://usememos.com/</description>
|
||||
<onfailure action="restart" delay="10 sec"/>
|
||||
<executable>%BASE%\memos.exe</executable>
|
||||
<arguments>--mode prod --port 5230</arguments>
|
||||
<delayedAutoStart>true</delayedAutoStart>
|
||||
<log mode="none" />
|
||||
</service>
|
||||
```
|
||||
|
||||
Then, install the service:
|
||||
|
||||
```powershell
|
||||
# Install the service
|
||||
.\memos-service.exe install
|
||||
|
||||
# Start the service
|
||||
.\memos-service.exe start
|
||||
|
||||
# Remove the service, if ever needed
|
||||
.\memos-service.exe uninstall
|
||||
```
|
||||
|
||||
### Manage the service
|
||||
|
||||
You may use the `net` command to manage the service:
|
||||
|
||||
```powershell
|
||||
net start memos
|
||||
net stop memos
|
||||
```
|
||||
|
||||
Also, by using one of the provided methods, the service will appear in the Windows Services Manager `services.msc`.
|
||||
|
||||
## Notes
|
||||
|
||||
- On Windows, memos store its data in the following directory:
|
||||
|
||||
```powershell
|
||||
$env:ProgramData\memos
|
||||
# Typically, this will resolve to C:\ProgramData\memos
|
||||
```
|
||||
|
||||
You may specify a custom directory by appending `--data <path>` to the service command line.
|
||||
|
||||
- If the service fails to start, you should inspect the Windows Event Viewer `eventvwr.msc`.
|
||||
|
||||
- Memos will be accessible at [http://localhost:5230](http://localhost:5230) by default.
|
||||
168
go.mod
168
go.mod
@@ -1,53 +1,127 @@
|
||||
module github.com/usememos/memos
|
||||
|
||||
go 1.19
|
||||
|
||||
require github.com/mattn/go-sqlite3 v1.14.9
|
||||
|
||||
require github.com/google/uuid v1.3.0
|
||||
|
||||
require (
|
||||
golang.org/x/crypto v0.1.0
|
||||
golang.org/x/net v0.1.0
|
||||
)
|
||||
|
||||
require github.com/labstack/echo/v4 v4.9.0
|
||||
|
||||
require (
|
||||
github.com/VictoriaMetrics/fastcache v1.10.0
|
||||
github.com/gorilla/feeds v1.1.1
|
||||
github.com/gorilla/sessions v1.2.1
|
||||
github.com/labstack/echo-contrib v0.13.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/gorilla/context v1.1.1 // indirect
|
||||
github.com/gorilla/securecookie v1.1.1 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/labstack/gommon v0.3.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.12 // indirect
|
||||
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/segmentio/backo-go v1.0.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.1 // indirect
|
||||
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect
|
||||
golang.org/x/sys v0.1.0 // indirect
|
||||
golang.org/x/text v0.4.0 // indirect
|
||||
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
|
||||
)
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/Masterminds/squirrel v1.5.4
|
||||
github.com/aws/aws-sdk-go-v2 v1.22.1
|
||||
github.com/aws/aws-sdk-go-v2/config v1.22.1
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.15.1
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.13.2
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.42.0
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/go-sql-driver/mysql v1.7.1
|
||||
github.com/google/cel-go v0.18.1
|
||||
github.com/google/uuid v1.4.0
|
||||
github.com/gorilla/feeds v1.1.2
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1
|
||||
github.com/improbable-eng/grpc-web v0.15.0
|
||||
github.com/labstack/echo/v4 v4.11.2
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/microcosm-cc/bluemonday v1.0.26
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/segmentio/analytics-go v3.1.0+incompatible
|
||||
github.com/stretchr/testify v1.7.0
|
||||
golang.org/x/exp v0.0.0-20230111222715-75897c7a292a
|
||||
golang.org/x/mod v0.6.0
|
||||
github.com/spf13/cobra v1.8.0
|
||||
github.com/spf13/viper v1.17.0
|
||||
github.com/stretchr/testify v1.8.4
|
||||
github.com/swaggo/echo-swagger v1.4.1
|
||||
github.com/swaggo/swag v1.16.2
|
||||
github.com/yuin/goldmark v1.6.0
|
||||
go.uber.org/zap v1.26.0
|
||||
golang.org/x/crypto v0.14.0
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d
|
||||
golang.org/x/mod v0.14.0
|
||||
golang.org/x/net v0.17.0
|
||||
golang.org/x/oauth2 v0.13.0
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17
|
||||
google.golang.org/grpc v1.59.0
|
||||
modernc.org/sqlite v1.27.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230305170008-8188dc5388df // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
|
||||
github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ghodss/yaml v1.0.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.20.0 // indirect
|
||||
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||
github.com/go-openapi/spec v0.20.9 // indirect
|
||||
github.com/go-openapi/swag v0.22.4 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rs/cors v1.10.1 // indirect
|
||||
github.com/sagikazarmark/locafero v0.3.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/stoewer/go-strcase v1.3.0 // indirect
|
||||
github.com/swaggo/files/v2 v2.0.0 // indirect
|
||||
golang.org/x/image v0.13.0 // indirect
|
||||
golang.org/x/tools v0.14.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
lukechampine.com/uint128 v1.3.0 // indirect
|
||||
modernc.org/cc/v3 v3.41.0 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.15 // indirect
|
||||
modernc.org/libc v1.30.0 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.7.2 // indirect
|
||||
modernc.org/opt v0.1.3 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
nhooyr.io/websocket v1.8.10 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.5.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.17.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.19.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.25.0 // indirect
|
||||
github.com/aws/smithy-go v1.16.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/labstack/gommon v0.4.0 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/posthog/posthog-go v0.0.0-20230801140217-d607812dee69
|
||||
github.com/spf13/afero v1.10.0 // indirect
|
||||
github.com/spf13/cast v1.5.1 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/sys v0.14.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
golang.org/x/time v0.4.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
google.golang.org/protobuf v1.31.0
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
179
internal/cron/cron.go
Normal file
179
internal/cron/cron.go
Normal file
@@ -0,0 +1,179 @@
|
||||
// Package cron implements a crontab-like service to execute and schedule repeative tasks/jobs.
|
||||
package cron
|
||||
|
||||
// Example:
|
||||
//
|
||||
// c := cron.New()
|
||||
// c.MustAdd("dailyReport", "0 0 * * *", func() { ... })
|
||||
// c.Start()
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type job struct {
|
||||
schedule *Schedule
|
||||
run func()
|
||||
}
|
||||
|
||||
// Cron is a crontab-like struct for tasks/jobs scheduling.
|
||||
type Cron struct {
|
||||
sync.RWMutex
|
||||
|
||||
interval time.Duration
|
||||
timezone *time.Location
|
||||
ticker *time.Ticker
|
||||
jobs map[string]*job
|
||||
}
|
||||
|
||||
// New create a new Cron struct with default tick interval of 1 minute
|
||||
// and timezone in UTC.
|
||||
//
|
||||
// You can change the default tick interval with Cron.SetInterval().
|
||||
// You can change the default timezone with Cron.SetTimezone().
|
||||
func New() *Cron {
|
||||
return &Cron{
|
||||
interval: 1 * time.Minute,
|
||||
timezone: time.UTC,
|
||||
jobs: map[string]*job{},
|
||||
}
|
||||
}
|
||||
|
||||
// SetInterval changes the current cron tick interval
|
||||
// (it usually should be >= 1 minute).
|
||||
func (c *Cron) SetInterval(d time.Duration) {
|
||||
// update interval
|
||||
c.Lock()
|
||||
wasStarted := c.ticker != nil
|
||||
c.interval = d
|
||||
c.Unlock()
|
||||
|
||||
// restart the ticker
|
||||
if wasStarted {
|
||||
c.Start()
|
||||
}
|
||||
}
|
||||
|
||||
// SetTimezone changes the current cron tick timezone.
|
||||
func (c *Cron) SetTimezone(l *time.Location) {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
|
||||
c.timezone = l
|
||||
}
|
||||
|
||||
// MustAdd is similar to Add() but panic on failure.
|
||||
func (c *Cron) MustAdd(jobID string, cronExpr string, run func()) {
|
||||
if err := c.Add(jobID, cronExpr, run); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add registers a single cron job.
|
||||
//
|
||||
// If there is already a job with the provided id, then the old job
|
||||
// will be replaced with the new one.
|
||||
//
|
||||
// cronExpr is a regular cron expression, eg. "0 */3 * * *" (aka. at minute 0 past every 3rd hour).
|
||||
// Check cron.NewSchedule() for the supported tokens.
|
||||
func (c *Cron) Add(jobID string, cronExpr string, run func()) error {
|
||||
if run == nil {
|
||||
return errors.New("failed to add new cron job: run must be non-nil function")
|
||||
}
|
||||
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
|
||||
schedule, err := NewSchedule(cronExpr)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to add new cron job")
|
||||
}
|
||||
|
||||
c.jobs[jobID] = &job{
|
||||
schedule: schedule,
|
||||
run: run,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove removes a single cron job by its id.
|
||||
func (c *Cron) Remove(jobID string) {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
|
||||
delete(c.jobs, jobID)
|
||||
}
|
||||
|
||||
// RemoveAll removes all registered cron jobs.
|
||||
func (c *Cron) RemoveAll() {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
|
||||
c.jobs = map[string]*job{}
|
||||
}
|
||||
|
||||
// Total returns the current total number of registered cron jobs.
|
||||
func (c *Cron) Total() int {
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
|
||||
return len(c.jobs)
|
||||
}
|
||||
|
||||
// Stop stops the current cron ticker (if not already).
|
||||
//
|
||||
// You can resume the ticker by calling Start().
|
||||
func (c *Cron) Stop() {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
|
||||
if c.ticker == nil {
|
||||
return // already stopped
|
||||
}
|
||||
|
||||
c.ticker.Stop()
|
||||
c.ticker = nil
|
||||
}
|
||||
|
||||
// Start starts the cron ticker.
|
||||
//
|
||||
// Calling Start() on already started cron will restart the ticker.
|
||||
func (c *Cron) Start() {
|
||||
c.Stop()
|
||||
|
||||
c.Lock()
|
||||
c.ticker = time.NewTicker(c.interval)
|
||||
c.Unlock()
|
||||
|
||||
go func() {
|
||||
for t := range c.ticker.C {
|
||||
c.runDue(t)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// HasStarted checks whether the current Cron ticker has been started.
|
||||
func (c *Cron) HasStarted() bool {
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
|
||||
return c.ticker != nil
|
||||
}
|
||||
|
||||
// runDue runs all registered jobs that are scheduled for the provided time.
|
||||
func (c *Cron) runDue(t time.Time) {
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
|
||||
moment := NewMoment(t.In(c.timezone))
|
||||
|
||||
for _, j := range c.jobs {
|
||||
if j.schedule.IsDue(moment) {
|
||||
go j.run()
|
||||
}
|
||||
}
|
||||
}
|
||||
249
internal/cron/cron_test.go
Normal file
249
internal/cron/cron_test.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCronNew(t *testing.T) {
|
||||
c := New()
|
||||
|
||||
expectedInterval := 1 * time.Minute
|
||||
if c.interval != expectedInterval {
|
||||
t.Fatalf("Expected default interval %v, got %v", expectedInterval, c.interval)
|
||||
}
|
||||
|
||||
expectedTimezone := time.UTC
|
||||
if c.timezone.String() != expectedTimezone.String() {
|
||||
t.Fatalf("Expected default timezone %v, got %v", expectedTimezone, c.timezone)
|
||||
}
|
||||
|
||||
if len(c.jobs) != 0 {
|
||||
t.Fatalf("Expected no jobs by default, got \n%v", c.jobs)
|
||||
}
|
||||
|
||||
if c.ticker != nil {
|
||||
t.Fatal("Expected the ticker NOT to be initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronSetInterval(t *testing.T) {
|
||||
c := New()
|
||||
|
||||
interval := 2 * time.Minute
|
||||
|
||||
c.SetInterval(interval)
|
||||
|
||||
if c.interval != interval {
|
||||
t.Fatalf("Expected interval %v, got %v", interval, c.interval)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronSetTimezone(t *testing.T) {
|
||||
c := New()
|
||||
|
||||
timezone, _ := time.LoadLocation("Asia/Tokyo")
|
||||
|
||||
c.SetTimezone(timezone)
|
||||
|
||||
if c.timezone.String() != timezone.String() {
|
||||
t.Fatalf("Expected timezone %v, got %v", timezone, c.timezone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronAddAndRemove(t *testing.T) {
|
||||
c := New()
|
||||
|
||||
if err := c.Add("test0", "* * * * *", nil); err == nil {
|
||||
t.Fatal("Expected nil function error")
|
||||
}
|
||||
|
||||
if err := c.Add("test1", "invalid", func() {}); err == nil {
|
||||
t.Fatal("Expected invalid cron expression error")
|
||||
}
|
||||
|
||||
if err := c.Add("test2", "* * * * *", func() {}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := c.Add("test3", "* * * * *", func() {}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := c.Add("test4", "* * * * *", func() {}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// overwrite test2
|
||||
if err := c.Add("test2", "1 2 3 4 5", func() {}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := c.Add("test5", "1 2 3 4 5", func() {}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// mock job deletion
|
||||
c.Remove("test4")
|
||||
|
||||
// try to remove non-existing (should be no-op)
|
||||
c.Remove("missing")
|
||||
|
||||
// check job keys
|
||||
{
|
||||
expectedKeys := []string{"test3", "test2", "test5"}
|
||||
|
||||
if v := len(c.jobs); v != len(expectedKeys) {
|
||||
t.Fatalf("Expected %d jobs, got %d", len(expectedKeys), v)
|
||||
}
|
||||
|
||||
for _, k := range expectedKeys {
|
||||
if c.jobs[k] == nil {
|
||||
t.Fatalf("Expected job with key %s, got nil", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check the jobs schedule
|
||||
{
|
||||
expectedSchedules := map[string]string{
|
||||
"test2": `{"minutes":{"1":{}},"hours":{"2":{}},"days":{"3":{}},"months":{"4":{}},"daysOfWeek":{"5":{}}}`,
|
||||
"test3": `{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
|
||||
"test5": `{"minutes":{"1":{}},"hours":{"2":{}},"days":{"3":{}},"months":{"4":{}},"daysOfWeek":{"5":{}}}`,
|
||||
}
|
||||
for k, v := range expectedSchedules {
|
||||
raw, err := json.Marshal(c.jobs[k].schedule)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if string(raw) != v {
|
||||
t.Fatalf("Expected %q schedule \n%s, \ngot \n%s", k, v, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronMustAdd(t *testing.T) {
|
||||
c := New()
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Errorf("test1 didn't panic")
|
||||
}
|
||||
}()
|
||||
|
||||
c.MustAdd("test1", "* * * * *", nil)
|
||||
|
||||
c.MustAdd("test2", "* * * * *", func() {})
|
||||
|
||||
if _, ok := c.jobs["test2"]; !ok {
|
||||
t.Fatal("Couldn't find job test2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronRemoveAll(t *testing.T) {
|
||||
c := New()
|
||||
|
||||
if err := c.Add("test1", "* * * * *", func() {}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := c.Add("test2", "* * * * *", func() {}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := c.Add("test3", "* * * * *", func() {}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if v := len(c.jobs); v != 3 {
|
||||
t.Fatalf("Expected %d jobs, got %d", 3, v)
|
||||
}
|
||||
|
||||
c.RemoveAll()
|
||||
|
||||
if v := len(c.jobs); v != 0 {
|
||||
t.Fatalf("Expected %d jobs, got %d", 0, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronTotal(t *testing.T) {
|
||||
c := New()
|
||||
|
||||
if v := c.Total(); v != 0 {
|
||||
t.Fatalf("Expected 0 jobs, got %v", v)
|
||||
}
|
||||
|
||||
if err := c.Add("test1", "* * * * *", func() {}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := c.Add("test2", "* * * * *", func() {}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// overwrite
|
||||
if err := c.Add("test1", "* * * * *", func() {}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if v := c.Total(); v != 2 {
|
||||
t.Fatalf("Expected 2 jobs, got %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronStartStop(t *testing.T) {
|
||||
c := New()
|
||||
|
||||
c.SetInterval(1 * time.Second)
|
||||
|
||||
test1 := 0
|
||||
test2 := 0
|
||||
|
||||
err := c.Add("test1", "* * * * *", func() {
|
||||
test1++
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = c.Add("test2", "* * * * *", func() {
|
||||
test2++
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedCalls := 3
|
||||
|
||||
// call twice Start to check if the previous ticker will be reseted
|
||||
c.Start()
|
||||
c.Start()
|
||||
|
||||
time.Sleep(3250 * time.Millisecond)
|
||||
|
||||
// call twice Stop to ensure that the second stop is no-op
|
||||
c.Stop()
|
||||
c.Stop()
|
||||
|
||||
if test1 != expectedCalls {
|
||||
t.Fatalf("Expected %d test1, got %d", expectedCalls, test1)
|
||||
}
|
||||
if test2 != expectedCalls {
|
||||
t.Fatalf("Expected %d test2, got %d", expectedCalls, test2)
|
||||
}
|
||||
|
||||
// resume for ~5 seconds
|
||||
c.Start()
|
||||
time.Sleep(5250 * time.Millisecond)
|
||||
c.Stop()
|
||||
|
||||
expectedCalls += 5
|
||||
|
||||
if test1 != expectedCalls {
|
||||
t.Fatalf("Expected %d test1, got %d", expectedCalls, test1)
|
||||
}
|
||||
if test2 != expectedCalls {
|
||||
t.Fatalf("Expected %d test2, got %d", expectedCalls, test2)
|
||||
}
|
||||
}
|
||||
194
internal/cron/schedule.go
Normal file
194
internal/cron/schedule.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Moment represents a parsed single time moment.
|
||||
type Moment struct {
|
||||
Minute int `json:"minute"`
|
||||
Hour int `json:"hour"`
|
||||
Day int `json:"day"`
|
||||
Month int `json:"month"`
|
||||
DayOfWeek int `json:"dayOfWeek"`
|
||||
}
|
||||
|
||||
// NewMoment creates a new Moment from the specified time.
|
||||
func NewMoment(t time.Time) *Moment {
|
||||
return &Moment{
|
||||
Minute: t.Minute(),
|
||||
Hour: t.Hour(),
|
||||
Day: t.Day(),
|
||||
Month: int(t.Month()),
|
||||
DayOfWeek: int(t.Weekday()),
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule stores parsed information for each time component when a cron job should run.
|
||||
type Schedule struct {
|
||||
Minutes map[int]struct{} `json:"minutes"`
|
||||
Hours map[int]struct{} `json:"hours"`
|
||||
Days map[int]struct{} `json:"days"`
|
||||
Months map[int]struct{} `json:"months"`
|
||||
DaysOfWeek map[int]struct{} `json:"daysOfWeek"`
|
||||
}
|
||||
|
||||
// IsDue checks whether the provided Moment satisfies the current Schedule.
|
||||
func (s *Schedule) IsDue(m *Moment) bool {
|
||||
if _, ok := s.Minutes[m.Minute]; !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if _, ok := s.Hours[m.Hour]; !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if _, ok := s.Days[m.Day]; !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if _, ok := s.DaysOfWeek[m.DayOfWeek]; !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if _, ok := s.Months[m.Month]; !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// NewSchedule creates a new Schedule from a cron expression.
|
||||
//
|
||||
// A cron expression is consisted of 5 segments separated by space,
|
||||
// representing: minute, hour, day of the month, month and day of the week.
|
||||
//
|
||||
// Each segment could be in the following formats:
|
||||
// - wildcard: *
|
||||
// - range: 1-30
|
||||
// - step: */n or 1-30/n
|
||||
// - list: 1,2,3,10-20/n
|
||||
func NewSchedule(cronExpr string) (*Schedule, error) {
|
||||
segments := strings.Split(cronExpr, " ")
|
||||
if len(segments) != 5 {
|
||||
return nil, errors.New("invalid cron expression - must have exactly 5 space separated segments")
|
||||
}
|
||||
|
||||
minutes, err := parseCronSegment(segments[0], 0, 59)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hours, err := parseCronSegment(segments[1], 0, 23)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
days, err := parseCronSegment(segments[2], 1, 31)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
months, err := parseCronSegment(segments[3], 1, 12)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
daysOfWeek, err := parseCronSegment(segments[4], 0, 6)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Schedule{
|
||||
Minutes: minutes,
|
||||
Hours: hours,
|
||||
Days: days,
|
||||
Months: months,
|
||||
DaysOfWeek: daysOfWeek,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseCronSegment parses a single cron expression segment and
|
||||
// returns its time schedule slots.
|
||||
func parseCronSegment(segment string, min int, max int) (map[int]struct{}, error) {
|
||||
slots := map[int]struct{}{}
|
||||
|
||||
list := strings.Split(segment, ",")
|
||||
for _, p := range list {
|
||||
stepParts := strings.Split(p, "/")
|
||||
|
||||
// step (*/n, 1-30/n)
|
||||
var step int
|
||||
switch len(stepParts) {
|
||||
case 1:
|
||||
step = 1
|
||||
case 2:
|
||||
parsedStep, err := strconv.Atoi(stepParts[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parsedStep < 1 || parsedStep > max {
|
||||
return nil, errors.Errorf("invalid segment step boundary - the step must be between 1 and the %d", max)
|
||||
}
|
||||
step = parsedStep
|
||||
default:
|
||||
return nil, errors.New("invalid segment step format - must be in the format */n or 1-30/n")
|
||||
}
|
||||
|
||||
// find the min and max range of the segment part
|
||||
var rangeMin, rangeMax int
|
||||
if stepParts[0] == "*" {
|
||||
rangeMin = min
|
||||
rangeMax = max
|
||||
} else {
|
||||
// single digit (1) or range (1-30)
|
||||
rangeParts := strings.Split(stepParts[0], "-")
|
||||
switch len(rangeParts) {
|
||||
case 1:
|
||||
if step != 1 {
|
||||
return nil, errors.New("invalid segement step - step > 1 could be used only with the wildcard or range format")
|
||||
}
|
||||
parsed, err := strconv.Atoi(rangeParts[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parsed < min || parsed > max {
|
||||
return nil, errors.New("invalid segment value - must be between the min and max of the segment")
|
||||
}
|
||||
rangeMin = parsed
|
||||
rangeMax = rangeMin
|
||||
case 2:
|
||||
parsedMin, err := strconv.Atoi(rangeParts[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parsedMin < min || parsedMin > max {
|
||||
return nil, errors.Errorf("invalid segment range minimum - must be between %d and %d", min, max)
|
||||
}
|
||||
rangeMin = parsedMin
|
||||
|
||||
parsedMax, err := strconv.Atoi(rangeParts[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parsedMax < parsedMin || parsedMax > max {
|
||||
return nil, errors.Errorf("invalid segment range maximum - must be between %d and %d", rangeMin, max)
|
||||
}
|
||||
rangeMax = parsedMax
|
||||
default:
|
||||
return nil, errors.New("invalid segment range format - the range must have 1 or 2 parts")
|
||||
}
|
||||
}
|
||||
|
||||
// fill the slots
|
||||
for i := rangeMin; i <= rangeMax; i += step {
|
||||
slots[i] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
return slots, nil
|
||||
}
|
||||
361
internal/cron/schedule_test.go
Normal file
361
internal/cron/schedule_test.go
Normal file
@@ -0,0 +1,361 @@
|
||||
package cron_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/usememos/memos/internal/cron"
|
||||
)
|
||||
|
||||
func TestNewMoment(t *testing.T) {
|
||||
date, err := time.Parse("2006-01-02 15:04", "2023-05-09 15:20")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m := cron.NewMoment(date)
|
||||
|
||||
if m.Minute != 20 {
|
||||
t.Fatalf("Expected m.Minute %d, got %d", 20, m.Minute)
|
||||
}
|
||||
|
||||
if m.Hour != 15 {
|
||||
t.Fatalf("Expected m.Hour %d, got %d", 15, m.Hour)
|
||||
}
|
||||
|
||||
if m.Day != 9 {
|
||||
t.Fatalf("Expected m.Day %d, got %d", 9, m.Day)
|
||||
}
|
||||
|
||||
if m.Month != 5 {
|
||||
t.Fatalf("Expected m.Month %d, got %d", 5, m.Month)
|
||||
}
|
||||
|
||||
if m.DayOfWeek != 2 {
|
||||
t.Fatalf("Expected m.DayOfWeek %d, got %d", 2, m.DayOfWeek)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSchedule(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
cronExpr string
|
||||
expectError bool
|
||||
expectSchedule string
|
||||
}{
|
||||
{
|
||||
"invalid",
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"* * * *",
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"* * * * * *",
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"2/3 * * * *",
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"* * * * *",
|
||||
false,
|
||||
`{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
|
||||
},
|
||||
{
|
||||
"*/2 */3 */5 */4 */2",
|
||||
false,
|
||||
`{"minutes":{"0":{},"10":{},"12":{},"14":{},"16":{},"18":{},"2":{},"20":{},"22":{},"24":{},"26":{},"28":{},"30":{},"32":{},"34":{},"36":{},"38":{},"4":{},"40":{},"42":{},"44":{},"46":{},"48":{},"50":{},"52":{},"54":{},"56":{},"58":{},"6":{},"8":{}},"hours":{"0":{},"12":{},"15":{},"18":{},"21":{},"3":{},"6":{},"9":{}},"days":{"1":{},"11":{},"16":{},"21":{},"26":{},"31":{},"6":{}},"months":{"1":{},"5":{},"9":{}},"daysOfWeek":{"0":{},"2":{},"4":{},"6":{}}}`,
|
||||
},
|
||||
|
||||
// minute segment
|
||||
{
|
||||
"-1 * * * *",
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"60 * * * *",
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"0 * * * *",
|
||||
false,
|
||||
`{"minutes":{"0":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
|
||||
},
|
||||
{
|
||||
"59 * * * *",
|
||||
false,
|
||||
`{"minutes":{"59":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
|
||||
},
|
||||
{
|
||||
"1,2,5,7,40-50/2 * * * *",
|
||||
false,
|
||||
`{"minutes":{"1":{},"2":{},"40":{},"42":{},"44":{},"46":{},"48":{},"5":{},"50":{},"7":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
|
||||
},
|
||||
|
||||
// hour segment
|
||||
{
|
||||
"* -1 * * *",
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"* 24 * * *",
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"* 0 * * *",
|
||||
false,
|
||||
`{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"0":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
|
||||
},
|
||||
{
|
||||
"* 23 * * *",
|
||||
false,
|
||||
`{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"23":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
|
||||
},
|
||||
{
|
||||
"* 3,4,8-16/3,7 * * *",
|
||||
false,
|
||||
`{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"11":{},"14":{},"3":{},"4":{},"7":{},"8":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
|
||||
},
|
||||
|
||||
// day segment
|
||||
{
|
||||
"* * 0 * *",
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"* * 32 * *",
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"* * 1 * *",
|
||||
false,
|
||||
`{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"1":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
|
||||
},
|
||||
{
|
||||
"* * 31 * *",
|
||||
false,
|
||||
`{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"31":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
|
||||
},
|
||||
{
|
||||
"* * 5,6,20-30/3,1 * *",
|
||||
false,
|
||||
`{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"1":{},"20":{},"23":{},"26":{},"29":{},"5":{},"6":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
|
||||
},
|
||||
|
||||
// month segment
|
||||
{
|
||||
"* * * 0 *",
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"* * * 13 *",
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"* * * 1 *",
|
||||
false,
|
||||
`{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"1":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
|
||||
},
|
||||
{
|
||||
"* * * 12 *",
|
||||
false,
|
||||
`{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"12":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
|
||||
},
|
||||
{
|
||||
"* * * 1,4,5-10/2 *",
|
||||
false,
|
||||
`{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"1":{},"4":{},"5":{},"7":{},"9":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
|
||||
},
|
||||
|
||||
// day of week segment
|
||||
{
|
||||
"* * * * -1",
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"* * * * 7",
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"* * * * 0",
|
||||
false,
|
||||
`{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"0":{}}}`,
|
||||
},
|
||||
{
|
||||
"* * * * 6",
|
||||
false,
|
||||
`{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"6":{}}}`,
|
||||
},
|
||||
{
|
||||
"* * * * 1,2-5/2",
|
||||
false,
|
||||
`{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"1":{},"2":{},"4":{}}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
schedule, err := cron.NewSchedule(s.cronExpr)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Fatalf("[%s] Expected hasErr to be %v, got %v (%v)", s.cronExpr, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
continue
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(schedule)
|
||||
if err != nil {
|
||||
t.Fatalf("[%s] Failed to marshalize the result schedule: %v", s.cronExpr, err)
|
||||
}
|
||||
encodedStr := string(encoded)
|
||||
|
||||
if encodedStr != s.expectSchedule {
|
||||
t.Fatalf("[%s] Expected \n%s, \ngot \n%s", s.cronExpr, s.expectSchedule, encodedStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleIsDue(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
cronExpr string
|
||||
moment *cron.Moment
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
"* * * * *",
|
||||
&cron.Moment{},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"* * * * *",
|
||||
&cron.Moment{
|
||||
Minute: 1,
|
||||
Hour: 1,
|
||||
Day: 1,
|
||||
Month: 1,
|
||||
DayOfWeek: 1,
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"5 * * * *",
|
||||
&cron.Moment{
|
||||
Minute: 1,
|
||||
Hour: 1,
|
||||
Day: 1,
|
||||
Month: 1,
|
||||
DayOfWeek: 1,
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"5 * * * *",
|
||||
&cron.Moment{
|
||||
Minute: 5,
|
||||
Hour: 1,
|
||||
Day: 1,
|
||||
Month: 1,
|
||||
DayOfWeek: 1,
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"* 2-6 * * 2,3",
|
||||
&cron.Moment{
|
||||
Minute: 1,
|
||||
Hour: 2,
|
||||
Day: 1,
|
||||
Month: 1,
|
||||
DayOfWeek: 1,
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"* 2-6 * * 2,3",
|
||||
&cron.Moment{
|
||||
Minute: 1,
|
||||
Hour: 2,
|
||||
Day: 1,
|
||||
Month: 1,
|
||||
DayOfWeek: 3,
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"* * 1,2,5,15-18 * *",
|
||||
&cron.Moment{
|
||||
Minute: 1,
|
||||
Hour: 1,
|
||||
Day: 6,
|
||||
Month: 1,
|
||||
DayOfWeek: 1,
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"* * 1,2,5,15-18/2 * *",
|
||||
&cron.Moment{
|
||||
Minute: 1,
|
||||
Hour: 1,
|
||||
Day: 2,
|
||||
Month: 1,
|
||||
DayOfWeek: 1,
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"* * 1,2,5,15-18/2 * *",
|
||||
&cron.Moment{
|
||||
Minute: 1,
|
||||
Hour: 1,
|
||||
Day: 18,
|
||||
Month: 1,
|
||||
DayOfWeek: 1,
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"* * 1,2,5,15-18/2 * *",
|
||||
&cron.Moment{
|
||||
Minute: 1,
|
||||
Hour: 1,
|
||||
Day: 17,
|
||||
Month: 1,
|
||||
DayOfWeek: 1,
|
||||
},
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
schedule, err := cron.NewSchedule(s.cronExpr)
|
||||
if err != nil {
|
||||
t.Fatalf("[%d-%s] Unexpected cron error: %v", i, s.cronExpr, err)
|
||||
}
|
||||
|
||||
result := schedule.IsDue(s.moment)
|
||||
|
||||
if result != s.expected {
|
||||
t.Fatalf("[%d-%s] Expected %v, got %v", i, s.cronExpr, s.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
66
internal/log/logger.go
Normal file
66
internal/log/logger.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
var (
|
||||
// `gl` is the global logger.
|
||||
// Other packages should use public methods such as Info/Error to do the logging.
|
||||
// For other types of logging, e.g. logging to a separate file, they should use their own loggers.
|
||||
gl *zap.Logger
|
||||
gLevel zap.AtomicLevel
|
||||
)
|
||||
|
||||
// Initializes the global console logger.
|
||||
func init() {
|
||||
gLevel = zap.NewAtomicLevelAt(zap.InfoLevel)
|
||||
gl, _ = zap.Config{
|
||||
Level: gLevel,
|
||||
Development: true,
|
||||
// Use "console" to print readable stacktrace.
|
||||
Encoding: "console",
|
||||
EncoderConfig: zap.NewDevelopmentEncoderConfig(),
|
||||
OutputPaths: []string{"stderr"},
|
||||
ErrorOutputPaths: []string{"stderr"},
|
||||
}.Build(
|
||||
// Skip one caller stack to locate the correct caller.
|
||||
zap.AddCallerSkip(1),
|
||||
)
|
||||
}
|
||||
|
||||
// SetLevel wraps the zap Level's SetLevel method.
|
||||
func SetLevel(level zapcore.Level) {
|
||||
gLevel.SetLevel(level)
|
||||
}
|
||||
|
||||
// EnabledLevel wraps the zap Level's Enabled method.
|
||||
func EnabledLevel(level zapcore.Level) bool {
|
||||
return gLevel.Enabled(level)
|
||||
}
|
||||
|
||||
// Debug wraps the zap Logger's Debug method.
|
||||
func Debug(msg string, fields ...zap.Field) {
|
||||
gl.Debug(msg, fields...)
|
||||
}
|
||||
|
||||
// Info wraps the zap Logger's Info method.
|
||||
func Info(msg string, fields ...zap.Field) {
|
||||
gl.Info(msg, fields...)
|
||||
}
|
||||
|
||||
// Warn wraps the zap Logger's Warn method.
|
||||
func Warn(msg string, fields ...zap.Field) {
|
||||
gl.Warn(msg, fields...)
|
||||
}
|
||||
|
||||
// Error wraps the zap Logger's Error method.
|
||||
func Error(msg string, fields ...zap.Field) {
|
||||
gl.Error(msg, fields...)
|
||||
}
|
||||
|
||||
// Sync wraps the zap Logger's Sync method.
|
||||
func Sync() {
|
||||
_ = gl.Sync()
|
||||
}
|
||||
70
internal/util/util.go
Normal file
70
internal/util/util.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"math/big"
|
||||
"net/mail"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ConvertStringToInt32 converts a string to int32.
|
||||
func ConvertStringToInt32(src string) (int32, error) {
|
||||
parsed, err := strconv.ParseInt(src, 10, 32)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int32(parsed), nil
|
||||
}
|
||||
|
||||
// HasPrefixes returns true if the string s has any of the given prefixes.
|
||||
func HasPrefixes(src string, prefixes ...string) bool {
|
||||
for _, prefix := range prefixes {
|
||||
if strings.HasPrefix(src, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidateEmail validates the email.
|
||||
func ValidateEmail(email string) bool {
|
||||
if _, err := mail.ParseAddress(email); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func GenUUID() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
func Min(x, y int) int {
|
||||
if x < y {
|
||||
return x
|
||||
}
|
||||
return y
|
||||
}
|
||||
|
||||
var letters = []rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||
|
||||
// RandomString returns a random string with length n.
|
||||
func RandomString(n int) (string, error) {
|
||||
var sb strings.Builder
|
||||
sb.Grow(n)
|
||||
for i := 0; i < n; i++ {
|
||||
// The reason for using crypto/rand instead of math/rand is that
|
||||
// the former relies on hardware to generate random numbers and
|
||||
// thus has a stronger source of random numbers.
|
||||
randNum, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := sb.WriteRune(letters[randNum.Uint64()]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package common
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
15
main.go
Normal file
15
main.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/usememos/memos/cmd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
3
plugin/gomark/README.md
Normal file
3
plugin/gomark/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# gomark
|
||||
|
||||
A markdown parser for memos. WIP
|
||||
19
plugin/gomark/ast/ast.go
Normal file
19
plugin/gomark/ast/ast.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package ast
|
||||
|
||||
type Node struct {
|
||||
Type string
|
||||
Text string
|
||||
Children []*Node
|
||||
}
|
||||
|
||||
type Document struct {
|
||||
Nodes []*Node
|
||||
}
|
||||
|
||||
func NewDocument() *Document {
|
||||
return &Document{}
|
||||
}
|
||||
|
||||
func (d *Document) AddNode(node *Node) {
|
||||
d.Nodes = append(d.Nodes, node)
|
||||
}
|
||||
12
plugin/gomark/ast/node.go
Normal file
12
plugin/gomark/ast/node.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package ast
|
||||
|
||||
func NewNode(tp, text string) *Node {
|
||||
return &Node{
|
||||
Type: tp,
|
||||
Text: text,
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Node) AddChild(child *Node) {
|
||||
n.Children = append(n.Children, child)
|
||||
}
|
||||
1
plugin/gomark/gomark.go
Normal file
1
plugin/gomark/gomark.go
Normal file
@@ -0,0 +1 @@
|
||||
package gomark
|
||||
49
plugin/gomark/parser/bold.go
Normal file
49
plugin/gomark/parser/bold.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
)
|
||||
|
||||
type BoldParser struct {
|
||||
ContentTokens []*tokenizer.Token
|
||||
}
|
||||
|
||||
func NewBoldParser() *BoldParser {
|
||||
return &BoldParser{}
|
||||
}
|
||||
|
||||
func (*BoldParser) Match(tokens []*tokenizer.Token) *BoldParser {
|
||||
if len(tokens) < 5 {
|
||||
return nil
|
||||
}
|
||||
|
||||
prefixTokens := tokens[:2]
|
||||
if prefixTokens[0].Type != prefixTokens[1].Type {
|
||||
return nil
|
||||
}
|
||||
prefixTokenType := prefixTokens[0].Type
|
||||
if prefixTokenType != tokenizer.Star && prefixTokenType != tokenizer.Underline {
|
||||
return nil
|
||||
}
|
||||
|
||||
contentTokens := []*tokenizer.Token{}
|
||||
cursor, matched := 2, false
|
||||
for ; cursor < len(tokens)-1; cursor++ {
|
||||
token, nextToken := tokens[cursor], tokens[cursor+1]
|
||||
if token.Type == tokenizer.Newline || nextToken.Type == tokenizer.Newline {
|
||||
return nil
|
||||
}
|
||||
if token.Type == prefixTokenType && nextToken.Type == prefixTokenType {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
contentTokens = append(contentTokens, token)
|
||||
}
|
||||
if !matched {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &BoldParser{
|
||||
ContentTokens: contentTokens,
|
||||
}
|
||||
}
|
||||
89
plugin/gomark/parser/bold_test.go
Normal file
89
plugin/gomark/parser/bold_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
)
|
||||
|
||||
func TestBoldParser(t *testing.T) {
|
||||
tests := []struct {
|
||||
text string
|
||||
bold *BoldParser
|
||||
}{
|
||||
{
|
||||
text: "*Hello world!",
|
||||
bold: nil,
|
||||
},
|
||||
{
|
||||
text: "**Hello**",
|
||||
bold: &BoldParser{
|
||||
ContentTokens: []*tokenizer.Token{
|
||||
{
|
||||
Type: tokenizer.Text,
|
||||
Value: "Hello",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "** Hello **",
|
||||
bold: &BoldParser{
|
||||
ContentTokens: []*tokenizer.Token{
|
||||
{
|
||||
Type: tokenizer.Space,
|
||||
Value: " ",
|
||||
},
|
||||
{
|
||||
Type: tokenizer.Text,
|
||||
Value: "Hello",
|
||||
},
|
||||
{
|
||||
Type: tokenizer.Space,
|
||||
Value: " ",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "** Hello * *",
|
||||
bold: nil,
|
||||
},
|
||||
{
|
||||
text: "* * Hello **",
|
||||
bold: nil,
|
||||
},
|
||||
{
|
||||
text: `** Hello
|
||||
**`,
|
||||
bold: nil,
|
||||
},
|
||||
{
|
||||
text: `**Hello \n**`,
|
||||
bold: &BoldParser{
|
||||
ContentTokens: []*tokenizer.Token{
|
||||
{
|
||||
Type: tokenizer.Text,
|
||||
Value: "Hello",
|
||||
},
|
||||
{
|
||||
Type: tokenizer.Space,
|
||||
Value: " ",
|
||||
},
|
||||
{
|
||||
Type: tokenizer.Text,
|
||||
Value: `\n`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
tokens := tokenizer.Tokenize(test.text)
|
||||
bold := NewBoldParser()
|
||||
require.Equal(t, test.bold, bold.Match(tokens))
|
||||
}
|
||||
}
|
||||
38
plugin/gomark/parser/code.go
Normal file
38
plugin/gomark/parser/code.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package parser
|
||||
|
||||
import "github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
|
||||
type CodeParser struct {
|
||||
Content string
|
||||
}
|
||||
|
||||
func NewCodeParser() *CodeParser {
|
||||
return &CodeParser{}
|
||||
}
|
||||
|
||||
func (*CodeParser) Match(tokens []*tokenizer.Token) *CodeParser {
|
||||
if len(tokens) < 3 {
|
||||
return nil
|
||||
}
|
||||
if tokens[0].Type != tokenizer.Backtick {
|
||||
return nil
|
||||
}
|
||||
|
||||
content, matched := "", false
|
||||
for _, token := range tokens[1:] {
|
||||
if token.Type == tokenizer.Newline {
|
||||
return nil
|
||||
}
|
||||
if token.Type == tokenizer.Backtick {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
content += token.Value
|
||||
}
|
||||
if !matched || len(content) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &CodeParser{
|
||||
Content: content,
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user