mirror of
https://github.com/usememos/memos.git
synced 2025-06-05 22:09:59 +02:00
Compare commits
548 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
749187e1e9 | ||
|
|
a9812592fe | ||
|
|
e4070f7753 | ||
|
|
ff53187eae | ||
|
|
89ef9b8531 | ||
|
|
56b55ad941 | ||
|
|
24672e0c5e | ||
|
|
52743017a3 | ||
|
|
6cf7192d6a | ||
|
|
6763dab4e5 | ||
|
|
e0290b94b4 | ||
|
|
242f64fa8e | ||
|
|
3edce174d6 | ||
|
|
5266a62685 | ||
|
|
43ef9eaced | ||
|
|
453707d18c | ||
|
|
2d9c5d16e1 | ||
|
|
b20e0097cf | ||
|
|
dd83782522 | ||
|
|
aa3632e2ac | ||
|
|
7f1f6f77a0 | ||
|
|
7eb5be0a4e | ||
|
|
603a6a4971 | ||
|
|
6bda64064e | ||
|
|
ec7992553f | ||
|
|
e5de8c08f5 | ||
|
|
c608877c3e | ||
|
|
52f399a154 | ||
|
|
88728906a8 | ||
|
|
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 |
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?
|
||||
|
||||
24
.github/workflows/backend-tests.yml
vendored
24
.github/workflows/backend-tests.yml
vendored
@@ -1,39 +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:
|
||||
version: v1.52.0
|
||||
args: -v --timeout=3m
|
||||
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
|
||||
|
||||
@@ -13,10 +13,10 @@ jobs:
|
||||
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
|
||||
@@ -25,13 +25,13 @@ 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@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -39,14 +39,14 @@ jobs:
|
||||
|
||||
- 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@v4
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
neosmemo/memos
|
||||
@@ -55,15 +55,14 @@ jobs:
|
||||
type=raw,value=latest
|
||||
type=semver,pattern={{version}},value=${{ env.VERSION }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ env.VERSION }}
|
||||
type=semver,pattern={{major}},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,linux/arm/v7
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
14
.github/workflows/build-and-push-test-image.yml
vendored
14
.github/workflows/build-and-push-test-image.yml
vendored
@@ -11,19 +11,19 @@ jobs:
|
||||
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@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -31,14 +31,14 @@ jobs:
|
||||
|
||||
- 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@v4
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
neosmemo/memos
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
|
||||
- name: Build and Push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./
|
||||
file: ./Dockerfile
|
||||
|
||||
87
.github/workflows/build-artifacts.yml
vendored
87
.github/workflows/build-artifacts.yml
vendored
@@ -1,87 +0,0 @@
|
||||
name: build-artifacts
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
# Run on pushing branches like `release/1.0.0`
|
||||
- "release/*.*.*"
|
||||
|
||||
jobs:
|
||||
build-artifacts:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
goarch: [amd64, arm64]
|
||||
include:
|
||||
- os: windows-latest
|
||||
goos: windows
|
||||
goarch: amd64
|
||||
cgo_env: CC=x86_64-w64-mingw32-gcc
|
||||
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: 1
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Clone Memos
|
||||
run: git clone https://github.com/usememos/memos.git
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: "18"
|
||||
|
||||
- name: Build frontend (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
cd memos/web
|
||||
npm install -g pnpm
|
||||
pnpm i --frozen-lockfile
|
||||
pnpm build
|
||||
Remove-Item -Path ../server/dist -Recurse -Force
|
||||
mv dist ../server/
|
||||
|
||||
- name: Build frontend (non-Windows)
|
||||
if: matrix.os != 'windows-latest'
|
||||
run: |
|
||||
cd memos/web
|
||||
npm install -g pnpm
|
||||
pnpm i --frozen-lockfile
|
||||
pnpm build
|
||||
rm -rf ../server/dist
|
||||
mv dist ../server/
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.19
|
||||
|
||||
- name: Install mingw-w64 (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: |
|
||||
choco install mingw
|
||||
echo ${{ matrix.cgo_env }} >> $GITHUB_ENV
|
||||
|
||||
- name: Install gcc-aarch64-linux-gnu (Ubuntu ARM64)
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.goarch == 'arm64'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-aarch64-linux-gnu
|
||||
echo "CC=aarch64-linux-gnu-gcc" >> $GITHUB_ENV
|
||||
|
||||
- name: Build backend
|
||||
run: |
|
||||
cd memos
|
||||
go build -o memos-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.os == 'windows-latest' && '.exe' || '' }} ./main.go
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: memos-binary-${{ matrix.os }}-${{ matrix.goarch }}
|
||||
path: memos/memos-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.os == 'windows-latest' && '.exe' || '' }}
|
||||
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
|
||||
|
||||
24
.github/workflows/frontend-tests.yml
vendored
24
.github/workflows/frontend-tests.yml
vendored
@@ -1,26 +1,32 @@
|
||||
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: pnpm/action-setup@v2.2.4
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v2.4.0
|
||||
with:
|
||||
version: 8
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
node-version: "20"
|
||||
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: pnpm lint
|
||||
working-directory: web
|
||||
@@ -28,17 +34,19 @@ jobs:
|
||||
frontend-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: pnpm/action-setup@v2.2.4
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v2.4.0
|
||||
with:
|
||||
version: 8
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
node-version: "20"
|
||||
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: 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
|
||||
85
.github/workflows/uffizzi-build.yml
vendored
85
.github/workflows/uffizzi-build.yml
vendored
@@ -1,85 +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: Upload PR Event as Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: preview-spec
|
||||
path: ${{github.event_path}}
|
||||
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: Upload PR Event as Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: preview-spec
|
||||
path: ${{github.event_path}}
|
||||
retention-days: 2
|
||||
88
.github/workflows/uffizzi-preview.yml
vendored
88
.github/workflows/uffizzi-preview.yml
vendored
@@ -1,88 +0,0 @@
|
||||
name: Deploy Uffizzi Preview
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- "Build PR Image"
|
||||
types:
|
||||
- completed
|
||||
|
||||
|
||||
jobs:
|
||||
cache-compose-file:
|
||||
name: Cache Compose File
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
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 -e '\nEOF' >> $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
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
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
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@ tmp
|
||||
|
||||
# Frontend asset
|
||||
web/dist
|
||||
server/frontend/dist
|
||||
|
||||
# build folder
|
||||
build
|
||||
|
||||
@@ -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
|
||||
@@ -51,14 +67,25 @@ linters-settings:
|
||||
disabled: true
|
||||
- name: early-return
|
||||
disabled: true
|
||||
- name: use-any
|
||||
disabled: true
|
||||
- name: exported
|
||||
disabled: true
|
||||
- name: unhandled-error
|
||||
disabled: true
|
||||
gocritic:
|
||||
disabled-checks:
|
||||
- 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)?'
|
||||
|
||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -2,8 +2,4 @@
|
||||
"json.schemaDownload.enable":true,
|
||||
"go.lintOnSave": "workspace",
|
||||
"go.lintTool": "golangci-lint",
|
||||
"go.inferGopath": false,
|
||||
"go.toolsEnvVars": {
|
||||
"GO111MODULE": "on"
|
||||
}
|
||||
}
|
||||
|
||||
16
Dockerfile
16
Dockerfile
@@ -1,26 +1,26 @@
|
||||
# Build frontend dist.
|
||||
FROM node:18.12.1-alpine3.16 AS frontend
|
||||
FROM node:20-alpine AS frontend
|
||||
WORKDIR /frontend-build
|
||||
|
||||
COPY ./web/package.json ./web/pnpm-lock.yaml ./
|
||||
COPY . .
|
||||
|
||||
RUN corepack enable && pnpm i --frozen-lockfile
|
||||
WORKDIR /frontend-build/web
|
||||
|
||||
COPY ./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
|
||||
|
||||
COPY . .
|
||||
COPY --from=frontend /frontend-build/dist ./server/dist
|
||||
COPY --from=frontend /frontend-build/web/dist ./server/frontend/dist
|
||||
|
||||
RUN CGO_ENABLED=0 go build -o memos ./main.go
|
||||
RUN CGO_ENABLED=0 go build -o memos ./bin/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
|
||||
|
||||
26
README.md
26
README.md
@@ -1,19 +1,20 @@
|
||||
# memos
|
||||
|
||||
<img height="72px" src="https://usememos.com/logo.webp" alt="✍️ memos" align="right" />
|
||||
<img height="56px" src="https://www.usememos.com/full-logo-landscape.png" alt="Memos" />
|
||||
|
||||
A privacy-first, lightweight note-taking service. Easily capture and share your great thoughts.
|
||||
|
||||
<a href="https://usememos.com/docs">Documentation</a> •
|
||||
<a href="https://demo.usememos.com/">Live Demo</a> •
|
||||
Discuss in <a href="https://discord.gg/tfPJa4UmAv">Discord</a> / <a href="https://t.me/+-_tNF1k70UU4ZTc9">Telegram</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>
|
||||
|
||||

|
||||

