mirror of
https://github.com/usememos/memos.git
synced 2025-06-05 22:09:59 +02:00
Compare commits
574 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f05a89315c | ||
|
|
a4452d8a2f | ||
|
|
5e74394643 | ||
|
|
f4e722c516 | ||
|
|
12275c6a34 | ||
|
|
21ef5a9bc0 | ||
|
|
87b23940a6 | ||
|
|
11dd23f59b | ||
|
|
887903b66b | ||
|
|
309fab222e | ||
|
|
1dc4f02b64 | ||
|
|
8db90a040c | ||
|
|
932f636d84 | ||
|
|
ed32b20c9e | ||
|
|
10d709c167 | ||
|
|
8455114eef | ||
|
|
c26109cd36 | ||
|
|
4b223c1e4c | ||
|
|
b9cbe6626f | ||
|
|
566171783d | ||
|
|
7edb3598ea | ||
|
|
bc2d2d0cde | ||
|
|
e1977df14b | ||
|
|
ddc89029b7 | ||
|
|
f8b9a83d4a | ||
|
|
2f16b7065a | ||
|
|
e5ff1829a5 | ||
|
|
d7889d9903 | ||
|
|
db3457e081 | ||
|
|
4f2b00b4f3 | ||
|
|
79558028c0 | ||
|
|
70d1301dc3 | ||
|
|
6d5e1def76 | ||
|
|
a5bc2d0ed6 | ||
|
|
08ac60cc70 | ||
|
|
f654d3c90e | ||
|
|
1b69b73eb9 | ||
|
|
3dbb254aeb | ||
|
|
fdb1779a59 | ||
|
|
a316e239ce | ||
|
|
d7f02b94e5 | ||
|
|
d165d87288 | ||
|
|
bf905bba86 | ||
|
|
3a129d5cfb | ||
|
|
024a818e91 | ||
|
|
54a24833a7 | ||
|
|
a620d140c5 | ||
|
|
370054e040 | ||
|
|
fae0b4e900 | ||
|
|
c38404b5d5 | ||
|
|
4d48f50815 | ||
|
|
582cc6609c | ||
|
|
40bd75c725 | ||
|
|
b2fc3076f6 | ||
|
|
288527914b | ||
|
|
a2aea3747c | ||
|
|
8382354ef7 | ||
|
|
264e6e6e9c | ||
|
|
eb72609ea3 | ||
|
|
776785ac90 | ||
|
|
d5f874e185 | ||
|
|
89d940d9b7 | ||
|
|
bd1cf62761 | ||
|
|
196facfacd | ||
|
|
afe75fd9f2 | ||
|
|
8a34013558 | ||
|
|
67f5ac3657 | ||
|
|
7236552b6c | ||
|
|
1f5899d238 | ||
|
|
ec4884ea04 | ||
|
|
2e0619b4dc | ||
|
|
c9146bc749 | ||
|
|
f5b5bd64bc | ||
|
|
d31d9eb71c | ||
|
|
f28b654057 | ||
|
|
8738b68a44 | ||
|
|
42381fa154 | ||
|
|
22427101f8 | ||
|
|
2a4ebf5774 | ||
|
|
5172e4df7c | ||
|
|
893dd2c85e | ||
|
|
d426f89cf0 | ||
|
|
7de3de5610 | ||
|
|
2856e66609 | ||
|
|
354011f994 | ||
|
|
8ed827cd2d | ||
|
|
05c0aeb789 | ||
|
|
aecffe3402 | ||
|
|
70e6b2bb82 | ||
|
|
54296f0437 | ||
|
|
8fcd9332f7 | ||
|
|
1857362d03 | ||
|
|
6d7186fc81 | ||
|
|
e4488da96e | ||
|
|
cc43d06d33 | ||
|
|
9ffd827028 | ||
|
|
15e6542f0d | ||
|
|
24bb3e096a | ||
|
|
5bcbbd4c52 | ||
|
|
ff13d977e9 | ||
|
|
1fdb8b7b01 | ||
|
|
f1ee88c4e1 | ||
|
|
b578afbc6a | ||
|
|
ad94e8e3c6 | ||
|
|
3f4b361fad | ||
|
|
46bd470640 | ||
|
|
fdbf2d8af2 | ||
|
|
5a723f00fa | ||
|
|
728a9705ea | ||
|
|
cd3a98c095 | ||
|
|
a22ad90174 | ||
|
|
5ebbed9115 | ||
|
|
7ae4299df2 | ||
|
|
3d23c01e26 | ||
|
|
089e04bcfd | ||
|
|
98762be1e5 | ||
|
|
d44e74bd1e | ||
|
|
8e0ce4d678 | ||
|
|
45cf158508 | ||
|
|
7340ae15f7 | ||
|
|
6db7ad76da | ||
|
|
4a407668bc | ||
|
|
ab1fa44f00 | ||
|
|
cd0004cf88 | ||
|
|
667aaf06a0 | ||
|
|
a8074d94e8 | ||
|
|
16e68fbfff | ||
|
|
81942b3b98 | ||
|
|
a7cda28fc7 | ||
|
|
0c52f1ee6a | ||
|
|
1994c20c54 | ||
|
|
a1dda913c3 | ||
|
|
d626de1875 | ||
|
|
6cfd94cc69 | ||
|
|
79b68222ff | ||
|
|
aaec46a39c | ||
|
|
9c663b1ba2 | ||
|
|
777ed899a3 | ||
|
|
ddcf1d669d | ||
|
|
32d02ba022 | ||
|
|
5449342016 | ||
|
|
43e42079a4 | ||
|
|
cafa7c5adc | ||
|
|
1258c5a5b0 | ||
|
|
83141f9be2 | ||
|
|
4c59035757 | ||
|
|
9459ae8265 | ||
|
|
8893a302e2 | ||
|
|
d67eaaaee2 | ||
|
|
fd8333eeda | ||
|
|
f5a1739472 | ||
|
|
a297cc3140 | ||
|
|
79c13c6f83 | ||
|
|
8b9455d784 | ||
|
|
501f8898f6 | ||
|
|
ee13927607 | ||
|
|
d2a9aaa9d4 | ||
|
|
f563b58a85 | ||
|
|
ce2d37b90c | ||
|
|
454cd4e24f | ||
|
|
6320d042c8 | ||
|
|
d7ed59581c | ||
|
|
9593b0b091 | ||
|
|
ca53630410 | ||
|
|
f484c38745 | ||
|
|
d12a2b0c38 | ||
|
|
c842b921bc | ||
|
|
6b2eec86c2 | ||
|
|
2eba4e2cd4 | ||
|
|
73baeaa0ad | ||
|
|
c58851bc97 | ||
|
|
96140f3875 | ||
|
|
369b8af109 | ||
|
|
914c0620c4 | ||
|
|
138b69e36e | ||
|
|
3181c076b2 | ||
|
|
673809e07d | ||
|
|
f74fa97b4a | ||
|
|
c797099950 | ||
|
|
0f8bfb6328 | ||
|
|
4cd01ece30 | ||
|
|
14b34edca3 | ||
|
|
411e807dcc | ||
|
|
ea87a1dc0c | ||
|
|
46f7cffc7b | ||
|
|
2a6f054876 | ||
|
|
30dca18b79 | ||
|
|
09c195c752 | ||
|
|
2ae6d94e2c | ||
|
|
9ee4b75bbd | ||
|
|
cc40803b06 | ||
|
|
a0a03b0389 | ||
|
|
0dfc367e56 | ||
|
|
c8d7f93dca | ||
|
|
6fac116d8c | ||
|
|
f48ff102c9 | ||
|
|
bd5a0679ee | ||
|
|
fcfb76a103 | ||
|
|
8e325f9986 | ||
|
|
b8eaf1d57e | ||
|
|
42608cdd8f | ||
|
|
2cfa4c3b76 | ||
|
|
aa136a2776 | ||
|
|
68413a5371 | ||
|
|
638f17a02c | ||
|
|
273d6a6986 | ||
|
|
953141813c | ||
|
|
be2db3f170 | ||
|
|
eefce6ade3 | ||
|
|
c6ebb5552e | ||
|
|
4d64d4bf25 | ||
|
|
2ee4d7d745 | ||
|
|
1b81999329 | ||
|
|
df5aeb6d88 | ||
|
|
df3303dcd3 | ||
|
|
c267074851 | ||
|
|
21874d0509 | ||
|
|
7898df2876 | ||
|
|
b2ec0d1217 | ||
|
|
5673e29e51 | ||
|
|
feefaabce9 | ||
|
|
29b540ade3 | ||
|
|
919f75af1a | ||
|
|
17e905085f | ||
|
|
34af969785 | ||
|
|
fd9c3ccbae | ||
|
|
a3feeceace | ||
|
|
02265a6e1a | ||
|
|
81524c38e9 | ||
|
|
671551bdc1 | ||
|
|
10c81ccba3 | ||
|
|
9361613f23 | ||
|
|
b14334220f | ||
|
|
f184d65267 | ||
|
|
b64e2ff6ff | ||
|
|
cbdae24314 | ||
|
|
762cb25227 | ||
|
|
fc01a796f8 | ||
|
|
feb700f325 | ||
|
|
5334fdf1b2 | ||
|
|
abc14217f6 | ||
|
|
af68cae6ea | ||
|
|
e0cacfc6d6 | ||
|
|
b575064d47 | ||
|
|
6290234ad1 | ||
|
|
aeed25648a | ||
|
|
43e7506ed5 | ||
|
|
a3a1bbe8de | ||
|
|
fe4ec0b156 | ||
|
|
7c5fdd1b06 | ||
|
|
4d54463aeb | ||
|
|
40bc8df63d | ||
|
|
61de7c8a32 | ||
|
|
d6656db20d | ||
|
|
15a091fe4c | ||
|
|
d8a0528135 | ||
|
|
16fb5faebd | ||
|
|
2c4b5d75b3 | ||
|
|
770607f93f | ||
|
|
db0eff4743 | ||
|
|
0793f96578 | ||
|
|
8095d94c97 | ||
|
|
bcfcd59642 | ||
|
|
5d677c3c57 | ||
|
|
28c0549705 | ||
|
|
bb42042db4 | ||
|
|
1c7fb77e05 | ||
|
|
e8ca2ea5a0 | ||
|
|
e43a445c34 | ||
|
|
1237643028 | ||
|
|
aee0e31b0a | ||
|
|
47af632c79 | ||
|
|
7b0ceee57b | ||
|
|
bdc867d153 | ||
|
|
6421fbc68a | ||
|
|
b00443c222 | ||
|
|
a10b3d3821 | ||
|
|
7735cfac31 | ||
|
|
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 |
13
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
13
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,11 +1,11 @@
|
||||
name: Bug Report
|
||||
description: Create a report to help us improve
|
||||
description: If something isn't working as expected
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
If you are reporting a new issue, make sure that we do not have any duplicates already open. You can ensure this by searching the issue list for this repository. If there is a duplicate, please close your issue and add a comment to the existing issue instead.
|
||||
Before submitting a bug report, please check if the issue is already present in the issues. If it is, please add a reaction to the issue. If it isn't, please fill out the form below.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
@@ -24,8 +24,15 @@ body:
|
||||
3. See error
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: The version of Memos you're using
|
||||
description: |
|
||||
Provide the version of Memos you're using.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Screenshots or additional context
|
||||
description: |
|
||||
Add screenshots or any other context about the problem.
|
||||
If applicable, add screenshots to help explain your problem. And add any other context about the problem here. Such as the device you're using, etc.
|
||||
|
||||
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
|
||||
32
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
32
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,28 +1,36 @@
|
||||
name: Feature Request
|
||||
description: Suggest an idea for this project
|
||||
description: If you have a suggestion for a new feature
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to suggest an idea for Memos!
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Is your feature request related to a problem?
|
||||
description: |
|
||||
A clear and concise description of what the problem is.
|
||||
placeholder: |
|
||||
I'm always frustrated when [...]
|
||||
validations:
|
||||
required: true
|
||||
Before submitting a feature request, please check if the issue is already present in the issues. If it is, please add a reaction to the issue. If it isn't, please fill out the form below.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: |
|
||||
A clear and concise description of what you want to happen.
|
||||
placeholder: |
|
||||
It would be great if [...]
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Type of feature
|
||||
description: What type of feature is this?
|
||||
options:
|
||||
- User Interface (UI)
|
||||
- User Experience (UX)
|
||||
- API
|
||||
- Documentation
|
||||
- Integrations
|
||||
- Other
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context or screenshots about the feature request.
|
||||
description: |
|
||||
What are you trying to do? Why is this important to you?
|
||||
|
||||
8
.github/workflows/backend-tests.yml
vendored
8
.github/workflows/backend-tests.yml
vendored
@@ -7,13 +7,17 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- "release/*.*.*"
|
||||
paths:
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "**.go"
|
||||
|
||||
jobs:
|
||||
go-static-checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.21
|
||||
check-latest: true
|
||||
@@ -33,7 +37,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.21
|
||||
check-latest: true
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
- 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,27 +39,25 @@ 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
|
||||
ghcr.io/usememos/memos
|
||||
tags: |
|
||||
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
|
||||
|
||||
59
.github/workflows/build-and-push-stable-image.yml
vendored
Normal file
59
.github/workflows/build-and-push-stable-image.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
name: build-and-push-stable-image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "stable"
|
||||
|
||||
jobs:
|
||||
build-and-push-release-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: neosmemo
|
||||
password: ${{ secrets.DOCKER_NEOSMEMO_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
install: true
|
||||
version: v0.9.1
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
neosmemo/memos
|
||||
ghcr.io/usememos/memos
|
||||
tags: |
|
||||
type=raw,value=stable
|
||||
|
||||
- name: Build and Push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
12
.github/workflows/build-and-push-test-image.yml
vendored
12
.github/workflows/build-and-push-test-image.yml
vendored
@@ -14,16 +14,16 @@ jobs:
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
22
.github/workflows/frontend-tests.yml
vendored
22
.github/workflows/frontend-tests.yml
vendored
@@ -15,19 +15,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: bufbuild/buf-setup-action@v1
|
||||
- run: buf generate
|
||||
working-directory: proto
|
||||
- uses: pnpm/action-setup@v2.2.4
|
||||
- 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
|
||||
@@ -36,19 +35,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: bufbuild/buf-setup-action@v1
|
||||
- run: buf generate
|
||||
working-directory: proto
|
||||
- uses: pnpm/action-setup@v2.2.4
|
||||
- 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
|
||||
|
||||
2
.github/workflows/proto-linter.yml
vendored
2
.github/workflows/proto-linter.yml
vendored
@@ -20,6 +20,8 @@ jobs:
|
||||
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:
|
||||
|
||||
19
.github/workflows/stale.yml
vendored
Normal file
19
.github/workflows/stale.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: Close Stale Issues
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@v9.0.0
|
||||
with:
|
||||
stale-issue-message: "This issue is stale because it has been open 14 days with no activity."
|
||||
close-issue-message: "This issue was closed because it has been stalled for 28 days with no activity."
|
||||
days-before-issue-stale: 14
|
||||
days-before-issue-close: 14
|
||||
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@v4
|
||||
|
||||
- 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@v4
|
||||
- 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
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -6,7 +6,7 @@ tmp
|
||||
|
||||
# Frontend asset
|
||||
web/dist
|
||||
server/dist
|
||||
server/frontend/dist
|
||||
|
||||
# build folder
|
||||
build
|
||||
@@ -16,4 +16,9 @@ build
|
||||
# Jetbrains
|
||||
.idea
|
||||
|
||||
# Docker Compose Environment File
|
||||
.env
|
||||
|
||||
bin/air
|
||||
|
||||
dev-dist
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
run:
|
||||
timeout: 10m
|
||||
linters:
|
||||
enable:
|
||||
- errcheck
|
||||
@@ -65,6 +67,14 @@ linters-settings:
|
||||
disabled: true
|
||||
- name: early-return
|
||||
disabled: true
|
||||
- name: use-any
|
||||
disabled: true
|
||||
- name: exported
|
||||
disabled: true
|
||||
- name: unhandled-error
|
||||
disabled: true
|
||||
- name: if-return
|
||||
disabled: true
|
||||
gocritic:
|
||||
disabled-checks:
|
||||
- ifElseChain
|
||||
|
||||
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"recommendations": ["golang.go"]
|
||||
}
|
||||
12
.vscode/project.code-workspace
vendored
12
.vscode/project.code-workspace
vendored
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"name": "server",
|
||||
"path": "../"
|
||||
},
|
||||
{
|
||||
"name": "web",
|
||||
"path": "../web"
|
||||
}
|
||||
]
|
||||
}
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"json.schemaDownload.enable":true,
|
||||
"go.lintOnSave": "workspace",
|
||||
"go.lintTool": "golangci-lint",
|
||||
}
|
||||
26
Dockerfile
26
Dockerfile
@@ -1,24 +1,12 @@
|
||||
# Build protobuf.
|
||||
FROM golang:1.21-alpine AS protobuf
|
||||
WORKDIR /protobuf-generate
|
||||
# Build frontend dist.
|
||||
FROM node:20-alpine AS frontend
|
||||
WORKDIR /frontend-build
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN GO111MODULE=on GOBIN=/usr/local/bin go install github.com/bufbuild/buf/cmd/buf@v1.26.1
|
||||
WORKDIR /frontend-build/web
|
||||
|
||||
WORKDIR /protobuf-generate/proto
|
||||
|
||||
RUN buf generate
|
||||
|
||||
# Build frontend dist.
|
||||
FROM node:18-alpine AS frontend
|
||||
WORKDIR /frontend-build
|
||||
|
||||
COPY ./web .
|
||||
|
||||
COPY --from=protobuf /protobuf-generate/web/src/types/proto ./src/types/proto
|
||||
|
||||
RUN corepack enable && pnpm i --frozen-lockfile
|
||||
RUN corepack enable && pnpm i --frozen-lockfile && pnpm type-gen
|
||||
|
||||
RUN pnpm build
|
||||
|
||||
@@ -27,9 +15,8 @@ FROM golang:1.21-alpine AS backend
|
||||
WORKDIR /backend-build
|
||||
|
||||
COPY . .
|
||||
COPY --from=frontend /frontend-build/dist ./server/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:latest AS monolithic
|
||||
@@ -38,6 +25,7 @@ WORKDIR /usr/local/memos
|
||||
RUN apk add --no-cache tzdata
|
||||
ENV TZ="UTC"
|
||||
|
||||
COPY --from=frontend /frontend-build/web/dist /usr/local/memos/dist
|
||||
COPY --from=backend /backend-build/memos /usr/local/memos/
|
||||
|
||||
EXPOSE 5230
|
||||
|
||||
38
README.md
38
README.md
@@ -1,21 +1,19 @@
|
||||
# memos
|
||||
|
||||
<img height="72px" src="https://usememos.com/logo.png" 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
|
||||
|
||||
@@ -28,39 +26,21 @@ Discuss in <a href="https://discord.gg/tfPJa4UmAv">Discord</a> / <a href="https:
|
||||
## Deploy with Docker in seconds
|
||||
|
||||
```bash
|
||||
docker run -d --name memos -p 5230:5230 -v ~/.memos/:/var/opt/memos ghcr.io/usememos/memos:latest
|
||||
docker run -d --name memos -p 5230:5230 -v ~/.memos/:/var/opt/memos neosmemo/memos:stable
|
||||
```
|
||||
|
||||
> 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>
|
||||
|
||||
---
|
||||
|
||||
- [Moe Memos](https://memos.moe/) - Third party client for iOS and Android
|
||||
- [lmm214/memos-bber](https://github.com/lmm214/memos-bber) - Chrome extension
|
||||
- [Rabithua/memos_wmp](https://github.com/Rabithua/memos_wmp) - WeChat MiniProgram
|
||||
- [qazxcdswe123/telegramMemoBot](https://github.com/qazxcdswe123/telegramMemoBot) - Telegram bot
|
||||
- [eallion/memos.top](https://github.com/eallion/memos.top) - Static page rendered with the Memos API
|
||||
- [eindex/logseq-memos-sync](https://github.com/EINDEX/logseq-memos-sync) - Logseq 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
|
||||
- [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).
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
164
api/resource/resource.go
Normal file
164
api/resource/resource.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"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/:resourceName", s.streamResource)
|
||||
g.GET("/r/:resourceName/*", s.streamResource)
|
||||
}
|
||||
|
||||
func (s *Service) streamResource(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
resourceName := c.Param("resourceName")
|
||||
resource, err := s.Store.GetResource(ctx, &store.FindResource{
|
||||
ResourceName: &resourceName,
|
||||
GetBlob: true,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find resource by id: %s", resourceName)).SetInternal(err)
|
||||
}
|
||||
if resource == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource not found: %s", resourceName))
|
||||
}
|
||||
// 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 := filepath.FromSlash(resource.InternalPath)
|
||||
if !filepath.IsAbs(resourcePath) {
|
||||
resourcePath = filepath.Join(s.Profile.Data, resourcePath)
|
||||
}
|
||||
|
||||
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 := filepath.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,136 +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"
|
||||
// ActivityMemoView is the type for viewing memos.
|
||||
ActivityMemoView ActivityType = "memo.view"
|
||||
// ActivityMemoUpdate is the type for updating memos.
|
||||
ActivityMemoUpdate ActivityType = "memo.update"
|
||||
// ActivityMemoDelete is the type for deleting memos.
|
||||
ActivityMemoDelete ActivityType = "memo.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 int32 `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
Role Role `json:"role"`
|
||||
}
|
||||
|
||||
type ActivityUserAuthSignInPayload struct {
|
||||
UserID int32 `json:"userId"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
type ActivityUserAuthSignUpPayload struct {
|
||||
Username string `json:"username"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
type ActivityMemoCreatePayload struct {
|
||||
MemoID int32 `json:"memoId"`
|
||||
}
|
||||
|
||||
type ActivityMemoViewPayload struct {
|
||||
MemoID int32 `json:"memoId"`
|
||||
}
|
||||
|
||||
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 int32 `json:"id"`
|
||||
|
||||
// Standard fields
|
||||
CreatorID int32 `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 int32
|
||||
|
||||
// Domain specific fields
|
||||
Type ActivityType `json:"type"`
|
||||
Level ActivityLevel
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
129
api/v1/auth.go
129
api/v1/auth.go
@@ -14,20 +14,17 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/usememos/memos/api/auth"
|
||||
"github.com/usememos/memos/common/util"
|
||||
"github.com/usememos/memos/internal/util"
|
||||
"github.com/usememos/memos/plugin/idp"
|
||||
"github.com/usememos/memos/plugin/idp/oauth2"
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
var (
|
||||
usernameMatcher = regexp.MustCompile("^[a-z]([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 {
|
||||
@@ -104,17 +101,21 @@ func (s *APIV1Service) SignIn(c echo.Context) error {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Incorrect login credentials, please try again")
|
||||
}
|
||||
|
||||
accessToken, err := auth.GenerateAccessToken(user.Username, user.ID, time.Now().Add(auth.AccessTokenDuration), []byte(s.Secret))
|
||||
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)
|
||||
}
|
||||
if err := s.createAuthSignInActivity(c, user); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
cookieExp := time.Now().Add(auth.CookieExpDuration)
|
||||
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
||||
userMessage := convertUserFromStore(user)
|
||||
return c.JSON(http.StatusOK, userMessage)
|
||||
@@ -192,7 +193,7 @@ func (s *APIV1Service) SignInSSO(c echo.Context) error {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting").SetInternal(err)
|
||||
}
|
||||
|
||||
allowSignUpSettingValue := false
|
||||
allowSignUpSettingValue := true
|
||||
if allowSignUpSetting != nil {
|
||||
err = json.Unmarshal([]byte(allowSignUpSetting.Value), &allowSignUpSettingValue)
|
||||
if err != nil {
|
||||
@@ -235,9 +236,6 @@ func (s *APIV1Service) SignInSSO(c echo.Context) error {
|
||||
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)
|
||||
}
|
||||
if err := s.createAuthSignInActivity(c, user); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
cookieExp := time.Now().Add(auth.CookieExpDuration)
|
||||
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
||||
userMessage := convertUserFromStore(user)
|
||||
@@ -252,33 +250,14 @@ func (s *APIV1Service) SignInSSO(c echo.Context) error {
|
||||
// @Success 200 {boolean} true "Sign-out success"
|
||||
// @Router /api/v1/auth/signout [POST]
|
||||
func (s *APIV1Service) SignOut(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
accessToken := findAccessToken(c)
|
||||
userID, _ := getUserIDFromAccessToken(accessToken, s.Secret)
|
||||
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
|
||||
// Auto remove the current access token from the user access tokens.
|
||||
if err == nil && len(userAccessTokens) != 0 {
|
||||
accessTokens := []*storepb.AccessTokensUserSetting_AccessToken{}
|
||||
for _, userAccessToken := range userAccessTokens {
|
||||
if accessToken != userAccessToken.AccessToken {
|
||||
accessTokens = append(accessTokens, userAccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := s.Store.UpsertUserSettingV1(ctx, &storepb.UserSetting{
|
||||
UserId: userID,
|
||||
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
||||
Value: &storepb.UserSetting_AccessTokens{
|
||||
AccessTokens: &storepb.AccessTokensUserSetting{
|
||||
AccessTokens: accessTokens,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert user setting, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
removeAccessTokenAndCookies(c)
|
||||
return c.JSON(http.StatusOK, true)
|
||||
}
|
||||
|
||||
@@ -310,7 +289,7 @@ func (s *APIV1Service) SignUp(c echo.Context) error {
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Failed to find users").SetInternal(err)
|
||||
}
|
||||
if !usernameMatcher.MatchString(strings.ToLower(signup.Username)) {
|
||||
if !util.ResourceNameMatcher.MatchString(strings.ToLower(signup.Username)) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid username %s", signup.Username)).SetInternal(err)
|
||||
}
|
||||
|
||||
@@ -331,7 +310,7 @@ func (s *APIV1Service) SignUp(c echo.Context) error {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting").SetInternal(err)
|
||||
}
|
||||
|
||||
allowSignUpSettingValue := false
|
||||
allowSignUpSettingValue := true
|
||||
if allowSignUpSetting != nil {
|
||||
err = json.Unmarshal([]byte(allowSignUpSetting.Value), &allowSignUpSettingValue)
|
||||
if err != nil {
|
||||
@@ -341,6 +320,23 @@ func (s *APIV1Service) SignUp(c echo.Context) error {
|
||||
if !allowSignUpSettingValue {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "signup is disabled").SetInternal(err)
|
||||
}
|
||||
|
||||
disablePasswordLoginSystemSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
|
||||
Name: SystemSettingDisablePasswordLoginName.String(),
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting").SetInternal(err)
|
||||
}
|
||||
if disablePasswordLoginSystemSetting != nil {
|
||||
disablePasswordLogin := false
|
||||
err = json.Unmarshal([]byte(disablePasswordLoginSystemSetting.Value), &disablePasswordLogin)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting").SetInternal(err)
|
||||
}
|
||||
if disablePasswordLogin {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "password login is deactivated")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(signup.Password), bcrypt.DefaultCost)
|
||||
@@ -360,9 +356,6 @@ func (s *APIV1Service) SignUp(c echo.Context) error {
|
||||
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)
|
||||
}
|
||||
if err := s.createAuthSignUpActivity(c, user); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
cookieExp := time.Now().Add(auth.CookieExpDuration)
|
||||
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
||||
userMessage := convertUserFromStore(user)
|
||||
@@ -379,7 +372,7 @@ func (s *APIV1Service) UpsertAccessTokenToStore(ctx context.Context, user *store
|
||||
Description: "Account sign in",
|
||||
}
|
||||
userAccessTokens = append(userAccessTokens, &userAccessToken)
|
||||
if _, err := s.Store.UpsertUserSettingV1(ctx, &storepb.UserSetting{
|
||||
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
||||
UserId: user.ID,
|
||||
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
||||
Value: &storepb.UserSetting_AccessTokens{
|
||||
@@ -393,54 +386,16 @@ func (s *APIV1Service) UpsertAccessTokenToStore(ctx context.Context, user *store
|
||||
return nil
|
||||
}
|
||||
|
||||
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()),
|
||||
}
|
||||
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: user.ID,
|
||||
Type: string(ActivityUserAuthSignIn),
|
||||
Level: string(ActivityInfo),
|
||||
Payload: string(payloadBytes),
|
||||
})
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to marshal activity payload")
|
||||
}
|
||||
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")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// removeAccessTokenAndCookies removes the jwt token from the cookies.
|
||||
func removeAccessTokenAndCookies(c echo.Context) {
|
||||
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.
|
||||
|
||||
2111
api/v1/docs.go
2111
api/v1/docs.go
File diff suppressed because it is too large
Load Diff
@@ -11,43 +11,14 @@ import (
|
||||
)
|
||||
|
||||
func (*APIV1Service) registerGetterPublicRoutes(g *echo.Group) {
|
||||
// GET /get/httpmeta?url={url} - Get website meta.
|
||||
g.GET("/get/httpmeta", GetWebsiteMetadata)
|
||||
|
||||
// GET /get/image?url={url} - Get image.
|
||||
g.GET("/get/image", GetImage)
|
||||
}
|
||||
|
||||
// GetWebsiteMetadata godoc
|
||||
//
|
||||
// @Summary Get website metadata
|
||||
// @Tags get
|
||||
// @Produce json
|
||||
// @Param url query string true "Website URL"
|
||||
// @Success 200 {object} getter.HTMLMeta "Extracted metadata"
|
||||
// @Failure 400 {object} nil "Missing website url | Wrong url"
|
||||
// @Failure 406 {object} nil "Failed to get website meta with url: %s"
|
||||
// @Router /o/get/GetWebsiteMetadata [GET]
|
||||
func GetWebsiteMetadata(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)
|
||||
}
|
||||
|
||||
// GetImage godoc
|
||||
//
|
||||
// @Summary Get GetImage from URL
|
||||
// @Tags get
|
||||
// @Tags image-url
|
||||
// @Produce GetImage/*
|
||||
// @Param url query string true "Image url"
|
||||
// @Success 200 {object} nil "Image"
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"github.com/usememos/memos/common/util"
|
||||
"github.com/usememos/memos/internal/util"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
|
||||
@@ -5,12 +5,14 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/usememos/memos/api/auth"
|
||||
"github.com/usememos/memos/common/util"
|
||||
"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"
|
||||
)
|
||||
@@ -60,7 +62,7 @@ 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") && path != "/api/v1/user/me" && method == http.MethodGet {
|
||||
if util.HasPrefixes(path, "/api/v1/ping", "/api/v1/status") && method == http.MethodGet {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
@@ -71,7 +73,7 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
|
||||
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")
|
||||
@@ -79,7 +81,10 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
|
||||
|
||||
userID, err := getUserIDFromAccessToken(accessToken, secret)
|
||||
if err != nil {
|
||||
removeAccessTokenAndCookies(c)
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -88,7 +93,10 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get user access tokens.").WithInternal(err)
|
||||
}
|
||||
if !validateAccessToken(accessToken, accessTokens) {
|
||||
removeAccessTokenAndCookies(c)
|
||||
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.")
|
||||
}
|
||||
|
||||
|
||||
415
api/v1/memo.go
415
api/v1/memo.go
@@ -9,11 +9,15 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/lithammer/shortuuid/v4"
|
||||
"github.com/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/usememos/memos/common/log"
|
||||
"github.com/usememos/memos/common/util"
|
||||
"github.com/usememos/memos/internal/log"
|
||||
"github.com/usememos/memos/internal/util"
|
||||
"github.com/usememos/memos/plugin/webhook"
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
"github.com/usememos/memos/server/service/metric"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
@@ -57,7 +61,6 @@ type Memo struct {
|
||||
Pinned bool `json:"pinned"`
|
||||
|
||||
// Related fields
|
||||
Parent *Memo `json:"parent"`
|
||||
CreatorName string `json:"creatorName"`
|
||||
CreatorUsername string `json:"creatorUsername"`
|
||||
ResourceList []*Resource `json:"resourceList"`
|
||||
@@ -144,45 +147,42 @@ func (s *APIV1Service) registerMemoRoutes(g *echo.Group) {
|
||||
// @Router /api/v1/memo [GET]
|
||||
func (s *APIV1Service) GetMemoList(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
findMemoMessage := &store.FindMemo{}
|
||||
find := &store.FindMemo{
|
||||
OrderByPinned: true,
|
||||
}
|
||||
if userID, err := util.ConvertStringToInt32(c.QueryParam("creatorId")); err == nil {
|
||||
findMemoMessage.CreatorID = &userID
|
||||
find.CreatorID = &userID
|
||||
}
|
||||
|
||||
if username := c.QueryParam("creatorUsername"); username != "" {
|
||||
user, _ := s.Store.GetUser(ctx, &store.FindUser{Username: &username})
|
||||
if user != nil {
|
||||
findMemoMessage.CreatorID = &user.ID
|
||||
find.CreatorID = &user.ID
|
||||
}
|
||||
}
|
||||
|
||||
currentUserID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
// Anonymous use should only fetch PUBLIC memos with specified user
|
||||
if findMemoMessage.CreatorID == nil {
|
||||
if find.CreatorID == nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Missing user to find memo")
|
||||
}
|
||||
findMemoMessage.VisibilityList = []store.Visibility{store.Public}
|
||||
find.VisibilityList = []store.Visibility{store.Public}
|
||||
} else {
|
||||
// Authorized user can fetch all PUBLIC/PROTECTED memo
|
||||
visibilityList := []store.Visibility{store.Public, store.Protected}
|
||||
|
||||
// If Creator is authorized user (as default), PRIVATE memo is OK
|
||||
if findMemoMessage.CreatorID == nil || *findMemoMessage.CreatorID == currentUserID {
|
||||
findMemoMessage.CreatorID = ¤tUserID
|
||||
if find.CreatorID == nil || *find.CreatorID == currentUserID {
|
||||
find.CreatorID = ¤tUserID
|
||||
visibilityList = append(visibilityList, store.Private)
|
||||
}
|
||||
findMemoMessage.VisibilityList = visibilityList
|
||||
find.VisibilityList = visibilityList
|
||||
}
|
||||
|
||||
rowStatus := store.RowStatus(c.QueryParam("rowStatus"))
|
||||
if rowStatus != "" {
|
||||
findMemoMessage.RowStatus = &rowStatus
|
||||
}
|
||||
pinnedStr := c.QueryParam("pinned")
|
||||
if pinnedStr != "" {
|
||||
pinned := pinnedStr == "true"
|
||||
findMemoMessage.Pinned = &pinned
|
||||
find.RowStatus = &rowStatus
|
||||
}
|
||||
|
||||
contentSearch := []string{}
|
||||
@@ -190,17 +190,17 @@ func (s *APIV1Service) GetMemoList(c echo.Context) error {
|
||||
if tag != "" {
|
||||
contentSearch = append(contentSearch, "#"+tag)
|
||||
}
|
||||
contentSlice := c.QueryParams()["content"]
|
||||
if len(contentSlice) > 0 {
|
||||
contentSearch = append(contentSearch, contentSlice...)
|
||||
content := c.QueryParam("content")
|
||||
if content != "" {
|
||||
contentSearch = append(contentSearch, content)
|
||||
}
|
||||
findMemoMessage.ContentSearch = contentSearch
|
||||
find.ContentSearch = contentSearch
|
||||
|
||||
if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
|
||||
findMemoMessage.Limit = &limit
|
||||
find.Limit = &limit
|
||||
}
|
||||
if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil {
|
||||
findMemoMessage.Offset = &offset
|
||||
find.Offset = &offset
|
||||
}
|
||||
|
||||
memoDisplayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx)
|
||||
@@ -208,10 +208,10 @@ func (s *APIV1Service) GetMemoList(c echo.Context) error {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get memo display with updated ts setting value").SetInternal(err)
|
||||
}
|
||||
if memoDisplayWithUpdatedTs {
|
||||
findMemoMessage.OrderByUpdatedTs = true
|
||||
find.OrderByUpdatedTs = true
|
||||
}
|
||||
|
||||
list, err := s.Store.ListMemos(ctx, findMemoMessage)
|
||||
list, err := s.Store.ListMemos(ctx, find)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch memo list").SetInternal(err)
|
||||
}
|
||||
@@ -262,18 +262,13 @@ func (s *APIV1Service) CreateMemo(c echo.Context) error {
|
||||
if createMemoRequest.Visibility == "" {
|
||||
userMemoVisibilitySetting, err := s.Store.GetUserSetting(ctx, &store.FindUserSetting{
|
||||
UserID: &userID,
|
||||
Key: UserSettingMemoVisibilityKey.String(),
|
||||
Key: storepb.UserSettingKey_USER_SETTING_MEMO_VISIBILITY,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user setting").SetInternal(err)
|
||||
}
|
||||
if userMemoVisibilitySetting != nil {
|
||||
memoVisibility := Private
|
||||
err := json.Unmarshal([]byte(userMemoVisibilitySetting.Value), &memoVisibility)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal user setting value").SetInternal(err)
|
||||
}
|
||||
createMemoRequest.Visibility = memoVisibility
|
||||
createMemoRequest.Visibility = Visibility(userMemoVisibilitySetting.GetMemoVisibility())
|
||||
} else {
|
||||
// Private is the default memo visibility.
|
||||
createMemoRequest.Visibility = Private
|
||||
@@ -315,9 +310,6 @@ func (s *APIV1Service) CreateMemo(c echo.Context) error {
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create memo").SetInternal(err)
|
||||
}
|
||||
if err := s.createMemoCreateActivity(ctx, memo); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
|
||||
for _, resourceID := range createMemoRequest.ResourceIDList {
|
||||
if _, err := s.Store.UpdateResource(ctx, &store.UpdateResource{
|
||||
@@ -336,6 +328,42 @@ func (s *APIV1Service) CreateMemo(c echo.Context) error {
|
||||
}); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo relation").SetInternal(err)
|
||||
}
|
||||
if memo.Visibility != store.Private && memoRelationUpsert.Type == MemoRelationComment {
|
||||
relatedMemo, err := s.Store.GetMemo(ctx, &store.FindMemo{
|
||||
ID: &memoRelationUpsert.RelatedMemoID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get related memo").SetInternal(err)
|
||||
}
|
||||
if relatedMemo.CreatorID != memo.CreatorID {
|
||||
activity, err := s.Store.CreateActivity(ctx, &store.Activity{
|
||||
CreatorID: memo.CreatorID,
|
||||
Type: store.ActivityTypeMemoComment,
|
||||
Level: store.ActivityLevelInfo,
|
||||
Payload: &storepb.ActivityPayload{
|
||||
MemoComment: &storepb.ActivityMemoCommentPayload{
|
||||
MemoId: memo.ID,
|
||||
RelatedMemoId: memoRelationUpsert.RelatedMemoID,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
metric.Enqueue("memo comment create")
|
||||
if _, err := s.Store.CreateInbox(ctx, &store.Inbox{
|
||||
SenderID: memo.CreatorID,
|
||||
ReceiverID: relatedMemo.CreatorID,
|
||||
Status: store.UNREAD,
|
||||
Message: &storepb.InboxMessage{
|
||||
Type: storepb.InboxMessage_TYPE_MEMO_COMMENT,
|
||||
ActivityId: &activity.ID,
|
||||
},
|
||||
}); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create inbox").SetInternal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composedMemo, err := s.Store.GetMemo(ctx, &store.FindMemo{
|
||||
@@ -353,23 +381,15 @@ func (s *APIV1Service) CreateMemo(c echo.Context) error {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err)
|
||||
}
|
||||
|
||||
// send notification by telegram bot if memo is not Private
|
||||
// Send notification to telegram if memo is not private.
|
||||
if memoResponse.Visibility != Private {
|
||||
// fetch all telegram UserID
|
||||
userSettings, err := s.Store.ListUserSettings(ctx, &store.FindUserSetting{Key: UserSettingTelegramUserIDKey.String()})
|
||||
userSettings, err := s.Store.ListUserSettings(ctx, &store.FindUserSetting{Key: storepb.UserSettingKey_USER_SETTING_TELEGRAM_USER_ID})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to ListUserSettings").SetInternal(err)
|
||||
}
|
||||
for _, userSetting := range userSettings {
|
||||
// parse telegram UserID setting value into a int64
|
||||
var tgUserIDStr string
|
||||
err := json.Unmarshal([]byte(userSetting.Value), &tgUserIDStr)
|
||||
if err != nil {
|
||||
log.Error("failed to parse Telegram UserID", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
tgUserID, err := strconv.ParseInt(tgUserIDStr, 10, 64)
|
||||
tgUserID, err := strconv.ParseInt(userSetting.GetTelegramUserId(), 10, 64)
|
||||
if err != nil {
|
||||
log.Error("failed to parse Telegram UserID", zap.Error(err))
|
||||
continue
|
||||
@@ -384,6 +404,12 @@ func (s *APIV1Service) CreateMemo(c echo.Context) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Try to dispatch webhook when memo is created.
|
||||
if err := s.DispatchMemoCreatedWebhook(ctx, memoResponse); err != nil {
|
||||
log.Warn("Failed to dispatch memo created webhook", zap.Error(err))
|
||||
}
|
||||
|
||||
metric.Enqueue("memo create")
|
||||
return c.JSON(http.StatusOK, memoResponse)
|
||||
}
|
||||
|
||||
@@ -404,34 +430,34 @@ func (s *APIV1Service) CreateMemo(c echo.Context) error {
|
||||
// - creatorUsername is listed at ./web/src/helpers/api.ts:82, but it's not present here
|
||||
func (s *APIV1Service) GetAllMemos(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
findMemoMessage := &store.FindMemo{}
|
||||
memoFind := &store.FindMemo{}
|
||||
_, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
findMemoMessage.VisibilityList = []store.Visibility{store.Public}
|
||||
memoFind.VisibilityList = []store.Visibility{store.Public}
|
||||
} else {
|
||||
findMemoMessage.VisibilityList = []store.Visibility{store.Public, store.Protected}
|
||||
memoFind.VisibilityList = []store.Visibility{store.Public, store.Protected}
|
||||
}
|
||||
|
||||
if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
|
||||
findMemoMessage.Limit = &limit
|
||||
memoFind.Limit = &limit
|
||||
}
|
||||
if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil {
|
||||
findMemoMessage.Offset = &offset
|
||||
memoFind.Offset = &offset
|
||||
}
|
||||
|
||||
// Only fetch normal status memos.
|
||||
normalStatus := store.Normal
|
||||
findMemoMessage.RowStatus = &normalStatus
|
||||
memoFind.RowStatus = &normalStatus
|
||||
|
||||
memoDisplayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get memo display with updated ts setting value").SetInternal(err)
|
||||
}
|
||||
if memoDisplayWithUpdatedTs {
|
||||
findMemoMessage.OrderByUpdatedTs = true
|
||||
memoFind.OrderByUpdatedTs = true
|
||||
}
|
||||
|
||||
list, err := s.Store.ListMemos(ctx, findMemoMessage)
|
||||
list, err := s.Store.ListMemos(ctx, memoFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch all memo list").SetInternal(err)
|
||||
}
|
||||
@@ -462,7 +488,8 @@ func (s *APIV1Service) GetMemoStats(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
normalStatus := store.Normal
|
||||
findMemoMessage := &store.FindMemo{
|
||||
RowStatus: &normalStatus,
|
||||
RowStatus: &normalStatus,
|
||||
ExcludeContent: true,
|
||||
}
|
||||
if creatorID, err := util.ConvertStringToInt32(c.QueryParam("creatorId")); err == nil {
|
||||
findMemoMessage.CreatorID = &creatorID
|
||||
@@ -556,9 +583,6 @@ func (s *APIV1Service) GetMemo(c echo.Context) error {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "this memo is protected, missing user in session")
|
||||
}
|
||||
}
|
||||
if err := s.createMemoViewActivity(c, memo, userID); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
memoResponse, err := s.convertMemoFromStore(ctx, memo)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err)
|
||||
@@ -602,6 +626,13 @@ func (s *APIV1Service) DeleteMemo(c echo.Context) error {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
if memoMessage, err := s.convertMemoFromStore(ctx, memo); err == nil {
|
||||
// Try to dispatch webhook when memo is deleted.
|
||||
if err := s.DispatchMemoDeletedWebhook(ctx, memoMessage); err != nil {
|
||||
log.Warn("Failed to dispatch memo deleted webhook", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.Store.DeleteMemo(ctx, &store.DeleteMemo{
|
||||
ID: memoID,
|
||||
}); err != nil {
|
||||
@@ -681,6 +712,36 @@ func (s *APIV1Service) UpdateMemo(c echo.Context) error {
|
||||
if patchMemoRequest.Visibility != nil {
|
||||
visibility := store.Visibility(patchMemoRequest.Visibility.String())
|
||||
updateMemoMessage.Visibility = &visibility
|
||||
// Find disable public memos system setting.
|
||||
disablePublicMemosSystemSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
|
||||
Name: SystemSettingDisablePublicMemosName.String(),
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting").SetInternal(err)
|
||||
}
|
||||
if disablePublicMemosSystemSetting != nil {
|
||||
disablePublicMemos := false
|
||||
err = json.Unmarshal([]byte(disablePublicMemosSystemSetting.Value), &disablePublicMemos)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting").SetInternal(err)
|
||||
}
|
||||
if disablePublicMemos {
|
||||
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.StatusNotFound, "User not found")
|
||||
}
|
||||
// Enforce normal user to save as private memo if public memos are disabled.
|
||||
if user.Role == store.RoleUser {
|
||||
visibility = store.Visibility("PRIVATE")
|
||||
updateMemoMessage.Visibility = &visibility
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = s.Store.UpdateMemo(ctx, updateMemoMessage)
|
||||
@@ -695,8 +756,16 @@ func (s *APIV1Service) UpdateMemo(c echo.Context) error {
|
||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID))
|
||||
}
|
||||
|
||||
memoMessage, err := s.convertMemoFromStore(ctx, memo)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo").SetInternal(err)
|
||||
}
|
||||
if patchMemoRequest.ResourceIDList != nil {
|
||||
addedResourceIDList, removedResourceIDList := getIDListDiff(memo.ResourceIDList, patchMemoRequest.ResourceIDList)
|
||||
originResourceIDList := []int32{}
|
||||
for _, resource := range memoMessage.ResourceList {
|
||||
originResourceIDList = append(originResourceIDList, resource.ID)
|
||||
}
|
||||
addedResourceIDList, removedResourceIDList := getIDListDiff(originResourceIDList, patchMemoRequest.ResourceIDList)
|
||||
for _, resourceID := range addedResourceIDList {
|
||||
if _, err := s.Store.UpdateResource(ctx, &store.UpdateResource{
|
||||
ID: resourceID,
|
||||
@@ -715,15 +784,15 @@ func (s *APIV1Service) UpdateMemo(c echo.Context) error {
|
||||
}
|
||||
|
||||
if patchMemoRequest.RelationList != nil {
|
||||
patchMemoRelationList := make([]*store.MemoRelation, 0)
|
||||
patchMemoRelationList := make([]*MemoRelation, 0)
|
||||
for _, memoRelation := range patchMemoRequest.RelationList {
|
||||
patchMemoRelationList = append(patchMemoRelationList, &store.MemoRelation{
|
||||
patchMemoRelationList = append(patchMemoRelationList, &MemoRelation{
|
||||
MemoID: memo.ID,
|
||||
RelatedMemoID: memoRelation.RelatedMemoID,
|
||||
Type: store.MemoRelationType(memoRelation.Type),
|
||||
Type: memoRelation.Type,
|
||||
})
|
||||
}
|
||||
addedMemoRelationList, removedMemoRelationList := getMemoRelationListDiff(memo.RelationList, patchMemoRelationList)
|
||||
addedMemoRelationList, removedMemoRelationList := getMemoRelationListDiff(memoMessage.RelationList, patchMemoRelationList)
|
||||
for _, memoRelation := range addedMemoRelationList {
|
||||
if _, err := s.Store.UpsertMemoRelation(ctx, memoRelation); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo relation").SetInternal(err)
|
||||
@@ -752,52 +821,16 @@ func (s *APIV1Service) UpdateMemo(c echo.Context) error {
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err)
|
||||
}
|
||||
// Try to dispatch webhook when memo is updated.
|
||||
if err := s.DispatchMemoUpdatedWebhook(ctx, memoResponse); err != nil {
|
||||
log.Warn("Failed to dispatch memo updated webhook", zap.Error(err))
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, memoResponse)
|
||||
}
|
||||
|
||||
func (s *APIV1Service) createMemoCreateActivity(ctx context.Context, memo *store.Memo) error {
|
||||
payload := ActivityMemoCreatePayload{
|
||||
MemoID: memo.ID,
|
||||
}
|
||||
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: memo.CreatorID,
|
||||
Type: ActivityMemoCreate.String(),
|
||||
Level: ActivityInfo.String(),
|
||||
Payload: string(payloadBytes),
|
||||
})
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *APIV1Service) createMemoViewActivity(c echo.Context, memo *store.Memo, userID int32) error {
|
||||
ctx := c.Request().Context()
|
||||
payload := ActivityMemoViewPayload{
|
||||
MemoID: memo.ID,
|
||||
}
|
||||
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: userID,
|
||||
Type: string(ActivityMemoView),
|
||||
Level: string(ActivityInfo),
|
||||
Payload: string(payloadBytes),
|
||||
})
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *APIV1Service) convertMemoFromStore(ctx context.Context, memo *store.Memo) (*Memo, error) {
|
||||
memoResponse := &Memo{
|
||||
memoMessage := &Memo{
|
||||
ID: memo.ID,
|
||||
RowStatus: RowStatus(memo.RowStatus.String()),
|
||||
CreatorID: memo.CreatorID,
|
||||
@@ -810,63 +843,63 @@ func (s *APIV1Service) convertMemoFromStore(ctx context.Context, memo *store.Mem
|
||||
|
||||
// Compose creator name.
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &memoResponse.CreatorID,
|
||||
ID: &memoMessage.CreatorID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user.Nickname != "" {
|
||||
memoResponse.CreatorName = user.Nickname
|
||||
memoMessage.CreatorName = user.Nickname
|
||||
} else {
|
||||
memoResponse.CreatorName = user.Username
|
||||
memoMessage.CreatorName = user.Username
|
||||
}
|
||||
memoResponse.CreatorUsername = user.Username
|
||||
memoMessage.CreatorUsername = user.Username
|
||||
|
||||
// Compose display ts.
|
||||
memoResponse.DisplayTs = memoResponse.CreatedTs
|
||||
memoMessage.DisplayTs = memoMessage.CreatedTs
|
||||
// Find memo display with updated ts setting.
|
||||
memoDisplayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if memoDisplayWithUpdatedTs {
|
||||
memoResponse.DisplayTs = memoResponse.UpdatedTs
|
||||
memoMessage.DisplayTs = memoMessage.UpdatedTs
|
||||
}
|
||||
|
||||
// Compose related resources.
|
||||
resourceList, err := s.Store.ListResources(ctx, &store.FindResource{
|
||||
MemoID: &memo.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to list resources")
|
||||
}
|
||||
memoMessage.ResourceList = []*Resource{}
|
||||
for _, resource := range resourceList {
|
||||
memoMessage.ResourceList = append(memoMessage.ResourceList, convertResourceFromStore(resource))
|
||||
}
|
||||
|
||||
// Compose related memo relations.
|
||||
relationList := []*MemoRelation{}
|
||||
for _, relation := range memo.RelationList {
|
||||
tempList, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{
|
||||
MemoID: &memo.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, relation := range tempList {
|
||||
relationList = append(relationList, convertMemoRelationFromStore(relation))
|
||||
}
|
||||
memoResponse.RelationList = relationList
|
||||
|
||||
resourceList := []*Resource{}
|
||||
for _, resourceID := range memo.ResourceIDList {
|
||||
resource, err := s.Store.GetResource(ctx, &store.FindResource{
|
||||
ID: &resourceID,
|
||||
})
|
||||
if resource != nil && err == nil {
|
||||
resourceList = append(resourceList, convertResourceFromStore(resource))
|
||||
}
|
||||
tempList, err = s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{
|
||||
RelatedMemoID: &memo.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
memoResponse.ResourceList = resourceList
|
||||
|
||||
if memo.ParentID != nil {
|
||||
parentMemo, err := s.Store.GetMemo(ctx, &store.FindMemo{
|
||||
ID: memo.ParentID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parentMemo != nil {
|
||||
parent, err := s.convertMemoFromStore(ctx, parentMemo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
memoResponse.Parent = parent
|
||||
}
|
||||
for _, relation := range tempList {
|
||||
relationList = append(relationList, convertMemoRelationFromStore(relation))
|
||||
}
|
||||
|
||||
return memoResponse, nil
|
||||
memoMessage.RelationList = relationList
|
||||
return memoMessage, nil
|
||||
}
|
||||
|
||||
func (s *APIV1Service) getMemoDisplayWithUpdatedTsSettingValue(ctx context.Context) (bool, error) {
|
||||
@@ -892,14 +925,15 @@ func convertCreateMemoRequestToMemoMessage(memoCreate *CreateMemoRequest) *store
|
||||
createdTs = *memoCreate.CreatedTs
|
||||
}
|
||||
return &store.Memo{
|
||||
CreatorID: memoCreate.CreatorID,
|
||||
CreatedTs: createdTs,
|
||||
Content: memoCreate.Content,
|
||||
Visibility: store.Visibility(memoCreate.Visibility),
|
||||
ResourceName: shortuuid.New(),
|
||||
CreatorID: memoCreate.CreatorID,
|
||||
CreatedTs: createdTs,
|
||||
Content: memoCreate.Content,
|
||||
Visibility: store.Visibility(memoCreate.Visibility),
|
||||
}
|
||||
}
|
||||
|
||||
func getMemoRelationListDiff(oldList, newList []*store.MemoRelation) (addedList, removedList []*store.MemoRelation) {
|
||||
func getMemoRelationListDiff(oldList, newList []*MemoRelation) (addedList, removedList []*store.MemoRelation) {
|
||||
oldMap := map[string]bool{}
|
||||
for _, relation := range oldList {
|
||||
oldMap[fmt.Sprintf("%d-%s", relation.RelatedMemoID, relation.Type)] = true
|
||||
@@ -911,13 +945,21 @@ func getMemoRelationListDiff(oldList, newList []*store.MemoRelation) (addedList,
|
||||
for _, relation := range oldList {
|
||||
key := fmt.Sprintf("%d-%s", relation.RelatedMemoID, relation.Type)
|
||||
if !newMap[key] {
|
||||
removedList = append(removedList, relation)
|
||||
removedList = append(removedList, &store.MemoRelation{
|
||||
MemoID: relation.MemoID,
|
||||
RelatedMemoID: relation.RelatedMemoID,
|
||||
Type: store.MemoRelationType(relation.Type),
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, relation := range newList {
|
||||
key := fmt.Sprintf("%d-%s", relation.RelatedMemoID, relation.Type)
|
||||
if !oldMap[key] {
|
||||
addedList = append(addedList, relation)
|
||||
addedList = append(addedList, &store.MemoRelation{
|
||||
MemoID: relation.MemoID,
|
||||
RelatedMemoID: relation.RelatedMemoID,
|
||||
Type: store.MemoRelationType(relation.Type),
|
||||
})
|
||||
}
|
||||
}
|
||||
return addedList, removedList
|
||||
@@ -944,3 +986,82 @@ func getIDListDiff(oldList, newList []int32) (addedList, removedList []int32) {
|
||||
}
|
||||
return addedList, removedList
|
||||
}
|
||||
|
||||
// DispatchMemoCreatedWebhook dispatches webhook when memo is created.
|
||||
func (s *APIV1Service) DispatchMemoCreatedWebhook(ctx context.Context, memo *Memo) error {
|
||||
return s.dispatchMemoRelatedWebhook(ctx, memo, "memos.memo.created")
|
||||
}
|
||||
|
||||
// DispatchMemoUpdatedWebhook dispatches webhook when memo is updated.
|
||||
func (s *APIV1Service) DispatchMemoUpdatedWebhook(ctx context.Context, memo *Memo) error {
|
||||
return s.dispatchMemoRelatedWebhook(ctx, memo, "memos.memo.updated")
|
||||
}
|
||||
|
||||
// DispatchMemoDeletedWebhook dispatches webhook when memo is deletedd.
|
||||
func (s *APIV1Service) DispatchMemoDeletedWebhook(ctx context.Context, memo *Memo) error {
|
||||
return s.dispatchMemoRelatedWebhook(ctx, memo, "memos.memo.deleted")
|
||||
}
|
||||
|
||||
func (s *APIV1Service) dispatchMemoRelatedWebhook(ctx context.Context, memo *Memo, activityType string) error {
|
||||
webhooks, err := s.Store.ListWebhooks(ctx, &store.FindWebhook{
|
||||
CreatorID: &memo.CreatorID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
metric.Enqueue("webhook dispatch")
|
||||
for _, hook := range webhooks {
|
||||
payload := convertMemoToWebhookPayload(memo)
|
||||
payload.ActivityType = activityType
|
||||
payload.URL = hook.Url
|
||||
err := webhook.Post(*payload)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to post webhook")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func convertMemoToWebhookPayload(memo *Memo) *webhook.WebhookPayload {
|
||||
return &webhook.WebhookPayload{
|
||||
CreatorID: memo.CreatorID,
|
||||
CreatedTs: time.Now().Unix(),
|
||||
Memo: &webhook.Memo{
|
||||
ID: memo.ID,
|
||||
CreatorID: memo.CreatorID,
|
||||
CreatedTs: memo.CreatedTs,
|
||||
UpdatedTs: memo.UpdatedTs,
|
||||
Content: memo.Content,
|
||||
Visibility: memo.Visibility.String(),
|
||||
Pinned: memo.Pinned,
|
||||
ResourceList: func() []*webhook.Resource {
|
||||
resources := []*webhook.Resource{}
|
||||
for _, resource := range memo.ResourceList {
|
||||
resources = append(resources, &webhook.Resource{
|
||||
ID: resource.ID,
|
||||
CreatorID: resource.CreatorID,
|
||||
CreatedTs: resource.CreatedTs,
|
||||
UpdatedTs: resource.UpdatedTs,
|
||||
Filename: resource.Filename,
|
||||
InternalPath: resource.InternalPath,
|
||||
ExternalLink: resource.ExternalLink,
|
||||
Type: resource.Type,
|
||||
Size: resource.Size,
|
||||
})
|
||||
}
|
||||
return resources
|
||||
}(),
|
||||
RelationList: func() []*webhook.MemoRelation {
|
||||
relations := []*webhook.MemoRelation{}
|
||||
for _, relation := range memo.RelationList {
|
||||
relations = append(relations, &webhook.MemoRelation{
|
||||
MemoID: relation.MemoID,
|
||||
RelatedMemoID: relation.RelatedMemoID,
|
||||
Type: relation.Type.String(),
|
||||
})
|
||||
}
|
||||
return relations
|
||||
}(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"github.com/usememos/memos/common/util"
|
||||
"github.com/usememos/memos/internal/util"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
|
||||
@@ -7,17 +7,21 @@ import (
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"github.com/usememos/memos/common/util"
|
||||
"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 int32 `json:"memoId"`
|
||||
RelatedMemoID int32 `json:"relatedMemoId"`
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -9,27 +8,27 @@ import (
|
||||
"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/lithammer/shortuuid/v4"
|
||||
"github.com/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/usememos/memos/common/log"
|
||||
"github.com/usememos/memos/common/util"
|
||||
"github.com/usememos/memos/internal/log"
|
||||
"github.com/usememos/memos/internal/util"
|
||||
"github.com/usememos/memos/plugin/storage/s3"
|
||||
"github.com/usememos/memos/server/service/metric"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
type Resource struct {
|
||||
ID int32 `json:"id"`
|
||||
ID int32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
|
||||
// Standard fields
|
||||
CreatorID int32 `json:"creatorId"`
|
||||
@@ -67,9 +66,6 @@ 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}\}`)
|
||||
@@ -82,11 +78,6 @@ func (s *APIV1Service) registerResourceRoutes(g *echo.Group) {
|
||||
g.DELETE("/resource/:resourceId", s.DeleteResource)
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerResourcePublicRoutes(g *echo.Group) {
|
||||
g.GET("/r/:resourceId", s.streamResource)
|
||||
g.GET("/r/:resourceId/*", s.streamResource)
|
||||
}
|
||||
|
||||
// GetResourceList godoc
|
||||
//
|
||||
// @Summary Get a list of resources
|
||||
@@ -150,6 +141,7 @@ func (s *APIV1Service) CreateResource(c echo.Context) error {
|
||||
}
|
||||
|
||||
create := &store.Resource{
|
||||
ResourceName: shortuuid.New(),
|
||||
CreatorID: userID,
|
||||
Filename: request.Filename,
|
||||
ExternalLink: request.ExternalLink,
|
||||
@@ -170,9 +162,7 @@ func (s *APIV1Service) CreateResource(c echo.Context) error {
|
||||
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)
|
||||
}
|
||||
metric.Enqueue("resource create")
|
||||
return c.JSON(http.StatusOK, convertResourceFromStore(resource))
|
||||
}
|
||||
|
||||
@@ -196,7 +186,7 @@ func (s *APIV1Service) UploadResource(c echo.Context) error {
|
||||
}
|
||||
|
||||
// This is the backend default max upload size limit.
|
||||
maxUploadSetting := s.Store.GetSystemSettingValueWithDefault(&ctx, SystemSettingMaxUploadSizeMiBName.String(), "32")
|
||||
maxUploadSetting := s.Store.GetSystemSettingValueWithDefault(ctx, SystemSettingMaxUploadSizeMiBName.String(), "32")
|
||||
var settingMaxUploadSizeBytes int
|
||||
if settingMaxUploadSizeMiB, err := strconv.Atoi(maxUploadSetting); err == nil {
|
||||
settingMaxUploadSizeBytes = settingMaxUploadSizeMiB * MebiByte
|
||||
@@ -228,10 +218,11 @@ func (s *APIV1Service) UploadResource(c echo.Context) error {
|
||||
defer sourceFile.Close()
|
||||
|
||||
create := &store.Resource{
|
||||
CreatorID: userID,
|
||||
Filename: file.Filename,
|
||||
Type: file.Header.Get("Content-Type"),
|
||||
Size: file.Size,
|
||||
ResourceName: shortuuid.New(),
|
||||
CreatorID: userID,
|
||||
Filename: file.Filename,
|
||||
Type: file.Header.Get("Content-Type"),
|
||||
Size: file.Size,
|
||||
}
|
||||
err = SaveResourceBlob(ctx, s.Store, create, sourceFile)
|
||||
if err != nil {
|
||||
@@ -242,9 +233,7 @@ func (s *APIV1Service) UploadResource(c echo.Context) error {
|
||||
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)
|
||||
}
|
||||
metric.Enqueue("resource create")
|
||||
return c.JSON(http.StatusOK, convertResourceFromStore(resource))
|
||||
}
|
||||
|
||||
@@ -283,18 +272,6 @@ func (s *APIV1Service) DeleteResource(c echo.Context) error {
|
||||
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 {
|
||||
@@ -362,113 +339,6 @@ func (s *APIV1Service) UpdateResource(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, convertResourceFromStore(resource))
|
||||
}
|
||||
|
||||
// streamResource godoc
|
||||
//
|
||||
// @Summary Stream a resource
|
||||
// @Description *Swagger UI may have problems displaying other file types than images
|
||||
// @Tags resource
|
||||
// @Produce octet-stream
|
||||
// @Param resourceId path int true "Resource ID"
|
||||
// @Param thumbnail query int false "Thumbnail"
|
||||
// @Success 200 {object} nil "Requested resource"
|
||||
// @Failure 400 {object} nil "ID is not a number: %s | Failed to get resource visibility"
|
||||
// @Failure 401 {object} nil "Resource visibility not match"
|
||||
// @Failure 404 {object} nil "Resource not found: %d"
|
||||
// @Failure 500 {object} nil "Failed to find resource by ID: %v | Failed to open the local resource: %s | Failed to read the local resource: %s"
|
||||
// @Router /o/r/{resourceId} [GET]
|
||||
func (s *APIV1Service) 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=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
|
||||
}
|
||||
c.Response().Writer.Header().Set("Content-Disposition", fmt.Sprintf(`filename="%s"`, resource.Filename))
|
||||
return c.Stream(http.StatusOK, resourceType, bytes.NewReader(blob))
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to marshal activity payload")
|
||||
}
|
||||
activity, err := s.Store.CreateActivity(ctx, &store.Activity{
|
||||
CreatorID: resource.CreatorID,
|
||||
Type: ActivityResourceCreate.String(),
|
||||
Level: ActivityInfo.String(),
|
||||
Payload: string(payloadBytes),
|
||||
})
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func replacePathTemplate(path, filename string) string {
|
||||
t := time.Now()
|
||||
path = fileKeyPattern.ReplaceAllStringFunc(path, func(s string) string {
|
||||
@@ -489,60 +359,18 @@ func replacePathTemplate(path, filename string) string {
|
||||
return fmt.Sprintf("%02d", t.Minute())
|
||||
case "{second}":
|
||||
return fmt.Sprintf("%02d", t.Second())
|
||||
case "{uuid}":
|
||||
return util.GenUUID()
|
||||
}
|
||||
return s
|
||||
})
|
||||
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 convertResourceFromStore(resource *store.Resource) *Resource {
|
||||
return &Resource{
|
||||
ID: resource.ID,
|
||||
Name: resource.ResourceName,
|
||||
CreatorID: resource.CreatorID,
|
||||
CreatedTs: resource.CreatedTs,
|
||||
UpdatedTs: resource.UpdatedTs,
|
||||
@@ -596,17 +424,24 @@ func SaveResourceBlob(ctx context.Context, s *store.Store, create *store.Resourc
|
||||
return errors.Wrap(err, "Failed to unmarshal SystemSettingLocalStoragePathName")
|
||||
}
|
||||
}
|
||||
filePath := filepath.FromSlash(localStoragePath)
|
||||
if !strings.Contains(filePath, "{filename}") {
|
||||
filePath = filepath.Join(filePath, "{filename}")
|
||||
}
|
||||
filePath = filepath.Join(s.Profile.Data, replacePathTemplate(filePath, create.Filename))
|
||||
|
||||
dir := filepath.Dir(filePath)
|
||||
internalPath := localStoragePath
|
||||
if !strings.Contains(internalPath, "{filename}") {
|
||||
internalPath = filepath.Join(internalPath, "{filename}")
|
||||
}
|
||||
internalPath = replacePathTemplate(internalPath, create.Filename)
|
||||
internalPath = filepath.ToSlash(internalPath)
|
||||
create.InternalPath = internalPath
|
||||
|
||||
osPath := filepath.FromSlash(internalPath)
|
||||
if !filepath.IsAbs(osPath) {
|
||||
osPath = filepath.Join(s.Profile.Data, osPath)
|
||||
}
|
||||
dir := filepath.Dir(osPath)
|
||||
if err = os.MkdirAll(dir, os.ModePerm); err != nil {
|
||||
return errors.Wrap(err, "Failed to create directory")
|
||||
}
|
||||
dst, err := os.Create(filePath)
|
||||
dst, err := os.Create(osPath)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed to create file")
|
||||
}
|
||||
@@ -616,7 +451,6 @@ func SaveResourceBlob(ctx context.Context, s *store.Store, create *store.Resourc
|
||||
return errors.Wrap(err, "Failed to copy file")
|
||||
}
|
||||
|
||||
create.InternalPath = filePath
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
106
api/v1/rss.go
106
api/v1/rss.go
@@ -1,7 +1,6 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -12,15 +11,19 @@ import (
|
||||
|
||||
"github.com/gorilla/feeds"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/yuin/goldmark"
|
||||
|
||||
"github.com/usememos/memos/common/util"
|
||||
"github.com/usememos/memos/internal/util"
|
||||
"github.com/usememos/memos/plugin/gomark/ast"
|
||||
"github.com/usememos/memos/plugin/gomark/parser"
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
"github.com/usememos/memos/plugin/gomark/renderer"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
const maxRSSItemCount = 100
|
||||
const maxRSSItemTitleLength = 100
|
||||
const (
|
||||
maxRSSItemCount = 100
|
||||
maxRSSItemTitleLength = 128
|
||||
)
|
||||
|
||||
func (s *APIV1Service) registerRSSRoutes(g *echo.Group) {
|
||||
g.GET("/explore/rss.xml", s.GetExploreRSS)
|
||||
@@ -114,30 +117,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/" + fmt.Sprintf("%d", memo.ID)},
|
||||
Description: getRSSItemDescription(memo.Content),
|
||||
Created: time.Unix(memo.CreatedTs, 0),
|
||||
Enclosure: &feeds.Enclosure{Url: baseURL + "/m/" + fmt.Sprintf("%d", 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 "", errors.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/" + fmt.Sprintf("%d", resource.ID)
|
||||
enclosure.Url = baseURL + "/o/r/" + resource.Name
|
||||
}
|
||||
enclosure.Length = strconv.Itoa(int(resource.Size))
|
||||
enclosure.Type = resource.Type
|
||||
@@ -160,12 +161,9 @@ func (s *APIV1Service) getSystemCustomizedProfile(ctx context.Context) (*Customi
|
||||
return nil, err
|
||||
}
|
||||
customizedProfile := &CustomizedProfile{
|
||||
Name: "memos",
|
||||
LogoURL: "",
|
||||
Description: "",
|
||||
Locale: "en",
|
||||
Appearance: "system",
|
||||
ExternalURL: "",
|
||||
Name: "Memos",
|
||||
Locale: "en",
|
||||
Appearance: "system",
|
||||
}
|
||||
if systemSetting != nil {
|
||||
if err := json.Unmarshal([]byte(systemSetting.Value), customizedProfile); err != nil {
|
||||
@@ -176,36 +174,28 @@ func (s *APIV1Service) getSystemCustomizedProfile(ctx context.Context) (*Customi
|
||||
}
|
||||
|
||||
func getRSSItemTitle(content string) string {
|
||||
var title string
|
||||
if isTitleDefined(content) {
|
||||
title = strings.Split(content, "\n")[0][2:]
|
||||
} else {
|
||||
title = strings.Split(content, "\n")[0]
|
||||
var titleLengthLimit = util.Min(len(title), maxRSSItemTitleLength)
|
||||
if titleLengthLimit < len(title) {
|
||||
title = title[:titleLengthLimit] + "..."
|
||||
}
|
||||
tokens := tokenizer.Tokenize(content)
|
||||
nodes, _ := parser.Parse(tokens)
|
||||
if len(nodes) > 0 {
|
||||
firstNode := nodes[0]
|
||||
title := renderer.NewStringRenderer().Render([]ast.Node{firstNode})
|
||||
return title
|
||||
}
|
||||
|
||||
title := strings.Split(content, "\n")[0]
|
||||
var titleLengthLimit = util.Min(len(title), maxRSSItemTitleLength)
|
||||
if titleLengthLimit < len(title) {
|
||||
title = title[:titleLengthLimit] + "..."
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
func getRSSItemDescription(content string) string {
|
||||
var description string
|
||||
if isTitleDefined(content) {
|
||||
var firstLineEnd = strings.Index(content, "\n")
|
||||
description = strings.Trim(content[firstLineEnd+1:], " ")
|
||||
} else {
|
||||
description = content
|
||||
func getRSSItemDescription(content string) (string, error) {
|
||||
tokens := tokenizer.Tokenize(content)
|
||||
nodes, err := parser.Parse(tokens)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// TODO: use our `./plugin/gomark` parser to handle markdown-like content.
|
||||
var buf bytes.Buffer
|
||||
if err := goldmark.Convert([]byte(description), &buf); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func isTitleDefined(content string) bool {
|
||||
return strings.HasPrefix(content, "# ")
|
||||
result := renderer.NewHTMLRenderer().Render(nodes)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"github.com/usememos/memos/common/util"
|
||||
"github.com/usememos/memos/internal/util"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
|
||||
1460
api/v1/swagger.yaml
1460
api/v1/swagger.yaml
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/usememos/memos/common/log"
|
||||
"github.com/usememos/memos/internal/log"
|
||||
"github.com/usememos/memos/server/profile"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
@@ -26,8 +26,6 @@ type SystemStatus struct {
|
||||
DisablePublicMemos bool `json:"disablePublicMemos"`
|
||||
// Max upload size.
|
||||
MaxUploadSizeMiB int `json:"maxUploadSizeMiB"`
|
||||
// Auto Backup Interval.
|
||||
AutoBackupInterval int `json:"autoBackupInterval"`
|
||||
// Additional style.
|
||||
AdditionalStyle string `json:"additionalStyle"`
|
||||
// Additional script.
|
||||
@@ -72,12 +70,15 @@ func (s *APIV1Service) GetSystemStatus(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
|
||||
systemStatus := SystemStatus{
|
||||
Profile: *s.Profile,
|
||||
Profile: profile.Profile{
|
||||
Mode: s.Profile.Mode,
|
||||
Version: s.Profile.Version,
|
||||
},
|
||||
// Allow sign up by default.
|
||||
AllowSignUp: true,
|
||||
MaxUploadSizeMiB: 32,
|
||||
CustomizedProfile: CustomizedProfile{
|
||||
Name: "memos",
|
||||
Name: "Memos",
|
||||
Locale: "en",
|
||||
Appearance: "system",
|
||||
},
|
||||
@@ -101,14 +102,14 @@ func (s *APIV1Service) GetSystemStatus(c echo.Context) error {
|
||||
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() {
|
||||
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))
|
||||
// Skip invalid value.
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -121,8 +122,6 @@ func (s *APIV1Service) GetSystemStatus(c echo.Context) error {
|
||||
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():
|
||||
|
||||
@@ -3,6 +3,7 @@ package v1
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
@@ -40,8 +41,8 @@ const (
|
||||
SystemSettingTelegramBotTokenName SystemSettingName = "telegram-bot-token"
|
||||
// SystemSettingMemoDisplayWithUpdatedTsName is the name of memo display with updated ts.
|
||||
SystemSettingMemoDisplayWithUpdatedTsName SystemSettingName = "memo-display-with-updated-ts"
|
||||
// SystemSettingAutoBackupIntervalName is the name of auto backup interval as seconds.
|
||||
SystemSettingAutoBackupIntervalName SystemSettingName = "auto-backup-interval"
|
||||
// SystemSettingInstanceURLName is the name of instance url setting.
|
||||
SystemSettingInstanceURLName SystemSettingName = "instance-url"
|
||||
)
|
||||
const systemSettingUnmarshalError = `failed to unmarshal value from system setting "%v"`
|
||||
|
||||
@@ -242,13 +243,23 @@ func (upsert UpsertSystemSettingRequest) Validate() error {
|
||||
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||
return errors.Errorf(systemSettingUnmarshalError, settingName)
|
||||
}
|
||||
case SystemSettingAutoBackupIntervalName:
|
||||
var value int
|
||||
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||
return errors.Errorf(systemSettingUnmarshalError, settingName)
|
||||
}
|
||||
if value < 0 {
|
||||
return errors.New("must be positive")
|
||||
|
||||
trimmedValue := strings.TrimSpace(value)
|
||||
switch {
|
||||
case trimmedValue != value:
|
||||
return errors.New("local storage path must not contain leading or trailing whitespace")
|
||||
case trimmedValue == "":
|
||||
return errors.New("local storage path can't be empty")
|
||||
case strings.Contains(trimmedValue, "\\"):
|
||||
return errors.New("local storage path must use forward slashes `/`")
|
||||
case strings.Contains(trimmedValue, "../"):
|
||||
return errors.New("local storage path is not allowed to contain `../`")
|
||||
case strings.HasPrefix(trimmedValue, "./"):
|
||||
return errors.New("local storage path is not allowed to start with `./`")
|
||||
case filepath.IsAbs(trimmedValue) || trimmedValue[0] == '/':
|
||||
return errors.New("local storage path must be a relative path")
|
||||
case !strings.Contains(trimmedValue, "{filename}"):
|
||||
return errors.New("local storage path must contain `{filename}`")
|
||||
}
|
||||
case SystemSettingTelegramBotTokenName:
|
||||
if upsert.Value == "" {
|
||||
@@ -271,6 +282,7 @@ func (upsert UpsertSystemSettingRequest) Validate() error {
|
||||
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||
return errors.Errorf(systemSettingUnmarshalError, settingName)
|
||||
}
|
||||
case SystemSettingInstanceURLName:
|
||||
default:
|
||||
return errors.New("invalid system setting name")
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"sort"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/exp/slices"
|
||||
|
||||
"github.com/usememos/memos/store"
|
||||
@@ -99,9 +98,6 @@ func (s *APIV1Service) CreateTag(c echo.Context) error {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -196,27 +192,6 @@ func (s *APIV1Service) GetTagSuggestion(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, tagList)
|
||||
}
|
||||
|
||||
func (s *APIV1Service) createTagCreateActivity(c echo.Context, tag *Tag) error {
|
||||
ctx := c.Request().Context()
|
||||
payload := ActivityTagCreatePayload{
|
||||
TagName: tag.Name,
|
||||
}
|
||||
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),
|
||||
})
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func convertTagFromStore(tag *store.Tag) *Tag {
|
||||
return &Tag{
|
||||
Name: tag.Name,
|
||||
|
||||
107
api/v1/user.go
107
api/v1/user.go
@@ -11,7 +11,8 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/usememos/memos/common/util"
|
||||
"github.com/usememos/memos/internal/util"
|
||||
"github.com/usememos/memos/server/service/metric"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
@@ -40,13 +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:"-"`
|
||||
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 {
|
||||
@@ -87,6 +87,23 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
||||
// @Router /api/v1/user [GET]
|
||||
func (s *APIV1Service) GetUserList(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
|
||||
}
|
||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by id").SetInternal(err)
|
||||
}
|
||||
if currentUser == nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
|
||||
}
|
||||
if currentUser.Role != store.RoleHost && currentUser.Role != store.RoleAdmin {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized to list users")
|
||||
}
|
||||
|
||||
list, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch user list").SetInternal(err)
|
||||
@@ -141,7 +158,7 @@ func (s *APIV1Service) CreateUser(c echo.Context) error {
|
||||
if err := userCreate.Validate(); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format").SetInternal(err)
|
||||
}
|
||||
if !usernameMatcher.MatchString(strings.ToLower(userCreate.Username)) {
|
||||
if !util.ResourceNameMatcher.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.
|
||||
@@ -166,9 +183,7 @@ func (s *APIV1Service) CreateUser(c echo.Context) error {
|
||||
}
|
||||
|
||||
userMessage := convertUserFromStore(user)
|
||||
if err := s.createUserCreateActivity(c, userMessage); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
metric.Enqueue("user create")
|
||||
return c.JSON(http.StatusOK, userMessage)
|
||||
}
|
||||
|
||||
@@ -196,18 +211,7 @@ func (s *APIV1Service) GetCurrentUser(c echo.Context) error {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -265,8 +269,12 @@ func (s *APIV1Service) GetUserByID(c echo.Context) error {
|
||||
}
|
||||
|
||||
userMessage := convertUserFromStore(user)
|
||||
// data desensitize
|
||||
userMessage.Email = ""
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok || userID != user.ID {
|
||||
// Data desensitize.
|
||||
userMessage.Email = ""
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, userMessage)
|
||||
}
|
||||
|
||||
@@ -304,11 +312,13 @@ func (s *APIV1Service) DeleteUser(c echo.Context) error {
|
||||
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 currentUserID == userID {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Cannot delete current user")
|
||||
}
|
||||
if err := s.Store.DeleteUser(ctx, userDelete); err != nil {
|
||||
|
||||
if err := s.Store.DeleteUser(ctx, &store.DeleteUser{
|
||||
ID: userID,
|
||||
}); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete user").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, true)
|
||||
@@ -364,9 +374,12 @@ func (s *APIV1Service) UpdateUser(c echo.Context) error {
|
||||
if request.RowStatus != nil {
|
||||
rowStatus := store.RowStatus(request.RowStatus.String())
|
||||
userUpdate.RowStatus = &rowStatus
|
||||
if rowStatus == store.Archived && currentUserID == userID {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Cannot archive current user")
|
||||
}
|
||||
}
|
||||
if request.Username != nil {
|
||||
if !usernameMatcher.MatchString(strings.ToLower(*request.Username)) {
|
||||
if !util.ResourceNameMatcher.MatchString(strings.ToLower(*request.Username)) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid username %s", *request.Username)).SetInternal(err)
|
||||
}
|
||||
userUpdate.Username = request.Username
|
||||
@@ -395,18 +408,7 @@ func (s *APIV1Service) UpdateUser(c echo.Context) error {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -471,29 +473,6 @@ func (update UpdateUserRequest) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
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: user.ID,
|
||||
Type: ActivityUserCreate.String(),
|
||||
Level: ActivityInfo.String(),
|
||||
Payload: string(payloadBytes),
|
||||
})
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func convertUserFromStore(user *store.User) *User {
|
||||
return &User{
|
||||
ID: user.ID,
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/exp/slices"
|
||||
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
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"
|
||||
// UserSettingTelegramUserIDKey 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 int32 `json:"userId"`
|
||||
Key UserSettingKey `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type UpsertUserSettingRequest struct {
|
||||
UserID int32 `json:"-"`
|
||||
Key UserSettingKey `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerUserSettingRoutes(g *echo.Group) {
|
||||
g.POST("/user/setting", s.UpsertUserSetting)
|
||||
}
|
||||
|
||||
// UpsertUserSetting godoc
|
||||
//
|
||||
// @Summary Upsert user setting
|
||||
// @Tags user-setting
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body UpsertUserSettingRequest true "Request object."
|
||||
// @Success 200 {object} store.UserSetting "Created user setting"
|
||||
// @Failure 400 {object} nil "Malformatted post user setting upsert request | Invalid user setting format"
|
||||
// @Failure 401 {object} nil "Missing auth session"
|
||||
// @Failure 500 {object} nil "Failed to upsert user setting"
|
||||
// @Router /api/v1/user/setting [POST]
|
||||
func (s *APIV1Service) UpsertUserSetting(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
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 (upsert UpsertUserSettingRequest) Validate() error {
|
||||
if upsert.Key == UserSettingLocaleKey {
|
||||
localeValue := "en"
|
||||
err := json.Unmarshal([]byte(upsert.Value), &localeValue)
|
||||
if err != nil {
|
||||
return errors.New("failed to unmarshal user setting locale value")
|
||||
}
|
||||
if !slices.Contains(UserSettingLocaleValue, localeValue) {
|
||||
return errors.New("invalid user setting locale value")
|
||||
}
|
||||
} else if upsert.Key == UserSettingAppearanceKey {
|
||||
appearanceValue := "system"
|
||||
err := json.Unmarshal([]byte(upsert.Value), &appearanceValue)
|
||||
if err != nil {
|
||||
return errors.New("failed to unmarshal user setting appearance value")
|
||||
}
|
||||
if !slices.Contains(UserSettingAppearanceValue, appearanceValue) {
|
||||
return errors.New("invalid user setting appearance value")
|
||||
}
|
||||
} else if upsert.Key == UserSettingMemoVisibilityKey {
|
||||
memoVisibilityValue := Private
|
||||
err := json.Unmarshal([]byte(upsert.Value), &memoVisibilityValue)
|
||||
if err != nil {
|
||||
return errors.New("failed to unmarshal user setting memo visibility value")
|
||||
}
|
||||
if !slices.Contains(UserSettingMemoVisibilityValue, memoVisibilityValue) {
|
||||
return errors.New("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 errors.New("invalid user setting telegram user id value")
|
||||
}
|
||||
} else {
|
||||
return errors.New("invalid user setting key")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func convertUserSettingFromStore(userSetting *store.UserSetting) *UserSetting {
|
||||
return &UserSetting{
|
||||
UserID: userSetting.UserID,
|
||||
Key: UserSettingKey(userSetting.Key),
|
||||
Value: userSetting.Value,
|
||||
}
|
||||
}
|
||||
27
api/v1/v1.go
27
api/v1/v1.go
@@ -1,8 +1,13 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"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"
|
||||
@@ -44,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)
|
||||
})
|
||||
@@ -52,7 +72,6 @@ func (s *APIV1Service) Register(rootGroup *echo.Group) {
|
||||
s.registerAuthRoutes(apiV1Group)
|
||||
s.registerIdentityProviderRoutes(apiV1Group)
|
||||
s.registerUserRoutes(apiV1Group)
|
||||
s.registerUserSettingRoutes(apiV1Group)
|
||||
s.registerTagRoutes(apiV1Group)
|
||||
s.registerStorageRoutes(apiV1Group)
|
||||
s.registerResourceRoutes(apiV1Group)
|
||||
@@ -66,7 +85,9 @@ 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
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/pkg/errors"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/usememos/memos/api/auth"
|
||||
"github.com/usememos/memos/common/util"
|
||||
"github.com/usememos/memos/internal/util"
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
@@ -68,6 +68,9 @@ func (in *GRPCAuthInterceptor) AuthenticationInterceptor(ctx context.Context, re
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -3,9 +3,15 @@ package v2
|
||||
import "strings"
|
||||
|
||||
var authenticationAllowlistMethods = map[string]bool{
|
||||
"/memos.api.v2.SystemService/GetSystemInfo": true,
|
||||
"/memos.api.v2.UserService/GetUser": true,
|
||||
"/memos.api.v2.MemoService/ListMemos": true,
|
||||
"/memos.api.v2.WorkspaceService/GetWorkspaceProfile": true,
|
||||
"/memos.api.v2.AuthService/GetAuthStatus": true,
|
||||
"/memos.api.v2.UserService/GetUser": true,
|
||||
"/memos.api.v2.MemoService/ListMemos": true,
|
||||
"/memos.api.v2.MemoService/GetMemo": true,
|
||||
"/memos.api.v2.MemoService/GetMemoByName": true,
|
||||
"/memos.api.v2.MemoService/ListMemoResources": true,
|
||||
"/memos.api.v2.MemoService/ListMemoRelations": true,
|
||||
"/memos.api.v2.MemoService/ListMemoComments": true,
|
||||
}
|
||||
|
||||
// isUnauthorizeAllowedMethod returns whether the method is exempted from authentication.
|
||||
|
||||
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
|
||||
}
|
||||
41
api/v2/auth_service.go
Normal file
41
api/v2/auth_service.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"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"
|
||||
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 {
|
||||
// Set the cookie header to expire access token.
|
||||
if err := clearAccessTokenCookie(ctx); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to set grpc header")
|
||||
}
|
||||
return nil, status.Errorf(codes.Unauthenticated, "user not found")
|
||||
}
|
||||
return &apiv2pb.GetAuthStatusResponse{
|
||||
User: convertUserFromStore(user),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func clearAccessTokenCookie(ctx context.Context) error {
|
||||
if err := grpc.SetHeader(ctx, metadata.New(map[string]string{
|
||||
"Set-Cookie": fmt.Sprintf("%s=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Strict", auth.AccessTokenCookieName),
|
||||
})); err != nil {
|
||||
return errors.Wrap(err, "failed to set grpc header")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -2,6 +2,10 @@ package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
|
||||
"github.com/usememos/memos/store"
|
||||
@@ -42,3 +46,29 @@ func getCurrentUser(ctx context.Context, s *store.Store) (*store.User, error) {
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func getPageToken(limit int, offset int) (string, error) {
|
||||
return marshalPageToken(&apiv2pb.PageToken{
|
||||
Limit: int32(limit),
|
||||
Offset: int32(offset),
|
||||
})
|
||||
}
|
||||
|
||||
func marshalPageToken(pageToken *apiv2pb.PageToken) (string, error) {
|
||||
b, err := proto.Marshal(pageToken)
|
||||
if err != nil {
|
||||
return "", errors.Wrapf(err, "failed to marshal page token")
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func unmarshalPageToken(s string, pageToken *apiv2pb.PageToken) error {
|
||||
b, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to decode page token")
|
||||
}
|
||||
if err := proto.Unmarshal(b, pageToken); err != nil {
|
||||
return errors.Wrapf(err, "failed to unmarshal page token")
|
||||
}
|
||||
return 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
|
||||
}
|
||||
}
|
||||
231
api/v2/markdown_service.go
Normal file
231
api/v2/markdown_service.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/usememos/memos/plugin/gomark/ast"
|
||||
"github.com/usememos/memos/plugin/gomark/parser"
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
|
||||
)
|
||||
|
||||
func (*APIV2Service) ParseMarkdown(_ context.Context, request *apiv2pb.ParseMarkdownRequest) (*apiv2pb.ParseMarkdownResponse, error) {
|
||||
rawNodes, err := parser.Parse(tokenizer.Tokenize(request.Markdown))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to parse memo content")
|
||||
}
|
||||
nodes := convertFromASTNodes(rawNodes)
|
||||
return &apiv2pb.ParseMarkdownResponse{
|
||||
Nodes: nodes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func convertFromASTNodes(rawNodes []ast.Node) []*apiv2pb.Node {
|
||||
nodes := []*apiv2pb.Node{}
|
||||
for _, rawNode := range rawNodes {
|
||||
node := convertFromASTNode(rawNode)
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
func convertFromASTNode(rawNode ast.Node) *apiv2pb.Node {
|
||||
node := &apiv2pb.Node{
|
||||
Type: apiv2pb.NodeType(rawNode.Type()),
|
||||
}
|
||||
|
||||
switch n := rawNode.(type) {
|
||||
case *ast.LineBreak:
|
||||
node.Node = &apiv2pb.Node_LineBreakNode{}
|
||||
case *ast.Paragraph:
|
||||
children := convertFromASTNodes(n.Children)
|
||||
node.Node = &apiv2pb.Node_ParagraphNode{ParagraphNode: &apiv2pb.ParagraphNode{Children: children}}
|
||||
case *ast.CodeBlock:
|
||||
node.Node = &apiv2pb.Node_CodeBlockNode{CodeBlockNode: &apiv2pb.CodeBlockNode{Language: n.Language, Content: n.Content}}
|
||||
case *ast.Heading:
|
||||
children := convertFromASTNodes(n.Children)
|
||||
node.Node = &apiv2pb.Node_HeadingNode{HeadingNode: &apiv2pb.HeadingNode{Level: int32(n.Level), Children: children}}
|
||||
case *ast.HorizontalRule:
|
||||
node.Node = &apiv2pb.Node_HorizontalRuleNode{HorizontalRuleNode: &apiv2pb.HorizontalRuleNode{Symbol: n.Symbol}}
|
||||
case *ast.Blockquote:
|
||||
children := convertFromASTNodes(n.Children)
|
||||
node.Node = &apiv2pb.Node_BlockquoteNode{BlockquoteNode: &apiv2pb.BlockquoteNode{Children: children}}
|
||||
case *ast.OrderedList:
|
||||
children := convertFromASTNodes(n.Children)
|
||||
node.Node = &apiv2pb.Node_OrderedListNode{OrderedListNode: &apiv2pb.OrderedListNode{Number: n.Number, Indent: int32(n.Indent), Children: children}}
|
||||
case *ast.UnorderedList:
|
||||
children := convertFromASTNodes(n.Children)
|
||||
node.Node = &apiv2pb.Node_UnorderedListNode{UnorderedListNode: &apiv2pb.UnorderedListNode{Symbol: n.Symbol, Indent: int32(n.Indent), Children: children}}
|
||||
case *ast.TaskList:
|
||||
children := convertFromASTNodes(n.Children)
|
||||
node.Node = &apiv2pb.Node_TaskListNode{TaskListNode: &apiv2pb.TaskListNode{Symbol: n.Symbol, Indent: int32(n.Indent), Complete: n.Complete, Children: children}}
|
||||
case *ast.MathBlock:
|
||||
node.Node = &apiv2pb.Node_MathBlockNode{MathBlockNode: &apiv2pb.MathBlockNode{Content: n.Content}}
|
||||
case *ast.Table:
|
||||
node.Node = &apiv2pb.Node_TableNode{TableNode: convertTableFromASTNode(n)}
|
||||
case *ast.EmbeddedContent:
|
||||
node.Node = &apiv2pb.Node_EmbeddedContentNode{EmbeddedContentNode: &apiv2pb.EmbeddedContentNode{ResourceName: n.ResourceName, Params: n.Params}}
|
||||
case *ast.Text:
|
||||
node.Node = &apiv2pb.Node_TextNode{TextNode: &apiv2pb.TextNode{Content: n.Content}}
|
||||
case *ast.Bold:
|
||||
children := convertFromASTNodes(n.Children)
|
||||
node.Node = &apiv2pb.Node_BoldNode{BoldNode: &apiv2pb.BoldNode{Symbol: n.Symbol, Children: children}}
|
||||
case *ast.Italic:
|
||||
node.Node = &apiv2pb.Node_ItalicNode{ItalicNode: &apiv2pb.ItalicNode{Symbol: n.Symbol, Content: n.Content}}
|
||||
case *ast.BoldItalic:
|
||||
node.Node = &apiv2pb.Node_BoldItalicNode{BoldItalicNode: &apiv2pb.BoldItalicNode{Symbol: n.Symbol, Content: n.Content}}
|
||||
case *ast.Code:
|
||||
node.Node = &apiv2pb.Node_CodeNode{CodeNode: &apiv2pb.CodeNode{Content: n.Content}}
|
||||
case *ast.Image:
|
||||
node.Node = &apiv2pb.Node_ImageNode{ImageNode: &apiv2pb.ImageNode{AltText: n.AltText, Url: n.URL}}
|
||||
case *ast.Link:
|
||||
node.Node = &apiv2pb.Node_LinkNode{LinkNode: &apiv2pb.LinkNode{Text: n.Text, Url: n.URL}}
|
||||
case *ast.AutoLink:
|
||||
node.Node = &apiv2pb.Node_AutoLinkNode{AutoLinkNode: &apiv2pb.AutoLinkNode{Url: n.URL, IsRawText: n.IsRawText}}
|
||||
case *ast.Tag:
|
||||
node.Node = &apiv2pb.Node_TagNode{TagNode: &apiv2pb.TagNode{Content: n.Content}}
|
||||
case *ast.Strikethrough:
|
||||
node.Node = &apiv2pb.Node_StrikethroughNode{StrikethroughNode: &apiv2pb.StrikethroughNode{Content: n.Content}}
|
||||
case *ast.EscapingCharacter:
|
||||
node.Node = &apiv2pb.Node_EscapingCharacterNode{EscapingCharacterNode: &apiv2pb.EscapingCharacterNode{Symbol: n.Symbol}}
|
||||
case *ast.Math:
|
||||
node.Node = &apiv2pb.Node_MathNode{MathNode: &apiv2pb.MathNode{Content: n.Content}}
|
||||
case *ast.Highlight:
|
||||
node.Node = &apiv2pb.Node_HighlightNode{HighlightNode: &apiv2pb.HighlightNode{Content: n.Content}}
|
||||
case *ast.Subscript:
|
||||
node.Node = &apiv2pb.Node_SubscriptNode{SubscriptNode: &apiv2pb.SubscriptNode{Content: n.Content}}
|
||||
case *ast.Superscript:
|
||||
node.Node = &apiv2pb.Node_SuperscriptNode{SuperscriptNode: &apiv2pb.SuperscriptNode{Content: n.Content}}
|
||||
case *ast.ReferencedContent:
|
||||
node.Node = &apiv2pb.Node_ReferencedContentNode{ReferencedContentNode: &apiv2pb.ReferencedContentNode{ResourceName: n.ResourceName, Params: n.Params}}
|
||||
default:
|
||||
node.Node = &apiv2pb.Node_TextNode{TextNode: &apiv2pb.TextNode{}}
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
func convertToASTNodes(nodes []*apiv2pb.Node) []ast.Node {
|
||||
rawNodes := []ast.Node{}
|
||||
for _, node := range nodes {
|
||||
rawNode := convertToASTNode(node)
|
||||
rawNodes = append(rawNodes, rawNode)
|
||||
}
|
||||
return rawNodes
|
||||
}
|
||||
|
||||
func convertToASTNode(node *apiv2pb.Node) ast.Node {
|
||||
switch n := node.Node.(type) {
|
||||
case *apiv2pb.Node_LineBreakNode:
|
||||
return &ast.LineBreak{}
|
||||
case *apiv2pb.Node_ParagraphNode:
|
||||
children := convertToASTNodes(n.ParagraphNode.Children)
|
||||
return &ast.Paragraph{Children: children}
|
||||
case *apiv2pb.Node_CodeBlockNode:
|
||||
return &ast.CodeBlock{Language: n.CodeBlockNode.Language, Content: n.CodeBlockNode.Content}
|
||||
case *apiv2pb.Node_HeadingNode:
|
||||
children := convertToASTNodes(n.HeadingNode.Children)
|
||||
return &ast.Heading{Level: int(n.HeadingNode.Level), Children: children}
|
||||
case *apiv2pb.Node_HorizontalRuleNode:
|
||||
return &ast.HorizontalRule{Symbol: n.HorizontalRuleNode.Symbol}
|
||||
case *apiv2pb.Node_BlockquoteNode:
|
||||
children := convertToASTNodes(n.BlockquoteNode.Children)
|
||||
return &ast.Blockquote{Children: children}
|
||||
case *apiv2pb.Node_OrderedListNode:
|
||||
children := convertToASTNodes(n.OrderedListNode.Children)
|
||||
return &ast.OrderedList{Number: n.OrderedListNode.Number, Indent: int(n.OrderedListNode.Indent), Children: children}
|
||||
case *apiv2pb.Node_UnorderedListNode:
|
||||
children := convertToASTNodes(n.UnorderedListNode.Children)
|
||||
return &ast.UnorderedList{Symbol: n.UnorderedListNode.Symbol, Indent: int(n.UnorderedListNode.Indent), Children: children}
|
||||
case *apiv2pb.Node_TaskListNode:
|
||||
children := convertToASTNodes(n.TaskListNode.Children)
|
||||
return &ast.TaskList{Symbol: n.TaskListNode.Symbol, Indent: int(n.TaskListNode.Indent), Complete: n.TaskListNode.Complete, Children: children}
|
||||
case *apiv2pb.Node_MathBlockNode:
|
||||
return &ast.MathBlock{Content: n.MathBlockNode.Content}
|
||||
case *apiv2pb.Node_TableNode:
|
||||
return convertTableToASTNode(node)
|
||||
case *apiv2pb.Node_EmbeddedContentNode:
|
||||
return &ast.EmbeddedContent{ResourceName: n.EmbeddedContentNode.ResourceName, Params: n.EmbeddedContentNode.Params}
|
||||
case *apiv2pb.Node_TextNode:
|
||||
return &ast.Text{Content: n.TextNode.Content}
|
||||
case *apiv2pb.Node_BoldNode:
|
||||
children := convertToASTNodes(n.BoldNode.Children)
|
||||
return &ast.Bold{Symbol: n.BoldNode.Symbol, Children: children}
|
||||
case *apiv2pb.Node_ItalicNode:
|
||||
return &ast.Italic{Symbol: n.ItalicNode.Symbol, Content: n.ItalicNode.Content}
|
||||
case *apiv2pb.Node_BoldItalicNode:
|
||||
return &ast.BoldItalic{Symbol: n.BoldItalicNode.Symbol, Content: n.BoldItalicNode.Content}
|
||||
case *apiv2pb.Node_CodeNode:
|
||||
return &ast.Code{Content: n.CodeNode.Content}
|
||||
case *apiv2pb.Node_ImageNode:
|
||||
return &ast.Image{AltText: n.ImageNode.AltText, URL: n.ImageNode.Url}
|
||||
case *apiv2pb.Node_LinkNode:
|
||||
return &ast.Link{Text: n.LinkNode.Text, URL: n.LinkNode.Url}
|
||||
case *apiv2pb.Node_AutoLinkNode:
|
||||
return &ast.AutoLink{URL: n.AutoLinkNode.Url, IsRawText: n.AutoLinkNode.IsRawText}
|
||||
case *apiv2pb.Node_TagNode:
|
||||
return &ast.Tag{Content: n.TagNode.Content}
|
||||
case *apiv2pb.Node_StrikethroughNode:
|
||||
return &ast.Strikethrough{Content: n.StrikethroughNode.Content}
|
||||
case *apiv2pb.Node_EscapingCharacterNode:
|
||||
return &ast.EscapingCharacter{Symbol: n.EscapingCharacterNode.Symbol}
|
||||
case *apiv2pb.Node_MathNode:
|
||||
return &ast.Math{Content: n.MathNode.Content}
|
||||
case *apiv2pb.Node_HighlightNode:
|
||||
return &ast.Highlight{Content: n.HighlightNode.Content}
|
||||
case *apiv2pb.Node_SubscriptNode:
|
||||
return &ast.Subscript{Content: n.SubscriptNode.Content}
|
||||
case *apiv2pb.Node_SuperscriptNode:
|
||||
return &ast.Superscript{Content: n.SuperscriptNode.Content}
|
||||
case *apiv2pb.Node_ReferencedContentNode:
|
||||
return &ast.ReferencedContent{ResourceName: n.ReferencedContentNode.ResourceName, Params: n.ReferencedContentNode.Params}
|
||||
default:
|
||||
return &ast.Text{}
|
||||
}
|
||||
}
|
||||
|
||||
func convertTableToASTNode(node *apiv2pb.Node) *ast.Table {
|
||||
table := &ast.Table{
|
||||
Header: node.GetTableNode().Header,
|
||||
Delimiter: node.GetTableNode().Delimiter,
|
||||
}
|
||||
for _, row := range node.GetTableNode().Rows {
|
||||
table.Rows = append(table.Rows, row.Cells)
|
||||
}
|
||||
return table
|
||||
}
|
||||
|
||||
func convertTableFromASTNode(node *ast.Table) *apiv2pb.TableNode {
|
||||
table := &apiv2pb.TableNode{
|
||||
Header: node.Header,
|
||||
Delimiter: node.Delimiter,
|
||||
}
|
||||
for _, row := range node.Rows {
|
||||
table.Rows = append(table.Rows, &apiv2pb.TableNode_Row{Cells: row})
|
||||
}
|
||||
return table
|
||||
}
|
||||
|
||||
func traverseASTNodes(nodes []ast.Node, fn func(ast.Node)) {
|
||||
for _, node := range nodes {
|
||||
fn(node)
|
||||
switch n := node.(type) {
|
||||
case *ast.Paragraph:
|
||||
traverseASTNodes(n.Children, fn)
|
||||
case *ast.Heading:
|
||||
traverseASTNodes(n.Children, fn)
|
||||
case *ast.Blockquote:
|
||||
traverseASTNodes(n.Children, fn)
|
||||
case *ast.OrderedList:
|
||||
traverseASTNodes(n.Children, fn)
|
||||
case *ast.UnorderedList:
|
||||
traverseASTNodes(n.Children, fn)
|
||||
case *ast.TaskList:
|
||||
traverseASTNodes(n.Children, fn)
|
||||
case *ast.Bold:
|
||||
traverseASTNodes(n.Children, fn)
|
||||
}
|
||||
}
|
||||
}
|
||||
30
api/v2/markdown_service_test.go
Normal file
30
api/v2/markdown_service_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/usememos/memos/plugin/gomark/ast"
|
||||
apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
|
||||
)
|
||||
|
||||
func TestConvertFromASTNodes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rawNodes []ast.Node
|
||||
want []*apiv2pb.Node
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
want: []*apiv2pb.Node{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := convertFromASTNodes(tt.rawNodes)
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
100
api/v2/memo_relation_service.go
Normal file
100
api/v2/memo_relation_service.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"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) SetMemoRelations(ctx context.Context, request *apiv2pb.SetMemoRelationsRequest) (*apiv2pb.SetMemoRelationsResponse, error) {
|
||||
referenceType := store.MemoRelationReference
|
||||
// Delete all reference relations first.
|
||||
if err := s.Store.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{
|
||||
MemoID: &request.Id,
|
||||
Type: &referenceType,
|
||||
}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to delete memo relation")
|
||||
}
|
||||
|
||||
for _, relation := range request.Relations {
|
||||
// Ignore reflexive relations.
|
||||
if request.Id == relation.RelatedMemoId {
|
||||
continue
|
||||
}
|
||||
// Ignore comment relations as there's no need to update a comment's relation.
|
||||
// Inserting/Deleting a comment is handled elsewhere.
|
||||
if relation.Type == apiv2pb.MemoRelation_COMMENT {
|
||||
continue
|
||||
}
|
||||
if _, err := s.Store.UpsertMemoRelation(ctx, &store.MemoRelation{
|
||||
MemoID: request.Id,
|
||||
RelatedMemoID: relation.RelatedMemoId,
|
||||
Type: convertMemoRelationTypeToStore(relation.Type),
|
||||
}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to upsert memo relation")
|
||||
}
|
||||
}
|
||||
|
||||
return &apiv2pb.SetMemoRelationsResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) ListMemoRelations(ctx context.Context, request *apiv2pb.ListMemoRelationsRequest) (*apiv2pb.ListMemoRelationsResponse, error) {
|
||||
relationList := []*apiv2pb.MemoRelation{}
|
||||
tempList, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{
|
||||
MemoID: &request.Id,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, relation := range tempList {
|
||||
relationList = append(relationList, convertMemoRelationFromStore(relation))
|
||||
}
|
||||
tempList, err = s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{
|
||||
RelatedMemoID: &request.Id,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, relation := range tempList {
|
||||
relationList = append(relationList, convertMemoRelationFromStore(relation))
|
||||
}
|
||||
|
||||
response := &apiv2pb.ListMemoRelationsResponse{
|
||||
Relations: relationList,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func convertMemoRelationFromStore(memoRelation *store.MemoRelation) *apiv2pb.MemoRelation {
|
||||
return &apiv2pb.MemoRelation{
|
||||
MemoId: memoRelation.MemoID,
|
||||
RelatedMemoId: memoRelation.RelatedMemoID,
|
||||
Type: convertMemoRelationTypeFromStore(memoRelation.Type),
|
||||
}
|
||||
}
|
||||
|
||||
func convertMemoRelationTypeFromStore(relationType store.MemoRelationType) apiv2pb.MemoRelation_Type {
|
||||
switch relationType {
|
||||
case store.MemoRelationReference:
|
||||
return apiv2pb.MemoRelation_REFERENCE
|
||||
case store.MemoRelationComment:
|
||||
return apiv2pb.MemoRelation_COMMENT
|
||||
default:
|
||||
return apiv2pb.MemoRelation_TYPE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func convertMemoRelationTypeToStore(relationType apiv2pb.MemoRelation_Type) store.MemoRelationType {
|
||||
switch relationType {
|
||||
case apiv2pb.MemoRelation_REFERENCE:
|
||||
return store.MemoRelationReference
|
||||
case apiv2pb.MemoRelation_COMMENT:
|
||||
return store.MemoRelationComment
|
||||
default:
|
||||
return store.MemoRelationReference
|
||||
}
|
||||
}
|
||||
73
api/v2/memo_resource_service.go
Normal file
73
api/v2/memo_resource_service.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"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) SetMemoResources(ctx context.Context, request *apiv2pb.SetMemoResourcesRequest) (*apiv2pb.SetMemoResourcesResponse, error) {
|
||||
resources, err := s.Store.ListResources(ctx, &store.FindResource{
|
||||
MemoID: &request.Id,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list resources")
|
||||
}
|
||||
|
||||
// Delete resources that are not in the request.
|
||||
for _, resource := range resources {
|
||||
found := false
|
||||
for _, requestResource := range request.Resources {
|
||||
if resource.ID == int32(requestResource.Id) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
if err = s.Store.DeleteResource(ctx, &store.DeleteResource{
|
||||
ID: int32(resource.ID),
|
||||
MemoID: &request.Id,
|
||||
}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to delete resource")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slices.Reverse(request.Resources)
|
||||
// Update resources' memo_id in the request.
|
||||
for index, resource := range request.Resources {
|
||||
updatedTs := time.Now().Unix() + int64(index)
|
||||
if _, err := s.Store.UpdateResource(ctx, &store.UpdateResource{
|
||||
ID: resource.Id,
|
||||
MemoID: &request.Id,
|
||||
UpdatedTs: &updatedTs,
|
||||
}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to update resource: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &apiv2pb.SetMemoResourcesResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) ListMemoResources(ctx context.Context, request *apiv2pb.ListMemoResourcesRequest) (*apiv2pb.ListMemoResourcesResponse, error) {
|
||||
resources, err := s.Store.ListResources(ctx, &store.FindResource{
|
||||
MemoID: &request.Id,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list resources")
|
||||
}
|
||||
|
||||
response := &apiv2pb.ListMemoResourcesResponse{
|
||||
Resources: []*apiv2pb.Resource{},
|
||||
}
|
||||
for _, resource := range resources {
|
||||
response.Resources = append(response.Resources, s.convertResourceFromStore(ctx, resource))
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
@@ -2,31 +2,39 @@ package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/cel-go/cel"
|
||||
"github.com/lithammer/shortuuid/v4"
|
||||
"github.com/pkg/errors"
|
||||
v1alpha1 "google.golang.org/genproto/googleapis/api/expr/v1alpha1"
|
||||
"go.uber.org/zap"
|
||||
expr "google.golang.org/genproto/googleapis/api/expr/v1alpha1"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
apiv1 "github.com/usememos/memos/api/v1"
|
||||
"github.com/usememos/memos/internal/log"
|
||||
"github.com/usememos/memos/internal/util"
|
||||
"github.com/usememos/memos/plugin/gomark/ast"
|
||||
"github.com/usememos/memos/plugin/gomark/parser"
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
"github.com/usememos/memos/plugin/gomark/restore"
|
||||
"github.com/usememos/memos/plugin/webhook"
|
||||
apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
"github.com/usememos/memos/server/service/metric"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
type MemoService struct {
|
||||
apiv2pb.UnimplementedMemoServiceServer
|
||||
const (
|
||||
DefaultPageSize = 10
|
||||
MaxContentLength = 8 * 1024
|
||||
)
|
||||
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
// NewMemoService creates a new MemoService.
|
||||
func NewMemoService(store *store.Store) *MemoService {
|
||||
return &MemoService{
|
||||
Store: store,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MemoService) CreateMemo(ctx context.Context, request *apiv2pb.CreateMemoRequest) (*apiv2pb.CreateMemoResponse, error) {
|
||||
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")
|
||||
@@ -34,68 +42,194 @@ func (s *MemoService) CreateMemo(ctx context.Context, request *apiv2pb.CreateMem
|
||||
if user == nil {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
if len(request.Content) > MaxContentLength {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "content too long")
|
||||
}
|
||||
|
||||
nodes, err := parser.Parse(tokenizer.Tokenize(request.Content))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to parse memo content")
|
||||
}
|
||||
|
||||
create := &store.Memo{
|
||||
CreatorID: user.ID,
|
||||
Content: request.Content,
|
||||
Visibility: store.Visibility(request.Visibility),
|
||||
ResourceName: shortuuid.New(),
|
||||
CreatorID: user.ID,
|
||||
Content: request.Content,
|
||||
Visibility: convertVisibilityToStore(request.Visibility),
|
||||
}
|
||||
// Find disable public memos system setting.
|
||||
disablePublicMemosSystem, err := s.getDisablePublicMemosSystemSettingValue(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get system setting")
|
||||
}
|
||||
if disablePublicMemosSystem && create.Visibility == store.Public {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "disable public memos system setting is enabled")
|
||||
}
|
||||
|
||||
memo, err := s.Store.CreateMemo(ctx, create)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metric.Enqueue("memo create")
|
||||
|
||||
// Dynamically upsert tags from memo content.
|
||||
traverseASTNodes(nodes, func(node ast.Node) {
|
||||
if tag, ok := node.(*ast.Tag); ok {
|
||||
if _, err := s.Store.UpsertTag(ctx, &store.Tag{
|
||||
Name: tag.Content,
|
||||
CreatorID: user.ID,
|
||||
}); err != nil {
|
||||
log.Warn("Failed to create tag", zap.Error(err))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
memoMessage, err := s.convertMemoFromStore(ctx, memo)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to convert memo")
|
||||
}
|
||||
// Try to dispatch webhook when memo is created.
|
||||
if err := s.DispatchMemoCreatedWebhook(ctx, memoMessage); err != nil {
|
||||
log.Warn("Failed to dispatch memo created webhook", zap.Error(err))
|
||||
}
|
||||
|
||||
response := &apiv2pb.CreateMemoResponse{
|
||||
Memo: convertMemoFromStore(memo),
|
||||
Memo: memoMessage,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *MemoService) ListMemos(ctx context.Context, request *apiv2pb.ListMemosRequest) (*apiv2pb.ListMemosResponse, error) {
|
||||
memoFind := &store.FindMemo{}
|
||||
func (s *APIV2Service) ListMemos(ctx context.Context, request *apiv2pb.ListMemosRequest) (*apiv2pb.ListMemosResponse, error) {
|
||||
memoFind := &store.FindMemo{
|
||||
// Exclude comments by default.
|
||||
ExcludeComments: true,
|
||||
}
|
||||
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 len(filter.ContentSearch) > 0 {
|
||||
memoFind.ContentSearch = filter.ContentSearch
|
||||
}
|
||||
if filter.CreatedTsBefore != nil {
|
||||
memoFind.CreatedTsBefore = filter.CreatedTsBefore
|
||||
if len(filter.Visibilities) > 0 {
|
||||
memoFind.VisibilityList = filter.Visibilities
|
||||
}
|
||||
if filter.CreatedTsAfter != nil {
|
||||
memoFind.CreatedTsAfter = filter.CreatedTsAfter
|
||||
if filter.OrderByPinned {
|
||||
memoFind.OrderByPinned = filter.OrderByPinned
|
||||
}
|
||||
if filter.DisplayTimeAfter != nil {
|
||||
displayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get memo display with updated ts setting value")
|
||||
}
|
||||
if displayWithUpdatedTs {
|
||||
memoFind.UpdatedTsAfter = filter.DisplayTimeAfter
|
||||
} else {
|
||||
memoFind.CreatedTsAfter = filter.DisplayTimeAfter
|
||||
}
|
||||
}
|
||||
if filter.DisplayTimeBefore != nil {
|
||||
displayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get memo display with updated ts setting value")
|
||||
}
|
||||
if displayWithUpdatedTs {
|
||||
memoFind.UpdatedTsBefore = filter.DisplayTimeBefore
|
||||
} else {
|
||||
memoFind.CreatedTsBefore = filter.DisplayTimeBefore
|
||||
}
|
||||
}
|
||||
if filter.Creator != nil {
|
||||
username, err := ExtractUsernameFromName(*filter.Creator)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid creator name")
|
||||
}
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
Username: &username,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get user")
|
||||
}
|
||||
if user == nil {
|
||||
return nil, status.Errorf(codes.NotFound, "user not found")
|
||||
}
|
||||
memoFind.CreatorID = &user.ID
|
||||
}
|
||||
if filter.RowStatus != nil {
|
||||
memoFind.RowStatus = filter.RowStatus
|
||||
}
|
||||
} else {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "filter is required")
|
||||
}
|
||||
|
||||
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.PageSize != 0 {
|
||||
offset := int(request.Page * request.PageSize)
|
||||
limit := int(request.PageSize)
|
||||
memoFind.Offset = &offset
|
||||
memoFind.Limit = &limit
|
||||
if user != nil && memoFind.CreatorID != nil && *memoFind.CreatorID != user.ID {
|
||||
memoFind.VisibilityList = []store.Visibility{store.Public, store.Protected}
|
||||
}
|
||||
|
||||
displayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get memo display with updated ts setting value")
|
||||
}
|
||||
if displayWithUpdatedTs {
|
||||
memoFind.OrderByUpdatedTs = true
|
||||
}
|
||||
|
||||
var limit, offset int
|
||||
if request.PageToken != "" {
|
||||
var pageToken apiv2pb.PageToken
|
||||
if err := unmarshalPageToken(request.PageToken, &pageToken); err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid page token: %v", err)
|
||||
}
|
||||
if pageToken.Limit < 0 {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "page size cannot be negative")
|
||||
}
|
||||
limit = int(pageToken.Limit)
|
||||
offset = int(pageToken.Offset)
|
||||
} else {
|
||||
limit = int(request.PageSize)
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = DefaultPageSize
|
||||
}
|
||||
limitPlusOne := limit + 1
|
||||
memoFind.Offset = &offset
|
||||
memoFind.Limit = &limitPlusOne
|
||||
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)
|
||||
memoMessages := []*apiv2pb.Memo{}
|
||||
nextPageToken := ""
|
||||
if len(memos) == limitPlusOne {
|
||||
memos = memos[:limit]
|
||||
nextPageToken, err = getPageToken(limit, offset+limit)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get next page token, error: %v", err)
|
||||
}
|
||||
}
|
||||
for _, memo := range memos {
|
||||
memoMessage, err := s.convertMemoFromStore(ctx, memo)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to convert memo")
|
||||
}
|
||||
memoMessages = append(memoMessages, memoMessage)
|
||||
}
|
||||
|
||||
response := &apiv2pb.ListMemosResponse{
|
||||
Memos: memoMessages,
|
||||
Memos: memoMessages,
|
||||
NextPageToken: nextPageToken,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *MemoService) GetMemo(ctx context.Context, request *apiv2pb.GetMemoRequest) (*apiv2pb.GetMemoResponse, error) {
|
||||
func (s *APIV2Service) GetMemo(ctx context.Context, request *apiv2pb.GetMemoRequest) (*apiv2pb.GetMemoResponse, error) {
|
||||
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
|
||||
ID: &request.Id,
|
||||
})
|
||||
@@ -118,13 +252,196 @@ func (s *MemoService) GetMemo(ctx context.Context, request *apiv2pb.GetMemoReque
|
||||
}
|
||||
}
|
||||
|
||||
memoMessage, err := s.convertMemoFromStore(ctx, memo)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to convert memo")
|
||||
}
|
||||
response := &apiv2pb.GetMemoResponse{
|
||||
Memo: convertMemoFromStore(memo),
|
||||
Memo: memoMessage,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *MemoService) CreateMemoComment(ctx context.Context, request *apiv2pb.CreateMemoCommentRequest) (*apiv2pb.CreateMemoCommentResponse, error) {
|
||||
func (s *APIV2Service) GetMemoByName(ctx context.Context, request *apiv2pb.GetMemoByNameRequest) (*apiv2pb.GetMemoByNameResponse, error) {
|
||||
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
|
||||
ResourceName: &request.Name,
|
||||
})
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
memoMessage, err := s.convertMemoFromStore(ctx, memo)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to convert memo")
|
||||
}
|
||||
response := &apiv2pb.GetMemoByNameResponse{
|
||||
Memo: memoMessage,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) UpdateMemo(ctx context.Context, request *apiv2pb.UpdateMemoRequest) (*apiv2pb.UpdateMemoResponse, error) {
|
||||
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "update mask is required")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
user, _ := getCurrentUser(ctx, s.Store)
|
||||
if memo.CreatorID != user.ID {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
|
||||
currentTs := time.Now().Unix()
|
||||
update := &store.UpdateMemo{
|
||||
ID: request.Id,
|
||||
UpdatedTs: ¤tTs,
|
||||
}
|
||||
for _, path := range request.UpdateMask.Paths {
|
||||
if path == "content" {
|
||||
update.Content = &request.Memo.Content
|
||||
nodes, err := parser.Parse(tokenizer.Tokenize(*update.Content))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to parse memo content")
|
||||
}
|
||||
|
||||
// Dynamically upsert tags from memo content.
|
||||
traverseASTNodes(nodes, func(node ast.Node) {
|
||||
if tag, ok := node.(*ast.Tag); ok {
|
||||
if _, err := s.Store.UpsertTag(ctx, &store.Tag{
|
||||
Name: tag.Content,
|
||||
CreatorID: user.ID,
|
||||
}); err != nil {
|
||||
log.Warn("Failed to create tag", zap.Error(err))
|
||||
}
|
||||
}
|
||||
})
|
||||
} else if path == "nodes" {
|
||||
nodes := convertToASTNodes(request.Memo.Nodes)
|
||||
content := restore.Restore(nodes)
|
||||
update.Content = &content
|
||||
} else if path == "resource_name" {
|
||||
update.ResourceName = &request.Memo.Name
|
||||
if !util.ResourceNameMatcher.MatchString(*update.ResourceName) {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid resource name")
|
||||
}
|
||||
} else if path == "visibility" {
|
||||
visibility := convertVisibilityToStore(request.Memo.Visibility)
|
||||
// Find disable public memos system setting.
|
||||
disablePublicMemosSystem, err := s.getDisablePublicMemosSystemSettingValue(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get system setting")
|
||||
}
|
||||
if disablePublicMemosSystem && visibility == store.Public {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "disable public memos system setting is enabled")
|
||||
}
|
||||
update.Visibility = &visibility
|
||||
} else if path == "row_status" {
|
||||
rowStatus := convertRowStatusToStore(request.Memo.RowStatus)
|
||||
println("rowStatus", rowStatus)
|
||||
update.RowStatus = &rowStatus
|
||||
} else if path == "created_ts" {
|
||||
createdTs := request.Memo.CreateTime.AsTime().Unix()
|
||||
update.CreatedTs = &createdTs
|
||||
} else if path == "pinned" {
|
||||
if _, err := s.Store.UpsertMemoOrganizer(ctx, &store.MemoOrganizer{
|
||||
MemoID: request.Id,
|
||||
UserID: user.ID,
|
||||
Pinned: request.Memo.Pinned,
|
||||
}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to upsert memo organizer")
|
||||
}
|
||||
}
|
||||
}
|
||||
if update.Content != nil && len(*update.Content) > MaxContentLength {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "content too long")
|
||||
}
|
||||
|
||||
if err = s.Store.UpdateMemo(ctx, update); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to update memo")
|
||||
}
|
||||
|
||||
memo, err = s.Store.GetMemo(ctx, &store.FindMemo{
|
||||
ID: &request.Id,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to get memo")
|
||||
}
|
||||
memoMessage, err := s.convertMemoFromStore(ctx, memo)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to convert memo")
|
||||
}
|
||||
// Try to dispatch webhook when memo is updated.
|
||||
if err := s.DispatchMemoUpdatedWebhook(ctx, memoMessage); err != nil {
|
||||
log.Warn("Failed to dispatch memo updated webhook", zap.Error(err))
|
||||
}
|
||||
|
||||
return &apiv2pb.UpdateMemoResponse{
|
||||
Memo: memoMessage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) DeleteMemo(ctx context.Context, request *apiv2pb.DeleteMemoRequest) (*apiv2pb.DeleteMemoResponse, 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")
|
||||
}
|
||||
|
||||
user, _ := getCurrentUser(ctx, s.Store)
|
||||
if memo.CreatorID != user.ID {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
|
||||
if memoMessage, err := s.convertMemoFromStore(ctx, memo); err == nil {
|
||||
// Try to dispatch webhook when memo is deleted.
|
||||
if err := s.DispatchMemoDeletedWebhook(ctx, memoMessage); err != nil {
|
||||
log.Warn("Failed to dispatch memo deleted webhook", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
if err = s.Store.DeleteMemo(ctx, &store.DeleteMemo{
|
||||
ID: request.Id,
|
||||
}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to delete memo")
|
||||
}
|
||||
|
||||
return &apiv2pb.DeleteMemoResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) CreateMemoComment(ctx context.Context, request *apiv2pb.CreateMemoCommentRequest) (*apiv2pb.CreateMemoCommentResponse, error) {
|
||||
relatedMemo, err := s.Store.GetMemo(ctx, &store.FindMemo{ID: &request.Id})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get memo")
|
||||
}
|
||||
|
||||
// Create the comment memo first.
|
||||
createMemoResponse, err := s.CreateMemo(ctx, request.Create)
|
||||
if err != nil {
|
||||
@@ -141,6 +458,34 @@ func (s *MemoService) CreateMemoComment(ctx context.Context, request *apiv2pb.Cr
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to create memo relation")
|
||||
}
|
||||
if memo.Visibility != apiv2pb.Visibility_PRIVATE && memo.CreatorId != relatedMemo.CreatorID {
|
||||
activity, err := s.Store.CreateActivity(ctx, &store.Activity{
|
||||
CreatorID: memo.CreatorId,
|
||||
Type: store.ActivityTypeMemoComment,
|
||||
Level: store.ActivityLevelInfo,
|
||||
Payload: &storepb.ActivityPayload{
|
||||
MemoComment: &storepb.ActivityMemoCommentPayload{
|
||||
MemoId: memo.Id,
|
||||
RelatedMemoId: request.Id,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to create activity")
|
||||
}
|
||||
if _, err := s.Store.CreateInbox(ctx, &store.Inbox{
|
||||
SenderID: memo.CreatorId,
|
||||
ReceiverID: relatedMemo.CreatorID,
|
||||
Status: store.UNREAD,
|
||||
Message: &storepb.InboxMessage{
|
||||
Type: storepb.InboxMessage_TYPE_MEMO_COMMENT,
|
||||
ActivityId: &activity.ID,
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to create inbox")
|
||||
}
|
||||
}
|
||||
metric.Enqueue("memo comment create")
|
||||
|
||||
response := &apiv2pb.CreateMemoCommentResponse{
|
||||
Memo: memo,
|
||||
@@ -148,7 +493,7 @@ func (s *MemoService) CreateMemoComment(ctx context.Context, request *apiv2pb.Cr
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *MemoService) ListMemoComments(ctx context.Context, request *apiv2pb.ListMemoCommentsRequest) (*apiv2pb.ListMemoCommentsResponse, error) {
|
||||
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,
|
||||
@@ -167,7 +512,11 @@ func (s *MemoService) ListMemoComments(ctx context.Context, request *apiv2pb.Lis
|
||||
return nil, status.Errorf(codes.Internal, "failed to get memo")
|
||||
}
|
||||
if memo != nil {
|
||||
memos = append(memos, convertMemoFromStore(memo))
|
||||
memoMessage, err := s.convertMemoFromStore(ctx, memo)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to convert memo")
|
||||
}
|
||||
memos = append(memos, memoMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,72 +526,179 @@ func (s *MemoService) ListMemoComments(ctx context.Context, request *apiv2pb.Lis
|
||||
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...)
|
||||
func (s *APIV2Service) GetUserMemosStats(ctx context.Context, request *apiv2pb.GetUserMemosStatsRequest) (*apiv2pb.GetUserMemosStatsResponse, error) {
|
||||
username, err := ExtractUsernameFromName(request.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid username")
|
||||
}
|
||||
ast, issues := e.Compile(expression)
|
||||
if issues != nil {
|
||||
return nil, errors.Errorf("found issue %v", issues)
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
Username: &username,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get user")
|
||||
}
|
||||
if user == nil {
|
||||
return nil, status.Errorf(codes.NotFound, "user not found")
|
||||
}
|
||||
filter := &ListMemosFilter{}
|
||||
callExpr := ast.Expr().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
|
||||
normalRowStatus := store.Normal
|
||||
memoFind := &store.FindMemo{
|
||||
CreatorID: &user.ID,
|
||||
RowStatus: &normalRowStatus,
|
||||
ExcludeComments: true,
|
||||
ExcludeContent: true,
|
||||
}
|
||||
displayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get memo display with updated ts setting value")
|
||||
}
|
||||
if displayWithUpdatedTs {
|
||||
memoFind.OrderByUpdatedTs = true
|
||||
}
|
||||
if request.Filter != "" {
|
||||
filter, err := parseListMemosFilter(request.Filter)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid filter: %v", err)
|
||||
}
|
||||
if len(filter.ContentSearch) > 0 {
|
||||
memoFind.ContentSearch = filter.ContentSearch
|
||||
}
|
||||
if len(filter.Visibilities) > 0 {
|
||||
memoFind.VisibilityList = filter.Visibilities
|
||||
}
|
||||
if filter.OrderByPinned {
|
||||
memoFind.OrderByPinned = filter.OrderByPinned
|
||||
}
|
||||
if filter.DisplayTimeAfter != nil {
|
||||
displayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get memo display with updated ts setting value")
|
||||
}
|
||||
if idExpr.Name == "created_ts_before" {
|
||||
createdTsBefore := callExpr.Args[1].GetConstExpr().GetInt64Value()
|
||||
filter.CreatedTsBefore = &createdTsBefore
|
||||
if displayWithUpdatedTs {
|
||||
memoFind.UpdatedTsAfter = filter.DisplayTimeAfter
|
||||
} else {
|
||||
memoFind.CreatedTsAfter = filter.DisplayTimeAfter
|
||||
}
|
||||
if idExpr.Name == "created_ts_after" {
|
||||
createdTsAfter := callExpr.Args[1].GetConstExpr().GetInt64Value()
|
||||
filter.CreatedTsAfter = &createdTsAfter
|
||||
}
|
||||
if filter.DisplayTimeBefore != nil {
|
||||
displayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get memo display with updated ts setting value")
|
||||
}
|
||||
return
|
||||
if displayWithUpdatedTs {
|
||||
memoFind.UpdatedTsBefore = filter.DisplayTimeBefore
|
||||
} else {
|
||||
memoFind.CreatedTsBefore = filter.DisplayTimeBefore
|
||||
}
|
||||
}
|
||||
if filter.RowStatus != nil {
|
||||
memoFind.RowStatus = filter.RowStatus
|
||||
}
|
||||
}
|
||||
for _, arg := range callExpr.Args {
|
||||
callExpr := arg.GetCallExpr()
|
||||
if callExpr != nil {
|
||||
findField(callExpr, filter)
|
||||
}
|
||||
|
||||
memos, err := s.Store.ListMemos(ctx, memoFind)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list memos")
|
||||
}
|
||||
|
||||
location, err := time.LoadLocation(request.Timezone)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "invalid timezone location")
|
||||
}
|
||||
|
||||
stats := make(map[string]int32)
|
||||
for _, memo := range memos {
|
||||
displayTs := memo.CreatedTs
|
||||
if displayWithUpdatedTs {
|
||||
displayTs = memo.UpdatedTs
|
||||
}
|
||||
stats[time.Unix(displayTs, 0).In(location).Format("2006-01-02")]++
|
||||
}
|
||||
|
||||
response := &apiv2pb.GetUserMemosStatsResponse{
|
||||
Stats: stats,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func convertMemoFromStore(memo *store.Memo) *apiv2pb.Memo {
|
||||
func (s *APIV2Service) convertMemoFromStore(ctx context.Context, memo *store.Memo) (*apiv2pb.Memo, error) {
|
||||
rawNodes, err := parser.Parse(tokenizer.Tokenize(memo.Content))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to parse memo content")
|
||||
}
|
||||
displayTs := memo.CreatedTs
|
||||
if displayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx); err == nil && displayWithUpdatedTs {
|
||||
displayTs = memo.UpdatedTs
|
||||
}
|
||||
|
||||
creator, err := s.Store.GetUser(ctx, &store.FindUser{ID: &memo.CreatorID})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to get creator")
|
||||
}
|
||||
|
||||
listMemoRelationsResponse, err := s.ListMemoRelations(ctx, &apiv2pb.ListMemoRelationsRequest{Id: memo.ID})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to list memo relations")
|
||||
}
|
||||
|
||||
listMemoResourcesResponse, err := s.ListMemoResources(ctx, &apiv2pb.ListMemoResourcesRequest{Id: memo.ID})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to list memo resources")
|
||||
}
|
||||
|
||||
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,
|
||||
Id: int32(memo.ID),
|
||||
Name: memo.ResourceName,
|
||||
RowStatus: convertRowStatusFromStore(memo.RowStatus),
|
||||
Creator: fmt.Sprintf("%s%s", UserNamePrefix, creator.Username),
|
||||
CreatorId: int32(memo.CreatorID),
|
||||
CreateTime: timestamppb.New(time.Unix(memo.CreatedTs, 0)),
|
||||
UpdateTime: timestamppb.New(time.Unix(memo.UpdatedTs, 0)),
|
||||
DisplayTime: timestamppb.New(time.Unix(displayTs, 0)),
|
||||
Content: memo.Content,
|
||||
Nodes: convertFromASTNodes(rawNodes),
|
||||
Visibility: convertVisibilityFromStore(memo.Visibility),
|
||||
Pinned: memo.Pinned,
|
||||
ParentId: memo.ParentID,
|
||||
Relations: listMemoRelationsResponse.Relations,
|
||||
Resources: listMemoResourcesResponse.Resources,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) getMemoDisplayWithUpdatedTsSettingValue(ctx context.Context) (bool, error) {
|
||||
memoDisplayWithUpdatedTsSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
|
||||
Name: apiv1.SystemSettingMemoDisplayWithUpdatedTsName.String(),
|
||||
})
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "failed to find system setting")
|
||||
}
|
||||
if memoDisplayWithUpdatedTsSetting == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
memoDisplayWithUpdatedTs := false
|
||||
if err := json.Unmarshal([]byte(memoDisplayWithUpdatedTsSetting.Value), &memoDisplayWithUpdatedTs); err != nil {
|
||||
return false, errors.Wrap(err, "failed to unmarshal system setting value")
|
||||
}
|
||||
return memoDisplayWithUpdatedTs, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) getDisablePublicMemosSystemSettingValue(ctx context.Context) (bool, error) {
|
||||
disablePublicMemosSystemSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
|
||||
Name: apiv1.SystemSettingDisablePublicMemosName.String(),
|
||||
})
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "failed to find system setting")
|
||||
}
|
||||
if disablePublicMemosSystemSetting == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
disablePublicMemos := false
|
||||
if err := json.Unmarshal([]byte(disablePublicMemosSystemSetting.Value), &disablePublicMemos); err != nil {
|
||||
return false, errors.Wrap(err, "failed to unmarshal system setting value")
|
||||
}
|
||||
return disablePublicMemos, nil
|
||||
}
|
||||
|
||||
func convertVisibilityFromStore(visibility store.Visibility) apiv2pb.Visibility {
|
||||
@@ -257,3 +713,176 @@ func convertVisibilityFromStore(visibility store.Visibility) apiv2pb.Visibility
|
||||
return apiv2pb.Visibility_VISIBILITY_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func convertVisibilityToStore(visibility apiv2pb.Visibility) store.Visibility {
|
||||
switch visibility {
|
||||
case apiv2pb.Visibility_PRIVATE:
|
||||
return store.Private
|
||||
case apiv2pb.Visibility_PROTECTED:
|
||||
return store.Protected
|
||||
case apiv2pb.Visibility_PUBLIC:
|
||||
return store.Public
|
||||
default:
|
||||
return store.Private
|
||||
}
|
||||
}
|
||||
|
||||
// ListMemosFilterCELAttributes are the CEL attributes for ListMemosFilter.
|
||||
var ListMemosFilterCELAttributes = []cel.EnvOption{
|
||||
cel.Variable("content_search", cel.ListType(cel.StringType)),
|
||||
cel.Variable("visibilities", cel.ListType(cel.StringType)),
|
||||
cel.Variable("order_by_pinned", cel.BoolType),
|
||||
cel.Variable("display_time_before", cel.IntType),
|
||||
cel.Variable("display_time_after", cel.IntType),
|
||||
cel.Variable("creator", cel.StringType),
|
||||
cel.Variable("row_status", cel.StringType),
|
||||
}
|
||||
|
||||
type ListMemosFilter struct {
|
||||
ContentSearch []string
|
||||
Visibilities []store.Visibility
|
||||
OrderByPinned bool
|
||||
DisplayTimeBefore *int64
|
||||
DisplayTimeAfter *int64
|
||||
Creator *string
|
||||
RowStatus *store.RowStatus
|
||||
}
|
||||
|
||||
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 *expr.Expr_Call, filter *ListMemosFilter) {
|
||||
if len(callExpr.Args) == 2 {
|
||||
idExpr := callExpr.Args[0].GetIdentExpr()
|
||||
if idExpr != nil {
|
||||
if idExpr.Name == "content_search" {
|
||||
contentSearch := []string{}
|
||||
for _, expr := range callExpr.Args[1].GetListExpr().GetElements() {
|
||||
value := expr.GetConstExpr().GetStringValue()
|
||||
contentSearch = append(contentSearch, value)
|
||||
}
|
||||
filter.ContentSearch = contentSearch
|
||||
} else if idExpr.Name == "visibilities" {
|
||||
visibilities := []store.Visibility{}
|
||||
for _, expr := range callExpr.Args[1].GetListExpr().GetElements() {
|
||||
value := expr.GetConstExpr().GetStringValue()
|
||||
visibilities = append(visibilities, store.Visibility(value))
|
||||
}
|
||||
filter.Visibilities = visibilities
|
||||
} else if idExpr.Name == "order_by_pinned" {
|
||||
value := callExpr.Args[1].GetConstExpr().GetBoolValue()
|
||||
filter.OrderByPinned = value
|
||||
} else if idExpr.Name == "display_time_before" {
|
||||
displayTimeBefore := callExpr.Args[1].GetConstExpr().GetInt64Value()
|
||||
filter.DisplayTimeBefore = &displayTimeBefore
|
||||
} else if idExpr.Name == "display_time_after" {
|
||||
displayTimeAfter := callExpr.Args[1].GetConstExpr().GetInt64Value()
|
||||
filter.DisplayTimeAfter = &displayTimeAfter
|
||||
} else if idExpr.Name == "creator" {
|
||||
creator := callExpr.Args[1].GetConstExpr().GetStringValue()
|
||||
filter.Creator = &creator
|
||||
} else if idExpr.Name == "row_status" {
|
||||
rowStatus := store.RowStatus(callExpr.Args[1].GetConstExpr().GetStringValue())
|
||||
filter.RowStatus = &rowStatus
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, arg := range callExpr.Args {
|
||||
callExpr := arg.GetCallExpr()
|
||||
if callExpr != nil {
|
||||
findField(callExpr, filter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DispatchMemoCreatedWebhook dispatches webhook when memo is created.
|
||||
func (s *APIV2Service) DispatchMemoCreatedWebhook(ctx context.Context, memo *apiv2pb.Memo) error {
|
||||
return s.dispatchMemoRelatedWebhook(ctx, memo, "memos.memo.created")
|
||||
}
|
||||
|
||||
// DispatchMemoUpdatedWebhook dispatches webhook when memo is updated.
|
||||
func (s *APIV2Service) DispatchMemoUpdatedWebhook(ctx context.Context, memo *apiv2pb.Memo) error {
|
||||
return s.dispatchMemoRelatedWebhook(ctx, memo, "memos.memo.updated")
|
||||
}
|
||||
|
||||
// DispatchMemoDeletedWebhook dispatches webhook when memo is deleted.
|
||||
func (s *APIV2Service) DispatchMemoDeletedWebhook(ctx context.Context, memo *apiv2pb.Memo) error {
|
||||
return s.dispatchMemoRelatedWebhook(ctx, memo, "memos.memo.deleted")
|
||||
}
|
||||
|
||||
func (s *APIV2Service) dispatchMemoRelatedWebhook(ctx context.Context, memo *apiv2pb.Memo, activityType string) error {
|
||||
webhooks, err := s.Store.ListWebhooks(ctx, &store.FindWebhook{
|
||||
CreatorID: &memo.CreatorId,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
metric.Enqueue("webhook dispatch")
|
||||
for _, hook := range webhooks {
|
||||
payload := convertMemoToWebhookPayload(memo)
|
||||
payload.ActivityType = activityType
|
||||
payload.URL = hook.Url
|
||||
err := webhook.Post(*payload)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to post webhook")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func convertMemoToWebhookPayload(memo *apiv2pb.Memo) *webhook.WebhookPayload {
|
||||
return &webhook.WebhookPayload{
|
||||
CreatorID: memo.CreatorId,
|
||||
CreatedTs: time.Now().Unix(),
|
||||
Memo: &webhook.Memo{
|
||||
ID: memo.Id,
|
||||
CreatorID: memo.CreatorId,
|
||||
CreatedTs: memo.CreateTime.Seconds,
|
||||
UpdatedTs: memo.UpdateTime.Seconds,
|
||||
Content: memo.Content,
|
||||
Visibility: memo.Visibility.String(),
|
||||
Pinned: memo.Pinned,
|
||||
ResourceList: func() []*webhook.Resource {
|
||||
resources := []*webhook.Resource{}
|
||||
for _, resource := range memo.Resources {
|
||||
resources = append(resources, &webhook.Resource{
|
||||
ID: resource.Id,
|
||||
Filename: resource.Filename,
|
||||
ExternalLink: resource.ExternalLink,
|
||||
Type: resource.Type,
|
||||
Size: resource.Size,
|
||||
})
|
||||
}
|
||||
return resources
|
||||
}(),
|
||||
RelationList: func() []*webhook.MemoRelation {
|
||||
relations := []*webhook.MemoRelation{}
|
||||
for _, relation := range memo.Relations {
|
||||
relations = append(relations, &webhook.MemoRelation{
|
||||
MemoID: relation.MemoId,
|
||||
RelatedMemoID: relation.RelatedMemoId,
|
||||
Type: relation.Type.String(),
|
||||
})
|
||||
}
|
||||
return relations
|
||||
}(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -2,30 +2,57 @@ package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/lithammer/shortuuid/v4"
|
||||
"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/server/service/metric"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
type ResourceService struct {
|
||||
apiv2pb.UnimplementedResourceServiceServer
|
||||
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
// NewResourceService creates a new ResourceService.
|
||||
func NewResourceService(store *store.Store) *ResourceService {
|
||||
return &ResourceService{
|
||||
Store: store,
|
||||
func (s *APIV2Service) CreateResource(ctx context.Context, request *apiv2pb.CreateResourceRequest) (*apiv2pb.CreateResourceResponse, 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.ExternalLink != "" {
|
||||
// Only allow those external links scheme with http/https
|
||||
linkURL, err := url.Parse(request.ExternalLink)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid external link: %v", err)
|
||||
}
|
||||
if linkURL.Scheme != "http" && linkURL.Scheme != "https" {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid external link scheme: %v", linkURL.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
create := &store.Resource{
|
||||
ResourceName: shortuuid.New(),
|
||||
CreatorID: user.ID,
|
||||
Filename: request.Filename,
|
||||
ExternalLink: request.ExternalLink,
|
||||
Type: request.Type,
|
||||
}
|
||||
if request.MemoId != nil {
|
||||
create.MemoID = request.MemoId
|
||||
}
|
||||
resource, err := s.Store.CreateResource(ctx, create)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to create resource: %v", err)
|
||||
}
|
||||
|
||||
metric.Enqueue("resource create")
|
||||
return &apiv2pb.CreateResourceResponse{
|
||||
Resource: s.convertResourceFromStore(ctx, resource),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ResourceService) ListResources(ctx context.Context, _ *apiv2pb.ListResourcesRequest) (*apiv2pb.ListResourcesResponse, error) {
|
||||
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)
|
||||
@@ -44,13 +71,49 @@ func (s *ResourceService) ListResources(ctx context.Context, _ *apiv2pb.ListReso
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *ResourceService) UpdateResource(ctx context.Context, request *apiv2pb.UpdateResourceRequest) (*apiv2pb.UpdateResourceResponse, error) {
|
||||
func (s *APIV2Service) GetResource(ctx context.Context, request *apiv2pb.GetResourceRequest) (*apiv2pb.GetResourceResponse, error) {
|
||||
resource, err := s.Store.GetResource(ctx, &store.FindResource{
|
||||
ID: &request.Id,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get resource: %v", err)
|
||||
}
|
||||
if resource == nil {
|
||||
return nil, status.Errorf(codes.NotFound, "resource not found")
|
||||
}
|
||||
|
||||
return &apiv2pb.GetResourceResponse{
|
||||
Resource: s.convertResourceFromStore(ctx, resource),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) GetResourceByName(ctx context.Context, request *apiv2pb.GetResourceByNameRequest) (*apiv2pb.GetResourceByNameResponse, error) {
|
||||
resource, err := s.Store.GetResource(ctx, &store.FindResource{
|
||||
ResourceName: &request.Name,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get resource: %v", err)
|
||||
}
|
||||
if resource == nil {
|
||||
return nil, status.Errorf(codes.NotFound, "resource not found")
|
||||
}
|
||||
|
||||
return &apiv2pb.GetResourceByNameResponse{
|
||||
Resource: s.convertResourceFromStore(ctx, resource),
|
||||
}, 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.Id,
|
||||
ID: request.Resource.Id,
|
||||
UpdatedTs: ¤tTs,
|
||||
}
|
||||
for _, field := range request.UpdateMask {
|
||||
for _, field := range request.UpdateMask.Paths {
|
||||
if field == "filename" {
|
||||
update.Filename = &request.Resource.Filename
|
||||
} else if field == "memo_id" {
|
||||
@@ -67,7 +130,7 @@ func (s *ResourceService) UpdateResource(ctx context.Context, request *apiv2pb.U
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ResourceService) DeleteResource(ctx context.Context, request *apiv2pb.DeleteResourceRequest) (*apiv2pb.DeleteResourceResponse, error) {
|
||||
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)
|
||||
@@ -82,6 +145,7 @@ func (s *ResourceService) DeleteResource(ctx context.Context, request *apiv2pb.D
|
||||
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 {
|
||||
@@ -90,7 +154,7 @@ func (s *ResourceService) DeleteResource(ctx context.Context, request *apiv2pb.D
|
||||
return &apiv2pb.DeleteResourceResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *ResourceService) convertResourceFromStore(ctx context.Context, resource *store.Resource) *apiv2pb.Resource {
|
||||
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{
|
||||
@@ -103,7 +167,8 @@ func (s *ResourceService) convertResourceFromStore(ctx context.Context, resource
|
||||
|
||||
return &apiv2pb.Resource{
|
||||
Id: resource.ID,
|
||||
CreatedTs: timestamppb.New(time.Unix(resource.CreatedTs, 0)),
|
||||
Name: resource.ResourceName,
|
||||
CreateTime: timestamppb.New(time.Unix(resource.CreatedTs, 0)),
|
||||
Filename: resource.Filename,
|
||||
ExternalLink: resource.ExternalLink,
|
||||
Type: resource.Type,
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
|
||||
"github.com/usememos/memos/server/profile"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
type SystemService struct {
|
||||
apiv2pb.UnimplementedSystemServiceServer
|
||||
|
||||
Profile *profile.Profile
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
// NewSystemService creates a new SystemService.
|
||||
func NewSystemService(profile *profile.Profile, store *store.Store) *SystemService {
|
||||
return &SystemService{
|
||||
Profile: profile,
|
||||
Store: store,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SystemService) 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 {
|
||||
fi, err := os.Stat(s.Profile.DSN)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get file info: %v", err)
|
||||
}
|
||||
defaultSystemInfo.DbSize = fi.Size()
|
||||
}
|
||||
|
||||
response := &apiv2pb.GetSystemInfoResponse{
|
||||
SystemInfo: defaultSystemInfo,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *SystemService) 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) == 0 {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "update mask is required")
|
||||
}
|
||||
|
||||
// Update system settings.
|
||||
for _, path := range request.UpdateMask {
|
||||
if path == "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 path == "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 path == "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 path == "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
|
||||
}
|
||||
@@ -2,28 +2,24 @@ 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"
|
||||
|
||||
"github.com/usememos/memos/plugin/gomark/ast"
|
||||
"github.com/usememos/memos/plugin/gomark/parser"
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
"github.com/usememos/memos/plugin/gomark/restore"
|
||||
apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
type TagService struct {
|
||||
apiv2pb.UnimplementedTagServiceServer
|
||||
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
// NewTagService creates a new TagService.
|
||||
func NewTagService(store *store.Store) *TagService {
|
||||
return &TagService{
|
||||
Store: store,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TagService) UpsertTag(ctx context.Context, request *apiv2pb.UpsertTagRequest) (*apiv2pb.UpsertTagResponse, error) {
|
||||
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")
|
||||
@@ -37,14 +33,31 @@ func (s *TagService) UpsertTag(ctx context.Context, request *apiv2pb.UpsertTagRe
|
||||
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: convertTagFromStore(tag),
|
||||
Tag: t,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *TagService) ListTags(ctx context.Context, request *apiv2pb.ListTagsRequest) (*apiv2pb.ListTagsResponse, error) {
|
||||
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: request.CreatorId,
|
||||
CreatorID: user.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list tags: %v", err)
|
||||
@@ -52,26 +65,186 @@ func (s *TagService) ListTags(ctx context.Context, request *apiv2pb.ListTagsRequ
|
||||
|
||||
response := &apiv2pb.ListTagsResponse{}
|
||||
for _, tag := range tags {
|
||||
response.Tags = append(response.Tags, convertTagFromStore(tag))
|
||||
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 *TagService) DeleteTag(ctx context.Context, request *apiv2pb.DeleteTagRequest) (*apiv2pb.DeleteTagResponse, error) {
|
||||
err := s.Store.DeleteTag(ctx, &store.DeleteTag{
|
||||
Name: request.Tag.Name,
|
||||
CreatorID: request.Tag.CreatorId,
|
||||
func (s *APIV2Service) RenameTag(ctx context.Context, request *apiv2pb.RenameTagRequest) (*apiv2pb.RenameTagResponse, 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")
|
||||
}
|
||||
|
||||
// Find all related memos.
|
||||
memos, err := s.Store.ListMemos(ctx, &store.FindMemo{
|
||||
CreatorID: &user.ID,
|
||||
ContentSearch: []string{fmt.Sprintf("#%s", request.OldName)},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list memos: %v", err)
|
||||
}
|
||||
// Replace tag name in memo content.
|
||||
for _, memo := range memos {
|
||||
nodes, err := parser.Parse(tokenizer.Tokenize(memo.Content))
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to parse memo: %v", err)
|
||||
}
|
||||
traverseASTNodes(nodes, func(node ast.Node) {
|
||||
if tag, ok := node.(*ast.Tag); ok && tag.Content == request.OldName {
|
||||
tag.Content = request.NewName
|
||||
}
|
||||
})
|
||||
content := restore.Restore(nodes)
|
||||
if err := s.Store.UpdateMemo(ctx, &store.UpdateMemo{
|
||||
ID: memo.ID,
|
||||
Content: &content,
|
||||
}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to update memo: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete old tag and create new tag.
|
||||
if err := s.Store.DeleteTag(ctx, &store.DeleteTag{
|
||||
CreatorID: user.ID,
|
||||
Name: request.OldName,
|
||||
}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to delete tag: %v", err)
|
||||
}
|
||||
tag, err := s.Store.UpsertTag(ctx, &store.Tag{
|
||||
CreatorID: user.ID,
|
||||
Name: request.NewName,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to upsert tag: %v", err)
|
||||
}
|
||||
|
||||
tagMessage, err := s.convertTagFromStore(ctx, tag)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to convert tag: %v", err)
|
||||
}
|
||||
return &apiv2pb.RenameTagResponse{Tag: tagMessage}, 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 convertTagFromStore(tag *store.Tag) *apiv2pb.Tag {
|
||||
return &apiv2pb.Tag{
|
||||
Name: tag.Name,
|
||||
CreatorId: int32(tag.CreatorID),
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -17,33 +17,42 @@ import (
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/usememos/memos/api/auth"
|
||||
"github.com/usememos/memos/internal/util"
|
||||
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-z]([a-z0-9-]{1,30}[a-z0-9])?$")
|
||||
)
|
||||
|
||||
type UserService struct {
|
||||
apiv2pb.UnimplementedUserServiceServer
|
||||
|
||||
Store *store.Store
|
||||
Secret string
|
||||
}
|
||||
|
||||
// NewUserService creates a new UserService.
|
||||
func NewUserService(store *store.Store, secret string) *UserService {
|
||||
return &UserService{
|
||||
Store: store,
|
||||
Secret: secret,
|
||||
func (s *APIV2Service) ListUsers(ctx context.Context, _ *apiv2pb.ListUsersRequest) (*apiv2pb.ListUsersResponse, 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 && currentUser.Role != store.RoleAdmin {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
|
||||
users, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list users: %v", err)
|
||||
}
|
||||
|
||||
response := &apiv2pb.ListUsersResponse{
|
||||
Users: []*apiv2pb.User{},
|
||||
}
|
||||
for _, user := range users {
|
||||
response.Users = append(response.Users, convertUserFromStore(user))
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *UserService) GetUser(ctx context.Context, request *apiv2pb.GetUserRequest) (*apiv2pb.GetUserResponse, error) {
|
||||
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: &request.Username,
|
||||
Username: &username,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
|
||||
@@ -59,26 +68,76 @@ func (s *UserService) GetUser(ctx context.Context, request *apiv2pb.GetUserReque
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *UserService) UpdateUser(ctx context.Context, request *apiv2pb.UpdateUserRequest) (*apiv2pb.UpdateUserResponse, error) {
|
||||
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.Username != request.Username && currentUser.Role != store.RoleAdmin {
|
||||
if currentUser.Role != store.RoleHost {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
if request.UpdateMask == nil || len(request.UpdateMask) == 0 {
|
||||
|
||||
username, err := ExtractUsernameFromName(request.User.Name)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "name is required")
|
||||
}
|
||||
if !util.ResourceNameMatcher.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: currentUser.ID,
|
||||
ID: user.ID,
|
||||
UpdatedTs: ¤tTs,
|
||||
}
|
||||
for _, field := range request.UpdateMask {
|
||||
for _, field := range request.UpdateMask.Paths {
|
||||
if field == "username" {
|
||||
if !usernameMatcher.MatchString(strings.ToLower(request.User.Username)) {
|
||||
if !util.ResourceNameMatcher.MatchString(strings.ToLower(request.User.Username)) {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid username: %s", request.User.Username)
|
||||
}
|
||||
update.Username = &request.User.Username
|
||||
@@ -106,27 +165,179 @@ func (s *UserService) UpdateUser(ctx context.Context, request *apiv2pb.UpdateUse
|
||||
}
|
||||
}
|
||||
|
||||
user, err := s.Store.UpdateUser(ctx, update)
|
||||
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(user),
|
||||
User: convertUserFromStore(updatedUser),
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *UserService) ListUserAccessTokens(ctx context.Context, request *apiv2pb.ListUserAccessTokensRequest) (*apiv2pb.ListUserAccessTokensResponse, error) {
|
||||
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)
|
||||
}
|
||||
if user == nil || user.Username != request.Username {
|
||||
|
||||
userSettings, err := s.Store.ListUserSettings(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.UpsertUserSetting(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.UpsertUserSetting(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.UpsertUserSetting(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.UpsertUserSetting(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")
|
||||
}
|
||||
|
||||
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID)
|
||||
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)
|
||||
}
|
||||
@@ -162,8 +373,8 @@ func (s *UserService) ListUserAccessTokens(ctx context.Context, request *apiv2pb
|
||||
}
|
||||
|
||||
// Sort by issued time in descending order.
|
||||
slices.SortFunc(accessTokens, func(i, j *apiv2pb.UserAccessToken) bool {
|
||||
return i.IssuedAt.Seconds > j.IssuedAt.Seconds
|
||||
slices.SortFunc(accessTokens, func(i, j *apiv2pb.UserAccessToken) int {
|
||||
return int(i.IssuedAt.Seconds - j.IssuedAt.Seconds)
|
||||
})
|
||||
response := &apiv2pb.ListUserAccessTokensResponse{
|
||||
AccessTokens: accessTokens,
|
||||
@@ -171,7 +382,7 @@ func (s *UserService) ListUserAccessTokens(ctx context.Context, request *apiv2pb
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *UserService) CreateUserAccessToken(ctx context.Context, request *apiv2pb.CreateUserAccessTokenRequest) (*apiv2pb.CreateUserAccessTokenResponse, error) {
|
||||
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)
|
||||
@@ -181,6 +392,7 @@ func (s *UserService) CreateUserAccessToken(ctx context.Context, request *apiv2p
|
||||
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)
|
||||
@@ -221,7 +433,7 @@ func (s *UserService) CreateUserAccessToken(ctx context.Context, request *apiv2p
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *UserService) DeleteUserAccessToken(ctx context.Context, request *apiv2pb.DeleteUserAccessTokenRequest) (*apiv2pb.DeleteUserAccessTokenResponse, error) {
|
||||
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)
|
||||
@@ -238,7 +450,7 @@ func (s *UserService) DeleteUserAccessToken(ctx context.Context, request *apiv2p
|
||||
}
|
||||
updatedUserAccessTokens = append(updatedUserAccessTokens, userAccessToken)
|
||||
}
|
||||
if _, err := s.Store.UpsertUserSettingV1(ctx, &storepb.UserSetting{
|
||||
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
||||
UserId: user.ID,
|
||||
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
||||
Value: &storepb.UserSetting_AccessTokens{
|
||||
@@ -253,7 +465,7 @@ func (s *UserService) DeleteUserAccessToken(ctx context.Context, request *apiv2p
|
||||
return &apiv2pb.DeleteUserAccessTokenResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *UserService) UpsertAccessTokenToStore(ctx context.Context, user *store.User, accessToken, description string) error {
|
||||
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")
|
||||
@@ -263,7 +475,7 @@ func (s *UserService) UpsertAccessTokenToStore(ctx context.Context, user *store.
|
||||
Description: description,
|
||||
}
|
||||
userAccessTokens = append(userAccessTokens, &userAccessToken)
|
||||
if _, err := s.Store.UpsertUserSettingV1(ctx, &storepb.UserSetting{
|
||||
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
||||
UserId: user.ID,
|
||||
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
||||
Value: &storepb.UserSetting_AccessTokens{
|
||||
@@ -279,12 +491,13 @@ func (s *UserService) UpsertAccessTokenToStore(ctx context.Context, user *store.
|
||||
|
||||
func convertUserFromStore(user *store.User) *apiv2pb.User {
|
||||
return &apiv2pb.User{
|
||||
Id: int32(user.ID),
|
||||
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)),
|
||||
Username: user.Username,
|
||||
Role: convertUserRoleFromStore(user.Role),
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Nickname: user.Nickname,
|
||||
AvatarUrl: user.AvatarURL,
|
||||
|
||||
70
api/v2/v2.go
70
api/v2/v2.go
@@ -3,20 +3,35 @@ package v2
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
grpcRuntime "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||
"github.com/improbable-eng/grpc-web/go/grpcweb"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/reflection"
|
||||
|
||||
"github.com/usememos/memos/internal/log"
|
||||
apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
|
||||
"github.com/usememos/memos/server/profile"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
type APIV2Service struct {
|
||||
apiv2pb.UnimplementedWorkspaceServiceServer
|
||||
apiv2pb.UnimplementedAuthServiceServer
|
||||
apiv2pb.UnimplementedUserServiceServer
|
||||
apiv2pb.UnimplementedMemoServiceServer
|
||||
apiv2pb.UnimplementedResourceServiceServer
|
||||
apiv2pb.UnimplementedTagServiceServer
|
||||
apiv2pb.UnimplementedInboxServiceServer
|
||||
apiv2pb.UnimplementedActivityServiceServer
|
||||
apiv2pb.UnimplementedWebhookServiceServer
|
||||
apiv2pb.UnimplementedMarkdownServiceServer
|
||||
|
||||
Secret string
|
||||
Profile *profile.Profile
|
||||
Store *store.Store
|
||||
@@ -33,20 +48,27 @@ func NewAPIV2Service(secret string, profile *profile.Profile, store *store.Store
|
||||
authProvider.AuthenticationInterceptor,
|
||||
),
|
||||
)
|
||||
apiv2pb.RegisterSystemServiceServer(grpcServer, NewSystemService(profile, store))
|
||||
apiv2pb.RegisterUserServiceServer(grpcServer, NewUserService(store, secret))
|
||||
apiv2pb.RegisterMemoServiceServer(grpcServer, NewMemoService(store))
|
||||
apiv2pb.RegisterTagServiceServer(grpcServer, NewTagService(store))
|
||||
apiv2pb.RegisterResourceServiceServer(grpcServer, NewResourceService(store))
|
||||
reflection.Register(grpcServer)
|
||||
|
||||
return &APIV2Service{
|
||||
apiv2Service := &APIV2Service{
|
||||
Secret: secret,
|
||||
Profile: profile,
|
||||
Store: store,
|
||||
grpcServer: grpcServer,
|
||||
grpcServerPort: grpcServerPort,
|
||||
}
|
||||
|
||||
apiv2pb.RegisterWorkspaceServiceServer(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)
|
||||
apiv2pb.RegisterMarkdownServiceServer(grpcServer, apiv2Service)
|
||||
reflection.Register(grpcServer)
|
||||
|
||||
return apiv2Service
|
||||
}
|
||||
|
||||
func (s *APIV2Service) GetGRPCServer() *grpc.Server {
|
||||
@@ -66,8 +88,11 @@ func (s *APIV2Service) RegisterGateway(ctx context.Context, e *echo.Echo) error
|
||||
return err
|
||||
}
|
||||
|
||||
gwMux := grpcRuntime.NewServeMux()
|
||||
if err := apiv2pb.RegisterSystemServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||
gwMux := runtime.NewServeMux()
|
||||
if err := apiv2pb.RegisterWorkspaceServiceHandler(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 {
|
||||
@@ -82,6 +107,18 @@ func (s *APIV2Service) RegisterGateway(ctx context.Context, e *echo.Echo) error
|
||||
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
|
||||
}
|
||||
if err := apiv2pb.RegisterMarkdownServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
e.Any("/api/v2/*", echo.WrapHandler(gwMux))
|
||||
|
||||
// GRPC web proxy.
|
||||
@@ -94,5 +131,16 @@ func (s *APIV2Service) RegisterGateway(ctx context.Context, e *echo.Echo) error
|
||||
wrappedGrpc := grpcweb.WrapServer(s.grpcServer, options...)
|
||||
e.Any("/memos.api.v2.*", echo.WrapHandler(wrappedGrpc))
|
||||
|
||||
// Start gRPC server.
|
||||
listen, err := net.Listen("tcp", fmt.Sprintf("%s:%d", s.Profile.Addr, s.grpcServerPort))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to start gRPC server")
|
||||
}
|
||||
go func() {
|
||||
if err := s.grpcServer.Serve(listen); err != nil {
|
||||
log.Error("grpc server listen error", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
2110
api/v2/v2.swagger.yaml
Normal file
2110
api/v2/v2.swagger.yaml
Normal file
File diff suppressed because it is too large
Load Diff
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,
|
||||
}
|
||||
}
|
||||
81
api/v2/workspace_service.go
Normal file
81
api/v2/workspace_service.go
Normal file
@@ -0,0 +1,81 @@
|
||||
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) GetWorkspaceProfile(_ context.Context, _ *apiv2pb.GetWorkspaceProfileRequest) (*apiv2pb.GetWorkspaceProfileResponse, error) {
|
||||
workspaceProfile := &apiv2pb.WorkspaceProfile{
|
||||
Version: s.Profile.Version,
|
||||
Mode: s.Profile.Mode,
|
||||
}
|
||||
response := &apiv2pb.GetWorkspaceProfileResponse{
|
||||
WorkspaceProfile: workspaceProfile,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) UpdateWorkspaceProfile(ctx context.Context, request *apiv2pb.UpdateWorkspaceProfileRequest) (*apiv2pb.UpdateWorkspaceProfileResponse, 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.WorkspaceProfile.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.WorkspaceProfile.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.WorkspaceProfile.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.WorkspaceProfile.AdditionalStyle,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to update additional_style system setting: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
workspaceProfileMessage, err := s.GetWorkspaceProfile(ctx, &apiv2pb.GetWorkspaceProfileRequest{})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get system info: %v", err)
|
||||
}
|
||||
return &apiv2pb.UpdateWorkspaceProfileResponse{
|
||||
WorkspaceProfile: workspaceProfileMessage.WorkspaceProfile,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package cmd
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -12,9 +12,10 @@ import (
|
||||
"github.com/spf13/viper"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/usememos/memos/common/log"
|
||||
"github.com/usememos/memos/internal/log"
|
||||
"github.com/usememos/memos/server"
|
||||
_profile "github.com/usememos/memos/server/profile"
|
||||
"github.com/usememos/memos/server/service/metric"
|
||||
"github.com/usememos/memos/store"
|
||||
"github.com/usememos/memos/store/db"
|
||||
)
|
||||
@@ -31,13 +32,14 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
profile *_profile.Profile
|
||||
mode string
|
||||
addr string
|
||||
port int
|
||||
data string
|
||||
driver string
|
||||
dsn string
|
||||
profile *_profile.Profile
|
||||
mode string
|
||||
addr string
|
||||
port int
|
||||
data string
|
||||
driver string
|
||||
dsn string
|
||||
enableMetric bool
|
||||
|
||||
rootCmd = &cobra.Command{
|
||||
Use: "memos",
|
||||
@@ -56,14 +58,25 @@ var (
|
||||
return
|
||||
}
|
||||
|
||||
store := store.New(dbDriver, profile)
|
||||
s, err := server.NewServer(ctx, profile, store)
|
||||
storeInstance := store.New(dbDriver, profile)
|
||||
if err := storeInstance.MigrateManually(ctx); err != nil {
|
||||
cancel()
|
||||
log.Error("failed to migrate manually", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
s, err := server.NewServer(ctx, profile, storeInstance)
|
||||
if err != nil {
|
||||
cancel()
|
||||
log.Error("failed to create server", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
if profile.Metric {
|
||||
// nolint
|
||||
metric.NewMetricClient(s.ID, *profile)
|
||||
}
|
||||
|
||||
c := make(chan os.Signal, 1)
|
||||
// Trigger graceful shutdown on SIGINT or SIGTERM.
|
||||
// The default signal sent by the `kill` command is SIGTERM,
|
||||
@@ -105,6 +118,7 @@ func init() {
|
||||
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 {
|
||||
@@ -130,11 +144,16 @@ func init() {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -149,12 +168,14 @@ 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("---")
|
||||
}
|
||||
|
||||
@@ -171,3 +192,10 @@ func printGreetings() {
|
||||
fmt.Printf("👉GitHub: %s\n", "https://github.com/usememos/memos")
|
||||
println("---")
|
||||
}
|
||||
|
||||
func main() {
|
||||
err := Execute()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
99
cmd/mvrss.go
99
cmd/mvrss.go
@@ -1,99 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/usememos/memos/store"
|
||||
"github.com/usememos/memos/store/db/sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
mvrssCmdFlagFrom = "from"
|
||||
mvrssCmdFlagTo = "to"
|
||||
mvrssCmd = &cobra.Command{
|
||||
Use: "mvrss", // `mvrss` is a shortened for 'means move resource'
|
||||
Short: "Move resource between storage",
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
from, err := cmd.Flags().GetString(mvrssCmdFlagFrom)
|
||||
if err != nil {
|
||||
fmt.Printf("failed to get from storage, error: %+v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
to, err := cmd.Flags().GetString(mvrssCmdFlagTo)
|
||||
if err != nil {
|
||||
fmt.Printf("failed to get to storage, error: %+v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if from != "local" || to != "db" {
|
||||
fmt.Printf("only local=>db be supported currently\n")
|
||||
return
|
||||
}
|
||||
|
||||
driver, err := sqlite.NewDB(profile)
|
||||
if err != nil {
|
||||
fmt.Printf("failed to create db driver, error: %+v\n", err)
|
||||
return
|
||||
}
|
||||
if err := driver.Migrate(ctx); err != nil {
|
||||
fmt.Printf("failed to migrate db, error: %+v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
s := store.New(driver, profile)
|
||||
resources, err := s.ListResources(ctx, &store.FindResource{})
|
||||
if err != nil {
|
||||
fmt.Printf("failed to list resources, error: %+v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
var emptyString string
|
||||
for _, res := range resources {
|
||||
if res.InternalPath == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
buf, err := os.ReadFile(res.InternalPath)
|
||||
if err != nil {
|
||||
fmt.Printf("Resource %5d failed to read file: %s\n", res.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(buf) != int(res.Size) {
|
||||
fmt.Printf("Resource %5d size of file %d != %d\n", res.ID, len(buf), res.Size)
|
||||
continue
|
||||
}
|
||||
|
||||
update := store.UpdateResource{
|
||||
ID: res.ID,
|
||||
Blob: buf,
|
||||
InternalPath: &emptyString,
|
||||
}
|
||||
_, err = s.UpdateResource(ctx, &update)
|
||||
if err != nil {
|
||||
fmt.Printf("Resource %5d failed to update: %s\n", res.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("Resource %5d copy %12d bytes from %s\n", res.ID, len(buf), res.InternalPath)
|
||||
}
|
||||
println("done")
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
mvrssCmd.Flags().String(mvrssCmdFlagFrom, "local", "From storage")
|
||||
mvrssCmd.Flags().String(mvrssCmdFlagTo, "db", "To Storage")
|
||||
|
||||
rootCmd.AddCommand(mvrssCmd)
|
||||
}
|
||||
142
cmd/setup.go
142
cmd/setup.go
@@ -1,142 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/usememos/memos/common/util"
|
||||
"github.com/usememos/memos/store"
|
||||
"github.com/usememos/memos/store/db/sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
setupCmdFlagHostUsername = "host-username"
|
||||
setupCmdFlagHostPassword = "host-password"
|
||||
setupCmd = &cobra.Command{
|
||||
Use: "setup",
|
||||
Short: "Make initial setup for memos",
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
hostUsername, err := cmd.Flags().GetString(setupCmdFlagHostUsername)
|
||||
if err != nil {
|
||||
fmt.Printf("failed to get owner username, error: %+v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
hostPassword, err := cmd.Flags().GetString(setupCmdFlagHostPassword)
|
||||
if err != nil {
|
||||
fmt.Printf("failed to get owner password, error: %+v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
driver, err := sqlite.NewDB(profile)
|
||||
if err != nil {
|
||||
fmt.Printf("failed to create db driver, error: %+v\n", err)
|
||||
return
|
||||
}
|
||||
if err := driver.Migrate(ctx); err != nil {
|
||||
fmt.Printf("failed to migrate db, error: %+v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
store := store.New(driver, profile)
|
||||
if err := ExecuteSetup(ctx, store, hostUsername, hostPassword); err != nil {
|
||||
fmt.Printf("failed to setup, error: %+v\n", err)
|
||||
return
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
setupCmd.Flags().String(setupCmdFlagHostUsername, "", "Owner username")
|
||||
setupCmd.Flags().String(setupCmdFlagHostPassword, "", "Owner password")
|
||||
|
||||
rootCmd.AddCommand(setupCmd)
|
||||
}
|
||||
|
||||
func ExecuteSetup(ctx context.Context, store *store.Store, hostUsername, hostPassword string) error {
|
||||
s := setupService{store: store}
|
||||
return s.Setup(ctx, hostUsername, hostPassword)
|
||||
}
|
||||
|
||||
type setupService struct {
|
||||
store *store.Store
|
||||
}
|
||||
|
||||
func (s setupService) Setup(ctx context.Context, hostUsername, hostPassword string) error {
|
||||
if err := s.makeSureHostUserNotExists(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.createUser(ctx, hostUsername, hostPassword); err != nil {
|
||||
return errors.Wrap(err, "create user")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s setupService) makeSureHostUserNotExists(ctx context.Context) error {
|
||||
hostUserType := store.RoleHost
|
||||
existedHostUsers, err := s.store.ListUsers(ctx, &store.FindUser{Role: &hostUserType})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "find user list")
|
||||
}
|
||||
|
||||
if len(existedHostUsers) != 0 {
|
||||
return errors.New("host user already exists")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s setupService) createUser(ctx context.Context, hostUsername, hostPassword string) error {
|
||||
userCreate := &store.User{
|
||||
Username: hostUsername,
|
||||
// The new signup user should be normal user by default.
|
||||
Role: store.RoleHost,
|
||||
Nickname: hostUsername,
|
||||
}
|
||||
|
||||
if len(userCreate.Username) < 3 {
|
||||
return errors.New("username is too short, minimum length is 3")
|
||||
}
|
||||
if len(userCreate.Username) > 32 {
|
||||
return errors.New("username is too long, maximum length is 32")
|
||||
}
|
||||
if len(hostPassword) < 3 {
|
||||
return errors.New("password is too short, minimum length is 3")
|
||||
}
|
||||
if len(hostPassword) > 512 {
|
||||
return errors.New("password is too long, maximum length is 512")
|
||||
}
|
||||
if len(userCreate.Nickname) > 64 {
|
||||
return errors.New("nickname is too long, maximum length is 64")
|
||||
}
|
||||
if userCreate.Email != "" {
|
||||
if len(userCreate.Email) > 256 {
|
||||
return errors.New("email is too long, maximum length is 256")
|
||||
}
|
||||
if !util.ValidateEmail(userCreate.Email) {
|
||||
return errors.New("invalid email format")
|
||||
}
|
||||
}
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(hostPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to hash password")
|
||||
}
|
||||
|
||||
userCreate.PasswordHash = string(passwordHash)
|
||||
if _, err := s.store.CreateUser(ctx, userCreate); err != nil {
|
||||
return errors.Wrap(err, "failed to create user")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,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:
|
||||
2045
docs/api/v1.md
2045
docs/api/v1.md
File diff suppressed because it is too large
Load Diff
@@ -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,7 +69,7 @@ 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
|
||||
|
||||
@@ -10,34 +10,33 @@ Memos is built with a curated tech stack. It is optimized for developer experien
|
||||
|
||||
- [Go](https://golang.org/doc/install)
|
||||
- [Air](https://github.com/cosmtrek/air#installation) for backend live reload
|
||||
- [Buf](https://buf.build/docs/installation)
|
||||
- [Node.js](https://nodejs.org/)
|
||||
- [pnpm](https://pnpm.io/installation)
|
||||
|
||||
## Steps
|
||||
|
||||
1. pull source code
|
||||
1. Pull the source code
|
||||
|
||||
```bash
|
||||
git clone https://github.com/usememos/memos
|
||||
```
|
||||
|
||||
2. start backend using air(with live reload)
|
||||
2. Start backend server with [`air`](https://github.com/cosmtrek/air) (with live reload)
|
||||
|
||||
```bash
|
||||
air -c scripts/.air.toml
|
||||
```
|
||||
|
||||
3. generate TypeScript code from protobuf with `buf`
|
||||
3. Install frontend dependencies and generate TypeScript code from protobuf
|
||||
|
||||
```
|
||||
cd proto && buf generate
|
||||
cd web && pnpm i && pnpm type-gen
|
||||
```
|
||||
|
||||
4. start frontend dev server
|
||||
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.
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
# 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,98 +0,0 @@
|
||||
# Installing memos as a service on Windows
|
||||
|
||||
While memos first-class support is for Docker, you may also install memos as a Windows service. It will run under SYSTEM account and start automatically at system boot.
|
||||
|
||||
❗ All service management methods requires admin privileges. Use [gsudo](https://gerardog.github.io/gsudo/docs/install), or open a new PowerShell terminal as admin:
|
||||
|
||||
```powershell
|
||||
Start-Process powershell -Verb RunAs
|
||||
```
|
||||
|
||||
## Choose one of the following methods
|
||||
|
||||
### 1. Using [NSSM](https://nssm.cc/download)
|
||||
|
||||
NSSM is a lightweight service wrapper.
|
||||
|
||||
You may put `nssm.exe` in the same directory as `memos.exe`, or add its directory to your system PATH. Prefer the latest 64-bit version of `nssm.exe`.
|
||||
|
||||
```powershell
|
||||
# Install memos as a service
|
||||
nssm install memos "C:\path\to\memos.exe" --mode prod --port 5230
|
||||
|
||||
# Delay auto start
|
||||
nssm set memos DisplayName "memos service"
|
||||
|
||||
# Configure extra service parameters
|
||||
nssm set memos Description "A lightweight, self-hosted memo hub. https://usememos.com/"
|
||||
|
||||
# Delay auto start
|
||||
nssm set memos Start SERVICE_DELAYED_AUTO_START
|
||||
|
||||
# Edit service using NSSM GUI
|
||||
nssm edit memos
|
||||
|
||||
# Start the service
|
||||
nssm start memos
|
||||
|
||||
# Remove the service, if ever needed
|
||||
nssm remove memos confirm
|
||||
```
|
||||
|
||||
### 2. Using [WinSW](https://github.com/winsw/winsw)
|
||||
|
||||
Find the latest release tag and download the asset `WinSW-net46x.exe`. Then, put it in the same directory as `memos.exe` and rename it to `memos-service.exe`.
|
||||
|
||||
Now, in the same directory, create the service configuration file `memos-service.xml`:
|
||||
|
||||
```xml
|
||||
<service>
|
||||
<id>memos</id>
|
||||
<name>memos service</name>
|
||||
<description>A lightweight, self-hosted memo hub. https://usememos.com/</description>
|
||||
<onfailure action="restart" delay="10 sec"/>
|
||||
<executable>%BASE%\memos.exe</executable>
|
||||
<arguments>--mode prod --port 5230</arguments>
|
||||
<delayedAutoStart>true</delayedAutoStart>
|
||||
<log mode="none" />
|
||||
</service>
|
||||
```
|
||||
|
||||
Then, install the service:
|
||||
|
||||
```powershell
|
||||
# Install the service
|
||||
.\memos-service.exe install
|
||||
|
||||
# Start the service
|
||||
.\memos-service.exe start
|
||||
|
||||
# Remove the service, if ever needed
|
||||
.\memos-service.exe uninstall
|
||||
```
|
||||
|
||||
### Manage the service
|
||||
|
||||
You may use the `net` command to manage the service:
|
||||
|
||||
```powershell
|
||||
net start memos
|
||||
net stop memos
|
||||
```
|
||||
|
||||
Also, by using one of the provided methods, the service will appear in the Windows Services Manager `services.msc`.
|
||||
|
||||
## Notes
|
||||
|
||||
- On Windows, memos store its data in the following directory:
|
||||
|
||||
```powershell
|
||||
$env:ProgramData\memos
|
||||
# Typically, this will resolve to C:\ProgramData\memos
|
||||
```
|
||||
|
||||
You may specify a custom directory by appending `--data <path>` to the service command line.
|
||||
|
||||
- If the service fails to start, you should inspect the Windows Event Viewer `eventvwr.msc`.
|
||||
|
||||
- Memos will be accessible at [http://localhost:5230](http://localhost:5230) by default.
|
||||
166
go.mod
166
go.mod
@@ -3,119 +3,119 @@ module github.com/usememos/memos
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
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/aws/aws-sdk-go-v2 v1.24.1
|
||||
github.com/aws/aws-sdk-go-v2/config v1.26.6
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.16.16
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.15
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.48.1
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/go-sql-driver/mysql v1.7.1
|
||||
github.com/google/cel-go v0.17.1
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/gorilla/feeds v1.1.1
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.2
|
||||
github.com/google/cel-go v0.19.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/feeds v1.1.2
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0
|
||||
github.com/improbable-eng/grpc-web v0.15.0
|
||||
github.com/labstack/echo/v4 v4.11.1
|
||||
github.com/microcosm-cc/bluemonday v1.0.25
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/labstack/echo/v4 v4.11.4
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/lithammer/shortuuid/v4 v4.0.0
|
||||
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/spf13/cobra v1.8.0
|
||||
github.com/spf13/viper v1.18.2
|
||||
github.com/stretchr/testify v1.8.4
|
||||
github.com/swaggo/echo-swagger v1.4.0
|
||||
github.com/swaggo/swag v1.16.1
|
||||
github.com/yuin/goldmark v1.5.4
|
||||
go.uber.org/zap v1.24.0
|
||||
golang.org/x/crypto v0.13.0
|
||||
golang.org/x/exp v0.0.0-20230111222715-75897c7a292a
|
||||
golang.org/x/mod v0.12.0
|
||||
golang.org/x/net v0.15.0
|
||||
golang.org/x/oauth2 v0.10.0
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20230726155614-23370e0ffb3e
|
||||
google.golang.org/grpc v1.57.0
|
||||
modernc.org/sqlite v1.24.0
|
||||
github.com/swaggo/swag v1.16.2
|
||||
go.uber.org/zap v1.26.0
|
||||
golang.org/x/crypto v0.18.0
|
||||
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a
|
||||
golang.org/x/mod v0.14.0
|
||||
golang.org/x/net v0.20.0
|
||||
golang.org/x/oauth2 v0.16.0
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240125205218-1f4bbc51befe
|
||||
google.golang.org/grpc v1.61.0
|
||||
modernc.org/sqlite v1.28.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230305170008-8188dc5388df // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.1.1 // 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/go-openapi/jsonpointer v0.20.0 // indirect
|
||||
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||
github.com/go-openapi/spec v0.20.9 // indirect
|
||||
github.com/go-openapi/swag v0.22.4 // indirect
|
||||
github.com/gorilla/css v1.0.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.20.2 // indirect
|
||||
github.com/go-openapi/jsonreference v0.20.4 // indirect
|
||||
github.com/go-openapi/spec v0.20.14 // indirect
|
||||
github.com/go-openapi/swag v0.22.9 // 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/klauspost/compress v1.11.7 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rogpeppe/go-internal v1.9.0 // indirect
|
||||
github.com/rs/cors v1.7.0 // indirect
|
||||
github.com/stoewer/go-strcase v1.2.0 // indirect
|
||||
github.com/swaggo/files/v2 v2.0.0 // indirect
|
||||
golang.org/x/image v0.7.0 // indirect
|
||||
golang.org/x/tools v0.11.1 // indirect
|
||||
google.golang.org/genproto v0.0.0-20230706204954-ccb25ca9f130 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20230726155614-23370e0ffb3e // 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
|
||||
golang.org/x/image v0.15.0 // indirect
|
||||
golang.org/x/tools v0.17.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20240125205218-1f4bbc51befe // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240125205218-1f4bbc51befe // 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.40.7 // 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
|
||||
nhooyr.io/websocket v1.8.6 // 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.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.10 // 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.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 // 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-jwt/jwt/v5 v5.2.0
|
||||
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/labstack/gommon v0.4.0 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // 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.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/posthog/posthog-go v0.0.0-20240115103626-fbd687c18571
|
||||
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.2 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.8.0 // indirect
|
||||
golang.org/x/sys v0.12.0 // indirect
|
||||
golang.org/x/text v0.13.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/protobuf v1.31.0
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/sys v0.16.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.32.0
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
7
internal/util/resource_name.go
Normal file
7
internal/util/resource_name.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package util
|
||||
|
||||
import "regexp"
|
||||
|
||||
var (
|
||||
ResourceNameMatcher = regexp.MustCompile("^[a-zA-Z0-9]([a-zA-Z0-9-]{1,30}[a-zA-Z0-9])$")
|
||||
)
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
|
||||
// ConvertStringToInt32 converts a string to int32.
|
||||
func ConvertStringToInt32(src string) (int32, error) {
|
||||
i, err := strconv.Atoi(src)
|
||||
parsed, err := strconv.ParseInt(src, 10, 32)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int32(i), nil
|
||||
return int32(parsed), nil
|
||||
}
|
||||
|
||||
// HasPrefixes returns true if the string s has any of the given prefixes.
|
||||
15
main.go
15
main.go
@@ -1,15 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/usememos/memos/cmd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,88 @@
|
||||
package ast
|
||||
|
||||
type Node struct {
|
||||
Type string
|
||||
Text string
|
||||
Children []*Node
|
||||
type NodeType uint32
|
||||
|
||||
const (
|
||||
UnknownNode NodeType = iota
|
||||
// Block nodes.
|
||||
LineBreakNode
|
||||
ParagraphNode
|
||||
CodeBlockNode
|
||||
HeadingNode
|
||||
HorizontalRuleNode
|
||||
BlockquoteNode
|
||||
OrderedListNode
|
||||
UnorderedListNode
|
||||
TaskListNode
|
||||
MathBlockNode
|
||||
TableNode
|
||||
EmbeddedContentNode
|
||||
// Inline nodes.
|
||||
TextNode
|
||||
BoldNode
|
||||
ItalicNode
|
||||
BoldItalicNode
|
||||
CodeNode
|
||||
ImageNode
|
||||
LinkNode
|
||||
AutoLinkNode
|
||||
TagNode
|
||||
StrikethroughNode
|
||||
EscapingCharacterNode
|
||||
MathNode
|
||||
HighlightNode
|
||||
SubscriptNode
|
||||
SuperscriptNode
|
||||
ReferencedContentNode
|
||||
)
|
||||
|
||||
type Node interface {
|
||||
// Type returns a node type.
|
||||
Type() NodeType
|
||||
|
||||
// Restore returns a string representation of this node.
|
||||
Restore() string
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
type Document struct {
|
||||
Nodes []*Node
|
||||
type BaseNode struct {
|
||||
prevSibling Node
|
||||
|
||||
nextSibling Node
|
||||
}
|
||||
|
||||
func NewDocument() *Document {
|
||||
return &Document{}
|
||||
func (n *BaseNode) PrevSibling() Node {
|
||||
return n.prevSibling
|
||||
}
|
||||
|
||||
func (d *Document) AddNode(node *Node) {
|
||||
d.Nodes = append(d.Nodes, node)
|
||||
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
|
||||
}
|
||||
|
||||
func IsBlockNode(node Node) bool {
|
||||
switch node.Type() {
|
||||
case ParagraphNode, CodeBlockNode, HeadingNode, HorizontalRuleNode, BlockquoteNode, OrderedListNode, UnorderedListNode, TaskListNode, MathBlockNode, TableNode, EmbeddedContentNode:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
250
plugin/gomark/ast/block.go
Normal file
250
plugin/gomark/ast/block.go
Normal file
@@ -0,0 +1,250 @@
|
||||
package ast
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type BaseBlock struct {
|
||||
BaseNode
|
||||
}
|
||||
|
||||
type LineBreak struct {
|
||||
BaseBlock
|
||||
}
|
||||
|
||||
func (*LineBreak) Type() NodeType {
|
||||
return LineBreakNode
|
||||
}
|
||||
|
||||
func (*LineBreak) Restore() string {
|
||||
return "\n"
|
||||
}
|
||||
|
||||
type Paragraph struct {
|
||||
BaseBlock
|
||||
|
||||
Children []Node
|
||||
}
|
||||
|
||||
func (*Paragraph) Type() NodeType {
|
||||
return ParagraphNode
|
||||
}
|
||||
|
||||
func (n *Paragraph) Restore() string {
|
||||
var result string
|
||||
for _, child := range n.Children {
|
||||
result += child.Restore()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type CodeBlock struct {
|
||||
BaseBlock
|
||||
|
||||
Language string
|
||||
Content string
|
||||
}
|
||||
|
||||
func (*CodeBlock) Type() NodeType {
|
||||
return CodeBlockNode
|
||||
}
|
||||
|
||||
func (n *CodeBlock) Restore() string {
|
||||
return fmt.Sprintf("```%s\n%s\n```", n.Language, n.Content)
|
||||
}
|
||||
|
||||
type Heading struct {
|
||||
BaseBlock
|
||||
|
||||
Level int
|
||||
Children []Node
|
||||
}
|
||||
|
||||
func (*Heading) Type() NodeType {
|
||||
return HeadingNode
|
||||
}
|
||||
|
||||
func (n *Heading) Restore() string {
|
||||
var result string
|
||||
for _, child := range n.Children {
|
||||
result += child.Restore()
|
||||
}
|
||||
symbol := ""
|
||||
for i := 0; i < n.Level; i++ {
|
||||
symbol += "#"
|
||||
}
|
||||
return fmt.Sprintf("%s %s", symbol, result)
|
||||
}
|
||||
|
||||
type HorizontalRule struct {
|
||||
BaseBlock
|
||||
|
||||
// Symbol is "*" or "-" or "_".
|
||||
Symbol string
|
||||
}
|
||||
|
||||
func (*HorizontalRule) Type() NodeType {
|
||||
return HorizontalRuleNode
|
||||
}
|
||||
|
||||
func (n *HorizontalRule) Restore() string {
|
||||
return n.Symbol + n.Symbol + n.Symbol
|
||||
}
|
||||
|
||||
type Blockquote struct {
|
||||
BaseBlock
|
||||
|
||||
Children []Node
|
||||
}
|
||||
|
||||
func (*Blockquote) Type() NodeType {
|
||||
return BlockquoteNode
|
||||
}
|
||||
|
||||
func (n *Blockquote) Restore() string {
|
||||
var result string
|
||||
for _, child := range n.Children {
|
||||
result += child.Restore()
|
||||
}
|
||||
return fmt.Sprintf("> %s", result)
|
||||
}
|
||||
|
||||
type OrderedList struct {
|
||||
BaseBlock
|
||||
|
||||
// Number is the number of the list.
|
||||
Number string
|
||||
// Indent is the number of spaces.
|
||||
Indent int
|
||||
Children []Node
|
||||
}
|
||||
|
||||
func (*OrderedList) Type() NodeType {
|
||||
return OrderedListNode
|
||||
}
|
||||
|
||||
func (n *OrderedList) Restore() string {
|
||||
var result string
|
||||
for _, child := range n.Children {
|
||||
result += child.Restore()
|
||||
}
|
||||
return fmt.Sprintf("%s%s. %s", strings.Repeat(" ", n.Indent), n.Number, result)
|
||||
}
|
||||
|
||||
type UnorderedList struct {
|
||||
BaseBlock
|
||||
|
||||
// Symbol is "*" or "-" or "+".
|
||||
Symbol string
|
||||
// Indent is the number of spaces.
|
||||
Indent int
|
||||
Children []Node
|
||||
}
|
||||
|
||||
func (*UnorderedList) Type() NodeType {
|
||||
return UnorderedListNode
|
||||
}
|
||||
|
||||
func (n *UnorderedList) Restore() string {
|
||||
var result string
|
||||
for _, child := range n.Children {
|
||||
result += child.Restore()
|
||||
}
|
||||
return fmt.Sprintf("%s%s %s", strings.Repeat(" ", n.Indent), n.Symbol, result)
|
||||
}
|
||||
|
||||
type TaskList struct {
|
||||
BaseBlock
|
||||
|
||||
// Symbol is "*" or "-" or "+".
|
||||
Symbol string
|
||||
// Indent is the number of spaces.
|
||||
Indent int
|
||||
Complete bool
|
||||
Children []Node
|
||||
}
|
||||
|
||||
func (*TaskList) Type() NodeType {
|
||||
return TaskListNode
|
||||
}
|
||||
|
||||
func (n *TaskList) Restore() string {
|
||||
var result string
|
||||
for _, child := range n.Children {
|
||||
result += child.Restore()
|
||||
}
|
||||
complete := " "
|
||||
if n.Complete {
|
||||
complete = "x"
|
||||
}
|
||||
return fmt.Sprintf("%s%s [%s] %s", strings.Repeat(" ", n.Indent), n.Symbol, complete, result)
|
||||
}
|
||||
|
||||
type MathBlock struct {
|
||||
BaseBlock
|
||||
|
||||
Content string
|
||||
}
|
||||
|
||||
func (*MathBlock) Type() NodeType {
|
||||
return MathBlockNode
|
||||
}
|
||||
|
||||
func (n *MathBlock) Restore() string {
|
||||
return fmt.Sprintf("$$\n%s\n$$", n.Content)
|
||||
}
|
||||
|
||||
type Table struct {
|
||||
BaseBlock
|
||||
|
||||
Header []string
|
||||
Delimiter []string
|
||||
Rows [][]string
|
||||
}
|
||||
|
||||
func (*Table) Type() NodeType {
|
||||
return TableNode
|
||||
}
|
||||
|
||||
func (n *Table) Restore() string {
|
||||
result := ""
|
||||
for _, header := range n.Header {
|
||||
result += fmt.Sprintf("| %s ", header)
|
||||
}
|
||||
result += "|\n"
|
||||
for _, d := range n.Delimiter {
|
||||
result += fmt.Sprintf("| %s ", d)
|
||||
}
|
||||
result += "|\n"
|
||||
for index, row := range n.Rows {
|
||||
for _, cell := range row {
|
||||
result += fmt.Sprintf("| %s ", cell)
|
||||
}
|
||||
result += "|"
|
||||
if index != len(n.Rows)-1 {
|
||||
result += "\n"
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type EmbeddedContent struct {
|
||||
BaseBlock
|
||||
|
||||
ResourceName string
|
||||
Params string
|
||||
}
|
||||
|
||||
func (*EmbeddedContent) Type() NodeType {
|
||||
return EmbeddedContentNode
|
||||
}
|
||||
|
||||
func (n *EmbeddedContent) Restore() string {
|
||||
params := ""
|
||||
if n.Params != "" {
|
||||
params = fmt.Sprintf("?%s", n.Params)
|
||||
}
|
||||
result := fmt.Sprintf("![[%s%s]]", n.ResourceName, params)
|
||||
return result
|
||||
}
|
||||
255
plugin/gomark/ast/inline.go
Normal file
255
plugin/gomark/ast/inline.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package ast
|
||||
|
||||
import "fmt"
|
||||
|
||||
type BaseInline struct {
|
||||
BaseNode
|
||||
}
|
||||
|
||||
type Text struct {
|
||||
BaseInline
|
||||
|
||||
Content string
|
||||
}
|
||||
|
||||
func (*Text) Type() NodeType {
|
||||
return TextNode
|
||||
}
|
||||
|
||||
func (n *Text) Restore() string {
|
||||
return n.Content
|
||||
}
|
||||
|
||||
type Bold struct {
|
||||
BaseInline
|
||||
|
||||
// Symbol is "*" or "_".
|
||||
Symbol string
|
||||
Children []Node
|
||||
}
|
||||
|
||||
func (*Bold) Type() NodeType {
|
||||
return BoldNode
|
||||
}
|
||||
|
||||
func (n *Bold) Restore() string {
|
||||
symbol := n.Symbol + n.Symbol
|
||||
children := ""
|
||||
for _, child := range n.Children {
|
||||
children += child.Restore()
|
||||
}
|
||||
return fmt.Sprintf("%s%s%s", symbol, children, symbol)
|
||||
}
|
||||
|
||||
type Italic struct {
|
||||
BaseInline
|
||||
|
||||
// Symbol is "*" or "_".
|
||||
Symbol string
|
||||
Content string
|
||||
}
|
||||
|
||||
func (*Italic) Type() NodeType {
|
||||
return ItalicNode
|
||||
}
|
||||
|
||||
func (n *Italic) Restore() string {
|
||||
return fmt.Sprintf("%s%s%s", n.Symbol, n.Content, n.Symbol)
|
||||
}
|
||||
|
||||
type BoldItalic struct {
|
||||
BaseInline
|
||||
|
||||
// Symbol is "*" or "_".
|
||||
Symbol string
|
||||
Content string
|
||||
}
|
||||
|
||||
func (*BoldItalic) Type() NodeType {
|
||||
return BoldItalicNode
|
||||
}
|
||||
|
||||
func (n *BoldItalic) Restore() string {
|
||||
symbol := n.Symbol + n.Symbol + n.Symbol
|
||||
return fmt.Sprintf("%s%s%s", symbol, n.Content, symbol)
|
||||
}
|
||||
|
||||
type Code struct {
|
||||
BaseInline
|
||||
|
||||
Content string
|
||||
}
|
||||
|
||||
func (*Code) Type() NodeType {
|
||||
return CodeNode
|
||||
}
|
||||
|
||||
func (n *Code) Restore() string {
|
||||
return fmt.Sprintf("`%s`", n.Content)
|
||||
}
|
||||
|
||||
type Image struct {
|
||||
BaseInline
|
||||
|
||||
AltText string
|
||||
URL string
|
||||
}
|
||||
|
||||
func (*Image) Type() NodeType {
|
||||
return ImageNode
|
||||
}
|
||||
|
||||
func (n *Image) Restore() string {
|
||||
return fmt.Sprintf("", n.AltText, n.URL)
|
||||
}
|
||||
|
||||
type Link struct {
|
||||
BaseInline
|
||||
|
||||
Text string
|
||||
URL string
|
||||
}
|
||||
|
||||
func (*Link) Type() NodeType {
|
||||
return LinkNode
|
||||
}
|
||||
|
||||
func (n *Link) Restore() string {
|
||||
return fmt.Sprintf("[%s](%s)", n.Text, n.URL)
|
||||
}
|
||||
|
||||
type AutoLink struct {
|
||||
BaseInline
|
||||
|
||||
URL string
|
||||
IsRawText bool
|
||||
}
|
||||
|
||||
func (*AutoLink) Type() NodeType {
|
||||
return AutoLinkNode
|
||||
}
|
||||
|
||||
func (n *AutoLink) Restore() string {
|
||||
if n.IsRawText {
|
||||
return n.URL
|
||||
}
|
||||
return fmt.Sprintf("<%s>", n.URL)
|
||||
}
|
||||
|
||||
type Tag struct {
|
||||
BaseInline
|
||||
|
||||
Content string
|
||||
}
|
||||
|
||||
func (*Tag) Type() NodeType {
|
||||
return TagNode
|
||||
}
|
||||
|
||||
func (n *Tag) Restore() string {
|
||||
return fmt.Sprintf("#%s", n.Content)
|
||||
}
|
||||
|
||||
type Strikethrough struct {
|
||||
BaseInline
|
||||
|
||||
Content string
|
||||
}
|
||||
|
||||
func (*Strikethrough) Type() NodeType {
|
||||
return StrikethroughNode
|
||||
}
|
||||
|
||||
func (n *Strikethrough) Restore() string {
|
||||
return fmt.Sprintf("~~%s~~", n.Content)
|
||||
}
|
||||
|
||||
type EscapingCharacter struct {
|
||||
BaseInline
|
||||
|
||||
Symbol string
|
||||
}
|
||||
|
||||
func (*EscapingCharacter) Type() NodeType {
|
||||
return EscapingCharacterNode
|
||||
}
|
||||
|
||||
func (n *EscapingCharacter) Restore() string {
|
||||
return fmt.Sprintf("\\%s", n.Symbol)
|
||||
}
|
||||
|
||||
type Math struct {
|
||||
BaseInline
|
||||
|
||||
Content string
|
||||
}
|
||||
|
||||
func (*Math) Type() NodeType {
|
||||
return MathNode
|
||||
}
|
||||
|
||||
func (n *Math) Restore() string {
|
||||
return fmt.Sprintf("$%s$", n.Content)
|
||||
}
|
||||
|
||||
type Highlight struct {
|
||||
BaseInline
|
||||
|
||||
Content string
|
||||
}
|
||||
|
||||
func (*Highlight) Type() NodeType {
|
||||
return HighlightNode
|
||||
}
|
||||
|
||||
func (n *Highlight) Restore() string {
|
||||
return fmt.Sprintf("==%s==", n.Content)
|
||||
}
|
||||
|
||||
type Subscript struct {
|
||||
BaseInline
|
||||
|
||||
Content string
|
||||
}
|
||||
|
||||
func (*Subscript) Type() NodeType {
|
||||
return SubscriptNode
|
||||
}
|
||||
|
||||
func (n *Subscript) Restore() string {
|
||||
return fmt.Sprintf("~%s~", n.Content)
|
||||
}
|
||||
|
||||
type Superscript struct {
|
||||
BaseInline
|
||||
|
||||
Content string
|
||||
}
|
||||
|
||||
func (*Superscript) Type() NodeType {
|
||||
return SuperscriptNode
|
||||
}
|
||||
|
||||
func (n *Superscript) Restore() string {
|
||||
return fmt.Sprintf("^%s^", n.Content)
|
||||
}
|
||||
|
||||
type ReferencedContent struct {
|
||||
BaseInline
|
||||
|
||||
ResourceName string
|
||||
Params string
|
||||
}
|
||||
|
||||
func (*ReferencedContent) Type() NodeType {
|
||||
return ReferencedContentNode
|
||||
}
|
||||
|
||||
func (n *ReferencedContent) Restore() string {
|
||||
params := ""
|
||||
if n.Params != "" {
|
||||
params = fmt.Sprintf("?%s", n.Params)
|
||||
}
|
||||
result := fmt.Sprintf("[[%s%s]]", n.ResourceName, params)
|
||||
return result
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package ast
|
||||
|
||||
func NewNode(tp, text string) *Node {
|
||||
return &Node{
|
||||
Type: tp,
|
||||
Text: text,
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Node) AddChild(child *Node) {
|
||||
n.Children = append(n.Children, child)
|
||||
}
|
||||
23
plugin/gomark/ast/utils.go
Normal file
23
plugin/gomark/ast/utils.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package ast
|
||||
|
||||
func FindPrevSiblingExceptLineBreak(node Node) Node {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
prev := node.PrevSibling()
|
||||
if prev != nil && prev.Type() == LineBreakNode && prev.PrevSibling() != nil && prev.PrevSibling().Type() != LineBreakNode {
|
||||
return FindPrevSiblingExceptLineBreak(prev)
|
||||
}
|
||||
return prev
|
||||
}
|
||||
|
||||
func FindNextSiblingExceptLineBreak(node Node) Node {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
next := node.NextSibling()
|
||||
if next != nil && next.Type() == LineBreakNode && next.NextSibling() != nil && next.NextSibling().Type() != LineBreakNode {
|
||||
return FindNextSiblingExceptLineBreak(next)
|
||||
}
|
||||
return next
|
||||
}
|
||||
43
plugin/gomark/parser/auto_link.go
Normal file
43
plugin/gomark/parser/auto_link.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
|
||||
"github.com/usememos/memos/plugin/gomark/ast"
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
)
|
||||
|
||||
type AutoLinkParser struct{}
|
||||
|
||||
func NewAutoLinkParser() *AutoLinkParser {
|
||||
return &AutoLinkParser{}
|
||||
}
|
||||
|
||||
func (*AutoLinkParser) Match(tokens []*tokenizer.Token) (ast.Node, int) {
|
||||
if len(tokens) < 3 {
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
matchedTokens := tokenizer.GetFirstLine(tokens)
|
||||
urlStr, isRawText := "", true
|
||||
if matchedTokens[0].Type == tokenizer.LessThan {
|
||||
greaterThanIndex := tokenizer.FindUnescaped(matchedTokens, tokenizer.GreaterThan)
|
||||
if greaterThanIndex < 0 {
|
||||
return nil, 0
|
||||
}
|
||||
matchedTokens = matchedTokens[:greaterThanIndex+1]
|
||||
urlStr = tokenizer.Stringify(matchedTokens[1 : len(matchedTokens)-1])
|
||||
isRawText = false
|
||||
} else {
|
||||
u, err := url.Parse(tokenizer.Stringify(matchedTokens))
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return nil, 0
|
||||
}
|
||||
urlStr = tokenizer.Stringify(matchedTokens)
|
||||
}
|
||||
|
||||
return &ast.AutoLink{
|
||||
URL: urlStr,
|
||||
IsRawText: isRawText,
|
||||
}, len(matchedTokens)
|
||||
}
|
||||
42
plugin/gomark/parser/auto_link_test.go
Normal file
42
plugin/gomark/parser/auto_link_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/usememos/memos/plugin/gomark/ast"
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
"github.com/usememos/memos/plugin/gomark/restore"
|
||||
)
|
||||
|
||||
func TestAutoLinkParser(t *testing.T) {
|
||||
tests := []struct {
|
||||
text string
|
||||
link ast.Node
|
||||
}{
|
||||
{
|
||||
text: "<https://example.com)",
|
||||
link: nil,
|
||||
},
|
||||
{
|
||||
text: "<https://example.com>",
|
||||
link: &ast.AutoLink{
|
||||
URL: "https://example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "https://example.com",
|
||||
link: &ast.AutoLink{
|
||||
URL: "https://example.com",
|
||||
IsRawText: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
tokens := tokenizer.Tokenize(test.text)
|
||||
node, _ := NewAutoLinkParser().Match(tokens)
|
||||
require.Equal(t, restore.Restore([]ast.Node{test.link}), restore.Restore([]ast.Node{node}))
|
||||
}
|
||||
}
|
||||
49
plugin/gomark/parser/blockquote.go
Normal file
49
plugin/gomark/parser/blockquote.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"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) (ast.Node, int) {
|
||||
rows := tokenizer.Split(tokens, tokenizer.NewLine)
|
||||
contentRows := [][]*tokenizer.Token{}
|
||||
for _, row := range rows {
|
||||
if len(row) < 3 || row[0].Type != tokenizer.GreaterThan || row[1].Type != tokenizer.Space {
|
||||
break
|
||||
}
|
||||
contentRows = append(contentRows, row)
|
||||
}
|
||||
if len(contentRows) == 0 {
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
children := []ast.Node{}
|
||||
size := 0
|
||||
for index, row := range contentRows {
|
||||
contentTokens := row[2:]
|
||||
nodes, err := ParseBlockWithParsers(contentTokens, []BlockParser{NewBlockquoteParser(), NewParagraphParser()})
|
||||
if err != nil {
|
||||
return nil, 0
|
||||
}
|
||||
if len(nodes) != 1 {
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
children = append(children, nodes[0])
|
||||
size += len(row)
|
||||
if index != len(contentRows)-1 {
|
||||
size++ // NewLine.
|
||||
}
|
||||
}
|
||||
|
||||
return &ast.Blockquote{
|
||||
Children: children,
|
||||
}, size
|
||||
}
|
||||
103
plugin/gomark/parser/blockquote_test.go
Normal file
103
plugin/gomark/parser/blockquote_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/usememos/memos/plugin/gomark/ast"
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
"github.com/usememos/memos/plugin/gomark/restore"
|
||||
)
|
||||
|
||||
func TestBlockquoteParser(t *testing.T) {
|
||||
tests := []struct {
|
||||
text string
|
||||
blockquote ast.Node
|
||||
}{
|
||||
{
|
||||
text: ">Hello world",
|
||||
blockquote: nil,
|
||||
},
|
||||
{
|
||||
text: "> Hello world",
|
||||
blockquote: &ast.Blockquote{
|
||||
Children: []ast.Node{
|
||||
&ast.Paragraph{
|
||||
Children: []ast.Node{
|
||||
&ast.Text{
|
||||
Content: "Hello world",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "> 你好",
|
||||
blockquote: &ast.Blockquote{
|
||||
Children: []ast.Node{
|
||||
&ast.Paragraph{
|
||||
Children: []ast.Node{
|
||||
&ast.Text{
|
||||
Content: "你好",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "> Hello\n> world",
|
||||
blockquote: &ast.Blockquote{
|
||||
Children: []ast.Node{
|
||||
&ast.Paragraph{
|
||||
Children: []ast.Node{
|
||||
&ast.Text{
|
||||
Content: "Hello",
|
||||
},
|
||||
},
|
||||
},
|
||||
&ast.Paragraph{
|
||||
Children: []ast.Node{
|
||||
&ast.Text{
|
||||
Content: "world",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "> Hello\n> > world",
|
||||
blockquote: &ast.Blockquote{
|
||||
Children: []ast.Node{
|
||||
&ast.Paragraph{
|
||||
Children: []ast.Node{
|
||||
&ast.Text{
|
||||
Content: "Hello",
|
||||
},
|
||||
},
|
||||
},
|
||||
&ast.Blockquote{
|
||||
Children: []ast.Node{
|
||||
&ast.Paragraph{
|
||||
Children: []ast.Node{
|
||||
&ast.Text{
|
||||
Content: "world",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
tokens := tokenizer.Tokenize(test.text)
|
||||
node, _ := NewBlockquoteParser().Match(tokens)
|
||||
require.Equal(t, restore.Restore([]ast.Node{test.blockquote}), restore.Restore([]ast.Node{node}))
|
||||
}
|
||||
}
|
||||
@@ -1,49 +1,54 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"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 {
|
||||
if len(tokens) < 5 {
|
||||
return nil
|
||||
func (*BoldParser) Match(tokens []*tokenizer.Token) (ast.Node, int) {
|
||||
matchedTokens := tokenizer.GetFirstLine(tokens)
|
||||
if len(matchedTokens) < 5 {
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
prefixTokens := tokens[:2]
|
||||
prefixTokens := matchedTokens[:2]
|
||||
if prefixTokens[0].Type != prefixTokens[1].Type {
|
||||
return nil
|
||||
return nil, 0
|
||||
}
|
||||
prefixTokenType := prefixTokens[0].Type
|
||||
if prefixTokenType != tokenizer.Star && prefixTokenType != tokenizer.Underline {
|
||||
return nil
|
||||
if prefixTokenType != tokenizer.Asterisk {
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
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
|
||||
for ; cursor < len(matchedTokens)-1; cursor++ {
|
||||
token, nextToken := matchedTokens[cursor], matchedTokens[cursor+1]
|
||||
if token.Type == tokenizer.NewLine || nextToken.Type == tokenizer.NewLine {
|
||||
return nil, 0
|
||||
}
|
||||
if token.Type == prefixTokenType && nextToken.Type == prefixTokenType {
|
||||
matchedTokens = matchedTokens[:cursor+2]
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
contentTokens = append(contentTokens, token)
|
||||
}
|
||||
if !matched {
|
||||
return nil
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
return &BoldParser{
|
||||
ContentTokens: contentTokens,
|
||||
size := len(matchedTokens)
|
||||
children, err := ParseInlineWithParsers(matchedTokens[2:size-2], []InlineParser{NewLinkParser(), NewTextParser()})
|
||||
if err != nil {
|
||||
return nil, 0
|
||||
}
|
||||
return &ast.Bold{
|
||||
Symbol: prefixTokenType,
|
||||
Children: children,
|
||||
}, size
|
||||
}
|
||||
|
||||
49
plugin/gomark/parser/bold_italic.go
Normal file
49
plugin/gomark/parser/bold_italic.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"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) (ast.Node, int) {
|
||||
matchedTokens := tokenizer.GetFirstLine(tokens)
|
||||
if len(matchedTokens) < 7 {
|
||||
return nil, 0
|
||||
}
|
||||
prefixTokens := matchedTokens[:3]
|
||||
if prefixTokens[0].Type != prefixTokens[1].Type || prefixTokens[0].Type != prefixTokens[2].Type || prefixTokens[1].Type != prefixTokens[2].Type {
|
||||
return nil, 0
|
||||
}
|
||||
prefixTokenType := prefixTokens[0].Type
|
||||
if prefixTokenType != tokenizer.Asterisk {
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
cursor, matched := 3, false
|
||||
for ; cursor < len(matchedTokens)-2; cursor++ {
|
||||
token, nextToken, endToken := matchedTokens[cursor], matchedTokens[cursor+1], matchedTokens[cursor+2]
|
||||
if token.Type == tokenizer.NewLine || nextToken.Type == tokenizer.NewLine || endToken.Type == tokenizer.NewLine {
|
||||
return nil, 0
|
||||
}
|
||||
if token.Type == prefixTokenType && nextToken.Type == prefixTokenType && endToken.Type == prefixTokenType {
|
||||
matchedTokens = matchedTokens[:cursor+3]
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
size := len(matchedTokens)
|
||||
return &ast.BoldItalic{
|
||||
Symbol: prefixTokenType,
|
||||
Content: tokenizer.Stringify(matchedTokens[3 : size-3]),
|
||||
}, len(matchedTokens)
|
||||
}
|
||||
51
plugin/gomark/parser/bold_italic_test.go
Normal file
51
plugin/gomark/parser/bold_italic_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/usememos/memos/plugin/gomark/ast"
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
"github.com/usememos/memos/plugin/gomark/restore"
|
||||
)
|
||||
|
||||
func TestBoldItalicParser(t *testing.T) {
|
||||
tests := []struct {
|
||||
text string
|
||||
boldItalic ast.Node
|
||||
}{
|
||||
{
|
||||
text: "*Hello world!",
|
||||
boldItalic: nil,
|
||||
},
|
||||
{
|
||||
text: "*** Hello * *",
|
||||
boldItalic: nil,
|
||||
},
|
||||
{
|
||||
text: "*** Hello **",
|
||||
boldItalic: nil,
|
||||
},
|
||||
{
|
||||
text: "***Hello***",
|
||||
boldItalic: &ast.BoldItalic{
|
||||
Symbol: "*",
|
||||
Content: "Hello",
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "*** Hello ***",
|
||||
boldItalic: &ast.BoldItalic{
|
||||
Symbol: "*",
|
||||
Content: " Hello ",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
tokens := tokenizer.Tokenize(test.text)
|
||||
node, _ := NewBoldItalicParser().Match(tokens)
|
||||
require.Equal(t, restore.Restore([]ast.Node{test.boldItalic}), restore.Restore([]ast.Node{node}))
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,15 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/usememos/memos/plugin/gomark/ast"
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
"github.com/usememos/memos/plugin/gomark/restore"
|
||||
)
|
||||
|
||||
func TestBoldParser(t *testing.T) {
|
||||
tests := []struct {
|
||||
text string
|
||||
bold *BoldParser
|
||||
bold ast.Node
|
||||
}{
|
||||
{
|
||||
text: "*Hello world!",
|
||||
@@ -19,30 +21,22 @@ func TestBoldParser(t *testing.T) {
|
||||
},
|
||||
{
|
||||
text: "**Hello**",
|
||||
bold: &BoldParser{
|
||||
ContentTokens: []*tokenizer.Token{
|
||||
{
|
||||
Type: tokenizer.Text,
|
||||
Value: "Hello",
|
||||
bold: &ast.Bold{
|
||||
Symbol: "*",
|
||||
Children: []ast.Node{
|
||||
&ast.Text{
|
||||
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: "*",
|
||||
Children: []ast.Node{
|
||||
&ast.Text{
|
||||
Content: " Hello ",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -55,35 +49,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().Match(tokens)
|
||||
require.Equal(t, restore.Restore([]ast.Node{test.bold}), restore.Restore([]ast.Node{node}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,30 @@
|
||||
package parser
|
||||
|
||||
import "github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
import (
|
||||
"github.com/usememos/memos/plugin/gomark/ast"
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
)
|
||||
|
||||
type CodeParser struct {
|
||||
Content string
|
||||
}
|
||||
type CodeParser struct{}
|
||||
|
||||
func NewCodeParser() *CodeParser {
|
||||
return &CodeParser{}
|
||||
}
|
||||
|
||||
func (*CodeParser) Match(tokens []*tokenizer.Token) *CodeParser {
|
||||
if len(tokens) < 3 {
|
||||
return nil
|
||||
func (*CodeParser) Match(tokens []*tokenizer.Token) (ast.Node, int) {
|
||||
matchedTokens := tokenizer.GetFirstLine(tokens)
|
||||
if len(matchedTokens) < 3 {
|
||||
return nil, 0
|
||||
}
|
||||
if tokens[0].Type != tokenizer.Backtick {
|
||||
return nil
|
||||
if matchedTokens[0].Type != tokenizer.Backtick {
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
content, matched := "", false
|
||||
for _, token := range tokens[1:] {
|
||||
if token.Type == tokenizer.Newline {
|
||||
return nil
|
||||
}
|
||||
if token.Type == tokenizer.Backtick {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
content += token.Value
|
||||
}
|
||||
if !matched || len(content) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &CodeParser{
|
||||
Content: content,
|
||||
nextBacktickIndex := tokenizer.FindUnescaped(matchedTokens[1:], tokenizer.Backtick)
|
||||
if nextBacktickIndex < 0 {
|
||||
return nil, 0
|
||||
}
|
||||
matchedTokens = matchedTokens[:1+nextBacktickIndex+1]
|
||||
return &ast.Code{
|
||||
Content: tokenizer.Stringify(matchedTokens[1 : len(matchedTokens)-1]),
|
||||
}, len(matchedTokens)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package parser
|
||||
|
||||
import "github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/usememos/memos/plugin/gomark/ast"
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
)
|
||||
|
||||
type CodeBlockParser struct {
|
||||
Language string
|
||||
@@ -11,42 +16,57 @@ func NewCodeBlockParser() *CodeBlockParser {
|
||||
return &CodeBlockParser{}
|
||||
}
|
||||
|
||||
func (*CodeBlockParser) Match(tokens []*tokenizer.Token) *CodeBlockParser {
|
||||
if len(tokens) < 9 {
|
||||
return nil
|
||||
func (*CodeBlockParser) Match(tokens []*tokenizer.Token) (ast.Node, int) {
|
||||
rows := tokenizer.Split(tokens, tokenizer.NewLine)
|
||||
if len(rows) < 3 {
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
if tokens[0].Type != tokenizer.Backtick || tokens[1].Type != tokenizer.Backtick || tokens[2].Type != tokenizer.Backtick {
|
||||
return nil
|
||||
firstRow := rows[0]
|
||||
if len(firstRow) < 3 {
|
||||
return nil, 0
|
||||
}
|
||||
if tokens[3].Type != tokenizer.Newline && tokens[4].Type != tokenizer.Newline {
|
||||
return nil
|
||||
if firstRow[0].Type != tokenizer.Backtick || firstRow[1].Type != tokenizer.Backtick || firstRow[2].Type != tokenizer.Backtick {
|
||||
return nil, 0
|
||||
}
|
||||
cursor, language := 4, ""
|
||||
if tokens[3].Type != tokenizer.Newline {
|
||||
language = tokens[3].Value
|
||||
cursor = 5
|
||||
}
|
||||
|
||||
content, 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 {
|
||||
matched = true
|
||||
break
|
||||
} else if tokens[cursor+4].Type == tokenizer.Newline {
|
||||
matched = true
|
||||
break
|
||||
languageTokens := []*tokenizer.Token{}
|
||||
if len(firstRow) > 3 {
|
||||
languageTokens = firstRow[3:]
|
||||
// Check if language is valid.
|
||||
availableLanguageTokenTypes := []tokenizer.TokenType{tokenizer.Text, tokenizer.Number, tokenizer.Underscore}
|
||||
for _, token := range languageTokens {
|
||||
if !slices.Contains(availableLanguageTokenTypes, token.Type) {
|
||||
return nil, 0
|
||||
}
|
||||
}
|
||||
content += tokens[cursor].Value
|
||||
}
|
||||
if !matched {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &CodeBlockParser{
|
||||
Language: language,
|
||||
Content: content,
|
||||
contentRows := [][]*tokenizer.Token{}
|
||||
matched := false
|
||||
for _, row := range rows[1:] {
|
||||
if len(row) == 3 && row[0].Type == tokenizer.Backtick && row[1].Type == tokenizer.Backtick && row[2].Type == tokenizer.Backtick {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
contentRows = append(contentRows, row)
|
||||
}
|
||||
if !matched {
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
contentTokens := []*tokenizer.Token{}
|
||||
for index, row := range contentRows {
|
||||
contentTokens = append(contentTokens, row...)
|
||||
if index != len(contentRows)-1 {
|
||||
contentTokens = append(contentTokens, &tokenizer.Token{
|
||||
Type: tokenizer.NewLine,
|
||||
Value: "\n",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return &ast.CodeBlock{
|
||||
Content: tokenizer.Stringify(contentTokens),
|
||||
Language: tokenizer.Stringify(languageTokens),
|
||||
}, 4 + len(languageTokens) + len(contentTokens) + 4
|
||||
}
|
||||
|
||||
@@ -5,13 +5,15 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/usememos/memos/plugin/gomark/ast"
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
"github.com/usememos/memos/plugin/gomark/restore"
|
||||
)
|
||||
|
||||
func TestCodeBlockParser(t *testing.T) {
|
||||
tests := []struct {
|
||||
text string
|
||||
codeBlock *CodeBlockParser
|
||||
codeBlock ast.Node
|
||||
}{
|
||||
{
|
||||
text: "```Hello world!```",
|
||||
@@ -19,21 +21,21 @@ func TestCodeBlockParser(t *testing.T) {
|
||||
},
|
||||
{
|
||||
text: "```\nHello\n```",
|
||||
codeBlock: &CodeBlockParser{
|
||||
codeBlock: &ast.CodeBlock{
|
||||
Language: "",
|
||||
Content: "Hello",
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "```\nHello world!\n```",
|
||||
codeBlock: &CodeBlockParser{
|
||||
codeBlock: &ast.CodeBlock{
|
||||
Language: "",
|
||||
Content: "Hello world!",
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "```java\nHello \n world!\n```",
|
||||
codeBlock: &CodeBlockParser{
|
||||
codeBlock: &ast.CodeBlock{
|
||||
Language: "java",
|
||||
Content: "Hello \n world!",
|
||||
},
|
||||
@@ -48,7 +50,7 @@ func TestCodeBlockParser(t *testing.T) {
|
||||
},
|
||||
{
|
||||
text: "```java\nHello \n world!\n```\n123123",
|
||||
codeBlock: &CodeBlockParser{
|
||||
codeBlock: &ast.CodeBlock{
|
||||
Language: "java",
|
||||
Content: "Hello \n world!",
|
||||
},
|
||||
@@ -57,7 +59,7 @@ func TestCodeBlockParser(t *testing.T) {
|
||||
|
||||
for _, test := range tests {
|
||||
tokens := tokenizer.Tokenize(test.text)
|
||||
codeBlock := NewCodeBlockParser()
|
||||
require.Equal(t, test.codeBlock, codeBlock.Match(tokens))
|
||||
node, _ := NewCodeBlockParser().Match(tokens)
|
||||
require.Equal(t, restore.Restore([]ast.Node{test.codeBlock}), restore.Restore([]ast.Node{node}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,15 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/usememos/memos/plugin/gomark/ast"
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
"github.com/usememos/memos/plugin/gomark/restore"
|
||||
)
|
||||
|
||||
func TestCodeParser(t *testing.T) {
|
||||
tests := []struct {
|
||||
text string
|
||||
code *CodeParser
|
||||
code ast.Node
|
||||
}{
|
||||
{
|
||||
text: "`Hello world!",
|
||||
@@ -19,7 +21,7 @@ func TestCodeParser(t *testing.T) {
|
||||
},
|
||||
{
|
||||
text: "`Hello world!`",
|
||||
code: &CodeParser{
|
||||
code: &ast.Code{
|
||||
Content: "Hello world!",
|
||||
},
|
||||
},
|
||||
@@ -31,7 +33,7 @@ func TestCodeParser(t *testing.T) {
|
||||
|
||||
for _, test := range tests {
|
||||
tokens := tokenizer.Tokenize(test.text)
|
||||
code := NewCodeParser()
|
||||
require.Equal(t, test.code, code.Match(tokens))
|
||||
node, _ := NewCodeParser().Match(tokens)
|
||||
require.Equal(t, restore.Restore([]ast.Node{test.code}), restore.Restore([]ast.Node{node}))
|
||||
}
|
||||
}
|
||||
|
||||
43
plugin/gomark/parser/embedded_content.go
Normal file
43
plugin/gomark/parser/embedded_content.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"github.com/usememos/memos/plugin/gomark/ast"
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
)
|
||||
|
||||
type EmbeddedContentParser struct{}
|
||||
|
||||
func NewEmbeddedContentParser() *EmbeddedContentParser {
|
||||
return &EmbeddedContentParser{}
|
||||
}
|
||||
|
||||
func (*EmbeddedContentParser) Match(tokens []*tokenizer.Token) (ast.Node, int) {
|
||||
matchedTokens := tokenizer.GetFirstLine(tokens)
|
||||
if len(matchedTokens) < 6 {
|
||||
return nil, 0
|
||||
}
|
||||
if matchedTokens[0].Type != tokenizer.ExclamationMark || matchedTokens[1].Type != tokenizer.LeftSquareBracket || matchedTokens[2].Type != tokenizer.LeftSquareBracket {
|
||||
return nil, 0
|
||||
}
|
||||
matched := false
|
||||
for index, token := range matchedTokens[:len(matchedTokens)-1] {
|
||||
if token.Type == tokenizer.RightSquareBracket && matchedTokens[index+1].Type == tokenizer.RightSquareBracket && index+1 == len(matchedTokens)-1 {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
contentTokens := matchedTokens[3 : len(matchedTokens)-2]
|
||||
resourceName, params := tokenizer.Stringify(contentTokens), ""
|
||||
questionMarkIndex := tokenizer.FindUnescaped(contentTokens, tokenizer.QuestionMark)
|
||||
if questionMarkIndex > 0 {
|
||||
resourceName, params = tokenizer.Stringify(contentTokens[:questionMarkIndex]), tokenizer.Stringify(contentTokens[questionMarkIndex+1:])
|
||||
}
|
||||
return &ast.EmbeddedContent{
|
||||
ResourceName: resourceName,
|
||||
Params: params,
|
||||
}, len(matchedTokens)
|
||||
}
|
||||
65
plugin/gomark/parser/embedded_content_test.go
Normal file
65
plugin/gomark/parser/embedded_content_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/usememos/memos/plugin/gomark/ast"
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
"github.com/usememos/memos/plugin/gomark/restore"
|
||||
)
|
||||
|
||||
func TestEmbeddedContentParser(t *testing.T) {
|
||||
tests := []struct {
|
||||
text string
|
||||
embeddedContent ast.Node
|
||||
}{
|
||||
{
|
||||
text: "![[Hello world]",
|
||||
embeddedContent: nil,
|
||||
},
|
||||
{
|
||||
text: "![[Hello world]]",
|
||||
embeddedContent: &ast.EmbeddedContent{
|
||||
ResourceName: "Hello world",
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "![[memos/1]]",
|
||||
embeddedContent: &ast.EmbeddedContent{
|
||||
ResourceName: "memos/1",
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "![[resources/101]] \n123",
|
||||
embeddedContent: nil,
|
||||
},
|
||||
{
|
||||
text: "![[resources/101]]\n123",
|
||||
embeddedContent: &ast.EmbeddedContent{
|
||||
ResourceName: "resources/101",
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "![[resources/101?align=center]]\n123",
|
||||
embeddedContent: &ast.EmbeddedContent{
|
||||
ResourceName: "resources/101",
|
||||
Params: "align=center",
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "![[resources/6uxnhT98q8vN8anBbUbRGu?align=center]]",
|
||||
embeddedContent: &ast.EmbeddedContent{
|
||||
ResourceName: "resources/6uxnhT98q8vN8anBbUbRGu",
|
||||
Params: "align=center",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
tokens := tokenizer.Tokenize(test.text)
|
||||
node, _ := NewEmbeddedContentParser().Match(tokens)
|
||||
require.Equal(t, restore.Restore([]ast.Node{test.embeddedContent}), restore.Restore([]ast.Node{node}))
|
||||
}
|
||||
}
|
||||
27
plugin/gomark/parser/escaping_character.go
Normal file
27
plugin/gomark/parser/escaping_character.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"github.com/usememos/memos/plugin/gomark/ast"
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
)
|
||||
|
||||
type EscapingCharacterParser struct{}
|
||||
|
||||
func NewEscapingCharacterParser() *EscapingCharacterParser {
|
||||
return &EscapingCharacterParser{}
|
||||
}
|
||||
|
||||
func (*EscapingCharacterParser) Match(tokens []*tokenizer.Token) (ast.Node, int) {
|
||||
if len(tokens) < 2 {
|
||||
return nil, 0
|
||||
}
|
||||
if tokens[0].Type != tokenizer.Backslash {
|
||||
return nil, 0
|
||||
}
|
||||
if tokens[1].Type == tokenizer.NewLine || tokens[1].Type == tokenizer.Space || tokens[1].Type == tokenizer.Text || tokens[1].Type == tokenizer.Number {
|
||||
return nil, 0
|
||||
}
|
||||
return &ast.EscapingCharacter{
|
||||
Symbol: tokens[1].Value,
|
||||
}, 2
|
||||
}
|
||||
31
plugin/gomark/parser/escaping_character_test.go
Normal file
31
plugin/gomark/parser/escaping_character_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/usememos/memos/plugin/gomark/ast"
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
"github.com/usememos/memos/plugin/gomark/restore"
|
||||
)
|
||||
|
||||
func TestEscapingCharacterParser(t *testing.T) {
|
||||
tests := []struct {
|
||||
text string
|
||||
node ast.Node
|
||||
}{
|
||||
{
|
||||
text: `\# 123`,
|
||||
node: &ast.EscapingCharacter{
|
||||
Symbol: "#",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
tokens := tokenizer.Tokenize(test.text)
|
||||
node, _ := NewEscapingCharacterParser().Match(tokens)
|
||||
require.Equal(t, restore.Restore([]ast.Node{test.node}), restore.Restore([]ast.Node{node}))
|
||||
}
|
||||
}
|
||||
@@ -1,53 +1,44 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"github.com/usememos/memos/plugin/gomark/ast"
|
||||
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
|
||||
)
|
||||
|
||||
type HeadingParser struct {
|
||||
Level int
|
||||
ContentTokens []*tokenizer.Token
|
||||
}
|
||||
type HeadingParser struct{}
|
||||
|
||||
func NewHeadingParser() *HeadingParser {
|
||||
return &HeadingParser{}
|
||||
}
|
||||
|
||||
func (*HeadingParser) Match(tokens []*tokenizer.Token) *HeadingParser {
|
||||
cursor := 0
|
||||
for _, token := range tokens {
|
||||
if token.Type == tokenizer.Hash {
|
||||
cursor++
|
||||
} else {
|
||||
break
|
||||
func (*HeadingParser) Match(tokens []*tokenizer.Token) (ast.Node, int) {
|
||||
matchedTokens := tokenizer.GetFirstLine(tokens)
|
||||
spaceIndex := tokenizer.FindUnescaped(matchedTokens, tokenizer.Space)
|
||||
if spaceIndex < 0 {
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
for _, token := range matchedTokens[:spaceIndex] {
|
||||
if token.Type != tokenizer.PoundSign {
|
||||
return nil, 0
|
||||
}
|
||||
}
|
||||
if len(tokens) <= cursor+1 {
|
||||
return nil
|
||||
}
|
||||
if tokens[cursor].Type != tokenizer.Space {
|
||||
return nil
|
||||
}
|
||||
level := cursor
|
||||
level := spaceIndex
|
||||
if level == 0 || level > 6 {
|
||||
return nil
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
cursor++
|
||||
contentTokens := []*tokenizer.Token{}
|
||||
for _, token := range tokens[cursor:] {
|
||||
if token.Type == tokenizer.Newline {
|
||||
break
|
||||
}
|
||||
contentTokens = append(contentTokens, token)
|
||||
cursor++
|
||||
}
|
||||
contentTokens := matchedTokens[level+1:]
|
||||
if len(contentTokens) == 0 {
|
||||
return nil
|
||||
return nil, 0
|
||||
}
|
||||
children, err := ParseInline(contentTokens)
|
||||
if err != nil {
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
return &HeadingParser{
|
||||
Level: level,
|
||||
ContentTokens: contentTokens,
|
||||
}
|
||||
return &ast.Heading{
|
||||
Level: level,
|
||||
Children: children,
|
||||
}, len(contentTokens) + level + 1
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user