Compare commits

...

574 Commits

Author SHA1 Message Date
Steven
f05a89315c chore: fix list memos 2024-01-28 08:38:29 +08:00
Steven
a4452d8a2f chore: update linter rules 2024-01-28 08:17:11 +08:00
Steven
5e74394643 chore: add resource name migrator 2024-01-28 07:58:53 +08:00
Steven
f4e722c516 chore: remove latest tag 2024-01-28 07:46:08 +08:00
Steven
12275c6a34 chore: fix linter warning 2024-01-28 07:38:01 +08:00
Steven
21ef5a9bc0 chore: tweak workspace service 2024-01-28 07:35:42 +08:00
Steven
87b23940a6 chore: upgrade backend dependencies 2024-01-28 07:13:11 +08:00
Steven
11dd23f59b chore: tweak link checks 2024-01-28 07:04:35 +08:00
Lincoln Nogueira
887903b66b feat: add buf plugin to generate openapiv2 spec (#2843) 2024-01-28 06:16:37 +08:00
Steven
309fab222e chore: implement nested blockquote 2024-01-27 21:38:07 +08:00
Steven
1dc4f02b64 chore: update memo requests 2024-01-27 20:35:48 +08:00
Steven
8db90a040c chore: remove unused dependencies 2024-01-27 20:20:36 +08:00
Steven
932f636d84 chore: update codeblock renderer 2024-01-27 19:09:10 +08:00
Steven
ed32b20c9e chore: update frontend dependencies 2024-01-27 17:28:06 +08:00
Steven
10d709c167 chore: fix highlight cursor 2024-01-27 12:35:01 +08:00
Steven
8455114eef chore: fix list memos request 2024-01-27 11:17:59 +08:00
Steven
c26109cd36 chore: update list memos request 2024-01-27 11:14:17 +08:00
Steven
4b223c1e4c chore: update collapse sidebar 2024-01-27 05:27:42 +08:00
Steven
b9cbe6626f chore: update tag rename 2024-01-27 05:26:32 +08:00
Steven
566171783d chore: tweak embedded memo style 2024-01-26 23:33:40 +08:00
Steven
7edb3598ea chore: update default request limit 2024-01-26 23:03:10 +08:00
Steven
bc2d2d0cde feat: support set embedded content in UI 2024-01-26 22:51:57 +08:00
Steven
e1977df14b chore: remove check underscores 2024-01-26 21:23:36 +08:00
Wei Zhang
ddc89029b7 fix: use mysql to parse dsn (#2838)
Signed-off-by: Zhang Wei <kweizh@gmail.com>
2024-01-26 13:43:48 +08:00
Steven
f8b9a83d4a chore: tweak default value 2024-01-26 09:22:47 +08:00
Steven
2f16b7065a chore: tweak scripts 2024-01-26 09:15:27 +08:00
Steven
e5ff1829a5 chore: add Hungarian locale 2024-01-26 08:44:12 +08:00
Steven
d7889d9903 chore: tweak url filters 2024-01-26 08:39:53 +08:00
Steven
db3457e081 chore: bump version 2024-01-26 08:30:22 +08:00
Steven
4f2b00b4f3 chore: add migration scripts 2024-01-26 08:29:11 +08:00
Steven
79558028c0 feat: implement rename tag 2024-01-25 23:09:35 +08:00
Steven
70d1301dc3 chore: use filter in url params 2024-01-25 20:05:47 +08:00
Steven
6d5e1def76 chore: update member section 2024-01-25 19:49:39 +08:00
boojack
a5bc2d0ed6 chore: update i18n from Weblate (#2832)
* Added translation using Weblate (Hungarian)

* Translated using Weblate (Hungarian)

Currently translated at 0.3% (1 of 317 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/hu/

* Translated using Weblate (Hungarian)

Currently translated at 71.2% (226 of 317 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/hu/

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (316 of 316 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/pt_BR/

* Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (316 of 316 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/zh_Hant/

---------

Co-authored-by: Vermunds <com.github@weylus.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Lincoln Nogueira <lincolnthalles@users.noreply.github.com>
Co-authored-by: ti777777 <ti12272198686@yahoo.com.tw>
2024-01-25 19:36:02 +08:00
Steven
08ac60cc70 chore: update memo relation dialog 2024-01-25 08:59:10 +08:00
Mudkip
f654d3c90e fix: encode filename when using url prefix for resources (#2829)
* fix: encode filename when using url prefix for resources

* fix: only encode the last parts of filename

* fix: encode all parts in filepath
2024-01-24 11:28:26 +08:00
Steven
1b69b73eb9 chore: update calendar styles 2024-01-23 23:15:54 +08:00
Steven
3dbb254aeb chore: update referenced memo title from params 2024-01-23 21:54:15 +08:00
Steven
fdb1779a59 chore: implement referenced content renderer 2024-01-23 21:47:17 +08:00
Steven
a316e239ce chore: implement referenced content node 2024-01-23 21:40:59 +08:00
Steven
d7f02b94e5 chore: fix linter 2024-01-23 21:27:05 +08:00
Steven
d165d87288 refactor: markdown parser matchers 2024-01-23 21:23:40 +08:00
Steven
bf905bba86 chore: remove unused date picker 2024-01-22 22:50:27 +08:00
Brilliant Hanabi
3a129d5cfb fix: avoid making memos public when disabled (#2816)
* fix: avoid making memos public when disabled in v2

* fix: avoid making memos public when disabled in v1
2024-01-22 20:51:33 +08:00
ti777777
024a818e91 chore: update zh-Hant (#2815) 2024-01-21 23:50:44 +08:00
Steven
54a24833a7 chore: fix resource seeds 2024-01-21 21:36:31 +08:00
Steven
a620d140c5 chore: update embedded content renderers 2024-01-21 21:27:04 +08:00
Steven
370054e040 chore: implement collapsed navigation 2024-01-21 21:02:55 +08:00
Steven
fae0b4e900 chore: update memo relation style 2024-01-21 11:22:25 +08:00
Steven
c38404b5d5 chore: tweak get memo by name 2024-01-21 10:57:53 +08:00
Steven
4d48f50815 chore: use resource name in frontend 2024-01-21 10:55:49 +08:00
Steven
582cc6609c feat: add user-defined id to resource 2024-01-21 10:49:30 +08:00
Steven
40bd75c725 fix: create memo with resource name 2024-01-21 10:33:31 +08:00
Steven
b2fc3076f6 chore: update memo store 2024-01-21 01:23:55 +08:00
Steven
288527914b chore: migrate memo route 2024-01-21 01:23:44 +08:00
Steven
a2aea3747c chore: remove server tests 2024-01-20 23:51:24 +08:00
Steven
8382354ef7 feat: add user-defined name to memo 2024-01-20 23:48:35 +08:00
Steven
264e6e6e9c chore: tweak file name 2024-01-20 23:47:04 +08:00
Steven
eb72609ea3 chore: update memo editor cache key 2024-01-20 20:52:11 +08:00
Brilliant Hanabi
776785ac90 chore: update zh-Hans & zh-Hant translations (#2804) 2024-01-20 20:27:20 +08:00
Steven
d5f874e185 chore: handle resource not found 2024-01-20 12:47:43 +08:00
Steven
89d940d9b7 feat: implement params field for embedded content node 2024-01-20 12:41:08 +08:00
Mark Zhao
bd1cf62761 feat: enable iframe rendering in markdown code block (#2799)
* enable iframe rendering in code block

* fix eslint issue
2024-01-20 11:36:45 +08:00
Steven
196facfacd feat: implement embedded resource renderer 2024-01-20 09:17:31 +08:00
Steven
afe75fd9f2 chore: fix tokens split tests 2024-01-20 02:09:33 +08:00
Steven
8a34013558 feat: implement embedded memo renderer 2024-01-20 01:56:10 +08:00
Steven
67f5ac3657 feat: implement subscript and superscript renderer 2024-01-19 23:10:16 +08:00
Steven
7236552b6c feat: implement subscript and superscript parsers 2024-01-19 23:06:22 +08:00
Steven
1f5899d238 chore: update dependencies 2024-01-19 19:20:59 +08:00
Wen Sun
ec4884ea04 fix: incorrect timeline month display (#2792)
Fix incorrect timeline month display
2024-01-19 18:07:08 +08:00
Elliot Chen
2e0619b4dc feat: add a webhook action for deleted memos (#2791) 2024-01-19 09:56:00 +08:00
Steven
c9146bc749 chore: update code style 2024-01-19 07:06:28 +08:00
Steven
f5b5bd64bc chore: tweak datetime.ts 2024-01-18 22:18:56 +08:00
Steven
d31d9eb71c chore: remove unused nil checks 2024-01-18 19:23:45 +08:00
Steven
f28b654057 chore: fix setting nil checks 2024-01-18 19:20:48 +08:00
Steven
8738b68a44 chore: tweak readme 2024-01-18 19:06:49 +08:00
Steven
42381fa154 chore: add stable build action 2024-01-18 18:49:06 +08:00
Steven
22427101f8 chore: add stale issue action 2024-01-18 16:18:18 +08:00
Steven
2a4ebf5774 chore: add search bar in archived page 2024-01-18 15:28:37 +08:00
Steven
5172e4df7c chore: create memo visibility when creating 2024-01-18 15:20:22 +08:00
Steven
893dd2c85e chore: add disable filter to renderer context 2024-01-18 14:52:57 +08:00
Steven
d426f89cf0 chore: add time filter to timeline 2024-01-18 14:30:20 +08:00
Steven
7de3de5610 chore: fix go test warning 2024-01-18 11:45:59 +08:00
Steven
2856e66609 chore: fix go test 2024-01-18 11:42:57 +08:00
Steven
354011f994 chore: handle filter in user profile page 2024-01-18 11:38:45 +08:00
Steven
8ed827cd2d chore: update table node delimiter 2024-01-18 11:36:13 +08:00
Steven
05c0aeb789 feat: implement table renderer 2024-01-18 10:49:28 +08:00
Steven
aecffe3402 feat: implement table parser 2024-01-18 10:21:08 +08:00
Mehad Nadeem
70e6b2bb82 chore: added tooltip for vacuum button (#2778)
* chore: added tooltip for vacuum button

- At the moment only has text for English language therefore other JSON files need to be updated accordingly

* Update en.json
2024-01-18 08:09:49 +08:00
Steven
54296f0437 chore: update filter in timeline 2024-01-18 08:06:59 +08:00
Steven
8fcd9332f7 chore: tweak timeline styles 2024-01-17 22:32:58 +08:00
Steven
1857362d03 fix: locale and appearance setting 2024-01-17 22:16:12 +08:00
Steven
6d7186fc81 feat: rebuild timeline page 2024-01-17 21:59:49 +08:00
Wen Sun
e4488da96e fix: signup is not allowed if password login is disabled (#2776)
Signup is not allowed if password login is disabled

If password login is disabled in the system configuration, the "signup" in the "/auth" page disappears, but the user can manually enter "/auth/signup" to access the system by creating a new user.
2024-01-17 10:49:22 +08:00
Steven
cc43d06d33 chore: update memo stats response 2024-01-17 09:17:33 +08:00
Steven
9ffd827028 fix: appearance and locale initial value 2024-01-16 22:53:45 +08:00
Hanqin Guan
15e6542f0d fix: server overrides user's locale/appearance (#2771) 2024-01-16 18:21:08 +08:00
Noah Alderton
24bb3e096a fix: DatePicker by passing in Timezone to API (#2770)
* Fix DatePicker by passing in Timezone to API

* Add some clarity
2024-01-16 18:02:09 +08:00
Lincoln Nogueira
5bcbbd4c52 chore: fix store tests on Windows (#2769)
It's just a matter of explicitly closing the database, so that TempDir.removeAll doesn't fail.
2024-01-16 13:51:26 +08:00
Noah Alderton
ff13d977e9 feat: add URL paste handler (#2768)
* feat: Add URL paste handler

* Check if text highlighted for URL pasting
2024-01-16 10:06:16 +08:00
Wen Sun
1fdb8b7b01 fix: apply customized profile name in mobile header (#2723)
Fix mobile header title, apply customized profile name
2024-01-16 09:04:03 +08:00
Wen Sun
f1ee88c4e1 fix: display system logo in user banner if user not logged in (#2747)
Display system logo in user banner if user not logged in
2024-01-16 09:03:27 +08:00
Noah Alderton
b578afbc6a fix: DatePicker Local Date (#2766)
Fix DatePicker Local Date
2024-01-16 08:35:48 +08:00
Steven
ad94e8e3c6 feat: implement highlight renderer 2024-01-15 22:54:18 +08:00
Steven
3f4b361fad feat: implement highlight parser 2024-01-15 22:30:06 +08:00
Steven
46bd470640 chore: update favicon 2024-01-15 21:10:41 +08:00
Steven
fdbf2d8af2 chore: fix blockquote renderer 2024-01-15 20:41:37 +08:00
Steven
5a723f00fa chore: split editor keydown handler 2024-01-15 20:33:42 +08:00
Noah Alderton
728a9705ea feat: Markdown Editor Keyboard Shortcuts (#2763)
* Add bold and italic keyboard shortcut

* Add hyperlink keyboard shortcut support
2024-01-15 20:19:59 +08:00
THELOSTSOUL
cd3a98c095 fix: change use-set priority (#2760)
The user settings(locale, appearance) are not in use when restart broswer
2024-01-15 20:08:14 +08:00
Wen Sun
a22ad90174 fix: set memo resources error in mysql (#2761)
Fix error updating memo resources in mysql
2024-01-15 20:05:07 +08:00
Steven
5ebbed9115 chore: handle tag click 2024-01-15 08:15:34 +08:00
Steven
7ae4299df2 chore: implement create resource 2024-01-15 08:13:06 +08:00
Noah Alderton
3d23c01e26 feat: add additional favicon formats (#2752)
Add additional favicons
2024-01-15 07:54:33 +08:00
Lincoln Nogueira
089e04bcfd chore: use webp compression on logo (#2756)
- Logo size reduced from 310 KB to 36 KB.
- Point metadata image URL to local logo instead of remote
2024-01-14 22:21:03 +08:00
Steven
98762be1e5 feat: implement indent for list nodes 2024-01-14 22:19:03 +08:00
Steven
d44e74bd1e chore: update editor actions 2024-01-14 21:47:03 +08:00
Steven
8e0ce4d678 fix: list memos with pinned 2024-01-14 20:51:52 +08:00
Steven
45cf158508 chore: fix max width of home section 2024-01-14 20:25:45 +08:00
Anish Kelkar
7340ae15f7 chore: delete .vscode directory (#2693)
* Delete .vscode directory

* web/.vscode deleted
2024-01-14 20:12:57 +08:00
Steven
6db7ad76da chore: update tag selector 2024-01-13 16:26:42 +08:00
Steven
4a407668bc chore: tweak dialog close button 2024-01-13 15:33:23 +08:00
Steven
ab1fa44f00 feat: implement markdown buttons 2024-01-13 15:09:06 +08:00
Steven
cd0004cf88 chore: update icon version 2024-01-13 15:08:58 +08:00
Steven
667aaf06a0 chore: update dependencies 2024-01-13 14:33:50 +08:00
Steven
a8074d94e8 chore: update image attrs 2024-01-13 14:08:36 +08:00
Hanqin Guan
16e68fbfff fix: duplicated/reflexive relation in v2 api. (#2750) 2024-01-13 07:21:19 +08:00
Wen Sun
81942b3b98 chore: updating the default scopes of GitHub SSO (#2746)
Updating the default scopes of GitHub SSO

The scope of "user" in GitHub OAuth includes permissions to update a user's profile.
2024-01-12 14:02:11 +08:00
Athurg Gooth
a7cda28fc7 fix: filename with space (#2745) 2024-01-12 14:01:19 +08:00
Steven
0c52f1ee6a chore: tweak home style 2024-01-12 08:08:24 +08:00
Steven
1994c20c54 chore: tweak setting page 2024-01-11 22:25:05 +08:00
Steven
a1dda913c3 chore: fix tag selector position 2024-01-11 21:36:22 +08:00
Athurg Gooth
d626de1875 fix: pnpm install failed in docker (#2732)
fix pnpm install failed in docker
2024-01-11 21:29:55 +08:00
Wen Sun
6cfd94cc69 fix: deleting inbox records that senders have been deleted (#2743)
Deleting inbox records that senders have been deleted
2024-01-11 21:29:22 +08:00
Athurg Gooth
79b68222ff chore: set image loading to lazy (#2733)
set image loading to lazy to avoid concurrent problem
2024-01-11 10:27:18 +08:00
Steven
aaec46a39c chore: update find memo with updated time 2024-01-10 00:10:59 +08:00
Steven
9c663b1ba2 fix: merge mysql dsn with params 2024-01-10 00:03:47 +08:00
Steven
777ed899a3 chore: add memo uid 2024-01-08 21:48:26 +08:00
Steven
ddcf1d669d chore: add max content length 2024-01-08 21:17:21 +08:00
Steven
32d02ba022 chore: fix horizontal rule matcher 2024-01-08 21:00:45 +08:00
Steven
5449342016 fix: auto link converters 2024-01-08 20:57:44 +08:00
Noah Alderton
43e42079a4 feat: export Memos as Markdown FIles (#2716) 2024-01-08 11:40:50 +08:00
Steven
cafa7c5adc chore: update backend dependencies 2024-01-06 19:46:35 +08:00
Steven
1258c5a5b0 chore: update workspace setting proto 2024-01-06 19:46:21 +08:00
Steven
83141f9be2 chore: tweak navigation styles 2024-01-06 19:12:26 +08:00
Steven
4c59035757 chore: update about page 2024-01-06 19:01:11 +08:00
Steven
9459ae8265 chore: update postgres stmt builder 2024-01-06 17:12:10 +08:00
Steven
8893a302e2 chore: update logs 2024-01-06 16:58:58 +08:00
Steven
d67eaaaee2 chore: update database migrator 2024-01-06 16:55:13 +08:00
Steven
fd8333eeda chore: fix memo parent_id 2024-01-06 13:22:02 +08:00
Steven
f5a1739472 chore: update memo detail checks 2024-01-06 10:03:36 +08:00
Steven
a297cc3140 chore: exclude comments in memo list response 2024-01-06 09:48:11 +08:00
Steven
79c13c6f83 chore: fix edit memo params 2024-01-06 09:25:17 +08:00
Steven
8b9455d784 chore: fix memo resources position 2024-01-06 09:23:20 +08:00
Steven
501f8898f6 chore: fix postgres stmts 2024-01-05 21:27:16 +08:00
Steven
ee13927607 chore: fix restore tag node 2024-01-05 18:50:28 +08:00
Steven
d2a9aaa9d4 chore: update line break renderer 2024-01-05 09:18:37 +08:00
Steven
f563b58a85 chore: fix renderer props 2024-01-05 08:47:43 +08:00
Steven
ce2d37b90c chore: fix find sibling node 2024-01-05 08:43:30 +08:00
Steven
454cd4e24f feat: implement switchable task list node 2024-01-05 08:40:16 +08:00
Steven
6320d042c8 chore: update home padding styles 2024-01-04 22:50:46 +08:00
Steven
d7ed59581c chore: fix math block matcher 2024-01-04 21:50:13 +08:00
Steven
9593b0b091 chore: fix link rel field 2024-01-04 21:26:56 +08:00
Steven
ca53630410 chore: update drawer background 2024-01-04 21:11:22 +08:00
Steven
f484c38745 chore: fix dependencies 2024-01-04 20:07:53 +08:00
Steven
d12a2b0c38 feat: implement math expression parser 2024-01-04 20:05:29 +08:00
Steven
c842b921bc chore: update backend dependencies 2024-01-04 19:15:25 +08:00
Wen Sun
6b2eec86c2 fix: image upload failed with cloudflare R2 (#2704)
Fix image upload failed with cloudflare R2
2024-01-04 19:08:54 +08:00
Steven
2eba4e2cd4 chore: update version 2024-01-04 08:36:45 +08:00
Steven
73baeaa0ad chore: tweak dark mode styles 2024-01-04 08:32:14 +08:00
Steven
c58851bc97 chore: tweak accent color 2024-01-03 23:30:28 +08:00
Steven
96140f3875 chore: tweak dark mode styles 2024-01-03 23:12:50 +08:00
Lincoln Nogueira
369b8af109 chore: improve resource internal_path migrator (#2698)
* chore: improve internal path migrator
- handle mixed path styles
- handle Windows paths
- add tests

* chore: fix goimports error
2024-01-03 08:31:59 +08:00
Steven
914c0620c4 chore: add statistics view 2024-01-03 08:22:32 +08:00
Steven
138b69e36e chore: fix memo comment 2024-01-03 08:19:38 +08:00
Elliot Chen
3181c076b2 feat: add {uuid} in path template when using local storage or S3 (#2696)
Add {uuid} in path template when using local storage or S3

Add an addition tag `{uuid}` to the `replacePathTemplate`.

It is a workaround to leak the public links of a resource when using S3-based object storage. Currently, all resource blobs stored in S3 (R2, OSS) are set to be public. It is insecure as the resources for the private memos are also accessible on the Internet. Using an additional {uuid} might reduce this risk.

Meanwhile, it is also possible to avoid filename conflict
2024-01-02 20:57:55 +08:00
Noah Alderton
673809e07d fix: docker-compose.dev.yaml (#2695)
* Fix docker-compose.dev.yaml

* Add newline to .gitignore
2024-01-02 18:33:19 +08:00
Steven
f74fa97b4a chore: traverse nodes to upsert tags 2024-01-02 08:56:30 +08:00
Steven
c797099950 chore: update resource internal path migrator 2024-01-02 08:29:18 +08:00
Steven
0f8bfb6328 chore: update index.html 2023-12-29 08:28:17 +08:00
Steven
4cd01ece30 chore: update frontend metadata 2023-12-29 08:19:32 +08:00
Lincoln Nogueira
14b34edca3 chore: fix misuse of package path instead of filepath.path (#2684)
As stated by https://pkg.go.dev/path, "path" is mainly for URLs, "path.filepath" for file systems
2023-12-29 07:50:15 +08:00
Lincoln Nogueira
411e807dcc chore: use consistent relative paths for resources (#2683)
- always store resources with a relative path with forward slashes, which will be transformed as needed when the file is accessed

- fix an issue with thumbnail generation on Windows

- add several validations for local storage setting

- improve front-end error feedback when changing local storage

- add migrations to make existing resource paths relative (not needed, but improves database consistency)
2023-12-29 07:49:55 +08:00
Steven
ea87a1dc0c chore: update memo content props 2023-12-28 22:57:51 +08:00
Steven
46f7cffc7b feat: implement restore nodes 2023-12-28 22:35:39 +08:00
Steven
2a6f054876 chore: update auto link parser 2023-12-28 21:32:44 +08:00
Steven
30dca18b79 chore: fix suspense wrapper 2023-12-28 08:34:06 +08:00
Steven
09c195c752 chore: update backend dependencies 2023-12-28 08:28:50 +08:00
Steven
2ae6d94e2c chore: update frontend dependencies 2023-12-28 08:27:06 +08:00
Steven
9ee4b75bbd chore: tweak memo detail styles 2023-12-27 23:25:02 +08:00
Steven
cc40803b06 chore: update feature request template 2023-12-27 09:00:16 +08:00
Steven
a0a03b0389 chore: tweak list memos request 2023-12-27 08:54:00 +08:00
Steven
0dfc367e56 chore: start grpc server 2023-12-27 08:50:02 +08:00
Steven
c8d7f93dca feat: implement auto link parser 2023-12-27 08:44:51 +08:00
Steven
6fac116d8c chore: update user store 2023-12-26 23:05:33 +08:00
subks
f48ff102c9 fix: eslint check failure after fixing #2672 (#2673) 2023-12-26 21:46:26 +08:00
subks
bd5a0679ee fix: date format in share memo dialog (#2672)
fix: Date format in shareable Memo Images (#2668)
2023-12-26 17:50:09 +08:00
Steven
fcfb76a103 chore: remove user urlsets in sitemap 2023-12-23 19:35:46 +08:00
Steven
8e325f9986 chore: return username in user response 2023-12-23 19:23:39 +08:00
Steven
b8eaf1d57e chore: deprecate memo creation stats legacy api 2023-12-23 18:35:47 +08:00
Steven
42608cdd8f chore: fix server context 2023-12-23 17:59:15 +08:00
Steven
2cfa4c3b76 chore: tweak frontend routes register 2023-12-23 17:58:49 +08:00
Steven
aa136a2776 chore: remove vite pwa plugin 2023-12-23 17:42:13 +08:00
Steven
68413a5371 chore: update frontend service 2023-12-23 17:04:52 +08:00
Steven
638f17a02c chore: update scripts 2023-12-23 15:12:25 +08:00
Steven
273d6a6986 chore: update dockerfile 2023-12-23 14:13:40 +08:00
Steven
953141813c chore: regenerate pnpm lock file 2023-12-23 12:01:26 +08:00
Leyang
be2db3f170 feat: use vite plugin pwa for generate right sw.js (#2658)
Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-12-23 12:00:03 +08:00
Steven
eefce6ade3 chore: implement webhook dispatch in v2 api 2023-12-23 11:17:35 +08:00
Steven
c6ebb5552e chore: add 403 page 2023-12-23 10:08:23 +08:00
Steven
4d64d4bf25 chore: upgrade frontend dependencies 2023-12-23 09:47:43 +08:00
Steven
2ee4d7d745 chore: add startTransition to links 2023-12-23 08:55:43 +08:00
Steven
1b81999329 chore: skip invalida setting checks 2023-12-23 08:55:23 +08:00
Steven
df5aeb6d88 chore: remove v1 prefix in store name 2023-12-23 08:48:11 +08:00
Steven
df3303dcd3 chore: update list users 2023-12-23 08:35:54 +08:00
Steven
c267074851 chore: prevent archive/delete current user 2023-12-23 08:05:05 +08:00
Steven
21874d0509 chore: fix navigation errors 2023-12-23 08:01:16 +08:00
Steven
7898df2876 chore: update issue templates 2023-12-23 08:00:59 +08:00
Steven
b2ec0d1217 chore: add skip cache requesting 2023-12-22 20:30:28 +08:00
Steven
5673e29e51 chore: compose memo in backend 2023-12-22 20:18:31 +08:00
Steven
feefaabce9 chore: update heatmap click handler 2023-12-22 20:07:17 +08:00
Steven
29b540ade3 chore: fix fetch memos 2023-12-22 19:47:46 +08:00
Steven
919f75af1a chore: update suspense loading 2023-12-22 19:27:09 +08:00
Steven
17e905085f chore: update acl config 2023-12-22 09:11:55 +08:00
Steven
34af969785 chore: fix list memos order by pinned 2023-12-22 09:09:03 +08:00
Steven
fd9c3ccbae chore: implement useMemoList store 2023-12-22 09:01:30 +08:00
Steven
a3feeceace chore: remove component v1 suffix 2023-12-22 08:29:02 +08:00
Steven
02265a6e1a chore: fix memo search 2023-12-22 00:31:29 +08:00
Steven
81524c38e9 chore: refactor memo module 2023-12-21 23:40:43 +08:00
Steven
671551bdc1 chore: update memo detail page 2023-12-21 22:42:06 +08:00
Steven
10c81ccba3 chore: fix type definition 2023-12-21 21:43:28 +08:00
Steven
9361613f23 chore: update timeline page 2023-12-21 21:24:08 +08:00
Athurg Gooth
b14334220f fix: trim the dirname of attachment send by telegram bot (#2651) 2023-12-21 09:47:57 +08:00
Athurg Gooth
f184d65267 fix: attachments send from telegram lost (#2650) 2023-12-21 09:47:25 +08:00
Steven
b64e2ff6ff chore: implement list memo resources api 2023-12-20 23:46:04 +08:00
Steven
cbdae24314 chore: update archived page 2023-12-20 23:23:26 +08:00
Steven
762cb25227 chore: update memo service 2023-12-20 23:14:15 +08:00
Steven
fc01a796f8 chore: fix demo seed data 2023-12-20 08:18:56 +08:00
Steven
feb700f325 chore: clear access token when user not found 2023-12-20 07:42:02 +08:00
Steven
5334fdf1b2 chore: use api v2 in archived page 2023-12-19 23:49:24 +08:00
Steven
abc14217f6 chore: tweak padding styles 2023-12-19 23:09:57 +08:00
Steven
af68cae6ea chore: regenerate swagger docs 2023-12-19 22:37:07 +08:00
Steven
e0cacfc6d6 chore: retire auto backup for sqlite 2023-12-19 22:34:06 +08:00
Steven
b575064d47 chore: tweak padding 2023-12-19 21:44:40 +08:00
Steven
6290234ad1 chore: fix button styles 2023-12-19 21:29:07 +08:00
Steven
aeed25648a chore: tweak drawer background 2023-12-19 08:55:21 +08:00
Steven
43e7506ed5 chore: fix react-uses import 2023-12-19 08:45:47 +08:00
Steven
a3a1bbe8de chore: tweak responsible styles 2023-12-19 08:41:41 +08:00
Steven
fe4ec0b156 chore: rename navigator 2023-12-19 00:13:22 +08:00
Steven
7c5fdd1b06 chore: remove demo banner 2023-12-18 23:46:48 +08:00
Steven
4d54463aeb chore: add mobile header 2023-12-18 23:33:09 +08:00
Steven
40bc8df63d chore: fix container height 2023-12-18 23:01:39 +08:00
Steven
61de7c8a32 chore: fix demo banner 2023-12-18 22:29:29 +08:00
Steven
d6656db20d feat: implement part of full-screen layout 2023-12-18 22:10:36 +08:00
Steven
15a091fe4c chore: fix offset params in explore page 2023-12-18 20:54:51 +08:00
Steven
d8a0528135 chore: tweak variable names 2023-12-18 20:47:29 +08:00
Steven
16fb5faebd chore: revert go mod update 2023-12-18 19:40:39 +08:00
Steven
2c4b5d75b3 chore: fix html escaping 2023-12-17 23:37:00 +08:00
Steven
770607f93f fix: add markdown service to acl 2023-12-17 11:37:38 +08:00
Steven
db0eff4743 chore: clean frontend dependencies 2023-12-17 11:25:10 +08:00
Steven
0793f96578 chore: update heading styles 2023-12-17 11:08:13 +08:00
Steven
8095d94c97 chore: deprecate marked 2023-12-17 11:02:16 +08:00
Steven
bcfcd59642 chore: deprecate old MemoContent 2023-12-17 10:58:22 +08:00
Steven
5d677c3c57 chore: implement node renderer components 2023-12-17 10:49:49 +08:00
Steven
28c0549705 feat: add markdown service 2023-12-17 09:53:22 +08:00
Steven
bb42042db4 chore: implement task list parser 2023-12-16 12:48:52 +08:00
Steven
1c7fb77e05 chore: update user setting names 2023-12-16 12:18:53 +08:00
Steven
e8ca2ea5a0 chore: rename renderer package 2023-12-16 11:57:36 +08:00
Steven
e43a445c34 chore: implement escaping character node 2023-12-16 11:47:29 +08:00
Steven
1237643028 chore: update parser tests 2023-12-16 11:34:55 +08:00
Steven
aee0e31b0a chore: update parser functions 2023-12-16 10:38:05 +08:00
Steven
47af632c79 chore: update inline parser 2023-12-16 10:09:20 +08:00
Steven
7b0ceee57b chore: update memo metadata description 2023-12-16 09:23:45 +08:00
Steven
bdc867d153 fix: heading render 2023-12-16 09:12:55 +08:00
Steven
6421fbc68a chore: implement list html render 2023-12-16 09:01:19 +08:00
Steven
b00443c222 chore: implement list nodes 2023-12-16 08:51:29 +08:00
Steven
a10b3d3821 chore: tweak custom profile 2023-12-15 22:57:53 +08:00
Steven
7735cfac31 chore: update seed data 2023-12-15 22:34:19 +08:00
Steven
749187e1e9 chore: update dockerfile 2023-12-15 21:46:11 +08:00
Steven
a9812592fe chore: tweak editor border styles 2023-12-15 21:35:31 +08:00
Steven
e4070f7753 chore: bump version 2023-12-15 21:11:04 +08:00
Steven
ff53187eae chore: add sitemap and robots routes 2023-12-15 20:18:01 +08:00
Steven
89ef9b8531 chore: add instance url system setting 2023-12-15 19:39:37 +08:00
Steven
56b55ad941 chore: update memo metadata 2023-12-15 19:13:56 +08:00
Steven
24672e0c5e chore: update memo metadata 2023-12-15 08:12:10 +08:00
Steven
52743017a3 chore: implement memo route 2023-12-15 07:32:49 +08:00
Steven
6cf7192d6a chore: add ssr placeholder in index.html 2023-12-14 23:29:42 +08:00
Steven
6763dab4e5 chore: handle newline in block parsers 2023-12-14 22:55:46 +08:00
Steven
e0290b94b4 chore: use gomark in rss api 2023-12-14 22:33:20 +08:00
Steven
242f64fa8e chore: implement html render 2023-12-14 22:21:23 +08:00
Steven
3edce174d6 chore: remove unused methods 2023-12-14 00:24:41 +08:00
Steven
5266a62685 chore: implement html renderer 2023-12-14 00:04:20 +08:00
Steven
43ef9eaced chore: implement part of html renderer 2023-12-13 23:50:05 +08:00
Steven
453707d18c feat: implement gomark parsers 2023-12-13 21:00:13 +08:00
Steven
2d9c5d16e1 chore: fix user string 2023-12-13 19:08:06 +08:00
Steven
b20e0097cf chore: implement part of nodes 2023-12-13 09:06:47 +08:00
Steven
dd83782522 chore: add line break node 2023-12-12 23:38:43 +08:00
Steven
aa3632e2ac chore: implement gomark skeleton 2023-12-12 23:24:02 +08:00
boojack
7f1f6f77a0 chore: update i18n with weblate (#2614)
* Translated using Weblate (French)

Currently translated at 100.0% (317 of 317 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/fr/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (317 of 317 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/pt_BR/

---------

Co-authored-by: Ezmana <ezmana.land@gmail.com>
Co-authored-by: Lincoln Nogueira <lincolnthalles@users.noreply.github.com>
2023-12-12 21:06:13 +08:00
Steven
7eb5be0a4e chore: fix update user 2023-12-12 20:01:31 +08:00
Steven
603a6a4971 chore: fix vacuum memo 2023-12-12 19:56:15 +08:00
Cologler
6bda64064e fix: delete one memo will delete all memos on pgsql (#2611)
fix: delete single memo will all memos on pgsql

Close #2605
2023-12-12 19:52:39 +08:00
Steven
ec7992553f chore: go mod update 2023-12-11 22:21:05 +08:00
Steven
e5de8c08f5 chore: clean debug code 2023-12-11 22:20:57 +08:00
Steven
c608877c3e chore: clean binary entries 2023-12-11 22:16:39 +08:00
Steven
52f399a154 chore: remove unused functions 2023-12-11 21:53:16 +08:00
Gabe Cook
88728906a8 fix(copydb): fix migration to Postgres (#2601)
* chore(copydb): Use query builder during setup

* fix(copydb): Fix migration to Postgres
2023-12-11 18:05:15 +08:00
boojack
0916ec35da chore: update i18n with Weblate (#2594)
* Translated using Weblate (French)

Currently translated at 100.0% (317 of 317 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (317 of 317 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/fr/

---------

Co-authored-by: Ezmana <ezmana.land@gmail.com>
Co-authored-by: LibreTranslate <noreply-mt-libretranslate@weblate.org>
2023-12-10 15:49:29 +08:00
Steven
9f4f2e8e27 chore: fix user setting values 2023-12-10 11:57:31 +08:00
Steven
82009d3147 chore: tweak webhook section 2023-12-10 10:21:36 +08:00
Steven
0127e08a28 fix: data conflict handler 2023-12-10 10:07:26 +08:00
Steven
d275713aff chore: fix timestamp type in postgres 2023-12-09 23:19:57 +08:00
Steven
c50f4f4cb4 chore: update migration scripts 2023-12-09 22:18:05 +08:00
Steven
fa34a7af4b chore: tweak memo parent styles 2023-12-09 19:32:16 +08:00
Steven
77b75aa6c4 chore: tweak memo display 2023-12-09 18:57:59 +08:00
Steven
9faee68dab chore: code clean 2023-12-09 18:57:18 +08:00
Steven
4f05c972d5 chore: fix jwt acl 2023-12-09 16:17:11 +08:00
Steven
abda6ad041 chore: update latest schema 2023-12-09 12:05:45 +08:00
Steven
7fc7b19d64 chore: deprecate user setting legacy api 2023-12-08 22:41:47 +08:00
Steven
b2d898dc15 chore: fix import order 2023-12-08 22:06:42 +08:00
Steven
15425093af chore: skip user setting key checks 2023-12-08 22:05:43 +08:00
K.B.Dharun Krishna
b02aa2d5e5 chore: bump actions in workflows (#2588)
Signed-off-by: K.B.Dharun Krishna <kbdharunkrishna@gmail.com>
2023-12-08 09:55:18 +08:00
steven
c68bfcc3b9 chore: fix user setting store 2023-12-08 09:54:32 +08:00
Athurg Gooth
2964cf93ab fix: visibility of user preference is empty (#2581)
Fix visibility of user preference is empty
2023-12-07 22:50:03 +08:00
Arnaud Brochard
787cf2a9fe feat: tables support (#2573)
* Tables support

* Linter fixes

* Regex Redos fix

* Fix empty row and variables proper naming

* Default cell style

* Now unncessary

* Support rows without a starting pipe char

* Striped rows

* Fix parsing issues

* Support tabs in separators
2023-12-07 22:49:49 +08:00
Athurg Gooth
ed190cd41e fix: visibility of memo editor is empty (#2580)
Fix visibility of memo editor is empty
2023-12-06 23:17:26 +08:00
Steven
33dda9bf87 chore: fix auth status checks 2023-12-06 23:03:24 +08:00
Steven
fa6693a7ae chore: update list memos 2023-12-06 22:44:49 +08:00
t.yang
055a327b5e fix: detail page user-avatar size have unexpected height (#2576)
Co-authored-by: tyangs.yang <tyangs.yang@hstong.com>
2023-12-06 10:58:33 +08:00
Athurg Gooth
aff1b47072 chore: remove debug log (#2582)
Remove debug log
2023-12-06 10:57:55 +08:00
Athurg Gooth
5f86769255 fix: field type of row_status for table webhook (#2579)
Fix field type of row_status for table webhook
2023-12-06 10:57:03 +08:00
Irving Ou
9c18960f47 feat: support Postgres (#2569)
* skeleton of postgres

skeleton

* Adding Postgres specific db schema sql

* user test passed

* memo store test passed

* tag is working

* update user setting test done

* activity test done

* idp test passed

* inbox test done

* memo_organizer, UNTESTED

* memo relation test passed

* webhook test passed

* system setting test passed

* passed storage test

* pass resource test

* migration_history done

* fix memo_relation_test

* fixing server memo_relation test

* passes memo relation server test

* paess memo test

* final manual testing done

* final fixes

* final fixes cleanup

* sync schema

* lint

* lint

* lint

* lint

* lint
2023-12-03 13:31:29 +08:00
Webysther Sperandio
484efbbfe2 chore: update manifest.json (#2568) 2023-12-01 16:43:48 +08:00
Steven
e83d483454 refactor(frontend): use auth service 2023-12-01 09:15:02 +08:00
Steven
b944418257 fix: register auth service 2023-12-01 09:13:32 +08:00
Steven
4ddd3caec7 chore: update user setting api 2023-12-01 09:03:30 +08:00
Steven
c1f55abaeb chore: update user setting api 2023-11-30 23:08:54 +08:00
Steven
fff42ebc0d fix: check auth status 2023-11-30 21:52:02 +08:00
Steven
2437419b7f fix: add auth status checks 2023-11-30 20:58:36 +08:00
boojack
e53cedaf14 chore: update i18n with weblate (#2562)
* Translated using Weblate (Polish)

Currently translated at 46.3% (147 of 317 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/pl/

* Translated using Weblate (Polish)

Currently translated at 56.1% (178 of 317 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/pl/

---------

Co-authored-by: igormakarowicz <igormakarowicz@gmail.com>
2023-11-30 19:34:50 +08:00
Steven
6d469fd997 chore: fix image size 2023-11-30 19:36:12 +08:00
boojack
9552cddc93 chore: update graph format 2023-11-29 09:00:29 +08:00
boojack
34181243e5 chore: update graph route 2023-11-28 23:06:19 +08:00
boojack
a0eb891132 chore: update contributor graph source 2023-11-28 22:31:34 +08:00
Steven
e136355408 chore: tweak setting button style 2023-11-28 21:15:24 +08:00
Steven
5069476dcc chore: add webhook metric 2023-11-28 21:15:10 +08:00
Steven
f950750d56 chore: tweak storage list title 2023-11-28 21:03:21 +08:00
Steven
0026f9e54f chore(frontend): add webhooks section 2023-11-28 20:52:48 +08:00
Steven
f8f73d117b chore: update healthz message 2023-11-26 23:33:34 +08:00
Steven
8586ebf098 chore: add /healthz endpoint 2023-11-26 23:06:50 +08:00
Steven
472afce98f chore: fix current user store 2023-11-25 22:58:17 +08:00
Steven
a12844f5db chore: tweak seed data 2023-11-25 10:34:54 +08:00
Steven
bc965f6afa chore: implement webhook dispatch in api v1 2023-11-25 10:31:58 +08:00
Steven
db95b94c9a chore: implement webhook service 2023-11-24 23:04:36 +08:00
Steven
1a5bce49c2 chore: implement webhook store 2023-11-24 22:45:38 +08:00
Steven
436eb0e591 chore: tweak s3 comments 2023-11-24 21:55:09 +08:00
Hou Xiaoxuan
e016244aba fix: remove ACL when set URLPrefix (#2532) 2023-11-23 23:20:11 +08:00
Steven
d317b03832 feat: add search box in resources dashboard 2023-11-23 22:12:15 +08:00
Athurg Gooth
3e138405b3 chore: remove the max height limit for single media (#2545)
Remove the max-height limit for single media
2023-11-23 09:59:33 +08:00
Steven
0dd0714ad0 chore: update security 2023-11-23 08:55:57 +08:00
Steven
45d7d0d5f6 chore: migrate get current user 2023-11-23 08:50:33 +08:00
boojack
c3db4ee236 chore: translated using Weblate (Portuguese (Brazil)) (#2543)
Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (316 of 316 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/pt_BR/

Co-authored-by: Lincoln Nogueira <lincolnthalles@users.noreply.github.com>
2023-11-22 23:22:10 +08:00
Athurg Gooth
91296257fc chore: remove invalid access token from db (#2539)
Remove invalid access token from db
2023-11-22 23:20:45 +08:00
Steven
e5f660a006 chore: migrate update user 2023-11-22 23:11:29 +08:00
Steven
c0628ef95b chore: migrate create user 2023-11-22 22:58:04 +08:00
Steven
c0b5070e46 chore: migrate delete user 2023-11-22 22:52:19 +08:00
Steven
b1128fc786 chore: fix inline latext renderer 2023-11-22 22:38:03 +08:00
Steven
bcd8a5a7a9 chore: migrate get tag suggestions 2023-11-22 22:33:02 +08:00
boojack
0cf280fa9a chore: translated using Weblate (Croatian) (#2541)
Translated using Weblate (Croatian)

Currently translated at 92.7% (293 of 316 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/hr/

Co-authored-by: May Kittens Devour Your Soul <yoshimitsu002@gmail.com>
2023-11-22 19:22:12 +08:00
Steven
b11653658d chore: update readme 2023-11-21 20:39:48 +08:00
Cheng Jiang
adf96a47bb fix: correct some Chinese translation inaccuracy (#2536)
Signed-off-by: Cheng Jiang <gabriel.chengj@gmail.com>
2023-11-21 10:58:19 +08:00
Steven
6529375a8b chore: update seed data 2023-11-19 23:58:35 +08:00
Steven
e7e83874cd chore: upgrade version 2023-11-19 11:01:21 +08:00
Steven
7ef125e3af chore: update fetch tags 2023-11-19 11:01:04 +08:00
Steven
dfa14689e4 chore: add click away event to date picker 2023-11-19 10:41:08 +08:00
Steven
ec2995d64a chore: fix order by pinned 2023-11-19 09:42:59 +08:00
Steven
94c71cb834 chore: fix loading status 2023-11-19 09:38:04 +08:00
Steven
7f7ddf77b8 chore: update allow sign up default value 2023-11-18 12:51:07 +08:00
Steven
089cd3de87 chore: fix linter 2023-11-18 12:44:49 +08:00
zty
2d34615eac fix: parse inline latex as a inline element (#2525)
* fix:  parse inline latex as a inline element

* Update web/src/labs/marked/parser/InlineLatex.tsx

---------

Co-authored-by: zty <zty.dev@outlook.com>
Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-11-18 12:41:33 +08:00
Steven
4da3c1d5e5 chore: fix update user 2023-11-18 12:37:24 +08:00
zty
4f222bca5c fix: keep content and query param on save when access token is invalid (#2524)
fix:
keep content on save when access token is invalid

Co-authored-by: zty <zty.dev@outlook.com>
2023-11-17 10:01:14 +08:00
Steven
0bb0407f46 chore: add overflow tips to tag 2023-11-17 08:22:47 +08:00
Zexi
8bc117bce9 feat: optimize media resource display (#2518)
* feat: optimize media resource display

* fix: type error

* Update web/src/components/MemoResourceListView.tsx

Co-authored-by: boojack <stevenlgtm@gmail.com>

* Update MemoResourceListView.tsx

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-11-17 08:03:26 +08:00
Athurg Gooth
afd0e72e37 chore: skip timeout for blob upload (#2516)
Skip timeout for blob upload
2023-11-15 17:23:56 +08:00
Vespa314
d758ba2702 fix: allow host role update user info (#2515) 2023-11-15 11:43:49 +08:00
Leyang
0f126ec217 docs: add obsidian plugin (#2512) 2023-11-14 23:22:54 +08:00
Steven
c1a6dc9bac chore: fix home sidebar max width 2023-11-14 23:23:05 +08:00
Athurg Gooth
6ee95a2c0c fix: clear localStorage while draft is empty (#2510)
* Clear localStorage while draft is empty

* change == into ===
2023-11-14 10:02:16 +08:00
Athurg Gooth
6814915c88 feat: backup file rotate (#2511)
Add support for rotate backup files
2023-11-13 22:12:25 +08:00
Athurg Gooth
52fdf8bccd fix: persist jwt expires by cookie (#2509)
fix pesist jwt expires by cookie
2023-11-13 13:52:04 +08:00
Mahoo Huang
f67757f606 feat: add editor auto focus preference (#2498)
* feat: add editor auto focus perference

* feat: set editor auto focus
2023-11-13 13:51:52 +08:00
ti777777
38f05fd6f2 chore: fix tag in http_getter.go (#2500)
Update http_getter.go

fix tag in  http_getter.go
2023-11-11 23:21:15 +08:00
Vespa314
65022beb0d fix: duplicate memo filter in user profile page (#2502) 2023-11-11 23:20:53 +08:00
swebdev
5d81338aca fix: demo banner link for self-hosting guide (#2499) 2023-11-11 19:15:24 +08:00
Steven
c288d49138 chore: fix decouple user name 2023-11-10 23:08:11 +08:00
Steven
0ea0645258 chore: add use reponsive width 2023-11-10 11:22:32 +08:00
steven
9227ca5b5b chore: update debounce ms 2023-11-09 08:52:02 +08:00
steven
eb6b0ddead chore: update navigation 2023-11-09 08:46:26 +08:00
Steven
dca90fb5d2 chore: update header 2023-11-09 08:27:46 +08:00
steven
172e27016b chore: upgrade frontend deps 2023-11-09 08:26:00 +08:00
steven
6da2ff7ffb chore: clean logs 2023-11-09 08:25:28 +08:00
Steven
6c433b452f chore: update user checks 2023-11-08 22:58:35 +08:00
Steven
65a34ee41a chore: update home sidebar 2023-11-08 22:18:12 +08:00
Steven
5ff0234c71 chore: update response styles 2023-11-08 22:10:15 +08:00
Steven
e76509a577 chore: update header menu style in mobile view 2023-11-08 22:00:49 +08:00
Steven
4499f45b67 chore: deprecate daily review offset local setting 2023-11-08 21:49:03 +08:00
boojack
504d1768f2 chore: update i18n with Weblate (#2492)
* Translated using Weblate (Chinese (Traditional))

Currently translated at 91.7% (290 of 316 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/zh_Hant/

* Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (316 of 316 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/zh_Hant/

---------

Co-authored-by: dwong33 <dwong@posteo.ch>
2023-11-08 13:02:24 +08:00
zty
caea065594 feat: add share btn in more-action (#2491)
Co-authored-by: zty <zty.dev@outlook.com>
2023-11-08 10:34:07 +08:00
Athurg Gooth
2ee426386a fix: skip system_setting check while copydb (#2490)
Skip system_setting check while copydb
2023-11-08 10:06:27 +08:00
boojack
9df05fe0fa chore: update i18n from Weblate (#2489)
* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (316 of 316 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (316 of 316 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/pt_BR/

---------

Co-authored-by: LibreTranslate <noreply-mt-libretranslate@weblate.org>
Co-authored-by: Lincoln Nogueira <lincolnthalles@users.noreply.github.com>
2023-11-07 22:39:27 +08:00
zty
184f79ef8e feat: support code in headings (#2488)
Co-authored-by: zty <zty.dev@outlook.com>
2023-11-07 22:38:38 +08:00
Athurg Gooth
35f0861d6e fix: prevent copydb to create a fresh memos (#2486)
Prevent copydb to create a fresh memos
2023-11-07 13:53:53 +08:00
Steven
c4d27e7a78 chore: update backend dependencies 2023-11-07 07:36:16 +08:00
Steven
7e545533cf chore: update resource cache control 2023-11-07 07:24:41 +08:00
Steven
32cafbff9b chore: add OverflowTip kit component 2023-11-07 07:20:17 +08:00
Steven
9c4f72c96e chore: update tooltips 2023-11-07 07:06:38 +08:00
Steven
5e4493b227 chore: remove debug codes 2023-11-06 23:20:26 +08:00
Steven
834b58fbbd feat: add version update inbox message 2023-11-06 22:53:55 +08:00
Steven
342d1aeefb fix: version checker 2023-11-06 22:33:12 +08:00
Steven
363c107359 chore: update frontend deps 2023-11-06 21:08:09 +08:00
boojack
0458269e15 revert: "chore: add frontend type definitions" (#2483)
Revert "chore: add frontend type definitions (#2482)"

This reverts commit 64d4db81ca.
2023-11-06 21:01:17 +08:00
boojack
64d4db81ca chore: add frontend type definitions (#2482)
chore: update
2023-11-06 21:00:42 +08:00
Steven
865cc997a4 chore: remove upgrade version banner 2023-11-06 20:51:59 +08:00
Steven
981bfe0464 feat: add version checker 2023-11-06 20:49:02 +08:00
Steven
695fb1e0ca chore: update migration history store 2023-11-06 08:33:31 +08:00
Steven
21ad6cc871 chore: update tag service creator 2023-11-06 08:05:07 +08:00
Steven
c24181b2be chore: fix jwt checks 2023-11-05 23:39:30 +08:00
Steven
39a0e69b04 chore: update function name 2023-11-05 23:28:09 +08:00
Steven
e60e47f76f chore: update user definition 2023-11-05 23:03:43 +08:00
Steven
e67820cabe chore: update list user api permission 2023-11-05 22:35:09 +08:00
Steven
3266c3a58a chore: update link styles 2023-11-05 22:26:09 +08:00
Steven
ef820a1138 chore: fix memo editor padding in daily review 2023-11-05 21:42:02 +08:00
Steven
137e64b0dd chore: update metrics 2023-11-05 21:41:47 +08:00
Steven
982b0285c9 chore: fix date picker 2023-11-05 16:02:51 +08:00
Steven
405fc2b4d2 chore: simplify find migration history 2023-11-05 15:49:57 +08:00
Steven
eacd3e1c17 chore: fix mysql latest schema 2023-11-05 15:38:45 +08:00
Christopher
a62f1e15a6 fix: private memos being public (#2480)
* fix(web/memo): filter out public option

Filter out the public option if we have disabled public memos

* feat(api/memo): sanity check for disabled public memos

In case something goes wrong, we check the system setting on the backend in order to valdiate if we can create a public memo

* refactor(web/memo): disable public option

Seems like a better option than removing it, as it looks werid if you are looking at a memo that is previously public

* fix(web/memo): use translation keys

* chore(web/editor): remove unsused tooltip

* revert(api/memo): sanity check

* fix(web/memo): allow admins to create public memos

* chore(web/memo): remove unused import

* fix(web/memo): check for both host and admin

* fix(web/memo): remove warning text from MemoDetail
2023-11-05 01:19:54 +08:00
Zexi
8b0083ffc5 fix: auto fetch more (#2472)
* fix: auto fetch more

* feat: use union type
2023-11-03 05:16:55 +08:00
Athurg Gooth
5d69d89627 feat: week from monday in heatmap for zh-Hans and ko (#2457)
* week from monday in heatmap for zh-Hans and ko

* optimize code
2023-10-31 12:06:14 +08:00
Athurg Gooth
b966c16dd5 fix: data too large for mysql (#2470)
* Extend some TEXT field to LONGTEXT in mysql

* move db migration version

* fix error in migrate SQL
2023-10-31 10:23:15 +08:00
Steven
97190645cc chore: update memo editor styles 2023-10-29 23:59:23 +08:00
Steven
c26417de70 chore: update docs links 2023-10-29 18:36:09 +08:00
Steven
f5c1e79195 chore: update about dialog 2023-10-28 15:17:33 +08:00
Steven
d02105ca30 chore: update i18n 2023-10-28 15:10:20 +08:00
Steven
44e50797ca chore: update go mods 2023-10-28 14:57:39 +08:00
Steven
7058f0c8c2 chore: add docs link in settings 2023-10-28 14:56:08 +08:00
Steven
f532ccdf94 chore: upgrade frontend deps 2023-10-28 14:55:57 +08:00
Steven
a6fcdfce05 chore: update memo comment i18n 2023-10-28 11:39:10 +08:00
Steven
dca712d273 chore: fix resource tests 2023-10-28 10:51:03 +08:00
Steven
ac81d856f6 chore: delete resource file sync 2023-10-28 10:42:39 +08:00
Steven
88fb79e458 chore: impl inbox store for mysql 2023-10-28 09:44:52 +08:00
Steven
480c53d7a2 chore: fix id converter 2023-10-28 09:04:32 +08:00
Steven
2b7d7c95a5 chore: update inbox detect 2023-10-28 09:02:02 +08:00
Steven
0ee938c38b chore: remove unused inbox status 2023-10-28 02:49:35 +08:00
Steven
3c36cc2953 feat: add inbox ui 2023-10-28 02:43:46 +08:00
Steven
79bb3253b6 chore: add activity service 2023-10-28 00:21:53 +08:00
Steven
18107248aa chore: rename list inbox 2023-10-28 00:08:42 +08:00
Athurg Gooth
4f1bb55e55 fix: metric env not affect (#2450)
fix metric env not affect
2023-10-27 23:26:23 +08:00
Athurg Gooth
20d3abb99a chore: downgrade log level for auto backup disable (#2454)
downgrade log level for auto backup disable
2023-10-27 23:25:51 +08:00
Steven
1b34119e60 chore: update activity store definition 2023-10-27 23:24:56 +08:00
Steven
9d2b785be6 chore: fix inbox test 2023-10-27 23:17:17 +08:00
Steven
36b4ba33fa chore: remove outdated activity definition 2023-10-27 23:11:56 +08:00
Steven
625ebbea1a chore: update proto linter action 2023-10-27 21:49:07 +08:00
Athurg Gooth
0f4e5857f0 chore: remove gRPC listener (#2456)
Disable gRPC listener
2023-10-27 21:38:17 +08:00
Athurg Gooth
76d955a69a chore: docker compose dev (#2458)
* add golang build cache volume to speedup build

* replace `lint` with `npm` to run more scripts

* wrap golangci-lint as entrypoint instead of command
2023-10-27 21:21:52 +08:00
Athurg Gooth
e41ea445c9 fix: missing relation after post comment (#2452)
fix missing relation after post comment
2023-10-27 10:12:25 +08:00
boojack
c952651dc1 chore: update i18n from Weblate (#2455)
* Translated using Weblate (Croatian)

Currently translated at 92.3% (290 of 314 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/hr/

* Translated using Weblate (Korean)

Currently translated at 100.0% (314 of 314 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/ko/

---------

Co-authored-by: May Kittens Devour Your Soul <yoshimitsu002@gmail.com>
Co-authored-by: nulta <un5450@naver.com>
2023-10-27 10:12:02 +08:00
Steven
58e771a1d7 chore: combine v2 services 2023-10-27 09:07:35 +08:00
Steven
e876ed3717 feat: impl part of inbox service 2023-10-27 09:01:17 +08:00
Steven
67d2e4ebcb chore: update method name 2023-10-27 08:36:43 +08:00
Steven
4ea78fa1a2 chore: impl inbox store methods 2023-10-27 08:17:58 +08:00
Steven
93b8e2211c chore: update dev latest schema 2023-10-27 01:18:00 +08:00
Steven
052216c471 chore: fix list activities typo 2023-10-27 01:11:41 +08:00
Steven
e5978a70f5 chore: initial inbox store model 2023-10-27 01:10:19 +08:00
Steven
59f0ee862d chore: fix viper default value 2023-10-27 00:49:58 +08:00
Athurg Gooth
215981dfde chore: remote context.Context pointer (#2448)
remote context.Context pointer
2023-10-26 20:21:44 +08:00
Athurg Gooth
bfdb33f26b chore: add a flag to change metric switch (#2447)
* add a flag to change metric switch

* change the default value of metric
2023-10-26 20:21:18 +08:00
Steven
5b3af827e1 chore: move common packages to internal 2023-10-26 09:02:50 +08:00
Steven
9859d77cba chore: update links 2023-10-26 09:00:36 +08:00
Athurg Gooth
064c930aed fix: validate username before create token (#2439)
Validate username before create token
2023-10-25 12:05:44 +08:00
Athurg Gooth
043357d7dc fix: list token for others failed (#2440)
Fix list token for others failed
2023-10-25 12:05:30 +08:00
Athurg Gooth
3a5deefe11 chore: disable NPM update notice while running lint (#2438)
Disable NPM update notice while running lint
2023-10-25 10:00:45 +08:00
Athurg Gooth
2c71371b29 chore: update @typescript-eslint to avoid WARNING (#2437)
Update @typescript-eslint to avoid WARNING
2023-10-25 10:00:30 +08:00
Steven
222e6b28b7 chore: update website links in readme 2023-10-25 07:43:52 +08:00
Athurg Gooth
496cde87b2 feat: list access tokens by admin (#2434)
* Allow admin user list access_tokens of anyone

* fix undefined variable

* Update api/v2/user_service.go

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-10-24 18:51:01 +08:00
Athurg Gooth
79bbe4b82a feat: support filter creator in /api/v2/memos (#2432)
* Add creator_id param in /api/v2/memos

* make creator_id optional
2023-10-23 21:32:58 +08:00
Zexi
035d71e07c fix: visibility translation (#2429)
* fix: visibility translation

* refactor: remove useless file

* feat: add visibility icon
2023-10-23 08:06:59 +08:00
Christopher
82effea070 tweak(web): use iconbutton for editor helpers (#2426) 2023-10-22 22:10:27 +08:00
Lincoln Nogueira
331f4dcc1b chore: update dev scripts (#2427)
- add type-gen
- remove some unused air settings
- restrict air monitoring to changed go files
2023-10-22 22:09:25 +08:00
Lincoln Nogueira
055b246857 chore: update ci (#2428)
- restrict codeql and backend tests from running on pull requests with unrelated files
- upgrade codeql: current version is generating a deprecation warning in logs
2023-10-22 22:08:38 +08:00
boojack
8fc9a318a4 chore: translated using Weblate (Portuguese (Brazil)) (#2422)
Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (314 of 314 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/pt_BR/

Co-authored-by: Lincoln Nogueira <lincolnthalles@users.noreply.github.com>
2023-10-22 12:27:29 +08:00
MotH
9aed80a4fd feat: better tag suggestion (#2421)
Better Tag Suggestion
2023-10-22 09:15:25 +08:00
MotH
c31f306b5b fix: smaller logo file (#2418) 2023-10-22 05:51:37 +08:00
Steven
bfd2dbfee2 chore: fix update resource api 2023-10-21 12:41:55 +08:00
Steven
c42af95dd3 chore: fix update user 2023-10-21 12:22:23 +08:00
Steven
89a073adc0 chore: implement create user api v2 2023-10-21 12:19:06 +08:00
Steven
1c2d82a62f chore: remove major label 2023-10-21 09:36:50 +08:00
Steven
02f7a36fa4 chore: remove unsupported linux/arm/v7 2023-10-21 08:29:21 +08:00
Steven
a76f762196 chore: update memo share dialog 2023-10-21 08:19:25 +08:00
Steven
2c2955a229 chore: add back linux/arm/v7 2023-10-21 08:15:25 +08:00
Steven
d06d01cef2 chore: release mysql driver 2023-10-21 01:25:07 +08:00
boojack
4b91738f21 chore: translated using Weblate (Croatian) (#2413)
Translated using Weblate (Croatian)

Currently translated at 87.5% (274 of 313 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/hr/

Co-authored-by: May Kittens Devour Your Soul <yoshimitsu002@gmail.com>
2023-10-21 01:23:12 +08:00
Steven
12fd8f34be chore: fix styles 2023-10-21 01:23:21 +08:00
Steven
c2ab05d422 chore: fix member section style 2023-10-21 01:09:52 +08:00
Steven
7b25b8c1e1 feat: update daily review 2023-10-21 00:57:44 +08:00
Athurg Gooth
af7c0a76d0 fix: fail to update user's update_ts (#2410) 2023-10-20 19:10:38 +08:00
Athurg Gooth
664c9c4a7c chore: extend height of setting page (#2407)
Extend height of setting page
2023-10-20 17:41:37 +08:00
Athurg Gooth
fd5d51ee54 fix: some fields of profile leaked without auth (#2408)
* fix some fields of profile leaked without auth

* protect driver and dsn of profile
2023-10-20 17:41:21 +08:00
Steven
1b105db958 chore: fix field syntax 2023-10-20 08:52:16 +08:00
Steven
a541e8d3e3 chore: upgrade version 2023-10-20 08:49:58 +08:00
Steven
6f2ca6c87a chore: update find memo api 2023-10-20 08:48:52 +08:00
Steven
952539f817 chore: update memo editor dialog 2023-10-20 08:19:08 +08:00
Steven
c87b679f41 chore: add memo relation list 2023-10-19 21:26:38 +08:00
Baksi
e6b20c5246 chore: update Shortcuts link (#2405)
* Update Shortcuts

* Update README.md

* Update README.md
2023-10-19 04:56:59 -05:00
Athurg Gooth
0bfcff676c feat: add support for remember sign in (#2402) 2023-10-18 20:38:49 -05:00
Athurg Gooth
37601e5d03 chore: change the timeout value of golangci-lint (#2403) 2023-10-18 20:37:35 -05:00
Steven
21c70e7993 feat: update memo relations dialog 2023-10-19 00:18:07 +08:00
Athurg Gooth
22d331d6c4 chore: switch storage of selected date in DailyReview (#2399)
Switch storage of selected date in DailyReview
2023-10-18 09:06:34 -05:00
Steven
9bfb2d60b9 chore: tweak wording 2023-10-18 06:05:19 +08:00
Steven
203b2d9181 chore: fix container styles 2023-10-18 06:02:39 +08:00
boojack
ddc4566dcb chore: translated using Weblate (German) (#2396)
Translated using Weblate (German)

Currently translated at 99.6% (312 of 313 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/de/

Co-authored-by: Frank Emmerlich <femmdi2012@gmail.com>
2023-10-17 10:49:36 -05:00
Steven
1755c9dc79 chore: update button style 2023-10-17 23:49:26 +08:00
Steven
a5df36eff2 chore: update metrics 2023-10-17 23:44:16 +08:00
Athurg Gooth
e30d0c2dd0 fix: image width error while loading (#2394)
fix image width error while loading
2023-10-17 09:00:45 -05:00
boojack
213c2ea71b chore: translated with Weblate (German) (#2390)
Translated using Weblate (German)

Currently translated at 99.6% (312 of 313 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/de/

Co-authored-by: Frank Emmerlich <femmdi2012@gmail.com>
2023-10-16 11:21:11 -05:00
guopeng
73f59eaf09 fix: storage setting changed don't take effect (#2385)
* fix: Storage setting changed don't take effect

* fix: Storage setting changed don't take effect

* fix: Storage setting changed don't take effect
2023-10-16 08:07:21 -05:00
TianLun Song
c999d71455 chore: update iOS shortcut link (#2387)
* Update README.md

New shortcut for memos version 0.15.0 and above on IOS

* Update README.md

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-10-16 08:06:36 -05:00
Steven
5359d5a66d chore: add container-queries tailwind plugin 2023-10-14 14:13:55 +08:00
Steven
c58820fa64 chore: update username regexp 2023-10-14 13:42:27 +08:00
Steven
cfc5599334 chore: cleanup less files 2023-10-14 12:06:24 +08:00
Steven
c02f3c0a7d chore: remove less files in editor 2023-10-14 11:55:37 +08:00
Steven
dd83358850 chore: update some styles 2023-10-14 01:12:41 +08:00
Steven
d95a6ce898 chore: add ar locale item 2023-10-14 00:25:01 +08:00
boojack
7e80e14f16 chore: add Arabic from weblate (#2382)
* Added translation using Weblate (Arabic)

* Translated using Weblate (Arabic)

Currently translated at 46.6% (146 of 313 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/ar/

---------

Co-authored-by: Ali AlShaikh <Mi3LiX9@outlook.sa>
2023-10-13 11:11:56 -05:00
Steven
219304e38d chore: update frontend deps 2023-10-13 23:29:55 +08:00
Steven
20e5597104 chore: fix memo container max width 2023-10-13 23:26:56 +08:00
Athurg Gooth
ed2e299797 fix: invalid type convert in apiv2 (#2380)
fix invalid type convert in apiv2
2023-10-13 09:53:58 -05:00
boojack
bacc529391 chore: fix linter errors (#2381)
* chore: fix linter errors

* chore: update

* chore: update
2023-10-13 09:53:02 -05:00
Steven
ed1ff11e80 chore: update 2023-10-13 00:13:13 +08:00
Steven
a0f8e6987c chore: update go deps 2023-10-13 00:08:52 +08:00
Athurg Gooth
d3e32f0d5a chore: add latency in log (#2374)
* Print profile.Data in boot log

* Add latency in request log
2023-10-10 06:03:32 -05:00
Athurg Gooth
95bfcb8055 chore: print profile.Data in boot log (#2373)
Print profile.Data in boot log
2023-10-10 05:59:19 -05:00
Athurg Gooth
096c489eb3 feat: copy data between drivers (#2370)
* Add copydb command to copy data between drivers

* Check if table is empty before copy
2023-10-09 22:45:17 -05:00
boojack
b6425f9004 chore: update i18n with weblate (#2369)
* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (315 of 315 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/pt_BR/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (315 of 315 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/zh_Hans/

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/

---------

Co-authored-by: Lincoln Nogueira <lincolnthalles@users.noreply.github.com>
Co-authored-by: Qing Long <longyinx@duck.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
2023-10-09 10:54:54 -05:00
Steven
ab2c86640b chore: move rate limiter to apiv1 2023-10-09 23:10:41 +08:00
Steven
1489feb054 chore: update common dialog default color 2023-10-09 23:08:52 +08:00
Steven
acdeabb861 chore: add issue template config.yml 2023-10-09 22:10:28 +08:00
Athurg Gooth
6bb6c043e5 feat: add support for ListMemoOrganizer (#2367)
* Add support for ListMemoOrganizer

* fix rows not close
2023-10-09 08:18:47 -05:00
Athurg Gooth
fa2bba51c1 feat: add support for ListActivity (#2365)
Add support for ListActivity
2023-10-09 08:18:33 -05:00
Steven
3822c26e32 chore: update memo props 2023-10-09 21:09:17 +08:00
Athurg Gooth
425b43b3bb fix: ListTag not support omit params (#2366)
fix ListTag not support omit params
2023-10-09 01:40:54 -05:00
Athurg Gooth
c00dac1bbf fix: index page failed with 429 (#2363) 2023-10-08 20:54:12 -05:00
Steven
3ff4d19782 chore: update initial global loader 2023-10-08 20:31:38 +08:00
Steven
31997936d6 chore: move resource public api 2023-10-08 19:40:30 +08:00
Athurg Gooth
287f1beb90 fix: create storage without some attributes (#2358) 2023-10-08 05:30:24 -05:00
Athurg Gooth
7680be1a2f fix: create user without some attributes (#2357) 2023-10-08 05:29:32 -05:00
Athurg Gooth
55e0fbf24e fix: create activity without some attributes (#2356) 2023-10-08 05:29:22 -05:00
Athurg Gooth
eaac17a236 fix: create memo without some attributes (#2355) 2023-10-08 05:29:12 -05:00
Athurg Gooth
1fbd568dfe fix: create resource without some attributes (#2354) 2023-10-08 05:29:03 -05:00
Athurg Gooth
c0619ef4a4 fix: CreateIdentityProvider without id (#2352) 2023-10-08 05:28:22 -05:00
Athurg Gooth
b2aa66b4fd fix: migration always in mysql (#2353) 2023-10-08 05:28:11 -05:00
boojack
dfaf2ee29c chore: update pnpm scripts (#2350)
* chore: update pnpm scripts

* chore: update development guide
2023-10-07 12:06:18 -05:00
Steven
b938c8d7b6 chore: only show comments in memo detail page 2023-10-08 00:42:02 +08:00
Steven
553de3cc7e fix: mysql syntax 2023-10-07 22:56:12 +08:00
Steven
73980e9644 chore: fix video element syntax 2023-10-07 22:01:07 +08:00
Athurg Gooth
087e631dd8 chore: optmize docker-compose.dev.yml (#2347)
Optmize docker-compose.dev.yml
2023-10-07 08:24:53 -05:00
boojack
76fb280720 chore: translated with Weblate (#2348)
Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (315 of 315 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/zh_Hans/

Co-authored-by: Qing Long <longyinx@duck.com>
2023-10-07 08:23:06 -05:00
Steven
6ffc09d86a chore: remove unused httpmeta getter api 2023-10-06 23:03:36 +08:00
白宦成
125c9c92eb chore: compress image and reduce 500kb in network (#2339)
feat: compress image and reduce 500kb in network
2023-10-06 07:45:17 -05:00
Steven
15eb95f964 chore: delete resource file synchronously 2023-10-06 19:02:40 +08:00
boojack
ed96d65645 chore: update i18n with weblate (#2338)
* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (315 of 315 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/pt_BR/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (315 of 315 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/zh_Hans/

---------

Co-authored-by: Lincoln Nogueira <lincolnthalles@users.noreply.github.com>
Co-authored-by: Qing Long <longyinx@duck.com>
2023-10-06 05:29:50 -05:00
boojack
9c2f87ec2e chore: update i18n with weblate (#2337)
* Translated using Weblate (English)

Currently translated at 100.0% (315 of 315 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/en/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 98.4% (310 of 315 strings)

Translation: memos-i18n/i18n
Translate-URL: https://hosted.weblate.org/projects/memos-i18n/english/pt_BR/

---------

Co-authored-by: Lincoln Nogueira <lincolnthalles@gmail.com>
2023-10-05 22:49:38 -05:00
Steven
19f6ed5530 chore: remove pr preview actions 2023-10-06 11:00:17 +08:00
617 changed files with 51708 additions and 23659 deletions

View File

@@ -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
View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -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?

View File

@@ -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

View File

@@ -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

View 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 }}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -1,3 +0,0 @@
{
"recommendations": ["golang.go"]
}

View File

@@ -1,12 +0,0 @@
{
"folders": [
{
"name": "server",
"path": "../"
},
{
"name": "web",
"path": "../web"
}
]
}

View File

@@ -1,5 +0,0 @@
{
"json.schemaDownload.enable":true,
"go.lintOnSave": "workspace",
"go.lintTool": "golangci-lint",
}

View File

@@ -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

View File

@@ -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>
![demo](https://usememos.com/demo.webp)
![demo](https://www.usememos.com/demo.png)
## 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
[![Star History Chart](https://api.star-history.com/svg?repos=usememos/memos&type=Date)](https://star-history.com/#usememos/memos&Date)

View File

@@ -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).

View File

@@ -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
View 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
}

View File

@@ -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"`
}

View File

@@ -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.

File diff suppressed because it is too large Load Diff

View File

@@ -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"

View File

@@ -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"
)

View File

@@ -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.")
}

View File

@@ -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 = &currentUserID
if find.CreatorID == nil || *find.CreatorID == currentUserID {
find.CreatorID = &currentUserID
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
}(),
},
}
}

View File

@@ -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"
)

View File

@@ -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"`

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"
)

File diff suppressed because it is too large Load Diff

View File

@@ -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():

View File

@@ -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")
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
}
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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.

View 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
View 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
}

View File

@@ -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
View 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
View 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)
}
}
}

View 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)
})
}
}

View 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
}
}

View 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
}

View File

@@ -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: &currentTs,
}
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
View 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
}

View File

@@ -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: &currentTs,
}
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,

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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: &currentTs,
}
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,

View File

@@ -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

File diff suppressed because it is too large Load Diff

120
api/v2/webhook_service.go Normal file
View 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: &currentUser.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,
}
}

View 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
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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:

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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
View File

@@ -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
)

687
go.sum

File diff suppressed because it is too large Load Diff

View 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])$")
)

View File

@@ -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
View File

@@ -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)
}
}

View File

@@ -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
View 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
View 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("![%s](%s)", 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
}

View File

@@ -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)
}

View 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
}

View 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)
}

View 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}))
}
}

View 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
}

View 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}))
}
}

View File

@@ -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
}

View 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)
}

View 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}))
}
}

View File

@@ -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}))
}
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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}))
}
}

View File

@@ -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}))
}
}

View 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)
}

View 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}))
}
}

View 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
}

View 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}))
}
}

View File

@@ -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