|
||||
|
||||
## Key points
|
||||
|
||||
@@ -31,14 +32,14 @@ docker run -d --name memos -p 5230:5230 -v ~/.memos/:/var/opt/memos ghcr.io/usem
|
||||
|
||||
> 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.
|
||||
|
||||
Learn more about [other installation methods](https://usememos.com/docs#installation).
|
||||
Learn more about [other installation methods](https://www.usememos.com/docs/install).
|
||||
|
||||
## Contribution
|
||||
|
||||
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! 🥰
|
||||
|
||||
<a href="https://github.com/usememos/memos/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=usememos/memos" />
|
||||
<img src="https://contri-graphy.yourselfhosted.com/graph?repo=usememos/memos&format=svg" />
|
||||
</a>
|
||||
|
||||
---
|
||||
@@ -49,16 +50,13 @@ Contributions are what make the open-source community such an amazing place to l
|
||||
- [qazxcdswe123/telegramMemoBot](https://github.com/qazxcdswe123/telegramMemoBot) - Telegram bot
|
||||
- [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
|
||||
- [Send to memos](https://sharecuts.cn/shortcut/12640) - A shortcut for iOS
|
||||
- [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
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
- Thanks [Uffizzi](https://www.uffizzi.com/) for sponsoring preview environments for PRs.
|
||||
|
||||
## Star history
|
||||
|
||||
[](https://star-history.com/#usememos/memos&Date)
|
||||
|
||||
@@ -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).
|
||||
|
||||
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
|
||||
}
|
||||
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,145 +0,0 @@
|
||||
package v1
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func (t ActivityType) String() string {
|
||||
return string(t)
|
||||
}
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
func (l ActivityLevel) String() string {
|
||||
return string(l)
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
530
api/v1/auth.go
530
api/v1/auth.go
@@ -1,28 +1,38 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/api/v1/auth"
|
||||
"github.com/usememos/memos/common/util"
|
||||
"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"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
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 int `json:"identityProviderId"`
|
||||
IdentityProviderID int32 `json:"identityProviderId"`
|
||||
Code string `json:"code"`
|
||||
RedirectURI string `json:"redirectUri"`
|
||||
}
|
||||
@@ -33,241 +43,357 @@ type SignUp struct {
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerAuthRoutes(g *echo.Group) {
|
||||
// POST /auth/signin - Sign in.
|
||||
g.POST("/auth/signin", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
signin := &SignIn{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signin request").SetInternal(err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
// 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{}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
if err := auth.GenerateTokensAndSetCookies(c, user, s.Secret); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate tokens").SetInternal(err)
|
||||
}
|
||||
if err := s.createAuthSignInActivity(c, user); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, user)
|
||||
disablePasswordLoginSystemSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
|
||||
Name: SystemSettingDisablePasswordLoginName.String(),
|
||||
})
|
||||
|
||||
// POST /auth/signin/sso - Sign in with SSO
|
||||
g.POST("/auth/signin/sso", func(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 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 find identity provider").SetInternal(err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting").SetInternal(err)
|
||||
}
|
||||
if identityProvider == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "Identity provider not found")
|
||||
if disablePasswordLogin {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Password login is deactivated")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signin request").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 {
|
||||
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,
|
||||
OpenID: util.GenUUID(),
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
if err := auth.GenerateTokensAndSetCookies(c, user, s.Secret); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate tokens").SetInternal(err)
|
||||
}
|
||||
if err := s.createAuthSignInActivity(c, user); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, user)
|
||||
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))
|
||||
}
|
||||
|
||||
// POST /auth/signup - Sign up a new user.
|
||||
g.POST("/auth/signup", func(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)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
hostUserType := store.RoleHost
|
||||
existedHostUsers, err := s.Store.ListUsers(ctx, &store.FindUser{
|
||||
Role: &hostUserType,
|
||||
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.StatusBadRequest, "Failed to find users").SetInternal(err)
|
||||
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: signup.Username,
|
||||
Username: userInfo.Identifier,
|
||||
// The new signup user should be normal user by default.
|
||||
Role: store.RoleUser,
|
||||
Nickname: signup.Username,
|
||||
OpenID: util.GenUUID(),
|
||||
Nickname: userInfo.DisplayName,
|
||||
Email: userInfo.Email,
|
||||
}
|
||||
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 := false
|
||||
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)
|
||||
}
|
||||
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(signup.Password), bcrypt.DefaultCost)
|
||||
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)
|
||||
user, err = s.Store.CreateUser(ctx, userCreate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
|
||||
}
|
||||
if err := auth.GenerateTokensAndSetCookies(c, user, s.Secret); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate tokens").SetInternal(err)
|
||||
}
|
||||
if err := s.createAuthSignUpActivity(c, user); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").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)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, user)
|
||||
})
|
||||
|
||||
// POST /auth/signout - Sign out.
|
||||
g.POST("/auth/signout", func(c echo.Context) error {
|
||||
auth.RemoveTokensAndCookies(c)
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIV1Service) createAuthSignInActivity(c echo.Context, user *store.User) error {
|
||||
ctx := c.Request().Context()
|
||||
payload := ActivityUserAuthSignInPayload{
|
||||
UserID: user.ID,
|
||||
IP: echo.ExtractIPFromRealIPHeader()(c.Request()),
|
||||
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)
|
||||
}
|
||||
}
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(signup.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to marshal activity payload")
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
|
||||
}
|
||||
activity, err := s.Store.CreateActivity(ctx, &store.Activity{
|
||||
CreatorID: user.ID,
|
||||
Type: string(ActivityUserAuthSignIn),
|
||||
Level: string(ActivityInfo),
|
||||
Payload: string(payloadBytes),
|
||||
})
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
|
||||
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)
|
||||
}
|
||||
return 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) createAuthSignUpActivity(c echo.Context, user *store.User) error {
|
||||
ctx := c.Request().Context()
|
||||
payload := ActivityUserAuthSignUpPayload{
|
||||
Username: user.Username,
|
||||
IP: echo.ExtractIPFromRealIPHeader()(c.Request()),
|
||||
}
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
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 marshal activity payload")
|
||||
return errors.Wrap(err, "failed to get user access tokens")
|
||||
}
|
||||
activity, err := s.Store.CreateActivity(ctx, &store.Activity{
|
||||
CreatorID: user.ID,
|
||||
Type: string(ActivityUserAuthSignUp),
|
||||
Level: string(ActivityInfo),
|
||||
Payload: string(payloadBytes),
|
||||
})
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
userAccessToken := storepb.AccessTokensUserSetting_AccessToken{
|
||||
AccessToken: accessToken,
|
||||
Description: "Account sign in",
|
||||
}
|
||||
return err
|
||||
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,131 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
const (
|
||||
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"
|
||||
// RefreshTokenAudienceName is the audience name of the refresh token.
|
||||
RefreshTokenAudienceName = "user.refresh-token"
|
||||
apiTokenDuration = 2 * time.Hour
|
||||
accessTokenDuration = 24 * time.Hour
|
||||
refreshTokenDuration = 7 * 24 * time.Hour
|
||||
// RefreshThresholdDuration is the threshold duration for refreshing token.
|
||||
RefreshThresholdDuration = 1 * 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.
|
||||
// Suppose we have a valid refresh token, we will refresh the token in 2 cases:
|
||||
// 1. The access token is about to expire in <<refreshThresholdDuration>>
|
||||
// 2. The access token has already expired, we refresh the token so that the ongoing request can pass through.
|
||||
CookieExpDuration = refreshTokenDuration - 1*time.Minute
|
||||
// AccessTokenCookieName is the cookie name of access token.
|
||||
AccessTokenCookieName = "memos.access-token"
|
||||
// RefreshTokenCookieName is the cookie name of refresh token.
|
||||
RefreshTokenCookieName = "memos.refresh-token"
|
||||
)
|
||||
|
||||
type claimsMessage struct {
|
||||
Name string `json:"name"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// GenerateAPIToken generates an API token.
|
||||
func GenerateAPIToken(userName string, userID int, secret string) (string, error) {
|
||||
expirationTime := time.Now().Add(apiTokenDuration)
|
||||
return generateToken(userName, userID, AccessTokenAudienceName, expirationTime, []byte(secret))
|
||||
}
|
||||
|
||||
// GenerateAccessToken generates an access token for web.
|
||||
func GenerateAccessToken(userName string, userID int, secret string) (string, error) {
|
||||
expirationTime := time.Now().Add(accessTokenDuration)
|
||||
return generateToken(userName, userID, AccessTokenAudienceName, expirationTime, []byte(secret))
|
||||
}
|
||||
|
||||
// GenerateRefreshToken generates a refresh token for web.
|
||||
func GenerateRefreshToken(userName string, userID int, secret string) (string, error) {
|
||||
expirationTime := time.Now().Add(refreshTokenDuration)
|
||||
return generateToken(userName, userID, RefreshTokenAudienceName, expirationTime, []byte(secret))
|
||||
}
|
||||
|
||||
// GenerateTokensAndSetCookies generates jwt token and saves it to the http-only cookie.
|
||||
func GenerateTokensAndSetCookies(c echo.Context, user *store.User, secret string) error {
|
||||
accessToken, err := GenerateAccessToken(user.Username, user.ID, secret)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to generate access token")
|
||||
}
|
||||
|
||||
cookieExp := time.Now().Add(CookieExpDuration)
|
||||
setTokenCookie(c, AccessTokenCookieName, accessToken, cookieExp)
|
||||
|
||||
// We generate here a new refresh token and saving it to the cookie.
|
||||
refreshToken, err := GenerateRefreshToken(user.Username, user.ID, secret)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to generate refresh token")
|
||||
}
|
||||
setTokenCookie(c, RefreshTokenCookieName, refreshToken, cookieExp)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveTokensAndCookies removes the jwt token and refresh token from the cookies.
|
||||
func RemoveTokensAndCookies(c echo.Context) {
|
||||
// We set the expiration time to the past, so that the cookie will be removed.
|
||||
cookieExp := time.Now().Add(-1 * time.Hour)
|
||||
setTokenCookie(c, AccessTokenCookieName, "", cookieExp)
|
||||
setTokenCookie(c, RefreshTokenCookieName, "", cookieExp)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// generateToken generates a jwt token.
|
||||
func generateToken(username string, userID int, aud string, expirationTime time.Time, secret []byte) (string, error) {
|
||||
// Create the JWT claims, which includes the username and expiry time.
|
||||
claims := &claimsMessage{
|
||||
Name: username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Audience: jwt.ClaimStrings{aud},
|
||||
// In JWT, the expiry time is expressed as unix milliseconds.
|
||||
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: issuer,
|
||||
Subject: strconv.Itoa(userID),
|
||||
},
|
||||
}
|
||||
|
||||
// Declare the token with the HS256 algorithm used for signing, and the claims.
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
token.Header["kid"] = keyID
|
||||
|
||||
// Create the JWT string.
|
||||
tokenString, err := token.SignedString(secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return tokenString, nil
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
package v1
|
||||
|
||||
// UnknownID is the ID for unknowns.
|
||||
const UnknownID = -1
|
||||
|
||||
// RowStatus is the status for a row.
|
||||
type RowStatus string
|
||||
|
||||
|
||||
3015
api/v1/docs.go
Normal file
3015
api/v1/docs.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,48 +6,44 @@ import (
|
||||
"net/url"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
getter "github.com/usememos/memos/plugin/http-getter"
|
||||
)
|
||||
|
||||
func (*APIV1Service) registerGetterPublicRoutes(g *echo.Group) {
|
||||
// GET /get/httpmeta?url={url} - Get website meta.
|
||||
g.GET("/get/httpmeta", func(c echo.Context) error {
|
||||
urlStr := c.QueryParam("url")
|
||||
if urlStr == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Missing website url")
|
||||
}
|
||||
if _, err := url.Parse(urlStr); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Wrong url").SetInternal(err)
|
||||
}
|
||||
|
||||
htmlMeta, err := getter.GetHTMLMeta(urlStr)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusNotAcceptable, fmt.Sprintf("Failed to get website meta with url: %s", urlStr)).SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, htmlMeta)
|
||||
})
|
||||
|
||||
// GET /get/image?url={url} - Get image.
|
||||
g.GET("/get/image", func(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
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
364
api/v1/idp.go
364
api/v1/idp.go
@@ -4,9 +4,10 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"github.com/usememos/memos/internal/util"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
@@ -41,7 +42,7 @@ type FieldMapping struct {
|
||||
}
|
||||
|
||||
type IdentityProvider struct {
|
||||
ID int `json:"id"`
|
||||
ID int32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type IdentityProviderType `json:"type"`
|
||||
IdentifierFilter string `json:"identifierFilter"`
|
||||
@@ -56,7 +57,7 @@ type CreateIdentityProviderRequest struct {
|
||||
}
|
||||
|
||||
type UpdateIdentityProviderRequest struct {
|
||||
ID int `json:"-"`
|
||||
ID int32 `json:"-"`
|
||||
Type IdentityProviderType `json:"type"`
|
||||
Name *string `json:"name"`
|
||||
IdentifierFilter *string `json:"identifierFilter"`
|
||||
@@ -64,176 +65,241 @@ type UpdateIdentityProviderRequest struct {
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerIdentityProviderRoutes(g *echo.Group) {
|
||||
g.POST("/idp", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
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 {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
if user == nil || user.Role == store.RoleHost {
|
||||
isHostUser = true
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
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))
|
||||
// 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")
|
||||
}
|
||||
|
||||
g.PATCH("/idp/:idpId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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 := strconv.Atoi(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))
|
||||
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))
|
||||
}
|
||||
|
||||
g.GET("/idp", func(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)
|
||||
}
|
||||
// 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")
|
||||
}
|
||||
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
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)
|
||||
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")
|
||||
}
|
||||
|
||||
g.GET("/idp/:idpId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
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 := strconv.Atoi(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))
|
||||
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")
|
||||
}
|
||||
|
||||
g.DELETE("/idp/:idpId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
return c.JSON(http.StatusOK, convertIdentityProviderFromStore(identityProvider))
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
// 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")
|
||||
}
|
||||
|
||||
identityProviderID, err := strconv.Atoi(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)
|
||||
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 {
|
||||
|
||||
209
api/v1/jwt.go
209
api/v1/jwt.go
@@ -3,36 +3,26 @@ package v1
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/api/v1/auth"
|
||||
"github.com/usememos/memos/common/util"
|
||||
"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 (
|
||||
// Context section
|
||||
// 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 getUserIDContextKey() string {
|
||||
return userIDContextKey
|
||||
}
|
||||
|
||||
// Claims creates a struct that will be encoded to a JWT.
|
||||
// We add jwt.RegisteredClaims as an embedded type, to provide fields such as name.
|
||||
type Claims struct {
|
||||
Name string `json:"name"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func extractTokenFromHeader(c echo.Context) (string, error) {
|
||||
authHeader := c.Request().Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
@@ -48,32 +38,22 @@ func extractTokenFromHeader(c echo.Context) (string, error) {
|
||||
}
|
||||
|
||||
func findAccessToken(c echo.Context) string {
|
||||
accessToken := ""
|
||||
cookie, _ := c.Cookie(auth.AccessTokenCookieName)
|
||||
if cookie != nil {
|
||||
accessToken = cookie.Value
|
||||
}
|
||||
// Check the HTTP request header first.
|
||||
accessToken, _ := extractTokenFromHeader(c)
|
||||
if accessToken == "" {
|
||||
accessToken, _ = extractTokenFromHeader(c)
|
||||
// Check the cookie.
|
||||
cookie, _ := c.Cookie(auth.AccessTokenCookieName)
|
||||
if cookie != nil {
|
||||
accessToken = cookie.Value
|
||||
}
|
||||
}
|
||||
|
||||
return accessToken
|
||||
}
|
||||
|
||||
func audienceContains(audience jwt.ClaimStrings, token string) bool {
|
||||
for _, v := range audience {
|
||||
if v == token {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// JWTMiddleware validates the access token.
|
||||
// If the access token is about to expire or has expired and the request has a valid refresh token, it
|
||||
// will try to generate new access token and refresh 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
|
||||
|
||||
@@ -82,60 +62,42 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
|
||||
}
|
||||
|
||||
// Skip validation for server status endpoints.
|
||||
if util.HasPrefixes(path, "/api/v1/ping", "/api/v1/idp", "/api/v1/status", "/api/v1/user/:id") && method == http.MethodGet {
|
||||
if util.HasPrefixes(path, "/api/v1/ping", "/api/v1/status") && method == http.MethodGet {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
token := findAccessToken(c)
|
||||
if token == "" {
|
||||
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/memo") && method == http.MethodGet {
|
||||
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")
|
||||
}
|
||||
|
||||
claims := &Claims{}
|
||||
accessToken, err := jwt.ParseWithClaims(token, 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"])
|
||||
})
|
||||
|
||||
generateToken := time.Until(claims.ExpiresAt.Time) < auth.RefreshThresholdDuration
|
||||
userID, err := getUserIDFromAccessToken(accessToken, secret)
|
||||
if err != nil {
|
||||
var ve *jwt.ValidationError
|
||||
if errors.As(err, &ve) {
|
||||
// If expiration error is the only error, we will clear the err
|
||||
// and generate new access token and refresh token
|
||||
if ve.Errors == jwt.ValidationErrorExpired {
|
||||
generateToken = true
|
||||
}
|
||||
} else {
|
||||
auth.RemoveTokensAndCookies(c)
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, errors.Wrap(err, "Invalid or expired access token"))
|
||||
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")
|
||||
}
|
||||
|
||||
if !audienceContains(claims.Audience, auth.AccessTokenAudienceName) {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Invalid access token, audience mismatch, got %q, expected %q.", claims.Audience, auth.AccessTokenAudienceName))
|
||||
}
|
||||
|
||||
// We either have a valid access token or we will attempt to generate new access token and refresh token
|
||||
ctx := c.Request().Context()
|
||||
userID, err := strconv.Atoi(claims.Subject)
|
||||
accessTokens, err := server.Store.GetUserAccessTokens(ctx, userID)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Malformed ID in the token.")
|
||||
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.
|
||||
@@ -149,89 +111,46 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Failed to find user ID: %d", userID))
|
||||
}
|
||||
|
||||
if generateToken {
|
||||
generateTokenFunc := func() error {
|
||||
rc, err := c.Cookie(auth.RefreshTokenCookieName)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Failed to generate access token. Missing refresh token.")
|
||||
}
|
||||
|
||||
// Parses token and checks if it's valid.
|
||||
refreshTokenClaims := &Claims{}
|
||||
refreshToken, err := jwt.ParseWithClaims(rc.Value, refreshTokenClaims, func(t *jwt.Token) (any, error) {
|
||||
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
||||
return nil, errors.Errorf("unexpected refresh token signing method=%v, expected %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 refresh token kid=%v", t.Header["kid"])
|
||||
})
|
||||
if err != nil {
|
||||
if err == jwt.ErrSignatureInvalid {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Failed to generate access token. Invalid refresh token signature.")
|
||||
}
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to refresh expired token. User Id %d", userID)).SetInternal(err)
|
||||
}
|
||||
|
||||
if !audienceContains(refreshTokenClaims.Audience, auth.RefreshTokenAudienceName) {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized,
|
||||
fmt.Sprintf("Invalid refresh token, audience mismatch, got %q, expected %q. you may send request to the wrong environment",
|
||||
refreshTokenClaims.Audience,
|
||||
auth.RefreshTokenAudienceName,
|
||||
))
|
||||
}
|
||||
|
||||
// If we have a valid refresh token, we will generate new access token and refresh token
|
||||
if refreshToken != nil && refreshToken.Valid {
|
||||
if err := auth.GenerateTokensAndSetCookies(c, user, secret); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to refresh expired token. User Id %d", userID)).SetInternal(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// It may happen that we still have a valid access token, but we encounter issue when trying to generate new token
|
||||
// In such case, we won't return the error.
|
||||
if err := generateTokenFunc(); err != nil && !accessToken.Valid {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Stores userID into context.
|
||||
c.Set(getUserIDContextKey(), userID)
|
||||
c.Set(userIDContextKey, userID)
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *APIV1Service) defaultAuthSkipper(c echo.Context) bool {
|
||||
ctx := c.Request().Context()
|
||||
path := c.Path()
|
||||
|
||||
// Skip auth.
|
||||
if util.HasPrefixes(path, "/api/v1/auth") {
|
||||
return true
|
||||
}
|
||||
|
||||
// If there is openId in query string and related user is found, then skip auth.
|
||||
openID := c.QueryParam("openId")
|
||||
if openID != "" {
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
OpenID: &openID,
|
||||
})
|
||||
if err != nil {
|
||||
return false
|
||||
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 user != nil {
|
||||
// Stores userID into context.
|
||||
c.Set(getUserIDContextKey(), user.ID)
|
||||
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
|
||||
}
|
||||
|
||||
1333
api/v1/memo.go
1333
api/v1/memo.go
File diff suppressed because it is too large
Load Diff
@@ -4,16 +4,17 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"github.com/usememos/memos/internal/util"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
type MemoOrganizer struct {
|
||||
MemoID int `json:"memoId"`
|
||||
UserID int `json:"userId"`
|
||||
Pinned bool `json:"pinned"`
|
||||
MemoID int32 `json:"memoId"`
|
||||
UserID int32 `json:"userId"`
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
|
||||
type UpsertMemoOrganizerRequest struct {
|
||||
@@ -21,60 +22,76 @@ type UpsertMemoOrganizerRequest struct {
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerMemoOrganizerRoutes(g *echo.Group) {
|
||||
g.POST("/memo/:memoId/organizer", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
memoID, err := strconv.Atoi(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(getUserIDContextKey()).(int)
|
||||
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)
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -4,91 +4,147 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"github.com/usememos/memos/internal/util"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
type MemoRelationType string
|
||||
|
||||
const (
|
||||
MemoRelationReference MemoRelationType = "REFERENCE"
|
||||
MemoRelationAdditional MemoRelationType = "ADDITIONAL"
|
||||
MemoRelationReference MemoRelationType = "REFERENCE"
|
||||
MemoRelationComment MemoRelationType = "COMMENT"
|
||||
)
|
||||
|
||||
func (t MemoRelationType) String() string {
|
||||
return string(t)
|
||||
}
|
||||
|
||||
type MemoRelation struct {
|
||||
MemoID int `json:"memoId"`
|
||||
RelatedMemoID int `json:"relatedMemoId"`
|
||||
MemoID int32 `json:"memoId"`
|
||||
RelatedMemoID int32 `json:"relatedMemoId"`
|
||||
Type MemoRelationType `json:"type"`
|
||||
}
|
||||
|
||||
type UpsertMemoRelationRequest struct {
|
||||
RelatedMemoID int `json:"relatedMemoId"`
|
||||
RelatedMemoID int32 `json:"relatedMemoId"`
|
||||
Type MemoRelationType `json:"type"`
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerMemoRelationRoutes(g *echo.Group) {
|
||||
g.POST("/memo/:memoId/relation", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
memoID, err := strconv.Atoi(c.Param("memoId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||
}
|
||||
g.GET("/memo/:memoId/relation", s.GetMemoRelationList)
|
||||
g.POST("/memo/:memoId/relation", s.CreateMemoRelation)
|
||||
g.DELETE("/memo/:memoId/relation/:relatedMemoId/type/:relationType", s.DeleteMemoRelation)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
g.GET("/memo/:memoId/relation", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
memoID, err := strconv.Atoi(c.Param("memoId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
g.DELETE("/memo/:memoId/relation/:relatedMemoId/type/:relationType", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
memoID, err := strconv.Atoi(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 := strconv.Atoi(c.Param("relatedMemoId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Related memo ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
|
||||
}
|
||||
relationType := store.MemoRelationType(c.Param("relationType"))
|
||||
// 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)
|
||||
})
|
||||
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 {
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
type MemoResource struct {
|
||||
MemoID int `json:"memoId"`
|
||||
ResourceID int `json:"resourceId"`
|
||||
CreatedTs int64 `json:"createdTs"`
|
||||
UpdatedTs int64 `json:"updatedTs"`
|
||||
}
|
||||
|
||||
type UpsertMemoResourceRequest struct {
|
||||
ResourceID int `json:"resourceId"`
|
||||
UpdatedTs *int64 `json:"updatedTs"`
|
||||
}
|
||||
|
||||
type MemoResourceFind struct {
|
||||
MemoID *int
|
||||
ResourceID *int
|
||||
}
|
||||
|
||||
type MemoResourceDelete struct {
|
||||
MemoID *int
|
||||
ResourceID *int
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerMemoResourceRoutes(g *echo.Group) {
|
||||
g.POST("/memo/:memoId/resource", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
memoID, err := strconv.Atoi(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(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
request := &UpsertMemoResourceRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo resource request").SetInternal(err)
|
||||
}
|
||||
resource, err := s.Store.GetResource(ctx, &store.FindResource{
|
||||
ID: &request.ResourceID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource").SetInternal(err)
|
||||
}
|
||||
if resource == nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Resource not found").SetInternal(err)
|
||||
} else if resource.CreatorID != userID {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized to bind this resource").SetInternal(err)
|
||||
}
|
||||
|
||||
upsert := &store.UpsertMemoResource{
|
||||
MemoID: memoID,
|
||||
ResourceID: request.ResourceID,
|
||||
CreatedTs: time.Now().Unix(),
|
||||
}
|
||||
if request.UpdatedTs != nil {
|
||||
upsert.UpdatedTs = request.UpdatedTs
|
||||
}
|
||||
if _, err := s.Store.UpsertMemoResource(ctx, upsert); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
|
||||
g.GET("/memo/:memoId/resource", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
memoID, err := strconv.Atoi(c.Param("memoId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
list, err := s.Store.ListResources(ctx, &store.FindResource{
|
||||
MemoID: &memoID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
|
||||
}
|
||||
resourceList := []*Resource{}
|
||||
for _, resource := range list {
|
||||
resourceList = append(resourceList, convertResourceFromStore(resource))
|
||||
}
|
||||
return c.JSON(http.StatusOK, resourceList)
|
||||
})
|
||||
|
||||
g.DELETE("/memo/:memoId/resource/:resourceId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
memoID, err := strconv.Atoi(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)
|
||||
}
|
||||
resourceID, err := strconv.Atoi(c.Param("resourceId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Resource ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
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.StatusBadRequest, "Memo not found")
|
||||
}
|
||||
if memo.CreatorID != userID {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
if err := s.Store.DeleteMemoResource(ctx, &store.DeleteMemoResource{
|
||||
MemoID: &memoID,
|
||||
ResourceID: &resourceID,
|
||||
}); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
}
|
||||
130
api/v1/openai.go
130
api/v1/openai.go
@@ -1,130 +0,0 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
echosse "github.com/CorrectRoadH/echo-sse"
|
||||
"github.com/PullRequestInc/go-gpt3"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/usememos/memos/plugin/openai"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
func (s *APIV1Service) registerOpenAIRoutes(g *echo.Group) {
|
||||
g.POST("/openai/chat-completion", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
openAIConfigSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
|
||||
Name: SystemSettingOpenAIConfigName.String(),
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find openai key").SetInternal(err)
|
||||
}
|
||||
|
||||
openAIConfig := OpenAIConfig{}
|
||||
if openAIConfigSetting != nil {
|
||||
err = json.Unmarshal([]byte(openAIConfigSetting.Value), &openAIConfig)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal openai system setting value").SetInternal(err)
|
||||
}
|
||||
}
|
||||
if openAIConfig.Key == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "OpenAI API key not set")
|
||||
}
|
||||
|
||||
messages := []openai.ChatCompletionMessage{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(&messages); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post chat completion request").SetInternal(err)
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "No messages provided")
|
||||
}
|
||||
|
||||
result, err := openai.PostChatCompletion(messages, openAIConfig.Key, openAIConfig.Host)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to post chat completion").SetInternal(err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, result)
|
||||
})
|
||||
|
||||
g.POST("/openai/chat-streaming", func(c echo.Context) error {
|
||||
messages := []gpt3.ChatCompletionRequestMessage{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(&messages); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post chat completion request").SetInternal(err)
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "No messages provided")
|
||||
}
|
||||
|
||||
ctx := c.Request().Context()
|
||||
openAIConfigSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
|
||||
Name: SystemSettingOpenAIConfigName.String(),
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find openai key").SetInternal(err)
|
||||
}
|
||||
|
||||
openAIConfig := OpenAIConfig{}
|
||||
if openAIConfigSetting != nil {
|
||||
err = json.Unmarshal([]byte(openAIConfigSetting.Value), &openAIConfig)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal openai system setting value").SetInternal(err)
|
||||
}
|
||||
}
|
||||
if openAIConfig.Key == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "OpenAI API key not set")
|
||||
}
|
||||
|
||||
sse := echosse.NewSSEClint(c)
|
||||
|
||||
// to do these things in server may not elegant.
|
||||
// But move it to openai plugin will break the simple. Because it is a streaming. We must use a channel to do it.
|
||||
// And we can think it is a forward proxy. So it in here is not a bad idea.
|
||||
client := gpt3.NewClient(openAIConfig.Key)
|
||||
err = client.ChatCompletionStream(ctx, gpt3.ChatCompletionRequest{
|
||||
Model: gpt3.GPT3Dot5Turbo,
|
||||
Messages: messages,
|
||||
Stream: true,
|
||||
},
|
||||
func(resp *gpt3.ChatCompletionStreamResponse) {
|
||||
// _ is for to pass the golangci-lint check
|
||||
_ = sse.SendEvent(resp.Choices[0].Delta.Content)
|
||||
|
||||
// to delay 0.5 s
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
// the delay is a very good way to make the chatbot more comfortable
|
||||
// otherwise the chatbot will reply too fast. Believe me it is not good.🤔
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to chat with OpenAI").SetInternal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
g.GET("/openai/enabled", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
openAIConfigSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
|
||||
Name: SystemSettingOpenAIConfigName.String(),
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find openai key").SetInternal(err)
|
||||
}
|
||||
|
||||
openAIConfig := OpenAIConfig{}
|
||||
if openAIConfigSetting != nil {
|
||||
err = json.Unmarshal([]byte(openAIConfigSetting.Value), &openAIConfig)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal openai system setting value").SetInternal(err)
|
||||
}
|
||||
}
|
||||
if openAIConfig.Key == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "OpenAI API key not set")
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, openAIConfig.Key != "")
|
||||
})
|
||||
}
|
||||
@@ -1,38 +1,35 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/common/log"
|
||||
"github.com/usememos/memos/common/util"
|
||||
"github.com/usememos/memos/plugin/storage/s3"
|
||||
"github.com/usememos/memos/store"
|
||||
"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 int `json:"id"`
|
||||
ID int32 `json:"id"`
|
||||
|
||||
// Standard fields
|
||||
CreatorID int `json:"creatorId"`
|
||||
CreatorID int32 `json:"creatorId"`
|
||||
CreatedTs int64 `json:"createdTs"`
|
||||
UpdatedTs int64 `json:"updatedTs"`
|
||||
|
||||
@@ -43,22 +40,17 @@ type Resource struct {
|
||||
ExternalLink string `json:"externalLink"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
|
||||
// Related fields
|
||||
LinkedMemoAmount int `json:"linkedMemoAmount"`
|
||||
}
|
||||
|
||||
type CreateResourceRequest struct {
|
||||
Filename string `json:"filename"`
|
||||
InternalPath string `json:"internalPath"`
|
||||
ExternalLink string `json:"externalLink"`
|
||||
Type string `json:"type"`
|
||||
DownloadToLocal bool `json:"downloadToLocal"`
|
||||
Filename string `json:"filename"`
|
||||
ExternalLink string `json:"externalLink"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type FindResourceRequest struct {
|
||||
ID *int `json:"id"`
|
||||
CreatorID *int `json:"creatorId"`
|
||||
ID *int32 `json:"id"`
|
||||
CreatorID *int32 `json:"creatorId"`
|
||||
Filename *string `json:"filename"`
|
||||
}
|
||||
|
||||
@@ -72,361 +64,274 @@ const (
|
||||
// This is unrelated to maximum upload size limit, which is now set through system setting.
|
||||
maxUploadBufferSizeBytes = 32 << 20
|
||||
MebiByte = 1024 * 1024
|
||||
|
||||
// thumbnailImagePath is the directory to store image thumbnails.
|
||||
thumbnailImagePath = ".thumbnail_cache"
|
||||
)
|
||||
|
||||
var fileKeyPattern = regexp.MustCompile(`\{[a-z]{1,9}\}`)
|
||||
|
||||
func (s *APIV1Service) registerResourceRoutes(g *echo.Group) {
|
||||
g.POST("/resource", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
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")
|
||||
}
|
||||
|
||||
if request.DownloadToLocal {
|
||||
resp, err := http.Get(linkURL.String())
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to request %s", request.ExternalLink))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
blob, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to read %s", request.ExternalLink))
|
||||
}
|
||||
|
||||
mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to read mime from %s", request.ExternalLink))
|
||||
}
|
||||
create.Type = mediaType
|
||||
|
||||
filename := path.Base(linkURL.Path)
|
||||
if path.Ext(filename) == "" {
|
||||
extensions, _ := mime.ExtensionsByType(mediaType)
|
||||
if len(extensions) > 0 {
|
||||
filename += extensions[0]
|
||||
}
|
||||
}
|
||||
create.Filename = filename
|
||||
create.ExternalLink = ""
|
||||
create.Size = int64(len(blob))
|
||||
|
||||
err = SaveResourceBlob(ctx, s.Store, create, bytes.NewReader(blob))
|
||||
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)
|
||||
}
|
||||
if err := s.createResourceCreateActivity(ctx, resource); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, convertResourceFromStore(resource))
|
||||
})
|
||||
|
||||
g.POST("/resource/blob", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
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)
|
||||
}
|
||||
if err := s.createResourceCreateActivity(ctx, resource); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, convertResourceFromStore(resource))
|
||||
})
|
||||
|
||||
g.GET("/resource", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
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)
|
||||
})
|
||||
|
||||
g.PATCH("/resource/:resourceId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
|
||||
resourceID, err := strconv.Atoi(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))
|
||||
})
|
||||
|
||||
g.DELETE("/resource/:resourceId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
|
||||
resourceID, err := strconv.Atoi(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 resource.InternalPath != "" {
|
||||
if err := os.Remove(resource.InternalPath); err != nil {
|
||||
log.Warn(fmt.Sprintf("failed to delete local file with path %s", resource.InternalPath), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
ext := filepath.Ext(resource.Filename)
|
||||
thumbnailPath := filepath.Join(s.Profile.Data, thumbnailImagePath, fmt.Sprintf("%d%s", resource.ID, ext))
|
||||
if err := os.Remove(thumbnailPath); err != nil {
|
||||
log.Warn(fmt.Sprintf("failed to delete local thumbnail with path %s", thumbnailPath), zap.Error(err))
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerResourcePublicRoutes(g *echo.Group) {
|
||||
f := func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
resourceID, err := strconv.Atoi(c.Param("resourceId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
resourceVisibility, err := checkResourceVisibility(ctx, s.Store, resourceID)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Failed to get resource visibility").SetInternal(err)
|
||||
}
|
||||
|
||||
// Protected resource require a logined user
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if resourceVisibility == store.Protected && (!ok || userID <= 0) {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Resource visibility not match").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))
|
||||
}
|
||||
|
||||
// Private resource require logined user is the creator
|
||||
if resourceVisibility == store.Private && (!ok || userID != resource.CreatorID) {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Resource visibility not match").SetInternal(err)
|
||||
}
|
||||
|
||||
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=31536000, immutable")
|
||||
c.Response().Writer.Header().Set(echo.HeaderContentSecurityPolicy, "default-src 'self'")
|
||||
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))
|
||||
// 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
|
||||
}
|
||||
|
||||
g.GET("/r/:resourceId", f)
|
||||
g.GET("/r/:resourceId/*", f)
|
||||
}
|
||||
|
||||
func (s *APIV1Service) createResourceCreateActivity(ctx context.Context, resource *store.Resource) error {
|
||||
payload := ActivityResourceCreatePayload{
|
||||
Filename: resource.Filename,
|
||||
Type: resource.Type,
|
||||
Size: resource.Size,
|
||||
}
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
list, err := s.Store.ListResources(ctx, find)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to marshal activity payload")
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
|
||||
}
|
||||
activity, err := s.Store.CreateActivity(ctx, &store.Activity{
|
||||
CreatorID: resource.CreatorID,
|
||||
Type: ActivityResourceCreate.String(),
|
||||
Level: ActivityInfo.String(),
|
||||
Payload: string(payloadBytes),
|
||||
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 || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find resource").SetInternal(err)
|
||||
}
|
||||
return 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 {
|
||||
@@ -455,105 +360,18 @@ func replacePathTemplate(path, filename string) string {
|
||||
return path
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func checkResourceVisibility(ctx context.Context, s *store.Store, resourceID int) (store.Visibility, error) {
|
||||
memoResources, err := s.ListMemoResources(ctx, &store.FindMemoResource{
|
||||
ResourceID: &resourceID,
|
||||
})
|
||||
if err != nil {
|
||||
return store.Private, err
|
||||
}
|
||||
|
||||
// If resource is belongs to no memo, it'll always PRIVATE.
|
||||
if len(memoResources) == 0 {
|
||||
return store.Private, nil
|
||||
}
|
||||
|
||||
memoIDs := make([]int, 0, len(memoResources))
|
||||
for _, memoResource := range memoResources {
|
||||
memoIDs = append(memoIDs, memoResource.MemoID)
|
||||
}
|
||||
visibilityList, err := s.FindMemosVisibilityList(ctx, memoIDs)
|
||||
if err != nil {
|
||||
return store.Private, err
|
||||
}
|
||||
|
||||
var isProtected bool
|
||||
for _, visibility := range visibilityList {
|
||||
// If any memo is PUBLIC, resource should be PUBLIC too.
|
||||
if visibility == store.Public {
|
||||
return store.Public, nil
|
||||
}
|
||||
|
||||
if visibility == store.Protected {
|
||||
isProtected = true
|
||||
}
|
||||
}
|
||||
|
||||
if isProtected {
|
||||
return store.Protected, nil
|
||||
}
|
||||
|
||||
return store.Private, nil
|
||||
}
|
||||
|
||||
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,
|
||||
LinkedMemoAmount: resource.LinkedMemoAmount,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,14 +384,14 @@ func convertResourceFromStore(resource *store.Resource) *Resource {
|
||||
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 fmt.Errorf("Failed to find SystemSettingStorageServiceIDName: %s", err)
|
||||
return errors.Wrap(err, "Failed to find SystemSettingStorageServiceIDName")
|
||||
}
|
||||
|
||||
storageServiceID := DatabaseStorage
|
||||
storageServiceID := DefaultStorage
|
||||
if systemSettingStorageServiceID != nil {
|
||||
err = json.Unmarshal([]byte(systemSettingStorageServiceID.Value), &storageServiceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to unmarshal storage service id: %s", err)
|
||||
return errors.Wrap(err, "Failed to unmarshal storage service id")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,23 +399,21 @@ func SaveResourceBlob(ctx context.Context, s *store.Store, create *store.Resourc
|
||||
if storageServiceID == DatabaseStorage {
|
||||
fileBytes, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to read file: %s", err)
|
||||
return errors.Wrap(err, "Failed to read file")
|
||||
}
|
||||
create.Blob = fileBytes
|
||||
return nil
|
||||
}
|
||||
|
||||
// `LocalStorage` means save blob into local disk
|
||||
if storageServiceID == LocalStorage {
|
||||
} 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 fmt.Errorf("Failed to find SystemSettingLocalStoragePathName: %s", err)
|
||||
return errors.Wrap(err, "Failed to find SystemSettingLocalStoragePathName")
|
||||
}
|
||||
localStoragePath := "assets/{filename}"
|
||||
localStoragePath := "assets/{timestamp}_{filename}"
|
||||
if systemSettingLocalStoragePath != nil && systemSettingLocalStoragePath.Value != "" {
|
||||
err = json.Unmarshal([]byte(systemSettingLocalStoragePath.Value), &localStoragePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to unmarshal SystemSettingLocalStoragePathName: %s", err)
|
||||
return errors.Wrap(err, "Failed to unmarshal SystemSettingLocalStoragePathName")
|
||||
}
|
||||
}
|
||||
filePath := filepath.FromSlash(localStoragePath)
|
||||
@@ -608,16 +424,16 @@ func SaveResourceBlob(ctx context.Context, s *store.Store, create *store.Resourc
|
||||
|
||||
dir := filepath.Dir(filePath)
|
||||
if err = os.MkdirAll(dir, os.ModePerm); err != nil {
|
||||
return fmt.Errorf("Failed to create directory: %s", err)
|
||||
return errors.Wrap(err, "Failed to create directory")
|
||||
}
|
||||
dst, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to create file: %s", err)
|
||||
return errors.Wrap(err, "Failed to create file")
|
||||
}
|
||||
defer dst.Close()
|
||||
_, err = io.Copy(dst, r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to copy file: %s", err)
|
||||
return errors.Wrap(err, "Failed to copy file")
|
||||
}
|
||||
|
||||
create.InternalPath = filePath
|
||||
@@ -627,18 +443,18 @@ func SaveResourceBlob(ctx context.Context, s *store.Store, create *store.Resourc
|
||||
// Others: store blob into external service, such as S3
|
||||
storage, err := s.GetStorage(ctx, &store.FindStorage{ID: &storageServiceID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to find StorageServiceID: %s", err)
|
||||
return errors.Wrap(err, "Failed to find StorageServiceID")
|
||||
}
|
||||
if storage == nil {
|
||||
return fmt.Errorf("Storage %d not found", storageServiceID)
|
||||
return errors.Errorf("Storage %d not found", storageServiceID)
|
||||
}
|
||||
storageMessage, err := ConvertStorageFromStore(storage)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to ConvertStorageFromStore: %s", err)
|
||||
return errors.Wrap(err, "Failed to ConvertStorageFromStore")
|
||||
}
|
||||
|
||||
if storageMessage.Type != StorageS3 {
|
||||
return fmt.Errorf("Unsupported storage type: %s", storageMessage.Type)
|
||||
return errors.Errorf("Unsupported storage type: %s", storageMessage.Type)
|
||||
}
|
||||
|
||||
s3Config := storageMessage.Config.S3Config
|
||||
@@ -652,7 +468,7 @@ func SaveResourceBlob(ctx context.Context, s *store.Store, create *store.Resourc
|
||||
URLSuffix: s3Config.URLSuffix,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to create s3 client: %s", err)
|
||||
return errors.Wrap(err, "Failed to create s3 client")
|
||||
}
|
||||
|
||||
filePath := s3Config.Path
|
||||
@@ -663,7 +479,7 @@ func SaveResourceBlob(ctx context.Context, s *store.Store, create *store.Resourc
|
||||
|
||||
link, err := s3Client.UploadFile(ctx, filePath, create.Type, r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to upload via s3 client: %s", err)
|
||||
return errors.Wrap(err, "Failed to upload via s3 client")
|
||||
}
|
||||
|
||||
create.ExternalLink = link
|
||||
|
||||
180
api/v1/rss.go
180
api/v1/rss.go
@@ -1,7 +1,6 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -12,72 +11,96 @@ import (
|
||||
|
||||
"github.com/gorilla/feeds"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/usememos/memos/common/util"
|
||||
|
||||
"github.com/usememos/memos/internal/util"
|
||||
"github.com/usememos/memos/plugin/gomark/parser"
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
"github.com/usememos/memos/plugin/gomark/render/html"
|
||||
"github.com/usememos/memos/store"
|
||||
"github.com/yuin/goldmark"
|
||||
)
|
||||
|
||||
const maxRSSItemCount = 100
|
||||
const maxRSSItemTitleLength = 100
|
||||
|
||||
func (s *APIV1Service) registerRSSRoutes(g *echo.Group) {
|
||||
g.GET("/explore/rss.xml", func(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)
|
||||
}
|
||||
g.GET("/explore/rss.xml", s.GetExploreRSS)
|
||||
g.GET("/u/:id/rss.xml", s.GetUserRSS)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
g.GET("/u/:id/rss.xml", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "User id is not a number").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)
|
||||
}
|
||||
|
||||
systemCustomizedProfile, err := s.getSystemCustomizedProfile(ctx)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get system customized profile").SetInternal(err)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
systemCustomizedProfile, err := s.getSystemCustomizedProfile(ctx)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get system customized profile").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)
|
||||
})
|
||||
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) {
|
||||
@@ -91,30 +114,28 @@ func (s *APIV1Service) generateRSSFromMemoList(ctx context.Context, memoList []*
|
||||
var itemCountLimit = util.Min(len(memoList), maxRSSItemCount)
|
||||
feed.Items = make([]*feeds.Item, itemCountLimit)
|
||||
for i := 0; i < itemCountLimit; i++ {
|
||||
memo := memoList[i]
|
||||
feed.Items[i] = &feeds.Item{
|
||||
Title: getRSSItemTitle(memo.Content),
|
||||
Link: &feeds.Link{Href: baseURL + "/m/" + strconv.Itoa(memo.ID)},
|
||||
Description: getRSSItemDescription(memo.Content),
|
||||
Created: time.Unix(memo.CreatedTs, 0),
|
||||
Enclosure: &feeds.Enclosure{Url: baseURL + "/m/" + strconv.Itoa(memo.ID) + "/image"},
|
||||
memoMessage, err := s.convertMemoFromStore(ctx, memoList[i])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(memo.ResourceIDList) > 0 {
|
||||
resourceID := memo.ResourceIDList[0]
|
||||
resource, err := s.Store.GetResource(ctx, &store.FindResource{
|
||||
ID: &resourceID,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resource == nil {
|
||||
return "", fmt.Errorf("Resource not found: %d", resourceID)
|
||||
}
|
||||
description, err := getRSSItemDescription(memoMessage.Content)
|
||||
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: description,
|
||||
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/" + strconv.Itoa(resource.ID)
|
||||
enclosure.Url = baseURL + "/o/r/" + fmt.Sprintf("%d", resource.ID)
|
||||
}
|
||||
enclosure.Length = strconv.Itoa(int(resource.Size))
|
||||
enclosure.Type = resource.Type
|
||||
@@ -166,7 +187,7 @@ func getRSSItemTitle(content string) string {
|
||||
return title
|
||||
}
|
||||
|
||||
func getRSSItemDescription(content string) string {
|
||||
func getRSSItemDescription(content string) (string, error) {
|
||||
var description string
|
||||
if isTitleDefined(content) {
|
||||
var firstLineEnd = strings.Index(content, "\n")
|
||||
@@ -175,12 +196,13 @@ func getRSSItemDescription(content string) string {
|
||||
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)
|
||||
tokens := tokenizer.Tokenize(description)
|
||||
nodes, err := parser.Parse(tokens)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String()
|
||||
result := html.NewHTMLRender().Render(nodes)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func isTitleDefined(content string) bool {
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
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 CreateShortcutRequest struct {
|
||||
Title string `json:"title"`
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
|
||||
type UpdateShortcutRequest struct {
|
||||
RowStatus *RowStatus `json:"rowStatus"`
|
||||
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
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
||||
g.POST("/shortcut", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
shortcutCreate := &CreateShortcutRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(shortcutCreate); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post shortcut request").SetInternal(err)
|
||||
}
|
||||
|
||||
shortcut, err := s.Store.CreateShortcut(ctx, &store.Shortcut{
|
||||
CreatorID: userID,
|
||||
Title: shortcutCreate.Title,
|
||||
Payload: shortcutCreate.Payload,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create shortcut").SetInternal(err)
|
||||
}
|
||||
|
||||
shortcutMessage := convertShortcutFromStore(shortcut)
|
||||
if err := s.createShortcutCreateActivity(c, shortcutMessage); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, shortcutMessage)
|
||||
})
|
||||
|
||||
g.PATCH("/shortcut/:shortcutId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
shortcutID, err := strconv.Atoi(c.Param("shortcutId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
||||
ID: &shortcutID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find shortcut").SetInternal(err)
|
||||
}
|
||||
if shortcut == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Shortcut not found: %d", shortcutID))
|
||||
}
|
||||
if shortcut.CreatorID != userID {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
request := &UpdateShortcutRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch shortcut request").SetInternal(err)
|
||||
}
|
||||
|
||||
currentTs := time.Now().Unix()
|
||||
shortcutUpdate := &store.UpdateShortcut{
|
||||
ID: shortcutID,
|
||||
UpdatedTs: ¤tTs,
|
||||
}
|
||||
if request.RowStatus != nil {
|
||||
rowStatus := store.RowStatus(*request.RowStatus)
|
||||
shortcutUpdate.RowStatus = &rowStatus
|
||||
}
|
||||
if request.Title != nil {
|
||||
shortcutUpdate.Title = request.Title
|
||||
}
|
||||
if request.Payload != nil {
|
||||
shortcutUpdate.Payload = request.Payload
|
||||
}
|
||||
|
||||
shortcut, err = s.Store.UpdateShortcut(ctx, shortcutUpdate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch shortcut").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, convertShortcutFromStore(shortcut))
|
||||
})
|
||||
|
||||
g.GET("/shortcut", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find shortcut")
|
||||
}
|
||||
|
||||
list, err := s.Store.ListShortcuts(ctx, &store.FindShortcut{
|
||||
CreatorID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get shortcut list").SetInternal(err)
|
||||
}
|
||||
shortcutMessageList := make([]*Shortcut, 0, len(list))
|
||||
for _, shortcut := range list {
|
||||
shortcutMessageList = append(shortcutMessageList, convertShortcutFromStore(shortcut))
|
||||
}
|
||||
return c.JSON(http.StatusOK, shortcutMessageList)
|
||||
})
|
||||
|
||||
g.GET("/shortcut/:shortcutId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
shortcutID, err := strconv.Atoi(c.Param("shortcutId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
||||
ID: &shortcutID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to fetch shortcut by ID %d", shortcutID)).SetInternal(err)
|
||||
}
|
||||
if shortcut == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Shortcut not found: %d", shortcutID))
|
||||
}
|
||||
return c.JSON(http.StatusOK, convertShortcutFromStore(shortcut))
|
||||
})
|
||||
|
||||
g.DELETE("/shortcut/:shortcutId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
shortcutID, err := strconv.Atoi(c.Param("shortcutId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
||||
ID: &shortcutID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find shortcut").SetInternal(err)
|
||||
}
|
||||
if shortcut == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Shortcut not found: %d", shortcutID))
|
||||
}
|
||||
if shortcut.CreatorID != userID {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
if err := s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{
|
||||
ID: &shortcutID,
|
||||
}); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete shortcut").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIV1Service) createShortcutCreateActivity(c echo.Context, shortcut *Shortcut) error {
|
||||
ctx := c.Request().Context()
|
||||
payload := ActivityShortcutCreatePayload{
|
||||
Title: shortcut.Title,
|
||||
Payload: shortcut.Payload,
|
||||
}
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to marshal activity payload")
|
||||
}
|
||||
activity, err := s.Store.CreateActivity(ctx, &store.Activity{
|
||||
CreatorID: shortcut.CreatorID,
|
||||
Type: ActivityShortcutCreate.String(),
|
||||
Level: ActivityInfo.String(),
|
||||
Payload: string(payloadBytes),
|
||||
})
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func convertShortcutFromStore(shortcut *store.Shortcut) *Shortcut {
|
||||
return &Shortcut{
|
||||
ID: shortcut.ID,
|
||||
RowStatus: RowStatus(shortcut.RowStatus),
|
||||
CreatorID: shortcut.CreatorID,
|
||||
Title: shortcut.Title,
|
||||
Payload: shortcut.Payload,
|
||||
CreatedTs: shortcut.CreatedTs,
|
||||
UpdatedTs: shortcut.UpdatedTs,
|
||||
}
|
||||
}
|
||||
@@ -4,17 +4,20 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"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 = -1
|
||||
LocalStorage int32 = -1
|
||||
// DatabaseStorage means the storage service is database.
|
||||
DatabaseStorage = 0
|
||||
DatabaseStorage int32 = 0
|
||||
// Default storage service is database.
|
||||
DefaultStorage int32 = DatabaseStorage
|
||||
)
|
||||
|
||||
type StorageType string
|
||||
@@ -43,7 +46,7 @@ type StorageS3Config struct {
|
||||
}
|
||||
|
||||
type Storage struct {
|
||||
ID int `json:"id"`
|
||||
ID int32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type StorageType `json:"type"`
|
||||
Config *StorageConfig `json:"config"`
|
||||
@@ -62,182 +65,234 @@ type UpdateStorageRequest struct {
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerStorageRoutes(g *echo.Group) {
|
||||
g.POST("/storage", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
g.GET("/storage", s.GetStorageList)
|
||||
g.POST("/storage", s.CreateStorage)
|
||||
g.PATCH("/storage/:storageId", s.UpdateStorage)
|
||||
g.DELETE("/storage/:storageId", s.DeleteStorage)
|
||||
}
|
||||
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
// 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 find user").SetInternal(err)
|
||||
}
|
||||
if user == nil || user.Role != store.RoleHost {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to convert storage").SetInternal(err)
|
||||
}
|
||||
storageList = append(storageList, storageMessage)
|
||||
}
|
||||
return c.JSON(http.StatusOK, storageList)
|
||||
}
|
||||
|
||||
create := &CreateStorageRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(create); err != nil {
|
||||
// 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)
|
||||
}
|
||||
|
||||
configString := ""
|
||||
if create.Type == StorageS3 && create.Config.S3Config != nil {
|
||||
configBytes, err := json.Marshal(create.Config.S3Config)
|
||||
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)
|
||||
configString := string(configBytes)
|
||||
storageUpdate.Config = &configString
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
g.PATCH("/storage/:storageId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
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 := strconv.Atoi(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)
|
||||
})
|
||||
|
||||
g.GET("/storage", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
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)
|
||||
})
|
||||
|
||||
g.DELETE("/storage/:storageId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
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 := strconv.Atoi(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 := DatabaseStorage
|
||||
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)
|
||||
})
|
||||
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) {
|
||||
|
||||
2033
api/v1/swagger.yaml
Normal file
2033
api/v1/swagger.yaml
Normal file
File diff suppressed because it is too large
Load Diff
261
api/v1/system.go
261
api/v1/system.go
@@ -5,10 +5,11 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/usememos/memos/common/log"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/usememos/memos/internal/log"
|
||||
"github.com/usememos/memos/server/profile"
|
||||
"github.com/usememos/memos/store"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type SystemStatus struct {
|
||||
@@ -19,6 +20,8 @@ type SystemStatus struct {
|
||||
// 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.
|
||||
@@ -32,7 +35,7 @@ type SystemStatus struct {
|
||||
// Customized server profile, including server name and external url.
|
||||
CustomizedProfile CustomizedProfile `json:"customizedProfile"`
|
||||
// Storage service ID.
|
||||
StorageServiceID int `json:"storageServiceId"`
|
||||
StorageServiceID int32 `json:"storageServiceId"`
|
||||
// Local storage path.
|
||||
LocalStoragePath string `json:"localStoragePath"`
|
||||
// Memo display with updated timestamp.
|
||||
@@ -40,119 +43,141 @@ type SystemStatus struct {
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerSystemRoutes(g *echo.Group) {
|
||||
g.GET("/ping", func(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, s.Profile)
|
||||
})
|
||||
|
||||
g.GET("/status", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
|
||||
systemStatus := SystemStatus{
|
||||
Profile: *s.Profile,
|
||||
DBSize: 0,
|
||||
AllowSignUp: false,
|
||||
DisablePublicMemos: false,
|
||||
MaxUploadSizeMiB: 32,
|
||||
AutoBackupInterval: 0,
|
||||
AdditionalStyle: "",
|
||||
AdditionalScript: "",
|
||||
CustomizedProfile: CustomizedProfile{
|
||||
Name: "memos",
|
||||
LogoURL: "",
|
||||
Description: "",
|
||||
Locale: "en",
|
||||
Appearance: "system",
|
||||
ExternalURL: "",
|
||||
},
|
||||
StorageServiceID: DatabaseStorage,
|
||||
LocalStoragePath: "assets/{timestamp}_{filename}",
|
||||
MemoDisplayWithUpdatedTs: false,
|
||||
}
|
||||
|
||||
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}
|
||||
// data desensitize
|
||||
systemStatus.Host.OpenID = ""
|
||||
systemStatus.Host.Email = ""
|
||||
systemStatus.Host.AvatarURL = ""
|
||||
}
|
||||
|
||||
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 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 = int(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)
|
||||
})
|
||||
|
||||
g.POST("/system/vacuum", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
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)
|
||||
})
|
||||
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() || systemSetting.Name == SystemSettingInstanceURLName.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)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
@@ -19,6 +20,8 @@ const (
|
||||
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.
|
||||
@@ -33,15 +36,16 @@ const (
|
||||
SystemSettingStorageServiceIDName SystemSettingName = "storage-service-id"
|
||||
// SystemSettingLocalStoragePathName is the name of local storage path.
|
||||
SystemSettingLocalStoragePathName SystemSettingName = "local-storage-path"
|
||||
// SystemSettingTelegramBotToken is the name of Telegram Bot Token.
|
||||
// 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"
|
||||
// SystemSettingOpenAIConfigName is the name of OpenAI config.
|
||||
SystemSettingOpenAIConfigName SystemSettingName = "openai-config"
|
||||
// SystemSettingAutoBackupIntervalName is the name of auto backup interval as seconds.
|
||||
SystemSettingAutoBackupIntervalName SystemSettingName = "auto-backup-interval"
|
||||
// SystemSettingInstanceURLName is the name of instance url setting.
|
||||
SystemSettingInstanceURLName SystemSettingName = "instance-url"
|
||||
)
|
||||
const systemSettingUnmarshalError = `failed to unmarshal value from system setting "%v"`
|
||||
|
||||
// CustomizedProfile is the struct definition for SystemSettingCustomizedProfileName system setting item.
|
||||
type CustomizedProfile struct {
|
||||
@@ -70,47 +74,151 @@ type SystemSetting struct {
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type OpenAIConfig struct {
|
||||
Key string `json:"key"`
|
||||
Host string `json:"host"`
|
||||
}
|
||||
|
||||
type UpsertSystemSettingRequest struct {
|
||||
Name SystemSettingName `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
const systemSettingUnmarshalError = `failed to unmarshal value from system setting "%v"`
|
||||
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 fmt.Errorf("updating %v is not allowed", settingName)
|
||||
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 fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||
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 fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||
return errors.Errorf(systemSettingUnmarshalError, settingName)
|
||||
}
|
||||
case SystemSettingMaxUploadSizeMiBName:
|
||||
var value int
|
||||
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||
return errors.Errorf(systemSettingUnmarshalError, settingName)
|
||||
}
|
||||
case SystemSettingAdditionalStyleName:
|
||||
var value string
|
||||
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||
return errors.Errorf(systemSettingUnmarshalError, settingName)
|
||||
}
|
||||
case SystemSettingAdditionalScriptName:
|
||||
var value string
|
||||
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||
return errors.Errorf(systemSettingUnmarshalError, settingName)
|
||||
}
|
||||
case SystemSettingCustomizedProfileName:
|
||||
customizedProfile := CustomizedProfile{
|
||||
@@ -122,32 +230,27 @@ func (upsert UpsertSystemSettingRequest) Validate() error {
|
||||
ExternalURL: "",
|
||||
}
|
||||
if err := json.Unmarshal([]byte(upsert.Value), &customizedProfile); err != nil {
|
||||
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||
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 fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||
return errors.Errorf(systemSettingUnmarshalError, settingName)
|
||||
}
|
||||
return nil
|
||||
case SystemSettingLocalStoragePathName:
|
||||
value := ""
|
||||
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||
}
|
||||
case SystemSettingOpenAIConfigName:
|
||||
value := OpenAIConfig{}
|
||||
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||
return errors.Errorf(systemSettingUnmarshalError, settingName)
|
||||
}
|
||||
case SystemSettingAutoBackupIntervalName:
|
||||
var value int
|
||||
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||
return errors.Errorf(systemSettingUnmarshalError, settingName)
|
||||
}
|
||||
if value < 0 {
|
||||
return fmt.Errorf("must be positive")
|
||||
return errors.New("must be positive")
|
||||
}
|
||||
case SystemSettingTelegramBotTokenName:
|
||||
if upsert.Value == "" {
|
||||
@@ -159,90 +262,24 @@ func (upsert UpsertSystemSettingRequest) Validate() error {
|
||||
if strings.HasPrefix(upsert.Value[slashIndex:], "/bot") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("token start with `http` must end with `/bot<token>`")
|
||||
return errors.New("token start with `http` must end with `/bot<token>`")
|
||||
}
|
||||
fragments := strings.Split(upsert.Value, ":")
|
||||
if len(fragments) != 2 {
|
||||
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||
return errors.Errorf(systemSettingUnmarshalError, settingName)
|
||||
}
|
||||
case SystemSettingMemoDisplayWithUpdatedTsName:
|
||||
var value bool
|
||||
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||
return errors.Errorf(systemSettingUnmarshalError, settingName)
|
||||
}
|
||||
case SystemSettingInstanceURLName:
|
||||
default:
|
||||
return fmt.Errorf("invalid system setting name")
|
||||
return errors.New("invalid system setting name")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerSystemSettingRoutes(g *echo.Group) {
|
||||
g.POST("/system/setting", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
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)
|
||||
}
|
||||
|
||||
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))
|
||||
})
|
||||
|
||||
g.GET("/system/setting", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
func convertSystemSettingFromStore(systemSetting *store.SystemSetting) *SystemSetting {
|
||||
return &SystemSetting{
|
||||
Name: SystemSettingName(systemSetting.Name),
|
||||
|
||||
299
api/v1/tag.go
299
api/v1/tag.go
@@ -8,14 +8,14 @@ import (
|
||||
"sort"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/store"
|
||||
"golang.org/x/exp/slices"
|
||||
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
type Tag struct {
|
||||
Name string
|
||||
CreatorID int
|
||||
CreatorID int32
|
||||
}
|
||||
|
||||
type UpsertTagRequest struct {
|
||||
@@ -27,146 +27,169 @@ type DeleteTagRequest struct {
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerTagRoutes(g *echo.Group) {
|
||||
g.POST("/tag", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
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)
|
||||
if err := s.createTagCreateActivity(c, tagMessage); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, tagMessage.Name)
|
||||
})
|
||||
|
||||
g.GET("/tag", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
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)
|
||||
})
|
||||
|
||||
g.GET("/tag/suggestion", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
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)
|
||||
})
|
||||
|
||||
g.POST("/tag/delete", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
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)
|
||||
})
|
||||
g.GET("/tag", s.GetTagList)
|
||||
g.POST("/tag", s.CreateTag)
|
||||
g.GET("/tag/suggestion", s.GetTagSuggestion)
|
||||
g.POST("/tag/delete", s.DeleteTag)
|
||||
}
|
||||
|
||||
func (s *APIV1Service) createTagCreateActivity(c echo.Context, tag *Tag) error {
|
||||
// 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()
|
||||
payload := ActivityTagCreatePayload{
|
||||
TagName: tag.Name,
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find tag")
|
||||
}
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to marshal activity payload")
|
||||
}
|
||||
activity, err := s.Store.CreateActivity(ctx, &store.Activity{
|
||||
CreatorID: tag.CreatorID,
|
||||
Type: ActivityTagCreate.String(),
|
||||
Level: ActivityInfo.String(),
|
||||
Payload: string(payloadBytes),
|
||||
|
||||
list, err := s.Store.ListTags(ctx, &store.FindTag{
|
||||
CreatorID: userID,
|
||||
})
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find tag list").SetInternal(err)
|
||||
}
|
||||
return 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 {
|
||||
@@ -176,7 +199,7 @@ func convertTagFromStore(tag *store.Tag) *Tag {
|
||||
}
|
||||
}
|
||||
|
||||
var tagRegexp = regexp.MustCompile(`#((?:[^\s\p{P}]|_)+)`)
|
||||
var tagRegexp = regexp.MustCompile(`#([^\s#,]+)`)
|
||||
|
||||
func findTagListFromMemoContent(memoContent string) []string {
|
||||
tagMapSet := make(map[string]bool)
|
||||
|
||||
@@ -2,8 +2,6 @@ package v1
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
func TestFindTagListFromMemoContent(t *testing.T) {
|
||||
@@ -37,16 +35,12 @@ func TestFindTagListFromMemoContent(t *testing.T) {
|
||||
},
|
||||
{
|
||||
memoContent: "#tag1 http://123123.com?123123#tag2 \n#tag3 #tag4 http://123123.com?123123#tag2) ",
|
||||
want: []string{"tag1", "tag2", "tag3", "tag4"},
|
||||
},
|
||||
{
|
||||
memoContent: "#tag1,#tag2! #tag3.. #tag_4",
|
||||
want: []string{"tag1", "tag2", "tag3", "tag_4"},
|
||||
want: []string{"tag1", "tag2", "tag2)", "tag3", "tag4"},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
result := findTagListFromMemoContent(test.memoContent)
|
||||
if !slices.Equal(result, test.want) {
|
||||
if len(result) != len(test.want) {
|
||||
t.Errorf("Find tag list %s: got result %v, want %v.", test.memoContent, result, test.want)
|
||||
}
|
||||
}
|
||||
|
||||
761
api/v1/user.go
761
api/v1/user.go
@@ -4,14 +4,16 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/common/util"
|
||||
"github.com/usememos/memos/store"
|
||||
"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.
|
||||
@@ -31,7 +33,7 @@ func (role Role) String() string {
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int `json:"id"`
|
||||
ID int32 `json:"id"`
|
||||
|
||||
// Standard fields
|
||||
RowStatus RowStatus `json:"rowStatus"`
|
||||
@@ -39,14 +41,12 @@ type User struct {
|
||||
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"`
|
||||
AvatarURL string `json:"avatarUrl"`
|
||||
UserSettingList []*UserSetting `json:"userSettingList"`
|
||||
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 {
|
||||
@@ -57,367 +57,415 @@ type CreateUserRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (create CreateUserRequest) Validate() error {
|
||||
if len(create.Username) < 3 {
|
||||
return fmt.Errorf("username is too short, minimum length is 3")
|
||||
}
|
||||
if len(create.Username) > 32 {
|
||||
return fmt.Errorf("username is too long, maximum length is 32")
|
||||
}
|
||||
if len(create.Password) < 3 {
|
||||
return fmt.Errorf("password is too short, minimum length is 3")
|
||||
}
|
||||
if len(create.Password) > 512 {
|
||||
return fmt.Errorf("password is too long, maximum length is 512")
|
||||
}
|
||||
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 !util.ValidateEmail(create.Email) {
|
||||
return fmt.Errorf("invalid email format")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
RowStatus *RowStatus `json:"rowStatus"`
|
||||
Username *string `json:"username"`
|
||||
Email *string `json:"email"`
|
||||
Nickname *string `json:"nickname"`
|
||||
Password *string `json:"password"`
|
||||
ResetOpenID *bool `json:"resetOpenId"`
|
||||
AvatarURL *string `json:"avatarUrl"`
|
||||
}
|
||||
|
||||
func (update UpdateUserRequest) Validate() error {
|
||||
if update.Username != nil && len(*update.Username) < 3 {
|
||||
return fmt.Errorf("username is too short, minimum length is 3")
|
||||
}
|
||||
if update.Username != nil && len(*update.Username) > 32 {
|
||||
return fmt.Errorf("username is too long, maximum length is 32")
|
||||
}
|
||||
if update.Password != nil && len(*update.Password) < 3 {
|
||||
return fmt.Errorf("password is too short, minimum length is 3")
|
||||
}
|
||||
if update.Password != nil && len(*update.Password) > 512 {
|
||||
return fmt.Errorf("password is too long, maximum length is 512")
|
||||
}
|
||||
if update.Nickname != nil && len(*update.Nickname) > 64 {
|
||||
return fmt.Errorf("nickname is too long, maximum length is 64")
|
||||
}
|
||||
if update.AvatarURL != nil {
|
||||
if len(*update.AvatarURL) > 2<<20 {
|
||||
return fmt.Errorf("avatar is too large, maximum is 2MB")
|
||||
}
|
||||
}
|
||||
if update.Email != nil && *update.Email != "" {
|
||||
if len(*update.Email) > 256 {
|
||||
return fmt.Errorf("email is too long, maximum length is 256")
|
||||
}
|
||||
if !util.ValidateEmail(*update.Email) {
|
||||
return fmt.Errorf("invalid email format")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
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) {
|
||||
// POST /user - Create a new user.
|
||||
g.POST("/user", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
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")
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
// Disallow host user to be created.
|
||||
if userCreate.Role == RoleHost {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "Could not create host user")
|
||||
}
|
||||
// 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")
|
||||
}
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(userCreate.Password), bcrypt.DefaultCost)
|
||||
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)
|
||||
}
|
||||
|
||||
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),
|
||||
OpenID: util.GenUUID(),
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
|
||||
}
|
||||
passwordHashStr := string(passwordHash)
|
||||
userUpdate.PasswordHash = &passwordHashStr
|
||||
}
|
||||
if request.AvatarURL != nil {
|
||||
userUpdate.AvatarURL = request.AvatarURL
|
||||
}
|
||||
|
||||
userMessage := convertUserFromStore(user)
|
||||
if err := s.createUserCreateActivity(c, userMessage); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, userMessage)
|
||||
})
|
||||
user, err := s.Store.UpdateUser(ctx, userUpdate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch user").SetInternal(err)
|
||||
}
|
||||
|
||||
// GET /user - List all users.
|
||||
g.GET("/user", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
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.OpenID = ""
|
||||
userMessage.Email = ""
|
||||
userMessageList = append(userMessageList, userMessage)
|
||||
}
|
||||
return c.JSON(http.StatusOK, userMessageList)
|
||||
})
|
||||
|
||||
// GET /user/me - Get current user.
|
||||
g.GET("/user/me", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
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")
|
||||
}
|
||||
|
||||
list, err := s.Store.ListUserSettings(ctx, &store.FindUserSetting{
|
||||
UserID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find userSettingList").SetInternal(err)
|
||||
}
|
||||
userSettingList := []*UserSetting{}
|
||||
for _, userSetting := range list {
|
||||
userSettingList = append(userSettingList, convertUserSettingFromStore(userSetting))
|
||||
}
|
||||
userMessage := convertUserFromStore(user)
|
||||
userMessage.UserSettingList = userSettingList
|
||||
return c.JSON(http.StatusOK, userMessage)
|
||||
})
|
||||
|
||||
// GET /user/:id - Get user by id.
|
||||
g.GET("/user/:id", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
id, err := strconv.Atoi(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)
|
||||
// data desensitize
|
||||
userMessage.OpenID = ""
|
||||
userMessage.Email = ""
|
||||
return c.JSON(http.StatusOK, userMessage)
|
||||
})
|
||||
|
||||
// GET /user/:username - Get user by username.
|
||||
g.GET("/user/:username", func(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.OpenID = ""
|
||||
userMessage.Email = ""
|
||||
return c.JSON(http.StatusOK, userMessage)
|
||||
})
|
||||
|
||||
// PUT /user/:id - Update user by id.
|
||||
g.PATCH("/user/:id", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, err := strconv.Atoi(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(getUserIDContextKey()).(int)
|
||||
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 {
|
||||
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.ResetOpenID != nil && *request.ResetOpenID {
|
||||
openID := util.GenUUID()
|
||||
userUpdate.OpenID = &openID
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
list, err := s.Store.ListUserSettings(ctx, &store.FindUserSetting{
|
||||
UserID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find userSettingList").SetInternal(err)
|
||||
}
|
||||
userSettingList := []*UserSetting{}
|
||||
for _, userSetting := range list {
|
||||
userSettingList = append(userSettingList, convertUserSettingFromStore(userSetting))
|
||||
}
|
||||
userMessage := convertUserFromStore(user)
|
||||
userMessage.UserSettingList = userSettingList
|
||||
return c.JSON(http.StatusOK, userMessage)
|
||||
})
|
||||
|
||||
// DELETE /user/:id - Delete user by id.
|
||||
g.DELETE("/user/:id", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
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 := strconv.Atoi(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)
|
||||
})
|
||||
userMessage := convertUserFromStore(user)
|
||||
return c.JSON(http.StatusOK, userMessage)
|
||||
}
|
||||
|
||||
func (s *APIV1Service) createUserCreateActivity(c echo.Context, user *User) error {
|
||||
ctx := c.Request().Context()
|
||||
payload := ActivityUserCreatePayload{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
func (create CreateUserRequest) Validate() error {
|
||||
if len(create.Username) < 3 {
|
||||
return errors.New("username is too short, minimum length is 3")
|
||||
}
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to marshal activity payload")
|
||||
if len(create.Username) > 32 {
|
||||
return errors.New("username is too long, maximum length is 32")
|
||||
}
|
||||
activity, err := s.Store.CreateActivity(ctx, &store.Activity{
|
||||
CreatorID: user.ID,
|
||||
Type: ActivityUserCreate.String(),
|
||||
Level: ActivityInfo.String(),
|
||||
Payload: string(payloadBytes),
|
||||
})
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
if len(create.Password) < 3 {
|
||||
return errors.New("password is too short, minimum length is 3")
|
||||
}
|
||||
return err
|
||||
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 {
|
||||
@@ -431,7 +479,6 @@ func convertUserFromStore(user *store.User) *User {
|
||||
Email: user.Email,
|
||||
Nickname: user.Nickname,
|
||||
PasswordHash: user.PasswordHash,
|
||||
OpenID: user.OpenID,
|
||||
AvatarURL: user.AvatarURL,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/usememos/memos/store"
|
||||
"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 = "memo-visibility"
|
||||
// UserSettingTelegramUserID is the key type for telegram UserID of memos user.
|
||||
UserSettingTelegramUserIDKey UserSettingKey = "telegram-user-id"
|
||||
)
|
||||
|
||||
// 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 "memo-visibility"
|
||||
case UserSettingTelegramUserIDKey:
|
||||
return "telegram-user-id"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var (
|
||||
UserSettingLocaleValue = []string{
|
||||
"de",
|
||||
"en",
|
||||
"es",
|
||||
"fr",
|
||||
"hi",
|
||||
"hr",
|
||||
"it",
|
||||
"ja",
|
||||
"ko",
|
||||
"nl",
|
||||
"pl",
|
||||
"pt-BR",
|
||||
"ru",
|
||||
"sl",
|
||||
"sv",
|
||||
"tr",
|
||||
"uk",
|
||||
"vi",
|
||||
"zh-Hans",
|
||||
"zh-Hant",
|
||||
}
|
||||
UserSettingAppearanceValue = []string{"system", "light", "dark"}
|
||||
UserSettingMemoVisibilityValue = []Visibility{Private, Protected, Public}
|
||||
)
|
||||
|
||||
type UserSetting struct {
|
||||
UserID int `json:"userId"`
|
||||
Key UserSettingKey `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type UpsertUserSettingRequest struct {
|
||||
UserID int `json:"-"`
|
||||
Key UserSettingKey `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
func (upsert UpsertUserSettingRequest) 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 == UserSettingTelegramUserIDKey {
|
||||
var key string
|
||||
err := json.Unmarshal([]byte(upsert.Value), &key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid user setting telegram user id value")
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("invalid user setting key")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerUserSettingRoutes(g *echo.Group) {
|
||||
g.POST("/user/setting", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
|
||||
}
|
||||
|
||||
userSettingUpsert := &UpsertUserSettingRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(userSettingUpsert); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user setting upsert request").SetInternal(err)
|
||||
}
|
||||
if err := userSettingUpsert.Validate(); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user setting format").SetInternal(err)
|
||||
}
|
||||
|
||||
userSettingUpsert.UserID = userID
|
||||
userSetting, err := s.Store.UpsertUserSetting(ctx, &store.UserSetting{
|
||||
UserID: userID,
|
||||
Key: userSettingUpsert.Key.String(),
|
||||
Value: userSettingUpsert.Value,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert user setting").SetInternal(err)
|
||||
}
|
||||
|
||||
userSettingMessage := convertUserSettingFromStore(userSetting)
|
||||
return c.JSON(http.StatusOK, userSettingMessage)
|
||||
})
|
||||
}
|
||||
|
||||
func convertUserSettingFromStore(userSetting *store.UserSetting) *UserSetting {
|
||||
return &UserSetting{
|
||||
UserID: userSetting.UserID,
|
||||
Key: UserSettingKey(userSetting.Key),
|
||||
Value: userSetting.Value,
|
||||
}
|
||||
}
|
||||
63
api/v1/v1.go
63
api/v1/v1.go
@@ -1,22 +1,45 @@
|
||||
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
|
||||
Secret string
|
||||
Profile *profile.Profile
|
||||
Store *store.Store
|
||||
telegramBot *telegram.Bot
|
||||
}
|
||||
|
||||
func NewAPIV1Service(secret string, profile *profile.Profile, store *store.Store) *APIV1Service {
|
||||
// @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,
|
||||
Secret: secret,
|
||||
Profile: profile,
|
||||
Store: store,
|
||||
telegramBot: telegramBot,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +49,21 @@ func (s *APIV1Service) Register(rootGroup *echo.Group) {
|
||||
|
||||
// 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)
|
||||
})
|
||||
@@ -34,16 +72,12 @@ func (s *APIV1Service) Register(rootGroup *echo.Group) {
|
||||
s.registerAuthRoutes(apiV1Group)
|
||||
s.registerIdentityProviderRoutes(apiV1Group)
|
||||
s.registerUserRoutes(apiV1Group)
|
||||
s.registerUserSettingRoutes(apiV1Group)
|
||||
s.registerTagRoutes(apiV1Group)
|
||||
s.registerShortcutRoutes(apiV1Group)
|
||||
s.registerStorageRoutes(apiV1Group)
|
||||
s.registerResourceRoutes(apiV1Group)
|
||||
s.registerMemoRoutes(apiV1Group)
|
||||
s.registerMemoOrganizerRoutes(apiV1Group)
|
||||
s.registerMemoResourceRoutes(apiV1Group)
|
||||
s.registerMemoRelationRoutes(apiV1Group)
|
||||
s.registerOpenAIRoutes(apiV1Group)
|
||||
|
||||
// Register public routes.
|
||||
publicGroup := rootGroup.Group("/o")
|
||||
@@ -51,5 +85,10 @@ func (s *APIV1Service) Register(rootGroup *echo.Group) {
|
||||
return JWTMiddleware(s, next, s.Secret)
|
||||
})
|
||||
s.registerGetterPublicRoutes(publicGroup)
|
||||
s.registerResourcePublicRoutes(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,4 +1,4 @@
|
||||
package cmd
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -7,14 +7,15 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"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/setup"
|
||||
"github.com/usememos/memos/server/service/metric"
|
||||
"github.com/usememos/memos/store"
|
||||
"github.com/usememos/memos/store/db"
|
||||
)
|
||||
@@ -31,31 +32,45 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
profile *_profile.Profile
|
||||
mode string
|
||||
port int
|
||||
data string
|
||||
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())
|
||||
db := db.NewDB(profile)
|
||||
if err := db.Open(ctx); err != nil {
|
||||
dbDriver, err := db.NewDBDriver(profile)
|
||||
if err != nil {
|
||||
cancel()
|
||||
fmt.Printf("failed to open db, error: %+v\n", err)
|
||||
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(db.DBInstance, profile)
|
||||
store := store.New(dbDriver, profile)
|
||||
s, err := server.NewServer(ctx, profile, store)
|
||||
if err != nil {
|
||||
cancel()
|
||||
fmt.Printf("failed to create server, error: %+v\n", err)
|
||||
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,
|
||||
@@ -63,16 +78,16 @@ var (
|
||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
||||
go func() {
|
||||
sig := <-c
|
||||
fmt.Printf("%s received.\n", sig.String())
|
||||
log.Info(fmt.Sprintf("%s received.\n", sig.String()))
|
||||
s.Shutdown(ctx)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
println(greetingBanner)
|
||||
fmt.Printf("Version %s has started at :%d\n", profile.Version, profile.Port)
|
||||
printGreetings()
|
||||
|
||||
if err := s.Start(ctx); err != nil {
|
||||
if err != http.ErrServerClosed {
|
||||
fmt.Printf("failed to start server, error: %+v\n", err)
|
||||
log.Error("failed to start server", zap.Error(err))
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
@@ -81,42 +96,10 @@ var (
|
||||
<-ctx.Done()
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
db := db.NewDB(profile)
|
||||
if err := db.Open(ctx); err != nil {
|
||||
fmt.Printf("failed to open db, error: %+v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
store := store.New(db.DBInstance, profile)
|
||||
if err := setup.Execute(ctx, store, hostUsername, hostPassword); err != nil {
|
||||
fmt.Printf("failed to setup, error: %+v\n", err)
|
||||
return
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func Execute() error {
|
||||
defer log.Sync()
|
||||
return rootCmd.Execute()
|
||||
}
|
||||
|
||||
@@ -124,13 +107,21 @@ 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)
|
||||
@@ -139,15 +130,25 @@ func init() {
|
||||
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")
|
||||
|
||||
setupCmd.Flags().String(setupCmdFlagHostUsername, "", "Owner username")
|
||||
setupCmd.Flags().String(setupCmdFlagHostPassword, "", "Owner password")
|
||||
|
||||
rootCmd.AddCommand(setupCmd)
|
||||
}
|
||||
|
||||
func initConfig() {
|
||||
@@ -161,14 +162,34 @@ func initConfig() {
|
||||
|
||||
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("---")
|
||||
}
|
||||
|
||||
const (
|
||||
setupCmdFlagHostUsername = "host-username"
|
||||
setupCmdFlagHostPassword = "host-password"
|
||||
)
|
||||
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("---")
|
||||
}
|
||||
|
||||
func main() {
|
||||
err := Execute()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,70 @@
|
||||
# 1.Prepare your workspace by:
|
||||
# docker compose run api go install github.com/cosmtrek/air@latest
|
||||
# docker compose run web npm install
|
||||
#
|
||||
# 2. Start you work by:
|
||||
# docker compose up -d
|
||||
#
|
||||
# 3. Check logs by:
|
||||
# docker compose logs -f
|
||||
#
|
||||
services:
|
||||
api:
|
||||
image: golang:1.19.3-alpine3.16
|
||||
working_dir: /work
|
||||
command: air -c ./scripts/.air.toml
|
||||
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:
|
||||
- $HOME/go/pkg/:/go/pkg/ # Cache for go mod shared with the host
|
||||
- ./.air/bin/:/go/bin/ # Cache for binary used only in container, such as *air*
|
||||
- .:/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.12.1-alpine3.16
|
||||
image: node:20-alpine
|
||||
working_dir: /work
|
||||
depends_on: ["api"]
|
||||
ports: ["3001:3001"]
|
||||
environment: ["DEV_PROXY_SERVER=http://api:8081/"]
|
||||
command: npm run dev
|
||||
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:20-alpine
|
||||
working_dir: /work
|
||||
environment: ["NPM_CONFIG_UPDATE_NOTIFIER=false"]
|
||||
entrypoint: "npm"
|
||||
volumes:
|
||||
- ./web:/work
|
||||
- ./.air/node_modules/:/work/node_modules/
|
||||
|
||||
@@ -1,17 +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", "demo"]
|
||||
|
||||
volumes:
|
||||
memos_volume:
|
||||
107
docs/api/auth.md
107
docs/api/auth.md
@@ -1,107 +0,0 @@
|
||||
# Authentication APIs
|
||||
|
||||
## Sign In
|
||||
|
||||
```
|
||||
POST /api/v1/auth/signin
|
||||
```
|
||||
|
||||
**Request Body**
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "john",
|
||||
"password": "password123"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"username": "john",
|
||||
"nickname": "John"
|
||||
// other user fields
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: Sign in success
|
||||
- 400: Invalid request
|
||||
- 401: Incorrect credentials
|
||||
- 403: User banned
|
||||
- 500: Internal server error
|
||||
|
||||
## SSO Sign In
|
||||
|
||||
```
|
||||
POST /api/v1/auth/signin/sso
|
||||
```
|
||||
|
||||
**Request Body**
|
||||
|
||||
```json
|
||||
{
|
||||
"identityProviderId": 123,
|
||||
"code": "abc123",
|
||||
"redirectUri": "https://example.com/callback"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
Same as **Sign In**
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: Success
|
||||
- 400: Invalid request
|
||||
- 401: Authentication failed
|
||||
- 403: User banned
|
||||
- 404: Identity provider not found
|
||||
- 500: Internal server error
|
||||
|
||||
## Sign Up
|
||||
|
||||
```
|
||||
POST /api/v1/auth/signup
|
||||
```
|
||||
|
||||
**Request Body**
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "mary",
|
||||
"password": "password456"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
Same as **Sign In**
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: Sign up success
|
||||
- 400: Invalid request
|
||||
- 401: Sign up disabled
|
||||
- 500: Internal server error
|
||||
|
||||
## Sign Out
|
||||
|
||||
```
|
||||
POST /api/v1/auth/signout
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
```
|
||||
true
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: Success
|
||||
- 500: Internal server error
|
||||
@@ -1,44 +0,0 @@
|
||||
# Guide to Access Memos API with OpenID
|
||||
|
||||
Memos API supports using OpenID as the user identifier to access the API.
|
||||
|
||||
## What is OpenID
|
||||
|
||||
OpenID is a unique identifier assigned by Memos system to each user.
|
||||
|
||||
When a user registers or logs in via third-party OAuth through Memos system, the OpenID will be generated automatically.
|
||||
|
||||
## How to Get User's OpenID
|
||||
|
||||
You can get a user's OpenID through:
|
||||
|
||||
- User checks the personal profile page in Memos system
|
||||
- Calling Memos API to get user details
|
||||
- Retrieving from login API response after successful login
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
// GET /api/v1/user/me
|
||||
|
||||
{
|
||||
"id": 123,
|
||||
"username": "john",
|
||||
"openId": "8613E04B4FA6603883F05A5E0A5E2517",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## How to Use OpenID to Access API
|
||||
|
||||
You can access the API on behalf of the user by appending `?openId=xxx` parameter to the API URL.
|
||||
|
||||
For example:
|
||||
|
||||
```
|
||||
curl 'https://demo.usememos.com/api/v1/memo?openId=8613E04B4FA6603883F05A5E0A5E2517' -H 'Content-Type: application/json' --data-raw '{"content":"Hello world!"}'
|
||||
```
|
||||
|
||||
The above request will create a Memo under the user with OpenID `8613E04B4FA6603883F05A5E0A5E2517`.
|
||||
|
||||
OpenID can be used in any API that requires user identity.
|
||||
@@ -1,67 +0,0 @@
|
||||
# Memo Relation APIs
|
||||
|
||||
## Create Memo Relation
|
||||
|
||||
```
|
||||
POST /api/v1/memo/:memoId/relation
|
||||
```
|
||||
|
||||
**Request Body**
|
||||
|
||||
```json
|
||||
{
|
||||
"relatedMemoId": 456,
|
||||
"type": "REFERENCE"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"memoId": 123,
|
||||
"relatedMemoId": 456,
|
||||
"type": "REFERENCE"
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: OK
|
||||
- 400: Invalid request
|
||||
- 500: Internal server error
|
||||
|
||||
## Get Memo Relations
|
||||
|
||||
```
|
||||
GET /api/v1/memo/:memoId/relation
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"memoId": 123,
|
||||
"relatedMemoId": 456,
|
||||
"type": "REFERENCE"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: OK
|
||||
- 500: Internal server error
|
||||
|
||||
## Delete Memo Relation
|
||||
|
||||
```
|
||||
DELETE /api/v1/memo/:memoId/relation/:relatedMemoId/type/:relationType
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: Deleted
|
||||
- 400: Invalid request
|
||||
- 500: Internal server error
|
||||
@@ -1,65 +0,0 @@
|
||||
# Memo Resource APIs
|
||||
|
||||
## Bind Resource to Memo
|
||||
|
||||
```
|
||||
POST /api/v1/memo/:memoId/resource
|
||||
```
|
||||
|
||||
**Request Body**
|
||||
|
||||
```json
|
||||
{
|
||||
"resourceId": 123
|
||||
}
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
```
|
||||
true
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: OK
|
||||
- 400: Invalid request
|
||||
- 401: Unauthorized
|
||||
- 404: Memo/Resource not found
|
||||
- 500: Internal server error
|
||||
|
||||
## Get Memo Resources
|
||||
|
||||
```
|
||||
GET /api/v1/memo/:memoId/resource
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 123,
|
||||
"filename": "example.png"
|
||||
// other resource fields
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: OK
|
||||
- 500: Internal server error
|
||||
|
||||
## Unbind Resource from Memo
|
||||
|
||||
```
|
||||
DELETE /api/v1/memo/:memoId/resource/:resourceId
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: OK
|
||||
- 401: Unauthorized
|
||||
- 404: Memo/Resource not found
|
||||
- 500: Internal server error
|
||||
136
docs/api/memo.md
136
docs/api/memo.md
@@ -1,136 +0,0 @@
|
||||
# Memo APIs
|
||||
|
||||
## Create Memo
|
||||
|
||||
```
|
||||
POST /api/v1/memo
|
||||
```
|
||||
|
||||
**Request Body**
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "Memo content",
|
||||
"visibility": "PUBLIC",
|
||||
"resourceIdList": [123, 456],
|
||||
"relationList": [{ "relatedMemoId": 789, "type": "REFERENCE" }]
|
||||
}
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1234,
|
||||
"content": "Memo content",
|
||||
"visibility": "PUBLIC"
|
||||
// other fields
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: Created
|
||||
- 400: Invalid request
|
||||
- 401: Unauthorized
|
||||
- 403: Forbidden to create public memo
|
||||
- 500: Internal server error
|
||||
|
||||
## Get Memo List
|
||||
|
||||
```
|
||||
GET /api/v1/memo
|
||||
```
|
||||
|
||||
**Parameters**
|
||||
|
||||
- `creatorId` (optional): Filter by creator ID
|
||||
- `visibility` (optional): Filter visibility, `PUBLIC`, `PROTECTED` or `PRIVATE`
|
||||
- `rowStatus` (optional): Filter Status, `ARCHIVE`, `NORMAL`, Default `NORMAL`
|
||||
- `pinned` (optional): Filter pinned memo, `true` or `false`
|
||||
- `tag` (optional): Filter memo with tag
|
||||
- `content` (optional): Search in content
|
||||
- `limit` (optional): Limit number of results
|
||||
- `offset` (optional): Offset of first result
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1234,
|
||||
"content": "Memo 1"
|
||||
// other fields
|
||||
},
|
||||
{
|
||||
"id": 5678,
|
||||
"content": "Memo 2"
|
||||
// other fields
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Get Memo By ID
|
||||
|
||||
```
|
||||
GET /api/v1/memo/:memoId
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1234,
|
||||
"content": "Memo content"
|
||||
// other fields
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: Success
|
||||
- 403: Forbidden for private memo
|
||||
- 404: Not found
|
||||
- 500: Internal server error
|
||||
|
||||
## Update Memo
|
||||
|
||||
```
|
||||
PATCH /api/v1/memo/:memoId
|
||||
```
|
||||
|
||||
**Request Body**
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "Updated content",
|
||||
"visibility": "PRIVATE"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
Same as **Get Memo By ID**
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: Updated
|
||||
- 400: Invalid request
|
||||
- 401: Unauthorized
|
||||
- 403: Forbidden
|
||||
- 404: Not found
|
||||
- 500: Internal server error
|
||||
|
||||
## Delete Memo
|
||||
|
||||
```
|
||||
DELETE /api/v1/memo/:memoId
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: Deleted
|
||||
- 401: Unauthorized
|
||||
- 403: Forbidden
|
||||
- 404: Not found
|
||||
- 500: Internal server error
|
||||
@@ -1,130 +0,0 @@
|
||||
# Resource APIs
|
||||
|
||||
## Upload Resource
|
||||
|
||||
### Upload File
|
||||
|
||||
```
|
||||
POST /api/v1/resource/blob
|
||||
```
|
||||
|
||||
**Request Form**
|
||||
|
||||
- `file`: Upload file
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"filename": "example.png"
|
||||
// other fields
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: OK
|
||||
- 400: Invalid request
|
||||
- 401: Unauthorized
|
||||
- 413: File too large
|
||||
- 500: Internal server error
|
||||
|
||||
### Create Resource
|
||||
|
||||
```
|
||||
POST /api/v1/resource
|
||||
```
|
||||
|
||||
**Request Body**
|
||||
|
||||
```json
|
||||
{
|
||||
"filename": "example.png",
|
||||
"externalLink": "https://example.com/image.png"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
Same as **Upload File**
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: OK
|
||||
- 400: Invalid request
|
||||
- 401: Unauthorized
|
||||
- 500: Internal server error
|
||||
|
||||
## Get Resource List
|
||||
|
||||
```
|
||||
GET /api/v1/resource
|
||||
```
|
||||
|
||||
**Parameters**
|
||||
|
||||
- `limit` (optional): Limit number of results
|
||||
- `offset` (optional): Offset of first result
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 123,
|
||||
"filename": "example.png"
|
||||
// other fields
|
||||
},
|
||||
{
|
||||
"id": 456,
|
||||
"filename": "doc.pdf"
|
||||
// other fields
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: OK
|
||||
- 401: Unauthorized
|
||||
- 500: Internal server error
|
||||
|
||||
## Update Resource
|
||||
|
||||
```
|
||||
PATCH /api/v1/resource/:resourceId
|
||||
```
|
||||
|
||||
**Request Body**
|
||||
|
||||
```json
|
||||
{
|
||||
"filename": "new_name.png"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
Same as **Get Resource List**
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: OK
|
||||
- 400: Invalid request
|
||||
- 401: Unauthorized
|
||||
- 404: Not found
|
||||
- 500: Internal server error
|
||||
|
||||
## Delete Resource
|
||||
|
||||
```
|
||||
DELETE /api/v1/resource/:resourceId
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: Deleted
|
||||
- 401: Unauthorized
|
||||
- 404: Not found
|
||||
- 500: Internal server error
|
||||
@@ -1,84 +0,0 @@
|
||||
# Tag APIs
|
||||
|
||||
## Create Tag
|
||||
|
||||
```
|
||||
POST /api/v1/tag
|
||||
```
|
||||
|
||||
**Request Body**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "python"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
```
|
||||
"python"
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: Created
|
||||
- 400: Invalid request
|
||||
- 500: Internal server error
|
||||
|
||||
## Get Tag List
|
||||
|
||||
```
|
||||
GET /api/v1/tag
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
["python", "golang", "javascript"]
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: OK
|
||||
- 401: Unauthorized
|
||||
- 500: Internal server error
|
||||
|
||||
## Suggest Tags
|
||||
|
||||
```
|
||||
GET /api/v1/tag/suggestion
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
["django", "flask", "numpy"]
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: OK
|
||||
- 401: Unauthorized
|
||||
- 500: Internal server error
|
||||
|
||||
## Delete Tag
|
||||
|
||||
```
|
||||
POST /api/v1/tag/delete
|
||||
```
|
||||
|
||||
**Request Body**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "outdated_tag"
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: Deleted
|
||||
- 400: Invalid request
|
||||
- 401: Unauthorized
|
||||
- 500: Internal server error
|
||||
164
docs/api/user.md
164
docs/api/user.md
@@ -1,164 +0,0 @@
|
||||
# User APIs
|
||||
|
||||
## Create User
|
||||
|
||||
```
|
||||
POST /api/v1/user
|
||||
```
|
||||
|
||||
**Request Body**
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "john",
|
||||
"role": "USER",
|
||||
"email": "john@example.com",
|
||||
"nickname": "John",
|
||||
"password": "password123"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"username": "john",
|
||||
"role": "USER",
|
||||
"email": "john@example.com",
|
||||
"nickname": "John",
|
||||
"avatarUrl": "",
|
||||
"createdTs": 1596647800,
|
||||
"updatedTs": 1596647800
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: Success
|
||||
- 400: Validation error
|
||||
- 401: Unauthorized
|
||||
- 403: Forbidden to create host user
|
||||
- 500: Internal server error
|
||||
|
||||
## Get User List
|
||||
|
||||
```
|
||||
GET /api/v1/user
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 123,
|
||||
"username": "john",
|
||||
"role": "USER"
|
||||
// other fields
|
||||
},
|
||||
{
|
||||
"id": 456,
|
||||
"username": "mary",
|
||||
"role": "ADMIN"
|
||||
// other fields
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: Success
|
||||
- 500: Internal server error
|
||||
|
||||
## Get User By ID
|
||||
|
||||
```
|
||||
GET /api/v1/user/:id
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"username": "john",
|
||||
"role": "USER"
|
||||
// other fields
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: Success
|
||||
- 404: Not found
|
||||
- 500: Internal server error
|
||||
|
||||
## Update User
|
||||
|
||||
```
|
||||
PATCH /api/v1/user/:id
|
||||
```
|
||||
|
||||
**Request Body**
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "johnny",
|
||||
"email": "johnny@example.com",
|
||||
"nickname": "Johnny",
|
||||
"avatarUrl": "https://avatars.example.com/u=123"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"username": "johnny",
|
||||
"role": "USER",
|
||||
"email": "johnny@example.com",
|
||||
"nickname": "Johnny",
|
||||
"avatarUrl": "https://avatars.example.com/u=123",
|
||||
"createdTs": 1596647800,
|
||||
"updatedTs": 1596647900
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: Success
|
||||
- 400: Validation error
|
||||
- 403: Forbidden
|
||||
- 404: Not found
|
||||
- 500: Internal server error
|
||||
|
||||
## Delete User
|
||||
|
||||
```
|
||||
DELETE /api/v1/user/:id
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: Success
|
||||
- 403: Forbidden
|
||||
- 404: Not found
|
||||
- 500: Internal server error
|
||||
|
||||
## Get Current User
|
||||
|
||||
```
|
||||
GET /api/v1/user/me
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
Same as **Get User By ID**
|
||||
|
||||
**Status Codes**
|
||||
|
||||
- 200: Success
|
||||
- 401: Unauthorized
|
||||
- 500: Internal server error
|
||||
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,18 +0,0 @@
|
||||
# Adding A Custom Theme
|
||||
|
||||
1. Open the Settings Dialog
|
||||
2. Navigate to the System Tab
|
||||
3. In the "Additional Styles" box add these lines of code:
|
||||
|
||||
```css
|
||||
.memo-list-container {
|
||||
background-color: #INSERT COLOR HERE;
|
||||
}
|
||||
.page-container {
|
||||
background-color: #INSERT COLOR HERE;
|
||||
}
|
||||
```
|
||||
|
||||
It is recommended that you choose the same color for both options
|
||||
|
||||
4. Refresh the page and the background color of your memos app will successfully update to reflect your changes
|
||||
@@ -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://usememos.com/logo-full.png" 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 reliability 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).
|
||||
@@ -1,4 +1,4 @@
|
||||
# Development
|
||||
# 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:
|
||||
|
||||
@@ -8,7 +8,7 @@ Memos is built with a curated tech stack. It is optimized for developer experien
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Frontend | Backend |
|
||||
| Frontend | Backend |
|
||||
| ---------------------------------------- | --------------------------------- |
|
||||
| [React](https://react.dev/) | [Go](https://go.dev/) |
|
||||
| [Tailwind CSS](https://tailwindcss.com/) | [SQLite](https://www.sqlite.org/) |
|
||||
@@ -56,12 +56,12 @@ Memos should now be running at [http://localhost:3001](http://localhost:3001) an
|
||||
|
||||
## 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 must be built before backend. The built frontend must be placed in the backend ./server/frontend/dist directory. Otherwise, you will get a "No frontend embeded" error.
|
||||
|
||||
### Frontend
|
||||
|
||||
```powershell
|
||||
Move-Item "./server/dist" "./server/dist.bak"
|
||||
Move-Item "./server/frontend/dist" "./server/frontend/dist.bak"
|
||||
cd web; pnpm i --frozen-lockfile; pnpm build; cd ..;
|
||||
Move-Item "./web/dist" "./server/" -Force
|
||||
```
|
||||
@@ -69,22 +69,22 @@ Move-Item "./web/dist" "./server/" -Force
|
||||
### Backend
|
||||
|
||||
```powershell
|
||||
go build -o ./build/memos.exe ./main.go
|
||||
go build -o ./build/memos.exe ./bin/memos/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:
|
||||
This will start both backend and frontend in detached PowerShell windows:
|
||||
|
||||
```powershell
|
||||
.\scripts\start.ps1
|
||||
```
|
||||
```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
|
||||
```
|
||||
```powershell
|
||||
.\scripts\build.ps1
|
||||
```
|
||||
|
||||
This will produce a memos.exe file in the ./build directory.
|
||||
This will produce a memos.exe file in the ./build directory.
|
||||
|
||||
@@ -15,22 +15,28 @@ Memos is built with a curated tech stack. It is optimized for developer experien
|
||||
|
||||
## 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 && pnpm i && pnpm 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.
|
||||
@@ -1,6 +0,0 @@
|
||||
# Setup
|
||||
|
||||
After deploying and running Memos in `prod` mode, you should create "host" user. There are two ways to do this:
|
||||
|
||||
1. Navigate to the Memos application URL, such as `http://localhost:5230`, and follow the prompts to create a username and password for the "host" user.
|
||||
2. Use the command `memos setup --host-username=$USERNAME --host-password=$PASSWORD --mode=prod` to set up the host user. This method may be more convenient for deploying through Ansible or other provisioning softwares.
|
||||
@@ -1,11 +0,0 @@
|
||||
# Updating memos after deploying
|
||||
|
||||
## fly.io
|
||||
|
||||
### update to latest
|
||||
Under the directory where you had your `fly.toml` file
|
||||
|
||||
```
|
||||
flyctl deploy
|
||||
```
|
||||
|
||||
@@ -24,7 +24,7 @@ nssm install memos "C:\path\to\memos.exe" --mode prod --port 5230
|
||||
nssm set memos DisplayName "memos service"
|
||||
|
||||
# Configure extra service parameters
|
||||
nssm set memos Description "A lightweight, self-hosted memo hub. https://usememos.com/"
|
||||
nssm set memos Description "A lightweight, self-hosted memo hub. https://usememos.com/"
|
||||
|
||||
# Delay auto start
|
||||
nssm set memos Start SERVICE_DELAYED_AUTO_START
|
||||
@@ -54,7 +54,7 @@ Now, in the same directory, create the service configuration file `memos-service
|
||||
<executable>%BASE%\memos.exe</executable>
|
||||
<arguments>--mode prod --port 5230</arguments>
|
||||
<delayedAutoStart>true</delayedAutoStart>
|
||||
<log mode="none" />
|
||||
<log mode="none" />
|
||||
</service>
|
||||
```
|
||||
|
||||
@@ -86,12 +86,12 @@ Also, by using one of the provided methods, the service will appear in the Windo
|
||||
|
||||
- On Windows, memos store its data in the following directory:
|
||||
|
||||
```powershell
|
||||
$env:ProgramData\memos
|
||||
# Typically, this will resolve to C:\ProgramData\memos
|
||||
```
|
||||
```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.
|
||||
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`.
|
||||
|
||||
|
||||
168
go.mod
168
go.mod
@@ -1,96 +1,126 @@
|
||||
module github.com/usememos/memos
|
||||
|
||||
go 1.19
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/CorrectRoadH/echo-sse v0.1.4
|
||||
github.com/PullRequestInc/go-gpt3 v1.1.15
|
||||
github.com/aws/aws-sdk-go-v2 v1.17.4
|
||||
github.com/aws/aws-sdk-go-v2/config v1.18.12
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.13.12
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.51
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.30.3
|
||||
github.com/Masterminds/squirrel v1.5.4
|
||||
github.com/aws/aws-sdk-go-v2 v1.24.0
|
||||
github.com/aws/aws-sdk-go-v2/config v1.26.1
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.16.12
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.7
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/gorilla/feeds v1.1.1
|
||||
github.com/labstack/echo/v4 v4.9.0
|
||||
github.com/go-sql-driver/mysql v1.7.1
|
||||
github.com/google/cel-go v0.18.2
|
||||
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.3
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/microcosm-cc/bluemonday v1.0.26
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/spf13/cobra v1.6.1
|
||||
github.com/spf13/viper v1.15.0
|
||||
github.com/stretchr/testify v1.8.1
|
||||
github.com/yuin/goldmark v1.5.4
|
||||
go.uber.org/zap v1.24.0
|
||||
golang.org/x/crypto v0.1.0
|
||||
golang.org/x/exp v0.0.0-20230111222715-75897c7a292a
|
||||
golang.org/x/mod v0.8.0
|
||||
golang.org/x/net v0.7.0
|
||||
golang.org/x/oauth2 v0.5.0
|
||||
modernc.org/sqlite v1.24.0
|
||||
github.com/spf13/cobra v1.8.0
|
||||
github.com/spf13/viper v1.18.1
|
||||
github.com/stretchr/testify v1.8.4
|
||||
github.com/swaggo/echo-swagger v1.4.1
|
||||
github.com/swaggo/swag v1.16.2
|
||||
go.uber.org/zap v1.26.0
|
||||
golang.org/x/crypto v0.16.0
|
||||
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb
|
||||
golang.org/x/mod v0.14.0
|
||||
golang.org/x/net v0.19.0
|
||||
golang.org/x/oauth2 v0.15.0
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20231127180814-3a041ad873d4
|
||||
google.golang.org/grpc v1.59.0
|
||||
modernc.org/sqlite v1.27.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.0 // 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.11 // 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
|
||||
golang.org/x/image v0.7.0 // indirect
|
||||
golang.org/x/tools v0.6.0 // indirect
|
||||
lukechampine.com/uint128 v1.2.0 // indirect
|
||||
modernc.org/cc/v3 v3.40.0 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.13 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
github.com/rs/cors v1.10.1 // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.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.14.0 // indirect
|
||||
golang.org/x/tools v0.16.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20231127180814-3a041ad873d4 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4 // 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.37.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.1.3 // indirect
|
||||
modernc.org/token v1.0.1 // 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.4.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.28 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.12.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.18.3 // indirect
|
||||
github.com/aws/smithy-go v1.13.5 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.6.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 // indirect
|
||||
github.com/aws/smithy-go v1.19.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.2 // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.0.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/labstack/gommon v0.3.1 // indirect
|
||||
github.com/labstack/gommon v0.4.1 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.12 // indirect
|
||||
github.com/mattn/go-isatty v0.0.16 // 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.0.6 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/spf13/afero v1.9.3 // indirect
|
||||
github.com/spf13/cast v1.5.0 // indirect
|
||||
github.com/spf13/jwalterweatherman v1.1.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.11.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/subosito/gotenv v1.4.2 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.1 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.8.0 // indirect
|
||||
golang.org/x/sys v0.5.0 // indirect
|
||||
golang.org/x/text v0.9.0 // indirect
|
||||
golang.org/x/time v0.1.0 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/protobuf v1.28.1 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/sys v0.15.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
golang.org/x/time v0.5.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package log implements a simple logging package.
|
||||
package log
|
||||
|
||||
import (
|
||||
@@ -4,11 +4,21 @@ 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 {
|
||||
14
main.go
14
main.go
@@ -1,14 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/usememos/memos/cmd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1 @@
|
||||
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)
|
||||
}
|
||||
|
||||
66
plugin/gomark/ast/block.go
Normal file
66
plugin/gomark/ast/block.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package ast
|
||||
|
||||
type BaseBlock struct {
|
||||
BaseNode
|
||||
}
|
||||
|
||||
type LineBreak struct {
|
||||
BaseBlock
|
||||
}
|
||||
|
||||
func (*LineBreak) Type() NodeType {
|
||||
return LineBreakNode
|
||||
}
|
||||
|
||||
type Paragraph struct {
|
||||
BaseBlock
|
||||
|
||||
Children []Node
|
||||
}
|
||||
|
||||
func (*Paragraph) Type() NodeType {
|
||||
return ParagraphNode
|
||||
}
|
||||
|
||||
type CodeBlock struct {
|
||||
BaseBlock
|
||||
|
||||
Language string
|
||||
Content string
|
||||
}
|
||||
|
||||
func (*CodeBlock) Type() NodeType {
|
||||
return CodeBlockNode
|
||||
}
|
||||
|
||||
type Heading struct {
|
||||
BaseBlock
|
||||
|
||||
Level int
|
||||
Children []Node
|
||||
}
|
||||
|
||||
func (*Heading) Type() NodeType {
|
||||
return HeadingNode
|
||||
}
|
||||
|
||||
type HorizontalRule struct {
|
||||
BaseBlock
|
||||
|
||||
// Symbol is "*" or "-" or "_".
|
||||
Symbol string
|
||||
}
|
||||
|
||||
func (*HorizontalRule) Type() NodeType {
|
||||
return HorizontalRuleNode
|
||||
}
|
||||
|
||||
type Blockquote struct {
|
||||
BaseBlock
|
||||
|
||||
Children []Node
|
||||
}
|
||||
|
||||
func (*Blockquote) Type() NodeType {
|
||||
return BlockquoteNode
|
||||
}
|
||||
103
plugin/gomark/ast/inline.go
Normal file
103
plugin/gomark/ast/inline.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package ast
|
||||
|
||||
type BaseInline struct {
|
||||
BaseNode
|
||||
}
|
||||
|
||||
type Text struct {
|
||||
BaseInline
|
||||
|
||||
Content string
|
||||
}
|
||||
|
||||
func (*Text) Type() NodeType {
|
||||
return TextNode
|
||||
}
|
||||
|
||||
type Bold struct {
|
||||
BaseInline
|
||||
|
||||
// Symbol is "*" or "_".
|
||||
Symbol string
|
||||
Content string
|
||||
}
|
||||
|
||||
func (*Bold) Type() NodeType {
|
||||
return BoldNode
|
||||
}
|
||||
|
||||
type Italic struct {
|
||||
BaseInline
|
||||
|
||||
// Symbol is "*" or "_".
|
||||
Symbol string
|
||||
Content string
|
||||
}
|
||||
|
||||
func (*Italic) Type() NodeType {
|
||||
return ItalicNode
|
||||
}
|
||||
|
||||
type BoldItalic struct {
|
||||
BaseInline
|
||||
|
||||
// Symbol is "*" or "_".
|
||||
Symbol string
|
||||
Content string
|
||||
}
|
||||
|
||||
func (*BoldItalic) Type() NodeType {
|
||||
return BoldItalicNode
|
||||
}
|
||||
|
||||
type Code struct {
|
||||
BaseInline
|
||||
|
||||
Content string
|
||||
}
|
||||
|
||||
func (*Code) Type() NodeType {
|
||||
return CodeNode
|
||||
}
|
||||
|
||||
type Image struct {
|
||||
BaseInline
|
||||
|
||||
AltText string
|
||||
URL string
|
||||
}
|
||||
|
||||
func (*Image) Type() NodeType {
|
||||
return ImageNode
|
||||
}
|
||||
|
||||
type Link struct {
|
||||
BaseInline
|
||||
|
||||
Text string
|
||||
URL string
|
||||
}
|
||||
|
||||
func (*Link) Type() NodeType {
|
||||
return LinkNode
|
||||
}
|
||||
|
||||
type Tag struct {
|
||||
BaseInline
|
||||
|
||||
Content string
|
||||
}
|
||||
|
||||
func (*Tag) Type() NodeType {
|
||||
return TagNode
|
||||
}
|
||||
|
||||
type Strikethrough struct {
|
||||
BaseInline
|
||||
|
||||
Content string
|
||||
}
|
||||
|
||||
func (*Strikethrough) Type() NodeType {
|
||||
return StrikethroughNode
|
||||
}
|
||||
@@ -1,12 +1,63 @@
|
||||
package ast
|
||||
|
||||
func NewNode(tp, text string) *Node {
|
||||
return &Node{
|
||||
Type: tp,
|
||||
Text: text,
|
||||
}
|
||||
type NodeType uint32
|
||||
|
||||
const (
|
||||
UnknownNode NodeType = iota
|
||||
// Block nodes.
|
||||
LineBreakNode
|
||||
ParagraphNode
|
||||
CodeBlockNode
|
||||
HeadingNode
|
||||
HorizontalRuleNode
|
||||
BlockquoteNode
|
||||
// Inline nodes.
|
||||
TextNode
|
||||
BoldNode
|
||||
ItalicNode
|
||||
BoldItalicNode
|
||||
CodeNode
|
||||
ImageNode
|
||||
LinkNode
|
||||
TagNode
|
||||
StrikethroughNode
|
||||
)
|
||||
|
||||
type Node interface {
|
||||
// Type returns a node type.
|
||||
Type() NodeType
|
||||
|
||||
// PrevSibling returns a previous sibling node of this node.
|
||||
PrevSibling() Node
|
||||
|
||||
// NextSibling returns a next sibling node of this node.
|
||||
NextSibling() Node
|
||||
|
||||
// SetPrevSibling sets a previous sibling node to this node.
|
||||
SetPrevSibling(Node)
|
||||
|
||||
// SetNextSibling sets a next sibling node to this node.
|
||||
SetNextSibling(Node)
|
||||
}
|
||||
|
||||
func (n *Node) AddChild(child *Node) {
|
||||
n.Children = append(n.Children, child)
|
||||
type BaseNode struct {
|
||||
prevSibling Node
|
||||
|
||||
nextSibling Node
|
||||
}
|
||||
|
||||
func (n *BaseNode) PrevSibling() Node {
|
||||
return n.prevSibling
|
||||
}
|
||||
|
||||
func (n *BaseNode) NextSibling() Node {
|
||||
return n.nextSibling
|
||||
}
|
||||
|
||||
func (n *BaseNode) SetPrevSibling(node Node) {
|
||||
n.prevSibling = node
|
||||
}
|
||||
|
||||
func (n *BaseNode) SetNextSibling(node Node) {
|
||||
n.nextSibling = node
|
||||
}
|
||||
|
||||
52
plugin/gomark/parser/blockquote.go
Normal file
52
plugin/gomark/parser/blockquote.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/usememos/memos/plugin/gomark/ast"
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
)
|
||||
|
||||
type BlockquoteParser struct{}
|
||||
|
||||
func NewBlockquoteParser() *BlockquoteParser {
|
||||
return &BlockquoteParser{}
|
||||
}
|
||||
|
||||
func (*BlockquoteParser) Match(tokens []*tokenizer.Token) (int, bool) {
|
||||
if len(tokens) < 4 {
|
||||
return 0, false
|
||||
}
|
||||
if tokens[0].Type != tokenizer.GreaterThan || tokens[1].Type != tokenizer.Space {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
contentTokens := []*tokenizer.Token{}
|
||||
for _, token := range tokens[2:] {
|
||||
contentTokens = append(contentTokens, token)
|
||||
if token.Type == tokenizer.Newline {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(contentTokens) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return len(contentTokens) + 2, true
|
||||
}
|
||||
|
||||
func (p *BlockquoteParser) Parse(tokens []*tokenizer.Token) (ast.Node, error) {
|
||||
size, ok := p.Match(tokens)
|
||||
if size == 0 || !ok {
|
||||
return nil, errors.New("not matched")
|
||||
}
|
||||
|
||||
contentTokens := tokens[2:size]
|
||||
children, err := ParseInline(contentTokens)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ast.Blockquote{
|
||||
Children: children,
|
||||
}, nil
|
||||
}
|
||||
49
plugin/gomark/parser/blockquote_test.go
Normal file
49
plugin/gomark/parser/blockquote_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/usememos/memos/plugin/gomark/ast"
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
)
|
||||
|
||||
func TestBlockquoteParser(t *testing.T) {
|
||||
tests := []struct {
|
||||
text string
|
||||
blockquote ast.Node
|
||||
}{
|
||||
{
|
||||
text: "> Hello world",
|
||||
blockquote: &ast.Blockquote{
|
||||
Children: []ast.Node{
|
||||
&ast.Text{
|
||||
Content: "Hello world",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "> Hello\nworld",
|
||||
blockquote: &ast.Blockquote{
|
||||
Children: []ast.Node{
|
||||
&ast.Text{
|
||||
Content: "Hello",
|
||||
},
|
||||
&ast.LineBreak{},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
text: ">Hello\nworld",
|
||||
blockquote: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
tokens := tokenizer.Tokenize(test.text)
|
||||
node, _ := NewBlockquoteParser().Parse(tokens)
|
||||
require.Equal(t, StringifyNodes([]ast.Node{test.blockquote}), StringifyNodes([]ast.Node{node}))
|
||||
}
|
||||
}
|
||||
@@ -1,49 +1,60 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/usememos/memos/plugin/gomark/ast"
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
)
|
||||
|
||||
type BoldParser struct {
|
||||
ContentTokens []*tokenizer.Token
|
||||
}
|
||||
type BoldParser struct{}
|
||||
|
||||
func NewBoldParser() *BoldParser {
|
||||
func NewBoldParser() InlineParser {
|
||||
return &BoldParser{}
|
||||
}
|
||||
|
||||
func (*BoldParser) Match(tokens []*tokenizer.Token) *BoldParser {
|
||||
func (*BoldParser) Match(tokens []*tokenizer.Token) (int, bool) {
|
||||
if len(tokens) < 5 {
|
||||
return nil
|
||||
return 0, false
|
||||
}
|
||||
|
||||
prefixTokens := tokens[:2]
|
||||
if prefixTokens[0].Type != prefixTokens[1].Type {
|
||||
return nil
|
||||
return 0, false
|
||||
}
|
||||
prefixTokenType := prefixTokens[0].Type
|
||||
if prefixTokenType != tokenizer.Star && prefixTokenType != tokenizer.Underline {
|
||||
return nil
|
||||
if prefixTokenType != tokenizer.Asterisk && prefixTokenType != tokenizer.Underline {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
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
|
||||
return 0, false
|
||||
}
|
||||
if token.Type == prefixTokenType && nextToken.Type == prefixTokenType {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
contentTokens = append(contentTokens, token)
|
||||
}
|
||||
if !matched {
|
||||
return nil
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return &BoldParser{
|
||||
ContentTokens: contentTokens,
|
||||
}
|
||||
return cursor + 2, true
|
||||
}
|
||||
|
||||
func (p *BoldParser) Parse(tokens []*tokenizer.Token) (ast.Node, error) {
|
||||
size, ok := p.Match(tokens)
|
||||
if size == 0 || !ok {
|
||||
return nil, errors.New("not matched")
|
||||
}
|
||||
|
||||
prefixTokenType := tokens[0].Type
|
||||
contentTokens := tokens[2 : size-2]
|
||||
return &ast.Bold{
|
||||
Symbol: prefixTokenType,
|
||||
Content: tokenizer.Stringify(contentTokens),
|
||||
}, nil
|
||||
}
|
||||
|
||||
60
plugin/gomark/parser/bold_italic.go
Normal file
60
plugin/gomark/parser/bold_italic.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/usememos/memos/plugin/gomark/ast"
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
)
|
||||
|
||||
type BoldItalicParser struct{}
|
||||
|
||||
func NewBoldItalicParser() InlineParser {
|
||||
return &BoldItalicParser{}
|
||||
}
|
||||
|
||||
func (*BoldItalicParser) Match(tokens []*tokenizer.Token) (int, bool) {
|
||||
if len(tokens) < 7 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
prefixTokens := tokens[:3]
|
||||
if prefixTokens[0].Type != prefixTokens[1].Type || prefixTokens[0].Type != prefixTokens[2].Type || prefixTokens[1].Type != prefixTokens[2].Type {
|
||||
return 0, false
|
||||
}
|
||||
prefixTokenType := prefixTokens[0].Type
|
||||
if prefixTokenType != tokenizer.Asterisk && prefixTokenType != tokenizer.Underline {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
cursor, matched := 3, false
|
||||
for ; cursor < len(tokens)-2; cursor++ {
|
||||
token, nextToken, endToken := tokens[cursor], tokens[cursor+1], tokens[cursor+2]
|
||||
if token.Type == tokenizer.Newline || nextToken.Type == tokenizer.Newline || endToken.Type == tokenizer.Newline {
|
||||
return 0, false
|
||||
}
|
||||
if token.Type == prefixTokenType && nextToken.Type == prefixTokenType && endToken.Type == prefixTokenType {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return cursor + 3, true
|
||||
}
|
||||
|
||||
func (p *BoldItalicParser) Parse(tokens []*tokenizer.Token) (ast.Node, error) {
|
||||
size, ok := p.Match(tokens)
|
||||
if size == 0 || !ok {
|
||||
return nil, errors.New("not matched")
|
||||
}
|
||||
|
||||
prefixTokenType := tokens[0].Type
|
||||
contentTokens := tokens[3 : size-3]
|
||||
return &ast.BoldItalic{
|
||||
Symbol: prefixTokenType,
|
||||
Content: tokenizer.Stringify(contentTokens),
|
||||
}, nil
|
||||
}
|
||||
50
plugin/gomark/parser/bold_italic_test.go
Normal file
50
plugin/gomark/parser/bold_italic_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/usememos/memos/plugin/gomark/ast"
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
)
|
||||
|
||||
func TestBoldItalicParser(t *testing.T) {
|
||||
tests := []struct {
|
||||
text string
|
||||
boldItalic ast.Node
|
||||
}{
|
||||
{
|
||||
text: "*Hello world!",
|
||||
boldItalic: nil,
|
||||
},
|
||||
{
|
||||
text: "***Hello***",
|
||||
boldItalic: &ast.BoldItalic{
|
||||
Symbol: "*",
|
||||
Content: "Hello",
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "*** Hello ***",
|
||||
boldItalic: &ast.BoldItalic{
|
||||
Symbol: "*",
|
||||
Content: " Hello ",
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "*** Hello * *",
|
||||
boldItalic: nil,
|
||||
},
|
||||
{
|
||||
text: "*** Hello **",
|
||||
boldItalic: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
tokens := tokenizer.Tokenize(test.text)
|
||||
node, _ := NewBoldItalicParser().Parse(tokens)
|
||||
require.Equal(t, StringifyNodes([]ast.Node{test.boldItalic}), StringifyNodes([]ast.Node{node}))
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,15 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/usememos/memos/plugin/gomark/ast"
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
)
|
||||
|
||||
func TestBoldParser(t *testing.T) {
|
||||
tests := []struct {
|
||||
text string
|
||||
bold *BoldParser
|
||||
bold ast.Node
|
||||
}{
|
||||
{
|
||||
text: "*Hello world!",
|
||||
@@ -18,32 +20,16 @@ func TestBoldParser(t *testing.T) {
|
||||
},
|
||||
{
|
||||
text: "**Hello**",
|
||||
bold: &BoldParser{
|
||||
ContentTokens: []*tokenizer.Token{
|
||||
{
|
||||
Type: tokenizer.Text,
|
||||
Value: "Hello",
|
||||
},
|
||||
},
|
||||
bold: &ast.Bold{
|
||||
Symbol: "*",
|
||||
Content: "Hello",
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "** Hello **",
|
||||
bold: &BoldParser{
|
||||
ContentTokens: []*tokenizer.Token{
|
||||
{
|
||||
Type: tokenizer.Space,
|
||||
Value: " ",
|
||||
},
|
||||
{
|
||||
Type: tokenizer.Text,
|
||||
Value: "Hello",
|
||||
},
|
||||
{
|
||||
Type: tokenizer.Space,
|
||||
Value: " ",
|
||||
},
|
||||
},
|
||||
bold: &ast.Bold{
|
||||
Symbol: "*",
|
||||
Content: " Hello ",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -54,35 +40,11 @@ func TestBoldParser(t *testing.T) {
|
||||
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))
|
||||
node, _ := NewBoldParser().Parse(tokens)
|
||||
require.Equal(t, StringifyNodes([]ast.Node{test.bold}), StringifyNodes([]ast.Node{node}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,51 @@
|
||||
package parser
|
||||
|
||||
import "github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
import (
|
||||
"errors"
|
||||
|
||||
type CodeParser struct {
|
||||
Content string
|
||||
}
|
||||
"github.com/usememos/memos/plugin/gomark/ast"
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
)
|
||||
|
||||
type CodeParser struct{}
|
||||
|
||||
func NewCodeParser() *CodeParser {
|
||||
return &CodeParser{}
|
||||
}
|
||||
|
||||
func (*CodeParser) Match(tokens []*tokenizer.Token) *CodeParser {
|
||||
func (*CodeParser) Match(tokens []*tokenizer.Token) (int, bool) {
|
||||
if len(tokens) < 3 {
|
||||
return nil
|
||||
return 0, false
|
||||
}
|
||||
if tokens[0].Type != tokenizer.Backtick {
|
||||
return nil
|
||||
return 0, false
|
||||
}
|
||||
|
||||
content, matched := "", false
|
||||
contentTokens, matched := []*tokenizer.Token{}, false
|
||||
for _, token := range tokens[1:] {
|
||||
if token.Type == tokenizer.Newline {
|
||||
return nil
|
||||
return 0, false
|
||||
}
|
||||
if token.Type == tokenizer.Backtick {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
content += token.Value
|
||||
contentTokens = append(contentTokens, token)
|
||||
}
|
||||
if !matched || len(content) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &CodeParser{
|
||||
Content: content,
|
||||
if !matched || len(contentTokens) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return len(contentTokens) + 2, true
|
||||
}
|
||||
|
||||
func (p *CodeParser) Parse(tokens []*tokenizer.Token) (ast.Node, error) {
|
||||
size, ok := p.Match(tokens)
|
||||
if size == 0 || !ok {
|
||||
return nil, errors.New("not matched")
|
||||
}
|
||||
|
||||
contentTokens := tokens[1 : size-1]
|
||||
return &ast.Code{
|
||||
Content: tokenizer.Stringify(contentTokens),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package parser
|
||||
|
||||
import "github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/usememos/memos/plugin/gomark/ast"
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
)
|
||||
|
||||
type CodeBlockParser struct {
|
||||
Language string
|
||||
@@ -11,42 +16,64 @@ func NewCodeBlockParser() *CodeBlockParser {
|
||||
return &CodeBlockParser{}
|
||||
}
|
||||
|
||||
func (*CodeBlockParser) Match(tokens []*tokenizer.Token) *CodeBlockParser {
|
||||
func (*CodeBlockParser) Match(tokens []*tokenizer.Token) (int, bool) {
|
||||
if len(tokens) < 9 {
|
||||
return nil
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if tokens[0].Type != tokenizer.Backtick || tokens[1].Type != tokenizer.Backtick || tokens[2].Type != tokenizer.Backtick {
|
||||
return nil
|
||||
return 0, false
|
||||
}
|
||||
if tokens[3].Type != tokenizer.Newline && tokens[4].Type != tokenizer.Newline {
|
||||
return nil
|
||||
return 0, false
|
||||
}
|
||||
cursor, language := 4, ""
|
||||
cursor := 4
|
||||
if tokens[3].Type != tokenizer.Newline {
|
||||
language = tokens[3].Value
|
||||
cursor = 5
|
||||
}
|
||||
|
||||
content, matched := "", false
|
||||
matched := false
|
||||
for ; cursor < len(tokens)-3; cursor++ {
|
||||
if tokens[cursor].Type == tokenizer.Newline && tokens[cursor+1].Type == tokenizer.Backtick && tokens[cursor+2].Type == tokenizer.Backtick && tokens[cursor+3].Type == tokenizer.Backtick {
|
||||
if cursor+3 == len(tokens)-1 {
|
||||
cursor += 4
|
||||
matched = true
|
||||
break
|
||||
} else if tokens[cursor+4].Type == tokenizer.Newline {
|
||||
cursor += 5
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
content += tokens[cursor].Value
|
||||
}
|
||||
if !matched {
|
||||
return nil
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return &CodeBlockParser{
|
||||
Language: language,
|
||||
Content: content,
|
||||
}
|
||||
return cursor, true
|
||||
}
|
||||
|
||||
func (p *CodeBlockParser) Parse(tokens []*tokenizer.Token) (ast.Node, error) {
|
||||
size, ok := p.Match(tokens)
|
||||
if size == 0 || !ok {
|
||||
return nil, errors.New("not matched")
|
||||
}
|
||||
|
||||
languageToken := tokens[3]
|
||||
contentStart, contentEnd := 5, size-4
|
||||
if languageToken.Type == tokenizer.Newline {
|
||||
languageToken = nil
|
||||
contentStart = 4
|
||||
}
|
||||
if tokens[size-1].Type == tokenizer.Newline {
|
||||
contentEnd = size - 5
|
||||
}
|
||||
|
||||
codeBlock := &ast.CodeBlock{
|
||||
Content: tokenizer.Stringify(tokens[contentStart:contentEnd]),
|
||||
}
|
||||
if languageToken != nil {
|
||||
codeBlock.Language = languageToken.String()
|
||||
}
|
||||
return codeBlock, nil
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user