Compare commits

...

1407 Commits

Author SHA1 Message Date
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
Steven
57c5a92427 chore: update archived memo styles 2023-10-06 00:34:40 +08:00
Steven
9410570195 chore: update version 2023-10-06 00:34:38 +08:00
Steven
c0422dea5b chore: fix sqlite migrator 2023-10-06 00:34:06 +08:00
Steven
7791fb10d8 chore: update new db driver 2023-10-05 23:19:52 +08:00
Steven
a6ee61e96d chore: update package name 2023-10-05 23:11:29 +08:00
boojack
99d9bd2d75 chore: update i18n with weblate (#2333)
* Translated using Weblate (Dutch)

Currently translated at 85.7% (271 of 316 strings)

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

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 85.4% (270 of 316 strings)

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

* Translated using Weblate (German)

Currently translated at 88.9% (281 of 316 strings)

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

* Translated using Weblate (Russian)

Currently translated at 86.7% (274 of 316 strings)

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

* Translated using Weblate (Japanese)

Currently translated at 90.8% (287 of 316 strings)

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

* Translated using Weblate (Croatian)

Currently translated at 86.3% (273 of 316 strings)

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

* Translated using Weblate (Hindi)

Currently translated at 68.0% (215 of 316 strings)

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

* Translated using Weblate (Italian)

Currently translated at 89.2% (282 of 316 strings)

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

* Translated using Weblate (Korean)

Currently translated at 89.2% (282 of 316 strings)

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

* Translated using Weblate (Chinese (Traditional))

Currently translated at 90.8% (287 of 316 strings)

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

* Translated using Weblate (Chinese (Simplified))

Currently translated at 89.8% (284 of 316 strings)

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

* Translated using Weblate (English)

Currently translated at 100.0% (316 of 316 strings)

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

---------

Co-authored-by: Jasper Platenburg <jasperdgp@outlook.com>
Co-authored-by: memos <usememos@gmail.com>
2023-10-05 08:36:23 -05:00
boojack
e7aeca736b chore: update i18n with weblate (#2332)
* 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 (Dutch)

Currently translated at 77.8% (246 of 316 strings)

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

* Translated using Weblate (Dutch)

Currently translated at 78.4% (248 of 316 strings)

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

* Translated using Weblate (Dutch)

Currently translated at 78.7% (249 of 316 strings)

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

* Translated using Weblate (Dutch)

Currently translated at 79.1% (250 of 316 strings)

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

* Translated using Weblate (Dutch)

Currently translated at 79.7% (252 of 316 strings)

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

* Translated using Weblate (Dutch)

Currently translated at 81.6% (258 of 316 strings)

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

---------

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jasper Platenburg <jasperdgp@outlook.com>
2023-10-05 04:36:00 -05:00
Steven
e85f325eb2 chore: add tranlated status badge 2023-10-05 17:32:23 +08:00
Steven
7dcc5cbaf1 chore: fix mysql migrator 2023-10-05 17:11:28 +08:00
Steven
1cdd70e008 chore: update dark mode styles 2023-10-05 16:16:02 +08:00
Steven
6a11fc571d chore: update empty icon 2023-10-05 15:52:16 +08:00
Steven
771fe394fd chore: fix initial guide 2023-10-05 15:50:16 +08:00
Steven
d474d1abd0 chore: update store cache 2023-10-05 15:17:40 +08:00
Steven
576111741b chore: downgrade zustand 2023-10-05 15:13:02 +08:00
Steven
9ac44dfbd9 chore: update mui/joy version 2023-10-05 15:08:55 +08:00
Steven
110b53b899 chore: update frontend deps 2023-10-05 15:07:10 +08:00
Steven
42e8d51550 chore: update i18n 2023-10-05 15:07:03 +08:00
Steven
fd7043ea40 chore: fix editor cache 2023-10-05 14:20:35 +08:00
Steven
34ae9b0687 chore: update default storage back to database 2023-10-05 13:36:33 +08:00
Steven
077bf95425 chore: add pinned icon in status bar 2023-10-05 13:12:03 +08:00
Steven
e465b2f0e8 chore: update i18n for auth pages 2023-10-05 12:48:40 +08:00
Steven
01ff3f73f8 chore: update auth pages 2023-10-05 12:38:46 +08:00
Steven
8aae0d00cd chore: fix fetch comments 2023-10-05 08:58:44 +08:00
Steven
16dad8b00d chore: update logo 2023-10-05 08:40:25 +08:00
Steven
7dc4bc5714 chore: update resource service 2023-10-03 23:44:14 +08:00
K.B.Dharun Krishna
2e82ba50f2 chore: bump actions/checkout; actions/setup-go to v4 (#2325)
* chore: bump actions/checkout to v4

Signed-off-by: GitHub <noreply@github.com>

* chore: bump actions/setup-go to v4

Signed-off-by: GitHub <noreply@github.com>

---------

Signed-off-by: GitHub <noreply@github.com>
2023-10-03 16:58:47 +08:00
Steven
1542f3172a chore: update tag service 2023-10-03 09:39:39 +08:00
serverless83
69d575fd5b chore: update it.json translation (#2323)
Changed translation of "private" to "Privato" instead of "Privao" (missing the letter "t")

Signed-off-by: serverless83 <35410475+serverless83@users.noreply.github.com>
2023-10-02 20:01:16 -05:00
Steven
607fecf437 chore: update store tests 2023-10-03 00:47:34 +08:00
Steven
91f7839b31 chore: update memo detail styles 2023-10-03 00:25:22 +08:00
Steven
078bc164d5 chore: update memo relations view 2023-10-02 08:26:15 +08:00
Steven
70f464e6f2 chore: update frontend deps 2023-10-01 22:14:33 +08:00
Steven
e40621eb0f chore: implement memo content views 2023-10-01 22:14:25 +08:00
Steven
fd395e5661 chore: update list memo relations 2023-10-01 21:35:17 +08:00
Steven
be046cae8e chore: add parent field to memo 2023-10-01 16:27:40 +08:00
Steven
922de07751 feat: impl memo comment api 2023-10-01 14:44:10 +08:00
Steven
7549c807ac chore: update memo view activity 2023-10-01 14:14:33 +08:00
Steven
de5eccf9d6 chore: update icon styles 2023-09-30 02:06:30 +08:00
Steven
952225d1da chore: add back to top button 2023-09-30 02:04:13 +08:00
steven
a928c4f845 chore: update error format 2023-09-29 13:04:54 +08:00
Johan
73e189ea61 chore: update fr.json (#2304)
* Update fr.json

Updated some translations

Signed-off-by: Johan <45337552+LittleJ@users.noreply.github.com>

* Apply suggestions from code review

Signed-off-by: boojack <stevenlgtm@gmail.com>

---------

Signed-off-by: Johan <45337552+LittleJ@users.noreply.github.com>
Signed-off-by: boojack <stevenlgtm@gmail.com>
Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-09-29 04:51:14 +00:00
steven
8168fb71a8 chore: update migrator 2023-09-29 12:47:49 +08:00
Steven
87ddeb2c79 chore: adjust store test for mysql 2023-09-29 09:15:54 +08:00
Athurg Gooth
255254eb69 feat: add some dev tools in the docker compose (#2309)
* Add some dev tools in the docker compose

* Merge tsc and lint
2023-09-28 14:54:11 +00:00
Athurg Gooth
c72f221fc0 feat: support mysql as backend storage driver (#2300)
* Rename checkDSN to checkDataDir

* Add option to set DSN and db driver

* Add mysql driver skeleton

* Add mysql container in compose for debug

* Add basic function for mysql driver

* Cleanup go mod with tidy

* Cleanup go.sum with tidy

* Add DeleteUser support for mysql driver

* Fix UpdateUser of mysql driver

* Add DeleteTag support for mysql driver

* Add DeleteResource support for mysql driver

* Add UpdateMemo and DeleteMemo support for mysql driver

* Add MemoRelation support for mysql driver

* Add MemoOrganizer support for mysql driver

* Add Idp support for mysql driver

* Add Storage support for mysql driver

* Add FindMemosVisibilityList support for mysql driver

* Add Vacuum support for mysql driver

* Add Migration support for mysql driver

* Add Migration support for mysql driver

* Fix ListMemo failed with referece

* Change Activity.CreateTs type in MySQL

* Change User.CreateTs type in MySQL

* Fix by golangci-lint

* Change Resource.CreateTs type in MySQL

* Change MigrationHistory.CreateTs type in MySQL

* Change Memo.CreateTs type in MySQL
2023-09-28 09:09:52 -05:00
CorrectRoadH
4ca2b551f5 chore: update seed data (#2311) 2023-09-28 09:03:32 -05:00
Athurg Gooth
5b6b2f0528 fix: apiv2 failed in container (#2307) 2023-09-28 05:45:45 -05:00
Steven
fbbfb11916 chore: adjust memo elements 2023-09-28 08:59:55 +08:00
Steven
c54febd024 chore: fix reset script 2023-09-27 18:55:26 +08:00
Athurg Gooth
5ebf920a61 chore: stop process on build error (#2295)
* Move migration and seed code into driver

* Stop process on build error
2023-09-27 11:56:39 +08:00
Athurg Gooth
5121e9f954 chore: move migration and seed code into driver (#2294)
Move migration and seed code into driver
2023-09-27 11:56:20 +08:00
Athurg Gooth
ca98367a0a chore: store vacuum and clean (#2293)
* Move all vacuum code into driver

* Remove db from Store
2023-09-26 20:27:31 -05:00
Steven
9abf294eed chore: update seed data 2023-09-27 09:13:56 +08:00
Athurg Gooth
9ce22e849c chore: move SQL code of Memo into Driver (#2292) 2023-09-27 00:57:12 +00:00
Athurg Gooth
58b84f83d1 chore: move SQL code of MemoOrganizer into Driver (#2291) 2023-09-26 19:54:50 -05:00
Athurg Gooth
acbde4fb2d chore: move SQL code of MemoRelation into Driver (#2290) 2023-09-26 19:43:46 -05:00
Steven
53090a7273 chore: show unused resources in dashboard 2023-09-27 08:09:30 +08:00
Steven
71ee299de7 chore: drop shortcut 2023-09-27 07:28:17 +08:00
Steven
9d1c9fc505 chore: regenerate swagger docs 2023-09-27 00:52:42 +08:00
Steven
03a0972712 chore: rename sqlite entry file name 2023-09-27 00:51:16 +08:00
Steven
0bddbba00e chore: fix frontend linter 2023-09-27 00:45:15 +08:00
Steven
6007f48b7d chore: retire memo resource relation table 2023-09-27 00:40:16 +08:00
Steven
4f10198ec0 chore: add tooltip to icon buttons 2023-09-26 23:48:34 +08:00
Steven
7722c41680 chore: add edit button to memo detail page 2023-09-26 23:46:58 +08:00
Steven
7cdc5c711c chore: update key of daily memo 2023-09-26 23:34:35 +08:00
Steven
4180cc3a3d refactor: migrate storage to driver 2023-09-26 19:43:55 +08:00
Steven
d6789550a0 refactor: migrate tag to driver 2023-09-26 19:37:22 +08:00
Steven
d68da34eec refactor: migrate idp to driver 2023-09-26 19:17:17 +08:00
Steven
63b55c4f65 chore: fix tests 2023-09-26 19:15:18 +08:00
Steven
96395b6d75 chore: rename package sqlite3 to sqlite 2023-09-26 19:07:14 +08:00
Athurg Gooth
d3a6fa50d6 chore: move sql code of Resource into driver (#2286)
Move sql code of Resource into driver
2023-09-26 19:04:07 +08:00
May Kittens Devour Your Soul
47f22a20ba chore: update Croatian (#2283)
* Add files via upload

Signed-off-by: May Kittens Devour Your Soul <yoshimitsu002@gmail.com>

* Update hr.json

Signed-off-by: May Kittens Devour Your Soul <yoshimitsu002@gmail.com>

* Update hr.json

Signed-off-by: May Kittens Devour Your Soul <yoshimitsu002@gmail.com>

* Delete web/src/css/prism.css

Signed-off-by: boojack <stevenlgtm@gmail.com>

---------

Signed-off-by: May Kittens Devour Your Soul <yoshimitsu002@gmail.com>
Signed-off-by: boojack <stevenlgtm@gmail.com>
Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-09-26 19:03:50 +08:00
Athurg Gooth
14ec524805 chore: move sql code of UserSetting into Driver (#2282)
* Move SQL code of UserSetting into Driver

* Fix golang import issue
2023-09-26 19:02:48 +08:00
Athurg Gooth
fcba3ffa26 chore: move sql code of User into driver (#2281)
Move SQL code of User into Driver
2023-09-26 18:23:45 +08:00
Athurg Gooth
41eba71f0f chore: split sql to driver (#2279)
* Add new database interface for SQL operations

* Move SQL code of Activity into Database

* Rename `Database` into `Driver`

* Move SQL code of SystemSetting into Driver

* Fix store.New in text code

* Change database into driver in the variables

* Change sqlite3.New into sqlite3.NewDriver
2023-09-26 17:16:58 +08:00
Steven
85ed0202d8 chore: fix user request cache 2023-09-25 20:52:29 +08:00
Steven
745902e8b1 chore: update access token order 2023-09-25 20:14:01 +08:00
Steven
ad3487a9ac chore: update username matcher 2023-09-25 20:03:58 +08:00
Steven
8c2f89edc5 chore: update demo username 2023-09-25 09:10:39 +08:00
Steven
6cff920f0c chore: update user demo data 2023-09-23 20:41:47 +08:00
Steven
27f3f6fbf0 chore: upgrade version 2023-09-23 20:41:42 +08:00
Steven
89c24415a6 chore: update not found page 2023-09-23 20:39:01 +08:00
Steven
0d803bf45f chore: update dark mode styles 2023-09-23 20:30:47 +08:00
Steven
d4e54f343d feat: update memo detail page 2023-09-23 20:14:07 +08:00
Steven
08a81e79dd chore: update frontend deps 2023-09-23 17:55:26 +08:00
Steven
cad789e948 chore: update frontend deps 2023-09-21 23:41:05 +08:00
Steven
4de18cfab1 chore: remove unused deps 2023-09-20 21:24:46 +08:00
Steven
5cec1a71da chore: update access token generator 2023-09-20 20:48:34 +08:00
Steven
ae1e22931f chore: auto remove current access token when sign out 2023-09-20 19:24:26 +08:00
Steven
a60d4dee41 chore: remove lazy loading image 2023-09-19 23:06:30 +08:00
Steven
7da10cd367 chore: update telegram integration folder 2023-09-19 22:35:20 +08:00
Steven
6d45616dbe chore: add cors middleware 2023-09-19 20:34:25 +08:00
Fabian Wünderich
ad326147f1 chore: fix typo in german localization (#2256)
Fix typo in german localization

Signed-off-by: Fabian Wünderich <fabian@wuenderich.de>
2023-09-19 20:21:32 +08:00
Steven
465b173b36 chore: fix resource int type 2023-09-19 09:05:34 +08:00
Steven
9bf1979fa8 fix: list resources 2023-09-19 08:24:24 +08:00
Steven
0a811e19ba chore: remove arm/v7 form platforms 2023-09-19 00:34:27 +08:00
Steven
e119acb0e9 chore: remove unused platform of test image 2023-09-19 00:08:58 +08:00
Steven
0f1e87bd93 chore: update test image platforms 2023-09-18 23:28:28 +08:00
Steven
f9f2f549af chore: update dockerfile 2023-09-18 23:23:13 +08:00
Steven
d665adf78b chore: remove outdate build artifacts action 2023-09-18 22:56:39 +08:00
Steven
1c27824e58 chore: upgrade version 2023-09-18 22:54:44 +08:00
Steven
14adcb56da chore: update resource description 2023-09-18 22:46:24 +08:00
Steven
b452d63fa6 chore: skip compose memo error 2023-09-18 22:41:49 +08:00
Steven
e2b82929ab chore: fix daily review params 2023-09-18 22:38:52 +08:00
Steven
8fbd33be09 chore: update username matcher 2023-09-18 22:37:13 +08:00
Steven
bff41a8957 fix: invalid username checks 2023-09-18 22:34:31 +08:00
Steven
2375001453 chore: fix acl interceptor 2023-09-18 21:50:59 +08:00
Zeng1998
462f10ab60 feat: optimize the logic of the checkbox button. (#2227) 2023-09-18 20:37:28 +08:00
Vespa314
58026c52ea fix: heatmap show on wrong date (#2243)
fix: heatmap show wrong date
2023-09-18 13:53:16 +08:00
victorsch
97b434722c fix: content sanitization in getimage endpoint (#2241) 2023-09-18 12:45:26 +08:00
Steven
b22d236b19 chore: update golangci-lint version 2023-09-17 23:21:03 +08:00
Steven
cc809a5c06 chore: update github action trigger 2023-09-17 23:18:18 +08:00
Steven
cd0ea6558d chore: update golangci-lint config 2023-09-17 22:55:13 +08:00
Steven
9eb077c4af chore: update service clients 2023-09-17 21:12:23 +08:00
Steven
6eeee6b704 docs: add buf to development guide 2023-09-17 20:56:03 +08:00
boojack
b13042d644 chore: move buf generated code to gitignore (#2236) 2023-09-17 20:55:05 +08:00
Steven
d09e3c3658 chore: remove buf es generator 2023-09-17 20:14:45 +08:00
Steven
72ca4e74ee refactor: impl part of grpcweb 2023-09-17 19:20:03 +08:00
Steven
d5c1706e9c chore: update api middlewares 2023-09-17 18:11:13 +08:00
Steven
3a1f82effa fix: migration script 2023-09-16 14:10:51 +08:00
Steven
a3d7cc9392 fix: migration script 2023-09-16 14:01:05 +08:00
Steven
178a5c0130 chore: upgrade version to 0.15.0 2023-09-16 12:46:26 +08:00
Steven
b233eaea97 chore: update docs link 2023-09-16 12:25:57 +08:00
Steven
51137e01ef chore: update resource description 2023-09-16 11:53:16 +08:00
Steven
fb1490c183 feat: impl resources list page 2023-09-16 11:48:53 +08:00
Steven
4424c8a231 chore: add resource service definition 2023-09-16 00:11:07 +08:00
Steven
723e6bcdae refactor: update resources page 2023-09-15 22:25:07 +08:00
Steven
d1156aa755 chore: update account setting styles 2023-09-15 22:09:51 +08:00
Steven
4e49d3cb22 chore: update frontend deps 2023-09-15 21:50:50 +08:00
Athurg Gooth
13c7871d20 chore: update vite dev server proxy setting (#2222)
fix vite proxy setting to keep the request headers
2023-09-15 21:49:34 +08:00
Athurg Gooth
137c8f8a07 chore: better date picker (#2220)
* Add buttons to increase year in DatePicker

* Show month with padding 0 to keep DatePicker size
2023-09-15 21:48:52 +08:00
Athurg Gooth
0c0c72c3ca chore: optimize layout of image resources (#2221)
Optimize layout of image resource
2023-09-15 17:58:17 +08:00
steven
e6a90a8be8 chore: register reflection grpc server 2023-09-15 17:57:45 +08:00
Steven
e65282dcc5 chore: fix user state loader 2023-09-15 09:10:16 +08:00
Steven
28a1888163 chore: fix user seed data 2023-09-15 08:55:18 +08:00
Steven
8824ee9b9d chore: fix user state loader 2023-09-15 08:43:52 +08:00
Steven
936fe5ac9d chore: update state initial loader 2023-09-15 08:31:19 +08:00
Steven
f5802a7d82 chore: update access token ui 2023-09-15 08:18:30 +08:00
Steven
33d9c13b7e chore: remove openid field from user 2023-09-14 22:57:27 +08:00
Steven
42bd9b194b feat: impl user access token api 2023-09-14 20:16:17 +08:00
Athurg Gooth
41e26f56e9 chore: persist selected date of DailyReview page (#2219)
* Persist selected date of DailyReview page

* Use hook useLocalStorage instead of useState

* Update web/src/pages/DailyReview.tsx

Co-authored-by: boojack <stevenlgtm@gmail.com>
Signed-off-by: Athurg Gooth <athurg@gooth.org>

---------

Signed-off-by: Athurg Gooth <athurg@gooth.org>
Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-09-14 19:57:44 +08:00
Steven
14aa3224ce chore: add readme about protobuf 2023-09-14 19:21:21 +08:00
Steven
8a796d12b4 chore: add user access token setting definition 2023-09-14 19:18:54 +08:00
Athurg Gooth
c87df8791b chore: optimize performance of /memo/stats (#2218)
Optimize performance of /memo/stats
2023-09-14 14:18:29 +08:00
Steven
f0f42aea9f chore: fix react use imports 2023-09-13 22:56:02 +08:00
Athurg Gooth
626ff5e3a7 feat: notify by telegram while new memo create by HTTP (#2215)
* Inject telegram bot into API service

* Add support for send telegram message

* Send notification by telegram while new memo post
2023-09-13 21:36:43 +08:00
Steven
36209eaef1 feat: add content cache for memo editor 2023-09-13 21:32:21 +08:00
Steven
d63715d4d9 feat: implement list memos filter 2023-09-13 20:42:44 +08:00
Kada Liao
9600fbb609 fix: multiple inline latex parsing (#2214) 2023-09-13 17:58:52 +08:00
Steven
04595a5fb1 chore: update resource icons 2023-09-13 09:12:51 +08:00
Kada Liao
9a0ada6756 feat: support LaTeX with react-katex (#2209)
feat: support latex

Co-authored-by: liaoxingyi <liaoxingyi@douban.com>
2023-09-12 23:53:46 +08:00
Steven
416e07cb1f fix: inject additional style and scripts 2023-09-12 23:43:32 +08:00
Steven
58429f88a0 fix: memo filter in daily review 2023-09-12 23:38:13 +08:00
Steven
439d88f06b chore: fix user avatar style 2023-09-12 23:35:10 +08:00
Steven
d165ad187c chore: pnpm update 2023-09-12 08:25:57 +08:00
Steven
319f679e30 chore: fix timestamp type 2023-09-10 23:52:35 +08:00
Steven
b6d1ded668 chore: adjust initial states 2023-09-10 23:44:06 +08:00
Steven
3ad0832516 chore: use user v2 api in frontend 2023-09-10 22:03:12 +08:00
Steven
93f062d0b9 chore: update user v2 api 2023-09-10 18:56:24 +08:00
Steven
866937787c chore: clean duplicated requests 2023-09-10 11:43:38 +08:00
Steven
ca336af4fa chore: update locale checks 2023-09-10 10:53:37 +08:00
Steven
7ec5d07cb8 chore: remove fullscreen button 2023-09-10 10:48:08 +08:00
Steven
2e79fe12e2 chore: remove helm folder 2023-09-10 10:39:46 +08:00
Steven
3df550927d chore: update user profile page 2023-09-10 10:33:22 +08:00
Willian Ricardo Da Silva
44be7201c0 chore: update pt-BR.json (#2196)
* chore: update pt-BR.json

* update import order
2023-09-07 09:23:12 +08:00
boojack
0d50f5bd08 chore: update comments (#2195) 2023-09-06 21:59:20 +08:00
boojack
8b1f7c52aa choer: add system setting api (#2194) 2023-09-06 21:54:12 +08:00
Athurg Gooth
9987337eca fix: all ID from int to int64 to avoid 32bits machine break (#2191)
Fix all ID from int to int64 to avoid 32bits machine break
2023-09-06 21:14:07 +08:00
Takuro Onoue
87a1d4633e chore: update ja.json (#2192)
* Update ja.json

I added the missing lines and translated.

* Update ja.json

I forgot to remove the comma at the end of the column.
I translated the untranslated portions.
2023-09-06 21:10:42 +08:00
boojack
c2aeec20b7 chore: upgrade deps version (#2181) 2023-08-27 16:08:39 +08:00
boojack
a5b3fb2a6a chore: move cron package to internal (#2180) 2023-08-26 23:13:03 +08:00
boojack
c67a69629e chore: update user menu items (#2179) 2023-08-26 23:11:45 +08:00
boojack
18fb02a1ec chore: update swag docs (#2178)
* chore: update swag docs

* chore: update
2023-08-26 08:07:43 +08:00
boojack
ad1822d308 chore: update db utils (#2177) 2023-08-26 07:33:45 +08:00
boojack
4af0d03e93 chore: add user profile page (#2175)
chore: some enhancements
2023-08-25 23:10:51 +08:00
boojack
8c312e647d chore: remove auto collapse setting (#2169) 2023-08-24 22:00:48 +08:00
boojack
d3bd3ddab0 chore: update some detail styles (#2168)
* chore: update some detail styls

* chore: update
2023-08-24 21:52:16 +08:00
Sandu Liviu Catalin
6c01e84099 feat: add configuration option to bind server to specific address (#2165) 2023-08-24 09:59:23 +08:00
boojack
b9b795bf0e chore: add react use (#2157)
* chore: add react use

* chore: update
2023-08-21 02:35:53 +08:00
boojack
19e7731abb chore: generate ts definition (#2156)
* chore: generate ts definition

* chore: update
2023-08-21 02:09:41 +08:00
ti777777
609b24f2ba chore: update zh-Hant.json (#2155)
Update zh-Hant.json

* removed unused entries
* updated the Traditional Chinese translation
2023-08-20 09:18:38 +08:00
Maciej Kasprzyk
735cfda768 fix: tag suggestions positioning (#2151) 2023-08-18 08:41:24 +08:00
Lincoln Nogueira
3f82729e9f chore: update build scripts (#2150)
update developer build scripts, bringing
feature parity between platforms.
2023-08-17 05:40:40 +08:00
Maciej Kasprzyk
077cfeb831 feat: improve tag suggestions (#2126)
* feat: make filtering case insensitive

* fix: wrong letter case when accepting suggestion

* refactor: wrap textarea in TagSuggestions

* fix: less styles not matching common-editor-inputer

* refactor: use explanatory const names for tested value in conditional checks

* feat: style highlighted option

* feat: handle down/up arrow keys

* feat: handle enter or tab to trigger autocomplete

* fix: wrong import

* fix: tab key adding whitespace after auto-completion

* fix: starting a note with a tag

* fix: close on escape

* refactor: early version of removed wrapping and children prop

* refactor: remove unnecessary return false

* refactor: finished rewriting to not wrap editor
2023-08-16 08:54:30 +08:00
boojack
95588542f9 chore: upgrade version to 0.14.4 (#2132) 2023-08-13 23:34:17 +08:00
boojack
dd529f845a fix: fetch tags in memo editor (#2131)
fix: fetch tag
2023-08-13 23:27:01 +08:00
boojack
9f8a0a8dd3 fix: lazy rendering checks (#2130) 2023-08-13 23:19:29 +08:00
boojack
e266d88edd chore: add acl config (#2128) 2023-08-13 00:06:03 +08:00
YuzeTT
0bb5f7f972 chore: update zh-Hans.json (#2127) 2023-08-12 21:34:01 +08:00
OKIAAAAA
012a5f4907 feat: add helm chart (#2095)
* add helm chart

* fix: remove unnecessary documents
2023-08-12 21:33:13 +08:00
YuNing Chen
409d686f7d chore: minor cleanup (#2124) 2023-08-11 22:34:08 +08:00
Jianwei Zhang
c835231d32 feat: add header into resource response (#2120)
Update - add header for get resource
2023-08-10 23:45:30 +08:00
boojack
723c444910 chore: update server tests (#2118) 2023-08-10 09:01:38 +08:00
boojack
35f2d399e2 chore: update api v1 docs (#2117)
* chore: update apiv1 docs

* chore: update
2023-08-09 22:30:27 +08:00
Lincoln Nogueira
4491c75135 feat: add SwaggerUI and v1 API docs (#2115)
* - Refactor several API routes from anonymous functions to regular definitions. Required to add parseable documentation comments.

- Add API documentation comments using Swag Declarative Comments Format

- Add echo-swagger to serve Swagger-UI at /api/index.html

- Fix error response from extraneous parameter resourceId to relatedMemoId in DELETE("/memo/:memoId/relation/:relatedMemoId/type/:relationType")

- Add an auto-generated ./docs/api/v1.md for quick reference on repo (generated by swagger-markdown)

- Add auxiliary scripts to generate docs.go and swagger.yaml

* fix: golangci-lint errors

* fix: go fmt flag in swag scripts
2023-08-09 21:53:06 +08:00
Chris Akring
513002ff60 chore: update zh-Hans translations for "Tag suggestions" (#2110) 2023-08-08 19:12:04 +08:00
boojack
9693940010 chore: update en locale (#2109) 2023-08-08 07:29:29 +08:00
Moris
8747c58c7d feat: fixed heat map colors, updated it.json (#2106)
* Update it.json

* Add files via upload

* Add files via upload
2023-08-08 07:13:35 +08:00
nulta
0fd791c02d feat: update Korean localization (#2105)
Update korean localization
2023-08-08 07:12:57 +08:00
Jasper Platenburg
3a804ce012 feat: update Dutch translation (#2107) 2023-08-08 07:12:43 +08:00
Ghost108
f864ec3730 chore: update de.json (#2101)
* Update de.json

I used the en.json as a template and translated it (german language)

* Update de.json

* Update de.json

* Update de.json

* Update de.json

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-08-07 20:09:48 +08:00
Chris Akring
9503f73115 feat: use user avatar in ShareMemoDialog (#2102)
fix: ShareMemoDialog use user avata
2023-08-07 19:40:43 +08:00
Derek Reiff
f9d1080a7d fix: minor spelling and wording changes for en and de (#2096)
Minor spelling and wording changes

I went through some of english and german localizations to correct or add minor things.

Added `invalid-tag-name` to json. Which also means it should be translated elsewhere.
2023-08-07 11:26:57 +08:00
boojack
4d3e4358e8 chore: update docs (#2094) 2023-08-06 11:25:35 +08:00
boojack
843850675f chore: update image displays (#2093) 2023-08-06 10:42:30 +08:00
boojack
726300394b chore: update image checks (#2092) 2023-08-06 10:38:39 +08:00
boojack
5d5d8de9fe fix: get all memo api (#2091) 2023-08-06 10:14:30 +08:00
boojack
e097e8331e chore: upgrade version 0.14.3 (#2086) 2023-08-05 22:47:29 +08:00
boojack
7189ba40d3 feat: add lazy rendering in home page (#2085) 2023-08-05 22:14:17 +08:00
boojack
218159bf83 chore: remove openai setting section (#2084) 2023-08-05 21:39:12 +08:00
boojack
238f896907 feat: add system service (#2083)
* feat: add system service

* chore: update
2023-08-05 21:30:23 +08:00
boojack
270a529948 chore: update resource type checks (#2081) 2023-08-05 20:17:33 +08:00
boojack
cc400da44e fix: remove translate hook in code block (#2080) 2023-08-05 20:01:32 +08:00
boojack
3df9da91b4 chore: update get memo api (#2079) 2023-08-05 19:51:32 +08:00
boojack
57dd1fc49f chore: initial memo service definition (#2077)
* chore: initial memo service definition

* chore: update

* chore: update

* chore: update
2023-08-05 09:32:52 +08:00
boojack
7c5296cf35 chore: update id type to int32 (#2076) 2023-08-04 21:55:07 +08:00
boojack
cbe27923b3 chore: update commands (#2074) 2023-08-03 23:48:21 +08:00
boojack
aa26cc30d7 chore: remove memo chat components (#2073) 2023-08-03 23:37:46 +08:00
boojack
1ce82ba0d6 chore: remove shortcut related api (#2072) 2023-08-03 23:33:45 +08:00
boojack
d1b0b0da10 chore: remove shortcuts in frontend (#2071) 2023-08-03 23:28:38 +08:00
Athurg Gooth
11abc45440 feat: add command to move blob from local to db (#2026)
* Add `mvrss` command to move blob from local to db

* Add comment for mvrss command
2023-08-03 19:08:39 +08:00
boojack
b5a6f1f997 chore: regenerate pnpm lock file (#2056)
* chore: regenerate pnpm lock file

* chore: update

* chore: update
2023-08-02 20:20:34 +08:00
boojack
d114b630d2 feat: add prettier sort import plugin (#2058) 2023-07-31 22:26:45 +08:00
boojack
5f819fc86f chore: update auth middleware (#2057)
* chore: update auth middleware

* chore: update

* chore: update
2023-07-31 20:55:40 +08:00
boojack
cc3a47fc65 feat: impl auth interceptor (#2055)
* feat: impl auth interceptor

* chore: update

* chore: update

* chore: update
2023-07-30 23:49:10 +08:00
Maciej Kasprzyk
5d3ea57d82 feat: tag suggestions (#2036)
* feat: figure out how to read caret position

* feat: figure out how to read caret position

* feat: create and style Editor/TagSuggestions.txs

* feat: progress on detect when to show and hide

* feat: progress on when to show and hide and setting position

* feat: toggling and exact placement done

* fix: pnpm lock problems

* feat: filter suggestions by partially typed tag name

* style: prettier

* chore: add types package for textarea-caret

* feat: handle option click

* style: prettier

* style: reorder imports

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

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-07-30 22:55:45 +08:00
Lilith
c1cbfd5766 feat: add system setting to disable password-based login (#2039)
* system setting to disable password login

* fix linter warning

* fix indentation warning

* Prohibit disable-password-login if no identity providers are configured

* Warnings and explicit confirmation when en-/disabling password-login

- Disabling password login now gives a warning and requires a second
  confirmation which needs to be explicitly typed.
- (Re)Enabling password login now also gives a simple warning.
- Removing an identity provider while password-login is disabled now
  also warns about possible problems.

* Fix formatting

* Fix code-style

---------

Co-authored-by: traumweh <5042134-traumweh@users.noreply.gitlab.com>
2023-07-30 21:22:02 +08:00
boojack
9ef0f8a901 feat: add user setting field (#2054) 2023-07-30 09:53:24 +08:00
boojack
470fe1df49 feat: implement part of user service (#2053)
* feat: implement part of user service

* chore: update

* chore: update
2023-07-30 01:35:00 +08:00
boojack
2107ac08d7 chore: add docs generator (#2052) 2023-07-30 00:12:16 +08:00
boojack
89ba2a6540 feat: implement part of tag service (#2051)
* feat: add grpc gateway tempalte

* chore: update

* chore: move directory

* chore: update
2023-07-30 00:00:49 +08:00
boojack
9cedb3cc6c chore: update github actions (#2050) 2023-07-29 20:59:22 +08:00
boojack
d0cfb62f35 chore: add tag service proto definition (#2049)
* chore: add tag proto definition

* chore: rename
2023-07-29 20:52:45 +08:00
boojack
9abf0eca1b feat: add buf configuration files and example proto (#2048)
* feat: add proto and buf configuration files

* chore: buf generate

* chore: update comments

* chore: go mod tidy
2023-07-29 19:44:09 +08:00
boojack
a6a1898c41 refactor: user v1 store (#2047) 2023-07-29 18:57:09 +08:00
boojack
f5793c142c revert: chore: update build docker image actions (#2046)
Revert "chore: update build docker image actions (#2045)"

This reverts commit 8f37c77dff.
2023-07-29 16:09:00 +08:00
boojack
8f37c77dff chore: update build docker image actions (#2045) 2023-07-29 15:59:23 +08:00
Gerald
28aecd86d3 fix: avoid content flash on auto collapse (#2042) 2023-07-29 09:04:34 +08:00
Gerald
95675cdf07 fix: show full content in detail page (#2041)
fix #1373 again
2023-07-28 15:52:50 +00:00
boojack
8328b5dd4a chore: upgrade version to 0.14.2 (#2035)
* chore: upgrade version to `0.14.2`

* chore: remove TestConcurrentReadWrite test
2023-07-26 22:42:38 +08:00
boojack
d8d6de9fca fix: get user by username api (#2034) 2023-07-26 22:41:21 +08:00
boojack
56c321aeaa revert: fix: exclude all punctuation chars except underscore in tags (#2033)
Revert "fix: exclude all punctuation chars except underscore in tags (#1974)"

This reverts commit 8c61531671.
2023-07-26 21:11:13 +08:00
Takuro Onoue
756e6a150c chore: update ja.json (#2032)
I think free means freedom, not freemiam.

Fixed some strange expressions in the heatmap section.

Added spaces before and after English words that are in Japanese sentences.
2023-07-26 20:53:14 +08:00
Takuro Onoue
828984c8ec chore: update ja.json (#2030)
Update ja.json

translated one part.
2023-07-26 08:57:53 +08:00
Harry Tran
9da0ca5cb3 feat: add search bar in archived and explore pages (#2025)
* feat: add search bar in archived and explore pages

* Update web/src/pages/Archived.tsx

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-07-24 11:09:30 +00:00
iMaeGoo
dc5f82ac9c feat: update chinese translate (#2023) 2023-07-24 19:08:46 +08:00
Ajay Kumbhare
d000083b41 fix: hashtag filter for Unicode characters (#2017) 2023-07-23 19:17:18 +08:00
Mason Sun
a9eb605b0f fix: auth api json format (#2021)
Update auth.go

api/v1/auth/相关接口未应用convertUserFromStore方法,会导致User对象获得类型存在问题,导致User定义的`json:`相关的字段转化失效。
导致输出json未被正确格式化
2023-07-23 19:11:29 +08:00
Ajay Kumbhare
5604129105 fix: empty state display issue with resourceList style Grid (#2018) 2023-07-23 19:10:33 +08:00
boojack
04b7a26c03 chore: fix request path (#2014) 2023-07-23 10:12:13 +08:00
boojack
28203bbaf9 chore: fix rss route (#2010) 2023-07-22 21:51:05 +08:00
boojack
9138ab8095 fix: rss route (#2008)
* fix: rss route

* chore: update
2023-07-22 12:58:17 +08:00
boojack
4231ec5a1a chore: upgrade version to 0.14.1 (#2004) 2023-07-22 09:58:03 +08:00
Ajay Kumbhare
55975a46d8 feat: add hindi language translation for i18n support (#2001) 2023-07-22 09:38:54 +08:00
Alexandr Tumaykin
1182545448 fix: add resource.clear to translate (#1999)
Co-authored-by: Александр Тумайкин <AATumaykin@tsum.ru>
2023-07-21 17:07:05 +08:00
Takuro Onoue
9f3c3ae094 chore: update ja.json (#1996)
It is strange to translate "about" as "Notes について". However, if we define "について" as "about", the sentence becomes "について Memos". It is better not to translate here.
2023-07-21 10:38:06 +08:00
boojack
4c33d8d762 chore: remove unused transaction in store (#1995)
* chore: remove unused transaction in store

* chore: update
2023-07-20 23:15:56 +08:00
Lincoln Nogueira
c8961ad489 fix: database is locked (#1992)
* fix: database is locked

The option "_journal_mode=WAL" is currently *not* being applied when
provided in the DSN.

This issue affects only new memos installations, not older ones where
the database journal was properly set to WAL mode by the previous sqlite
library go-sqlite3.

modernc.org/sqlite DSN parsing is different from go-sqlite3. It requires
the `_pragma=` prefix and even some options order matter.

https://gitlab.com/cznic/sqlite/-/issues/115

Closes #1985

* chore: upgraded notes on sqlite DSN
2023-07-20 20:51:25 +08:00
Alexandr Tumaykin
f91f09adea feat: use username instead of uid (#1977)
* #1916 replace userId to username

* resolve

---------

Co-authored-by: Александр Тумайкин <AATumaykin@tsum.ru>
2023-07-20 19:48:39 +08:00
Athurg Gooth
336b32004d feat: add AutoBackupInterval in SystemSetting (#1989)
Add AutoBackupInterval in SystemSetting page
2023-07-19 21:39:21 +08:00
Jerry Wang
7b5c13b712 fix: delete multiple resources
* fix: delete multiple resources, close #1986

* chore: remove useless comment
2023-07-19 21:36:02 +08:00
Jianwei Zhang
8bcc2bd715 fix: access token will expired after 24h (#1988) 2023-07-19 08:45:30 +08:00
Ajay Kumbhare
83b771d5cd fix: disable selection of future dates in daily review section (#1983)
* #1952 Fix incorrect localization key for sign-up failure message

* feat: add typeScript support to enforce valid translation keys

* feat: add typeScript support to enforce valid translation keys

* fix lint errors

* fix lint error

* chore: Disallow destructuring 't' from useTranslation

This commit adds a linting rule to disallow the destructuring of the 't' property from the result of the useTranslation function call. The no-restricted-syntax rule in the ESLint configuration has been updated to enforce this restriction. The intention is to promote alternative approaches like using the useTranslate hook for localization.

* fix: typo fixed for memoChat

* fix: copy code button toast message

Refactored the code for the "Copy Code" button to utilize i18 strings for displaying the success message. Replaced the hard-coded value with the appropriate i18 string "Code copied successfully."

* fix: #1980 disable selection of future dates in daily review section
2023-07-18 22:21:08 +08:00
EINDEX
8dbc63ed56 docs: add rowStatus parameter for memo api document (#1984)
add missing parameters for memo api
2023-07-18 22:20:22 +08:00
Felipe Martínez
8c61531671 fix: exclude all punctuation chars except underscore in tags (#1974)
* Change tag regex

* Update tests

* Add more tag tests
2023-07-18 01:53:07 +08:00
Ajay Kumbhare
b5d4b8eae8 fix: copy code button toast message (#1979)
* #1952 Fix incorrect localization key for sign-up failure message

* feat: add typeScript support to enforce valid translation keys

* feat: add typeScript support to enforce valid translation keys

* fix lint errors

* fix lint error

* chore: Disallow destructuring 't' from useTranslation

This commit adds a linting rule to disallow the destructuring of the 't' property from the result of the useTranslation function call. The no-restricted-syntax rule in the ESLint configuration has been updated to enforce this restriction. The intention is to promote alternative approaches like using the useTranslate hook for localization.

* fix: typo fixed for memoChat

* fix: copy code button toast message

Refactored the code for the "Copy Code" button to utilize i18 strings for displaying the success message. Replaced the hard-coded value with the appropriate i18 string "Code copied successfully."
2023-07-18 00:16:55 +08:00
Alexandr Tumaykin
e36e5823cd feat(security): disable access for anonymous users, when disablePublicMemos is true (#1966) 2023-07-17 09:12:53 +08:00
Ajay Kumbhare
4ac63ba1f0 chore: disallow destructuring 't' from useTranslation (#1973) 2023-07-16 21:26:26 +08:00
boojack
589b104671 chore: upgrade version to v0.14.0 (#1970)
* chore: upgrade version

* chore: update

* chore: update
2023-07-16 13:48:10 +08:00
boojack
220cba84ae chore: add dev guard for memo chat (#1968) 2023-07-16 13:02:52 +08:00
CorrectRoadH
032509ddba feat: save message to memo (#1940)
* feat: implment backend function

* feat: implment frontend component

* stash

* eslint

* eslint

* eslint

* delete node

* stash

* refactor the style

* eslint

* eslint

* eslint

* fix build error

* stash

* add dep

* feat: save message as memos

* eslint

* eslint

* Update web/src/components/MemosChat/MemosChatMessage.tsx

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

* stash

* eslint

* eslint

* chore: change translate

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-07-16 11:51:30 +08:00
5idereal
054ef3dc8d chore: update Traditional Chinese translation (#1967) 2023-07-16 10:19:08 +08:00
boojack
2effacd0a6 chore: add api docs (#1965) 2023-07-15 23:30:20 +08:00
boojack
01f4780655 chore: update detail styles (#1964) 2023-07-15 22:57:57 +08:00
Alexandr Tumaykin
49dd90578b fix: add resource.clear to en.json (#1963) 2023-07-15 20:03:49 +08:00
Ajay Kumbhare
1780225da5 feat: add typeScript support to enforce valid translation keys (#1954)
* #1952 Fix incorrect localization key for sign-up failure message

* feat: add typeScript support to enforce valid translation keys

* feat: add typeScript support to enforce valid translation keys

* fix lint errors

* fix lint error
2023-07-15 10:27:37 +08:00
boojack
5e20094386 chore: add indexes (#1959) 2023-07-15 10:26:31 +08:00
boojack
40a30d46af chore: update db connection params (#1960) 2023-07-15 10:26:19 +08:00
Alexandr Tumaykin
6b17a27a13 feat: update russian translate and new translate message (#1958)
* feat: add russian translate and new translate message

* fix

---------

Co-authored-by: Александр Тумайкин <AATumaykin@tsum.ru>
2023-07-15 10:01:40 +08:00
Felipe Martínez
2a7104e564 fix: exclude commas in tags (#1957) 2023-07-15 10:00:35 +08:00
Ajay Kumbhare
8ca2dac184 fix: incorrect localization key for sign-up failure message (#1953) 2023-07-14 21:43:46 +08:00
Athurg Gooth
d9b3501fae feat: add support for auto backup db file (#1950)
Add support for auto backup db file
2023-07-14 20:05:07 +08:00
CorrectRoadH
39351970d0 feat: implement memo chat frontend (#1938)
* feat: implment backend function

* feat: implment frontend component

* stash

* eslint

* eslint

* eslint

* delete node

* stash

* refactor the style

* eslint

* eslint

* eslint

* fix build error

* add dep

* Update web/src/components/MemosChat/ConversationTab.tsx

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

* Update web/src/components/MemosChat/ConversationTab.tsx

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

* feat: change the name

* disable for vistor

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-07-14 13:09:21 +08:00
Athurg Gooth
06dbd87311 chore: split save resource asset (#1939)
* Move resource blob save into a independent function

* Support save resouce blob from Telegram like HTTP API

* Support save resouce blob download from URL to LocalStorage or S3

* fix typo
2023-07-14 11:14:10 +08:00
Alexandr Tumaykin
c5a1f4c839 feat: format message from telegram and upload attachments (#1924)
* feat: format message from telegram and download documents

* fix: remove bool in expression

* refactor: convert to markdown

* refactor: resolve remarks and add support new message types

* refactor: resolve remarks

* feat: add test for mime type

---------

Co-authored-by: Александр Тумайкин <AATumaykin@tsum.ru>
2023-07-14 00:18:44 +08:00
Ikko Eltociear Ashimine
f074bb1be2 docs: fix typo in deploy-with-render.md (#1946)
reliablity -> reliability
2023-07-14 00:00:23 +08:00
boojack
90e7d02e35 chore: update readme (#1947) 2023-07-14 00:00:08 +08:00
boojack
437e05bd2f chore: update header style (#1945) 2023-07-13 23:15:11 +08:00
Sergei Vassiljev
934f57c92f chore: update MemoRelationListView.tsx (#1933) 2023-07-13 19:42:50 +08:00
Athurg Gooth
3093f80d68 fix: visibility param override the user auth state (#1942)
fix visibility param override the user auth state
2023-07-13 15:20:15 +08:00
Athurg Gooth
11aa01ee2e fix: visibility param override the user auth state (#1941)
fix visibility param override the user auth state
2023-07-13 14:56:43 +08:00
CorrectRoadH
d8b6e92813 feat: implement memos chat backend function (#1934)
* feat: implment backend function

* eslint

* eslint

* eslint
2023-07-13 11:25:59 +08:00
Athurg Gooth
6adbb7419c chore: split Go binary and src for dev (#1932)
Split Go binary and src for dev
2023-07-12 15:39:56 +08:00
boojack
d4b88c6c86 chore: remove auto signout in auth page (#1927) 2023-07-12 00:16:32 +08:00
boojack
698380f940 chore: update seed data (#1928) 2023-07-12 00:16:19 +08:00
Cyang39
dcac442ebf chore: change dropdown's background color in dark mode (#1925)
#1919
2023-07-11 22:37:04 +08:00
boojack
da70917b08 chore: update auth page (#1920)
* chore: update auth page

* chore: update
2023-07-09 21:13:26 +08:00
boojack
0292f472e0 chore: add data empty placeholder (#1913) 2023-07-08 13:04:12 +08:00
boojack
7e391bd53d chore: remove resource public id (#1912)
* chore: remove resource public id

* chore: update
2023-07-08 11:29:50 +08:00
Peng Ding
2157651d17 update zh-Hans & zh-Hant translations (#1909) 2023-07-07 08:40:35 +08:00
boojack
0e05c62a3b chore: update common utils (#1908) 2023-07-06 22:53:38 +08:00
boojack
a7573d5705 refactor: migrate memo to apiv1 (#1907)
* refactor: migrate memo to apiv1

* chore: update

* chore: update

* chore: update

* chore: upate

* chore: update

* chore: update
2023-07-06 21:56:42 +08:00
boojack
1fa9f162a5 refactor: migrate resource to apiv1 (#1901) 2023-07-06 00:01:40 +08:00
Athurg Gooth
5ea561af3d feat: add support for purged resource link (#1897)
Add support for purged resource link
2023-07-05 21:56:13 +08:00
Athurg Gooth
2033b0c8fa fix: skip auth on /api/v1/status to avoid sign up while token invalid (#1895)
Skip auth on /api/v1/status to avoid sign up while token invalid
2023-07-05 13:55:04 +08:00
Athurg Gooth
1c07ae2650 fix: escape on resource filename (#1892)
add escape on resource filename
2023-07-04 10:06:11 +08:00
boojack
5b6c98582e refactor: migrate storage to apiv1 (#1890)
* refactor: migrate storage to apiv1

* chore: update

* chore: update

* chore: update
2023-07-04 10:05:57 +08:00
Athurg Gooth
0af14fc81a fix: invalid orientation of image thumbnail in Apple devices (#1891) 2023-07-04 09:05:56 +08:00
Athurg Gooth
833fd23820 fix: OpenID was disappear sometimes (#1886)
Fix openid was disapear sometimes
2023-07-03 19:10:24 +08:00
Jiraiya8
d46126c5c3 fix: header archived id (#1888)
Co-authored-by: qingbo <qingbo@jingling.group>
2023-07-03 19:09:59 +08:00
Athurg Gooth
811c3e8844 fix: auto expand height of setting page (#1885) 2023-07-03 13:35:55 +08:00
boojack
223404a240 chore: update memo seed data (#1884) 2023-07-02 23:58:02 +08:00
boojack
31373be172 chore: remove unused packages (#1880) 2023-07-02 19:41:56 +08:00
boojack
66e65e4dc1 refactor: migrate definition to api v1 (#1879)
* refactor: user api v1

* refactor: system setting to apiv1

* chore: remove unused definition

* chore: update

* chore: refactor: system setting

* chore: update

* refactor: migrate tag

* feat: migrate activity store

* refactor: migrate shortcut apiv1

* chore: update
2023-07-02 18:56:25 +08:00
Phong H
b84ecc4574 chore: update vi.json locale (#1878)
Co-authored-by: phong.bnh <phong.bnh@globalfactories.com>
2023-07-02 16:41:03 +08:00
boojack
9a8d43bf88 chore: update user store names (#1877)
* chore: update user store names

* chore: update
2023-07-02 14:27:23 +08:00
boojack
ca770c87d6 chore: upgrade version to v0.13.2 (#1873) 2023-07-01 18:01:51 +08:00
boojack
c83fd1de38 chore: update dialog overflow style (#1872) 2023-07-01 17:37:43 +08:00
boojack
db8b8f0d58 chore: remove unused kit components (#1871) 2023-07-01 12:34:37 +08:00
CorrectRoadH
30fae208c2 fix: pin memos of other people (#1870) 2023-07-01 12:04:49 +08:00
boojack
5fe644a3b6 chore: add jwt middleware in apiv1 (#1869) 2023-07-01 00:03:28 +08:00
CorrectRoadH
a108f5e212 fix: ignore internalpath field when creating resource (#1868)
* fix/to_valid_token

* eslint

* revert

* fix/invalid_internalpath_file_upload
2023-06-30 23:50:55 +08:00
CorrectRoadH
c9aa2eeb98 fix: validate access token (#1867)
* fix/to_valid_token

* eslint

* revert

* Update server/jwt.go

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-06-30 14:59:52 +00:00
Vespa314
63d6b6f9f9 chore: listMemos sort by id for memos post/update at the same time (#1866) 2023-06-30 22:56:31 +08:00
boojack
847b4605f4 chore: update dark mode style (#1864) 2023-06-30 08:40:13 +08:00
boojack
6b3748e2a3 chore: update setting page styles (#1863)
* chore: update setting page styles

* chore: update
2023-06-29 23:17:01 +08:00
boojack
6a78887f1d chore: update store types name (#1862) 2023-06-29 22:55:03 +08:00
boojack
7226a9ad47 chore: update idp store (#1856) 2023-06-26 23:46:01 +08:00
boojack
b44f2b5ffb chore: migrate user setting to api v1 package (#1855)
* chore: migrate to api v1 package

* chore: update
2023-06-26 23:06:53 +08:00
boojack
07e82c3f4a fix: schema migrate (#1846)
* fix: schema migrate

* chore: update
2023-06-20 12:18:04 +08:00
boojack
b34aded376 refactor: migration idp api (#1842)
* refactor: migration idp api

* chore: update
2023-06-17 22:35:17 +08:00
boojack
4ed9a3a0ea refactor: migrate auth routes to v1 package (#1841)
* feat: add api v1 packages

* chore: migrate auth to v1

* chore: update test
2023-06-17 21:25:46 +08:00
boojack
f1d85eeaec chore: upgrade pnpm version (#1833)
* chore: upgrade pnpm version

* chore: update
2023-06-15 22:35:41 +08:00
SedationH
1b0efc5548 fix: use ?? in className use (#1829) 2023-06-15 20:05:19 +08:00
May Kittens Devour Your Soul
6dca551164 chore: update hr.json (#1832)
Update hr.json

Updated Croatan [some fixes]
2023-06-15 19:27:13 +08:00
Athurg Gooth
7694597728 feat: Add suppport for tap to reload on PWA (#1827)
* Add suppport for tap to reload on PWA

* Clean empty className

* Move click event to site title

---------

Co-authored-by: Athurg Feng <athurg@gooth.org>
2023-06-14 22:33:46 +08:00
Athurg Gooth
4d59689126 feat: set memo visibility in telegram (#1824)
* Add telegram.Bot in MessageHandler

* Change single message handler like group messages

* Move message notify wrapper from plugin to server

* Add keyboard buttons on Telegram reply message

* Add support to telegram CallbackQuery update

* Set visibility in callbackQuery

* Change original reply message after callbackQuery

---------

Co-authored-by: Athurg Feng <athurg@gooth.org>
2023-06-14 22:10:01 +08:00
May Kittens Devour Your Soul
8f7001cd9f feat: add croatian locale (#1822)
* Create hr.json

Croatian Language

* Update user_setting.go

* Update i18n.ts

* Update hr.json

* Update web/src/i18n.ts

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-06-14 22:09:19 +08:00
David Angel
781b1f7b3a chore: add classnames for easy logo/server name customizations via CSS. (#1828)
Update MemoDetail.tsx
2023-06-14 21:03:38 +08:00
Athurg Gooth
e6c9f2a00e feat: add support for download resource from link (#1800)
* Add support for download resource from link

* Parse external link and add file ext name from mime info

* Add zh-Hans locale for `download-link`

* fix typo on code and comments

* Update server/resource.go

---------

Co-authored-by: Athurg Feng <athurg@gooth.org>
Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-06-08 14:35:33 +00:00
Athurg Gooth
5d06c8093c fix: systemSetting in UI changed unexpectedly (#1812)
Fix systemSetting in UI changed unexpectedly

Co-authored-by: Athurg Feng <athurg@gooth.org>
2023-06-08 22:32:57 +08:00
kakik0u
c396cc9649 feat: add japanese locale (#1802)
* Add japanese

* Add japanese setting files

---------

Co-authored-by: barappe <121844712+barappe@users.noreply.github.com>
2023-06-08 13:50:48 +08:00
zhangpeng
ac5d8b47ca chore: modify the error message when registering a Host user (#1804)
* Modify the error message

* modify the error message
2023-06-07 12:12:23 +08:00
Athurg Gooth
77e8e9ebbd revert: fix: add public base path (#1748) (#1775) (#1806)
This reverts commit d205a7683a.
2023-06-07 10:07:30 +08:00
c14c6b3786 fix: user default 'Basic Setting' should follow server's setting (#1795)
fix: user default 'Basic Setting' should follow system setting
2023-06-07 00:38:50 +08:00
Athurg Gooth
c27c6cea13 fix: failed to upload OSS with S3 SDK (#1792)
Fix failed to upload OSS with S3 SDK

Co-authored-by: Athurg Feng <athurg@gooth.org>
2023-06-03 14:38:28 +08:00
boojack
11a385cda6 chore: update upgrade version view (#1791) 2023-06-03 14:32:04 +08:00
boojack
32e2f1d339 chore: update page routes (#1790)
chore: update routers
2023-06-03 13:03:22 +08:00
Athurg Gooth
69225b507b chore: clean Dockerfile to exclude musl-dev (#1787)
Clean Dockerfile to exclude musl-dev install

Co-authored-by: Athurg Feng <athurg@gooth.org>
2023-06-01 20:16:13 +08:00
-Shiken-
2f905eed0d chore: update zh-Hant.json (#1781)
* Update zh-Hant.json

follow these commits to complete zhTW translation:
96021e518a
8628d1e4b2
93d608f050
ce64894abe
845297ec03

* Update web/src/locales/zh-Hant.json

Co-authored-by: Zeng1998 <acan.coder@gmail.com>

* Fix json format error

---------

Co-authored-by: Zeng1998 <acan.coder@gmail.com>
Co-authored-by: Athurg Gooth <github@gooth.org>
2023-06-01 09:03:44 +08:00
Athurg Gooth
55cf19aa2e fix: copy-to-clipboard not works well in Safari (#1779)
* Fix copy-to-clipboard not works well in Safari

* Fix typescript type check failure

* Remove global copy inject in home page

---------

Co-authored-by: Athurg Feng <athurg@gooth.org>
2023-06-01 08:53:40 +08:00
boojack
dd8c10743d feat: memo editor dialog (#1772)
* feat: memo editor dialog

* chore: update mark

* chore: update
2023-05-30 20:23:26 +08:00
Athurg Gooth
25ce36e495 feat: resource visibility (#1777)
* Add method to query visibility list by memoIDs

* Add function to get visibility by resourceID

* Check resource visibility in /r/:resourceId/:publicId/:filename

* Check resource visibility in /r/:resourceId/:publicId

---------

Co-authored-by: Athurg Feng <athurg@gooth.org>
2023-05-30 19:00:54 +08:00
Gener
d205a7683a fix: add public base path (#1748) (#1775) 2023-05-30 18:55:23 +08:00
Athurg Gooth
7e4d71cf58 fix: infinite loop in home page (#1773)
Fix infinite loop in home page

Co-authored-by: Athurg Feng <athurg@gooth.org>
2023-05-30 18:54:58 +08:00
Athurg Gooth
97df1a82c7 feat: add docker compose file for development (#1769)
* Add support to fetch devProxyServer from environment

* Add docker compose file for developer

---------

Co-authored-by: Athurg Feng <athurg@gooth.org>
2023-05-29 19:49:47 +08:00
Athurg Gooth
845297ec03 refactor: change all Robot to Bot (#1767)
* Change all `Robot` to `Bot`

* Change all `r` of `Bot` to `b`

* Change `Robot` to `bot` in comments

* Fix typo

---------

Co-authored-by: Athurg Feng <athurg@gooth.org>
2023-05-29 19:49:05 +08:00
Athurg Gooth
ddf4cae537 feat: disable CGO_ENABLED (#1766)
* Replace mattn/go-sqlite3 with modernc.org/sqlite

* Disable CGO to make binary work without special c lib

* Replace mattn/go-sqlite3 with modernc.org/sqlite in testing code

* Tidy go module

---------

Co-authored-by: Athurg Feng <athurg@gooth.org>
2023-05-29 13:29:42 +08:00
Athurg Gooth
ce64894abe feat: add telegram proxy support (#1764)
* Add support for reverse proxy of telegram API

* Add Telegram API proxy hint

---------

Co-authored-by: Athurg Feng <athurg@gooth.org>
2023-05-29 13:29:21 +08:00
boojack
beb4d8ccb9 chore: order by updated ts in memo store (#1761) 2023-05-28 02:04:05 +08:00
boojack
e0e59c5831 feat: display memo with updated ts (#1760) 2023-05-28 01:50:09 +08:00
_Jellen
826541a714 feat: update korean localization (#1758) 2023-05-27 22:03:06 +08:00
boojack
c40aeb91e6 fix: patch memo row status (#1755) 2023-05-27 11:14:23 +08:00
boojack
2e34ce90a1 chore: upgrade version 0.13.1 (#1754) 2023-05-27 09:09:41 +08:00
GodMeowIceSun
93d608f050 feat(#1568): add "ask ai" section session splitting function (#1711)
* feat(#1568): Added "ask ai" section session splitting function

Added "ask ai" section session splitting function
Optimize the "ask ai" dialogue style

* fix(#1568): Fix wrong attribute "appearance"

* fix(#1568): Add ts type define

* fix(#1568): Add ts type define

* fix(#1568): Resolve the issue of components not being stretched when only user input is available

* feat(#1568): New session automatic switching function

* refactor(#1729): remove unused code

* feat(#1568): New Remove Session Function

New Remove Session Function
Rename some methods
2023-05-27 02:29:54 +08:00
boojack
ec26a9702d fix: sso templates (#1753) 2023-05-26 22:25:30 +08:00
boojack
dbe8aa1d3a chore: update telegram bot related section (#1750)
* chore: update telegram bot related section

* chore: update
2023-05-26 21:32:44 +08:00
Athurg Gooth
8628d1e4b2 feat: add Telegram bot config UI (#1747)
* Add retry wait for telegram.GetUpdates

* Add support to set telegram robot token from UI

* Change validator of UserSettingTelegramUserID

* Add support to set telegram user id from UI

* Fix typescript check

* Add validator for SystemSettingTelegramRobotTokenName

* Optimize error notice while config telegram params

* Change for review

* Fix telegram user id could not be empty

* Fix telegram robot could not be empty

* Fix for eslint (again)

* Update web/src/components/Settings/SystemSection.tsx

---------

Co-authored-by: Athurg Feng <athurg@gooth.org>
Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-05-26 19:16:51 +08:00
Athurg Gooth
4ea5426e18 feat: add support for content search (#1728)
* Change MemoFind.ContentSearch to slice

* Add support for content search

* Change for go-simple sugguest

---------

Co-authored-by: Athurg Feng <athurg@gooth.org>
2023-05-26 18:51:18 +08:00
Athurg Gooth
1282fe732e feat: implement telegram bot plugin (#1740) 2023-05-26 09:43:51 +08:00
boojack
a07d11e820 feat: tag parser (#1745) 2023-05-26 09:05:17 +08:00
boojack
dbc85fe7e4 feat: image and link parser (#1744)
* feat: image and link parser

* chore: update
2023-05-26 08:43:37 +08:00
boojack
523ef2bba5 chore: update demo banner style (#1743) 2023-05-26 00:47:53 +08:00
boojack
de8014dfe8 feat: resource store cache (#1742) 2023-05-26 00:38:27 +08:00
deeshu
b42e5c3213 chore: update vscode setting enforcement for go111module="on" (#1738)
vscode setting enforcement for go111module to set on and availability of schema for go extension
2023-05-25 22:00:32 +08:00
boojack
ea728d232d refactor: memo store (#1741) 2023-05-25 21:50:37 +08:00
boojack
43819b021e chore: add demo banner (#1739) 2023-05-25 19:29:30 +08:00
boojack
e69f7c735b chore(revert): retire demo site (#1733)
Revert "chore: retire demo site (#1659)"

This reverts commit cd2bdab683.
2023-05-24 20:34:07 +08:00
Athurg Gooth
5e792236af fix: infinite loop while daily memos more than DEFAULT_MEMO_LIMIT (#1730)
Co-authored-by: Athurg Feng <athurg@gooth.org>
2023-05-24 20:22:16 +08:00
boojack
45c119627b chore: move flags into env variables (#1732) 2023-05-24 20:08:47 +08:00
boojack
65890bc257 feat: implement code block parser (#1727) 2023-05-24 00:31:37 +08:00
boojack
42c653e1a4 feat: implement paragraph and italic parsers (#1725) 2023-05-23 21:11:01 +08:00
boojack
8c34be92a6 feat(gomark): add bold parser (#1724) 2023-05-23 20:49:32 +08:00
boojack
fa53a2550a feat: add heading tokenizer (#1723) 2023-05-23 19:52:31 +08:00
Athurg Gooth
616b8b0ee6 feat: introduce publicid to filename template (#1713)
* Add support for `publicid` in PathTemplate

* Use `publicid` by default instead of `filename` in filesystem

* Fix blank string of `systemSettingLocalStoragePath` affect incorrectly

* Add ext name to compatible with OS's preview

* Optimize code for systemSettingLocalStoragePath empty

---------

Co-authored-by: Athurg Feng <athurg@gooth.org>
2023-05-23 19:15:30 +08:00
Athurg Gooth
d24632682f chore: add volume define in Dockerfile to avoid data lose (#1718)
Add volume define in Dockerfile to protect data

Co-authored-by: Athurg Feng <athurg@gooth.org>
2023-05-23 18:50:17 +08:00
Athurg Gooth
3b1bab651a fix: system memo visibility was replace by user's setting (#1707)
Co-authored-by: Athurg Feng <athurg@gooth.org>
2023-05-22 12:15:00 +08:00
Athurg Gooth
0cea5ebaeb fix: concurrent counter operates (#1706)
Co-authored-by: Athurg Feng <athurg@gooth.org>
2023-05-22 11:08:49 +08:00
Zeng1998
1e4a867a9a fix: add thumbnail param only for internal link (#1701)
* fix: add thumbnail param using `URLSearchParams`

* update: add thumnail param only for internal link
2023-05-21 16:41:04 +08:00
CorrectRoadH
6bb0b4cd47 fix: pop search when touch textarea in Android Chrome (#1700)
* fix: pop search when touch textarea

* eslint
2023-05-21 16:31:50 +08:00
CorrectRoadH
56c6f603aa fix: md without search icon (#1699) 2023-05-21 16:28:30 +08:00
boojack
98b3a371f4 fix: patch memo visibility (#1695) 2023-05-21 11:51:13 +08:00
boojack
ba8e1e5dc2 chore: add available generator amount flag (#1696) 2023-05-21 11:50:57 +08:00
boojack
467f9080a1 feat: get or generate thumbnail image (#1691) 2023-05-20 22:08:07 +08:00
Athurg Gooth
0894bf13d2 fix: fail to open file while generate thumbnail (#1687)
* Fix fail to open file while generate thumbnail

* Fix for Uncontrolled data used in path expression check

---------

Co-authored-by: Athurg Feng <athurg@gooth.org>
2023-05-20 14:33:59 +08:00
boojack
1d7627dd72 chore: upgrade version 0.13.0 (#1685)
* chore: upgrade version `0.13.0`

* chore: update
2023-05-20 10:16:19 +08:00
boojack
d80aa67c97 feat: parse markdown to html format in rss (#1683) 2023-05-20 10:00:21 +08:00
boojack
ae1d9adf65 fix: initial system locale (#1684) 2023-05-20 09:39:20 +08:00
boojack
b40571095d feat: update memo detail page (#1682)
* feat: update memo detail page

* chore: update
2023-05-20 08:39:39 +08:00
Athurg Gooth
04124a2ace feat: generate thumbnail while get and improve thumbnail quality (#1680)
* Use disintegration/imaging to optimize thumbnail quality

* Generate thumbnail if not exists while GET it

* Changes for `go mod tidy`

* Changes for golang comments lint

---------

Co-authored-by: Athurg Feng <athurg@gooth.org>
2023-05-19 20:07:39 +08:00
Athurg Gooth
2730b90512 feat: highlight the DatePicker's date (#1669)
Co-authored-by: Athurg Feng <athurg@gooth.org>
2023-05-19 08:36:08 +08:00
Athurg Gooth
34913cfc83 feat: show thumbnail in resource dashboard (#1666)
* Add image thumbnail instead of an icon

* Change thumbnail size of dashboard to fixed

* Fix for eslint-checks

* Fix for eslint-checks

* Replace css with tailwind

* Remove the parent div used for style

* Show preview while click on the resource

* Change for review Suggested by @Zeng1998

---------

Co-authored-by: Athurg Feng <athurg@gooth.org>
2023-05-19 08:31:32 +08:00
boojack
88799d469c chore: initial gomark plugin (#1678)
chore: initial gomark folder
2023-05-18 21:33:18 +08:00
boojack
a07d5d38d6 feat: memo relation part1 (#1677)
* feat: memo relation part1

* chore: update
2023-05-18 21:29:28 +08:00
Stephen Zhou
ca5859296a fix: resource url in rss (#1672) 2023-05-18 06:53:20 +08:00
boojack
1a8310f027 chore: update system setting default value (#1665) 2023-05-15 22:59:26 +08:00
Athurg Gooth
041be46732 Add support for image thumbnail (#1641)
* Add a common function for resize image blob

* Auto generate thumbnail for image resources

* Auto thumbnail support for fetch image resources

* Add support for image thumbnail in view

* Fix missing error check

* Fix es-lint check

* Fix uncontrolled data used in path expression

* Remove thumbnail while origin resource been deleted

* Change the thumbnail's storage path

---------

Co-authored-by: Athurg Feng <athurg@gooth.org>
2023-05-15 22:42:12 +08:00
Zeng1998
9eafb6bfb5 chore: add the default value of MaxUploadSizeMiB (#1663) 2023-05-15 21:54:45 +08:00
Lincoln Nogueira
668a9e88c6 fix: File size exceeds allowed limit of 0 MiB (#1664)
fix: File size exceeds allowed limit of 0 MiB

This could happen in databases without "max-upload-size-mib" setting.

Now, both the front-end and the back-end will start with a default
limit of 32 MiB, even if the key is absent.

It is still possible to disable uploads by setting the value to 0.
2023-05-15 21:54:13 +08:00
boojack
cd2bdab683 chore: retire demo site (#1659) 2023-05-14 23:35:31 +08:00
CorrectRoadH
d72b4e9a98 feat: filter support plain link (#1657)
* fix: unexpected empty lines when copying-pasting

* add ref

* feat: support to filter plain link

* eslint

* fix the typo

* fix the typo

* unified the import path
2023-05-14 23:17:18 +08:00
boojack
2cc5691efd chore: update memo relation types (#1658) 2023-05-14 23:17:05 +08:00
guanzi008
7726ed4245 feat: add build-artifacts.yml (#1583)
* Create main.yml

构建的单文件运行版本几个平台的Windows amd64 ,Linux amd64,Linux arm64

* Rename main.yml to build-artifacts.yml

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-05-13 14:52:50 +00:00
boojack
921d4b996d chore: update help button style (#1656) 2023-05-13 22:52:06 +08:00
Lincoln Nogueira
96021e518a feat: add max upload size setting to UI & UI improvements (#1646)
* Add preliminar Windows support for both
development and production environments.

Default profile.Data will be set to "C:\ProgramData\memos" on Windows.
Folder will be created if it does not exist, as this behavior is
expected for Windows applications.

System service installation can be achieved with third-party tools,
explained in docs/windows-service.md.

Not sure if it's worth using https://github.com/kardianos/service
to make service support built-in.

This could be a nice addition alongside #1583 (add Windows artifacts)

* feat: improve Windows support

- Fix local file storage path handling on Windows

- Improve Windows dev script

* feat: add max upload size setting to UI & more

- feat: add max upload size setting to UI

- feat: max upload size setting is checked on UI during upload,
but also enforced by the server

- fix: overflowing mobile layout for Create SSO, Create Storage
and other Settings dialogs

- feat: add HelpButton component with some links to docs were appropriate

- remove LearnMore component in favor of HelpButton

- refactor: change some if/else to switch statements

- refactor: inline some err == nil checks

! Existing databases without the new setting 'max-upload-size-mib'
will show an upload error, but this can be user-fixed by simply
setting the value on system settings UI.

* improvements requested by @boojack
2023-05-13 22:27:28 +08:00
boojack
5c5199920e chore: seed data for new user (#1655) 2023-05-13 22:25:15 +08:00
CorrectRoadH
e1c809d6f1 fix: unexpected empty lines when copying-pasting (#1654) 2023-05-13 22:08:54 +08:00
Athurg Gooth
218009a5ec fix: wrong position of UsageStatItem's popup (#1647)
* fix: wrong position of UsageStatItem's popup

* Replace TAB into Space for eslint

---------

Co-authored-by: Athurg Feng <athurg@gooth.org>
2023-05-12 22:07:53 +08:00
Lincoln Nogueira
5340008ad7 feat: improve Windows support (#1645)
* Add preliminar Windows support for both
development and production environments.

Default profile.Data will be set to "C:\ProgramData\memos" on Windows.
Folder will be created if it does not exist, as this behavior is
expected for Windows applications.

System service installation can be achieved with third-party tools,
explained in docs/windows-service.md.

Not sure if it's worth using https://github.com/kardianos/service
to make service support built-in.

This could be a nice addition alongside #1583 (add Windows artifacts)

* feat: improve Windows support

- Fix local file storage path handling on Windows

- Improve Windows dev script
2023-05-10 08:03:55 +08:00
CorrectRoadH
700fe6b0e4 fix: return pinned status after edit (#1629)
* stash

* query pinned status after patch

* eslint

* refactor query

* eslint

* process specify case

* add test

* Update memo.go
2023-05-09 09:03:09 +08:00
boojack
9b8d69b2dd chore: add vacuum memo relation to dev guard (#1644)
* chore: add vacuum memo relation to dev guard

* chore: update
2023-05-09 09:02:59 +08:00
Zeng1998
84546ff11c chore: show server name instead of "MEMOS" (#1639) 2023-05-09 08:24:13 +08:00
Zeng1998
885a0ddad0 chore: add size for s3 resource (#1638) 2023-05-09 08:17:26 +08:00
Lincoln Nogueira
3b76c6792c feat: add preliminar Windows support (#1636)
Add preliminar Windows support for both
development and production environments.

Default profile.Data will be set to "C:\ProgramData\memos" on Windows.
Folder will be created if it does not exist, as this behavior is
expected for Windows applications.

System service installation can be achieved with third-party tools,
explained in docs/windows-service.md.

Not sure if it's worth using https://github.com/kardianos/service
to make service support built-in.

This could be a nice addition alongside #1583 (add Windows artifacts)
2023-05-09 08:16:38 +08:00
jonny
4605349bdc chore: update Chinese name translation (#1630) 2023-05-06 07:36:22 +08:00
Stephen Zhou
ff447ad22b feat: support file sorting when uploading (#1627) 2023-05-03 19:18:29 +08:00
Stephen Zhou
c081030d61 chore: lock pnpm version (#1628) 2023-05-03 19:14:21 +08:00
boojack
e3496ac1a2 refactor: memo editor components (#1625) 2023-05-03 19:13:37 +08:00
boojack
8911ea1619 chore: update related time format (#1621)
chore: update related time
2023-05-02 08:54:51 +08:00
boojack
34700a4c52 chore: check allow sign up setting in sso (#1620) 2023-05-02 08:45:03 +08:00
boojack
b6564bcd77 feat: implement memo relation server (#1618) 2023-05-01 16:09:41 +08:00
Peng Ding
6e6aae6649 feat: update zh-Hans translations and minor fixes in locale_updater.py (#1615)
* update zh-Hans translations

* minor update

* update instruction part

* print json_value for debugging purpose

* update post requests related

* machine translate with chunks to get around {{field}}
2023-05-01 13:27:08 +08:00
João Nuno Mota
b98f85d8a7 feat: add infinite scroll for memos (#1614)
Add infinite scroll for memos on home
2023-05-01 13:26:15 +08:00
João Nuno Mota
3314fe8b0e fix: failed eslint checks (#1616) 2023-05-01 08:38:35 +08:00
powersee
f12163bc94 feat: add linux/arm/v7 to docker build action (#1610) 2023-04-29 00:40:15 +08:00
CorrectRoadH
f7a1680f72 fix: only delete last file when select multiple files #1576 (#1578)
* fix the bug can't delete multiple files #1576

* using useEvent instead of useRef

* delete unused code

* delete unused code

* change hook file name

* refactor the useEvent

* delete unnecessary export

* fix import

* Apply suggestions from code review

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-04-28 16:17:08 +00:00
boojack
4603f414db chore: add system setting cache (#1609) 2023-04-28 00:02:54 +08:00
deeshu
884dca20b3 fix: reappearing of dialog should add body scrolling class (#1602)
When dialog is reappeared after being in a hidden state. Then reappeaning should block further body scrolling for consistent UX.
2023-04-27 07:16:15 +08:00
Max Malm
dbb544dc92 feat: read content from search params (#1607) 2023-04-27 07:15:40 +08:00
deeshu
3fad718807 fix: memo content availability for visitor mode (#1605) 2023-04-26 21:57:01 +08:00
boojack
fab8a71fd2 feat: implement memo relation store (#1598)
* feat: implement memo relation store

* chore: update
2023-04-25 23:27:38 +08:00
Barry
7776a6b7c6 docs: update readme with MemosGallery (#1590)
- add: https://github.com/BarryYangi/MemosGallery

A simple gallery static page based on the memos api, I think it might be useful for some people, so I just post it up. Close this if not necessary.
2023-04-25 22:27:02 +08:00
boojack
cd6ab61c2d chore: add memo_relation (#1585) 2023-04-25 22:26:45 +08:00
_Jellen
00f69d683a feat: update Korean translation (#1592)
Update Korean translation

- add missing keys
- polish some translations
- remove trailing whitespaces
2023-04-23 20:13:34 +08:00
boojack
0e70de4003 chore: split memo resource api (#1587) 2023-04-22 10:42:24 +08:00
boojack
35efa927b6 chore: update readme with docs (#1586) 2023-04-22 10:25:32 +08:00
Antzed
1ff03e87c2 docs: upgrade on fly.io (#1582) 2023-04-22 09:34:36 +08:00
boojack
edf934efbb chore: update memo style (#1581) 2023-04-21 14:46:41 +08:00
-Shiken-
d0815f586e feat: update zh-tw translation to latest file format (#1569)
* update zh-tw translation to latest file format

* Update zh-Hant.json

* Update web/src/locales/zh-Hant.json

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-04-19 20:22:23 +08:00
Yang
685a23bce8 feat: add auto collapse feature for all memos issue #1463 (#1550)
* add auto collapse feature

* fix some styles

* pass eslint

---------

Co-authored-by: liyang <liyangg@umich.edu>
2023-04-18 10:05:36 +08:00
boojack
0aa7085303 chore: add enclosure to rss (#1559) 2023-04-17 23:26:56 +08:00
boojack
994d5dd891 feat: server tests (#1556)
* feat: server tests

* chore: update
2023-04-17 21:34:59 +08:00
deeshu
e62a94c05a feat: hiding dialog using X button should remove class "overflow-hidden" (#1555)
Hiding dialogs result in the body to stay frozen due to mounting behaviour of the dialog, but using 'X' button hides the dialog and won't let user scroll any further. Removing overflow behaviour during hiding procedure will improve User Experience.
2023-04-17 20:13:33 +08:00
Manu
2b83572641 fix: the broken install docs link (#1554) 2023-04-17 19:00:13 +08:00
boojack
5f8aae69e4 chore: update save button style (#1542) 2023-04-16 15:47:01 +08:00
boojack
73b8d1dd99 fix: revert hide ask ai button (#1539) 2023-04-16 10:55:44 +08:00
boojack
58fa00079b chore: update version to 0.12.2 (#1538) 2023-04-16 10:40:21 +08:00
boojack
3060dafb45 chore: update resource link template (#1537) 2023-04-16 10:31:03 +08:00
boojack
5cb436174d chore: remove search key binding (#1536) 2023-04-16 10:03:33 +08:00
boojack
541fd9c044 chore: update window resize listener (#1535) 2023-04-16 10:00:49 +08:00
boojack
7d6934d00c fix: rss link (#1534) 2023-04-16 09:51:03 +08:00
João Nuno Mota
2c328a4540 feat: hide ask ai button when key is empty (#1515)
* Add option to hide Ask AI and update dev version

* Fix formatting according to eslint

* Replace option to hide Ask AI with auto hiding based on config

* Fix golangci-lint errors

* Remove showAskAI logic from OpenAPI
2023-04-16 00:54:33 +08:00
boojack
648634d376 chore: use pnpm (#1533)
* chore: use pnpm

* chore: update
2023-04-16 00:47:40 +08:00
Fog3211
a654a1cb88 fix: toast overload max size error (#1531)
Co-authored-by: Fog3211 <23151576+Fog3211@users.noreply.github.com>
2023-04-16 00:39:31 +08:00
boojack
ef02519e72 chore: regenerate yarn lock file (#1530) 2023-04-15 09:12:45 +08:00
Lincoln Nogueira
557278fac0 feat: improve i18n support as a whole (#1526)
* feat: improve i18n support as a whole

- Remove dayjs in favor of /helpers/datetime.ts, which uses
Intl.DateTimeFormat and Date. Dayjs is not exactly i18n friendly
and has several locale related opened issues.

- Move/refactor date/time code from /helpers/utils.ts to
/helpers/datetime.ts.

- Fix Daily Review weekday not changing according to selected date.

- Localize Daily review weekday and month.

- Load i18n listed strings from /locales/{locale}.json in a dynamic way.
This makes much easier to add new locales, by just adding a properly
named json file and listing it only in /web/src/i18n.ts and
/api/user_setting.go.

- Fallback languages are now set in /web/src/i18n.ts.

- Full language codes are now preffered, but they fallback to 2-letter
codes when not available.

- The locale dropdown is now populated dynamically from the available
locales. Locale names are populated by the browser via
Intl.DisplayNames(locale).

- /web/src/i18n.ts now exports a type TLocale from availableLocales
array. This is used only by findNearestLanguageMatch(). As I was unable
to use this type in ".d.ts" files, I switched the Locale type from
/web/src/types/i18n.d.ts to string.

- Move pretty much all hardcoded text strings to i18n strings.

- Add pt-BR translation.

- Remove site.ts and move its content to a i18n string.

- Rename zh.json to zh-Hans.json to get the correct language name on
selector dropdown.

- Remove pt_BR.json and replace with pt-BR.json.

- Some minor layout spacing fixes to accommodate larger texts.

- Improve some error messages.

* Delete .yarnrc.yml

* Delete package-lock.json

* fix: 158:28  error  Insert `⏎`  prettier/prettier
2023-04-15 08:56:03 +08:00
Zeng1998
5652bb76d4 fix: incorrect date parsing (#1527)
* fix: incorrect date parsing

* fix eslint
2023-04-15 00:54:48 +08:00
Alex Zhao
d0c40490a7 feat: add HostnameImmutable to aws endpoint config (#1230)
* add config to support S3-compatible urls like minio

* add comment for HostnameImmutable

* fix linting
2023-04-15 00:17:48 +08:00
CorrectRoadH
630d84348e feat: add resource backend unit test (#1521)
* add resource unit test

* add more resource unit test

* change variable name

* add more test cases

* delete unnecessary line

* eslint

* add more asset
2023-04-13 19:55:18 +08:00
CorrectRoadH
81d4f01b7f feat: add e2e test (#1486)
* add i18n

* add base e2e test

* add multiple test for e2e

* extract the funciton of write memo

* change test sturct

* deteled unused dir

* use fixture

* add fixture

* restruced the project

* feat: add workflow

* feat: change playwright test position

* feat: change playwright test position

* using yarn intead of npm

* change install method

* only enable sign in test

* adjust the order of test

* change report pos

* fix style of e2e workflow

* add review test

* unify locale

* randome write content

* change report pos

* reduce unused wait time

* reduce unused folder

* stash

* merge upstream locale

* change test name

* add test item

* change action name

* add lanuage setting

* add shotscreen

* change name of test

* fix the error of import dep

* fix the error of import dep

* fix the error of filename

* fix the format of workflow

* fix the name error of test case

* feat: change the describe of test case

* feat: remove unused test

* feat: change the fixtures name

* feat: remove unused config

* feat: change docker action

* feat: change the generate method

* feat: extrace screenshot

* feat: change extra path

* feat: change extra path

* feat: screenshot and upload

* feat: change upload filename

* feat: change login method

* feat: change e2e method

* feat: change e2e test

* feat: add wait for login

---------

Co-authored-by: CorrectRoadH <a778917369@gmail.comå>
2023-04-11 22:13:06 +08:00
boojack
b5c665cb7e chore: update docker image source (#1511) 2023-04-10 23:39:46 +08:00
Gabe Cook
836387cada fix(ci): fix release builds not having any tags (#1507) 2023-04-10 08:08:38 +08:00
Peng Ding
0020498c10 feat: update Chinese translations in zh.json and zh-Hant.json using locale_updater.py (#1506)
update zh.json and zh-Hant.json using locale_updater.py
2023-04-09 20:57:50 +08:00
_Jellen
66ed43cbcb feat: update and refactor Korean translation data (#1505)
refactor and update ko.json
- added missing translation keys into ko.json
- synced the JSON key order of ko.json with en.json
- removed unused translation data
2023-04-09 20:12:20 +08:00
Gabe Cook
c6e1d139f8 feat(ci): Add GHCR mirror and major/minor rolling tags (#1503) 2023-04-09 16:32:27 +08:00
boojack
ef7381f032 chore: upgrade version to 0.12.1 (#1499) 2023-04-09 11:51:43 +08:00
boojack
df30304d00 chore: update share memo buttons (#1498) 2023-04-09 11:38:30 +08:00
boojack
91a24ef9ce chore: update memo header (#1497)
* chore: update memo header

* chore: update
2023-04-09 11:05:09 +08:00
Luyu Cheng
d11083d3b9 fix(css): reorder the font fallback list (#1495) (#1496) 2023-04-09 09:48:42 +08:00
boojack
680b8ede6c chore: adjust header style (#1493) 2023-04-08 21:50:07 +08:00
boojack
4e023e2500 chore: add file type to audio (#1492)
* chore: add file type to audio

* chore: update
2023-04-08 19:16:25 +08:00
boojack
3eac19d258 chore: add ignore version upgrade setting (#1491) 2023-04-08 18:13:51 +08:00
boojack
ab867b68d3 chore: add timezone env to dockerfile (#1490) 2023-04-08 17:57:00 +08:00
boojack
8cdc662745 chore: update memo visibility display (#1485) 2023-04-07 08:53:20 +08:00
boojack
204c03e772 chore: update audience name (#1484) 2023-04-07 08:52:13 +08:00
boojack
d0ddac296f chore: update store error handler (#1479) 2023-04-06 07:42:39 +08:00
HappyZ
609366da6e chore: add "copy link" for each memo (#1474) 2023-04-06 07:12:12 +08:00
boojack
f48d91539e chore: update locale file structure (#1478) 2023-04-06 00:07:10 +08:00
boojack
cc23f69f66 chore: update import path (#1477) 2023-04-05 23:31:15 +08:00
Zeng1998
6ceafc1827 fix: unexpected reset of the storage setting (#1475) 2023-04-05 14:37:02 +08:00
Sönke Werner Köster
8c2224ae39 feat: allow instance moderators to post public via the API (#1464) 2023-04-04 22:28:20 +08:00
boojack
6ff7cfddda fix: return external link directly (#1465)
* fix: return external link directly

* chore: update
2023-04-04 08:31:11 +08:00
boojack
5361f76b11 chore: update filename when creating resource (#1460) 2023-04-03 23:16:43 +08:00
boojack
bdc00d67b2 chore: add default local storage path (#1457) 2023-04-03 17:13:41 +08:00
boojack
5caa8cdec5 chore: delete resource related file (#1456) 2023-04-03 17:02:47 +08:00
boojack
9ede3da882 chore: update learn more link (#1455) 2023-04-03 15:38:14 +08:00
boojack
836e496ee0 chore: allow remove user avatar (#1454) 2023-04-03 14:52:36 +08:00
boojack
5aa4ba32c9 fix: system setting field name (#1453) 2023-04-03 14:40:29 +08:00
boojack
4419b4d4ae chore: update version and remove isDev flag (#1452)
* chore: update version and remove isDev flag

* chore: update
2023-04-03 14:13:22 +08:00
boojack
1cab30f32f feat: add public id field to resource (#1451)
* feat: add public id field to resource

* feat: support reset resource link
2023-04-03 13:41:27 +08:00
boojack
c9a5df81ce chore: update store tests (#1449) 2023-04-03 09:53:36 +08:00
boojack
4f2adfef7b chore: update system setting name convention (#1448) 2023-04-03 09:36:34 +08:00
boojack
8a33290722 chore: update user setting key convention (#1447)
* chore: update user settng key convention

* chore: update
2023-04-03 09:02:02 +08:00
boojack
11cd9b21de chore: update auth form (#1445) 2023-04-02 14:25:38 +08:00
boojack
41c50e758a chore: revert resource visibility changes (#1444) 2023-04-02 14:09:25 +08:00
boojack
d71bfce1a0 chore: add usage into heatmap (#1443) 2023-04-02 11:56:09 +08:00
boojack
1ea65c0b60 chore: update logo (#1442)
* chore: update logo

* chore: update
2023-04-02 09:54:52 +08:00
boojack
c7a57191bd feat: add jwt auth (#1441)
* feat: add jwt auth

* chore: update
2023-04-02 09:28:02 +08:00
thehijacker
e3fc23ccf9 feat: updated Slovenian translation (#1440)
* Fixed some strings and typos

Checked on demo site and saw some string can be improved.

* Update LocaleSelect.tsx

Native name for language
2023-04-02 02:26:33 +08:00
boojack
0baf6b0e19 feat: add test for user store (#1438) 2023-04-01 22:47:19 +08:00
boojack
0cddb358c1 chore: add Slovenian locale (#1437)
chore: add sl locale
2023-04-01 21:34:10 +08:00
thehijacker
741eeb7835 feat: added Slovenian translation. (#1436)
Add files via upload
2023-04-01 13:23:12 +00:00
CorrectRoadH
424f10e180 feat: request pagination for resource(#1425)
* feat: add support for resource page on frontend

* [WIP]feat: add backend support for limit and offset search

* feat: add reducer to add resource

* support fetch all resource when first search

* beautify the fetch ui

* restore file

* feat: add all resource before clear resource

* eslint

* i18n

* chore:change the nane

* chore: change the name of param

* eslint

* feat: setIsComplete to true when first loading resource fully

* fix the bug of fetch

* feat change finally to then

* feat: add await and async to clear and search

* feat: return all resource when fetch

* chore: change variable name

* Update web/src/pages/ResourcesDashboard.tsx

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

* fix missing const value

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-04-01 16:51:20 +08:00
boojack
fab3dac70a chore: remove useListStyle hook (#1434) 2023-04-01 16:38:39 +08:00
boojack
89ab57d738 refactor: update import prefix with alias (#1433) 2023-04-01 16:03:14 +08:00
Dmitry Shemin
b03778fa73 feat: update RU i18n locale (#1422)
* feat: Fix i18n and RU locale

* fix: eslint issues

* change the position of deps

---------

Co-authored-by: CorrectRoadH <a778917369@gmail.com>
2023-04-01 15:35:25 +08:00
Xudong Cai
d21abfc60c feat: add URLSuffix resource option with S3 (#1428)
* feat: add URLSuffix resource option with S3

* feat: add URLSuffix resource option with S3

* fix: eslint
2023-04-01 15:28:00 +08:00
Xudong Cai
8eed9c267c fix: logo img rounded (#1427)
rounded-full move to img tag
2023-03-30 22:21:29 +08:00
CorrectRoadH
3c2578f666 feat: limit the num of lines for filename (#1424)
* feat: limit the linenum of  filename

* change the implement of line-clamp
2023-03-29 20:27:54 +08:00
CorrectRoadH
526fbbba45 feat: empty selected resource when search resource (#1423)
* feat: empty selected resource when search resource

* eslint
2023-03-29 18:59:51 +08:00
boojack
993ea024fd chore: update demo seed data (#1421) 2023-03-28 22:25:54 +08:00
Dmitry Shemin
bc595b40e7 feat: add docs for memos setup after deploying (#1419) 2023-03-27 23:45:02 +08:00
Dmitry Shemin
e7ee181a91 feat: add setup cmd (#1418)
This command can be used for automatization of initial application's setup
2023-03-27 21:22:49 +08:00
CorrectRoadH
6b703c4678 feat: add empty placeholder when search result is empty (#1416)
* feat: add empty placeholder when search result is empty

* Update web/src/pages/ResourcesDashboard.tsx

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

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-03-26 13:07:08 +00:00
boojack
dbb095fff4 chore: update list switch style (#1417) 2023-03-26 21:02:40 +08:00
CorrectRoadH
adf01ed511 feat: add more resource cover icon (#1413)
* stash: file upload

* feat: add style button

* feat: add style of list

* feat: add checkbox for list

* feat: support file upload by drag

* feat: beautify the ui

* feat: support file upload

* stash

* fix: the resource is incorrectly when upload multiple files

* feat: beautify the ui

* chore: reduce unused line

* stash

* chore: deleted unused line

* chore: deleted unused line

* chore

* chore: change the function declare

* feat: support to prompt file is too large

* feat:drop prompt to cover all element

* fix: eslint

* fix: the name of i18n

* chore: refactor the import deps

* feat: beautify the ui

* feat: support the style of button

* feat: beautify the switch ui

* chore: refactor the component

* chore: refactor the resource item dropdown

* feat: use memo to reduce unused computing in drop

* feat: use memo to reduce the calc of resource list

* chore:change name

* Update web/src/locales/en.json

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

* chore: the import of  deps

* fix: the window size of fecting data

* feat: support to save the state of style

* remove pnpm-lock

* merge main

* chore: simpify the statement

* fix: delete conflict marker

* feat: add i18n for select

* feat:support dark mode

* eslint

* feat: add more file icon

* feat: delete the storage of resource style

* Update web/src/components/ResourceCover.tsx

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

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-03-26 20:58:02 +08:00
Stephen Zhou
17ca97ebd1 fix: avatar is not rounded (#1415) 2023-03-26 12:43:15 +00:00
CorrectRoadH
7d89fcc892 feat: add list style for resource dashboard (#1389)
* stash: file upload

* feat: add style button

* feat: add style of list

* feat: add checkbox for list

* feat: support file upload by drag

* feat: beautify the ui

* feat: support file upload

* stash

* fix: the resource is incorrectly when upload multiple files

* feat: beautify the ui

* chore: reduce unused line

* stash

* chore: deleted unused line

* chore: deleted unused line

* chore

* chore: change the function declare

* feat: support to prompt file is too large

* feat:drop prompt to cover all element

* fix: eslint

* fix: the name of i18n

* chore: refactor the import deps

* feat: beautify the ui

* feat: support the style of button

* feat: beautify the switch ui

* chore: refactor the component

* chore: refactor the resource item dropdown

* feat: use memo to reduce unused computing in drop

* feat: use memo to reduce the calc of resource list

* chore:change name

* Update web/src/locales/en.json

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

* chore: the import of  deps

* fix: the window size of fecting data

* feat: support to save the state of style

* remove pnpm-lock

* merge main

* chore: simpify the statement

* fix: delete conflict marker

* feat: add i18n for select

* feat:support dark mode

* eslint

* feat: delete the storage of resource style

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-03-26 19:32:53 +08:00
_Jellen
e84d562146 feat: update Korean translation (#1414)
add missing translation keys into ko.json
2023-03-26 18:02:36 +08:00
boojack
2e14561bfc chore: update logo assets (#1407) 2023-03-24 08:43:26 +08:00
Stephen Zhou
166e57f1ef fix: image preview dialog overlapping (#1405)
* fix: image preview dialog overlapping

* Update web/src/less/preview-image-dialog.less

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-03-23 11:57:21 +00:00
boojack
eeff159a2d chore: remove weblate badge (#1402) 2023-03-22 22:57:54 +08:00
boojack
547f25178b chore: add rss button in user menu (#1401) 2023-03-22 22:33:59 +08:00
Steven Yan
9c0a3ff83c fix: the expand button's z-index is the same as Header (#1400)
fix: the expand button's z-index is higher than Header
2023-03-22 22:17:01 +08:00
CorrectRoadH
2ba54c9168 feat: upload file by drag and drop (#1388)
* stash: file upload

* feat: support file upload by drag

* feat: beautify the ui

* feat: support file upload

* stash

* fix: the resource is incorrectly when upload multiple files

* feat: beautify the ui

* chore: reduce unused line

* stash

* chore: deleted unused line

* chore: deleted unused line

* chore

* chore: change the function declare

* feat: support to prompt file is too large

* feat:drop prompt to cover all element

* fix: eslint

* fix: the name of i18n

* chore: refactor the import deps

* feat: beautify the ui

* Update web/src/locales/en.json

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

* chore: the import of  deps

* fix: the window size of fecting data

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-03-22 22:14:32 +08:00
Zeng1998
026fb3e50e fix: markdown support in blockquote (#1394) 2023-03-21 22:38:38 +08:00
CoffDream
af3d3c2c9b fix: daily review page & setting dialog style (#1392) 2023-03-21 09:55:53 +08:00
Shruti Chaturvedi
27a1792e78 chore: update Uffizzi Workflows (#1390)
* Update uffizzi-build.yml

* Update uffizzi-preview.yml
2023-03-21 00:15:43 +08:00
CorrectRoadH
63e0716457 chore: fix typo (#1387) 2023-03-20 13:59:53 +08:00
Zeng1998
f3090b115d feat: support local storage (#1383)
* feat: support local storage

* update

* update

* update

* update
2023-03-19 19:37:57 +08:00
boojack
a21ff5c2e3 chore: update project structure (#1382) 2023-03-18 23:07:40 +08:00
boojack
ff8851fd9f fix: golangci-lint version (#1381)
* chore: update interface declare

* chore: update args

* chore: update

* chore: update
2023-03-18 22:34:22 +08:00
boojack
573f07ec82 feat: support messages to ask AI (#1380) 2023-03-18 22:07:14 +08:00
Gerald
8b20cb9fd2 fix: make creation time a link to the detail page (#1379) 2023-03-18 22:06:44 +08:00
Zeng1998
7529296dd5 chore: remove {filetype} in path template (#1377)
* chore: remove {filetype} in path template

* fix go-static-check

* update
2023-03-18 22:06:15 +08:00
Zeng1998
7f44a73fd0 fix: show full content in detail page (#1375) 2023-03-18 20:19:32 +08:00
Zeng1998
eb835948b7 chroe: add ids for header elements (#1374)
* chroe: add id for header elements

* fix order of id and class
2023-03-18 20:19:13 +08:00
远浅
70e32637b0 build: update dockerfile for using cache to speed up (#1372) 2023-03-18 10:36:53 +08:00
CorrectRoadH
c04a31dcda fix: the dropdown be coverd (#1368) 2023-03-18 10:35:46 +08:00
boojack
e526cef754 fix: handle IME mode in editor (#1371)
* fix: handle IME mode in editor

* chore: update
2023-03-17 20:47:55 +08:00
远浅
2ba0dbf50b refactor: use function findMatchingParser to reduce duplicate code (#1367)
* refactor: Use function findMatchingParser to reduce duplicate code

* chore: declare type Parser
2023-03-17 20:46:07 +08:00
CorrectRoadH
4ee8cf08c6 feat: allow resource title mutiple line (#1370) 2023-03-17 20:20:20 +08:00
CorrectRoadH
f1f9140afc fix: the incorrectly height of grid row in safari (#1366) 2023-03-17 19:38:59 +08:00
boojack
c189654cd9 chore: update resource dashboard style (#1362) 2023-03-15 23:29:43 +08:00
CorrectRoadH
0a66c5c269 feat: new resource dashboard (#1346)
* feat: refator the file dashboard

* feat: support select resouce file

* feat: suppor delete select files

* feat: support share menu, implement rename and delete

* chore: change the color of hover

* chore: refator file dashboard to page

* feat: add i18n for button

* feat: beautify the button

* fix: the error position of button

* feat: only select when click circle instead of whole card

* feat: beautify file dashboard

* chore: factor the filecard code

* feat: using dropdown component intead of component

* feat: add i18n for delete selected resource button

* feat: delete the unused style of title

* chore: refactor file cover

* feat: support more type file cover

* feat: use memo to reduce unused computing in filecover

* feat: when no file be selected, click the delete will error

* feat: store the select resource id instead of source to save memory

* chore: delete unused code

* feat: refactor the file card

* chore: delete unused style file

* chore: change file to resource

* chore: delete unused import

* chore: fix the typo

* fix: the error of handle check click

* fix: the error of handle of uncheck

* chore: change the name of selectList to selectedList

* chore: change the name of selectList to selectedList

* chore: change the name of selectList to selectedList

* chore: delete unused import

* feat: support Responsive Design

* feat: min display two card in a line

* feat: adjust the num of a line in responsive design

* feat: adjust the num of a line to 6 when using md

* feat: add the color of hover source card when dark

* chore: refactor resource cover css to reduce code

* chore: delete unnessnary change

* chore: change the type of callback function

* chore: delete unused css code

* feat: add zh-hant i18n

* feat: change the position of buttons

* feat: add title for the icon button

* feat: add opacity for icon

* feat: refactor searchbar

* feat:move Debounce to search

* feat: new resource search bar

* feat: reduce the size of cover

* support file search

* Update web/src/pages/ResourcesDashboard.tsx

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

* Update web/src/components/ResourceCard.tsx

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

* chore: reduce css code

* feat: support lowcase and uppercase search

* chore: reserve the searchbar

* feat: refator resource Search bar

* chore: change the param name

* feat: resource bar support dark mode

* feat: beautify the UI of dashboard

* chore: extract positionClassName from actionsClassName

* feat: reduce the length of search bar

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-03-15 20:06:30 +08:00
远浅
e129b122a4 refactor: useTranslation in CreateTagDialog (#1356) 2023-03-15 20:05:35 +08:00
远浅
7f30e2e6ff chore: fix typo (#1355) 2023-03-15 07:39:09 +08:00
boojack
29f784cc20 feat: update find resource with linked memo amount (#1354)
* feat: update find resource with linked memo amount

* chore: remove unused test
2023-03-15 00:04:52 +08:00
Wujiao233
28242d3268 fix: expand btn display in front of menu (#1342)
* Docker

* fix:expand btn display issue

* restore Dockerfile

* change Header z-index to 2

* Update web/src/less/memo-content.less

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

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-03-14 08:39:07 +08:00
boojack
8d88477538 chore: rename http getter package (#1349) 2023-03-14 08:38:54 +08:00
boojack
89053e86b3 chore: fix cover bg color (#1337) 2023-03-11 15:23:05 +08:00
boojack
50f36e3ed5 chore: upgrade version to 0.11.2 (#1336) 2023-03-11 13:20:24 +08:00
boojack
ca6839f593 chore: remove editor shortcuts (#1334) 2023-03-11 12:31:45 +08:00
boojack
e5cbb8cd56 refactor: openAI config system setting (#1333) 2023-03-11 12:26:40 +08:00
boojack
7c92805aac fix: daily review page style (#1332) 2023-03-11 10:08:36 +08:00
boojack
a9218ed5f0 refactor: filter store (#1331) 2023-03-11 09:13:54 +08:00
boojack
f3f0efba1e feat: update page router (#1330) 2023-03-11 08:43:45 +08:00
boojack
ccdcd3d154 feat: fold memo when content overflow (#1327)
* feat: fold memo when content overflow

* chore: update
2023-03-09 23:32:35 +08:00
Cologler
8c774316ae refactor: build storage key (#1326)
* refactor build storage key

* sort imports

* use gofmt to format code
2023-03-09 22:41:48 +08:00
-Shiken-
25da3c073b feat: update zh-tw translation to new file format (#1324) 2023-03-09 12:37:59 +08:00
远浅
6866b6c30d fix: unified tag sorting logic (#1323) 2023-03-09 12:36:11 +08:00
boojack
f86816fea2 feat: use react-hot-toast (#1321) 2023-03-09 08:54:14 +08:00
Cologler
df6b4b0607 chore: expose port in dockerfile (#1319) 2023-03-09 08:33:33 +08:00
Aswath S
2428e6e190 feat: allow users to customize the refresh time for Daily Reviews (#1313)
* feat: Allow users to customize the refresh time for Daily Reviews

* feat: Allow users to customize the refresh time for Daily Reviews. Lint fix

* feat: Allow users to customize the refresh time for Daily Reviews. change daily review time offset to include only hour

* feat: Allow users to customize the refresh time for Daily Reviews. Retrigger to try CodeQL pass.

---------

Co-authored-by: Aswath S <aswath.s@thoughtworks.com>
2023-03-09 08:26:56 +08:00
Dillon Xu
2ff0e71272 feat: update README.md with memos-desktop (#1317)
Memos client built on electron cross-end framework.
2023-03-09 00:07:56 +08:00
Baptiste Roux
93609ca731 fix: update markdown hyperlink regex (#1315)
* fix: Update markdown hyperlink regex

* chore(lint): Remove unnecessary escape character
2023-03-08 23:22:16 +08:00
boojack
70a187cc18 chore: update ask AI trigger (#1316) 2023-03-08 23:09:15 +08:00
boojack
390e29f850 chore: remove part of less files (#1314) 2023-03-08 22:05:43 +08:00
boojack
3a466ad2a1 chore: update style of home sidebar (#1311) 2023-03-08 21:56:28 +08:00
boojack
ccf6af4dc3 fix: request body format in openai api (#1309) 2023-03-08 19:37:50 +08:00
Andrew Pollock
ce7564a91b fix: GO-2023-1571 vulnerability (#1308)
* Fix for GO-2023-1571

* Update go.sum to reflect fix for GO-2023-1571
2023-03-08 19:14:09 +08:00
boojack
483c1d5782 feat: update responsible layout (#1306)
* feat: update responsible layout

* chore: update
2023-03-08 09:00:10 +08:00
Jason Chen
65850dfd03 feat: auto focus search bar when sidebar is shown (close #1269) (#1304)
feat: auto focus search bar when sidebar is shown
2023-03-08 08:02:51 +08:00
Zeng1998
d1bafd66c8 feat: allow to filter memos with resources (#1299) 2023-03-07 19:36:36 +08:00
Xiang Jaywhen
daa1e9edfb fix: Ask-AI history list reversed when loading answer (#1301) 2023-03-07 19:36:18 +08:00
kimw
008d6a0c81 feat: add GitLab OAuth2 template (#1302) 2023-03-07 19:35:31 +08:00
远浅
7c5fae68fe fix: navigate faild silently (#1300) 2023-03-07 09:24:31 +08:00
Yunwei Xiao
c57cea1aaa fix: fix the typo of openai (#1298) 2023-03-07 08:38:41 +08:00
boojack
595dbdb0ec feat: add root layout (#1294) 2023-03-06 21:13:35 +08:00
Wujiao233
003161ea54 feat: support set openai api host (#1292)
* Docker

* feat: support set openai api host

* fix css

* fix eslint

* use API in backend & put host check in plugin/openai

* fix go-static-checks
2023-03-06 20:10:53 +08:00
Zeng1998
fd99c5461c feat(s3): customize filenames via placeholders (#1285)
* feat(s3): customize filenames via placeholders

* fix go-static-checks

* add tips on the frontend

* fix eslint check

* remove yarn.lock

* remove Config.Path

* update tips

* fix

* update
2023-03-06 12:04:19 +00:00
Zeng1998
37366dc2e1 fix: patch user with nil password (#1290) 2023-03-06 19:13:09 +08:00
Zeng1998
c1903df374 fix: correct the storage service state (#1288) 2023-03-06 19:13:00 +08:00
远浅
54374bca05 fix: missing prop key (#1291) 2023-03-06 19:12:50 +08:00
CorrectRoadH
ddf1eb0219 feat: automatically change language on first launch (#1278)
* feat: automatically change language to browser language on first launch(#1238)

* Update web/src/store/module/global.ts

* chroe: rename languageCodeCovert to convertLanguageCodeToLocale

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-03-05 23:45:33 +08:00
Juskinbo
8c5ba63f8c fix: overflow of dialog-content-container (#1277)
* fix: overflow of dialog-content-container

* fix: overflow of dialog-content-container
2023-03-05 23:19:01 +08:00
boojack
f7cd039819 chore: rename common to base component (#1279) 2023-03-05 23:08:02 +08:00
boojack
4335897367 chore: remove metrics plugin (#1276)
* chore: remove metrics plugin

* chore: update
2023-03-05 21:42:32 +08:00
boojack
6201dcf1aa chore: simplify readme (#1275) 2023-03-05 21:33:57 +08:00
boojack
5d24fe189d chore: update location store handler (#1273)
* chore: update location store handler

* chore: update search bar
2023-03-05 19:50:50 +08:00
boojack
6d9ead80b2 chore: update demo url (#1256) 2023-03-05 01:18:30 +08:00
boojack
bf46a9af68 chore: add heat map to sidebar (#1255) 2023-03-05 01:03:37 +08:00
boojack
c6d43581f9 revert: Fix: Markdown hyperlinks with parenthesis take first closing parenthesis as final (#1251)
Revert "fix: Markdown hyperlinks with parenthesis take first closing parenthesis as final (#1213)"

This reverts commit 1b0629bf0f.
2023-03-04 20:54:14 +08:00
boojack
31399fe475 fix: s3 custom path (#1249) 2023-03-04 20:06:32 +08:00
boojack
e150599274 chore: upgrade version to v0.11.1 (#1247) 2023-03-04 18:49:50 +08:00
boojack
b70117e9f4 chore: update demo screenshot (#1246) 2023-03-04 18:33:17 +08:00
boojack
df04e852bf feat: implement openai integration (#1245)
* feat: implement openai integration

* chore: update
2023-03-04 18:22:10 +08:00
boojack
dd625d8edc chore: update links reference (#1243) 2023-03-04 15:06:01 +08:00
boojack
6ab58f294e feat: update home layout (#1242) 2023-03-04 13:49:53 +08:00
Alex Zhao
9d4bb5b3af feat: add support for s3 path (#1233)
* add support for path

* fix typo and switch positions with Path and Bucket

* using path method instead of string concatenation
2023-03-04 07:59:44 +08:00
Mehmet Altuğ Akgül
e062c9b4a7 feat: add Turkish Translation file (#1202)
* Created tr.json for Turkish Translation

* updated file for trLocale

* Updated for Turkish Locale

* Update i18n.ts

* Update i18n.ts

* Update package.json

* Update package.json

* Update i18n.d.ts

* Update user_setting.go

* Update package.json

* Update web/src/components/LocaleSelect.tsx

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

* Update package.json

* Update LocaleSelect.tsx

* Update LocaleSelect.tsx

* Update i18n.ts

* Update i18n.ts

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-03-02 10:05:15 +08:00
Jason Shawn D' Souza
1b0629bf0f fix: Markdown hyperlinks with parenthesis take first closing parenthesis as final (#1213)
Updating regex to pick up edge case with parentheses
2023-03-01 22:43:13 +08:00
Thareek Anvar M
e83ea7fd76 fix: login security issue (#1198)
* fix

* fix bug

* changes

* Revert "changes"

This reverts commit 2b2084c7bd.

* should close the toast if its error also

* no internal errors + sso

* change the text to Incorrect login credentials, please try again
2023-03-01 22:33:43 +08:00
Dan Fiumara
6dab43523d docs: create CustomThemes.md (#1210)
* Create CustomThemes.md

* Update doc/CustomThemes.md

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

* Update doc/CustomThemes.md

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

* Update doc/CustomThemes.md

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

* Update CustomThemes.md

* Update CustomThemes.md

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-03-01 22:32:51 +08:00
Dane Roelofs
4a59965d7a fix: action button container not overflowing memo (#1218) 2023-03-01 22:05:02 +08:00
远浅
71de6613d3 refactor: declare variable for devProxyServer (#1220) 2023-03-01 19:50:43 +08:00
Stephen Zhou
e43e04b478 chore: fix unknown at rule @applyless(unknownAtRules) (#1221)
fix: Unknown at rule @applyless(unknownAtRules)
2023-03-01 19:50:09 +08:00
Dan Fiumara
4ab32d4c2c fix: corners not rounded on memos-editor-wrapper (#1209)
* Fixed corners on memos-editor-wrapper

* Remove change in Home.tsx

* Moved change to home.less
2023-03-01 13:38:33 +08:00
Weblate (bot)
1f05b52c4e chore: translations update from Hosted Weblate (#1196)
Translated using Weblate (Turkish)

Currently translated at 19.5% (41 of 210 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 33.3% (70 of 210 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (210 of 210 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (210 of 210 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (210 of 210 strings)

Translated using Weblate (Turkish)

Currently translated at 1.4% (3 of 210 strings)

Translated using Weblate (Turkish)

Currently translated at 0.4% (1 of 210 strings)

Translated using Weblate (Turkish)

Currently translated at 0.0% (0 of 0 strings)

Added translation using Weblate (Turkish)

Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (210 of 210 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 91.4% (192 of 210 strings)









Translate-URL: https://hosted.weblate.org/projects/memos/web/es/
Translate-URL: https://hosted.weblate.org/projects/memos/web/nl/
Translate-URL: https://hosted.weblate.org/projects/memos/web/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/memos/web/sv/
Translate-URL: https://hosted.weblate.org/projects/memos/web/tr/
Translate-URL: https://hosted.weblate.org/projects/memos/web/zh_Hant/
Translation: memos/web

Co-authored-by: Felipe Nogueira <contato.fnog@gmail.com>
Co-authored-by: Jasper Platenburg <jasperdgp@outlook.com>
Co-authored-by: Luna Jernberg <droidbittin@gmail.com>
Co-authored-by: Onur Ravli <onur@ravli.co>
Co-authored-by: SiriYang <www.yangxinruei@qq.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
2023-02-28 23:53:38 +08:00
Stephen Zhou
3e7fbac926 fix: corner style after scaling (#1199) 2023-02-28 23:52:04 +08:00
CorrectRoadH
eda27a60be fix: incorrect sharing image gerneration (#1157) (#1205) 2023-02-28 22:43:42 +08:00
Dan Fiumara
107a2dbe90 feat: update en locale (#1195) 2023-02-28 09:36:07 +08:00
boojack
9577f6dbe8 feat: add resource visibility to user setting (#1190) 2023-02-27 22:16:33 +08:00
boojack
ae61ade2b1 chore: add my account entry in user dropdown (#1187) 2023-02-27 21:30:54 +08:00
boojack
977e7f55e5 feat: add visibility field to resource (#1185) 2023-02-27 21:26:50 +08:00
Weblate (bot)
c399ff86e0 chore: translations update from Hosted Weblate (#1154)
Translated using Weblate (Portuguese (Brazil))

Currently translated at 25.7% (54 of 210 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 0.0% (0 of 0 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (210 of 210 strings)

Added translation using Weblate (Portuguese (Brazil))





Translate-URL: https://hosted.weblate.org/projects/memos/web/pl/
Translate-URL: https://hosted.weblate.org/projects/memos/web/pt_BR/
Translation: memos/web

Co-authored-by: Felipe Nogueira <contato.fnog@gmail.com>
Co-authored-by: Piotr Wik <p_00@o2.pl>
2023-02-27 20:09:44 +08:00
仝华帅
d43b806c5e fix: model fields are unconsistent with the data queried from the database (#1179)
fix function createActivity typo
2023-02-27 19:50:51 +08:00
Zeng1998
7b7061846c chore: open url in other tabs (#1173)
* chore: open url in other tabs

* update: add `rel="noreferrer"`
2023-02-27 19:50:43 +08:00
Zeng1998
d81cf5cc1b fix: z-index of image preview (#1171) 2023-02-27 19:50:26 +08:00
Zeng1998
4284fd0469 fix: omission of long filename (#1170) 2023-02-27 19:50:09 +08:00
boojack
039b6b247a chore: remove username click event (#1167)
chore: remove user name click event
2023-02-26 23:52:51 +08:00
H3arn
a09b2c4eea feat: use accent color when confirming deletion (#1161)
- .final-confirm
2023-02-26 19:37:01 +08:00
Zhizhen He
50a99e9120 fix: correct comments for exported functions and variables (#1158) 2023-02-25 20:48:38 +08:00
Zeng1998
57479b250a chore: remove validators on the frontend (#1156)
* chore: update minlength of username

* remove the validator on frontend

* update
2023-02-25 14:59:29 +08:00
Weblate (bot)
e64245099c chore: update translations from Hosted Weblate (#1150)
Translated using Weblate (Polish)

Currently translated at 99.0% (208 of 210 strings)


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

Co-authored-by: Piotr Wik <p_00@o2.pl>
2023-02-24 16:14:58 +00:00
boojack
d6e4b5e889 chore: fix dispatch memo pinned (#1152) 2023-02-25 00:13:41 +08:00
boojack
6e2e7ac782 fix: remove memo list api (#1151) 2023-02-24 21:11:12 +08:00
boojack
904a6bd97f fix: find memo list order (#1149) 2023-02-24 20:34:54 +08:00
Xiang Jaywhen
c24b7097fa fix: function name typo (#1148)
fixed function name typo

“handleAddFilterBenClick” -> "handleAddFilterBtnClick"

[#1147 ](https://github.com/usememos/memos/issues/1147)
2023-02-24 18:09:31 +08:00
boojack
cc23d5cafe chore: upgrade version to 0.11.0 (#1143)
* chore: upgrade version to `0.11.0`

* chore: update
2023-02-24 08:31:54 +08:00
boojack
9c5b44d070 feat: update storage schema (#1142) 2023-02-24 00:02:51 +08:00
boojack
84fb8b2288 feat: update storage setting section (#1140) 2023-02-23 23:22:34 +08:00
boojack
6d2d322140 chore: show pinned memos in explore (#1141) 2023-02-23 19:59:18 +08:00
boojack
1517688076 chore: update code structure (#1139)
* chore: update code structure

* chore: update
2023-02-23 00:07:16 +08:00
boojack
29124f56bb chore: update memo service (#1138)
* chore: update memo service

* chore: update
2023-02-22 20:07:55 +08:00
boojack
42d849abfc chore: update explore header style (#1137) 2023-02-22 19:21:08 +08:00
Weblate (bot)
d1b307b18f chore: update translations from Hosted Weblate (#1134)
Translated using Weblate (Polish)

Currently translated at 97.1% (204 of 210 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (210 of 210 strings)



Translate-URL: https://hosted.weblate.org/projects/memos/web/pl/
Translate-URL: https://hosted.weblate.org/projects/memos/web/zh_Hant/
Translation: memos/web

Co-authored-by: Piotr Wik <p_00@o2.pl>
Co-authored-by: SiriYang <www.yangxinruei@qq.com>
2023-02-22 00:58:49 +00:00
Weblate (bot)
f6d347c5e4 chore: update translations from Hosted Weblate (#1132)
Translated using Weblate (Chinese (Traditional))

Currently translated at 94.2% (198 of 210 strings)


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

Co-authored-by: SiriYang <www.yangxinruei@qq.com>
2023-02-21 21:39:57 +08:00
Weblate (bot)
4fe8476169 chore: update translations from Hosted Weblate (#1128)
Translated using Weblate (Polish)

Currently translated at 79.5% (167 of 210 strings)

Translated using Weblate (Polish)

Currently translated at 0.0% (0 of 0 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (210 of 210 strings)

Added translation using Weblate (Polish)

Translated using Weblate (Korean)

Currently translated at 98.5% (210 of 213 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 97.1% (207 of 213 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 92.0% (196 of 213 strings)

Translated using Weblate (Vietnamese)

Currently translated at 89.2% (190 of 213 strings)

Translated using Weblate (Ukrainian)

Currently translated at 91.5% (195 of 213 strings)

Translated using Weblate (Russian)

Currently translated at 91.5% (195 of 213 strings)

Translated using Weblate (Italian)

Currently translated at 91.5% (195 of 213 strings)

Translated using Weblate (French)

Currently translated at 90.1% (192 of 213 strings)

Translated using Weblate (Spanish)

Currently translated at 91.5% (195 of 213 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (210 of 210 strings)

Deleted translation using Weblate (English (United States))

Translated using Weblate (English (United States))

Currently translated at 0.0% (0 of 0 strings)

Added translation using Weblate (English (United States))

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (210 of 210 strings)

Translated using Weblate (Korean)

Currently translated at 98.5% (207 of 210 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 97.6% (205 of 210 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 93.3% (196 of 210 strings)

Translated using Weblate (Vietnamese)

Currently translated at 90.4% (190 of 210 strings)

Translated using Weblate (Ukrainian)

Currently translated at 92.8% (195 of 210 strings)

Translated using Weblate (Swedish)

Currently translated at 92.8% (195 of 210 strings)

Translated using Weblate (Russian)

Currently translated at 92.8% (195 of 210 strings)

Translated using Weblate (Dutch)

Currently translated at 85.7% (180 of 210 strings)

Translated using Weblate (Italian)

Currently translated at 92.8% (195 of 210 strings)

Translated using Weblate (French)

Currently translated at 91.4% (192 of 210 strings)

Translated using Weblate (Spanish)

Currently translated at 92.8% (195 of 210 strings)

Translated using Weblate (German)

Currently translated at 91.4% (192 of 210 strings)








Translate-URL: https://hosted.weblate.org/projects/memos/web/de/
Translate-URL: https://hosted.weblate.org/projects/memos/web/en_US/
Translate-URL: https://hosted.weblate.org/projects/memos/web/es/
Translate-URL: https://hosted.weblate.org/projects/memos/web/fr/
Translate-URL: https://hosted.weblate.org/projects/memos/web/it/
Translate-URL: https://hosted.weblate.org/projects/memos/web/ko/
Translate-URL: https://hosted.weblate.org/projects/memos/web/nl/
Translate-URL: https://hosted.weblate.org/projects/memos/web/pl/
Translate-URL: https://hosted.weblate.org/projects/memos/web/ru/
Translate-URL: https://hosted.weblate.org/projects/memos/web/sv/
Translate-URL: https://hosted.weblate.org/projects/memos/web/uk/
Translate-URL: https://hosted.weblate.org/projects/memos/web/vi/
Translate-URL: https://hosted.weblate.org/projects/memos/web/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/memos/web/zh_Hant/
Translation: memos/web

Co-authored-by: Luna Jernberg <droidbittin@gmail.com>
Co-authored-by: Piotr Wik <p_00@o2.pl>
Co-authored-by: Yoshino-s <cy-cui@outlook.com>
Co-authored-by: boojack <stevenlgtm@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
2023-02-21 07:59:02 +08:00
Yoshino-s
bbc5ac9f0e feat: make file uplaod support drag/drop (#1129) 2023-02-20 22:17:01 +08:00
_Jellen
29b5c393d1 feat: add Korean translation (#1127)
added Korean translation
2023-02-20 00:54:12 +00:00
boojack
b8cc0b1270 chore: update readme with weblate badge (#1126) 2023-02-19 21:55:29 +08:00
boojack
b145d8b8a2 chore: update setting dialog style (#1125) 2023-02-19 21:12:16 +08:00
boojack
ffe1073292 fix: schema path for demo mode (#1124) 2023-02-19 16:34:15 +08:00
Yoshino-s
afaaec8492 feat(mode): add demo mode (#1121)
* feat(mode): add demo mode

* chroe: Update store/db/db.go

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

* chroe: Update store/db/db.go

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

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-02-19 13:36:45 +08:00
boojack
d0b8b076cf feat: implement sign in with SSO (#1119)
* feat: implement sign in with SSO

* chore: update

* chore: update

* chore: update
2023-02-19 09:50:30 +08:00
boojack
708049bb89 feat: add SSO related UI (#1118)
* feat: add SSO related UI

* chore: update
2023-02-18 22:57:45 +08:00
Stephen Zhou
65aa51d525 feat: generate RSS item title and limit item count (#1117)
* feat: generate RSS item title and limit item count

* Update server/rss.go

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

* Update server/rss.go

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-02-18 22:20:28 +08:00
boojack
cbbd284e7a feat: add store cache for idp (#1116)
feat: add cache for idp
2023-02-18 18:41:52 +08:00
boojack
852903bdbd fix: idp config definition (#1115)
fix: idp definition
2023-02-18 18:31:03 +08:00
Zeng1998
19efacef9c chore: add desc for storage form (#1112) 2023-02-18 18:08:35 +08:00
boojack
0f57629d25 feat: implement idp server (#1111)
* feat: implement idp server

* chore: update
2023-02-18 11:29:12 +08:00
boojack
69726c3925 feat: implement oauth2 plugin (#1110) 2023-02-18 10:50:13 +08:00
boojack
37f9c7c8d6 chore: update avatar max size (#1109) 2023-02-18 10:48:31 +08:00
boojack
bcee0bbf3a feat: add avatar to user in frontend (#1108) 2023-02-18 10:00:46 +08:00
boojack
096a71c58b feat: add avatar_url field to user table (#1106)
refactor: add `avatar_url` field to user table
2023-02-17 23:55:56 +08:00
boojack
a538b9789b feat: introduce idp table (#1105)
* feat: introduce idp table

* chore: update
2023-02-17 13:06:41 +00:00
boojack
c6e525b06f chore: remove unused fields of storage table (#1104) 2023-02-17 20:12:08 +08:00
boojack
d29c40dc71 chore: update router loader (#1102) 2023-02-17 08:26:40 +08:00
Jake
4f5f541efe docs: add raycast extension (#1100) 2023-02-16 23:38:30 +08:00
boojack
caf054bae7 chore: add beta badge to storage (#1099)
* chore: add beta badge to storage

* chore: update
2023-02-16 21:21:39 +08:00
boojack
7e8011ba34 chore: support deleting storage (#1095) 2023-02-15 22:54:46 +08:00
Ruihang Xia
e46f77681d chore(build): anchor setup-buildx-action@v2 to version v0.9.1 (#1089)
Signed-off-by: Ruihang Xia <waynestxia@gmail.com>
2023-02-15 22:16:45 +08:00
Ryan McPherson
0de0a5aa87 chore: make README more comprehensive (#1065)
* Update README.md

* fix typos in readme

* Update README.md

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-02-15 20:52:17 +08:00
boojack
3394380ffa chore: update storage components (#1091) 2023-02-14 22:45:22 +08:00
Zeng1998
2493bb0fb7 feat: storage service frontend (#1088) 2023-02-14 09:56:04 +08:00
Christopher
4641e89c17 feat(system): support for disabling public memos (#1003)
* feat(system): support for disabling public memos

* fix(web/editor): set visibility to private on disabled public memos

* feat(server/memo): find/check if public memos are disabled

* fix(server/memo): handle error for finding system error

* fix(server/memo): unmarshal visiblity when getting system settings

* chore(web): move side effect imports to end

* Update memo.go

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-02-13 16:07:31 +00:00
Xi
28405f6d24 feat: not found page (#1081)
* feat: style for not found page (#1078)

* chore: translation for not found page (#1078)

* feat: add not found page (#1078)

* chore: router for not found page (#1078)

* fix: typo
2023-02-13 23:28:46 +08:00
boojack
5455cb3164 chore: simplify editor (#1087) 2023-02-13 23:27:45 +08:00
Zeng1998
1e4a81dea9 feat: storage service backend (#1086)
* feat: storage service backend

* update go.mod

* update the column name (urlPrefix -> url_prefix)

* update

* update
2023-02-13 19:36:48 +08:00
Jake
cbc3373e8e docs: add shortcut for ios (#1083) 2023-02-13 12:41:15 +08:00
boojack
870559046f chore: update skipper name (#1080) 2023-02-12 17:29:23 +08:00
boojack
a997e1d10d chore: simplify memo editor component (#1079) 2023-02-12 16:34:42 +08:00
Jake
c28d35d8f7 docs: memos-import-from-flomo support wechat reading (#1077) 2023-02-12 07:32:03 +08:00
boojack
b92da8f123 fix: check localsetting exists (#1076) 2023-02-11 22:54:13 +08:00
boojack
bdf0c44246 chore: add CreatedTs field to MemoCreate (#1073) 2023-02-11 21:32:42 +08:00
Jake
799fb058b4 docs: add memos-import-from-flomo (#1072) 2023-02-11 21:31:38 +08:00
Zeng1998
11924ad4c5 feat: add storage service table (#1070)
* feat: add storage service table

* update json field name

* update table name

* add updated_ts
2023-02-11 20:31:39 +08:00
boojack
b11d2130a0 chore: validate external link (#1069) 2023-02-11 17:34:29 +08:00
boojack
e0f4cb06b3 chore: update tags order (#1068) 2023-02-11 16:05:52 +08:00
boojack
aad97c4c54 chore: update signup api (#1067) 2023-02-11 15:15:56 +08:00
boojack
3590d3f8b6 feat: update store cache (#1066)
* feat: update store cache

* chore: update
2023-02-11 14:19:26 +08:00
Shruti Chaturvedi
6e5be6ba75 chore: add text for Uffizzi Previews in README (#1061) 2023-02-11 08:35:53 +08:00
boojack
b366ce7594 fix: delete tag (#1062) 2023-02-10 23:57:02 +08:00
Zeng1998
1eacf5367d chore: upgrade version to 0.10.3 (#1060) 2023-02-10 12:03:18 +08:00
boojack
f74d1b7bf8 chore: remove resource cache (#1059) 2023-02-10 08:43:39 +08:00
boojack
a004dcf320 fix: pass empty condition in rss (#1058)
fix: handle empty condition in rss
2023-02-10 08:28:14 +08:00
boojack
5df59a48b7 chore: update rss icon style (#1056) 2023-02-09 23:45:48 +08:00
boojack
989208eb45 chore: update resource select dialog (#999)
* chore: update resource select dialog

* chore: update
2023-02-09 23:24:51 +08:00
boojack
bec1558488 fix: patch resource id (#1055) 2023-02-09 23:20:36 +08:00
Stephen Zhou
6ff79c5d5c fix: can not input chinese (#1053) 2023-02-09 21:50:51 +08:00
Stephen Zhou
168c4f6950 feat: more rss info (#1052)
* feat: more rss info

* fix: ci
2023-02-09 21:17:15 +08:00
boojack
3e40b9df66 chore: update readme with dark mode demo (#1049) 2023-02-08 20:21:33 +08:00
Stephen Zhou
94f97208e3 chore: setup project workspace for better DX (#1048)
* chore: setup project workspace for better DX

* chore: remove prettier ext
2023-02-08 18:43:13 +08:00
boojack
bd9003c24b chore: update readme (#1047) 2023-02-08 08:51:36 +08:00
Nitin Khanna
26700a1ff0 fix: DatePicker should say Wed instead of Web (#1046)
DatePicker should say Wed instead of Web
2023-02-08 08:37:02 +08:00
Stephen Zhou
8b92021b1a fix: editor cursor not in view after smart editing (#1043) 2023-02-07 23:31:43 +08:00
Zeng1998
7cd474dbb7 feat: add setting for double-click of memos (#1036)
* feat: add setting for double-click of memos

* update

* update
2023-02-07 20:35:41 +08:00
boojack
9bf869767d chore: update seed data (#1042) 2023-02-07 20:35:32 +08:00
Zeng1998
9e818cddce feat: tag filter in explore (#1032)
* temp

* Revert "temp"

This reverts commit d2d14b4c57.

* Revert "Revert "temp""

This reverts commit c50be22cb4.

* feat: tag filter in explore page

* update
2023-02-07 20:11:22 +08:00
Stephen Zhou
d6fe180ca1 fix: parse chrome or edge urls in plain link (#1034)
fix: parse chrome or urls in plain link
2023-02-07 20:10:13 +08:00
Stephen Zhou
99cac7cac0 fix: scroll when clicking expand button (#1035) 2023-02-07 20:09:30 +08:00
boojack
81f2166912 chore: update code owners (#1041) 2023-02-07 20:08:51 +08:00
boojack
4de65ab55d fix: url encode for tag name (#1031) 2023-02-06 20:28:19 +08:00
Zeng1998
771ef44d82 feat: support enter to signin (#1014) 2023-02-06 20:03:33 +08:00
-Shiken-
76c42c6c9f chore: more translation correction to traditional Chinese (#1028)
* more translation correction to traditional Chinese

To be in line with the language habits of traditional Chinese users

* Update zh-Hant.json
2023-02-06 19:59:33 +08:00
Shruti Chaturvedi
003887d4e0 Use official Uffizzi reusable action (#1027)
* Use official Uffizzi reusable action

* Run preview if build passed successfully
2023-02-06 19:59:05 +08:00
-Shiken-
89743bd1e6 chore: update zh-Hant.json (#1023) 2023-02-05 17:45:50 +08:00
boojack
1ace332152 feat: graceful shutdown server (#1016) 2023-02-03 10:30:18 +08:00
Zeng1998
2d14047c73 fix: pdf resource preview (#1008) 2023-02-02 20:34:24 +08:00
Stephen Zhou
42cd93cf33 fix: show copy button on hover (#1002) 2023-01-31 18:38:58 +08:00
boojack
4a7b764ab3 chore: remove unused flags for sqlite (#997) 2023-01-30 00:03:21 +08:00
Shruti Chaturvedi
1bdb0d465c chore: update Uffizzi GHA for better error-handling (#996)
Update Uffizzi GHA for better error-handling
2023-01-29 18:16:44 +08:00
WY-WY-W
930b54fabd feat: update Traditional Chinese translation (#994) 2023-01-29 09:41:56 +08:00
boojack
5b0a54bfb7 chore: clean package.json (#993)
* chore: clean `package.json`

* chore: update
2023-01-26 00:35:50 +08:00
boojack
6c3ff6de63 chore: get resource blob optional (#991) 2023-01-25 16:11:02 +08:00
boojack
dd5a23e36e feat: support creating resource with external link (#988) 2023-01-22 21:16:28 +08:00
boojack
848ecd99ee chore: format SQL (#987)
chore: format sql
2023-01-22 21:16:03 +08:00
boojack
82f61f2a0e chore: upgrade version to 0.10.2 (#983) 2023-01-21 08:56:26 +08:00
boojack
c5368fe8d3 chore: update resource dialog style (#982) 2023-01-21 08:46:49 +08:00
Wujiao233
0aaf153717 fix: video and audio can't play on safari (#980)
* fix: video can't play on safari

* fix: audio can't play on safari
2023-01-20 16:52:38 +08:00
Stephen Zhou
942e1f887b feat: scrool to memo after editing (#907) 2023-01-19 20:57:45 +08:00
Wujiao233
b8ab43aa25 feat: support swipe to switch img on touchscreen (#970)
* feat: support swipe to switch img on touchscreen

* fix: fix two or more fingers touch

* fix lint
2023-01-19 20:57:03 +08:00
Wujiao233
a5f3b051f2 fix: round corner issue in resource blocks (#979) 2023-01-19 17:59:37 +08:00
boojack
4ba9767b94 fix: use input instead of textfield (#973) 2023-01-19 09:16:22 +08:00
Zeng1998
12fda38520 feat: add customized logo in share dialog (#969)
* feat: add qrcode in share dialog

* update: change the color

* feat: add customized logo in share dialog

* update: import order

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-01-18 10:52:25 +00:00
Zeng1998
9ed503fd6d feat: add qrcode in share dialog (#964)
* feat: add qrcode in share dialog

* update: change the color

* update: import order
2023-01-18 18:49:48 +08:00
Viet-Anh, Nguyen
a8976de634 feat: update Vietnamese translation (#965)
refactor: Update Vietnamese translation
2023-01-18 09:01:53 +08:00
Zeng1998
f8855ddb56 feat: support empty content memo (#963)
feat: support empty-text memo
2023-01-17 20:56:57 +08:00
Jasper Platenburg
288ecc617d feat: issue translator workflow (#956)
Create issue-translator.yml
2023-01-15 21:33:19 +08:00
Ángel Fernández Sánchez
14ec81b65c feat: update Spanish translation (#954) 2023-01-15 08:35:20 +08:00
boojack
fae0b64a08 fix: delete tag api (#950)
* fix: delete tag api

* chore: update
2023-01-14 12:08:31 +08:00
boojack
677750ef51 chore: upgrade version to 0.10.1 (#949) 2023-01-14 08:00:07 +08:00
boojack
4cfd000b92 feat: support audio player (#948) 2023-01-14 07:41:17 +08:00
boojack
aacaf3f37c chore: remove sponsor with open collective (#947)
chore: update github sponsors
2023-01-14 06:56:17 +08:00
boojack
ad4a79a510 chore: update GitHub sponsor (#946)
chore: update github sponsor
2023-01-14 06:51:21 +08:00
boojack
219d2754a0 chore: remove existed tags in suggestion (#944) 2023-01-13 22:37:30 +08:00
boojack
10430a66c3 chore: debounce search text input (#943)
* chore: debounce search text input

* chore: update
2023-01-13 22:33:52 +08:00
Jasper Platenburg
c167c21e4e chore: added translation for copy memo link (#942) 2023-01-13 21:22:20 +08:00
boojack
40d25f7dca fix: skip api error for static middleware (#941) 2023-01-13 07:06:15 +08:00
boojack
1441a1df1f chore: update funding with open collective (#940) 2023-01-12 23:48:51 +08:00
boojack
b19c3c6db3 feat: update renderer in list (#935) 2023-01-12 08:52:57 +08:00
boojack
8c146aed68 feat: update memo resources style (#933)
* feat: update memo resources style

* chore: update
2023-01-12 00:00:44 +08:00
boojack
805122f45c chore: add User stories section to readme (#932) 2023-01-11 09:21:52 +08:00
sfan5
7d5de1a07e feat: update German translation (#926) 2023-01-08 23:41:48 +08:00
boojack
4b860777cf fix: tag generate in code block (#925) 2023-01-08 13:49:26 +08:00
boojack
e29924c8a1 fix: codeblock renderer (#924) 2023-01-08 11:24:28 +08:00
boojack
1847756ade chore: remove escape (#918) 2023-01-07 14:52:47 +08:00
boojack
771c56f485 chore: fix renderer (#917) 2023-01-07 14:07:17 +08:00
boojack
0f057e81e9 fix: version compare (#916)
* fix: version compare

* chore: update
2023-01-07 13:58:42 +08:00
Stephen Zhou
529c9b34a7 fix: missing creator id in shortcut cache (#915)
fix: missing creatot id in shortcut cache
2023-01-07 12:45:55 +08:00
boojack
e2e8130f4c fix: sort version (#914) 2023-01-07 11:49:58 +08:00
boojack
46c13a4b7f chore: add skipper for secure (#913) 2023-01-07 10:51:34 +08:00
boojack
96798e10b4 feat: support embed memo with iframe (#912) 2023-01-07 01:56:02 +08:00
boojack
0f8ce3dd16 refactor: return jsx element instead of string in marked (#910)
* refactor: return jsx element instead of string in marked

* chore: update
2023-01-07 00:13:49 +08:00
boojack
491859bbf6 chore: update activity metrics (#908) 2023-01-05 20:56:50 +08:00
boojack
f16123a624 chore: update memo create activity (#903) 2023-01-03 23:49:11 +08:00
boojack
d50ad9433f feat: persistent session name (#902)
* feat: persistent session name

* chore: update
2023-01-03 23:05:42 +08:00
Zeng1998
92a8a4ac0c feat: support code copy (#901)
* feat: support code copy

* update
2023-01-03 23:05:00 +08:00
helaxious
62f53888ba feat: update docker command description (#893) (#900)
* Add a line on README to help prevent a mistake (#893)

* Update README.md

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

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-01-03 22:40:53 +08:00
boojack
79180928d4 chore: update server activity (#898) 2023-01-03 20:05:37 +08:00
boojack
e5550828a0 chore: update activity payload (#891) 2023-01-02 23:18:12 +08:00
Vincenzo Cardone
2e95f6824f feat: add Italian Translation (#890) 2023-01-02 09:41:39 +08:00
boojack
5195012217 feat: add activity table (#888)
feat: introduce activity
2023-01-01 23:55:02 +08:00
boojack
a797280e3f chore: update middleware skipper (#887)
* chore: update middleware skipper

* chore: update
2023-01-01 23:26:21 +08:00
boojack
293f88e40c chore: update GitHub action name (#886)
* chore: update github actions name

* chore: update
2023-01-01 22:01:16 +08:00
boojack
861eeb7b0f chore: add skipper in CSRF (#885) 2023-01-01 21:32:17 +08:00
boojack
24b21aa9d7 chore: update version to 0.9.1 (#882) 2022-12-31 15:40:53 +08:00
boojack
51eac649c5 chore: update create tag dialog (#881) 2022-12-31 15:13:25 +08:00
boojack
7670c95360 chore: fix XSS in renderer (#880) 2022-12-31 11:52:57 +08:00
Ivan
65e9fdead1 feat: add russian locale (#879) 2022-12-31 09:02:14 +08:00
Zeng1998
2b2792de73 fix: logic of email validation (#877)
* fix: fix logic of email validation

* update
2022-12-30 13:10:52 +08:00
boojack
c9bb2b785d chore: fix CSRF (#876) 2022-12-30 00:17:19 +08:00
boojack
64e5c343c5 chore: fix XSS in renderer (#875)
chore: fix xss in renderer
2022-12-29 23:27:56 +08:00
boojack
9169b3f2cd chore: update tip text for empty tag list (#872) 2022-12-29 09:14:24 +08:00
boojack
b6f7a85a2a fix: reload page when sign out (#871) 2022-12-28 20:58:59 +08:00
boojack
3556ae4e65 fix: access control (#870) 2022-12-28 20:22:52 +08:00
boojack
f888c62840 chore: update userinfo validator (#868)
* chore: update userinfo validator

* chore: update actions

* chore: update
2022-12-27 21:51:43 +08:00
Taras
c160bed403 fead: add ukrainian locale (#864) 2022-12-26 19:10:47 +08:00
boojack
afc9709484 chore: update dev config (#857) 2022-12-25 10:39:45 +08:00
boojack
05b41804e3 chore: hide host user email (#856) 2022-12-25 10:28:51 +08:00
Zeng1998
2e2657b39d feat: add setting for power editor (#851) 2022-12-24 16:18:13 +08:00
Zeng1998
60ee602639 feat: enable word break (#849)
* feat: enable word break

* Update web/src/less/editor.less

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

* Update web/src/less/memo-content.less

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

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-12-24 14:50:27 +08:00
Zeng1998
cac04e4406 feat: enable word break (#849)
* feat: enable word break

* Update web/src/less/editor.less

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

* Update web/src/less/memo-content.less

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

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-12-24 14:50:10 +08:00
M. Gschwandtner
278b4d21b4 fix: prioritize user css by moving it to the body end (#847)
Co-authored-by: M. Gschwandtner <84477901+OnlyPain-ctrl@users.noreply.github.com>
2022-12-24 09:35:30 +08:00
boojack
27fd1e2880 chore: remove secure flag (#848) 2022-12-24 08:31:37 +08:00
boojack
fae9b3db46 chore: revert add linux/arm/v7 to platforms (#843)
Revert "chore: add `linux/arm/v7` to platforms (#842)"

This reverts commit 49c7f49820.
2022-12-23 22:39:04 +08:00
boojack
49c7f49820 chore: add linux/arm/v7 to platforms (#842) 2022-12-23 22:25:24 +08:00
boojack
ef8981794e chore: restore dockerfile (#841) 2022-12-23 22:12:41 +08:00
boojack
e52d77b2c4 chore: restore lockfile (#840) 2022-12-23 22:08:54 +08:00
boojack
1d2953b1b1 chore: downgrade joy-ui version (#839) 2022-12-23 21:53:13 +08:00
boojack
d702eaa625 chore: update dockerfile (#838) 2022-12-23 20:48:51 +08:00
boojack
50811c3064 chore: update yarn.lock (#837) 2022-12-23 20:33:53 +08:00
boojack
99d9cc9168 fix: set csp header only for resource (#836) 2022-12-23 20:02:42 +08:00
boojack
119603da5d chore: upgrade version to 0.9.0 (#835) 2022-12-23 19:49:55 +08:00
boojack
f6039f2eb9 chore: update dialog title (#834) 2022-12-23 19:49:44 +08:00
boojack
65cc19c12e chore: add escape to prevent XSS (#833) 2022-12-23 19:17:33 +08:00
boojack
c07b4a57ca feat: add secure middleware (#832) 2022-12-23 18:58:55 +08:00
boojack
dca35bde87 fix: disable decode patch id (#831) 2022-12-23 18:38:24 +08:00
boojack
9f25badde3 chore: update logo format to png (#830) 2022-12-23 00:21:53 +08:00
boojack
7efa749c66 feat: customize system profile (#828) 2022-12-22 19:48:44 +08:00
boojack
72daa4e1d6 feat: support heading syntax (#827) 2022-12-22 19:48:19 +08:00
ChasLui
54702db9ba feat: prevent page jitter caused by the presence of scroll bars (#808) 2022-12-22 17:46:09 +08:00
Zeng1998
41ad084489 fix: fix css of input placeholder in auth page (#824) 2022-12-22 17:45:12 +08:00
boojack
2fb171e069 chore: update create tag dialog style (#818)
* chore: update create tag dialog

* chore: update
2022-12-22 09:29:22 +08:00
boojack
201c0b020d chore: update seed data for tag (#817)
* chore: update seed data

* chore: add `_journal_mode` for SQLite

* chore: update create tag dialog
2022-12-22 08:34:05 +08:00
boojack
b6f19ca093 feat: upsert tag based content (#816)
* feat: upsert tag based content

* chore: update
2022-12-22 00:35:47 +08:00
boojack
68a77b6e1f feat: create tag dialog (#814) 2022-12-21 23:59:03 +08:00
boojack
e4a8a4d708 feat: tag table (#811)
* feat: tag table

* chore: update

* chore: update
2022-12-21 19:22:32 +08:00
boojack
ab07c91d42 feat: update marked (#810) 2022-12-21 18:36:26 +08:00
ChasLui
1838e616fd feat: show active panel when searchBar is in focus (#806)
* feat: Show active panel when searchBar is in focus

* refactor: rename
2022-12-21 18:36:08 +08:00
M. Gschwandtner
90d0ccc2e8 feat: add arm/v7 to buildx platforms (#802)
added arm/v7 to buildx platforms

Co-authored-by: M. Gschwandtner <84477901+OnlyPain-ctrl@users.noreply.github.com>
2022-12-21 09:08:08 +08:00
ChasLui
358a5c0ed9 feat: press cmd+f to focus on the search bar (#800) 2022-12-21 00:46:22 +08:00
ChasLui
40f39fd66c feat: use shift+tab to unindent (#799)
* feat: Use shift+tab to unindent, just like vscode

* fix: shit+tab return
2022-12-20 23:03:25 +08:00
Jasper Platenburg
3b41976866 feat: implement plurals for stats (#783)
* implement plurals for stats

* renamed variables

* modified according to 18n guide
2022-12-20 21:29:10 +08:00
PublicHer0
a23de50bb8 feat: update spanish locale (#786)
* Adding spanish version

* update spanish locale

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-12-20 13:18:21 +00:00
Jasper Platenburg
6596e6893e feat: implement translation for days (#784)
implement trranslation for weekdays

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-12-20 13:10:25 +00:00
ChasLui
b6fe4d914e fix: incorrect cursor when text is selected in range (#797) 2022-12-20 20:51:32 +08:00
ChasLui
3c2cd43d28 fix: shortcuts should exclude the shift judgment (#796)
* fix: Shortcuts should exclude the shift judgment

* fix: eslint
2022-12-20 19:47:35 +08:00
ChasLui
2658b1fd09 feat: support command + k shortcuts insert []() (#793)
* feat: support `command + k` shortcuts insert []()

* fix: eslint

* fix: clear code

* fix: eslint

* feat: insert [](url)

* refactor: rename param

* fix: eslint
2022-12-20 18:51:22 +08:00
Zeng1998
b7df1f5bbf feat: matching punctuation (#791) 2022-12-20 18:10:02 +08:00
ChasLui
a0face6695 feat: update i18n (#790)
* feat: tag type i18n

* feat: custom server dialog i18n

* feat: i18n resources name

* feat: i18n toast

* fix: eslint

* eslint: fix

* fix: eslint

* fix: eslint
2022-12-20 17:47:02 +08:00
boojack
c177db69d5 chore: update tag regexp (#785) 2022-12-20 09:44:41 +08:00
boojack
b704c20809 chore: return raw text for html (#782) 2022-12-19 18:45:17 +08:00
boojack
6c17f94ef6 fix: max open conns for SQLite (#781) 2022-12-19 18:28:15 +08:00
lujiefsi
726285e634 chore: restrict the html file (#749)
* restrict the html file

* replace spaces with table

* remove space
2022-12-19 18:26:50 +08:00
Zeng1998
bd6ab71d41 chore: remove unused state (#780) 2022-12-19 18:03:39 +08:00
boojack
b67ed1ee13 feat: customize system profile (#774)
* feat: system setting for customized profile

* chore: update

* feat: update frontend

* chore: update
2022-12-18 21:18:30 +08:00
Zeng1998
55695f2189 feat: esc key to exit multiple dialogs (#692)
* fix: `esc` key to exit multiple dialogs

* update

* update

* update

* Update web/src/components/Dialog/BaseDialog.tsx

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-12-18 10:09:12 +00:00
boojack
e79d67d127 chore: update readme with deploy guides (#771) 2022-12-18 17:59:29 +08:00
ajstephens1995
1d9ef9813a docs: guide to deploy memos with render for beginners (#769) 2022-12-18 16:35:09 +08:00
boojack
ef621a444f refactor: introducing use{Module}Store instead of service (#768)
* refactor: introducing `useEditorStore`

* refactor: update

* chore: update
2022-12-18 15:25:18 +08:00
boojack
bd00fa798d chore: simplify ordered list in editor (#767)
chore: simplify editor
2022-12-18 12:44:46 +08:00
Zeng1998
a41745c9ae feat: editor enhancement for order list (#763) 2022-12-18 11:02:42 +08:00
M. Gschwandtner
1eec474007 fix: heatmap popup showing after logging out (#761)
* fix for heatmap popup showing after logging out

* moved node.remove to component unmount

* Update web/src/components/UsageHeatMap.tsx

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

Co-authored-by: M. Gschwandtner <84477901+OnlyPain-ctrl@users.noreply.github.com>
Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-12-17 03:10:06 +00:00
Stephen Zhou
83e5278b51 fix: dialog close when draging from in to out (#760) 2022-12-17 10:53:55 +08:00
boojack
a8751af6b5 fix: memo list padding bottom (#759) 2022-12-17 10:19:15 +08:00
boojack
6b24f52cd1 fix: watermark container width (#758) 2022-12-17 10:15:54 +08:00
boojack
7ec22482c1 chore: upgrade version to 0.8.3 (#755) 2022-12-16 23:12:07 +08:00
boojack
ee89dc00c0 chore: update list style (#754) 2022-12-16 23:09:01 +08:00
boojack
bbd5fe4eb2 feat: remove sticky style for memo editor (#752) 2022-12-16 22:41:12 +08:00
boojack
575a0610a3 chore: revert "feat: add visibility field to resource (#743)" (#751)
Revert "feat: add `visibility` field to resource (#743)"

This reverts commit b68cc08592.
2022-12-16 22:20:17 +08:00
boojack
b68cc08592 feat: add visibility field to resource (#743) 2022-12-15 21:15:16 +08:00
Stephen Zhou
d51af7e98a fix: hr in edited content do not trigger folding (#748) 2022-12-15 18:38:35 +08:00
M. Gschwandtner
334da5e903 fix: add a span as wrapper to fix whitespace (#747) 2022-12-15 08:45:20 +08:00
Jasper Platenburg
35fed76d1a feat: add 'theme' to translation (#746) 2022-12-15 08:43:32 +08:00
boojack
c77d49259a chore: update light bg color (#744) 2022-12-13 23:34:38 +08:00
PublicHer0
5520605ccc feat: add spanish locale (#741) 2022-12-13 09:12:22 +08:00
Zeng1998
1dee8ae49f fix: url resource filename decode (#738)
* fix: url resource filename decode

* update

* update

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

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-12-12 20:00:21 +08:00
Zeng1998
3fd4ee83ac fix: checklist auto continuation (#737) 2022-12-12 18:30:04 +08:00
boojack
5e978e2cfc chore: update color scheme listener (#735) 2022-12-11 23:18:25 +08:00
boojack
37b7b983d2 chore: add vite plugin legacy (#734) 2022-12-11 22:15:52 +08:00
Zeng1998
c4278ef55a fix: fix order in resource dialog (#733) 2022-12-11 21:36:17 +08:00
Zeng1998
91220ea4a6 fix: reset image state in gallery (#730) 2022-12-11 20:14:55 +08:00
Zeng1998
4bebbf3e1d feat: enable paste multiple resources (#729)
* feat: enable paste multiple resources

* update

* update
2022-12-11 19:17:39 +08:00
boojack
5d8b8c37a5 chore: upgrade vite (#728)
* chore: upgrade vite

* Revert "chore: remove lazy import component (#724)"

This reverts commit 688dc2304c.
2022-12-11 18:38:29 +08:00
boojack
564f20d13a chore: remove ESC to close edit (#726) 2022-12-11 14:18:04 +08:00
boojack
c3adb1b152 fix: set resource list in memo editor (#725) 2022-12-11 14:04:22 +08:00
boojack
688dc2304c chore: remove lazy import component (#724) 2022-12-10 19:39:43 +08:00
boojack
dd6e2337e6 chore: update version to 0.8.2 (#722) 2022-12-10 13:23:38 +08:00
boojack
66418d4210 feat: get image only when cors error (#721) 2022-12-10 13:20:48 +08:00
boojack
ab8c7b9d8a fix: auto complete in memo editor (#720) 2022-12-10 12:44:45 +08:00
M. Gschwandtner
387799b31c fix: added dark theme bg color to buttons (#719) 2022-12-10 12:14:02 +08:00
boojack
4a64a4dea8 fix: html lang attr (#718) 2022-12-10 10:42:10 +08:00
M. Gschwandtner
964c58ac01 feat: responsive layout for create shortcut dialog (#717) 2022-12-10 10:17:47 +08:00
boojack
56716cdad4 fix: break word (#708)
* fix: break word

* chore: update
2022-12-09 08:31:45 +08:00
Shruti Chaturvedi
a2ee750d1e fix: bump Version of reusable.yaml (#707)
Bump version

Bump up the version for reusable.yaml to v2
2022-12-09 00:31:00 +08:00
Shruti Chaturvedi
3f0601f651 feat: add Uffizzi Integration (#655)
* Integrate Uffizzi

* Update docker-compose.uffizzi.yml

Start memos in dev mode

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

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-12-08 23:45:23 +08:00
Zeng1998
6f8e3432e9 fix: correct priority of keys in editor (#703) 2022-12-08 18:46:43 +08:00
Stephen Zhou
b7ab6f8e7e fix: code highlight in dark mode (#702) 2022-12-08 18:30:46 +08:00
Zeng1998
36b92ad884 feat: auto continuation list in editor (#689)
* feat: auto continuation list in editor

* update

* update
2022-12-08 10:01:01 +08:00
apixandru
4d9857ce18 fix: update UsageHeatMap.tsx to account for daylight savings (#696) 2022-12-08 08:24:36 +08:00
boojack
43b22ce55f chore: fix typo (#695) 2022-12-07 22:49:05 +08:00
Zeng1998
147185309c feat: vacuum database in setting (#694)
* feat: vacuum database in setting

* update

* update

* update

* update
2022-12-07 22:45:47 +08:00
Maurice Bauer
f48226d4f2 chore: update German translation (#691)
Switched to plural to make the difference between TAG(S) and TAG(E) visible...
Refers to #545
2022-12-07 20:18:35 +08:00
Zeng1998
e92407d9ec feat: image preview enhancement (#682) 2022-12-06 09:38:01 +08:00
Jasper Platenburg
79bf365d78 Dutch locale (#687) 2022-12-06 08:11:21 +08:00
Maurice Bauer
492a1370ab feat: add German i18n item (#686) 2022-12-06 07:42:17 +08:00
boojack
e3ddf93c4d chore: update demo image (#672) 2022-12-04 20:43:46 +08:00
boojack
4a9314c476 chore: rename enableFoldMemo (#671)
* chore: rename `enableFoldMemo`

* chore: update
2022-12-04 15:34:03 +08:00
boojack
4767ee3293 feat: support follow system appearance (#670) 2022-12-04 12:23:29 +08:00
boojack
1ea74dfd0d chore: remove table syntax (#669) 2022-12-04 10:52:11 +08:00
Andreas Backström
53cf6ebb79 feat: add swedish/svenska translation (#668)
Add swedish / svenska translation
2022-12-03 21:38:25 +08:00
boojack
d1007950e0 chore: remove emoji picker (#667) 2022-12-03 15:28:37 +08:00
Zeng1998
331226ec68 chore: fix some typos of README (#666) 2022-12-03 15:01:27 +08:00
boojack
a7374cf998 fix: generate sharing memo image (#663) 2022-12-03 09:41:52 +08:00
boojack
e3d76193b9 chore: update global css (#658) 2022-12-02 22:00:03 +08:00
boojack
07f0c3f052 chore: update global css (#657) 2022-12-02 21:34:43 +08:00
boojack
a467a7c173 feat: upgrade dev version to 0.8.1 (#656)
* feat: upgrade version to `0.8.1`

* chore: update
2022-12-02 21:09:11 +08:00
boojack
14f9f29348 chore: update user setting appearance (#654) 2022-12-02 20:00:34 +08:00
EINDEX
5451fd2d2c feat: add a product of logseq plugin (#652) 2022-12-02 19:46:39 +08:00
hoi-lau
f092771ea1 fix: resource-container overflow (#649) 2022-12-02 19:45:22 +08:00
boojack
7c6d7226f5 feat: update appearance selector (#645) 2022-12-01 20:57:19 +08:00
Stephen Zhou
eaebc6dcef fix: apperance can not auto switch (#644) 2022-12-01 19:39:58 +08:00
boojack
c5200ca31b feat: dark mode for dialogs (#643) 2022-11-30 20:34:16 +08:00
Tiefseemonster
1078132b12 fix: member menu dropdown position (#639)
* fix: member menu dropdown position

* chore: cleanup

* chore: cleanup
2022-11-30 20:18:39 +08:00
Stephen Zhou
6b058cd299 feat: save folding option with localstorage (#641)
* fix: change folding option need reload

* fix: floding option undefied
2022-11-30 19:13:55 +08:00
Wence
b8f24af5ae feat: dynamic lazy loading route with simple loading page (#632)
* feat: dynamic loading route with simple loading page

* fix: lint fix

* Update web/src/less/loading.less

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

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-11-29 22:13:22 +00:00
boojack
6384f5af74 feat: dark mode for main pages (#637)
* feat: update dark mode styles for auth and explore page

* feat: dark mode for home page
2022-11-29 21:44:52 +08:00
Zeng1998
52038d26d2 chore: update i18n for validator message (#636) 2022-11-29 21:35:40 +08:00
boojack
55f37664ef chore: add theme file for joyui (#635) 2022-11-29 20:15:16 +08:00
Zeng1998
ab8e3473a1 feat: support resources reuse (#620)
* feat: support resource reuse

* update

* update

* update

* update
2022-11-29 19:07:20 +08:00
Zeng1998
eba23c4f6e fix: add validation for user information update (#633) 2022-11-29 19:03:29 +08:00
Zeng1998
00fe6d3862 chore: add joyui tooltip for resources dialog (#630) 2022-11-29 09:36:48 +08:00
Charles Chin
3646b8f5dd docs: update readme (#628) 2022-11-29 06:34:44 +08:00
boojack
d40639bf8e chore: update readme me with contributors graph (#626) 2022-11-28 22:48:02 +08:00
Zeng1998
12b81781b9 feat: share memo dialog (#618)
* feat: new share dialog

* update

* update

* update

* update
2022-11-28 21:20:35 +08:00
Zeng1998
b67a37453d feat: member management enhancement (#617)
* feat: member management enhancement

* update

* update

* update

* update
2022-11-28 19:59:11 +08:00
boojack
b04e001db1 fix: image url host missing (#623) 2022-11-28 19:52:03 +08:00
Stephen Zhou
fbe7b604ef feat: dark mode support for memo detail (#604)
* feat: dark mode support for memo detail

* chore: update

* chore: update

* chore: update
2022-11-28 19:40:08 +08:00
Zeng1998
0402cb7b27 fix: no user settings returns when patch user (#622) 2022-11-28 19:38:44 +08:00
Tiefseemonster
b72bfc9c24 fix: selector dropdown position in fullscreen mod (#619) 2022-11-28 19:36:42 +08:00
Zeng1998
40e92f9463 fix: change password max length validation (#616) 2022-11-28 19:33:14 +08:00
Zeng1998
f883dd9c1d feat: create user repeat password (#614)
* feat: create user repeat password

* update
2022-11-28 19:32:53 +08:00
Wujiao233
d8bf55efb2 fix: shoutcut tag filter handle mutiple tags (#608)
* fix: shoutcut tag filter handle mutiple tags

* not edit parser
2022-11-28 19:32:01 +08:00
Wujiao233
f982e83d0a fix: clear shortcut filter when delete this shortcut (#611) 2022-11-28 06:14:25 +08:00
Jasper Platenburg
3472a6db26 fix: password field visible (#609) 2022-11-27 21:53:10 +08:00
boojack
c79e51a91b chore: update readme (#606) 2022-11-27 16:04:35 +08:00
boojack
ce795a2a7d chore: show content image (#602) 2022-11-27 09:01:19 +08:00
boojack
045819c312 fix: initial database schema (#601) 2022-11-27 08:52:43 +08:00
Tiefseemonster
2fa01886da fix: tooltip overlaps a window border (#599) 2022-11-27 08:48:21 +08:00
Tiefseemonster
dd7d322c47 chore: add .vscode to gitignore (#596) 2022-11-27 07:56:19 +08:00
Tiefseemonster
dfe71f33c2 fix: search bar dropdown disappearing (#593) 2022-11-26 23:13:02 +08:00
boojack
db1d223448 fix: apperance select (#585) 2022-11-26 17:45:16 +08:00
Zeng1998
54271c1598 chore: fix some typos (#587) 2022-11-26 06:23:29 +00:00
Zeng1998
1ee8ebc9e1 fix: collapse btn cursor style (#586) 2022-11-26 05:12:37 +00:00
Stephen Zhou
6e5537d131 feat: dark mode support for explore page (#584)
* feat: dark mode support for auth page

* chore: update

* feat: dark mode support for explore page (#583)

* fix: avoid white text

* fix: import order
2022-11-26 12:19:00 +08:00
Stephen Zhou
90c85103c3 feat: dark mode support for auth page (#569)
* feat: dark mode support for auth page

* chore: update
2022-11-26 11:20:22 +08:00
Zeng1998
2d5d734da4 chore: update i18n for account settings (#582) 2022-11-26 02:23:57 +00:00
Zeng1998
e1e5121dd7 fix: get markdown image from backend (#581) 2022-11-26 10:20:49 +08:00
boojack
85db6721de chore: disable metrics collector (#580) 2022-11-26 09:23:38 +08:00
boojack
b511a7b634 fix: user patch with empty email (#578) 2022-11-25 22:34:24 +08:00
boojack
88c3b1ad0f feat: update prod version (#577) 2022-11-25 22:17:24 +08:00
boojack
9fe15a03b3 chore(revert): update test image platforms (#576)
Revert "chore: update test image platforms (#575)"

This reverts commit 500afffbcf.
2022-11-25 22:08:43 +08:00
boojack
500afffbcf chore: update test image platforms (#575)
chore: update test image plantforms
2022-11-25 22:06:40 +08:00
Zeng1998
5f3cade810 feat: highlight the searched text in memo content (#514)
* feat: highlight the searched text in memo content

* update

* update

* update

* update

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-11-25 21:59:21 +08:00
boojack
072851e3ba chore: update dialog style (#574)
* chore: update tests

* chore: update dialog styles
2022-11-25 21:55:45 +08:00
boojack
e2cbea2022 chore: update action name (#573) 2022-11-25 21:53:36 +08:00
boojack
29880be283 chore: update tests (#572) 2022-11-25 21:51:53 +08:00
Tiefseemonster
f4e2b7319a fix: make tooltip text no-selecting (#567)
* no selecting tooltip text

With a double click on button, you can get selection on tooltip text. That may be distracting a little bit.

* Update web/src/less/memo.less

Co-authored-by: Stephen Zhou <hi@hyoban.cc>

Co-authored-by: Stephen Zhou <hi@hyoban.cc>
Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-11-25 20:32:59 +08:00
Zeng1998
ff0db82dc3 chore: empty query text (#566) 2022-11-25 20:09:42 +08:00
Stephen Zhou
2daf085ce4 chore: fix vscode settings name (#565) 2022-11-25 01:51:43 +00:00
Tiefseemonster
495f1f2041 chore: fix name field in paragraph parser obj (#564) 2022-11-25 01:47:17 +00:00
boojack
89179f78c2 chore: add SECURITY.md (#562) 2022-11-25 09:05:52 +08:00
boojack
7ad7461a92 chore: add test docker image (#559) 2022-11-24 20:54:22 +08:00
boojack
8cfcd201b0 feat: support display video (#558) 2022-11-24 20:22:54 +08:00
boojack
abcd3cfafb feat: add Strikethrough syntax (#557)
feat: add `Strikethrough` rule
2022-11-24 20:05:51 +08:00
boojack
50d41c456b chore: change memo created time (#556) 2022-11-24 19:55:52 +08:00
Zeng1998
1d41d53723 fix: icon style (#555) 2022-11-24 18:44:47 +08:00
boojack
0dc003854f chore: fix typo (#551) 2022-11-24 10:03:00 +08:00
Zeng1998
a4b7a77016 fix: cursor style (#549) 2022-11-24 09:30:17 +08:00
boojack
939e836d1b chore: update readme (#548) 2022-11-24 08:45:36 +08:00
boojack
a0b35f7aa9 feat: add French i18n item (#547)
feat: add French i18n
2022-11-24 08:42:38 +08:00
baptiste313
574e160a11 chore: addition of the French translation and small correction for the English (#546) 2022-11-24 08:03:16 +08:00
boojack
2042737004 feat: add username field (#544)
* feat: add username field

* chore: update
2022-11-23 22:27:21 +08:00
boojack
a0667abec8 chore: update data initial requests (#538) 2022-11-22 23:45:11 +08:00
boojack
362306a9cb chore: update i18n for visibility (#537) 2022-11-22 22:59:54 +08:00
Zeng1998
5e069f79a2 feat: esc to cancel editing (#532) 2022-11-22 19:54:25 +08:00
Zeng1998
7c78fb064f fix: empty filter value (#529) 2022-11-22 11:31:37 +00:00
Zeng1998
11a7e897dc fix: update issue template (#530) 2022-11-22 19:30:04 +08:00
Yarden Shoham
d8adc0afe9 feat: extend validation config max length (#523) 2022-11-22 18:21:12 +08:00
boojack
17c1b97d23 fix: tag regexp (#518)
* fix: tag regexp

* chore: update
2022-11-21 23:49:52 +08:00
boojack
5aee2f46ab chore: update issue template (#517) 2022-11-21 23:38:21 +08:00
boojack
013ded1e04 chore: code clean (#516) 2022-11-21 23:23:05 +08:00
Zeng1998
0d0f893b93 chore: update i18 for memo sharing (#513) 2022-11-21 19:59:19 +08:00
Zeng1998
d2a87a4ddb chore: bgcolor of memo share image (#508) 2022-11-21 18:38:32 +08:00
boojack
b05a176490 chore: add license to README (#506) 2022-11-21 07:20:36 +08:00
boojack
d149926a39 chore: update seed data (#507) 2022-11-21 07:17:28 +08:00
boojack
2d49e96a8a feat: get image blob in backend (#495)
* feat: get image blob in backend

* chore: update
2022-11-19 18:43:56 +08:00
boojack
9036bd478b fix: image scrollbar (#494) 2022-11-19 17:36:25 +08:00
boojack
477130aa85 chore: update db filesize access control (#493) 2022-11-19 17:07:40 +08:00
boojack
878e0eabc8 feat: add crawler plugin (#492)
* feat: add crawler plugin

* chore: update

* chore: go mod tidy

* chore: update
2022-11-19 16:58:55 +08:00
boojack
62f63d4af7 chore: update dependencies version (#491) 2022-11-19 14:51:28 +08:00
Zeng1998
1acf2f8b13 chore: update i18 for settings (#490) 2022-11-19 10:33:52 +08:00
boojack
a4a5e539ed chore: update dev version (#489) 2022-11-19 09:57:54 +08:00
boojack
a2831b37c4 feat: add database filesize in UI (#488) 2022-11-18 21:17:52 +08:00
boojack
706b1b428f chore: add toast to system settings (#486) 2022-11-17 21:37:57 +08:00
boojack
1690566413 chore: update emoji picker (#483) 2022-11-17 21:11:58 +08:00
boojack
a24b885236 chore: update share memo image (#482) 2022-11-17 21:01:26 +08:00
Stephen Zhou
c89a6665b6 feat: fold memos according to horizontal rule (#478)
* feat: fold memos according to horizontal rule

* fix: button and content

* chore: update
2022-11-15 14:49:08 +00:00
Stephen Zhou
797accbc2c feat: parser for horizontal rule (#477)
* feat: parser for horizontal rule

* chore: revert
2022-11-15 21:56:44 +08:00
boojack
66f9bc48bb chore: remove mark memo (#476) 2022-11-15 21:22:08 +08:00
boojack
de2eff474c chore: remove memo card dialog (#475) 2022-11-15 21:16:53 +08:00
Mahoo Huang
67195859dc fix: abnormal link regex (#474) 2022-11-15 21:15:10 +08:00
boojack
61abc6dd11 feat: update sharing memo image (#473) 2022-11-15 21:02:55 +08:00
boojack
9fe9245727 chore: update cancel edit button style (#472) 2022-11-15 20:35:56 +08:00
Zeng1998
906ec7994e fix: clear query text (#469)
* fix: clear query text

* update

* update

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-11-15 19:52:16 +08:00
Zeng1998
5258b0a5b4 chore: change memo card's bg color (#471) 2022-11-15 18:55:11 +08:00
boojack
ceac53b6d0 feat: additional script system setting (#467) 2022-11-14 22:21:19 +08:00
Stephen Zhou
3775d5c9c2 feat: folding options (#459)
* feat: folding options

* chore: update

* chore: update

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-11-14 22:06:05 +08:00
Zeng1998
da80d4ef62 fix: duration query string (#465) 2022-11-14 18:57:43 +08:00
Stephen Zhou
205ad0fd6d chore: change rss item title (#464) 2022-11-14 13:28:50 +08:00
Zeng1998
d208731f5f feat: visibility click filter (#463)
* feat: visibility click filter

* update

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-11-13 21:23:40 +08:00
Stephen Zhou
a90acdabb3 fix: route confusion entering from non-home page (#460)
Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-11-13 19:40:16 +08:00
Zeng1998
407d1cdcaa feat: add visibility filter (#461)
* feat: add visibility filter

* update
2022-11-13 19:34:22 +08:00
boojack
cb50bbc3cb refactor: remove mixin colors (#458) 2022-11-13 14:25:02 +08:00
boojack
2743268fd7 chore: remove unused visibility selector (#457) 2022-11-13 10:47:21 +08:00
Zeng1998
8cc0977a01 fix: image url extraction (#453)
fix: image-url-extraction

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-11-12 21:25:50 +08:00
boojack
0809ec8c72 chore: update editor style (#456) 2022-11-12 21:22:51 +08:00
boojack
db93710f85 chore: remove mobile editor style user setting (#455) 2022-11-12 21:01:34 +08:00
boojack
241c93c6b7 feat: update editor style (#454)
* feat: update editor style

* chore: update bg
2022-11-12 20:57:08 +08:00
boojack
bf07ab9e2f fix: remove duplicate tag (#450)
fix: remove dumplicate tag
2022-11-12 11:31:16 +08:00
boojack
fe05e6a464 chore: update version 0.7.2 (#447) 2022-11-12 09:19:44 +08:00
boojack
d1aa6aa7f8 fix: editor resource list (#445) 2022-11-12 09:04:26 +08:00
boojack
79af7e8abf fix: parse tag list (#446) 2022-11-12 09:02:44 +08:00
boojack
a142d975d7 feat: additional style system setting (#444)
* feat: additional style system setting

* feat: remove editor font setting
2022-11-11 23:42:44 +08:00
boojack
67691d1e99 feat: update visibility selector style (#441) 2022-11-11 19:25:21 +08:00
Zeng1998
9b827b4801 feat: add support for time-shortcut (#434)
* feat: add support for time-shortcut

* update

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-11-11 19:14:38 +08:00
Zeng1998
421f4dbf60 feat: select visibility in editor (#421)
* feat: editing visibility selection

* update

* update

* update variable name

* update

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-11-10 13:43:49 +00:00
boojack
8f119c4265 chore: update session key (#440) 2022-11-10 20:51:37 +08:00
boojack
9866702850 fix: parser regexp for special character (#439) 2022-11-10 20:38:14 +08:00
Yunliang Zhou
a313a9bb31 fix: route confusion entering from non-home page (#430) 2022-11-10 19:50:12 +08:00
boojack
e53f5fdd29 chore: update seed data (#437) 2022-11-10 08:41:11 +08:00
Zeng1998
b6c0a04394 fix: share memo with resource list (#431)
* fix: share memo with resource list

* update

* Update web/src/components/ShareMemoImageDialog.tsx

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-11-09 14:58:16 +00:00
Zeng1998
1e3b8315a0 chore: update MemoResources props (#432) 2022-11-09 22:10:31 +08:00
boojack
dc5d705f8c feat: vacuum records manually (#420) 2022-11-06 04:21:58 +00:00
Zeng1998
4f10c12092 chore: update translation (#417) 2022-11-05 21:55:48 +08:00
Zeng1998
3aa44f9f6d fix: delete related resources (#415)
* chore: add icon

* fix: delete all related resources when a memo is deleted

* update

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-11-05 20:21:13 +08:00
Zeng1998
78b4733fb9 chore: add icon for deleting resource (#414) 2022-11-05 17:41:14 +08:00
winwin2011
37bb3bc546 chore: allow skip version (#411)
* chore: allow skip version

* chore: opacity

* chore: polish

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-11-04 15:40:48 +00:00
winwin2011
b43bfce254 fix: tag compressed (#412) 2022-11-04 23:39:20 +08:00
boojack
0d6281ef6b chore: update signin page (#410)
* chore: update signin page

* chore: update version `v0.7.1`
2022-11-04 12:28:16 +00:00
boojack
8e2844e0c5 fix: tag regexp (#409)
* chore: enable `no-unused-vars`

* fix: tag regexp
2022-11-04 00:28:29 +00:00
boojack
c27e38b11c chore: enable no-unused-vars (#408) 2022-11-03 23:43:56 +00:00
boojack
cf75054106 feat: add system setting to allow user signup (#407) 2022-11-03 21:47:36 +08:00
Zhou Yunliang
4ed987229b feat: text filter regex support (#406) 2022-11-03 21:06:17 +08:00
Mahoo Huang
a1068b6fe3 fix: abnormal blockquote regexp (#404)
```markdown
面向具体实现编程 ==> 面向抽象接口编程
```.
面向具体实现编程 ==
> 面向抽象接口编程
2022-11-02 20:33:25 +08:00
Zhou Yunliang
91a61e058a feat: view all images of a memo (#393)
* feat: view all images of a memo

* fix: function arguments

* refactor: unified image preview

* refactor: image preview for resource dialog

Co-authored-by: XQ <qiaobingxue1998@163.com>
2022-11-02 12:00:28 +00:00
Zhou Yunliang
bebcabc292 chore: update gitignore (#402) 2022-11-02 18:57:45 +08:00
winwin2011
5bdf15aece chore: fold/expand i18n (#400) 2022-11-01 22:44:23 +00:00
winwin2011
d347b7a91e chore: pin button color (#399) 2022-11-02 06:42:20 +08:00
boojack
2c653f170c chore: update corner width (#398) 2022-11-01 15:13:54 +00:00
boojack
75218ef826 chore: update readme (#397) 2022-11-01 15:09:17 +00:00
boojack
b7fbbed895 chore: update pagination for getting all memos (#396) 2022-11-01 14:57:33 +00:00
boojack
f8c0d73d2d chore: update resource dialog style (#395) 2022-11-01 14:24:54 +00:00
boojack
006cb56d28 fix: heatmap data (#394) 2022-11-01 14:06:02 +00:00
boojack
55dee0df7e fix: session max age (#392) 2022-11-01 13:03:33 +00:00
boojack
2a275b2875 fix: memo list default value (#390) 2022-11-01 09:12:00 +08:00
Zeng1998
4246232fbe feat: image preview enhancement (#385)
* feat: dialog content enhancement

* update

* feat: image carousel

* update

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-11-01 09:07:14 +08:00
boojack
9d8c9609c3 feat: cache editing memo id (#388)
* feat: cache editing memo id

* chore: update
2022-10-31 21:39:22 +08:00
boojack
ef5492074e chore: update memo stats api (#387) 2022-10-31 20:57:07 +08:00
boojack
40d5686031 chore: update unpin memo (#386) 2022-10-31 11:27:56 +00:00
Zeng1998
4276a7a56d feat: remove unused resources (#379)
* feat: dialog content enhancement

* feat: remove unused resources

* update

* update

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-10-31 11:23:44 +00:00
Zeng1998
d6fa1c7c80 feat: dialog content enhancement (#377) 2022-10-31 11:00:18 +00:00
boojack
6cbc3c78e2 chore: update readme (#375) 2022-10-30 14:42:56 +08:00
boojack
29966451b6 feat: unpin button in the corner (#374)
* feat: unpin button in corner

* chore: update
2022-10-30 05:21:24 +00:00
boojack
64f0662a61 fix: get memo list order (#370) 2022-10-29 12:54:54 +00:00
boojack
0ccfd0c743 fix: resource table migration (#369)
* fix: resource table migration

* chore: update
2022-10-29 11:47:31 +00:00
boojack
0ea1733acc fix: missing column in resource table (#368) 2022-10-29 10:49:58 +00:00
boojack
8fb59bbfb9 chore: release v0.7.0 (#367)
chore: update release version
2022-10-29 10:18:24 +00:00
boojack
7c401040c8 feat: add blockquote regexp (#366) 2022-10-29 10:14:53 +00:00
boojack
43541bde2c feat: add update version banner (#365)
feat: add update version banenr
2022-10-29 09:49:50 +00:00
boojack
1e01c4dc46 chore: update resource service (#364) 2022-10-29 09:24:56 +00:00
Zeng1998
e85d368f87 feat: patch resource filename (#360)
* feat: resource filename rename

* update: resource filename rename

* update: resource filename rename

* update: validation about the filename

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-10-29 07:40:09 +00:00
boojack
95376f78f6 feat: add metric plugin (#361) 2022-10-29 03:15:39 +00:00
Zeng1998
30daea0c4f fix: typo (#357)
* feat: resource dialog enhancements

* update

* fix: typo

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-10-29 01:13:11 +00:00
Zeng1998
b891e08928 feat: resource dialog enhancements (#356)
* feat: resource dialog enhancements

* update
2022-10-29 09:11:16 +08:00
boojack
94df09c8c0 chore: update memo list api (#350) 2022-10-27 14:02:42 +00:00
boojack
bdf6d4d42a feat: case-insensitive search (#347) 2022-10-27 00:05:45 +00:00
boojack
cb2e1ae355 fix: fetch memo with filter (#346) 2022-10-26 23:53:40 +00:00
boojack
9705406b82 feat: remove foreign key and triggers (#345) 2022-10-26 15:00:09 +00:00
Zhou Yunliang
4e00b1b0cd feat: rss support (#343)
* feat: rss support

* chore: go mod tidy

* chore: change route group prefix

* Update server/server.go

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

* Update server/rss.go

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

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-10-26 20:13:02 +08:00
boojack
fe5ba6850b chore: update insert content in editor (#336) 2022-10-23 12:28:30 +00:00
Mahoo Huang
5690dab6bb feat: add some hotkeys (#320)
* feat: add some hotkeys

* fix: hotkeys lose the text behind selected

* chore: adjust insertText params passing
2022-10-23 11:33:45 +00:00
Zeng1998
65506a5b30 chore: update the translation (#333) 2022-10-22 09:36:38 +00:00
boojack
f09bb2689c chore: release v0.6.1 (#332) 2022-10-21 15:21:37 +00:00
boojack
e6f376ae66 fix: user setting name (#331) 2022-10-21 15:06:15 +00:00
boojack
1c2998c4d8 feat: pagination for memo list (#330) 2022-10-21 14:51:41 +00:00
Zeng1998
fc5d5cf231 fix: patch memo with resource list (#328)
fix: 修改memo时添加图片不显示
2022-10-21 22:16:05 +08:00
boojack
2a4fc7dcc3 chore: update memo display time (#327)
* chore: update memo display time

* chore: update
2022-10-21 20:26:00 +08:00
boojack
b68d6e2693 feat: update memo sort option setting (#326)
feat: add memo display ts
2022-10-21 11:57:57 +00:00
boojack
0b34b142c8 chore: update marked (#325) 2022-10-21 09:13:19 +08:00
boojack
0b2a9d8511 fix: bold and emphasis regex (#323)
* fix: bold and emphasis regex

* chore: udpate
2022-10-20 21:57:40 +08:00
winwin2011
7c9c5c316b chore: update i18n in settings (#324)
chore: i18n
2022-10-20 21:40:03 +08:00
winwin2011
180ae206c7 feat: inline code within link (#321)
* feat: inline code with link

* fix: decoration style
2022-10-20 21:21:30 +08:00
boojack
69e3ba6bed chore: update demo seeding data (#318)
chore: update seeding data
2022-10-20 17:19:37 +08:00
Zeng1998
bf5b7e747d feat: customize memo list sorting rules (#312)
* chore: update .gitignore

* feat: 添加Memo列表按更新时间排序

* fix go-static-checks

* update

* update

* update Memo.tsx/MemoList.tsx

* handle conflict

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-10-19 21:00:34 +08:00
f97
24154c95f2 feat: editor tab support (#309)
* feat: editor tab support

* Update web/src/components/MemoEditor.tsx

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

* chore: if return style

Co-authored-by: boojack <stevenlgtm@gmail.com>
Co-authored-by: hyoban <hi@hyoban.cc>
2022-10-19 18:19:50 +08:00
boojack
0b10d24eb2 chore: update bug report template (#316)
chore: update but report template
2022-10-19 08:22:56 +08:00
boojack
06ec59eca4 chore: add issue template (#314) 2022-10-18 23:13:41 +08:00
boojack
35a8817442 fix: memo list auto scroll to top (#313)
fix: editor initial content
2022-10-18 22:56:43 +08:00
boojack
c7378e78d9 chore: update mask animation styles (#306) 2022-10-17 18:43:02 +08:00
Tiger
ab182dc22a chore: update zh.json (#304)
Update zh.json
2022-10-16 13:49:58 +08:00
boojack
f554e5a357 fix: close button z-index in setting dialog (#303) 2022-10-16 12:37:17 +08:00
Steven
749486ba3c chore: update marked tests 2022-10-15 21:57:56 +08:00
Hyoban
26aae0e637 fix: blank line after table (#298) 2022-10-15 17:51:17 +08:00
Hyoban
95c8d8fc9c docs: update docker-compose tip (#299) 2022-10-15 15:32:42 +08:00
Hyoban
1021023577 docs: docker compose tip (#296)
* docs: docker compose tip

* chore: update

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

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-10-15 13:27:06 +08:00
Hyoban
3d0c6004c0 feat: support markdown table (#294)
* feat: support markdown table

* chore: update table style

* test: for markdown table parse
2022-10-15 11:42:04 +08:00
Steven
6c53bd00f6 chore: restore user email 2022-10-15 11:27:12 +08:00
Steven
cc759bef56 fix: handle highlight unknown language error 2022-10-15 06:11:17 +08:00
Steven
d670adc11f chore: release v0.6.0 2022-10-14 23:25:07 +08:00
Steven
349c383604 chore: reorder memo resource 2022-10-14 23:14:08 +08:00
Steven
f407488128 chore: update dev version 2022-10-14 23:03:54 +08:00
boojack
086b10717f feat: upload files by dropping (#292) 2022-10-14 22:59:30 +08:00
boojack
eefd0444c8 feat: add highlight for code block (#291)
* feat: add highlight for code block

* chore: update test
2022-10-14 22:29:28 +08:00
Hyoban
65a61ed270 fix: can not click sidebar (#289) 2022-10-14 21:43:43 +08:00
Hyoban
5e7db4631e feat: handle esc keyboard event for editor (#288) 2022-10-14 10:14:35 +08:00
boojack
0d6114e25e feat: update sidebar mask styles (#287) 2022-10-14 07:26:43 +08:00
h2o2o
ce5a6fa3ac chore: update sidebar styles in mobile view (#285)
* 添加移动端点击自动关闭侧边栏

* 添加移动端点击自动关闭侧边栏

* 添加移动端点击自动关闭侧边栏

* move closeSidebar function to utils

* move closeSidebar function to utils

* 侧边栏优化

* 移动端侧边栏优化

* 移动端侧边栏优化

* 移动端侧边栏优化
2022-10-13 22:56:42 +08:00
boojack
246851fdbe fix: create memo with visibility (#281) 2022-10-13 09:01:09 +08:00
boojack
21c30ac157 chore: hide user email (#282) 2022-10-13 09:01:02 +08:00
Steven
269d92e637 fix: create triggers after dropping old tables 2022-10-13 08:23:05 +08:00
Steven
ffe145d436 chore: revert sidebar updates 2022-10-13 08:05:59 +08:00
zburu
37cd841992 chore: adjust sidebar style (#277) 2022-10-13 07:54:34 +08:00
h2o2o
315ab94c94 添加移动端点击自动关闭侧边栏 (#271) 2022-10-11 08:12:35 +08:00
f97
1aa9963e07 fix: i18n for filter (#275) 2022-10-10 17:46:52 +08:00
Steven
88ade2c0b7 chore: update i18n for filter 2022-10-09 08:54:05 +08:00
winwin2011
4ada7dce77 chore: update i18n for shortcut filter (#270)
* chore: resources i18n

* chore: shortcut-list i18n

* chore: resources i18n

* chore: resources i18n

* chore: resources i18n
2022-10-08 22:27:23 +08:00
steven
a323689ee6 chore: update copy button style 2022-10-05 20:55:04 +08:00
f97
2ea612e2fe feat: add copy button to memo (#267)
* feat: copy-content

* Update web/src/less/memo-detail.less

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-10-05 16:39:04 +08:00
boojack
ca2557eb7e fix: tag filter (#266) 2022-10-04 21:02:07 +08:00
steven
e0f95686f3 chore: update header style 2022-10-04 20:42:33 +08:00
f97
29770e8bfb chore: update vietnamese i18n (#261) 2022-10-04 14:37:44 +08:00
steven
b959acc69d fix: update marked test cases 2022-10-04 14:35:07 +08:00
steven
486cf8bdac feat: add escape to renderer 2022-10-04 13:53:36 +08:00
steven
ea911387f1 chore: update migration sql file 2022-10-04 12:04:26 +08:00
steven
0b9b89db81 chore: update VACUUM 2022-10-04 10:48:45 +08:00
boojack
eaf89aa2f2 feat: update marked parsers (#260)
* chore: remove external match functions

* chore: update parsers
2022-10-04 10:44:16 +08:00
boojack
4bd373ba57 fix: tag selector position (#259) 2022-10-04 08:48:43 +08:00
steven
7b29c65f58 feat: add inline-code syntax parser 2022-10-03 19:51:54 +08:00
steven
2298ac6ff3 chore: add FUNDING.yml 2022-10-03 19:43:56 +08:00
steven
4477f47c25 chore: release version 0.5.0 2022-10-03 18:49:11 +08:00
steven
4a2fe3aaae chore: update user banner style 2022-10-03 18:48:44 +08:00
steven
0f65b8bdd3 fix: re-generate table columns foreign keys 2022-10-03 18:47:31 +08:00
steven
778282ae29 chore: update resource dialog 2022-10-03 09:39:59 +08:00
steven
85dc29bfb9 feat: add linkedMemoAmount to resource 2022-10-03 09:39:49 +08:00
steven
05e46ee4a8 fix: emoji picker style 2022-10-03 09:11:07 +08:00
steven
6a3b052fa2 chore: don't clean data in dev mode 2022-10-03 08:49:20 +08:00
boojack
222c792539 fix: tag regexp (#253) 2022-10-02 23:12:06 +08:00
boojack
51fb8ddb07 feat: simple markdown parser (#252)
* feat: simple markdown parser

* chore: rename test file name

* feat: add plain text link parser

* chore: update style
2022-10-02 22:49:30 +08:00
steven
8e63b8f289 chore: update interval flag type 2022-10-01 23:01:32 +08:00
steven
17c35be426 chore: add --passWithNoTests to jest 2022-10-01 22:59:00 +08:00
steven
dfd461518f chore: add jest github action 2022-10-01 22:57:32 +08:00
steven
df5818cdc5 chore: add jest 2022-10-01 22:56:20 +08:00
steven
5894104524 chore: update inline image 2022-10-01 20:00:45 +08:00
steven
09732df0f5 chore: regenerate yarn.lock 2022-10-01 19:59:15 +08:00
steven
ae8f292306 feat: create memo with resourceIdList 2022-10-01 10:57:14 +08:00
steven
9f8c0ce567 fix: raw data cache 2022-10-01 10:37:02 +08:00
steven
c7cf35c7de feat: update memo resource component 2022-10-01 10:02:16 +08:00
steven
e5c9d8604d feat: compose memo resource list 2022-10-01 09:49:59 +08:00
steven
b2c22977c1 feat: update memo editor with uploading resources 2022-10-01 00:10:31 +08:00
steven
c0edb72b3d chore: update react version 2022-10-01 00:07:06 +08:00
steven
33d31b7dca fix: delete memo resource 2022-10-01 00:06:56 +08:00
steven
4c465bef2d feat: add memo resource apis 2022-09-30 22:58:59 +08:00
steven
3bde9543f3 chore: update dev version v0.5.0 2022-09-30 22:58:12 +08:00
steven
cff0e86989 feat: add memo_resource model 2022-09-30 20:20:00 +08:00
boojack
65f7aa7914 fix: emoji picker position in fullscreen (#251) 2022-09-30 18:40:46 +08:00
boojack
06f5a5788e fix: mobile editor float style (#247) 2022-09-27 22:58:03 +08:00
boojack
52c8ac2ad3 chore: update emoji picker toggle logic (#244) 2022-09-26 22:33:41 +08:00
Steven
0c80654cc2 chore: update dropdown action button style 2022-09-26 22:06:06 +08:00
Steven
a2180f177f chore: remove vite-plugin-pwa 2022-09-26 21:40:37 +08:00
Steven
b992d07f3e chore: update memo detail header 2022-09-24 10:05:51 +08:00
Steven
dc3052e225 chore: release v0.4.5 2022-09-24 09:41:14 +08:00
Steven
9b2e57cee5 fix: api access checks 2022-09-24 09:34:01 +08:00
Steven
77a3513a6b chore: update memo detail page 2022-09-24 09:31:20 +08:00
Steven
0dd2337663 chore: update router 2022-09-21 21:30:33 +08:00
Steven
d316c04837 feat: update dev latest schema 2022-09-21 19:34:54 +08:00
Steven
63468dbaf3 chore: update memo detail access handler 2022-09-21 19:31:02 +08:00
f97
2acd5d4af2 chore: support html form (#236) 2022-09-21 14:12:16 +08:00
Steven
6c1cc1d283 chore: use conditional rendering instead of OnlyWhen 2022-09-20 23:30:25 +08:00
Steven
15cfc9e1f5 chore: add memo detail page 2022-09-20 22:55:24 +08:00
Steven
004713d4cd chore: update dropdown component 2022-09-20 21:11:33 +08:00
f97
7a6eb53e0f feat: float mobile editor (#234)
* feat: float mobile editor

* fix: fix pr comment

* lint: fix golangci-lint
2022-09-20 20:42:14 +08:00
boojack
02c26d5bb4 chore: update i18n for memo visibility (#233) 2022-09-20 08:34:49 +08:00
Steven
c60c58ed69 chore: fix memo content click handler 2022-09-19 23:01:14 +08:00
Steven
366afdd1e4 feat: use i18next 2022-09-19 22:27:50 +08:00
Steven
307483e499 feat: use react-router 2022-09-19 21:53:27 +08:00
Steven
4608894e56 fix: add _foreign_keys to sqlite dsn 2022-09-18 22:48:26 +08:00
boojack
a1066322c8 chore: add vite-plugin-pwa (#230) 2022-09-18 11:58:03 +08:00
f97
050a2d39fa chore: fix i18n vietnamese typos and update MemoCardDialog (#228) 2022-09-18 09:17:46 +08:00
Steven
13aa61bbc0 chore: update i18n for change password dialog 2022-09-17 13:33:40 +08:00
boojack
f4d0e8c948 chore: hide the searchbar of emoji picker (#222)
chore: hide emoji picker searchbar
2022-09-17 09:23:53 +08:00
f97
e7db587a9e feat: add emoji picker in editor (#221)
* feat: add vietnamese

* feat: add emoji picker in editor

* fix failing checks

* move emoji button before upload button

* move script to body index.html

* Update web/index.html

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-09-17 00:42:52 +08:00
Steven
059080f194 chore: fix docker tag 2022-09-16 23:34:57 +08:00
Steven
78c159cd59 chore: fix build tag 2022-09-16 23:15:16 +08:00
Steven
721aa3c907 chore: update v0.4.4 2022-09-16 23:04:40 +08:00
Steven
77178afad5 chore: add no data tip 2022-09-16 22:55:39 +08:00
Steven
fd7b8c3293 chore: add copy non-private memo link 2022-09-16 22:49:11 +08:00
Steven
660908e436 chore: add react-router 2022-09-16 22:48:41 +08:00
Steven
8d694f7732 chore: remove language beta badge 2022-09-16 21:47:00 +08:00
f97
bdfa9f7a56 chore: update i18n for UserBanner (#219)
* feat: add vietnamese

* chore: update i18n

* Update web/src/locales/zh.json

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

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-09-16 21:48:18 +08:00
Steven
19ef97a7ec chore: update dev version 2022-09-16 21:45:56 +08:00
Steven
2c17ea703d chore: fix manifest.json image 2022-09-16 21:33:31 +08:00
Steven
1591fdf61c chore: update i18n structures 2022-09-16 21:25:39 +08:00
f97
811f3340e9 feat: add vietnamese (#218) 2022-09-16 21:09:59 +08:00
ChasLui
7e8d1128f8 chore: update i18n (#215) 2022-09-15 06:53:51 +08:00
Steven
54db6eda04 chore: update common locales 2022-09-14 19:35:45 +08:00
boojack
c5b26e3310 chore: fix copy to clipboard (#214) 2022-09-14 19:24:13 +08:00
ChasLui
7079faf2b9 chore: update i18n (#212)
* feat: 增加部分 i18n

* feat: 增加部分 i18n

* feat: 增加部分 i18n
2022-09-14 19:14:45 +08:00
Steven
707d1a96eb chore: move version pkg to server/version 2022-09-12 17:25:34 +08:00
boojack
76801dfa4f chore: vacuum db file after deleting resource (#210) 2022-09-10 23:43:19 +08:00
Steven
6e4577f721 feat: add MemoContent component 2022-09-10 21:22:26 +08:00
winwin2011
7b0987610c feat: i18n for dialogs (#203)
* feat: i18n in dialog

* fix: use common translation
2022-09-10 09:30:10 +08:00
boojack
50fa560d4a chore: update username validate config (#209) 2022-09-09 22:30:24 +08:00
666 changed files with 64994 additions and 14695 deletions

View File

@@ -1,2 +1 @@
web/node_modules
web/yarn.lock

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
github: usememos

31
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: Bug Report
description: Create a report to help us improve
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.
- type: textarea
attributes:
label: Describe the bug
description: |
Briefly describe the problem you are having in a few paragraphs.
validations:
required: true
- type: textarea
attributes:
label: Steps to reproduce
description: |
Provide the steps to reproduce the issue.
placeholder: |
1. Go to '...'
2. Click on '....'
3. See error
validations:
required: true
- type: textarea
attributes:
label: Screenshots or additional context
description: |
Add screenshots or any other context about the problem.

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

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

View File

@@ -0,0 +1,28 @@
name: Feature Request
description: Suggest an idea for this project
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
- type: textarea
attributes:
label: Describe the solution you'd like
description: |
A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
attributes:
label: Additional context
description: Add any other context or screenshots about the feature request.

48
.github/workflows/backend-tests.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
name: Backend Test
on:
push:
branches: [main]
pull_request:
branches:
- main
- "release/*.*.*"
paths:
- "go.mod"
- "go.sum"
- "**.go"
jobs:
go-static-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v4
with:
go-version: 1.21
check-latest: true
cache: true
- name: Verify go.mod is tidy
run: |
go mod tidy -go=1.21
git diff --exit-code
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
version: v1.54.1
args: --verbose --timeout=3m
skip-cache: true
go-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v4
with:
go-version: 1.21
check-latest: true
cache: true
- name: Run all tests
run: go test -v ./... | tee test.log; exit ${PIPESTATUS[0]}
- name: Pretty print tests running time
run: grep --color=never -e '--- PASS:' -e '--- FAIL:' test.log | sed 's/[:()]//g' | awk '{print $2,$3,$4}' | sort -t' ' -nk3 -r | awk '{sum += $3; print $1,$2,$3,sum"s"}'

View File

@@ -4,22 +4,25 @@ on:
push:
branches:
# Run on pushing branches like `release/1.0.0`
- "release/v*.*.*"
- "release/*.*.*"
jobs:
build-and-push-release-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Extract build args
# Extract version from branch name
# Example: branch name `release/v1.0.0` sets up env.VERSION=1.0.0
# Example: branch name `release/1.0.0` sets up env.VERSION=1.0.0
run: |
echo "VERSION=${GITHUB_REF_NAME#release/v}" >> $GITHUB_ENV
echo "VERSION=${GITHUB_REF_NAME#release/}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@v2
@@ -27,11 +30,31 @@ jobs:
username: neosmemo
password: ${{ secrets.DOCKER_NEOSMEMO_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
with:
install: true
version: v0.9.1
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
neosmemo/memos
ghcr.io/usememos/memos
tags: |
type=raw,value=latest
type=semver,pattern={{version}},value=${{ env.VERSION }}
type=semver,pattern={{major}}.{{minor}},value=${{ env.VERSION }}
- name: Build and Push
id: docker_build
@@ -41,4 +64,5 @@ jobs:
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: neosmemo/memos:latest, neosmemo/memos:${{ env.VERSION }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -0,0 +1,60 @@
name: build-and-push-test-image
on:
push:
branches: [main]
jobs:
build-and-push-test-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: neosmemo
password: ${{ secrets.DOCKER_NEOSMEMO_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
with:
install: true
version: v0.9.1
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
neosmemo/memos
ghcr.io/usememos/memos
flavor: |
latest=false
tags: |
type=raw,value=test
- name: Build and Push
id: docker_build
uses: docker/build-push-action@v3
with:
context: ./
file: ./Dockerfile
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -17,8 +17,12 @@ on:
pull_request:
# The branches below must be a subset of the branches above
branches: [main]
schedule:
- cron: "27 12 * * 0"
paths:
- "go.mod"
- "go.sum"
- "**.go"
- "proto/**"
- "web/**"
jobs:
analyze:
@@ -38,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.
@@ -53,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
@@ -67,4 +71,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
uses: github/codeql-action/analyze@v2

52
.github/workflows/frontend-tests.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: Frontend Test
on:
push:
branches: [main]
pull_request:
branches:
- main
- "release/*.*.*"
paths:
- "web/**"
jobs:
eslint-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2.2.4
with:
version: 8
- uses: actions/setup-node@v3
with:
node-version: "18"
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
frontend-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2.2.4
with:
version: 8
- uses: actions/setup-node@v3
with:
node-version: "18"
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

18
.github/workflows/issue-translator.yml vendored Normal file
View File

@@ -0,0 +1,18 @@
name: 'issue-translator'
on:
issue_comment:
types: [created]
issues:
types: [opened]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: usthe/issues-translate-action@v2.7
with:
IS_MODIFY_TITLE: false
# not require, default false, . Decide whether to modify the issue title
# if true, the robot account @Issues-translate-bot must have modification permissions, invite @Issues-translate-bot to your project or use your custom bot.
CUSTOM_BOT_NOTE: Issue is not in English. It has been translated automatically.
# not require. Customize the translation robot prefix message.

34
.github/workflows/proto-linter.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: Proto linter
on:
push:
branches: [main]
pull_request:
branches:
- main
- "release/*.*.*"
paths:
- "proto/**"
jobs:
lint-protos:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup buf
uses: bufbuild/buf-setup-action@v1
with:
github_token: ${{ github.token }}
- name: buf lint
uses: bufbuild/buf-lint-action@v1
with:
input: "proto"
- name: buf format
run: |
if [[ $(buf format -d) ]]; then
echo "Run 'buf format -w'"
exit 1
fi

View File

@@ -1,73 +0,0 @@
name: Test
on:
push:
branches:
- main
- "release/v*.*.*"
pull_request:
branches: [main]
jobs:
go-static-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: 1.18
check-latest: true
cache: true
- name: Verify go.mod is tidy
run: |
go mod tidy
git diff --exit-code
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
args: -v
skip-cache: true
eslint-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: "16"
cache: yarn
cache-dependency-path: "web/yarn.lock"
- run: yarn
working-directory: web
- name: Run eslint check
run: yarn lint
working-directory: web
frontend-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: "16"
cache: yarn
cache-dependency-path: "web/yarn.lock"
- run: yarn
working-directory: web
- name: Run frontend build
run: yarn build
working-directory: web
go-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: 1.18
check-latest: true
cache: true
- name: Run all tests
run: go test -v ./... | tee test.log; exit ${PIPESTATUS[0]}
- name: Pretty print tests running time
run: grep --color=never -e '--- PASS:' -e '--- FAIL:' test.log | sed 's/[:()]//g' | awk '{print $2,$3,$4}' | sort -t' ' -nk3 -r | awk '{sum += $3; print $1,$2,$3,sum"s"}'

8
.gitignore vendored
View File

@@ -6,8 +6,14 @@ tmp
# Frontend asset
web/dist
server/dist
# build folder
build
.DS_Store
.DS_Store
# Jetbrains
.idea
bin/air

View File

@@ -1,5 +1,8 @@
run:
timeout: 10m
linters:
enable:
- errcheck
- goimports
- revive
- govet
@@ -10,17 +13,30 @@ linters:
- rowserrcheck
- nilerr
- godot
- forbidigo
- mirror
- bodyclose
issues:
include:
# https://golangci-lint.run/usage/configuration/#command-line-options
exclude:
- Rollback
- logger.Sync
- pgInstance.Stop
- fmt.Printf
- fmt.Print
- Enter(.*)_(.*)
- Exit(.*)_(.*)
linters-settings:
goimports:
# Put imports beginning with prefix after 3rd-party packages.
local-prefixes: github.com/usememos/memos
revive:
# Default to run all linters so that new rules in the future could automatically be added to the static check.
enable-all-rules: true
rules:
# The following rules are too strict and make coding harder. We do not enable them for now.
- name: file-header
disabled: true
- name: line-length-limit
@@ -56,9 +72,14 @@ linters-settings:
- ifElseChain
govet:
settings:
printf:
funcs:
printf: # The name of the analyzer, run `go tool vet help` to see the list of all analyzers
funcs: # Run `go tool vet help printf` to see the full configuration of `printf`.
- common.Errorf
enable-all: true
disable:
- fieldalignment
- shadow
forbidigo:
forbid:
- 'fmt\.Errorf(# Please use errors\.Wrap\|Wrapf\|Errorf instead)?'
- 'ioutil\.ReadDir(# Please use os\.ReadDir)?'

3
.vscode/extensions.json vendored Normal file
View File

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

12
.vscode/project.code-workspace vendored Normal file
View File

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

View File

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

View File

@@ -1,2 +1,2 @@
# These owners will be the default owners for everything in the repo.
* @boojack @lqwakeup
* @boojack

View File

@@ -1,31 +1,40 @@
# Build frontend dist.
FROM node:16.15.0-alpine AS frontend
FROM node:18-alpine AS frontend
WORKDIR /frontend-build
COPY ./web/ .
COPY . .
RUN yarn
RUN yarn build
WORKDIR /frontend-build/web
RUN corepack enable && pnpm i --frozen-lockfile && pnpm type-gen
RUN pnpm build
# Build backend exec file.
FROM golang:1.18.3-alpine3.16 AS backend
FROM golang:1.21-alpine AS backend
WORKDIR /backend-build
RUN apk update
RUN apk --no-cache add gcc musl-dev
COPY . .
COPY --from=frontend /frontend-build/dist ./server/dist
COPY --from=frontend /frontend-build/web/dist ./server/dist
RUN go build -o memos ./bin/server/main.go
RUN CGO_ENABLED=0 go build -o memos ./main.go
# Make workspace with above generated files.
FROM alpine:3.16.0 AS monolithic
FROM alpine:latest AS monolithic
WORKDIR /usr/local/memos
RUN apk add --no-cache tzdata
ENV TZ="UTC"
COPY --from=backend /backend-build/memos /usr/local/memos/
EXPOSE 5230
# Directory to store the data, which can be referenced as the mounting point.
RUN mkdir -p /var/opt/memos
VOLUME /var/opt/memos
ENTRYPOINT ["./memos", "--mode", "prod", "--port", "5230"]
ENV MEMOS_MODE="prod"
ENV MEMOS_PORT="5230"
ENTRYPOINT ["./memos"]

View File

@@ -1,48 +1,63 @@
<p align="center"><a href="https://usememos.com"><img height="64px" src="https://raw.githubusercontent.com/usememos/memos/main/resources/logo-full.webp" alt="✍️ memos" /></a></p>
# memos
<p align="center">An open source, self-hosted knowledge base that works with a SQLite db file.</p>
<img height="72px" src="https://usememos.com/logo.png" alt="✍️ memos" align="right" />
<p align="center">
<a href="https://github.com/usememos/memos/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/usememos/memos" /></a>
<a href="https://hub.docker.com/r/neosmemo/memos"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/neosmemo/memos.svg" /></a>
<img alt="Go report" src="https://goreportcard.com/badge/github.com/usememos/memos" />
A privacy-first, lightweight note-taking service. Easily capture and share your great thoughts.
<a href="https://www.usememos.com">Home Page</a>
<a href="https://www.usememos.com/blog">Blogs</a> •
<a href="https://www.usememos.com/docs">Docs</a> •
<a href="https://demo.usememos.com/">Live Demo</a>
<p>
<a href="https://github.com/usememos/memos/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/usememos/memos?logo=github" /></a>
<a href="https://hub.docker.com/r/neosmemo/memos"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/neosmemo/memos.svg"/></a>
<a href="https://hosted.weblate.org/engage/memos-i18n/"><img src="https://hosted.weblate.org/widget/memos-i18n/english/svg-badge.svg" alt="Translation status" /></a>
<a href="https://discord.gg/tfPJa4UmAv"><img alt="Discord" src="https://img.shields.io/badge/discord-chat-5865f2?logo=discord&logoColor=f5f5f5" /></a>
</p>
<p align="center">
<a href="https://demo.usememos.com/">Live Demo</a> •
<a href="https://t.me/+-_tNF1k70UU4ZTc9">Discuss in Telegram 👾</a>
</p>
![demo](https://www.usememos.com/demo.webp)
![demo](https://raw.githubusercontent.com/usememos/memos/main/resources/demo.webp)
## Key points
## Features
- **Open source and free forever**. Embrace a future where creativity knows no boundaries with our open-source solution free today, tomorrow, and always.
- **Self-hosting with Docker in just seconds**. Enjoy the flexibility, scalability, and ease of setup that Docker provides, allowing you to have full control over your data and privacy.
- **Pure text with added Markdown support.** Say goodbye to the overwhelming mental burden of rich formatting and embrace a minimalist approach.
- **Customize and share your notes effortlessly**. With our intuitive sharing features, you can easily collaborate and distribute your notes with others.
- **RESTful API for third-party services.** Embrace the power of integration and unleash new possibilities with our RESTful API support.
- 🦄 Fully open source;
- 📜 Writing in plain textarea without any burden,
- and support some useful markdown syntax 💪.
- 🌄 Share the memo in a pretty image or personal page like Twitter;
- 🚀 Fast self-hosting with `Docker`;
- 🤠 Pleasant UI and UX;
## Deploy with Docker in seconds
## Deploy with Docker
### Docker Run
```docker
docker run -d --name memos -p 5230:5230 -v ~/.memos/:/var/opt/memos neosmemo/memos:latest
```bash
docker run -d --name memos -p 5230:5230 -v ~/.memos/:/var/opt/memos ghcr.io/usememos/memos:latest
```
Memos should be running at [http://localhost:5230](http://localhost:5230). If the `~/.memos/` does not have a `memos_prod.db` file, then memos will auto generate it.
> 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.
### Docker Compose
Learn more about [other installation methods](https://www.usememos.com/docs/install).
See more in the example [`docker-compose.yaml`](./docker-compose.yaml) file.
## Contribution
## Contributing
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! 🥰
Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are greatly appreciated. 🥰
<a href="https://github.com/usememos/memos/graphs/contributors">
<img src="https://contrib.rocks/image?repo=usememos/memos" />
</a>
Gets more about [development guide](https://github.com/usememos/memos/tree/main/docs/development.md).
---
- [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
- [quanru/obsidian-periodic-para](https://github.com/quanru/obsidian-periodic-para#daily-record) - Obsidian plugin
- [JakeLaoyu/memos-import-from-flomo](https://github.com/JakeLaoyu/memos-import-from-flomo) - Import data. Support from flomo, wechat reading
- [Quick Memo](https://www.icloud.com/shortcuts/1eaef307112843ed9f91d256f5ee7ad9) - Shortcuts (iOS, iPadOS or macOS)
- [Memos Raycast Extension](https://www.raycast.com/JakeYu/memos) - Raycast extension
- [Memos Desktop](https://github.com/xudaolong/memos-desktop) - Third party client for MacOS and Windows
- [MemosGallery](https://github.com/BarryYangi/MemosGallery) - A static Gallery rendered with the Memos API
## Star history

7
SECURITY.md Normal file
View File

@@ -0,0 +1,7 @@
# Security Policy
## Reporting a bug
Report security bugs via GitHub [issues](https://github.com/usememos/memos/issues).
For more information, please contact [stevenlgtm@gmail.com](stevenlgtm@gmail.com).

View File

@@ -1,13 +0,0 @@
package api
type Signin struct {
Email string `json:"email"`
Password string `json:"password"`
}
type Signup struct {
Email string `json:"email"`
Role Role `json:"role"`
Name string `json:"name"`
Password string `json:"password"`
}

63
api/auth/auth.go Normal file
View File

@@ -0,0 +1,63 @@
package auth
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v4"
)
const (
// issuer is the issuer of the jwt token.
Issuer = "memos"
// Signing key section. For now, this is only used for signing, not for verifying since we only
// have 1 version. But it will be used to maintain backward compatibility if we change the signing mechanism.
KeyID = "v1"
// AccessTokenAudienceName is the audience name of the access token.
AccessTokenAudienceName = "user.access-token"
AccessTokenDuration = 7 * 24 * time.Hour
// CookieExpDuration expires slightly earlier than the jwt expiration. Client would be logged out if the user
// cookie expires, thus the client would always logout first before attempting to make a request with the expired jwt.
CookieExpDuration = AccessTokenDuration - 1*time.Minute
// AccessTokenCookieName is the cookie name of access token.
AccessTokenCookieName = "memos.access-token"
)
type ClaimsMessage struct {
Name string `json:"name"`
jwt.RegisteredClaims
}
// GenerateAccessToken generates an access token.
func GenerateAccessToken(username string, userID int32, expirationTime time.Time, secret []byte) (string, error) {
return generateToken(username, userID, AccessTokenAudienceName, expirationTime, secret)
}
// generateToken generates a jwt token.
func generateToken(username string, userID int32, audience string, expirationTime time.Time, secret []byte) (string, error) {
registeredClaims := jwt.RegisteredClaims{
Issuer: Issuer,
Audience: jwt.ClaimStrings{audience},
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: fmt.Sprint(userID),
}
if !expirationTime.IsZero() {
registeredClaims.ExpiresAt = jwt.NewNumericDate(expirationTime)
}
// Declare the token with the HS256 algorithm used for signing, and the claims.
token := jwt.NewWithClaims(jwt.SigningMethodHS256, &ClaimsMessage{
Name: username,
RegisteredClaims: registeredClaims,
})
token.Header["kid"] = KeyID
// Create the JWT string.
tokenString, err := token.SignedString(secret)
if err != nil {
return "", err
}
return tokenString, nil
}

View File

@@ -1,22 +0,0 @@
package api
// CacheNamespace is the type of a cache.
type CacheNamespace string
const (
// UserCache is the cache type of users.
UserCache CacheNamespace = "u"
// MemoCache is the cache type of memos.
MemoCache CacheNamespace = "m"
// ShortcutCache is the cache type of shortcuts.
ShortcutCache CacheNamespace = "s"
// ResourceCache is the cache type of resources.
ResourceCache CacheNamespace = "r"
)
// CacheService is the service for caches.
type CacheService interface {
FindCache(namespace CacheNamespace, id int, entry interface{}) (bool, error)
UpsertCache(namespace CacheNamespace, id int, entry interface{}) error
DeleteCache(namespace CacheNamespace, id int)
}

View File

@@ -1,85 +0,0 @@
package api
// Visibility is the type of a visibility.
type Visibility string
const (
// Public is the PUBLIC visibility.
Public Visibility = "PUBLIC"
// Protected is the PROTECTED visibility.
Protected Visibility = "PROTECTED"
// Privite is the PRIVATE visibility.
Privite Visibility = "PRIVATE"
)
func (e Visibility) String() string {
switch e {
case Public:
return "PUBLIC"
case Protected:
return "PROTECTED"
case Privite:
return "PRIVATE"
}
return "PRIVATE"
}
type Memo struct {
ID int `json:"id"`
// Standard fields
RowStatus RowStatus `json:"rowStatus"`
CreatorID int `json:"creatorId"`
CreatedTs int64 `json:"createdTs"`
UpdatedTs int64 `json:"updatedTs"`
// Domain specific fields
Content string `json:"content"`
Visibility Visibility `json:"visibility"`
Pinned bool `json:"pinned"`
// Related fields
Creator *User `json:"creator"`
}
type MemoCreate struct {
// Standard fields
CreatorID int
// Domain specific fields
Visibility Visibility `json:"visibility"`
Content string `json:"content"`
}
type MemoPatch struct {
ID int
// Standard fields
CreatedTs *int64 `json:"createdTs"`
RowStatus *RowStatus `json:"rowStatus"`
// Domain specific fields
Content *string `json:"content"`
Visibility *Visibility `json:"visibility"`
}
type MemoFind struct {
ID *int `json:"id"`
// Standard fields
RowStatus *RowStatus `json:"rowStatus"`
CreatorID *int `json:"creatorId"`
// Domain specific fields
Pinned *bool
ContentSearch *string
VisibilityList []Visibility
// Pagination
Limit int
Offset int
}
type MemoDelete struct {
ID int `json:"id"`
}

View File

@@ -1,21 +0,0 @@
package api
type MemoOrganizer struct {
ID int
// Domain specific fields
MemoID int
UserID int
Pinned bool
}
type MemoOrganizerFind struct {
MemoID int
UserID int
}
type MemoOrganizerUpsert struct {
MemoID int
UserID int
Pinned bool `json:"pinned"`
}

View File

@@ -1,44 +0,0 @@
package api
type Resource struct {
ID int `json:"id"`
// Standard fields
CreatorID int `json:"creatorId"`
CreatedTs int64 `json:"createdTs"`
UpdatedTs int64 `json:"updatedTs"`
// Domain specific fields
Filename string `json:"filename"`
Blob []byte `json:"-"`
Type string `json:"type"`
Size int64 `json:"size"`
}
type ResourceCreate struct {
// Standard fields
CreatorID int
// Domain specific fields
Filename string `json:"filename"`
Blob []byte `json:"blob"`
Type string `json:"type"`
Size int64 `json:"size"`
}
type ResourceFind struct {
ID *int `json:"id"`
// Standard fields
CreatorID *int `json:"creatorId"`
// Domain specific fields
Filename *string `json:"filename"`
}
type ResourceDelete struct {
ID int
// Standard fields
CreatorID int
}

165
api/resource/resource.go Normal file
View File

@@ -0,0 +1,165 @@
package resource
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"sync/atomic"
"time"
"github.com/disintegration/imaging"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"go.uber.org/zap"
"github.com/usememos/memos/internal/log"
"github.com/usememos/memos/internal/util"
"github.com/usememos/memos/server/profile"
"github.com/usememos/memos/store"
)
const (
// The key name used to store user id in the context
// user id is extracted from the jwt token subject field.
userIDContextKey = "user-id"
// thumbnailImagePath is the directory to store image thumbnails.
thumbnailImagePath = ".thumbnail_cache"
)
type Service struct {
Profile *profile.Profile
Store *store.Store
}
func NewService(profile *profile.Profile, store *store.Store) *Service {
return &Service{
Profile: profile,
Store: store,
}
}
func (s *Service) RegisterResourcePublicRoutes(g *echo.Group) {
g.GET("/r/:resourceId", s.streamResource)
g.GET("/r/:resourceId/*", s.streamResource)
}
func (s *Service) streamResource(c echo.Context) error {
ctx := c.Request().Context()
resourceID, err := util.ConvertStringToInt32(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
resource, err := s.Store.GetResource(ctx, &store.FindResource{
ID: &resourceID,
GetBlob: true,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find resource by ID: %v", resourceID)).SetInternal(err)
}
if resource == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource not found: %d", resourceID))
}
// Check the related memo visibility.
if resource.MemoID != nil {
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
ID: resource.MemoID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find memo by ID: %v", resource.MemoID)).SetInternal(err)
}
if memo != nil && memo.Visibility != store.Public {
userID, ok := c.Get(userIDContextKey).(int32)
if !ok || (memo.Visibility == store.Private && userID != resource.CreatorID) {
return echo.NewHTTPError(http.StatusUnauthorized, "Resource visibility not match")
}
}
}
blob := resource.Blob
if resource.InternalPath != "" {
resourcePath := resource.InternalPath
src, err := os.Open(resourcePath)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to open the local resource: %s", resourcePath)).SetInternal(err)
}
defer src.Close()
blob, err = io.ReadAll(src)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to read the local resource: %s", resourcePath)).SetInternal(err)
}
}
if c.QueryParam("thumbnail") == "1" && util.HasPrefixes(resource.Type, "image/png", "image/jpeg") {
ext := filepath.Ext(resource.Filename)
thumbnailPath := filepath.Join(s.Profile.Data, thumbnailImagePath, fmt.Sprintf("%d%s", resource.ID, ext))
thumbnailBlob, err := getOrGenerateThumbnailImage(blob, thumbnailPath)
if err != nil {
log.Warn(fmt.Sprintf("failed to get or generate local thumbnail with path %s", thumbnailPath), zap.Error(err))
} else {
blob = thumbnailBlob
}
}
c.Response().Writer.Header().Set(echo.HeaderCacheControl, "max-age=3600")
c.Response().Writer.Header().Set(echo.HeaderContentSecurityPolicy, "default-src 'none'; script-src 'none'; img-src 'self'; media-src 'self'; sandbox;")
c.Response().Writer.Header().Set("Content-Disposition", fmt.Sprintf(`filename="%s"`, resource.Filename))
resourceType := strings.ToLower(resource.Type)
if strings.HasPrefix(resourceType, "text") {
resourceType = echo.MIMETextPlainCharsetUTF8
} else if strings.HasPrefix(resourceType, "video") || strings.HasPrefix(resourceType, "audio") {
http.ServeContent(c.Response(), c.Request(), resource.Filename, time.Unix(resource.UpdatedTs, 0), bytes.NewReader(blob))
return nil
}
return c.Stream(http.StatusOK, resourceType, bytes.NewReader(blob))
}
var availableGeneratorAmount int32 = 32
func getOrGenerateThumbnailImage(srcBlob []byte, dstPath string) ([]byte, error) {
if _, err := os.Stat(dstPath); err != nil {
if !errors.Is(err, os.ErrNotExist) {
return nil, errors.Wrap(err, "failed to check thumbnail image stat")
}
if atomic.LoadInt32(&availableGeneratorAmount) <= 0 {
return nil, errors.New("not enough available generator amount")
}
atomic.AddInt32(&availableGeneratorAmount, -1)
defer func() {
atomic.AddInt32(&availableGeneratorAmount, 1)
}()
reader := bytes.NewReader(srcBlob)
src, err := imaging.Decode(reader, imaging.AutoOrientation(true))
if err != nil {
return nil, errors.Wrap(err, "failed to decode thumbnail image")
}
thumbnailImage := imaging.Resize(src, 512, 0, imaging.Lanczos)
dstDir := path.Dir(dstPath)
if err := os.MkdirAll(dstDir, os.ModePerm); err != nil {
return nil, errors.Wrap(err, "failed to create thumbnail dir")
}
if err := imaging.Save(thumbnailImage, dstPath); err != nil {
return nil, errors.Wrap(err, "failed to resize thumbnail image")
}
}
dstFile, err := os.Open(dstPath)
if err != nil {
return nil, errors.Wrap(err, "failed to open the local resource")
}
defer dstFile.Close()
dstBlob, err := io.ReadAll(dstFile)
if err != nil {
return nil, errors.Wrap(err, "failed to read the local resource")
}
return dstBlob, nil
}

View File

@@ -1,49 +0,0 @@
package api
type Shortcut struct {
ID int `json:"id"`
// Standard fields
RowStatus RowStatus `json:"rowStatus"`
CreatorID int `json:"creatorId"`
CreatedTs int64 `json:"createdTs"`
UpdatedTs int64 `json:"updatedTs"`
// Domain specific fields
Title string `json:"title"`
Payload string `json:"payload"`
}
type ShortcutCreate struct {
// Standard fields
CreatorID int
// Domain specific fields
Title string `json:"title"`
Payload string `json:"payload"`
}
type ShortcutPatch struct {
ID int
// Standard fields
RowStatus *RowStatus `json:"rowStatus"`
// Domain specific fields
Title *string `json:"title"`
Payload *string `json:"payload"`
}
type ShortcutFind struct {
ID *int
// Standard fields
CreatorID *int
// Domain specific fields
Title *string `json:"title"`
}
type ShortcutDelete struct {
ID int
}

View File

@@ -1,8 +0,0 @@
package api
import "github.com/usememos/memos/server/profile"
type SystemStatus struct {
Host *User `json:"host"`
Profile *profile.Profile `json:"profile"`
}

View File

@@ -1,100 +0,0 @@
package api
import (
"fmt"
"github.com/usememos/memos/common"
)
// Role is the type of a role.
type Role string
const (
// Host is the HOST role.
Host Role = "HOST"
// NormalUser is the USER role.
NormalUser Role = "USER"
)
func (e Role) String() string {
switch e {
case Host:
return "HOST"
case NormalUser:
return "USER"
}
return "USER"
}
type User struct {
ID int `json:"id"`
// Standard fields
RowStatus RowStatus `json:"rowStatus"`
CreatedTs int64 `json:"createdTs"`
UpdatedTs int64 `json:"updatedTs"`
// Domain specific fields
Email string `json:"email"`
Role Role `json:"role"`
Name string `json:"name"`
PasswordHash string `json:"-"`
OpenID string `json:"openId"`
UserSettingList []*UserSetting `json:"userSettingList"`
}
type UserCreate struct {
// Domain specific fields
Email string `json:"email"`
Role Role `json:"role"`
Name string `json:"name"`
Password string `json:"password"`
PasswordHash string
OpenID string
}
func (create UserCreate) Validate() error {
if !common.ValidateEmail(create.Email) {
return fmt.Errorf("invalid email format")
}
if len(create.Email) < 6 {
return fmt.Errorf("email is too short, minimum length is 6")
}
if len(create.Password) < 6 {
return fmt.Errorf("password is too short, minimum length is 6")
}
return nil
}
type UserPatch struct {
ID int
// Standard fields
RowStatus *RowStatus `json:"rowStatus"`
// Domain specific fields
Email *string `json:"email"`
Name *string `json:"name"`
Password *string `json:"password"`
ResetOpenID *bool `json:"resetOpenId"`
PasswordHash *string
OpenID *string
}
type UserFind struct {
ID *int `json:"id"`
// Standard fields
RowStatus *RowStatus `json:"rowStatus"`
// Domain specific fields
Email *string `json:"email"`
Role *Role
Name *string `json:"name"`
OpenID *string
}
type UserDelete struct {
ID int
}

View File

@@ -1,114 +0,0 @@
package api
import (
"encoding/json"
"fmt"
)
type UserSettingKey string
const (
// UserSettingLocaleKey is the key type for user locale.
UserSettingLocaleKey UserSettingKey = "locale"
// UserSettingMemoVisibilityKey is the key type for user preference memo default visibility.
UserSettingMemoVisibilityKey UserSettingKey = "memoVisibility"
// UserSettingEditorFontStyleKey is the key type for editor font style.
UserSettingEditorFontStyleKey UserSettingKey = "editorFontStyle"
)
// String returns the string format of UserSettingKey type.
func (key UserSettingKey) String() string {
switch key {
case UserSettingLocaleKey:
return "locale"
case UserSettingMemoVisibilityKey:
return "memoVisibility"
case UserSettingEditorFontStyleKey:
return "editorFontFamily"
}
return ""
}
var (
UserSettingLocaleValue = []string{"en", "zh"}
UserSettingMemoVisibilityValue = []Visibility{Privite, Protected, Public}
UserSettingEditorFontStyleValue = []string{"normal", "mono"}
)
type UserSetting struct {
UserID int
Key UserSettingKey `json:"key"`
// Value is a JSON string with basic value
Value string `json:"value"`
}
type UserSettingUpsert struct {
UserID int
Key UserSettingKey `json:"key"`
Value string `json:"value"`
}
func (upsert UserSettingUpsert) Validate() error {
if upsert.Key == UserSettingLocaleKey {
localeValue := "en"
err := json.Unmarshal([]byte(upsert.Value), &localeValue)
if err != nil {
return fmt.Errorf("failed to unmarshal user setting locale value")
}
invalid := true
for _, value := range UserSettingLocaleValue {
if localeValue == value {
invalid = false
break
}
}
if invalid {
return fmt.Errorf("invalid user setting locale value")
}
} else if upsert.Key == UserSettingMemoVisibilityKey {
memoVisibilityValue := Privite
err := json.Unmarshal([]byte(upsert.Value), &memoVisibilityValue)
if err != nil {
return fmt.Errorf("failed to unmarshal user setting memo visibility value")
}
invalid := true
for _, value := range UserSettingMemoVisibilityValue {
if memoVisibilityValue == value {
invalid = false
break
}
}
if invalid {
return fmt.Errorf("invalid user setting memo visibility value")
}
} else if upsert.Key == UserSettingEditorFontStyleKey {
editorFontStyleValue := "normal"
err := json.Unmarshal([]byte(upsert.Value), &editorFontStyleValue)
if err != nil {
return fmt.Errorf("failed to unmarshal user setting editor font style")
}
invalid := true
for _, value := range UserSettingEditorFontStyleValue {
if editorFontStyleValue == value {
invalid = false
break
}
}
if invalid {
return fmt.Errorf("invalid user setting editor font style value")
}
} else {
return fmt.Errorf("invalid user setting key")
}
return nil
}
type UserSettingFind struct {
UserID int
Key *UserSettingKey `json:"key"`
}

412
api/v1/auth.go Normal file
View File

@@ -0,0 +1,412 @@
package v1
import (
"context"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
"github.com/usememos/memos/api/auth"
"github.com/usememos/memos/internal/util"
"github.com/usememos/memos/plugin/idp"
"github.com/usememos/memos/plugin/idp/oauth2"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
var (
usernameMatcher = regexp.MustCompile("^[a-z0-9]([a-z0-9-]{1,30}[a-z0-9])$")
)
type SignIn struct {
Username string `json:"username"`
Password string `json:"password"`
Remember bool `json:"remember"`
}
type SSOSignIn struct {
IdentityProviderID int32 `json:"identityProviderId"`
Code string `json:"code"`
RedirectURI string `json:"redirectUri"`
}
type SignUp struct {
Username string `json:"username"`
Password string `json:"password"`
}
func (s *APIV1Service) registerAuthRoutes(g *echo.Group) {
g.POST("/auth/signin", s.SignIn)
g.POST("/auth/signin/sso", s.SignInSSO)
g.POST("/auth/signout", s.SignOut)
g.POST("/auth/signup", s.SignUp)
}
// SignIn godoc
//
// @Summary Sign-in to memos.
// @Tags auth
// @Accept json
// @Produce json
// @Param body body SignIn true "Sign-in object"
// @Success 200 {object} store.User "User information"
// @Failure 400 {object} nil "Malformatted signin request"
// @Failure 401 {object} nil "Password login is deactivated | Incorrect login credentials, please try again"
// @Failure 403 {object} nil "User has been archived with username %s"
// @Failure 500 {object} nil "Failed to find system setting | Failed to unmarshal system setting | Incorrect login credentials, please try again | Failed to generate tokens | Failed to create activity"
// @Router /api/v1/auth/signin [POST]
func (s *APIV1Service) SignIn(c echo.Context) error {
ctx := c.Request().Context()
signin := &SignIn{}
disablePasswordLoginSystemSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
Name: SystemSettingDisablePasswordLoginName.String(),
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting").SetInternal(err)
}
if disablePasswordLoginSystemSetting != nil {
disablePasswordLogin := false
err = json.Unmarshal([]byte(disablePasswordLoginSystemSetting.Value), &disablePasswordLogin)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting").SetInternal(err)
}
if disablePasswordLogin {
return echo.NewHTTPError(http.StatusUnauthorized, "Password login is deactivated")
}
}
if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signin request").SetInternal(err)
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
Username: &signin.Username,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Incorrect login credentials, please try again")
}
if user == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Incorrect login credentials, please try again")
} else if user.RowStatus == store.Archived {
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with username %s", signin.Username))
}
// Compare the stored hashed password, with the hashed version of the password that was received.
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(signin.Password)); err != nil {
// If the two passwords don't match, return a 401 status.
return echo.NewHTTPError(http.StatusUnauthorized, "Incorrect login credentials, please try again")
}
var expireAt time.Time
// Set cookie expiration to 100 years to make it persistent.
cookieExp := time.Now().AddDate(100, 0, 0)
if !signin.Remember {
expireAt = time.Now().Add(auth.AccessTokenDuration)
cookieExp = time.Now().Add(auth.CookieExpDuration)
}
accessToken, err := auth.GenerateAccessToken(user.Username, user.ID, expireAt, []byte(s.Secret))
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
}
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert access token, err: %s", err)).SetInternal(err)
}
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
userMessage := convertUserFromStore(user)
return c.JSON(http.StatusOK, userMessage)
}
// SignInSSO godoc
//
// @Summary Sign-in to memos using SSO.
// @Tags auth
// @Accept json
// @Produce json
// @Param body body SSOSignIn true "SSO sign-in object"
// @Success 200 {object} store.User "User information"
// @Failure 400 {object} nil "Malformatted signin request"
// @Failure 401 {object} nil "Access denied, identifier does not match the filter."
// @Failure 403 {object} nil "User has been archived with username {username}"
// @Failure 404 {object} nil "Identity provider not found"
// @Failure 500 {object} nil "Failed to find identity provider | Failed to create identity provider instance | Failed to exchange token | Failed to get user info | Failed to compile identifier filter | Incorrect login credentials, please try again | Failed to generate random password | Failed to generate password hash | Failed to create user | Failed to generate tokens | Failed to create activity"
// @Router /api/v1/auth/signin/sso [POST]
func (s *APIV1Service) SignInSSO(c echo.Context) error {
ctx := c.Request().Context()
signin := &SSOSignIn{}
if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signin request").SetInternal(err)
}
identityProvider, err := s.Store.GetIdentityProvider(ctx, &store.FindIdentityProvider{
ID: &signin.IdentityProviderID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find identity provider").SetInternal(err)
}
if identityProvider == nil {
return echo.NewHTTPError(http.StatusNotFound, "Identity provider not found")
}
var userInfo *idp.IdentityProviderUserInfo
if identityProvider.Type == store.IdentityProviderOAuth2Type {
oauth2IdentityProvider, err := oauth2.NewIdentityProvider(identityProvider.Config.OAuth2Config)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create identity provider instance").SetInternal(err)
}
token, err := oauth2IdentityProvider.ExchangeToken(ctx, signin.RedirectURI, signin.Code)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to exchange token").SetInternal(err)
}
userInfo, err = oauth2IdentityProvider.UserInfo(token)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get user info").SetInternal(err)
}
}
identifierFilter := identityProvider.IdentifierFilter
if identifierFilter != "" {
identifierFilterRegex, err := regexp.Compile(identifierFilter)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compile identifier filter").SetInternal(err)
}
if !identifierFilterRegex.MatchString(userInfo.Identifier) {
return echo.NewHTTPError(http.StatusUnauthorized, "Access denied, identifier does not match the filter.").SetInternal(err)
}
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
Username: &userInfo.Identifier,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Incorrect login credentials, please try again")
}
if user == nil {
allowSignUpSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
Name: SystemSettingAllowSignUpName.String(),
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting").SetInternal(err)
}
allowSignUpSettingValue := true
if allowSignUpSetting != nil {
err = json.Unmarshal([]byte(allowSignUpSetting.Value), &allowSignUpSettingValue)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting allow signup").SetInternal(err)
}
}
if !allowSignUpSettingValue {
return echo.NewHTTPError(http.StatusUnauthorized, "signup is disabled").SetInternal(err)
}
userCreate := &store.User{
Username: userInfo.Identifier,
// The new signup user should be normal user by default.
Role: store.RoleUser,
Nickname: userInfo.DisplayName,
Email: userInfo.Email,
}
password, err := util.RandomString(20)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate random password").SetInternal(err)
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
}
userCreate.PasswordHash = string(passwordHash)
user, err = s.Store.CreateUser(ctx, userCreate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
}
}
if user.RowStatus == store.Archived {
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with username %s", userInfo.Identifier))
}
accessToken, err := auth.GenerateAccessToken(user.Username, user.ID, time.Now().Add(auth.AccessTokenDuration), []byte(s.Secret))
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
}
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert access token, err: %s", err)).SetInternal(err)
}
cookieExp := time.Now().Add(auth.CookieExpDuration)
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
userMessage := convertUserFromStore(user)
return c.JSON(http.StatusOK, userMessage)
}
// SignOut godoc
//
// @Summary Sign-out from memos.
// @Tags auth
// @Produce json
// @Success 200 {boolean} true "Sign-out success"
// @Router /api/v1/auth/signout [POST]
func (s *APIV1Service) SignOut(c echo.Context) error {
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)
}
}
removeAccessTokenAndCookies(c)
return c.JSON(http.StatusOK, true)
}
// SignUp godoc
//
// @Summary Sign-up to memos.
// @Tags auth
// @Accept json
// @Produce json
// @Param body body SignUp true "Sign-up object"
// @Success 200 {object} store.User "User information"
// @Failure 400 {object} nil "Malformatted signup request | Failed to find users"
// @Failure 401 {object} nil "signup is disabled"
// @Failure 403 {object} nil "Forbidden"
// @Failure 404 {object} nil "Not found"
// @Failure 500 {object} nil "Failed to find system setting | Failed to unmarshal system setting allow signup | Failed to generate password hash | Failed to create user | Failed to generate tokens | Failed to create activity"
// @Router /api/v1/auth/signup [POST]
func (s *APIV1Service) SignUp(c echo.Context) error {
ctx := c.Request().Context()
signup := &SignUp{}
if err := json.NewDecoder(c.Request().Body).Decode(signup); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signup request").SetInternal(err)
}
hostUserType := store.RoleHost
existedHostUsers, err := s.Store.ListUsers(ctx, &store.FindUser{
Role: &hostUserType,
})
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Failed to find users").SetInternal(err)
}
if !usernameMatcher.MatchString(strings.ToLower(signup.Username)) {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid username %s", signup.Username)).SetInternal(err)
}
userCreate := &store.User{
Username: signup.Username,
// The new signup user should be normal user by default.
Role: store.RoleUser,
Nickname: signup.Username,
}
if len(existedHostUsers) == 0 {
// Change the default role to host if there is no host user.
userCreate.Role = store.RoleHost
} else {
allowSignUpSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
Name: SystemSettingAllowSignUpName.String(),
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting").SetInternal(err)
}
allowSignUpSettingValue := true
if allowSignUpSetting != nil {
err = json.Unmarshal([]byte(allowSignUpSetting.Value), &allowSignUpSettingValue)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting allow signup").SetInternal(err)
}
}
if !allowSignUpSettingValue {
return echo.NewHTTPError(http.StatusUnauthorized, "signup is disabled").SetInternal(err)
}
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(signup.Password), bcrypt.DefaultCost)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
}
userCreate.PasswordHash = string(passwordHash)
user, err := s.Store.CreateUser(ctx, userCreate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
}
accessToken, err := auth.GenerateAccessToken(user.Username, user.ID, time.Now().Add(auth.AccessTokenDuration), []byte(s.Secret))
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
}
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert access token, err: %s", err)).SetInternal(err)
}
cookieExp := time.Now().Add(auth.CookieExpDuration)
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
userMessage := convertUserFromStore(user)
return c.JSON(http.StatusOK, userMessage)
}
func (s *APIV1Service) UpsertAccessTokenToStore(ctx context.Context, user *store.User, accessToken string) error {
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID)
if err != nil {
return errors.Wrap(err, "failed to get user access tokens")
}
userAccessToken := storepb.AccessTokensUserSetting_AccessToken{
AccessToken: accessToken,
Description: "Account sign in",
}
userAccessTokens = append(userAccessTokens, &userAccessToken)
if _, err := s.Store.UpsertUserSettingV1(ctx, &storepb.UserSetting{
UserId: user.ID,
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
Value: &storepb.UserSetting_AccessTokens{
AccessTokens: &storepb.AccessTokensUserSetting{
AccessTokens: userAccessTokens,
},
},
}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert user setting, err: %s", err)).SetInternal(err)
}
return nil
}
// removeAccessTokenAndCookies removes the jwt token from the cookies.
func removeAccessTokenAndCookies(c echo.Context) {
cookieExp := time.Now().Add(-1 * time.Hour)
setTokenCookie(c, auth.AccessTokenCookieName, "", cookieExp)
}
// setTokenCookie sets the token to the cookie.
func setTokenCookie(c echo.Context, name, token string, expiration time.Time) {
cookie := new(http.Cookie)
cookie.Name = name
cookie.Value = token
cookie.Expires = expiration
cookie.Path = "/"
// Http-only helps mitigate the risk of client side script accessing the protected cookie.
cookie.HttpOnly = true
cookie.SameSite = http.SameSiteStrictMode
c.SetCookie(cookie)
}

View File

@@ -1,4 +1,4 @@
package api
package v1
// RowStatus is the status for a row.
type RowStatus string
@@ -10,12 +10,6 @@ const (
Archived RowStatus = "ARCHIVED"
)
func (e RowStatus) String() string {
switch e {
case Normal:
return "NORMAL"
case Archived:
return "ARCHIVED"
}
return ""
func (r RowStatus) String() string {
return string(r)
}

3015
api/v1/docs.go Normal file

File diff suppressed because it is too large Load Diff

49
api/v1/http_getter.go Normal file
View File

@@ -0,0 +1,49 @@
package v1
import (
"fmt"
"net/http"
"net/url"
"github.com/labstack/echo/v4"
getter "github.com/usememos/memos/plugin/http-getter"
)
func (*APIV1Service) registerGetterPublicRoutes(g *echo.Group) {
// GET /get/image?url={url} - Get image.
g.GET("/get/image", GetImage)
}
// GetImage godoc
//
// @Summary Get GetImage from URL
// @Tags image-url
// @Produce GetImage/*
// @Param url query string true "Image url"
// @Success 200 {object} nil "Image"
// @Failure 400 {object} nil "Missing GetImage url | Wrong url | Failed to get GetImage url: %s"
// @Failure 500 {object} nil "Failed to write GetImage blob"
// @Router /o/get/GetImage [GET]
func GetImage(c echo.Context) error {
urlStr := c.QueryParam("url")
if urlStr == "" {
return echo.NewHTTPError(http.StatusBadRequest, "Missing image url")
}
if _, err := url.Parse(urlStr); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Wrong url").SetInternal(err)
}
image, err := getter.GetImage(urlStr)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to get image url: %s", urlStr)).SetInternal(err)
}
c.Response().Writer.WriteHeader(http.StatusOK)
c.Response().Writer.Header().Set("Content-Type", image.Mediatype)
c.Response().Writer.Header().Set(echo.HeaderCacheControl, "max-age=31536000, immutable")
if _, err := c.Response().Writer.Write(image.Blob); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to write image blob").SetInternal(err)
}
return nil
}

349
api/v1/idp.go Normal file
View File

@@ -0,0 +1,349 @@
package v1
import (
"encoding/json"
"fmt"
"net/http"
"github.com/labstack/echo/v4"
"github.com/usememos/memos/internal/util"
"github.com/usememos/memos/store"
)
type IdentityProviderType string
const (
IdentityProviderOAuth2Type IdentityProviderType = "OAUTH2"
)
func (t IdentityProviderType) String() string {
return string(t)
}
type IdentityProviderConfig struct {
OAuth2Config *IdentityProviderOAuth2Config `json:"oauth2Config"`
}
type IdentityProviderOAuth2Config struct {
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
AuthURL string `json:"authUrl"`
TokenURL string `json:"tokenUrl"`
UserInfoURL string `json:"userInfoUrl"`
Scopes []string `json:"scopes"`
FieldMapping *FieldMapping `json:"fieldMapping"`
}
type FieldMapping struct {
Identifier string `json:"identifier"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
}
type IdentityProvider struct {
ID int32 `json:"id"`
Name string `json:"name"`
Type IdentityProviderType `json:"type"`
IdentifierFilter string `json:"identifierFilter"`
Config *IdentityProviderConfig `json:"config"`
}
type CreateIdentityProviderRequest struct {
Name string `json:"name"`
Type IdentityProviderType `json:"type"`
IdentifierFilter string `json:"identifierFilter"`
Config *IdentityProviderConfig `json:"config"`
}
type UpdateIdentityProviderRequest struct {
ID int32 `json:"-"`
Type IdentityProviderType `json:"type"`
Name *string `json:"name"`
IdentifierFilter *string `json:"identifierFilter"`
Config *IdentityProviderConfig `json:"config"`
}
func (s *APIV1Service) registerIdentityProviderRoutes(g *echo.Group) {
g.GET("/idp", s.GetIdentityProviderList)
g.POST("/idp", s.CreateIdentityProvider)
g.GET("/idp/:idpId", s.GetIdentityProvider)
g.PATCH("/idp/:idpId", s.UpdateIdentityProvider)
g.DELETE("/idp/:idpId", s.DeleteIdentityProvider)
}
// GetIdentityProviderList godoc
//
// @Summary Get a list of identity providers
// @Description *clientSecret is only available for host user
// @Tags idp
// @Produce json
// @Success 200 {object} []IdentityProvider "List of available identity providers"
// @Failure 500 {object} nil "Failed to find identity provider list | Failed to find user"
// @Router /api/v1/idp [GET]
func (s *APIV1Service) GetIdentityProviderList(c echo.Context) error {
ctx := c.Request().Context()
list, err := s.Store.ListIdentityProviders(ctx, &store.FindIdentityProvider{})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find identity provider list").SetInternal(err)
}
userID, ok := c.Get(userIDContextKey).(int32)
isHostUser := false
if ok {
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if user == nil || user.Role == store.RoleHost {
isHostUser = true
}
}
identityProviderList := []*IdentityProvider{}
for _, item := range list {
identityProvider := convertIdentityProviderFromStore(item)
// data desensitize
if !isHostUser {
identityProvider.Config.OAuth2Config.ClientSecret = ""
}
identityProviderList = append(identityProviderList, identityProvider)
}
return c.JSON(http.StatusOK, identityProviderList)
}
// CreateIdentityProvider godoc
//
// @Summary Create Identity Provider
// @Tags idp
// @Accept json
// @Produce json
// @Param body body CreateIdentityProviderRequest true "Identity provider information"
// @Success 200 {object} store.IdentityProvider "Identity provider information"
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
// @Failure 400 {object} nil "Malformatted post identity provider request"
// @Failure 500 {object} nil "Failed to find user | Failed to create identity provider"
// @Router /api/v1/idp [POST]
func (s *APIV1Service) CreateIdentityProvider(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if user == nil || user.Role != store.RoleHost {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
identityProviderCreate := &CreateIdentityProviderRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(identityProviderCreate); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post identity provider request").SetInternal(err)
}
identityProvider, err := s.Store.CreateIdentityProvider(ctx, &store.IdentityProvider{
Name: identityProviderCreate.Name,
Type: store.IdentityProviderType(identityProviderCreate.Type),
IdentifierFilter: identityProviderCreate.IdentifierFilter,
Config: convertIdentityProviderConfigToStore(identityProviderCreate.Config),
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create identity provider").SetInternal(err)
}
return c.JSON(http.StatusOK, convertIdentityProviderFromStore(identityProvider))
}
// GetIdentityProvider godoc
//
// @Summary Get an identity provider by ID
// @Tags idp
// @Accept json
// @Produce json
// @Param idpId path int true "Identity provider ID"
// @Success 200 {object} store.IdentityProvider "Requested identity provider"
// @Failure 400 {object} nil "ID is not a number: %s"
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
// @Failure 404 {object} nil "Identity provider not found"
// @Failure 500 {object} nil "Failed to find identity provider list | Failed to find user"
// @Router /api/v1/idp/{idpId} [GET]
func (s *APIV1Service) GetIdentityProvider(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if user == nil || user.Role != store.RoleHost {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
identityProviderID, err := util.ConvertStringToInt32(c.Param("idpId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("idpId"))).SetInternal(err)
}
identityProvider, err := s.Store.GetIdentityProvider(ctx, &store.FindIdentityProvider{
ID: &identityProviderID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get identity provider").SetInternal(err)
}
if identityProvider == nil {
return echo.NewHTTPError(http.StatusNotFound, "Identity provider not found")
}
return c.JSON(http.StatusOK, convertIdentityProviderFromStore(identityProvider))
}
// DeleteIdentityProvider godoc
//
// @Summary Delete an identity provider by ID
// @Tags idp
// @Accept json
// @Produce json
// @Param idpId path int true "Identity Provider ID"
// @Success 200 {boolean} true "Identity Provider deleted"
// @Failure 400 {object} nil "ID is not a number: %s | Malformatted patch identity provider request"
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
// @Failure 500 {object} nil "Failed to find user | Failed to patch identity provider"
// @Router /api/v1/idp/{idpId} [DELETE]
func (s *APIV1Service) DeleteIdentityProvider(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if user == nil || user.Role != store.RoleHost {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
identityProviderID, err := util.ConvertStringToInt32(c.Param("idpId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("idpId"))).SetInternal(err)
}
if err = s.Store.DeleteIdentityProvider(ctx, &store.DeleteIdentityProvider{ID: identityProviderID}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete identity provider").SetInternal(err)
}
return c.JSON(http.StatusOK, true)
}
// UpdateIdentityProvider godoc
//
// @Summary Update an identity provider by ID
// @Tags idp
// @Accept json
// @Produce json
// @Param idpId path int true "Identity Provider ID"
// @Param body body UpdateIdentityProviderRequest true "Patched identity provider information"
// @Success 200 {object} store.IdentityProvider "Patched identity provider"
// @Failure 400 {object} nil "ID is not a number: %s | Malformatted patch identity provider request"
// @Failure 401 {object} nil "Missing user in session | Unauthorized
// @Failure 500 {object} nil "Failed to find user | Failed to patch identity provider"
// @Router /api/v1/idp/{idpId} [PATCH]
func (s *APIV1Service) UpdateIdentityProvider(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if user == nil || user.Role != store.RoleHost {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
identityProviderID, err := util.ConvertStringToInt32(c.Param("idpId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("idpId"))).SetInternal(err)
}
identityProviderPatch := &UpdateIdentityProviderRequest{
ID: identityProviderID,
}
if err := json.NewDecoder(c.Request().Body).Decode(identityProviderPatch); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch identity provider request").SetInternal(err)
}
identityProvider, err := s.Store.UpdateIdentityProvider(ctx, &store.UpdateIdentityProvider{
ID: identityProviderPatch.ID,
Type: store.IdentityProviderType(identityProviderPatch.Type),
Name: identityProviderPatch.Name,
IdentifierFilter: identityProviderPatch.IdentifierFilter,
Config: convertIdentityProviderConfigToStore(identityProviderPatch.Config),
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch identity provider").SetInternal(err)
}
return c.JSON(http.StatusOK, convertIdentityProviderFromStore(identityProvider))
}
func convertIdentityProviderFromStore(identityProvider *store.IdentityProvider) *IdentityProvider {
return &IdentityProvider{
ID: identityProvider.ID,
Name: identityProvider.Name,
Type: IdentityProviderType(identityProvider.Type),
IdentifierFilter: identityProvider.IdentifierFilter,
Config: convertIdentityProviderConfigFromStore(identityProvider.Config),
}
}
func convertIdentityProviderConfigFromStore(config *store.IdentityProviderConfig) *IdentityProviderConfig {
return &IdentityProviderConfig{
OAuth2Config: &IdentityProviderOAuth2Config{
ClientID: config.OAuth2Config.ClientID,
ClientSecret: config.OAuth2Config.ClientSecret,
AuthURL: config.OAuth2Config.AuthURL,
TokenURL: config.OAuth2Config.TokenURL,
UserInfoURL: config.OAuth2Config.UserInfoURL,
Scopes: config.OAuth2Config.Scopes,
FieldMapping: &FieldMapping{
Identifier: config.OAuth2Config.FieldMapping.Identifier,
DisplayName: config.OAuth2Config.FieldMapping.DisplayName,
Email: config.OAuth2Config.FieldMapping.Email,
},
},
}
}
func convertIdentityProviderConfigToStore(config *IdentityProviderConfig) *store.IdentityProviderConfig {
return &store.IdentityProviderConfig{
OAuth2Config: &store.IdentityProviderOAuth2Config{
ClientID: config.OAuth2Config.ClientID,
ClientSecret: config.OAuth2Config.ClientSecret,
AuthURL: config.OAuth2Config.AuthURL,
TokenURL: config.OAuth2Config.TokenURL,
UserInfoURL: config.OAuth2Config.UserInfoURL,
Scopes: config.OAuth2Config.Scopes,
FieldMapping: &store.FieldMapping{
Identifier: config.OAuth2Config.FieldMapping.Identifier,
DisplayName: config.OAuth2Config.FieldMapping.DisplayName,
Email: config.OAuth2Config.FieldMapping.Email,
},
},
}
}

148
api/v1/jwt.go Normal file
View File

@@ -0,0 +1,148 @@
package v1
import (
"fmt"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v4"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"github.com/usememos/memos/api/auth"
"github.com/usememos/memos/internal/util"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
const (
// The key name used to store user id in the context
// user id is extracted from the jwt token subject field.
userIDContextKey = "user-id"
)
func extractTokenFromHeader(c echo.Context) (string, error) {
authHeader := c.Request().Header.Get("Authorization")
if authHeader == "" {
return "", nil
}
authHeaderParts := strings.Fields(authHeader)
if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" {
return "", errors.New("Authorization header format must be Bearer {token}")
}
return authHeaderParts[1], nil
}
func findAccessToken(c echo.Context) string {
// Check the HTTP request header first.
accessToken, _ := extractTokenFromHeader(c)
if accessToken == "" {
// Check the cookie.
cookie, _ := c.Cookie(auth.AccessTokenCookieName)
if cookie != nil {
accessToken = cookie.Value
}
}
return accessToken
}
// JWTMiddleware validates the access token.
func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Request().Context()
path := c.Request().URL.Path
method := c.Request().Method
if server.defaultAuthSkipper(c) {
return next(c)
}
// Skip validation for server status endpoints.
if util.HasPrefixes(path, "/api/v1/ping", "/api/v1/idp", "/api/v1/status", "/api/v1/user") && path != "/api/v1/user/me" && path != "/api/v1/user" && method == http.MethodGet {
return next(c)
}
accessToken := findAccessToken(c)
if accessToken == "" {
// Allow the user to access the public endpoints.
if util.HasPrefixes(path, "/o") {
return next(c)
}
// When the request is not authenticated, we allow the user to access the memo endpoints for those public memos.
if util.HasPrefixes(path, "/api/v1/memo") && method == http.MethodGet {
return next(c)
}
return echo.NewHTTPError(http.StatusUnauthorized, "Missing access token")
}
userID, err := getUserIDFromAccessToken(accessToken, secret)
if err != nil {
removeAccessTokenAndCookies(c)
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid or expired access token")
}
accessTokens, err := server.Store.GetUserAccessTokens(ctx, userID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get user access tokens.").WithInternal(err)
}
if !validateAccessToken(accessToken, accessTokens) {
removeAccessTokenAndCookies(c)
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid access token.")
}
// Even if there is no error, we still need to make sure the user still exists.
user, err := server.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to find user ID: %d", userID)).SetInternal(err)
}
if user == nil {
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Failed to find user ID: %d", userID))
}
// Stores userID into context.
c.Set(userIDContextKey, userID)
return next(c)
}
}
func getUserIDFromAccessToken(accessToken, secret string) (int32, error) {
claims := &auth.ClaimsMessage{}
_, err := jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (any, error) {
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
}
if kid, ok := t.Header["kid"].(string); ok {
if kid == "v1" {
return []byte(secret), nil
}
}
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
})
if err != nil {
return 0, errors.Wrap(err, "Invalid or expired access token")
}
// We either have a valid access token or we will attempt to generate new access token.
userID, err := util.ConvertStringToInt32(claims.Subject)
if err != nil {
return 0, errors.Wrap(err, "Malformed ID in the token")
}
return userID, nil
}
func (*APIV1Service) defaultAuthSkipper(c echo.Context) bool {
path := c.Path()
return util.HasPrefixes(path, "/api/v1/auth")
}
func validateAccessToken(accessTokenString string, userAccessTokens []*storepb.AccessTokensUserSetting_AccessToken) bool {
for _, userAccessToken := range userAccessTokens {
if accessTokenString == userAccessToken.AccessToken {
return true
}
}
return false
}

948
api/v1/memo.go Normal file
View File

@@ -0,0 +1,948 @@
package v1
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"go.uber.org/zap"
"github.com/usememos/memos/internal/log"
"github.com/usememos/memos/internal/util"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/server/service/metric"
"github.com/usememos/memos/store"
)
// Visibility is the type of a visibility.
type Visibility string
const (
// Public is the PUBLIC visibility.
Public Visibility = "PUBLIC"
// Protected is the PROTECTED visibility.
Protected Visibility = "PROTECTED"
// Private is the PRIVATE visibility.
Private Visibility = "PRIVATE"
)
func (v Visibility) String() string {
switch v {
case Public:
return "PUBLIC"
case Protected:
return "PROTECTED"
case Private:
return "PRIVATE"
}
return "PRIVATE"
}
type Memo struct {
ID int32 `json:"id"`
// Standard fields
RowStatus RowStatus `json:"rowStatus"`
CreatorID int32 `json:"creatorId"`
CreatedTs int64 `json:"createdTs"`
UpdatedTs int64 `json:"updatedTs"`
// Domain specific fields
DisplayTs int64 `json:"displayTs"`
Content string `json:"content"`
Visibility Visibility `json:"visibility"`
Pinned bool `json:"pinned"`
// Related fields
Parent *Memo `json:"parent"`
CreatorName string `json:"creatorName"`
CreatorUsername string `json:"creatorUsername"`
ResourceList []*Resource `json:"resourceList"`
RelationList []*MemoRelation `json:"relationList"`
}
type CreateMemoRequest struct {
// Standard fields
CreatorID int32 `json:"-"`
CreatedTs *int64 `json:"createdTs"`
// Domain specific fields
Visibility Visibility `json:"visibility"`
Content string `json:"content"`
// Related fields
ResourceIDList []int32 `json:"resourceIdList"`
RelationList []*UpsertMemoRelationRequest `json:"relationList"`
}
type PatchMemoRequest struct {
ID int32 `json:"-"`
// Standard fields
CreatedTs *int64 `json:"createdTs"`
UpdatedTs *int64
RowStatus *RowStatus `json:"rowStatus"`
// Domain specific fields
Content *string `json:"content"`
Visibility *Visibility `json:"visibility"`
// Related fields
ResourceIDList []int32 `json:"resourceIdList"`
RelationList []*UpsertMemoRelationRequest `json:"relationList"`
}
type FindMemoRequest struct {
ID *int32
// Standard fields
RowStatus *RowStatus
CreatorID *int32
// Domain specific fields
Pinned *bool
ContentSearch []string
VisibilityList []Visibility
// Pagination
Limit *int
Offset *int
}
// maxContentLength means the max memo content bytes is 1MB.
const maxContentLength = 1 << 30
func (s *APIV1Service) registerMemoRoutes(g *echo.Group) {
g.GET("/memo", s.GetMemoList)
g.POST("/memo", s.CreateMemo)
g.GET("/memo/all", s.GetAllMemos)
g.GET("/memo/stats", s.GetMemoStats)
g.GET("/memo/:memoId", s.GetMemo)
g.PATCH("/memo/:memoId", s.UpdateMemo)
g.DELETE("/memo/:memoId", s.DeleteMemo)
}
// GetMemoList godoc
//
// @Summary Get a list of memos matching optional filters
// @Tags memo
// @Produce json
// @Param creatorId query int false "Creator ID"
// @Param creatorUsername query string false "Creator username"
// @Param rowStatus query store.RowStatus false "Row status"
// @Param pinned query bool false "Pinned"
// @Param tag query string false "Search for tag. Do not append #"
// @Param content query string false "Search for content"
// @Param limit query int false "Limit"
// @Param offset query int false "Offset"
// @Success 200 {object} []store.Memo "Memo list"
// @Failure 400 {object} nil "Missing user to find memo"
// @Failure 500 {object} nil "Failed to get memo display with updated ts setting value | Failed to fetch memo list | Failed to compose memo response"
// @Router /api/v1/memo [GET]
func (s *APIV1Service) GetMemoList(c echo.Context) error {
ctx := c.Request().Context()
hasParentFlag := false
find := &store.FindMemo{
HasParent: &hasParentFlag,
OrderByPinned: true,
}
if userID, err := util.ConvertStringToInt32(c.QueryParam("creatorId")); err == nil {
find.CreatorID = &userID
}
if username := c.QueryParam("creatorUsername"); username != "" {
user, _ := s.Store.GetUser(ctx, &store.FindUser{Username: &username})
if user != nil {
find.CreatorID = &user.ID
}
}
currentUserID, ok := c.Get(userIDContextKey).(int32)
if !ok {
// Anonymous use should only fetch PUBLIC memos with specified user
if find.CreatorID == nil {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user to find memo")
}
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 find.CreatorID == nil || *find.CreatorID == currentUserID {
find.CreatorID = &currentUserID
visibilityList = append(visibilityList, store.Private)
}
find.VisibilityList = visibilityList
}
rowStatus := store.RowStatus(c.QueryParam("rowStatus"))
if rowStatus != "" {
find.RowStatus = &rowStatus
}
pinnedStr := c.QueryParam("pinned")
if pinnedStr != "" {
pinned := pinnedStr == "true"
find.Pinned = &pinned
}
contentSearch := []string{}
tag := c.QueryParam("tag")
if tag != "" {
contentSearch = append(contentSearch, "#"+tag)
}
content := c.QueryParam("content")
if content != "" {
contentSearch = append(contentSearch, content)
}
find.ContentSearch = contentSearch
if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
find.Limit = &limit
}
if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil {
find.Offset = &offset
}
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 {
find.OrderByUpdatedTs = true
}
list, err := s.Store.ListMemos(ctx, find)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch memo list").SetInternal(err)
}
memoResponseList := []*Memo{}
for _, memo := range list {
memoResponse, err := s.convertMemoFromStore(ctx, memo)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err)
}
memoResponseList = append(memoResponseList, memoResponse)
}
return c.JSON(http.StatusOK, memoResponseList)
}
// CreateMemo godoc
//
// @Summary Create a memo
// @Description Visibility can be PUBLIC, PROTECTED or PRIVATE
// @Description *You should omit fields to use their default values
// @Tags memo
// @Accept json
// @Produce json
// @Param body body CreateMemoRequest true "Request object."
// @Success 200 {object} store.Memo "Stored memo"
// @Failure 400 {object} nil "Malformatted post memo request | Content size overflow, up to 1MB"
// @Failure 401 {object} nil "Missing user in session"
// @Failure 404 {object} nil "User not found | Memo not found: %d"
// @Failure 500 {object} nil "Failed to find user setting | Failed to unmarshal user setting value | Failed to find system setting | Failed to unmarshal system setting | Failed to find user | Failed to create memo | Failed to create activity | Failed to upsert memo resource | Failed to upsert memo relation | Failed to compose memo | Failed to compose memo response"
// @Router /api/v1/memo [POST]
//
// NOTES:
// - It's currently possible to create phantom resources and relations. Phantom relations will trigger backend 404's when fetching memo.
func (s *APIV1Service) CreateMemo(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
createMemoRequest := &CreateMemoRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(createMemoRequest); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo request").SetInternal(err)
}
if len(createMemoRequest.Content) > maxContentLength {
return echo.NewHTTPError(http.StatusBadRequest, "Content size overflow, up to 1MB")
}
if createMemoRequest.Visibility == "" {
userMemoVisibilitySetting, err := s.Store.GetUserSetting(ctx, &store.FindUserSetting{
UserID: &userID,
Key: UserSettingMemoVisibilityKey.String(),
})
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
} else {
// Private is the default memo visibility.
createMemoRequest.Visibility = Private
}
}
// 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 create private memo if public memos are disabled.
if user.Role == store.RoleUser {
createMemoRequest.Visibility = Private
}
}
}
createMemoRequest.CreatorID = userID
memo, err := s.Store.CreateMemo(ctx, convertCreateMemoRequestToMemoMessage(createMemoRequest))
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create memo").SetInternal(err)
}
for _, resourceID := range createMemoRequest.ResourceIDList {
if _, err := s.Store.UpdateResource(ctx, &store.UpdateResource{
ID: resourceID,
MemoID: &memo.ID,
}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err)
}
}
for _, memoRelationUpsert := range createMemoRequest.RelationList {
if _, err := s.Store.UpsertMemoRelation(ctx, &store.MemoRelation{
MemoID: memo.ID,
RelatedMemoID: memoRelationUpsert.RelatedMemoID,
Type: store.MemoRelationType(memoRelationUpsert.Type),
}); 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{
ID: &memo.ID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo").SetInternal(err)
}
if composedMemo == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memo.ID))
}
memoResponse, err := s.convertMemoFromStore(ctx, composedMemo)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err)
}
// send notification by telegram bot if memo is not Private
if memoResponse.Visibility != Private {
// fetch all telegram UserID
userSettings, err := s.Store.ListUserSettings(ctx, &store.FindUserSetting{Key: UserSettingTelegramUserIDKey.String()})
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)
if err != nil {
log.Error("failed to parse Telegram UserID", zap.Error(err))
continue
}
// send notification to telegram
content := memoResponse.CreatorName + " Says:\n\n" + memoResponse.Content
_, err = s.telegramBot.SendMessage(ctx, tgUserID, content)
if err != nil {
log.Error("Failed to send Telegram notification", zap.Error(err))
continue
}
}
}
metric.Enqueue("memo create")
return c.JSON(http.StatusOK, memoResponse)
}
// GetAllMemos godoc
//
// @Summary Get a list of public memos matching optional filters
// @Description This should also list protected memos if the user is logged in
// @Description Authentication is optional
// @Tags memo
// @Produce json
// @Param limit query int false "Limit"
// @Param offset query int false "Offset"
// @Success 200 {object} []store.Memo "Memo list"
// @Failure 500 {object} nil "Failed to get memo display with updated ts setting value | Failed to fetch all memo list | Failed to compose memo response"
// @Router /api/v1/memo/all [GET]
//
// NOTES:
// - 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()
hasParentFlag := false
findMemoMessage := &store.FindMemo{
HasParent: &hasParentFlag,
}
_, ok := c.Get(userIDContextKey).(int32)
if !ok {
findMemoMessage.VisibilityList = []store.Visibility{store.Public}
} else {
findMemoMessage.VisibilityList = []store.Visibility{store.Public, store.Protected}
}
if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
findMemoMessage.Limit = &limit
}
if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil {
findMemoMessage.Offset = &offset
}
// Only fetch normal status memos.
normalStatus := store.Normal
findMemoMessage.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
}
list, err := s.Store.ListMemos(ctx, findMemoMessage)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch all memo list").SetInternal(err)
}
memoResponseList := []*Memo{}
for _, memo := range list {
memoResponse, err := s.convertMemoFromStore(ctx, memo)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err)
}
memoResponseList = append(memoResponseList, memoResponse)
}
return c.JSON(http.StatusOK, memoResponseList)
}
// GetMemoStats godoc
//
// @Summary Get memo stats by creator ID or username
// @Description Used to generate the heatmap
// @Tags memo
// @Produce json
// @Param creatorId query int false "Creator ID"
// @Param creatorUsername query string false "Creator username"
// @Success 200 {object} []int "Memo createdTs list"
// @Failure 400 {object} nil "Missing user id to find memo"
// @Failure 500 {object} nil "Failed to get memo display with updated ts setting value | Failed to find memo list | Failed to compose memo response"
// @Router /api/v1/memo/stats [GET]
func (s *APIV1Service) GetMemoStats(c echo.Context) error {
ctx := c.Request().Context()
normalStatus := store.Normal
hasParentFlag := false
findMemoMessage := &store.FindMemo{
RowStatus: &normalStatus,
HasParent: &hasParentFlag,
ExcludeContent: true,
}
if creatorID, err := util.ConvertStringToInt32(c.QueryParam("creatorId")); err == nil {
findMemoMessage.CreatorID = &creatorID
}
if username := c.QueryParam("creatorUsername"); username != "" {
user, _ := s.Store.GetUser(ctx, &store.FindUser{Username: &username})
if user != nil {
findMemoMessage.CreatorID = &user.ID
}
}
if findMemoMessage.CreatorID == nil {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find memo")
}
currentUserID, ok := c.Get(userIDContextKey).(int32)
if !ok {
findMemoMessage.VisibilityList = []store.Visibility{store.Public}
} else {
if *findMemoMessage.CreatorID != currentUserID {
findMemoMessage.VisibilityList = []store.Visibility{store.Public, store.Protected}
} else {
findMemoMessage.VisibilityList = []store.Visibility{store.Public, store.Protected, store.Private}
}
}
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
}
list, err := s.Store.ListMemos(ctx, findMemoMessage)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
}
displayTsList := []int64{}
if memoDisplayWithUpdatedTs {
for _, memo := range list {
displayTsList = append(displayTsList, memo.UpdatedTs)
}
} else {
for _, memo := range list {
displayTsList = append(displayTsList, memo.CreatedTs)
}
}
return c.JSON(http.StatusOK, displayTsList)
}
// GetMemo godoc
//
// @Summary Get memo by ID
// @Tags memo
// @Produce json
// @Param memoId path int true "Memo ID"
// @Success 200 {object} []store.Memo "Memo list"
// @Failure 400 {object} nil "ID is not a number: %s"
// @Failure 401 {object} nil "Missing user in session"
// @Failure 403 {object} nil "this memo is private only | this memo is protected, missing user in session
// @Failure 404 {object} nil "Memo not found: %d"
// @Failure 500 {object} nil "Failed to find memo by ID: %v | Failed to compose memo response"
// @Router /api/v1/memo/{memoId} [GET]
func (s *APIV1Service) GetMemo(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := util.ConvertStringToInt32(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
ID: &memoID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find memo by ID: %v", memoID)).SetInternal(err)
}
if memo == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID))
}
userID, ok := c.Get(userIDContextKey).(int32)
if memo.Visibility == store.Private {
if !ok || memo.CreatorID != userID {
return echo.NewHTTPError(http.StatusForbidden, "this memo is private only")
}
} else if memo.Visibility == store.Protected {
if !ok {
return echo.NewHTTPError(http.StatusForbidden, "this memo is protected, missing user in session")
}
}
memoResponse, err := s.convertMemoFromStore(ctx, memo)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err)
}
return c.JSON(http.StatusOK, memoResponse)
}
// DeleteMemo godoc
//
// @Summary Delete memo by ID
// @Tags memo
// @Produce json
// @Param memoId path int true "Memo ID to delete"
// @Success 200 {boolean} true "Memo deleted"
// @Failure 400 {object} nil "ID is not a number: %s"
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
// @Failure 404 {object} nil "Memo not found: %d"
// @Failure 500 {object} nil "Failed to find memo | Failed to delete memo ID: %v"
// @Router /api/v1/memo/{memoId} [DELETE]
func (s *APIV1Service) DeleteMemo(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
memoID, err := util.ConvertStringToInt32(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
ID: &memoID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err)
}
if memo == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID))
}
if memo.CreatorID != userID {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
if err := s.Store.DeleteMemo(ctx, &store.DeleteMemo{
ID: memoID,
}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete memo ID: %v", memoID)).SetInternal(err)
}
return c.JSON(http.StatusOK, true)
}
// UpdateMemo godoc
//
// @Summary Update a memo
// @Description Visibility can be PUBLIC, PROTECTED or PRIVATE
// @Description *You should omit fields to use their default values
// @Tags memo
// @Accept json
// @Produce json
// @Param memoId path int true "ID of memo to update"
// @Param body body PatchMemoRequest true "Patched object."
// @Success 200 {object} store.Memo "Stored memo"
// @Failure 400 {object} nil "ID is not a number: %s | Malformatted patch memo request | Content size overflow, up to 1MB"
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
// @Failure 404 {object} nil "Memo not found: %d"
// @Failure 500 {object} nil "Failed to find memo | Failed to patch memo | Failed to upsert memo resource | Failed to delete memo resource | Failed to compose memo response"
// @Router /api/v1/memo/{memoId} [PATCH]
//
// NOTES:
// - It's currently possible to create phantom resources and relations. Phantom relations will trigger backend 404's when fetching memo.
// - Passing 0 to createdTs and updatedTs will set them to 0 in the database, which is probably unwanted.
func (s *APIV1Service) UpdateMemo(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
memoID, err := util.ConvertStringToInt32(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
ID: &memoID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err)
}
if memo == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID))
}
if memo.CreatorID != userID {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
currentTs := time.Now().Unix()
patchMemoRequest := &PatchMemoRequest{
ID: memoID,
UpdatedTs: &currentTs,
}
if err := json.NewDecoder(c.Request().Body).Decode(patchMemoRequest); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch memo request").SetInternal(err)
}
if patchMemoRequest.Content != nil && len(*patchMemoRequest.Content) > maxContentLength {
return echo.NewHTTPError(http.StatusBadRequest, "Content size overflow, up to 1MB").SetInternal(err)
}
updateMemoMessage := &store.UpdateMemo{
ID: memoID,
CreatedTs: patchMemoRequest.CreatedTs,
UpdatedTs: patchMemoRequest.UpdatedTs,
Content: patchMemoRequest.Content,
}
if patchMemoRequest.RowStatus != nil {
rowStatus := store.RowStatus(patchMemoRequest.RowStatus.String())
updateMemoMessage.RowStatus = &rowStatus
}
if patchMemoRequest.Visibility != nil {
visibility := store.Visibility(patchMemoRequest.Visibility.String())
updateMemoMessage.Visibility = &visibility
}
err = s.Store.UpdateMemo(ctx, updateMemoMessage)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch memo").SetInternal(err)
}
memo, err = s.Store.GetMemo(ctx, &store.FindMemo{ID: &memoID})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err)
}
if memo == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID))
}
if patchMemoRequest.ResourceIDList != nil {
addedResourceIDList, removedResourceIDList := getIDListDiff(memo.ResourceIDList, patchMemoRequest.ResourceIDList)
for _, resourceID := range addedResourceIDList {
if _, err := s.Store.UpdateResource(ctx, &store.UpdateResource{
ID: resourceID,
MemoID: &memo.ID,
}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err)
}
}
for _, resourceID := range removedResourceIDList {
if err := s.Store.DeleteResource(ctx, &store.DeleteResource{
ID: resourceID,
}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete resource").SetInternal(err)
}
}
}
if patchMemoRequest.RelationList != nil {
patchMemoRelationList := make([]*store.MemoRelation, 0)
for _, memoRelation := range patchMemoRequest.RelationList {
patchMemoRelationList = append(patchMemoRelationList, &store.MemoRelation{
MemoID: memo.ID,
RelatedMemoID: memoRelation.RelatedMemoID,
Type: store.MemoRelationType(memoRelation.Type),
})
}
addedMemoRelationList, removedMemoRelationList := getMemoRelationListDiff(memo.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)
}
}
for _, memoRelation := range removedMemoRelationList {
if err := s.Store.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{
MemoID: &memo.ID,
RelatedMemoID: &memoRelation.RelatedMemoID,
Type: &memoRelation.Type,
}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete memo relation").SetInternal(err)
}
}
}
memo, err = s.Store.GetMemo(ctx, &store.FindMemo{ID: &memoID})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err)
}
if memo == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID))
}
memoResponse, err := s.convertMemoFromStore(ctx, memo)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err)
}
return c.JSON(http.StatusOK, memoResponse)
}
func (s *APIV1Service) convertMemoFromStore(ctx context.Context, memo *store.Memo) (*Memo, error) {
memoResponse := &Memo{
ID: memo.ID,
RowStatus: RowStatus(memo.RowStatus.String()),
CreatorID: memo.CreatorID,
CreatedTs: memo.CreatedTs,
UpdatedTs: memo.UpdatedTs,
Content: memo.Content,
Visibility: Visibility(memo.Visibility.String()),
Pinned: memo.Pinned,
}
// Compose creator name.
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &memoResponse.CreatorID,
})
if err != nil {
return nil, err
}
if user.Nickname != "" {
memoResponse.CreatorName = user.Nickname
} else {
memoResponse.CreatorName = user.Username
}
memoResponse.CreatorUsername = user.Username
// Compose display ts.
memoResponse.DisplayTs = memoResponse.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
}
relationList := []*MemoRelation{}
for _, relation := range memo.RelationList {
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))
}
}
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
}
}
return memoResponse, nil
}
func (s *APIV1Service) getMemoDisplayWithUpdatedTsSettingValue(ctx context.Context) (bool, error) {
memoDisplayWithUpdatedTsSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
Name: SystemSettingMemoDisplayWithUpdatedTsName.String(),
})
if err != nil {
return false, errors.Wrap(err, "failed to find system setting")
}
memoDisplayWithUpdatedTs := false
if memoDisplayWithUpdatedTsSetting != nil {
err = json.Unmarshal([]byte(memoDisplayWithUpdatedTsSetting.Value), &memoDisplayWithUpdatedTs)
if err != nil {
return false, errors.Wrap(err, "failed to unmarshal system setting value")
}
}
return memoDisplayWithUpdatedTs, nil
}
func convertCreateMemoRequestToMemoMessage(memoCreate *CreateMemoRequest) *store.Memo {
createdTs := time.Now().Unix()
if memoCreate.CreatedTs != nil {
createdTs = *memoCreate.CreatedTs
}
return &store.Memo{
CreatorID: memoCreate.CreatorID,
CreatedTs: createdTs,
Content: memoCreate.Content,
Visibility: store.Visibility(memoCreate.Visibility),
}
}
func getMemoRelationListDiff(oldList, newList []*store.MemoRelation) (addedList, removedList []*store.MemoRelation) {
oldMap := map[string]bool{}
for _, relation := range oldList {
oldMap[fmt.Sprintf("%d-%s", relation.RelatedMemoID, relation.Type)] = true
}
newMap := map[string]bool{}
for _, relation := range newList {
newMap[fmt.Sprintf("%d-%s", relation.RelatedMemoID, relation.Type)] = true
}
for _, relation := range oldList {
key := fmt.Sprintf("%d-%s", relation.RelatedMemoID, relation.Type)
if !newMap[key] {
removedList = append(removedList, relation)
}
}
for _, relation := range newList {
key := fmt.Sprintf("%d-%s", relation.RelatedMemoID, relation.Type)
if !oldMap[key] {
addedList = append(addedList, relation)
}
}
return addedList, removedList
}
func getIDListDiff(oldList, newList []int32) (addedList, removedList []int32) {
oldMap := map[int32]bool{}
for _, id := range oldList {
oldMap[id] = true
}
newMap := map[int32]bool{}
for _, id := range newList {
newMap[id] = true
}
for id := range oldMap {
if !newMap[id] {
removedList = append(removedList, id)
}
}
for id := range newMap {
if !oldMap[id] {
addedList = append(addedList, id)
}
}
return addedList, removedList
}

97
api/v1/memo_organizer.go Normal file
View File

@@ -0,0 +1,97 @@
package v1
import (
"encoding/json"
"fmt"
"net/http"
"github.com/labstack/echo/v4"
"github.com/usememos/memos/internal/util"
"github.com/usememos/memos/store"
)
type MemoOrganizer struct {
MemoID int32 `json:"memoId"`
UserID int32 `json:"userId"`
Pinned bool `json:"pinned"`
}
type UpsertMemoOrganizerRequest struct {
Pinned bool `json:"pinned"`
}
func (s *APIV1Service) registerMemoOrganizerRoutes(g *echo.Group) {
g.POST("/memo/:memoId/organizer", s.CreateMemoOrganizer)
}
// CreateMemoOrganizer godoc
//
// @Summary Organize memo (pin/unpin)
// @Tags memo-organizer
// @Accept json
// @Produce json
// @Param memoId path int true "ID of memo to organize"
// @Param body body UpsertMemoOrganizerRequest true "Memo organizer object"
// @Success 200 {object} store.Memo "Memo information"
// @Failure 400 {object} nil "ID is not a number: %s | Malformatted post memo organizer request"
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
// @Failure 404 {object} nil "Memo not found: %v"
// @Failure 500 {object} nil "Failed to find memo | Failed to upsert memo organizer | Failed to find memo by ID: %v | Failed to compose memo response"
// @Router /api/v1/memo/{memoId}/organizer [POST]
func (s *APIV1Service) CreateMemoOrganizer(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := util.ConvertStringToInt32(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
ID: &memoID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err)
}
if memo == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %v", memoID))
}
if memo.CreatorID != userID {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
request := &UpsertMemoOrganizerRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo organizer request").SetInternal(err)
}
upsert := &store.MemoOrganizer{
MemoID: memoID,
UserID: userID,
Pinned: request.Pinned,
}
_, err = s.Store.UpsertMemoOrganizer(ctx, upsert)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo organizer").SetInternal(err)
}
memo, err = s.Store.GetMemo(ctx, &store.FindMemo{
ID: &memoID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find memo by ID: %v", memoID)).SetInternal(err)
}
if memo == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %v", memoID))
}
memoResponse, err := s.convertMemoFromStore(ctx, memo)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err)
}
return c.JSON(http.StatusOK, memoResponse)
}

152
api/v1/memo_relation.go Normal file
View File

@@ -0,0 +1,152 @@
package v1
import (
"encoding/json"
"fmt"
"net/http"
"github.com/labstack/echo/v4"
"github.com/usememos/memos/internal/util"
"github.com/usememos/memos/store"
)
type MemoRelationType string
const (
MemoRelationReference MemoRelationType = "REFERENCE"
MemoRelationComment MemoRelationType = "COMMENT"
)
type MemoRelation struct {
MemoID int32 `json:"memoId"`
RelatedMemoID int32 `json:"relatedMemoId"`
Type MemoRelationType `json:"type"`
}
type UpsertMemoRelationRequest struct {
RelatedMemoID int32 `json:"relatedMemoId"`
Type MemoRelationType `json:"type"`
}
func (s *APIV1Service) registerMemoRelationRoutes(g *echo.Group) {
g.GET("/memo/:memoId/relation", s.GetMemoRelationList)
g.POST("/memo/:memoId/relation", s.CreateMemoRelation)
g.DELETE("/memo/:memoId/relation/:relatedMemoId/type/:relationType", s.DeleteMemoRelation)
}
// GetMemoRelationList godoc
//
// @Summary Get a list of Memo Relations
// @Tags memo-relation
// @Accept json
// @Produce json
// @Param memoId path int true "ID of memo to find relations"
// @Success 200 {object} []store.MemoRelation "Memo relation information list"
// @Failure 400 {object} nil "ID is not a number: %s"
// @Failure 500 {object} nil "Failed to list memo relations"
// @Router /api/v1/memo/{memoId}/relation [GET]
func (s *APIV1Service) GetMemoRelationList(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := util.ConvertStringToInt32(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
memoRelationList, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{
MemoID: &memoID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to list memo relations").SetInternal(err)
}
return c.JSON(http.StatusOK, memoRelationList)
}
// CreateMemoRelation godoc
//
// @Summary Create Memo Relation
// @Description Create a relation between two memos
// @Tags memo-relation
// @Accept json
// @Produce json
// @Param memoId path int true "ID of memo to relate"
// @Param body body UpsertMemoRelationRequest true "Memo relation object"
// @Success 200 {object} store.MemoRelation "Memo relation information"
// @Failure 400 {object} nil "ID is not a number: %s | Malformatted post memo relation request"
// @Failure 500 {object} nil "Failed to upsert memo relation"
// @Router /api/v1/memo/{memoId}/relation [POST]
//
// NOTES:
// - Currently not secured
// - It's possible to create relations to memos that doesn't exist, which will trigger 404 errors when the frontend tries to load them.
// - It's possible to create multiple relations, though the interface only shows first.
func (s *APIV1Service) CreateMemoRelation(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := util.ConvertStringToInt32(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
request := &UpsertMemoRelationRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo relation request").SetInternal(err)
}
memoRelation, err := s.Store.UpsertMemoRelation(ctx, &store.MemoRelation{
MemoID: memoID,
RelatedMemoID: request.RelatedMemoID,
Type: store.MemoRelationType(request.Type),
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo relation").SetInternal(err)
}
return c.JSON(http.StatusOK, memoRelation)
}
// DeleteMemoRelation godoc
//
// @Summary Delete a Memo Relation
// @Description Removes a relation between two memos
// @Tags memo-relation
// @Accept json
// @Produce json
// @Param memoId path int true "ID of memo to find relations"
// @Param relatedMemoId path int true "ID of memo to remove relation to"
// @Param relationType path MemoRelationType true "Type of relation to remove"
// @Success 200 {boolean} true "Memo relation deleted"
// @Failure 400 {object} nil "Memo ID is not a number: %s | Related memo ID is not a number: %s"
// @Failure 500 {object} nil "Failed to delete memo relation"
// @Router /api/v1/memo/{memoId}/relation/{relatedMemoId}/type/{relationType} [DELETE]
//
// NOTES:
// - Currently not secured.
// - Will always return true, even if the relation doesn't exist.
func (s *APIV1Service) DeleteMemoRelation(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := util.ConvertStringToInt32(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Memo ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
relatedMemoID, err := util.ConvertStringToInt32(c.Param("relatedMemoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Related memo ID is not a number: %s", c.Param("relatedMemoId"))).SetInternal(err)
}
relationType := store.MemoRelationType(c.Param("relationType"))
if err := s.Store.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{
MemoID: &memoID,
RelatedMemoID: &relatedMemoID,
Type: &relationType,
}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete memo relation").SetInternal(err)
}
return c.JSON(http.StatusOK, true)
}
func convertMemoRelationFromStore(memoRelation *store.MemoRelation) *MemoRelation {
return &MemoRelation{
MemoID: memoRelation.MemoID,
RelatedMemoID: memoRelation.RelatedMemoID,
Type: MemoRelationType(memoRelation.Type),
}
}

487
api/v1/resource.go Normal file
View File

@@ -0,0 +1,487 @@
package v1
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"go.uber.org/zap"
"github.com/usememos/memos/internal/log"
"github.com/usememos/memos/internal/util"
"github.com/usememos/memos/plugin/storage/s3"
"github.com/usememos/memos/server/service/metric"
"github.com/usememos/memos/store"
)
type Resource struct {
ID int32 `json:"id"`
// Standard fields
CreatorID int32 `json:"creatorId"`
CreatedTs int64 `json:"createdTs"`
UpdatedTs int64 `json:"updatedTs"`
// Domain specific fields
Filename string `json:"filename"`
Blob []byte `json:"-"`
InternalPath string `json:"-"`
ExternalLink string `json:"externalLink"`
Type string `json:"type"`
Size int64 `json:"size"`
}
type CreateResourceRequest struct {
Filename string `json:"filename"`
ExternalLink string `json:"externalLink"`
Type string `json:"type"`
}
type FindResourceRequest struct {
ID *int32 `json:"id"`
CreatorID *int32 `json:"creatorId"`
Filename *string `json:"filename"`
}
type UpdateResourceRequest struct {
Filename *string `json:"filename"`
}
const (
// The upload memory buffer is 32 MiB.
// It should be kept low, so RAM usage doesn't get out of control.
// This is unrelated to maximum upload size limit, which is now set through system setting.
maxUploadBufferSizeBytes = 32 << 20
MebiByte = 1024 * 1024
)
var fileKeyPattern = regexp.MustCompile(`\{[a-z]{1,9}\}`)
func (s *APIV1Service) registerResourceRoutes(g *echo.Group) {
g.GET("/resource", s.GetResourceList)
g.POST("/resource", s.CreateResource)
g.POST("/resource/blob", s.UploadResource)
g.PATCH("/resource/:resourceId", s.UpdateResource)
g.DELETE("/resource/:resourceId", s.DeleteResource)
}
// GetResourceList godoc
//
// @Summary Get a list of resources
// @Tags resource
// @Produce json
// @Param limit query int false "Limit"
// @Param offset query int false "Offset"
// @Success 200 {object} []store.Resource "Resource list"
// @Failure 401 {object} nil "Missing user in session"
// @Failure 500 {object} nil "Failed to fetch resource list"
// @Router /api/v1/resource [GET]
func (s *APIV1Service) GetResourceList(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
find := &store.FindResource{
CreatorID: &userID,
}
if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
find.Limit = &limit
}
if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil {
find.Offset = &offset
}
list, err := s.Store.ListResources(ctx, find)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
}
resourceMessageList := []*Resource{}
for _, resource := range list {
resourceMessageList = append(resourceMessageList, convertResourceFromStore(resource))
}
return c.JSON(http.StatusOK, resourceMessageList)
}
// CreateResource godoc
//
// @Summary Create resource
// @Tags resource
// @Accept json
// @Produce json
// @Param body body CreateResourceRequest true "Request object."
// @Success 200 {object} store.Resource "Created resource"
// @Failure 400 {object} nil "Malformatted post resource request | Invalid external link | Invalid external link scheme | Failed to request %s | Failed to read %s | Failed to read mime from %s"
// @Failure 401 {object} nil "Missing user in session"
// @Failure 500 {object} nil "Failed to save resource | Failed to create resource | Failed to create activity"
// @Router /api/v1/resource [POST]
func (s *APIV1Service) CreateResource(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
request := &CreateResourceRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post resource request").SetInternal(err)
}
create := &store.Resource{
CreatorID: userID,
Filename: request.Filename,
ExternalLink: request.ExternalLink,
Type: request.Type,
}
if request.ExternalLink != "" {
// Only allow those external links scheme with http/https
linkURL, err := url.Parse(request.ExternalLink)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid external link").SetInternal(err)
}
if linkURL.Scheme != "http" && linkURL.Scheme != "https" {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid external link scheme")
}
}
resource, err := s.Store.CreateResource(ctx, create)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err)
}
metric.Enqueue("resource create")
return c.JSON(http.StatusOK, convertResourceFromStore(resource))
}
// UploadResource godoc
//
// @Summary Upload resource
// @Tags resource
// @Accept multipart/form-data
// @Produce json
// @Param file formData file true "File to upload"
// @Success 200 {object} store.Resource "Created resource"
// @Failure 400 {object} nil "Upload file not found | File size exceeds allowed limit of %d MiB | Failed to parse upload data"
// @Failure 401 {object} nil "Missing user in session"
// @Failure 500 {object} nil "Failed to get uploading file | Failed to open file | Failed to save resource | Failed to create resource | Failed to create activity"
// @Router /api/v1/resource/blob [POST]
func (s *APIV1Service) UploadResource(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
// This is the backend default max upload size limit.
maxUploadSetting := s.Store.GetSystemSettingValueWithDefault(ctx, SystemSettingMaxUploadSizeMiBName.String(), "32")
var settingMaxUploadSizeBytes int
if settingMaxUploadSizeMiB, err := strconv.Atoi(maxUploadSetting); err == nil {
settingMaxUploadSizeBytes = settingMaxUploadSizeMiB * MebiByte
} else {
log.Warn("Failed to parse max upload size", zap.Error(err))
settingMaxUploadSizeBytes = 0
}
file, err := c.FormFile("file")
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get uploading file").SetInternal(err)
}
if file == nil {
return echo.NewHTTPError(http.StatusBadRequest, "Upload file not found").SetInternal(err)
}
if file.Size > int64(settingMaxUploadSizeBytes) {
message := fmt.Sprintf("File size exceeds allowed limit of %d MiB", settingMaxUploadSizeBytes/MebiByte)
return echo.NewHTTPError(http.StatusBadRequest, message).SetInternal(err)
}
if err := c.Request().ParseMultipartForm(maxUploadBufferSizeBytes); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Failed to parse upload data").SetInternal(err)
}
sourceFile, err := file.Open()
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to open file").SetInternal(err)
}
defer sourceFile.Close()
create := &store.Resource{
CreatorID: userID,
Filename: file.Filename,
Type: file.Header.Get("Content-Type"),
Size: file.Size,
}
err = SaveResourceBlob(ctx, s.Store, create, sourceFile)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to save resource").SetInternal(err)
}
resource, err := s.Store.CreateResource(ctx, create)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err)
}
return c.JSON(http.StatusOK, convertResourceFromStore(resource))
}
// DeleteResource godoc
//
// @Summary Delete a resource
// @Tags resource
// @Produce json
// @Param resourceId path int true "Resource ID"
// @Success 200 {boolean} true "Resource deleted"
// @Failure 400 {object} nil "ID is not a number: %s"
// @Failure 401 {object} nil "Missing user in session"
// @Failure 404 {object} nil "Resource not found: %d"
// @Failure 500 {object} nil "Failed to find resource | Failed to delete resource"
// @Router /api/v1/resource/{resourceId} [DELETE]
func (s *APIV1Service) DeleteResource(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
resourceID, err := util.ConvertStringToInt32(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
resource, err := s.Store.GetResource(ctx, &store.FindResource{
ID: &resourceID,
CreatorID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find resource").SetInternal(err)
}
if resource == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource not found: %d", resourceID))
}
if err := s.Store.DeleteResource(ctx, &store.DeleteResource{
ID: resourceID,
}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete resource").SetInternal(err)
}
return c.JSON(http.StatusOK, true)
}
// UpdateResource godoc
//
// @Summary Update a resource
// @Tags resource
// @Produce json
// @Param resourceId path int true "Resource ID"
// @Param patch body UpdateResourceRequest true "Patch resource request"
// @Success 200 {object} store.Resource "Updated resource"
// @Failure 400 {object} nil "ID is not a number: %s | Malformatted patch resource request"
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
// @Failure 404 {object} nil "Resource not found: %d"
// @Failure 500 {object} nil "Failed to find resource | Failed to patch resource"
// @Router /api/v1/resource/{resourceId} [PATCH]
func (s *APIV1Service) UpdateResource(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
resourceID, err := util.ConvertStringToInt32(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
resource, err := s.Store.GetResource(ctx, &store.FindResource{
ID: &resourceID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find resource").SetInternal(err)
}
if resource == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource not found: %d", resourceID))
}
if resource.CreatorID != userID {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
request := &UpdateResourceRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch resource request").SetInternal(err)
}
currentTs := time.Now().Unix()
update := &store.UpdateResource{
ID: resourceID,
UpdatedTs: &currentTs,
}
if request.Filename != nil && *request.Filename != "" {
update.Filename = request.Filename
}
resource, err = s.Store.UpdateResource(ctx, update)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch resource").SetInternal(err)
}
return c.JSON(http.StatusOK, convertResourceFromStore(resource))
}
func replacePathTemplate(path, filename string) string {
t := time.Now()
path = fileKeyPattern.ReplaceAllStringFunc(path, func(s string) string {
switch s {
case "{filename}":
return filename
case "{timestamp}":
return fmt.Sprintf("%d", t.Unix())
case "{year}":
return fmt.Sprintf("%d", t.Year())
case "{month}":
return fmt.Sprintf("%02d", t.Month())
case "{day}":
return fmt.Sprintf("%02d", t.Day())
case "{hour}":
return fmt.Sprintf("%02d", t.Hour())
case "{minute}":
return fmt.Sprintf("%02d", t.Minute())
case "{second}":
return fmt.Sprintf("%02d", t.Second())
}
return s
})
return path
}
func convertResourceFromStore(resource *store.Resource) *Resource {
return &Resource{
ID: resource.ID,
CreatorID: resource.CreatorID,
CreatedTs: resource.CreatedTs,
UpdatedTs: resource.UpdatedTs,
Filename: resource.Filename,
Blob: resource.Blob,
InternalPath: resource.InternalPath,
ExternalLink: resource.ExternalLink,
Type: resource.Type,
Size: resource.Size,
}
}
// SaveResourceBlob save the blob of resource based on the storage config
//
// Depend on the storage config, some fields of *store.ResourceCreate will be changed:
// 1. *DatabaseStorage*: `create.Blob`.
// 2. *LocalStorage*: `create.InternalPath`.
// 3. Others( external service): `create.ExternalLink`.
func SaveResourceBlob(ctx context.Context, s *store.Store, create *store.Resource, r io.Reader) error {
systemSettingStorageServiceID, err := s.GetSystemSetting(ctx, &store.FindSystemSetting{Name: SystemSettingStorageServiceIDName.String()})
if err != nil {
return errors.Wrap(err, "Failed to find SystemSettingStorageServiceIDName")
}
storageServiceID := DefaultStorage
if systemSettingStorageServiceID != nil {
err = json.Unmarshal([]byte(systemSettingStorageServiceID.Value), &storageServiceID)
if err != nil {
return errors.Wrap(err, "Failed to unmarshal storage service id")
}
}
// `DatabaseStorage` means store blob into database
if storageServiceID == DatabaseStorage {
fileBytes, err := io.ReadAll(r)
if err != nil {
return errors.Wrap(err, "Failed to read file")
}
create.Blob = fileBytes
return nil
} else if storageServiceID == LocalStorage {
// `LocalStorage` means save blob into local disk
systemSettingLocalStoragePath, err := s.GetSystemSetting(ctx, &store.FindSystemSetting{Name: SystemSettingLocalStoragePathName.String()})
if err != nil {
return errors.Wrap(err, "Failed to find SystemSettingLocalStoragePathName")
}
localStoragePath := "assets/{timestamp}_{filename}"
if systemSettingLocalStoragePath != nil && systemSettingLocalStoragePath.Value != "" {
err = json.Unmarshal([]byte(systemSettingLocalStoragePath.Value), &localStoragePath)
if err != nil {
return errors.Wrap(err, "Failed to unmarshal SystemSettingLocalStoragePathName")
}
}
filePath := filepath.FromSlash(localStoragePath)
if !strings.Contains(filePath, "{filename}") {
filePath = filepath.Join(filePath, "{filename}")
}
filePath = filepath.Join(s.Profile.Data, replacePathTemplate(filePath, create.Filename))
dir := filepath.Dir(filePath)
if err = os.MkdirAll(dir, os.ModePerm); err != nil {
return errors.Wrap(err, "Failed to create directory")
}
dst, err := os.Create(filePath)
if err != nil {
return errors.Wrap(err, "Failed to create file")
}
defer dst.Close()
_, err = io.Copy(dst, r)
if err != nil {
return errors.Wrap(err, "Failed to copy file")
}
create.InternalPath = filePath
return nil
}
// Others: store blob into external service, such as S3
storage, err := s.GetStorage(ctx, &store.FindStorage{ID: &storageServiceID})
if err != nil {
return errors.Wrap(err, "Failed to find StorageServiceID")
}
if storage == nil {
return errors.Errorf("Storage %d not found", storageServiceID)
}
storageMessage, err := ConvertStorageFromStore(storage)
if err != nil {
return errors.Wrap(err, "Failed to ConvertStorageFromStore")
}
if storageMessage.Type != StorageS3 {
return errors.Errorf("Unsupported storage type: %s", storageMessage.Type)
}
s3Config := storageMessage.Config.S3Config
s3Client, err := s3.NewClient(ctx, &s3.Config{
AccessKey: s3Config.AccessKey,
SecretKey: s3Config.SecretKey,
EndPoint: s3Config.EndPoint,
Region: s3Config.Region,
Bucket: s3Config.Bucket,
URLPrefix: s3Config.URLPrefix,
URLSuffix: s3Config.URLSuffix,
})
if err != nil {
return errors.Wrap(err, "Failed to create s3 client")
}
filePath := s3Config.Path
if !strings.Contains(filePath, "{filename}") {
filePath = filepath.Join(filePath, "{filename}")
}
filePath = replacePathTemplate(filePath, create.Filename)
link, err := s3Client.UploadFile(ctx, filePath, create.Type, r)
if err != nil {
return errors.Wrap(err, "Failed to upload via s3 client")
}
create.ExternalLink = link
return nil
}

211
api/v1/rss.go Normal file
View File

@@ -0,0 +1,211 @@
package v1
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/gorilla/feeds"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"github.com/yuin/goldmark"
"github.com/usememos/memos/internal/util"
"github.com/usememos/memos/store"
)
const maxRSSItemCount = 100
const maxRSSItemTitleLength = 100
func (s *APIV1Service) registerRSSRoutes(g *echo.Group) {
g.GET("/explore/rss.xml", s.GetExploreRSS)
g.GET("/u/:id/rss.xml", s.GetUserRSS)
}
// GetExploreRSS godoc
//
// @Summary Get RSS
// @Tags rss
// @Produce xml
// @Success 200 {object} nil "RSS"
// @Failure 500 {object} nil "Failed to get system customized profile | Failed to find memo list | Failed to generate rss"
// @Router /explore/rss.xml [GET]
func (s *APIV1Service) GetExploreRSS(c echo.Context) error {
ctx := c.Request().Context()
systemCustomizedProfile, err := s.getSystemCustomizedProfile(ctx)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get system customized profile").SetInternal(err)
}
normalStatus := store.Normal
memoFind := store.FindMemo{
RowStatus: &normalStatus,
VisibilityList: []store.Visibility{store.Public},
}
memoList, err := s.Store.ListMemos(ctx, &memoFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
}
baseURL := c.Scheme() + "://" + c.Request().Host
rss, err := s.generateRSSFromMemoList(ctx, memoList, baseURL, systemCustomizedProfile)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate rss").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationXMLCharsetUTF8)
return c.String(http.StatusOK, rss)
}
// GetUserRSS godoc
//
// @Summary Get RSS for a user
// @Tags rss
// @Produce xml
// @Param id path int true "User ID"
// @Success 200 {object} nil "RSS"
// @Failure 400 {object} nil "User id is not a number"
// @Failure 500 {object} nil "Failed to get system customized profile | Failed to find memo list | Failed to generate rss"
// @Router /u/{id}/rss.xml [GET]
func (s *APIV1Service) GetUserRSS(c echo.Context) error {
ctx := c.Request().Context()
id, err := util.ConvertStringToInt32(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "User id is not a number").SetInternal(err)
}
systemCustomizedProfile, err := s.getSystemCustomizedProfile(ctx)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get system customized profile").SetInternal(err)
}
normalStatus := store.Normal
memoFind := store.FindMemo{
CreatorID: &id,
RowStatus: &normalStatus,
VisibilityList: []store.Visibility{store.Public},
}
memoList, err := s.Store.ListMemos(ctx, &memoFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
}
baseURL := c.Scheme() + "://" + c.Request().Host
rss, err := s.generateRSSFromMemoList(ctx, memoList, baseURL, systemCustomizedProfile)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate rss").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationXMLCharsetUTF8)
return c.String(http.StatusOK, rss)
}
func (s *APIV1Service) generateRSSFromMemoList(ctx context.Context, memoList []*store.Memo, baseURL string, profile *CustomizedProfile) (string, error) {
feed := &feeds.Feed{
Title: profile.Name,
Link: &feeds.Link{Href: baseURL},
Description: profile.Description,
Created: time.Now(),
}
var itemCountLimit = util.Min(len(memoList), maxRSSItemCount)
feed.Items = make([]*feeds.Item, itemCountLimit)
for i := 0; i < itemCountLimit; i++ {
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"},
}
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)
}
enclosure := feeds.Enclosure{}
if resource.ExternalLink != "" {
enclosure.Url = resource.ExternalLink
} else {
enclosure.Url = baseURL + "/o/r/" + fmt.Sprintf("%d", resource.ID)
}
enclosure.Length = strconv.Itoa(int(resource.Size))
enclosure.Type = resource.Type
feed.Items[i].Enclosure = &enclosure
}
}
rss, err := feed.ToRss()
if err != nil {
return "", err
}
return rss, nil
}
func (s *APIV1Service) getSystemCustomizedProfile(ctx context.Context) (*CustomizedProfile, error) {
systemSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
Name: SystemSettingCustomizedProfileName.String(),
})
if err != nil {
return nil, err
}
customizedProfile := &CustomizedProfile{
Name: "memos",
LogoURL: "",
Description: "",
Locale: "en",
Appearance: "system",
ExternalURL: "",
}
if systemSetting != nil {
if err := json.Unmarshal([]byte(systemSetting.Value), customizedProfile); err != nil {
return nil, err
}
}
return customizedProfile, nil
}
func getRSSItemTitle(content string) string {
var title string
if isTitleDefined(content) {
title = strings.Split(content, "\n")[0][2:]
} else {
title = strings.Split(content, "\n")[0]
var titleLengthLimit = util.Min(len(title), maxRSSItemTitleLength)
if titleLengthLimit < len(title) {
title = title[:titleLengthLimit] + "..."
}
}
return title
}
func getRSSItemDescription(content string) string {
var description string
if isTitleDefined(content) {
var firstLineEnd = strings.Index(content, "\n")
description = strings.Trim(content[firstLineEnd+1:], " ")
} else {
description = content
}
// TODO: use our `./plugin/gomark` parser to handle markdown-like content.
var buf bytes.Buffer
if err := goldmark.Convert([]byte(description), &buf); err != nil {
panic(err)
}
return buf.String()
}
func isTitleDefined(content string) bool {
return strings.HasPrefix(content, "# ")
}

315
api/v1/storage.go Normal file
View File

@@ -0,0 +1,315 @@
package v1
import (
"encoding/json"
"fmt"
"net/http"
"github.com/labstack/echo/v4"
"github.com/usememos/memos/internal/util"
"github.com/usememos/memos/store"
)
const (
// LocalStorage means the storage service is local file system.
LocalStorage int32 = -1
// DatabaseStorage means the storage service is database.
DatabaseStorage int32 = 0
// Default storage service is database.
DefaultStorage int32 = DatabaseStorage
)
type StorageType string
const (
StorageS3 StorageType = "S3"
)
func (t StorageType) String() string {
return string(t)
}
type StorageConfig struct {
S3Config *StorageS3Config `json:"s3Config"`
}
type StorageS3Config struct {
EndPoint string `json:"endPoint"`
Path string `json:"path"`
Region string `json:"region"`
AccessKey string `json:"accessKey"`
SecretKey string `json:"secretKey"`
Bucket string `json:"bucket"`
URLPrefix string `json:"urlPrefix"`
URLSuffix string `json:"urlSuffix"`
}
type Storage struct {
ID int32 `json:"id"`
Name string `json:"name"`
Type StorageType `json:"type"`
Config *StorageConfig `json:"config"`
}
type CreateStorageRequest struct {
Name string `json:"name"`
Type StorageType `json:"type"`
Config *StorageConfig `json:"config"`
}
type UpdateStorageRequest struct {
Type StorageType `json:"type"`
Name *string `json:"name"`
Config *StorageConfig `json:"config"`
}
func (s *APIV1Service) registerStorageRoutes(g *echo.Group) {
g.GET("/storage", s.GetStorageList)
g.POST("/storage", s.CreateStorage)
g.PATCH("/storage/:storageId", s.UpdateStorage)
g.DELETE("/storage/:storageId", s.DeleteStorage)
}
// GetStorageList godoc
//
// @Summary Get a list of storages
// @Tags storage
// @Produce json
// @Success 200 {object} []store.Storage "List of storages"
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
// @Failure 500 {object} nil "Failed to find user | Failed to convert storage"
// @Router /api/v1/storage [GET]
func (s *APIV1Service) GetStorageList(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
// We should only show storage list to host user.
if user == nil || user.Role != store.RoleHost {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
list, err := s.Store.ListStorages(ctx, &store.FindStorage{})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find storage list").SetInternal(err)
}
storageList := []*Storage{}
for _, storage := range list {
storageMessage, err := ConvertStorageFromStore(storage)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to convert storage").SetInternal(err)
}
storageList = append(storageList, storageMessage)
}
return c.JSON(http.StatusOK, storageList)
}
// CreateStorage godoc
//
// @Summary Create storage
// @Tags storage
// @Accept json
// @Produce json
// @Param body body CreateStorageRequest true "Request object."
// @Success 200 {object} store.Storage "Created storage"
// @Failure 400 {object} nil "Malformatted post storage request"
// @Failure 401 {object} nil "Missing user in session"
// @Failure 500 {object} nil "Failed to find user | Failed to create storage | Failed to convert storage"
// @Router /api/v1/storage [POST]
func (s *APIV1Service) CreateStorage(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if user == nil || user.Role != store.RoleHost {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
create := &CreateStorageRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(create); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post storage request").SetInternal(err)
}
configString := ""
if create.Type == StorageS3 && create.Config.S3Config != nil {
configBytes, err := json.Marshal(create.Config.S3Config)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post storage request").SetInternal(err)
}
configString = string(configBytes)
}
storage, err := s.Store.CreateStorage(ctx, &store.Storage{
Name: create.Name,
Type: create.Type.String(),
Config: configString,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create storage").SetInternal(err)
}
storageMessage, err := ConvertStorageFromStore(storage)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to convert storage").SetInternal(err)
}
return c.JSON(http.StatusOK, storageMessage)
}
// DeleteStorage godoc
//
// @Summary Delete a storage
// @Tags storage
// @Produce json
// @Param storageId path int true "Storage ID"
// @Success 200 {boolean} true "Storage deleted"
// @Failure 400 {object} nil "ID is not a number: %s | Storage service %d is using"
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
// @Failure 500 {object} nil "Failed to find user | Failed to find storage | Failed to unmarshal storage service id | Failed to delete storage"
// @Router /api/v1/storage/{storageId} [DELETE]
//
// NOTES:
// - error message "Storage service %d is using" probably should be "Storage service %d is in use".
func (s *APIV1Service) DeleteStorage(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if user == nil || user.Role != store.RoleHost {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
storageID, err := util.ConvertStringToInt32(c.Param("storageId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("storageId"))).SetInternal(err)
}
systemSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{Name: SystemSettingStorageServiceIDName.String()})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find storage").SetInternal(err)
}
if systemSetting != nil {
storageServiceID := DefaultStorage
err = json.Unmarshal([]byte(systemSetting.Value), &storageServiceID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal storage service id").SetInternal(err)
}
if storageServiceID == storageID {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Storage service %d is using", storageID))
}
}
if err = s.Store.DeleteStorage(ctx, &store.DeleteStorage{ID: storageID}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete storage").SetInternal(err)
}
return c.JSON(http.StatusOK, true)
}
// UpdateStorage godoc
//
// @Summary Update a storage
// @Tags storage
// @Produce json
// @Param storageId path int true "Storage ID"
// @Param patch body UpdateStorageRequest true "Patch request"
// @Success 200 {object} store.Storage "Updated resource"
// @Failure 400 {object} nil "ID is not a number: %s | Malformatted patch storage request | Malformatted post storage request"
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
// @Failure 500 {object} nil "Failed to find user | Failed to patch storage | Failed to convert storage"
// @Router /api/v1/storage/{storageId} [PATCH]
func (s *APIV1Service) UpdateStorage(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if user == nil || user.Role != store.RoleHost {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
storageID, err := util.ConvertStringToInt32(c.Param("storageId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("storageId"))).SetInternal(err)
}
update := &UpdateStorageRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(update); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch storage request").SetInternal(err)
}
storageUpdate := &store.UpdateStorage{
ID: storageID,
}
if update.Name != nil {
storageUpdate.Name = update.Name
}
if update.Config != nil {
if update.Type == StorageS3 {
configBytes, err := json.Marshal(update.Config.S3Config)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post storage request").SetInternal(err)
}
configString := string(configBytes)
storageUpdate.Config = &configString
}
}
storage, err := s.Store.UpdateStorage(ctx, storageUpdate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch storage").SetInternal(err)
}
storageMessage, err := ConvertStorageFromStore(storage)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to convert storage").SetInternal(err)
}
return c.JSON(http.StatusOK, storageMessage)
}
func ConvertStorageFromStore(storage *store.Storage) (*Storage, error) {
storageMessage := &Storage{
ID: storage.ID,
Name: storage.Name,
Type: StorageType(storage.Type),
Config: &StorageConfig{},
}
if storageMessage.Type == StorageS3 {
s3Config := &StorageS3Config{}
if err := json.Unmarshal([]byte(storage.Config), s3Config); err != nil {
return nil, err
}
storageMessage.Config = &StorageConfig{
S3Config: s3Config,
}
}
return storageMessage, nil
}

2033
api/v1/swagger.yaml Normal file

File diff suppressed because it is too large Load Diff

183
api/v1/system.go Normal file
View File

@@ -0,0 +1,183 @@
package v1
import (
"encoding/json"
"net/http"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
"github.com/usememos/memos/internal/log"
"github.com/usememos/memos/server/profile"
"github.com/usememos/memos/store"
)
type SystemStatus struct {
Host *User `json:"host"`
Profile profile.Profile `json:"profile"`
DBSize int64 `json:"dbSize"`
// System settings
// Allow sign up.
AllowSignUp bool `json:"allowSignUp"`
// Disable password login.
DisablePasswordLogin bool `json:"disablePasswordLogin"`
// Disable public memos.
DisablePublicMemos bool `json:"disablePublicMemos"`
// Max upload size.
MaxUploadSizeMiB int `json:"maxUploadSizeMiB"`
// Auto Backup Interval.
AutoBackupInterval int `json:"autoBackupInterval"`
// Additional style.
AdditionalStyle string `json:"additionalStyle"`
// Additional script.
AdditionalScript string `json:"additionalScript"`
// Customized server profile, including server name and external url.
CustomizedProfile CustomizedProfile `json:"customizedProfile"`
// Storage service ID.
StorageServiceID int32 `json:"storageServiceId"`
// Local storage path.
LocalStoragePath string `json:"localStoragePath"`
// Memo display with updated timestamp.
MemoDisplayWithUpdatedTs bool `json:"memoDisplayWithUpdatedTs"`
}
func (s *APIV1Service) registerSystemRoutes(g *echo.Group) {
g.GET("/ping", s.PingSystem)
g.GET("/status", s.GetSystemStatus)
g.POST("/system/vacuum", s.ExecVacuum)
}
// PingSystem godoc
//
// @Summary Ping the system
// @Tags system
// @Produce json
// @Success 200 {boolean} true "If succeed to ping the system"
// @Router /api/v1/ping [GET]
func (*APIV1Service) PingSystem(c echo.Context) error {
return c.JSON(http.StatusOK, true)
}
// GetSystemStatus godoc
//
// @Summary Get system GetSystemStatus
// @Tags system
// @Produce json
// @Success 200 {object} SystemStatus "System GetSystemStatus"
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
// @Failure 500 {object} nil "Failed to find host user | Failed to find system setting list | Failed to unmarshal system setting customized profile value"
// @Router /api/v1/status [GET]
func (s *APIV1Service) GetSystemStatus(c echo.Context) error {
ctx := c.Request().Context()
systemStatus := SystemStatus{
Profile: profile.Profile{
Mode: s.Profile.Mode,
Version: s.Profile.Version,
},
// Allow sign up by default.
AllowSignUp: true,
MaxUploadSizeMiB: 32,
CustomizedProfile: CustomizedProfile{
Name: "memos",
Locale: "en",
Appearance: "system",
},
StorageServiceID: DefaultStorage,
LocalStoragePath: "assets/{timestamp}_{filename}",
}
hostUserType := store.RoleHost
hostUser, err := s.Store.GetUser(ctx, &store.FindUser{
Role: &hostUserType,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find host user").SetInternal(err)
}
if hostUser != nil {
systemStatus.Host = &User{ID: hostUser.ID}
}
systemSettingList, err := s.Store.ListSystemSettings(ctx, &store.FindSystemSetting{})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting list").SetInternal(err)
}
for _, systemSetting := range systemSettingList {
if systemSetting.Name == SystemSettingServerIDName.String() || systemSetting.Name == SystemSettingSecretSessionName.String() || systemSetting.Name == SystemSettingTelegramBotTokenName.String() {
continue
}
var baseValue any
err := json.Unmarshal([]byte(systemSetting.Value), &baseValue)
if err != nil {
log.Warn("Failed to unmarshal system setting value", zap.String("setting name", systemSetting.Name))
continue
}
switch systemSetting.Name {
case SystemSettingAllowSignUpName.String():
systemStatus.AllowSignUp = baseValue.(bool)
case SystemSettingDisablePasswordLoginName.String():
systemStatus.DisablePasswordLogin = baseValue.(bool)
case SystemSettingDisablePublicMemosName.String():
systemStatus.DisablePublicMemos = baseValue.(bool)
case SystemSettingMaxUploadSizeMiBName.String():
systemStatus.MaxUploadSizeMiB = int(baseValue.(float64))
case SystemSettingAutoBackupIntervalName.String():
systemStatus.AutoBackupInterval = int(baseValue.(float64))
case SystemSettingAdditionalStyleName.String():
systemStatus.AdditionalStyle = baseValue.(string)
case SystemSettingAdditionalScriptName.String():
systemStatus.AdditionalScript = baseValue.(string)
case SystemSettingCustomizedProfileName.String():
customizedProfile := CustomizedProfile{}
if err := json.Unmarshal([]byte(systemSetting.Value), &customizedProfile); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting customized profile value").SetInternal(err)
}
systemStatus.CustomizedProfile = customizedProfile
case SystemSettingStorageServiceIDName.String():
systemStatus.StorageServiceID = int32(baseValue.(float64))
case SystemSettingLocalStoragePathName.String():
systemStatus.LocalStoragePath = baseValue.(string)
case SystemSettingMemoDisplayWithUpdatedTsName.String():
systemStatus.MemoDisplayWithUpdatedTs = baseValue.(bool)
default:
log.Warn("Unknown system setting name", zap.String("setting name", systemSetting.Name))
}
}
return c.JSON(http.StatusOK, systemStatus)
}
// ExecVacuum godoc
//
// @Summary Vacuum the database
// @Tags system
// @Produce json
// @Success 200 {boolean} true "Database vacuumed"
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
// @Failure 500 {object} nil "Failed to find user | Failed to ExecVacuum database"
// @Router /api/v1/system/vacuum [POST]
func (s *APIV1Service) ExecVacuum(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if user == nil || user.Role != store.RoleHost {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
if err := s.Store.Vacuum(ctx); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to vacuum database").SetInternal(err)
}
return c.JSON(http.StatusOK, true)
}

286
api/v1/system_setting.go Normal file
View File

@@ -0,0 +1,286 @@
package v1
import (
"encoding/json"
"net/http"
"strings"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"github.com/usememos/memos/store"
)
type SystemSettingName string
const (
// SystemSettingServerIDName is the name of server id.
SystemSettingServerIDName SystemSettingName = "server-id"
// SystemSettingSecretSessionName is the name of secret session.
SystemSettingSecretSessionName SystemSettingName = "secret-session"
// SystemSettingAllowSignUpName is the name of allow signup setting.
SystemSettingAllowSignUpName SystemSettingName = "allow-signup"
// SystemSettingDisablePasswordLoginName is the name of disable password login setting.
SystemSettingDisablePasswordLoginName SystemSettingName = "disable-password-login"
// SystemSettingDisablePublicMemosName is the name of disable public memos setting.
SystemSettingDisablePublicMemosName SystemSettingName = "disable-public-memos"
// SystemSettingMaxUploadSizeMiBName is the name of max upload size setting.
SystemSettingMaxUploadSizeMiBName SystemSettingName = "max-upload-size-mib"
// SystemSettingAdditionalStyleName is the name of additional style.
SystemSettingAdditionalStyleName SystemSettingName = "additional-style"
// SystemSettingAdditionalScriptName is the name of additional script.
SystemSettingAdditionalScriptName SystemSettingName = "additional-script"
// SystemSettingCustomizedProfileName is the name of customized server profile.
SystemSettingCustomizedProfileName SystemSettingName = "customized-profile"
// SystemSettingStorageServiceIDName is the name of storage service ID.
SystemSettingStorageServiceIDName SystemSettingName = "storage-service-id"
// SystemSettingLocalStoragePathName is the name of local storage path.
SystemSettingLocalStoragePathName SystemSettingName = "local-storage-path"
// SystemSettingTelegramBotTokenName is the name of Telegram Bot Token.
SystemSettingTelegramBotTokenName SystemSettingName = "telegram-bot-token"
// SystemSettingMemoDisplayWithUpdatedTsName is the name of memo display with updated ts.
SystemSettingMemoDisplayWithUpdatedTsName SystemSettingName = "memo-display-with-updated-ts"
// SystemSettingAutoBackupIntervalName is the name of auto backup interval as seconds.
SystemSettingAutoBackupIntervalName SystemSettingName = "auto-backup-interval"
)
const systemSettingUnmarshalError = `failed to unmarshal value from system setting "%v"`
// CustomizedProfile is the struct definition for SystemSettingCustomizedProfileName system setting item.
type CustomizedProfile struct {
// Name is the server name, default is `memos`
Name string `json:"name"`
// LogoURL is the url of logo image.
LogoURL string `json:"logoUrl"`
// Description is the server description.
Description string `json:"description"`
// Locale is the server default locale.
Locale string `json:"locale"`
// Appearance is the server default appearance.
Appearance string `json:"appearance"`
// ExternalURL is the external url of server. e.g. https://usermemos.com
ExternalURL string `json:"externalUrl"`
}
func (key SystemSettingName) String() string {
return string(key)
}
type SystemSetting struct {
Name SystemSettingName `json:"name"`
// Value is a JSON string with basic value.
Value string `json:"value"`
Description string `json:"description"`
}
type UpsertSystemSettingRequest struct {
Name SystemSettingName `json:"name"`
Value string `json:"value"`
Description string `json:"description"`
}
func (s *APIV1Service) registerSystemSettingRoutes(g *echo.Group) {
g.GET("/system/setting", s.GetSystemSettingList)
g.POST("/system/setting", s.CreateSystemSetting)
}
// GetSystemSettingList godoc
//
// @Summary Get a list of system settings
// @Tags system-setting
// @Produce json
// @Success 200 {object} []SystemSetting "System setting list"
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
// @Failure 500 {object} nil "Failed to find user | Failed to find system setting list"
// @Router /api/v1/system/setting [GET]
func (s *APIV1Service) GetSystemSettingList(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if user == nil || user.Role != store.RoleHost {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
list, err := s.Store.ListSystemSettings(ctx, &store.FindSystemSetting{})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting list").SetInternal(err)
}
systemSettingList := make([]*SystemSetting, 0, len(list))
for _, systemSetting := range list {
systemSettingList = append(systemSettingList, convertSystemSettingFromStore(systemSetting))
}
return c.JSON(http.StatusOK, systemSettingList)
}
// CreateSystemSetting godoc
//
// @Summary Create system setting
// @Tags system-setting
// @Accept json
// @Produce json
// @Param body body UpsertSystemSettingRequest true "Request object."
// @Success 200 {object} store.SystemSetting "Created system setting"
// @Failure 400 {object} nil "Malformatted post system setting request | invalid system setting"
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
// @Failure 403 {object} nil "Cannot disable passwords if no SSO identity provider is configured."
// @Failure 500 {object} nil "Failed to find user | Failed to upsert system setting"
// @Router /api/v1/system/setting [POST]
func (s *APIV1Service) CreateSystemSetting(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if user == nil || user.Role != store.RoleHost {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
systemSettingUpsert := &UpsertSystemSettingRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(systemSettingUpsert); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post system setting request").SetInternal(err)
}
if err := systemSettingUpsert.Validate(); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid system setting").SetInternal(err)
}
if systemSettingUpsert.Name == SystemSettingDisablePasswordLoginName {
var disablePasswordLogin bool
if err := json.Unmarshal([]byte(systemSettingUpsert.Value), &disablePasswordLogin); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid system setting").SetInternal(err)
}
identityProviderList, err := s.Store.ListIdentityProviders(ctx, &store.FindIdentityProvider{})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert system setting").SetInternal(err)
}
if disablePasswordLogin && len(identityProviderList) == 0 {
return echo.NewHTTPError(http.StatusForbidden, "Cannot disable passwords if no SSO identity provider is configured.")
}
}
systemSetting, err := s.Store.UpsertSystemSetting(ctx, &store.SystemSetting{
Name: systemSettingUpsert.Name.String(),
Value: systemSettingUpsert.Value,
Description: systemSettingUpsert.Description,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert system setting").SetInternal(err)
}
return c.JSON(http.StatusOK, convertSystemSettingFromStore(systemSetting))
}
func (upsert UpsertSystemSettingRequest) Validate() error {
switch settingName := upsert.Name; settingName {
case SystemSettingServerIDName:
return errors.Errorf("updating %v is not allowed", settingName)
case SystemSettingAllowSignUpName:
var value bool
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return errors.Errorf(systemSettingUnmarshalError, settingName)
}
case SystemSettingDisablePasswordLoginName:
var value bool
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return errors.Errorf(systemSettingUnmarshalError, settingName)
}
case SystemSettingDisablePublicMemosName:
var value bool
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return errors.Errorf(systemSettingUnmarshalError, settingName)
}
case SystemSettingMaxUploadSizeMiBName:
var value int
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return errors.Errorf(systemSettingUnmarshalError, settingName)
}
case SystemSettingAdditionalStyleName:
var value string
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return errors.Errorf(systemSettingUnmarshalError, settingName)
}
case SystemSettingAdditionalScriptName:
var value string
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return errors.Errorf(systemSettingUnmarshalError, settingName)
}
case SystemSettingCustomizedProfileName:
customizedProfile := CustomizedProfile{
Name: "memos",
LogoURL: "",
Description: "",
Locale: "en",
Appearance: "system",
ExternalURL: "",
}
if err := json.Unmarshal([]byte(upsert.Value), &customizedProfile); err != nil {
return errors.Errorf(systemSettingUnmarshalError, settingName)
}
case SystemSettingStorageServiceIDName:
// Note: 0 is the default value(database) for storage service ID.
value := 0
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return errors.Errorf(systemSettingUnmarshalError, settingName)
}
return nil
case SystemSettingLocalStoragePathName:
value := ""
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return errors.Errorf(systemSettingUnmarshalError, settingName)
}
case SystemSettingAutoBackupIntervalName:
var value int
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return errors.Errorf(systemSettingUnmarshalError, settingName)
}
if value < 0 {
return errors.New("must be positive")
}
case SystemSettingTelegramBotTokenName:
if upsert.Value == "" {
return nil
}
// Bot Token with Reverse Proxy shoule like `http.../bot<token>`
if strings.HasPrefix(upsert.Value, "http") {
slashIndex := strings.LastIndexAny(upsert.Value, "/")
if strings.HasPrefix(upsert.Value[slashIndex:], "/bot") {
return nil
}
return errors.New("token start with `http` must end with `/bot<token>`")
}
fragments := strings.Split(upsert.Value, ":")
if len(fragments) != 2 {
return errors.Errorf(systemSettingUnmarshalError, settingName)
}
case SystemSettingMemoDisplayWithUpdatedTsName:
var value bool
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return errors.Errorf(systemSettingUnmarshalError, settingName)
}
default:
return errors.New("invalid system setting name")
}
return nil
}
func convertSystemSettingFromStore(systemSetting *store.SystemSetting) *SystemSetting {
return &SystemSetting{
Name: SystemSettingName(systemSetting.Name),
Value: systemSetting.Value,
Description: systemSetting.Description,
}
}

218
api/v1/tag.go Normal file
View File

@@ -0,0 +1,218 @@
package v1
import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"sort"
"github.com/labstack/echo/v4"
"golang.org/x/exp/slices"
"github.com/usememos/memos/store"
)
type Tag struct {
Name string
CreatorID int32
}
type UpsertTagRequest struct {
Name string `json:"name"`
}
type DeleteTagRequest struct {
Name string `json:"name"`
}
func (s *APIV1Service) registerTagRoutes(g *echo.Group) {
g.GET("/tag", s.GetTagList)
g.POST("/tag", s.CreateTag)
g.GET("/tag/suggestion", s.GetTagSuggestion)
g.POST("/tag/delete", s.DeleteTag)
}
// GetTagList godoc
//
// @Summary Get a list of tags
// @Tags tag
// @Produce json
// @Success 200 {object} []string "Tag list"
// @Failure 400 {object} nil "Missing user id to find tag"
// @Failure 500 {object} nil "Failed to find tag list"
// @Router /api/v1/tag [GET]
func (s *APIV1Service) GetTagList(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find tag")
}
list, err := s.Store.ListTags(ctx, &store.FindTag{
CreatorID: userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find tag list").SetInternal(err)
}
tagNameList := []string{}
for _, tag := range list {
tagNameList = append(tagNameList, tag.Name)
}
return c.JSON(http.StatusOK, tagNameList)
}
// CreateTag godoc
//
// @Summary Create a tag
// @Tags tag
// @Accept json
// @Produce json
// @Param body body UpsertTagRequest true "Request object."
// @Success 200 {object} string "Created tag name"
// @Failure 400 {object} nil "Malformatted post tag request | Tag name shouldn't be empty"
// @Failure 401 {object} nil "Missing user in session"
// @Failure 500 {object} nil "Failed to upsert tag | Failed to create activity"
// @Router /api/v1/tag [POST]
func (s *APIV1Service) CreateTag(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
tagUpsert := &UpsertTagRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(tagUpsert); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post tag request").SetInternal(err)
}
if tagUpsert.Name == "" {
return echo.NewHTTPError(http.StatusBadRequest, "Tag name shouldn't be empty")
}
tag, err := s.Store.UpsertTag(ctx, &store.Tag{
Name: tagUpsert.Name,
CreatorID: userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert tag").SetInternal(err)
}
tagMessage := convertTagFromStore(tag)
return c.JSON(http.StatusOK, tagMessage.Name)
}
// DeleteTag godoc
//
// @Summary Delete a tag
// @Tags tag
// @Accept json
// @Produce json
// @Param body body DeleteTagRequest true "Request object."
// @Success 200 {boolean} true "Tag deleted"
// @Failure 400 {object} nil "Malformatted post tag request | Tag name shouldn't be empty"
// @Failure 401 {object} nil "Missing user in session"
// @Failure 500 {object} nil "Failed to delete tag name: %v"
// @Router /api/v1/tag/delete [POST]
func (s *APIV1Service) DeleteTag(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
tagDelete := &DeleteTagRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(tagDelete); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post tag request").SetInternal(err)
}
if tagDelete.Name == "" {
return echo.NewHTTPError(http.StatusBadRequest, "Tag name shouldn't be empty")
}
err := s.Store.DeleteTag(ctx, &store.DeleteTag{
Name: tagDelete.Name,
CreatorID: userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete tag name: %v", tagDelete.Name)).SetInternal(err)
}
return c.JSON(http.StatusOK, true)
}
// GetTagSuggestion godoc
//
// @Summary Get a list of tags suggested from other memos contents
// @Tags tag
// @Produce json
// @Success 200 {object} []string "Tag list"
// @Failure 400 {object} nil "Missing user session"
// @Failure 500 {object} nil "Failed to find memo list | Failed to find tag list"
// @Router /api/v1/tag/suggestion [GET]
func (s *APIV1Service) GetTagSuggestion(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user session")
}
normalRowStatus := store.Normal
memoFind := &store.FindMemo{
CreatorID: &userID,
ContentSearch: []string{"#"},
RowStatus: &normalRowStatus,
}
memoMessageList, err := s.Store.ListMemos(ctx, memoFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
}
list, err := s.Store.ListTags(ctx, &store.FindTag{
CreatorID: userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find tag list").SetInternal(err)
}
tagNameList := []string{}
for _, tag := range list {
tagNameList = append(tagNameList, tag.Name)
}
tagMapSet := make(map[string]bool)
for _, memo := range memoMessageList {
for _, tag := range findTagListFromMemoContent(memo.Content) {
if !slices.Contains(tagNameList, tag) {
tagMapSet[tag] = true
}
}
}
tagList := []string{}
for tag := range tagMapSet {
tagList = append(tagList, tag)
}
sort.Strings(tagList)
return c.JSON(http.StatusOK, tagList)
}
func convertTagFromStore(tag *store.Tag) *Tag {
return &Tag{
Name: tag.Name,
CreatorID: tag.CreatorID,
}
}
var tagRegexp = regexp.MustCompile(`#([^\s#,]+)`)
func findTagListFromMemoContent(memoContent string) []string {
tagMapSet := make(map[string]bool)
matches := tagRegexp.FindAllStringSubmatch(memoContent, -1)
for _, v := range matches {
tagName := v[1]
tagMapSet[tagName] = true
}
tagList := []string{}
for tag := range tagMapSet {
tagList = append(tagList, tag)
}
sort.Strings(tagList)
return tagList
}

47
api/v1/tag_test.go Normal file
View File

@@ -0,0 +1,47 @@
package v1
import (
"testing"
)
func TestFindTagListFromMemoContent(t *testing.T) {
tests := []struct {
memoContent string
want []string
}{
{
memoContent: "#tag1 ",
want: []string{"tag1"},
},
{
memoContent: "#tag1 #tag2 ",
want: []string{"tag1", "tag2"},
},
{
memoContent: "#tag1 #tag2 \n#tag3 ",
want: []string{"tag1", "tag2", "tag3"},
},
{
memoContent: "#tag1 #tag2 \n#tag3 #tag4 ",
want: []string{"tag1", "tag2", "tag3", "tag4"},
},
{
memoContent: "#tag1 #tag2 \n#tag3 #tag4 ",
want: []string{"tag1", "tag2", "tag3", "tag4"},
},
{
memoContent: "#tag1 123123#tag2 \n#tag3 #tag4 ",
want: []string{"tag1", "tag2", "tag3", "tag4"},
},
{
memoContent: "#tag1 http://123123.com?123123#tag2 \n#tag3 #tag4 http://123123.com?123123#tag2) ",
want: []string{"tag1", "tag2", "tag2)", "tag3", "tag4"},
},
}
for _, test := range tests {
result := findTagListFromMemoContent(test.memoContent)
if len(result) != len(test.want) {
t.Errorf("Find tag list %s: got result %v, want %v.", test.memoContent, result, test.want)
}
}
}

503
api/v1/user.go Normal file
View File

@@ -0,0 +1,503 @@
package v1
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
"github.com/usememos/memos/internal/util"
"github.com/usememos/memos/server/service/metric"
"github.com/usememos/memos/store"
)
// Role is the type of a role.
type Role string
const (
// RoleHost is the HOST role.
RoleHost Role = "HOST"
// RoleAdmin is the ADMIN role.
RoleAdmin Role = "ADMIN"
// RoleUser is the USER role.
RoleUser Role = "USER"
)
func (role Role) String() string {
return string(role)
}
type User struct {
ID int32 `json:"id"`
// Standard fields
RowStatus RowStatus `json:"rowStatus"`
CreatedTs int64 `json:"createdTs"`
UpdatedTs int64 `json:"updatedTs"`
// Domain specific fields
Username string `json:"username"`
Role Role `json:"role"`
Email string `json:"email"`
Nickname string `json:"nickname"`
PasswordHash string `json:"-"`
AvatarURL string `json:"avatarUrl"`
UserSettingList []*UserSetting `json:"userSettingList"`
}
type CreateUserRequest struct {
Username string `json:"username"`
Role Role `json:"role"`
Email string `json:"email"`
Nickname string `json:"nickname"`
Password string `json:"password"`
}
type UpdateUserRequest struct {
RowStatus *RowStatus `json:"rowStatus"`
Username *string `json:"username"`
Email *string `json:"email"`
Nickname *string `json:"nickname"`
Password *string `json:"password"`
AvatarURL *string `json:"avatarUrl"`
}
func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
g.GET("/user", s.GetUserList)
g.POST("/user", s.CreateUser)
g.GET("/user/me", s.GetCurrentUser)
// NOTE: This should be moved to /api/v2/user/:username
g.GET("/user/name/:username", s.GetUserByUsername)
g.GET("/user/:id", s.GetUserByID)
g.PATCH("/user/:id", s.UpdateUser)
g.DELETE("/user/:id", s.DeleteUser)
}
// GetUserList godoc
//
// @Summary Get a list of users
// @Tags user
// @Produce json
// @Success 200 {object} []store.User "User list"
// @Failure 500 {object} nil "Failed to fetch user list"
// @Router /api/v1/user [GET]
func (s *APIV1Service) GetUserList(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
}
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by id").SetInternal(err)
}
if currentUser == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
}
if currentUser.Role != store.RoleHost && currentUser.Role != store.RoleAdmin {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized to list users")
}
list, err := s.Store.ListUsers(ctx, &store.FindUser{})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch user list").SetInternal(err)
}
userMessageList := make([]*User, 0, len(list))
for _, user := range list {
userMessage := convertUserFromStore(user)
// data desensitize
userMessage.Email = ""
userMessageList = append(userMessageList, userMessage)
}
return c.JSON(http.StatusOK, userMessageList)
}
// CreateUser godoc
//
// @Summary Create a user
// @Tags user
// @Accept json
// @Produce json
// @Param body body CreateUserRequest true "Request object"
// @Success 200 {object} store.User "Created user"
// @Failure 400 {object} nil "Malformatted post user request | Invalid user create format"
// @Failure 401 {object} nil "Missing auth session | Unauthorized to create user"
// @Failure 403 {object} nil "Could not create host user"
// @Failure 500 {object} nil "Failed to find user by id | Failed to generate password hash | Failed to create user | Failed to create activity"
// @Router /api/v1/user [POST]
func (s *APIV1Service) CreateUser(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
}
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by id").SetInternal(err)
}
if currentUser == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
}
if currentUser.Role != store.RoleHost {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized to create user")
}
userCreate := &CreateUserRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(userCreate); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user request").SetInternal(err)
}
if err := userCreate.Validate(); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format").SetInternal(err)
}
if !usernameMatcher.MatchString(strings.ToLower(userCreate.Username)) {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid username %s", userCreate.Username)).SetInternal(err)
}
// Disallow host user to be created.
if userCreate.Role == RoleHost {
return echo.NewHTTPError(http.StatusForbidden, "Could not create host user")
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(userCreate.Password), bcrypt.DefaultCost)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
}
user, err := s.Store.CreateUser(ctx, &store.User{
Username: userCreate.Username,
Role: store.Role(userCreate.Role),
Email: userCreate.Email,
Nickname: userCreate.Nickname,
PasswordHash: string(passwordHash),
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
}
userMessage := convertUserFromStore(user)
metric.Enqueue("user create")
return c.JSON(http.StatusOK, userMessage)
}
// GetCurrentUser godoc
//
// @Summary Get current user
// @Tags user
// @Produce json
// @Success 200 {object} store.User "Current user"
// @Failure 401 {object} nil "Missing auth session"
// @Failure 500 {object} nil "Failed to find user | Failed to find userSettingList"
// @Router /api/v1/user/me [GET]
func (s *APIV1Service) GetCurrentUser(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
}
user, err := s.Store.GetUser(ctx, &store.FindUser{ID: &userID})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if user == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
}
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)
}
// GetUserByUsername godoc
//
// @Summary Get user by username
// @Tags user
// @Produce json
// @Param username path string true "Username"
// @Success 200 {object} store.User "Requested user"
// @Failure 404 {object} nil "User not found"
// @Failure 500 {object} nil "Failed to find user"
// @Router /api/v1/user/name/{username} [GET]
func (s *APIV1Service) GetUserByUsername(c echo.Context) error {
ctx := c.Request().Context()
username := c.Param("username")
user, err := s.Store.GetUser(ctx, &store.FindUser{Username: &username})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if user == nil {
return echo.NewHTTPError(http.StatusNotFound, "User not found")
}
userMessage := convertUserFromStore(user)
// data desensitize
userMessage.Email = ""
return c.JSON(http.StatusOK, userMessage)
}
// GetUserByID godoc
//
// @Summary Get user by id
// @Tags user
// @Produce json
// @Param id path int true "User ID"
// @Success 200 {object} store.User "Requested user"
// @Failure 400 {object} nil "Malformatted user id"
// @Failure 404 {object} nil "User not found"
// @Failure 500 {object} nil "Failed to find user"
// @Router /api/v1/user/{id} [GET]
func (s *APIV1Service) GetUserByID(c echo.Context) error {
ctx := c.Request().Context()
id, err := util.ConvertStringToInt32(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted user id").SetInternal(err)
}
user, err := s.Store.GetUser(ctx, &store.FindUser{ID: &id})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if user == nil {
return echo.NewHTTPError(http.StatusNotFound, "User not found")
}
userMessage := convertUserFromStore(user)
// data desensitize
userMessage.Email = ""
return c.JSON(http.StatusOK, userMessage)
}
// DeleteUser godoc
//
// @Summary Delete a user
// @Tags user
// @Produce json
// @Param id path string true "User ID"
// @Success 200 {boolean} true "User deleted"
// @Failure 400 {object} nil "ID is not a number: %s | Current session user not found with ID: %d"
// @Failure 401 {object} nil "Missing user in session"
// @Failure 403 {object} nil "Unauthorized to delete user"
// @Failure 500 {object} nil "Failed to find user | Failed to delete user"
// @Router /api/v1/user/{id} [DELETE]
func (s *APIV1Service) DeleteUser(c echo.Context) error {
ctx := c.Request().Context()
currentUserID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &currentUserID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if currentUser == nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Current session user not found with ID: %d", currentUserID)).SetInternal(err)
} else if currentUser.Role != store.RoleHost {
return echo.NewHTTPError(http.StatusForbidden, "Unauthorized to delete user").SetInternal(err)
}
userID, err := util.ConvertStringToInt32(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("id"))).SetInternal(err)
}
userDelete := &store.DeleteUser{
ID: userID,
}
if err := s.Store.DeleteUser(ctx, userDelete); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete user").SetInternal(err)
}
return c.JSON(http.StatusOK, true)
}
// UpdateUser godoc
//
// @Summary Update a user
// @Tags user
// @Produce json
// @Param id path string true "User ID"
// @Param patch body UpdateUserRequest true "Patch request"
// @Success 200 {object} store.User "Updated user"
// @Failure 400 {object} nil "ID is not a number: %s | Current session user not found with ID: %d | Malformatted patch user request | Invalid update user request"
// @Failure 401 {object} nil "Missing user in session"
// @Failure 403 {object} nil "Unauthorized to update user"
// @Failure 500 {object} nil "Failed to find user | Failed to generate password hash | Failed to patch user | Failed to find userSettingList"
// @Router /api/v1/user/{id} [PATCH]
func (s *APIV1Service) UpdateUser(c echo.Context) error {
ctx := c.Request().Context()
userID, err := util.ConvertStringToInt32(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("id"))).SetInternal(err)
}
currentUserID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{ID: &currentUserID})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if currentUser == nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Current session user not found with ID: %d", currentUserID)).SetInternal(err)
} else if currentUser.Role != store.RoleHost && currentUserID != userID {
return echo.NewHTTPError(http.StatusForbidden, "Unauthorized to update user").SetInternal(err)
}
request := &UpdateUserRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch user request").SetInternal(err)
}
if err := request.Validate(); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid update user request").SetInternal(err)
}
currentTs := time.Now().Unix()
userUpdate := &store.UpdateUser{
ID: userID,
UpdatedTs: &currentTs,
}
if request.RowStatus != nil {
rowStatus := store.RowStatus(request.RowStatus.String())
userUpdate.RowStatus = &rowStatus
}
if request.Username != nil {
if !usernameMatcher.MatchString(strings.ToLower(*request.Username)) {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid username %s", *request.Username)).SetInternal(err)
}
userUpdate.Username = request.Username
}
if request.Email != nil {
userUpdate.Email = request.Email
}
if request.Nickname != nil {
userUpdate.Nickname = request.Nickname
}
if request.Password != nil {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(*request.Password), bcrypt.DefaultCost)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
}
passwordHashStr := string(passwordHash)
userUpdate.PasswordHash = &passwordHashStr
}
if request.AvatarURL != nil {
userUpdate.AvatarURL = request.AvatarURL
}
user, err := s.Store.UpdateUser(ctx, userUpdate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch user").SetInternal(err)
}
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)
}
func (create CreateUserRequest) Validate() error {
if len(create.Username) < 3 {
return errors.New("username is too short, minimum length is 3")
}
if len(create.Username) > 32 {
return errors.New("username is too long, maximum length is 32")
}
if len(create.Password) < 3 {
return errors.New("password is too short, minimum length is 3")
}
if len(create.Password) > 512 {
return errors.New("password is too long, maximum length is 512")
}
if len(create.Nickname) > 64 {
return errors.New("nickname is too long, maximum length is 64")
}
if create.Email != "" {
if len(create.Email) > 256 {
return errors.New("email is too long, maximum length is 256")
}
if !util.ValidateEmail(create.Email) {
return errors.New("invalid email format")
}
}
return nil
}
func (update UpdateUserRequest) Validate() error {
if update.Username != nil && len(*update.Username) < 3 {
return errors.New("username is too short, minimum length is 3")
}
if update.Username != nil && len(*update.Username) > 32 {
return errors.New("username is too long, maximum length is 32")
}
if update.Password != nil && len(*update.Password) < 3 {
return errors.New("password is too short, minimum length is 3")
}
if update.Password != nil && len(*update.Password) > 512 {
return errors.New("password is too long, maximum length is 512")
}
if update.Nickname != nil && len(*update.Nickname) > 64 {
return errors.New("nickname is too long, maximum length is 64")
}
if update.AvatarURL != nil {
if len(*update.AvatarURL) > 2<<20 {
return errors.New("avatar is too large, maximum is 2MB")
}
}
if update.Email != nil && *update.Email != "" {
if len(*update.Email) > 256 {
return errors.New("email is too long, maximum length is 256")
}
if !util.ValidateEmail(*update.Email) {
return errors.New("invalid email format")
}
}
return nil
}
func convertUserFromStore(user *store.User) *User {
return &User{
ID: user.ID,
RowStatus: RowStatus(user.RowStatus),
CreatedTs: user.CreatedTs,
UpdatedTs: user.UpdatedTs,
Username: user.Username,
Role: Role(user.Role),
Email: user.Email,
Nickname: user.Nickname,
PasswordHash: user.PasswordHash,
AvatarURL: user.AvatarURL,
}
}

174
api/v1/user_setting.go Normal file
View File

@@ -0,0 +1,174 @@
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{
"ar",
"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,
}
}

95
api/v1/v1.go Normal file
View File

@@ -0,0 +1,95 @@
package v1
import (
"net/http"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/usememos/memos/api/resource"
"github.com/usememos/memos/plugin/telegram"
"github.com/usememos/memos/server/profile"
"github.com/usememos/memos/store"
)
type APIV1Service struct {
Secret string
Profile *profile.Profile
Store *store.Store
telegramBot *telegram.Bot
}
// @title memos API
// @version 1.0
// @description A privacy-first, lightweight note-taking service.
//
// @contact.name API Support
// @contact.url https://github.com/orgs/usememos/discussions
//
// @license.name MIT License
// @license.url https://github.com/usememos/memos/blob/main/LICENSE
//
// @BasePath /
//
// @externalDocs.url https://usememos.com/
// @externalDocs.description Find out more about Memos.
func NewAPIV1Service(secret string, profile *profile.Profile, store *store.Store, telegramBot *telegram.Bot) *APIV1Service {
return &APIV1Service{
Secret: secret,
Profile: profile,
Store: store,
telegramBot: telegramBot,
}
}
func (s *APIV1Service) Register(rootGroup *echo.Group) {
// Register RSS routes.
s.registerRSSRoutes(rootGroup)
// Register API v1 routes.
apiV1Group := rootGroup.Group("/api/v1")
apiV1Group.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
Store: middleware.NewRateLimiterMemoryStoreWithConfig(
middleware.RateLimiterMemoryStoreConfig{Rate: 30, Burst: 100, ExpiresIn: 3 * time.Minute},
),
IdentifierExtractor: func(ctx echo.Context) (string, error) {
id := ctx.RealIP()
return id, nil
},
ErrorHandler: func(context echo.Context, err error) error {
return context.JSON(http.StatusForbidden, nil)
},
DenyHandler: func(context echo.Context, identifier string, err error) error {
return context.JSON(http.StatusTooManyRequests, nil)
},
}))
apiV1Group.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return JWTMiddleware(s, next, s.Secret)
})
s.registerSystemRoutes(apiV1Group)
s.registerSystemSettingRoutes(apiV1Group)
s.registerAuthRoutes(apiV1Group)
s.registerIdentityProviderRoutes(apiV1Group)
s.registerUserRoutes(apiV1Group)
s.registerUserSettingRoutes(apiV1Group)
s.registerTagRoutes(apiV1Group)
s.registerStorageRoutes(apiV1Group)
s.registerResourceRoutes(apiV1Group)
s.registerMemoRoutes(apiV1Group)
s.registerMemoOrganizerRoutes(apiV1Group)
s.registerMemoRelationRoutes(apiV1Group)
// Register public routes.
publicGroup := rootGroup.Group("/o")
publicGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return JWTMiddleware(s, next, s.Secret)
})
s.registerGetterPublicRoutes(publicGroup)
// Create and register resource public routes.
resourceService := resource.NewService(s.Profile, s.Store)
resourceService.RegisterResourcePublicRoutes(publicGroup)
// programmatically set API version same as the server version
SwaggerInfo.Version = s.Profile.Version
}

159
api/v2/acl.go Normal file
View File

@@ -0,0 +1,159 @@
package v2
import (
"context"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v4"
"github.com/pkg/errors"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"github.com/usememos/memos/api/auth"
"github.com/usememos/memos/internal/util"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
// ContextKey is the key type of context value.
type ContextKey int
const (
// The key name used to store username in the context
// user id is extracted from the jwt token subject field.
usernameContextKey ContextKey = iota
)
// GRPCAuthInterceptor is the auth interceptor for gRPC server.
type GRPCAuthInterceptor struct {
Store *store.Store
secret string
}
// NewGRPCAuthInterceptor returns a new API auth interceptor.
func NewGRPCAuthInterceptor(store *store.Store, secret string) *GRPCAuthInterceptor {
return &GRPCAuthInterceptor{
Store: store,
secret: secret,
}
}
// AuthenticationInterceptor is the unary interceptor for gRPC API.
func (in *GRPCAuthInterceptor) AuthenticationInterceptor(ctx context.Context, request any, serverInfo *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Errorf(codes.Unauthenticated, "failed to parse metadata from incoming context")
}
accessToken, err := getTokenFromMetadata(md)
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, err.Error())
}
username, err := in.authenticate(ctx, accessToken)
if err != nil {
if isUnauthorizeAllowedMethod(serverInfo.FullMethod) {
return handler(ctx, request)
}
return nil, err
}
user, err := in.Store.GetUser(ctx, &store.FindUser{
Username: &username,
})
if err != nil {
return nil, errors.Wrap(err, "failed to get user")
}
if user == nil {
return nil, errors.Errorf("user %q not exists", username)
}
if isOnlyForAdminAllowedMethod(serverInfo.FullMethod) && user.Role != store.RoleHost && user.Role != store.RoleAdmin {
return nil, errors.Errorf("user %q is not admin", username)
}
// Stores userID into context.
childCtx := context.WithValue(ctx, usernameContextKey, username)
return handler(childCtx, request)
}
func (in *GRPCAuthInterceptor) authenticate(ctx context.Context, accessToken string) (string, error) {
if accessToken == "" {
return "", status.Errorf(codes.Unauthenticated, "access token not found")
}
claims := &auth.ClaimsMessage{}
_, err := jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (any, error) {
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
return nil, status.Errorf(codes.Unauthenticated, "unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
}
if kid, ok := t.Header["kid"].(string); ok {
if kid == "v1" {
return []byte(in.secret), nil
}
}
return nil, status.Errorf(codes.Unauthenticated, "unexpected access token kid=%v", t.Header["kid"])
})
if err != nil {
return "", status.Errorf(codes.Unauthenticated, "Invalid or expired access token")
}
// We either have a valid access token or we will attempt to generate new access token.
userID, err := util.ConvertStringToInt32(claims.Subject)
if err != nil {
return "", errors.Wrap(err, "malformed ID in the token")
}
user, err := in.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return "", errors.Wrap(err, "failed to get user")
}
if user == nil {
return "", errors.Errorf("user %q not exists", userID)
}
if user.RowStatus == store.Archived {
return "", errors.Errorf("user %q is archived", userID)
}
accessTokens, err := in.Store.GetUserAccessTokens(ctx, user.ID)
if err != nil {
return "", errors.Wrapf(err, "failed to get user access tokens")
}
if !validateAccessToken(accessToken, accessTokens) {
return "", status.Errorf(codes.Unauthenticated, "invalid access token")
}
return user.Username, nil
}
func getTokenFromMetadata(md metadata.MD) (string, error) {
// Check the HTTP request header first.
authorizationHeaders := md.Get("Authorization")
if len(md.Get("Authorization")) > 0 {
authHeaderParts := strings.Fields(authorizationHeaders[0])
if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" {
return "", errors.New("authorization header format must be Bearer {token}")
}
return authHeaderParts[1], nil
}
// Check the cookie header.
var accessToken string
for _, t := range append(md.Get("grpcgateway-cookie"), md.Get("cookie")...) {
header := http.Header{}
header.Add("Cookie", t)
request := http.Request{Header: header}
if v, _ := request.Cookie(auth.AccessTokenCookieName); v != nil {
accessToken = v.Value
}
}
return accessToken, nil
}
func validateAccessToken(accessTokenString string, userAccessTokens []*storepb.AccessTokensUserSetting_AccessToken) bool {
for _, userAccessToken := range userAccessTokens {
if accessTokenString == userAccessToken.AccessToken {
return true
}
}
return false
}

26
api/v2/acl_config.go Normal file
View File

@@ -0,0 +1,26 @@
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,
}
// isUnauthorizeAllowedMethod returns whether the method is exempted from authentication.
func isUnauthorizeAllowedMethod(fullMethodName string) bool {
if strings.HasPrefix(fullMethodName, "/grpc.reflection") {
return true
}
return authenticationAllowlistMethods[fullMethodName]
}
var allowedMethodsOnlyForAdmin = map[string]bool{
"/memos.api.v2.UserService/CreateUser": true,
}
// isOnlyForAdminAllowedMethod returns true if the method is allowed to be called only by admin.
func isOnlyForAdminAllowedMethod(methodName string) bool {
return allowedMethodsOnlyForAdmin[methodName]
}

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
}

44
api/v2/common.go Normal file
View File

@@ -0,0 +1,44 @@
package v2
import (
"context"
apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
"github.com/usememos/memos/store"
)
func convertRowStatusFromStore(rowStatus store.RowStatus) apiv2pb.RowStatus {
switch rowStatus {
case store.Normal:
return apiv2pb.RowStatus_ACTIVE
case store.Archived:
return apiv2pb.RowStatus_ARCHIVED
default:
return apiv2pb.RowStatus_ROW_STATUS_UNSPECIFIED
}
}
func convertRowStatusToStore(rowStatus apiv2pb.RowStatus) store.RowStatus {
switch rowStatus {
case apiv2pb.RowStatus_ACTIVE:
return store.Normal
case apiv2pb.RowStatus_ARCHIVED:
return store.Archived
default:
return store.Normal
}
}
func getCurrentUser(ctx context.Context, s *store.Store) (*store.User, error) {
username, ok := ctx.Value(usernameContextKey).(string)
if !ok {
return nil, nil
}
user, err := s.GetUser(ctx, &store.FindUser{
Username: &username,
})
if err != nil {
return nil, err
}
return user, nil
}

138
api/v2/inbox_service .go Normal file
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 _, path := range request.UpdateMask.Paths {
if path == "status" {
if request.Inbox.Status == apiv2pb.Inbox_STATUS_UNSPECIFIED {
return nil, status.Errorf(codes.InvalidArgument, "status is required")
}
update.Status = convertInboxStatusToStore(request.Inbox.Status)
}
}
inbox, err := s.Store.UpdateInbox(ctx, update)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to update inbox: %v", err)
}
inboxMessage, err := s.convertInboxFromStore(ctx, inbox)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to convert inbox from store: %v", err)
}
return &apiv2pb.UpdateInboxResponse{
Inbox: inboxMessage,
}, nil
}
func (s *APIV2Service) DeleteInbox(ctx context.Context, request *apiv2pb.DeleteInboxRequest) (*apiv2pb.DeleteInboxResponse, error) {
inboxID, err := ExtractInboxIDFromName(request.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid inbox name: %v", err)
}
if err := s.Store.DeleteInbox(ctx, &store.DeleteInbox{
ID: inboxID,
}); err != nil {
return nil, status.Errorf(codes.Internal, "failed to update inbox: %v", err)
}
return &apiv2pb.DeleteInboxResponse{}, nil
}
func (s *APIV2Service) convertInboxFromStore(ctx context.Context, inbox *store.Inbox) (*apiv2pb.Inbox, error) {
sender, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &inbox.SenderID,
})
if err != nil {
return nil, errors.Wrap(err, "failed to get sender")
}
receiver, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &inbox.ReceiverID,
})
if err != nil {
return nil, errors.Wrap(err, "failed to get receiver")
}
return &apiv2pb.Inbox{
Name: fmt.Sprintf("inboxes/%d", inbox.ID),
Sender: fmt.Sprintf("users/%s", sender.Username),
Receiver: fmt.Sprintf("users/%s", receiver.Username),
Status: convertInboxStatusFromStore(inbox.Status),
CreateTime: timestamppb.New(time.Unix(inbox.CreatedTs, 0)),
Type: apiv2pb.Inbox_Type(inbox.Message.Type),
ActivityId: inbox.Message.ActivityId,
}, nil
}
func convertInboxStatusFromStore(status store.InboxStatus) apiv2pb.Inbox_Status {
switch status {
case store.UNREAD:
return apiv2pb.Inbox_UNREAD
case store.ARCHIVED:
return apiv2pb.Inbox_ARCHIVED
default:
return apiv2pb.Inbox_STATUS_UNSPECIFIED
}
}
func convertInboxStatusToStore(status apiv2pb.Inbox_Status) store.InboxStatus {
switch status {
case apiv2pb.Inbox_UNREAD:
return store.UNREAD
case apiv2pb.Inbox_ARCHIVED:
return store.ARCHIVED
default:
return store.UNREAD
}
}

266
api/v2/memo_service.go Normal file
View File

@@ -0,0 +1,266 @@
package v2
import (
"context"
"github.com/google/cel-go/cel"
"github.com/pkg/errors"
v1alpha1 "google.golang.org/genproto/googleapis/api/expr/v1alpha1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
"github.com/usememos/memos/store"
)
func (s *APIV2Service) CreateMemo(ctx context.Context, request *apiv2pb.CreateMemoRequest) (*apiv2pb.CreateMemoResponse, error) {
user, err := getCurrentUser(ctx, s.Store)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user")
}
if user == nil {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
create := &store.Memo{
CreatorID: user.ID,
Content: request.Content,
Visibility: store.Visibility(request.Visibility.String()),
}
memo, err := s.Store.CreateMemo(ctx, create)
if err != nil {
return nil, err
}
response := &apiv2pb.CreateMemoResponse{
Memo: convertMemoFromStore(memo),
}
return response, nil
}
func (s *APIV2Service) ListMemos(ctx context.Context, request *apiv2pb.ListMemosRequest) (*apiv2pb.ListMemosResponse, error) {
memoFind := &store.FindMemo{}
if request.Filter != "" {
filter, err := parseListMemosFilter(request.Filter)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid filter: %v", err)
}
if filter.Visibility != nil {
memoFind.VisibilityList = []store.Visibility{*filter.Visibility}
}
if filter.CreatedTsBefore != nil {
memoFind.CreatedTsBefore = filter.CreatedTsBefore
}
if filter.CreatedTsAfter != nil {
memoFind.CreatedTsAfter = filter.CreatedTsAfter
}
}
user, _ := getCurrentUser(ctx, s.Store)
// If the user is not authenticated, only public memos are visible.
if user == nil {
memoFind.VisibilityList = []store.Visibility{store.Public}
}
if request.CreatorId != nil {
memoFind.CreatorID = request.CreatorId
}
// Remove the private memos from the list if the user is not the creator.
if user != nil && request.CreatorId != nil && *request.CreatorId != user.ID {
var filteredVisibility []store.Visibility
for _, v := range memoFind.VisibilityList {
if v != store.Private {
filteredVisibility = append(filteredVisibility, v)
}
}
memoFind.VisibilityList = filteredVisibility
}
if request.PageSize != 0 {
offset := int(request.Page * request.PageSize)
limit := int(request.PageSize)
memoFind.Offset = &offset
memoFind.Limit = &limit
}
memos, err := s.Store.ListMemos(ctx, memoFind)
if err != nil {
return nil, err
}
memoMessages := make([]*apiv2pb.Memo, len(memos))
for i, memo := range memos {
memoMessages[i] = convertMemoFromStore(memo)
}
response := &apiv2pb.ListMemosResponse{
Memos: memoMessages,
}
return response, nil
}
func (s *APIV2Service) GetMemo(ctx context.Context, request *apiv2pb.GetMemoRequest) (*apiv2pb.GetMemoResponse, error) {
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
ID: &request.Id,
})
if err != nil {
return nil, err
}
if memo == nil {
return nil, status.Errorf(codes.NotFound, "memo not found")
}
if memo.Visibility != store.Public {
user, err := getCurrentUser(ctx, s.Store)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user")
}
if user == nil {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
if memo.Visibility == store.Private && memo.CreatorID != user.ID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
}
response := &apiv2pb.GetMemoResponse{
Memo: convertMemoFromStore(memo),
}
return response, nil
}
func (s *APIV2Service) CreateMemoComment(ctx context.Context, request *apiv2pb.CreateMemoCommentRequest) (*apiv2pb.CreateMemoCommentResponse, error) {
// Create the comment memo first.
createMemoResponse, err := s.CreateMemo(ctx, request.Create)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to create memo")
}
// Build the relation between the comment memo and the original memo.
memo := createMemoResponse.Memo
_, err = s.Store.UpsertMemoRelation(ctx, &store.MemoRelation{
MemoID: memo.Id,
RelatedMemoID: request.Id,
Type: store.MemoRelationComment,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to create memo relation")
}
response := &apiv2pb.CreateMemoCommentResponse{
Memo: memo,
}
return response, nil
}
func (s *APIV2Service) ListMemoComments(ctx context.Context, request *apiv2pb.ListMemoCommentsRequest) (*apiv2pb.ListMemoCommentsResponse, error) {
memoRelationComment := store.MemoRelationComment
memoRelations, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{
RelatedMemoID: &request.Id,
Type: &memoRelationComment,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list memo relations")
}
var memos []*apiv2pb.Memo
for _, memoRelation := range memoRelations {
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
ID: &memoRelation.MemoID,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get memo")
}
if memo != nil {
memos = append(memos, convertMemoFromStore(memo))
}
}
response := &apiv2pb.ListMemoCommentsResponse{
Memos: memos,
}
return response, nil
}
// ListMemosFilterCELAttributes are the CEL attributes for ListMemosFilter.
var ListMemosFilterCELAttributes = []cel.EnvOption{
cel.Variable("visibility", cel.StringType),
cel.Variable("created_ts_before", cel.IntType),
cel.Variable("created_ts_after", cel.IntType),
}
type ListMemosFilter struct {
Visibility *store.Visibility
CreatedTsBefore *int64
CreatedTsAfter *int64
}
func parseListMemosFilter(expression string) (*ListMemosFilter, error) {
e, err := cel.NewEnv(ListMemosFilterCELAttributes...)
if err != nil {
return nil, err
}
ast, issues := e.Compile(expression)
if issues != nil {
return nil, errors.Errorf("found issue %v", issues)
}
filter := &ListMemosFilter{}
expr, err := cel.AstToParsedExpr(ast)
if err != nil {
return nil, err
}
callExpr := expr.GetExpr().GetCallExpr()
findField(callExpr, filter)
return filter, nil
}
func findField(callExpr *v1alpha1.Expr_Call, filter *ListMemosFilter) {
if len(callExpr.Args) == 2 {
idExpr := callExpr.Args[0].GetIdentExpr()
if idExpr != nil {
if idExpr.Name == "visibility" {
visibility := store.Visibility(callExpr.Args[1].GetConstExpr().GetStringValue())
filter.Visibility = &visibility
}
if idExpr.Name == "created_ts_before" {
createdTsBefore := callExpr.Args[1].GetConstExpr().GetInt64Value()
filter.CreatedTsBefore = &createdTsBefore
}
if idExpr.Name == "created_ts_after" {
createdTsAfter := callExpr.Args[1].GetConstExpr().GetInt64Value()
filter.CreatedTsAfter = &createdTsAfter
}
return
}
}
for _, arg := range callExpr.Args {
callExpr := arg.GetCallExpr()
if callExpr != nil {
findField(callExpr, filter)
}
}
}
func convertMemoFromStore(memo *store.Memo) *apiv2pb.Memo {
return &apiv2pb.Memo{
Id: int32(memo.ID),
RowStatus: convertRowStatusFromStore(memo.RowStatus),
CreatedTs: memo.CreatedTs,
UpdatedTs: memo.UpdatedTs,
CreatorId: int32(memo.CreatorID),
Content: memo.Content,
Visibility: convertVisibilityFromStore(memo.Visibility),
Pinned: memo.Pinned,
}
}
func convertVisibilityFromStore(visibility store.Visibility) apiv2pb.Visibility {
switch visibility {
case store.Private:
return apiv2pb.Visibility_PRIVATE
case store.Protected:
return apiv2pb.Visibility_PROTECTED
case store.Public:
return apiv2pb.Visibility_PUBLIC
default:
return apiv2pb.Visibility_VISIBILITY_UNSPECIFIED
}
}

57
api/v2/resource_name.go Normal file
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
}

105
api/v2/resource_service.go Normal file
View File

@@ -0,0 +1,105 @@
package v2
import (
"context"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
"github.com/usememos/memos/store"
)
func (s *APIV2Service) ListResources(ctx context.Context, _ *apiv2pb.ListResourcesRequest) (*apiv2pb.ListResourcesResponse, error) {
user, err := getCurrentUser(ctx, s.Store)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
resources, err := s.Store.ListResources(ctx, &store.FindResource{
CreatorID: &user.ID,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list resources: %v", err)
}
response := &apiv2pb.ListResourcesResponse{}
for _, resource := range resources {
response.Resources = append(response.Resources, s.convertResourceFromStore(ctx, resource))
}
return response, nil
}
func (s *APIV2Service) UpdateResource(ctx context.Context, request *apiv2pb.UpdateResourceRequest) (*apiv2pb.UpdateResourceResponse, error) {
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
return nil, status.Errorf(codes.InvalidArgument, "update mask is required")
}
currentTs := time.Now().Unix()
update := &store.UpdateResource{
ID: request.Resource.Id,
UpdatedTs: &currentTs,
}
for _, field := range request.UpdateMask.Paths {
if field == "filename" {
update.Filename = &request.Resource.Filename
} else if field == "memo_id" {
update.MemoID = request.Resource.MemoId
}
}
resource, err := s.Store.UpdateResource(ctx, update)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to update resource: %v", err)
}
return &apiv2pb.UpdateResourceResponse{
Resource: s.convertResourceFromStore(ctx, resource),
}, nil
}
func (s *APIV2Service) DeleteResource(ctx context.Context, request *apiv2pb.DeleteResourceRequest) (*apiv2pb.DeleteResourceResponse, error) {
user, err := getCurrentUser(ctx, s.Store)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
resource, err := s.Store.GetResource(ctx, &store.FindResource{
ID: &request.Id,
CreatorID: &user.ID,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to find resource: %v", err)
}
if resource == nil {
return nil, status.Errorf(codes.NotFound, "resource not found")
}
// Delete the resource from the database.
if err := s.Store.DeleteResource(ctx, &store.DeleteResource{
ID: resource.ID,
}); err != nil {
return nil, status.Errorf(codes.Internal, "failed to delete resource: %v", err)
}
return &apiv2pb.DeleteResourceResponse{}, nil
}
func (s *APIV2Service) convertResourceFromStore(ctx context.Context, resource *store.Resource) *apiv2pb.Resource {
var memoID *int32
if resource.MemoID != nil {
memo, _ := s.Store.GetMemo(ctx, &store.FindMemo{
ID: resource.MemoID,
})
if memo != nil {
memoID = &memo.ID
}
}
return &apiv2pb.Resource{
Id: resource.ID,
CreatedTs: timestamppb.New(time.Unix(resource.CreatedTs, 0)),
Filename: resource.Filename,
ExternalLink: resource.ExternalLink,
Type: resource.Type,
Size: resource.Size,
MemoId: memoID,
}
}

92
api/v2/system_service.go Normal file
View File

@@ -0,0 +1,92 @@
package v2
import (
"context"
"strconv"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
"github.com/usememos/memos/store"
)
func (s *APIV2Service) GetSystemInfo(ctx context.Context, _ *apiv2pb.GetSystemInfoRequest) (*apiv2pb.GetSystemInfoResponse, error) {
defaultSystemInfo := &apiv2pb.SystemInfo{}
// Get the database size if the user is a host.
currentUser, err := getCurrentUser(ctx, s.Store)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if currentUser != nil && currentUser.Role == store.RoleHost {
size, err := s.Store.GetCurrentDBSize(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get db size: %v", err)
}
defaultSystemInfo.DbSize = size
}
response := &apiv2pb.GetSystemInfoResponse{
SystemInfo: defaultSystemInfo,
}
return response, nil
}
func (s *APIV2Service) UpdateSystemInfo(ctx context.Context, request *apiv2pb.UpdateSystemInfoRequest) (*apiv2pb.UpdateSystemInfoResponse, error) {
user, err := getCurrentUser(ctx, s.Store)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if user.Role != store.RoleHost {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
return nil, status.Errorf(codes.InvalidArgument, "update mask is required")
}
// Update system settings.
for _, path := range request.UpdateMask.Paths {
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
}

105
api/v2/tag_service.go Normal file
View File

@@ -0,0 +1,105 @@
package v2
import (
"context"
"fmt"
"github.com/pkg/errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
"github.com/usememos/memos/store"
)
func (s *APIV2Service) UpsertTag(ctx context.Context, request *apiv2pb.UpsertTagRequest) (*apiv2pb.UpsertTagResponse, error) {
user, err := getCurrentUser(ctx, s.Store)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user")
}
tag, err := s.Store.UpsertTag(ctx, &store.Tag{
Name: request.Name,
CreatorID: user.ID,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to upsert tag: %v", err)
}
t, err := s.convertTagFromStore(ctx, tag)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to convert tag: %v", err)
}
return &apiv2pb.UpsertTagResponse{
Tag: t,
}, nil
}
func (s *APIV2Service) ListTags(ctx context.Context, request *apiv2pb.ListTagsRequest) (*apiv2pb.ListTagsResponse, error) {
username, err := ExtractUsernameFromName(request.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")
}
tags, err := s.Store.ListTags(ctx, &store.FindTag{
CreatorID: user.ID,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list tags: %v", err)
}
response := &apiv2pb.ListTagsResponse{}
for _, tag := range tags {
t, err := s.convertTagFromStore(ctx, tag)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to convert tag: %v", err)
}
response.Tags = append(response.Tags, t)
}
return response, nil
}
func (s *APIV2Service) DeleteTag(ctx context.Context, request *apiv2pb.DeleteTagRequest) (*apiv2pb.DeleteTagResponse, error) {
username, err := ExtractUsernameFromName(request.Tag.Creator)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid username: %v", err)
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
Username: &username,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
}
if user == nil {
return nil, status.Errorf(codes.NotFound, "user not found")
}
if err := s.Store.DeleteTag(ctx, &store.DeleteTag{
Name: request.Tag.Name,
CreatorID: user.ID,
}); err != nil {
return nil, status.Errorf(codes.Internal, "failed to delete tag: %v", err)
}
return &apiv2pb.DeleteTagResponse{}, nil
}
func (s *APIV2Service) 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
}

379
api/v2/user_service.go Normal file
View File

@@ -0,0 +1,379 @@
package v2
import (
"context"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
"golang.org/x/exp/slices"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/usememos/memos/api/auth"
apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
var (
usernameMatcher = regexp.MustCompile("^[a-z0-9]([a-z0-9-]{1,30}[a-z0-9])$")
)
func (s *APIV2Service) GetUser(ctx context.Context, request *apiv2pb.GetUserRequest) (*apiv2pb.GetUserResponse, error) {
username, err := ExtractUsernameFromName(request.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "name is required")
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
Username: &username,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
}
if user == nil {
return nil, status.Errorf(codes.NotFound, "user not found")
}
userMessage := convertUserFromStore(user)
response := &apiv2pb.GetUserResponse{
User: userMessage,
}
return response, nil
}
func (s *APIV2Service) CreateUser(ctx context.Context, request *apiv2pb.CreateUserRequest) (*apiv2pb.CreateUserResponse, error) {
currentUser, err := getCurrentUser(ctx, s.Store)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
}
if currentUser.Role != store.RoleHost {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
username, err := ExtractUsernameFromName(request.User.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "name is required")
}
if !usernameMatcher.MatchString(strings.ToLower(username)) {
return nil, status.Errorf(codes.InvalidArgument, "invalid username: %s", username)
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.User.Password), bcrypt.DefaultCost)
if err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to generate password hash").SetInternal(err)
}
user, err := s.Store.CreateUser(ctx, &store.User{
Username: username,
Role: convertUserRoleToStore(request.User.Role),
Email: request.User.Email,
Nickname: request.User.Nickname,
PasswordHash: string(passwordHash),
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to create user: %v", err)
}
response := &apiv2pb.CreateUserResponse{
User: convertUserFromStore(user),
}
return response, nil
}
func (s *APIV2Service) UpdateUser(ctx context.Context, request *apiv2pb.UpdateUserRequest) (*apiv2pb.UpdateUserResponse, error) {
username, err := ExtractUsernameFromName(request.User.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "name is required")
}
currentUser, err := getCurrentUser(ctx, s.Store)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
}
if currentUser.Username != username && currentUser.Role != store.RoleAdmin && currentUser.Role != store.RoleHost {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
return nil, status.Errorf(codes.InvalidArgument, "update mask is empty")
}
user, err := s.Store.GetUser(ctx, &store.FindUser{Username: &username})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
}
if user == nil {
return nil, status.Errorf(codes.NotFound, "user not found")
}
currentTs := time.Now().Unix()
update := &store.UpdateUser{
ID: user.ID,
UpdatedTs: &currentTs,
}
for _, field := range request.UpdateMask.Paths {
if field == "username" {
if !usernameMatcher.MatchString(strings.ToLower(username)) {
return nil, status.Errorf(codes.InvalidArgument, "invalid username: %s", username)
}
update.Username = &username
} else if field == "nickname" {
update.Nickname = &request.User.Nickname
} else if field == "email" {
update.Email = &request.User.Email
} else if field == "avatar_url" {
update.AvatarURL = &request.User.AvatarUrl
} else if field == "role" {
role := convertUserRoleToStore(request.User.Role)
update.Role = &role
} else if field == "password" {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.User.Password), bcrypt.DefaultCost)
if err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to generate password hash").SetInternal(err)
}
passwordHashStr := string(passwordHash)
update.PasswordHash = &passwordHashStr
} else if field == "row_status" {
rowStatus := convertRowStatusToStore(request.User.RowStatus)
update.RowStatus = &rowStatus
} else {
return nil, status.Errorf(codes.InvalidArgument, "invalid update path: %s", field)
}
}
updatedUser, err := s.Store.UpdateUser(ctx, update)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to update user: %v", err)
}
response := &apiv2pb.UpdateUserResponse{
User: convertUserFromStore(updatedUser),
}
return response, nil
}
func (s *APIV2Service) ListUserAccessTokens(ctx context.Context, request *apiv2pb.ListUserAccessTokensRequest) (*apiv2pb.ListUserAccessTokensResponse, error) {
user, err := getCurrentUser(ctx, s.Store)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if user == nil {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
userID := user.ID
username, err := ExtractUsernameFromName(request.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "name is required")
}
// List access token for other users need to be verified.
if user.Username != username {
// Normal users can only list their access tokens.
if user.Role == store.RoleUser {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
// The request user must be exist.
requestUser, err := s.Store.GetUser(ctx, &store.FindUser{Username: &username})
if requestUser == nil || err != nil {
return nil, status.Errorf(codes.NotFound, "fail to find user %s", username)
}
userID = requestUser.ID
}
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list access tokens: %v", err)
}
accessTokens := []*apiv2pb.UserAccessToken{}
for _, userAccessToken := range userAccessTokens {
claims := &auth.ClaimsMessage{}
_, err := jwt.ParseWithClaims(userAccessToken.AccessToken, claims, func(t *jwt.Token) (any, error) {
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
}
if kid, ok := t.Header["kid"].(string); ok {
if kid == "v1" {
return []byte(s.Secret), nil
}
}
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
})
if err != nil {
// If the access token is invalid or expired, just ignore it.
continue
}
userAccessToken := &apiv2pb.UserAccessToken{
AccessToken: userAccessToken.AccessToken,
Description: userAccessToken.Description,
IssuedAt: timestamppb.New(claims.IssuedAt.Time),
}
if claims.ExpiresAt != nil {
userAccessToken.ExpiresAt = timestamppb.New(claims.ExpiresAt.Time)
}
accessTokens = append(accessTokens, userAccessToken)
}
// Sort by issued time in descending order.
slices.SortFunc(accessTokens, func(i, j *apiv2pb.UserAccessToken) int {
return int(i.IssuedAt.Seconds - j.IssuedAt.Seconds)
})
response := &apiv2pb.ListUserAccessTokensResponse{
AccessTokens: accessTokens,
}
return response, nil
}
func (s *APIV2Service) CreateUserAccessToken(ctx context.Context, request *apiv2pb.CreateUserAccessTokenRequest) (*apiv2pb.CreateUserAccessTokenResponse, error) {
user, err := getCurrentUser(ctx, s.Store)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
expiresAt := time.Time{}
if request.ExpiresAt != nil {
expiresAt = request.ExpiresAt.AsTime()
}
accessToken, err := auth.GenerateAccessToken(user.Username, user.ID, expiresAt, []byte(s.Secret))
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to generate access token: %v", err)
}
claims := &auth.ClaimsMessage{}
_, err = jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (any, error) {
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
}
if kid, ok := t.Header["kid"].(string); ok {
if kid == "v1" {
return []byte(s.Secret), nil
}
}
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to parse access token: %v", err)
}
// Upsert the access token to user setting store.
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken, request.Description); err != nil {
return nil, status.Errorf(codes.Internal, "failed to upsert access token to store: %v", err)
}
userAccessToken := &apiv2pb.UserAccessToken{
AccessToken: accessToken,
Description: request.Description,
IssuedAt: timestamppb.New(claims.IssuedAt.Time),
}
if claims.ExpiresAt != nil {
userAccessToken.ExpiresAt = timestamppb.New(claims.ExpiresAt.Time)
}
response := &apiv2pb.CreateUserAccessTokenResponse{
AccessToken: userAccessToken,
}
return response, nil
}
func (s *APIV2Service) DeleteUserAccessToken(ctx context.Context, request *apiv2pb.DeleteUserAccessTokenRequest) (*apiv2pb.DeleteUserAccessTokenResponse, error) {
user, err := getCurrentUser(ctx, s.Store)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list access tokens: %v", err)
}
updatedUserAccessTokens := []*storepb.AccessTokensUserSetting_AccessToken{}
for _, userAccessToken := range userAccessTokens {
if userAccessToken.AccessToken == request.AccessToken {
continue
}
updatedUserAccessTokens = append(updatedUserAccessTokens, userAccessToken)
}
if _, err := s.Store.UpsertUserSettingV1(ctx, &storepb.UserSetting{
UserId: user.ID,
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
Value: &storepb.UserSetting_AccessTokens{
AccessTokens: &storepb.AccessTokensUserSetting{
AccessTokens: updatedUserAccessTokens,
},
},
}); err != nil {
return nil, status.Errorf(codes.Internal, "failed to upsert user setting: %v", err)
}
return &apiv2pb.DeleteUserAccessTokenResponse{}, nil
}
func (s *APIV2Service) UpsertAccessTokenToStore(ctx context.Context, user *store.User, accessToken, description string) error {
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID)
if err != nil {
return errors.Wrap(err, "failed to get user access tokens")
}
userAccessToken := storepb.AccessTokensUserSetting_AccessToken{
AccessToken: accessToken,
Description: description,
}
userAccessTokens = append(userAccessTokens, &userAccessToken)
if _, err := s.Store.UpsertUserSettingV1(ctx, &storepb.UserSetting{
UserId: user.ID,
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
Value: &storepb.UserSetting_AccessTokens{
AccessTokens: &storepb.AccessTokensUserSetting{
AccessTokens: userAccessTokens,
},
},
}); err != nil {
return errors.Wrap(err, "failed to upsert user setting")
}
return nil
}
func convertUserFromStore(user *store.User) *apiv2pb.User {
return &apiv2pb.User{
Name: fmt.Sprintf("%s%s", UserNamePrefix, user.Username),
Id: user.ID,
RowStatus: convertRowStatusFromStore(user.RowStatus),
CreateTime: timestamppb.New(time.Unix(user.CreatedTs, 0)),
UpdateTime: timestamppb.New(time.Unix(user.UpdatedTs, 0)),
Role: convertUserRoleFromStore(user.Role),
Email: user.Email,
Nickname: user.Nickname,
AvatarUrl: user.AvatarURL,
}
}
func convertUserRoleFromStore(role store.Role) apiv2pb.User_Role {
switch role {
case store.RoleHost:
return apiv2pb.User_HOST
case store.RoleAdmin:
return apiv2pb.User_ADMIN
case store.RoleUser:
return apiv2pb.User_USER
default:
return apiv2pb.User_ROLE_UNSPECIFIED
}
}
func convertUserRoleToStore(role apiv2pb.User_Role) store.Role {
switch role {
case apiv2pb.User_HOST:
return store.RoleHost
case apiv2pb.User_ADMIN:
return store.RoleAdmin
case apiv2pb.User_USER:
return store.RoleUser
default:
return store.RoleUser
}
}

116
api/v2/v2.go Normal file
View File

@@ -0,0 +1,116 @@
package v2
import (
"context"
"fmt"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/improbable-eng/grpc-web/go/grpcweb"
"github.com/labstack/echo/v4"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/reflection"
apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
"github.com/usememos/memos/server/profile"
"github.com/usememos/memos/store"
)
type APIV2Service struct {
apiv2pb.UnimplementedSystemServiceServer
apiv2pb.UnimplementedUserServiceServer
apiv2pb.UnimplementedMemoServiceServer
apiv2pb.UnimplementedResourceServiceServer
apiv2pb.UnimplementedTagServiceServer
apiv2pb.UnimplementedInboxServiceServer
apiv2pb.UnimplementedActivityServiceServer
Secret string
Profile *profile.Profile
Store *store.Store
grpcServer *grpc.Server
grpcServerPort int
}
func NewAPIV2Service(secret string, profile *profile.Profile, store *store.Store, grpcServerPort int) *APIV2Service {
grpc.EnableTracing = true
authProvider := NewGRPCAuthInterceptor(store, secret)
grpcServer := grpc.NewServer(
grpc.ChainUnaryInterceptor(
authProvider.AuthenticationInterceptor,
),
)
apiv2Service := &APIV2Service{
Secret: secret,
Profile: profile,
Store: store,
grpcServer: grpcServer,
grpcServerPort: grpcServerPort,
}
apiv2pb.RegisterSystemServiceServer(grpcServer, apiv2Service)
apiv2pb.RegisterUserServiceServer(grpcServer, apiv2Service)
apiv2pb.RegisterMemoServiceServer(grpcServer, apiv2Service)
apiv2pb.RegisterTagServiceServer(grpcServer, apiv2Service)
apiv2pb.RegisterResourceServiceServer(grpcServer, apiv2Service)
apiv2pb.RegisterInboxServiceServer(grpcServer, apiv2Service)
apiv2pb.RegisterActivityServiceServer(grpcServer, apiv2Service)
reflection.Register(grpcServer)
return apiv2Service
}
func (s *APIV2Service) GetGRPCServer() *grpc.Server {
return s.grpcServer
}
// RegisterGateway registers the gRPC-Gateway with the given Echo instance.
func (s *APIV2Service) RegisterGateway(ctx context.Context, e *echo.Echo) error {
// Create a client connection to the gRPC Server we just started.
// This is where the gRPC-Gateway proxies the requests.
conn, err := grpc.DialContext(
ctx,
fmt.Sprintf(":%d", s.grpcServerPort),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
return err
}
gwMux := runtime.NewServeMux()
if err := apiv2pb.RegisterSystemServiceHandler(context.Background(), gwMux, conn); err != nil {
return err
}
if err := apiv2pb.RegisterUserServiceHandler(context.Background(), gwMux, conn); err != nil {
return err
}
if err := apiv2pb.RegisterMemoServiceHandler(context.Background(), gwMux, conn); err != nil {
return err
}
if err := apiv2pb.RegisterTagServiceHandler(context.Background(), gwMux, conn); err != nil {
return err
}
if err := apiv2pb.RegisterResourceServiceHandler(context.Background(), gwMux, conn); err != nil {
return err
}
if err := apiv2pb.RegisterInboxServiceHandler(context.Background(), gwMux, conn); err != nil {
return err
}
if err := apiv2pb.RegisterActivityServiceHandler(context.Background(), gwMux, conn); err != nil {
return err
}
e.Any("/api/v2/*", echo.WrapHandler(gwMux))
// GRPC web proxy.
options := []grpcweb.Option{
grpcweb.WithCorsForRegisteredEndpointsOnly(false),
grpcweb.WithOriginFunc(func(origin string) bool {
return true
}),
}
wrappedGrpc := grpcweb.WrapServer(s.grpcServer, options...)
e.Any("/memos.api.v2.*", echo.WrapHandler(wrappedGrpc))
return nil
}

View File

@@ -1,74 +0,0 @@
package main
import (
"os"
_ "github.com/mattn/go-sqlite3"
"context"
"fmt"
"github.com/usememos/memos/server"
"github.com/usememos/memos/server/profile"
"github.com/usememos/memos/store"
DB "github.com/usememos/memos/store/db"
)
const (
greetingBanner = `
███╗ ███╗███████╗███╗ ███╗ ██████╗ ███████╗
████╗ ████║██╔════╝████╗ ████║██╔═══██╗██╔════╝
██╔████╔██║█████╗ ██╔████╔██║██║ ██║███████╗
██║╚██╔╝██║██╔══╝ ██║╚██╔╝██║██║ ██║╚════██║
██║ ╚═╝ ██║███████╗██║ ╚═╝ ██║╚██████╔╝███████║
╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝
`
)
func run(profile *profile.Profile) error {
ctx := context.Background()
db := DB.NewDB(profile)
if err := db.Open(ctx); err != nil {
return fmt.Errorf("cannot open db: %w", err)
}
s := server.NewServer(profile)
storeInstance := store.New(db.Db, profile)
s.Store = storeInstance
println(greetingBanner)
fmt.Printf("Version %s has started at :%d\n", profile.Version, profile.Port)
return s.Run()
}
func execute() error {
profile, err := profile.GetProfile()
if err != nil {
return err
}
println("---")
println("profile")
println("mode:", profile.Mode)
println("port:", profile.Port)
println("dsn:", profile.DSN)
println("version:", profile.Version)
println("---")
if err := run(profile); err != nil {
fmt.Printf("error: %+v\n", err)
return err
}
return nil
}
func main() {
if err := execute(); err != nil {
os.Exit(1)
}
}

390
cmd/copydb.go Normal file
View File

@@ -0,0 +1,390 @@
package cmd
import (
"context"
"fmt"
"strings"
"github.com/pkg/errors"
"github.com/spf13/cobra"
_profile "github.com/usememos/memos/server/profile"
"github.com/usememos/memos/store"
"github.com/usememos/memos/store/db"
)
var (
copydbCmdFlagFrom = "from"
copydbCmd = &cobra.Command{
Use: "copydb", // `copydb` is a shortened for 'copy database'
Short: "Copy data between db drivers",
Run: func(cmd *cobra.Command, _ []string) {
s, err := cmd.Flags().GetString(copydbCmdFlagFrom)
if err != nil {
println("fail to get from driver DSN")
println(err)
return
}
ss := strings.Split(s, "://")
if len(ss) != 2 {
println("fail to parse from driver DSN, should be like 'sqlite://memos_prod.db' or 'mysql://user:pass@tcp(host)/memos'")
return
}
fromProfile := &_profile.Profile{Driver: ss[0], DSN: ss[1]}
err = copydb(fromProfile, profile)
if err != nil {
fmt.Printf("fail to copydb: %s\n", err)
return
}
println("done")
},
}
)
func init() {
copydbCmd.Flags().String(copydbCmdFlagFrom, "sqlite://memos_prod.db", "From driver DSN")
rootCmd.AddCommand(copydbCmd)
}
func copydb(fromProfile, toProfile *_profile.Profile) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
toDriver, err := db.NewDBDriver(toProfile)
if err != nil {
return errors.Wrap(err, "fail to create `to` driver")
}
// Check if `to` driver has been created before
if history, err := toDriver.FindMigrationHistoryList(ctx, nil); err != nil {
return errors.New("fail to check migration history of `to` driver")
} else if len(history) == 0 {
return errors.New("migration history of `to` driver should not be empty")
}
if err := toDriver.Migrate(ctx); err != nil {
return errors.Wrap(err, "fail to migrate db")
}
fromDriver, err := db.NewDBDriver(fromProfile)
if err != nil {
return errors.Wrap(err, "fail to create `from` driver")
}
// Register here if any table is added
copyMap := map[string]func(context.Context, store.Driver, store.Driver) error{
"activity": copyActivity,
"idp": copyIdp,
"memo": copyMemo,
"memo_organizer": copyMemoOrganizer,
"memo_relation": copyMemoRelation,
"resource": copyResource,
"storage": copyStorage,
"system_setting": copySystemSettings,
"tag": copyTag,
"user": copyUser,
"user_setting": copyUserSettings,
}
toDb := toDriver.GetDB()
for table := range copyMap {
println("Checking " + table + "...")
var cnt int
err := toDb.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+table).Scan(&cnt)
if err != nil {
return errors.Wrapf(err, "fail to check '%s'", table)
}
if cnt > 0 && table != "system_setting" {
return errors.Errorf("table '%s' is not empty", table)
}
}
for _, f := range copyMap {
err = f(ctx, fromDriver, toDriver)
if err != nil {
return errors.Wrap(err, "fail to copy data")
}
}
return nil
}
func copyActivity(ctx context.Context, fromDriver, toDriver store.Driver) error {
println("Copying Activity...")
list, err := fromDriver.ListActivities(ctx, &store.FindActivity{})
if err != nil {
return err
}
fmt.Printf("\tTotal %d records\n", len(list))
for _, item := range list {
_, err := toDriver.CreateActivity(ctx, &store.Activity{
ID: item.ID,
CreatorID: item.CreatorID,
CreatedTs: item.CreatedTs,
Level: item.Level,
Type: item.Type,
Payload: item.Payload,
})
if err != nil {
return err
}
}
println("\tDONE")
return nil
}
func copyIdp(ctx context.Context, fromDriver, toDriver store.Driver) error {
println("Copying IdentityProvider...")
list, err := fromDriver.ListIdentityProviders(ctx, &store.FindIdentityProvider{})
if err != nil {
return err
}
fmt.Printf("\tTotal %d records\n", len(list))
for _, item := range list {
_, err := toDriver.CreateIdentityProvider(ctx, &store.IdentityProvider{
ID: item.ID,
Name: item.Name,
Type: item.Type,
IdentifierFilter: item.IdentifierFilter,
Config: item.Config,
})
if err != nil {
return err
}
}
println("\tDONE")
return nil
}
func copyMemo(ctx context.Context, fromDriver, toDriver store.Driver) error {
println("Copying Memo...")
list, err := fromDriver.ListMemos(ctx, &store.FindMemo{})
if err != nil {
return err
}
fmt.Printf("\tTotal %d records\n", len(list))
for _, item := range list {
_, err := toDriver.CreateMemo(ctx, &store.Memo{
ID: item.ID,
CreatorID: item.CreatorID,
CreatedTs: item.CreatedTs,
UpdatedTs: item.UpdatedTs,
RowStatus: item.RowStatus,
Content: item.Content,
Visibility: item.Visibility,
})
if err != nil {
return err
}
}
println("\tDONE")
return nil
}
func copyMemoOrganizer(ctx context.Context, fromDriver, toDriver store.Driver) error {
println("Copying MemoOrganizer...")
list, err := fromDriver.ListMemoOrganizer(ctx, &store.FindMemoOrganizer{})
if err != nil {
return err
}
fmt.Printf("\tTotal %d records\n", len(list))
for _, item := range list {
_, err := toDriver.UpsertMemoOrganizer(ctx, &store.MemoOrganizer{
MemoID: item.MemoID,
UserID: item.UserID,
Pinned: item.Pinned,
})
if err != nil {
return err
}
}
println("\tDONE")
return nil
}
func copyMemoRelation(ctx context.Context, fromDriver, toDriver store.Driver) error {
println("Copying MemoRelation...")
list, err := fromDriver.ListMemoRelations(ctx, &store.FindMemoRelation{})
if err != nil {
return err
}
fmt.Printf("\tTotal %d records\n", len(list))
for _, item := range list {
_, err := toDriver.UpsertMemoRelation(ctx, &store.MemoRelation{
MemoID: item.MemoID,
RelatedMemoID: item.RelatedMemoID,
Type: item.Type,
})
if err != nil {
return err
}
}
println("\tDONE")
return nil
}
func copyResource(ctx context.Context, fromDriver, toDriver store.Driver) error {
println("Copying Resource...")
list, err := fromDriver.ListResources(ctx, &store.FindResource{GetBlob: true})
if err != nil {
return err
}
fmt.Printf("\tTotal %d records\n", len(list))
for _, item := range list {
_, err := toDriver.CreateResource(ctx, &store.Resource{
ID: item.ID,
CreatorID: item.CreatorID,
CreatedTs: item.CreatedTs,
UpdatedTs: item.UpdatedTs,
Filename: item.Filename,
Blob: item.Blob,
ExternalLink: item.ExternalLink,
Type: item.Type,
Size: item.Size,
InternalPath: item.InternalPath,
MemoID: item.MemoID,
})
if err != nil {
return err
}
}
println("\tDONE")
return nil
}
func copyStorage(ctx context.Context, fromDriver, toDriver store.Driver) error {
println("Copying Storage...")
list, err := fromDriver.ListStorages(ctx, &store.FindStorage{})
if err != nil {
return err
}
fmt.Printf("\tTotal %d records\n", len(list))
for _, item := range list {
_, err := toDriver.CreateStorage(ctx, &store.Storage{
ID: item.ID,
Name: item.Name,
Type: item.Type,
Config: item.Config,
})
if err != nil {
return err
}
}
println("\tDONE")
return nil
}
func copySystemSettings(ctx context.Context, fromDriver, toDriver store.Driver) error {
println("Copying SystemSettings...")
list, err := fromDriver.ListSystemSettings(ctx, &store.FindSystemSetting{})
if err != nil {
return err
}
fmt.Printf("\tTotal %d records\n", len(list))
for _, item := range list {
_, err := toDriver.UpsertSystemSetting(ctx, &store.SystemSetting{
Name: item.Name,
Value: item.Value,
Description: item.Description,
})
if err != nil {
return err
}
}
println("\tDONE")
return nil
}
func copyTag(ctx context.Context, fromDriver, toDriver store.Driver) error {
println("Copying Tag...")
list, err := fromDriver.ListTags(ctx, &store.FindTag{})
if err != nil {
return err
}
fmt.Printf("\tTotal %d records\n", len(list))
for _, item := range list {
_, err := toDriver.UpsertTag(ctx, &store.Tag{
Name: item.Name,
CreatorID: item.CreatorID,
})
if err != nil {
return err
}
}
println("\tDONE")
return nil
}
func copyUser(ctx context.Context, fromDriver, toDriver store.Driver) error {
println("Copying User...")
list, err := fromDriver.ListUsers(ctx, &store.FindUser{})
if err != nil {
return err
}
fmt.Printf("\tTotal %d records\n", len(list))
for _, item := range list {
_, err := toDriver.CreateUser(ctx, &store.User{
ID: item.ID,
CreatedTs: item.CreatedTs,
UpdatedTs: item.UpdatedTs,
RowStatus: item.RowStatus,
Username: item.Username,
Role: item.Role,
Email: item.Email,
Nickname: item.Nickname,
PasswordHash: item.PasswordHash,
AvatarURL: item.AvatarURL,
})
if err != nil {
return err
}
}
println("\tDONE")
return nil
}
func copyUserSettings(ctx context.Context, fromDriver, toDriver store.Driver) error {
println("Copying UserSettings...")
list, err := fromDriver.ListUserSettings(ctx, &store.FindUserSetting{})
if err != nil {
return err
}
fmt.Printf("\tTotal %d records\n", len(list))
for _, item := range list {
_, err := toDriver.UpsertUserSetting(ctx, &store.UserSetting{
Key: item.Key,
Value: item.Value,
UserID: item.UserID,
})
if err != nil {
return err
}
}
println("\tDONE")
return nil
}

188
cmd/memos.go Normal file
View File

@@ -0,0 +1,188 @@
package cmd
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"go.uber.org/zap"
"github.com/usememos/memos/internal/log"
"github.com/usememos/memos/server"
_profile "github.com/usememos/memos/server/profile"
"github.com/usememos/memos/server/service/metric"
"github.com/usememos/memos/store"
"github.com/usememos/memos/store/db"
)
const (
greetingBanner = `
███╗ ███╗███████╗███╗ ███╗ ██████╗ ███████╗
████╗ ████║██╔════╝████╗ ████║██╔═══██╗██╔════╝
██╔████╔██║█████╗ ██╔████╔██║██║ ██║███████╗
██║╚██╔╝██║██╔══╝ ██║╚██╔╝██║██║ ██║╚════██║
██║ ╚═╝ ██║███████╗██║ ╚═╝ ██║╚██████╔╝███████║
╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝
`
)
var (
profile *_profile.Profile
mode string
addr string
port int
data string
driver string
dsn string
enableMetric bool
rootCmd = &cobra.Command{
Use: "memos",
Short: `An open-source, self-hosted memo hub with knowledge management and social networking.`,
Run: func(_cmd *cobra.Command, _args []string) {
ctx, cancel := context.WithCancel(context.Background())
dbDriver, err := db.NewDBDriver(profile)
if err != nil {
cancel()
log.Error("failed to create db driver", zap.Error(err))
return
}
if err := dbDriver.Migrate(ctx); err != nil {
cancel()
log.Error("failed to migrate db", zap.Error(err))
return
}
store := store.New(dbDriver, profile)
s, err := server.NewServer(ctx, profile, store)
if err != nil {
cancel()
log.Error("failed to create server", zap.Error(err))
return
}
if profile.Metric {
// nolint
metric.NewMetricClient(s.ID, *profile)
}
c := make(chan os.Signal, 1)
// Trigger graceful shutdown on SIGINT or SIGTERM.
// The default signal sent by the `kill` command is SIGTERM,
// which is taken as the graceful shutdown signal for many systems, eg., Kubernetes, Gunicorn.
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
go func() {
sig := <-c
log.Info(fmt.Sprintf("%s received.\n", sig.String()))
s.Shutdown(ctx)
cancel()
}()
printGreetings()
if err := s.Start(ctx); err != nil {
if err != http.ErrServerClosed {
log.Error("failed to start server", zap.Error(err))
cancel()
}
}
// Wait for CTRL-C.
<-ctx.Done()
},
}
)
func Execute() error {
defer log.Sync()
return rootCmd.Execute()
}
func init() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringVarP(&mode, "mode", "m", "demo", `mode of server, can be "prod" or "dev" or "demo"`)
rootCmd.PersistentFlags().StringVarP(&addr, "addr", "a", "", "address of server")
rootCmd.PersistentFlags().IntVarP(&port, "port", "p", 8081, "port of server")
rootCmd.PersistentFlags().StringVarP(&data, "data", "d", "", "data directory")
rootCmd.PersistentFlags().StringVarP(&driver, "driver", "", "", "database driver")
rootCmd.PersistentFlags().StringVarP(&dsn, "dsn", "", "", "database source name(aka. DSN)")
rootCmd.PersistentFlags().BoolVarP(&enableMetric, "metric", "", true, "allow metric collection")
err := viper.BindPFlag("mode", rootCmd.PersistentFlags().Lookup("mode"))
if err != nil {
panic(err)
}
err = viper.BindPFlag("addr", rootCmd.PersistentFlags().Lookup("addr"))
if err != nil {
panic(err)
}
err = viper.BindPFlag("port", rootCmd.PersistentFlags().Lookup("port"))
if err != nil {
panic(err)
}
err = viper.BindPFlag("data", rootCmd.PersistentFlags().Lookup("data"))
if err != nil {
panic(err)
}
err = viper.BindPFlag("driver", rootCmd.PersistentFlags().Lookup("driver"))
if err != nil {
panic(err)
}
err = viper.BindPFlag("dsn", rootCmd.PersistentFlags().Lookup("dsn"))
if err != nil {
panic(err)
}
err = viper.BindPFlag("metric", rootCmd.PersistentFlags().Lookup("metric"))
if err != nil {
panic(err)
}
viper.SetDefault("mode", "demo")
viper.SetDefault("driver", "sqlite")
viper.SetDefault("addr", "")
viper.SetDefault("port", 8081)
viper.SetDefault("metric", true)
viper.SetEnvPrefix("memos")
}
func initConfig() {
viper.AutomaticEnv()
var err error
profile, err = _profile.GetProfile()
if err != nil {
fmt.Printf("failed to get profile, error: %+v\n", err)
return
}
println("---")
println("Server profile")
println("data:", profile.Data)
println("dsn:", profile.DSN)
println("addr:", profile.Addr)
println("port:", profile.Port)
println("mode:", profile.Mode)
println("driver:", profile.Driver)
println("version:", profile.Version)
println("metric:", profile.Metric)
println("---")
}
func printGreetings() {
print(greetingBanner)
if len(profile.Addr) == 0 {
fmt.Printf("Version %s has been started on port %d\n", profile.Version, profile.Port)
} else {
fmt.Printf("Version %s has been started on address '%s' and port %d\n", profile.Version, profile.Addr, profile.Port)
}
println("---")
println("See more in:")
fmt.Printf("👉Website: %s\n", "https://usememos.com")
fmt.Printf("👉GitHub: %s\n", "https://github.com/usememos/memos")
println("---")
}

99
cmd/mvrss.go Normal file
View File

@@ -0,0 +1,99 @@
package cmd
import (
"context"
"fmt"
"os"
"time"
"github.com/spf13/cobra"
"github.com/usememos/memos/store"
"github.com/usememos/memos/store/db/sqlite"
)
var (
mvrssCmdFlagFrom = "from"
mvrssCmdFlagTo = "to"
mvrssCmd = &cobra.Command{
Use: "mvrss", // `mvrss` is a shortened for 'means move resource'
Short: "Move resource between storage",
Run: func(cmd *cobra.Command, _ []string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
from, err := cmd.Flags().GetString(mvrssCmdFlagFrom)
if err != nil {
fmt.Printf("failed to get from storage, error: %+v\n", err)
return
}
to, err := cmd.Flags().GetString(mvrssCmdFlagTo)
if err != nil {
fmt.Printf("failed to get to storage, error: %+v\n", err)
return
}
if from != "local" || to != "db" {
fmt.Printf("only local=>db be supported currently\n")
return
}
driver, err := sqlite.NewDB(profile)
if err != nil {
fmt.Printf("failed to create db driver, error: %+v\n", err)
return
}
if err := driver.Migrate(ctx); err != nil {
fmt.Printf("failed to migrate db, error: %+v\n", err)
return
}
s := store.New(driver, profile)
resources, err := s.ListResources(ctx, &store.FindResource{})
if err != nil {
fmt.Printf("failed to list resources, error: %+v\n", err)
return
}
var emptyString string
for _, res := range resources {
if res.InternalPath == "" {
continue
}
buf, err := os.ReadFile(res.InternalPath)
if err != nil {
fmt.Printf("Resource %5d failed to read file: %s\n", res.ID, err)
continue
}
if len(buf) != int(res.Size) {
fmt.Printf("Resource %5d size of file %d != %d\n", res.ID, len(buf), res.Size)
continue
}
update := store.UpdateResource{
ID: res.ID,
Blob: buf,
InternalPath: &emptyString,
}
_, err = s.UpdateResource(ctx, &update)
if err != nil {
fmt.Printf("Resource %5d failed to update: %s\n", res.ID, err)
continue
}
fmt.Printf("Resource %5d copy %12d bytes from %s\n", res.ID, len(buf), res.InternalPath)
}
println("done")
},
}
)
func init() {
mvrssCmd.Flags().String(mvrssCmdFlagFrom, "local", "From storage")
mvrssCmd.Flags().String(mvrssCmdFlagTo, "db", "To Storage")
rootCmd.AddCommand(mvrssCmd)
}

142
cmd/setup.go Normal file
View File

@@ -0,0 +1,142 @@
package cmd
import (
"context"
"fmt"
"time"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"golang.org/x/crypto/bcrypt"
"github.com/usememos/memos/internal/util"
"github.com/usememos/memos/store"
"github.com/usememos/memos/store/db/sqlite"
)
var (
setupCmdFlagHostUsername = "host-username"
setupCmdFlagHostPassword = "host-password"
setupCmd = &cobra.Command{
Use: "setup",
Short: "Make initial setup for memos",
Run: func(cmd *cobra.Command, _ []string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
hostUsername, err := cmd.Flags().GetString(setupCmdFlagHostUsername)
if err != nil {
fmt.Printf("failed to get owner username, error: %+v\n", err)
return
}
hostPassword, err := cmd.Flags().GetString(setupCmdFlagHostPassword)
if err != nil {
fmt.Printf("failed to get owner password, error: %+v\n", err)
return
}
driver, err := sqlite.NewDB(profile)
if err != nil {
fmt.Printf("failed to create db driver, error: %+v\n", err)
return
}
if err := driver.Migrate(ctx); err != nil {
fmt.Printf("failed to migrate db, error: %+v\n", err)
return
}
store := store.New(driver, profile)
if err := ExecuteSetup(ctx, store, hostUsername, hostPassword); err != nil {
fmt.Printf("failed to setup, error: %+v\n", err)
return
}
},
}
)
func init() {
setupCmd.Flags().String(setupCmdFlagHostUsername, "", "Owner username")
setupCmd.Flags().String(setupCmdFlagHostPassword, "", "Owner password")
rootCmd.AddCommand(setupCmd)
}
func ExecuteSetup(ctx context.Context, store *store.Store, hostUsername, hostPassword string) error {
s := setupService{store: store}
return s.Setup(ctx, hostUsername, hostPassword)
}
type setupService struct {
store *store.Store
}
func (s setupService) Setup(ctx context.Context, hostUsername, hostPassword string) error {
if err := s.makeSureHostUserNotExists(ctx); err != nil {
return err
}
if err := s.createUser(ctx, hostUsername, hostPassword); err != nil {
return errors.Wrap(err, "create user")
}
return nil
}
func (s setupService) makeSureHostUserNotExists(ctx context.Context) error {
hostUserType := store.RoleHost
existedHostUsers, err := s.store.ListUsers(ctx, &store.FindUser{Role: &hostUserType})
if err != nil {
return errors.Wrap(err, "find user list")
}
if len(existedHostUsers) != 0 {
return errors.New("host user already exists")
}
return nil
}
func (s setupService) createUser(ctx context.Context, hostUsername, hostPassword string) error {
userCreate := &store.User{
Username: hostUsername,
// The new signup user should be normal user by default.
Role: store.RoleHost,
Nickname: hostUsername,
}
if len(userCreate.Username) < 3 {
return errors.New("username is too short, minimum length is 3")
}
if len(userCreate.Username) > 32 {
return errors.New("username is too long, maximum length is 32")
}
if len(hostPassword) < 3 {
return errors.New("password is too short, minimum length is 3")
}
if len(hostPassword) > 512 {
return errors.New("password is too long, maximum length is 512")
}
if len(userCreate.Nickname) > 64 {
return errors.New("nickname is too long, maximum length is 64")
}
if userCreate.Email != "" {
if len(userCreate.Email) > 256 {
return errors.New("email is too long, maximum length is 256")
}
if !util.ValidateEmail(userCreate.Email) {
return errors.New("invalid email format")
}
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(hostPassword), bcrypt.DefaultCost)
if err != nil {
return errors.Wrap(err, "failed to hash password")
}
userCreate.PasswordHash = string(passwordHash)
if _, err := s.store.CreateUser(ctx, userCreate); err != nil {
return errors.Wrap(err, "failed to create user")
}
return nil
}

View File

@@ -1,72 +0,0 @@
package common
import (
"errors"
)
// Code is the error code.
type Code int
// Application error codes.
const (
// 0 ~ 99 general error.
Ok Code = 0
Internal Code = 1
NotAuthorized Code = 2
Invalid Code = 3
NotFound Code = 4
Conflict Code = 5
NotImplemented Code = 6
)
// Error represents an application-specific error. Application errors can be
// unwrapped by the caller to extract out the code & message.
//
// Any non-application error (such as a disk error) should be reported as an
// Internal error and the human user should only see "Internal error" as the
// message. These low-level internal error details should only be logged and
// reported to the operator of the application (not the end user).
type Error struct {
// Machine-readable error code.
Code Code
// Embedded error.
Err error
}
// Error implements the error interface. Not used by the application otherwise.
func (e *Error) Error() string {
return e.Err.Error()
}
// ErrorCode unwraps an application error and returns its code.
// Non-application errors always return EINTERNAL.
func ErrorCode(err error) Code {
var e *Error
if err == nil {
return Ok
} else if errors.As(err, &e) {
return e.Code
}
return Internal
}
// ErrorMessage unwraps an application error and returns its message.
// Non-application errors always return "Internal error".
func ErrorMessage(err error) string {
var e *Error
if err == nil {
return ""
} else if errors.As(err, &e) {
return e.Err.Error()
}
return "Internal error."
}
// Errorf is a helper function to return an Error with a given code and error.
func Errorf(code Code, err error) *Error {
return &Error{
Code: code,
Err: err,
}
}

View File

@@ -1,30 +0,0 @@
package common
import (
"net/mail"
"strings"
"github.com/google/uuid"
)
// HasPrefixes returns true if the string s has any of the given prefixes.
func HasPrefixes(src string, prefixes ...string) bool {
for _, prefix := range prefixes {
if strings.HasPrefix(src, prefix) {
return true
}
}
return false
}
// ValidateEmail validates the email.
func ValidateEmail(email string) bool {
if _, err := mail.ParseAddress(email); err != nil {
return false
}
return true
}
func GenUUID() string {
return uuid.New().String()
}

View File

@@ -1,67 +0,0 @@
package common
import (
"strconv"
"strings"
)
// Version is the service current released version.
// Semantic versioning: https://semver.org/
var Version = "0.4.3"
// DevVersion is the service current development version.
var DevVersion = "0.4.3"
func GetCurrentVersion(mode string) string {
if mode == "dev" {
return DevVersion
}
return Version
}
func GetMinorVersion(version string) string {
versionList := strings.Split(version, ".")
if len(versionList) < 3 {
return ""
}
return versionList[0] + "." + versionList[1]
}
func GetSchemaVersion(version string) string {
minorVersion := GetMinorVersion(version)
return minorVersion + ".0"
}
// convSemanticVersionToInt converts version string to int.
func convSemanticVersionToInt(version string) int {
versionList := strings.Split(version, ".")
if len(versionList) < 3 {
return 0
}
major, err := strconv.Atoi(versionList[0])
if err != nil {
return 0
}
minor, err := strconv.Atoi(versionList[1])
if err != nil {
return 0
}
patch, err := strconv.Atoi(versionList[2])
if err != nil {
return 0
}
return major*10000 + minor*100 + patch
}
// IsVersionGreaterThanOrEqualTo returns true if version is greater than or equal to target.
func IsVersionGreaterOrEqualThan(version, target string) bool {
return convSemanticVersionToInt(version) >= convSemanticVersionToInt(target)
}
// IsVersionGreaterThan returns true if version is greater than target.
func IsVersionGreaterThan(version, target string) bool {
return convSemanticVersionToInt(version) > convSemanticVersionToInt(target)
}

70
docker-compose.dev.yaml Normal file
View File

@@ -0,0 +1,70 @@
services:
db:
image: mysql
volumes:
- ./.air/mysql:/var/lib/mysql
api:
image: cosmtrek/air
working_dir: /work
command: ["-c", "./scripts/.air.toml"]
environment:
- "MEMOS_DSN=root@tcp(db)/memos"
- "MEMOS_DRIVER=mysql"
volumes:
- .:/work/
- .air/go-build:/root/.cache/go-build
- $HOME/go/pkg/:/go/pkg/ # Cache for go mod shared with the host
web:
image: node:18-alpine
working_dir: /work
depends_on: ["api"]
ports: ["3001:3001"]
environment: ["DEV_PROXY_SERVER=http://api:8081/"]
entrypoint: ["/bin/sh", "-c"]
command: ["corepack enable && pnpm install && pnpm dev"]
volumes:
- ./web:/work
- ./.air/node_modules/:/work/node_modules/ # Cache for Node Modules
# Services below are used for developers to run once
#
# You can just run `docker compose run --rm SERVICE_NAME` to use
# For example:
# To regenerate typescript code of gRPC proto
# Just run `docker compose run --rm buf`
#
# All of theses services belongs to profile 'tools'
# This will prevent to launch by normally `docker compose up` unexpectly
# Generate typescript code of gRPC proto
buf:
profiles: ["tools"]
image: bufbuild/buf
working_dir: /work/proto
command: generate
volumes:
- ./proto:/work/proto
- ./web/src/types/:/work/web/src/types/
# Do golang static code check before create PR
golangci-lint:
profiles: ["tools"]
image: golangci/golangci-lint:v1.54.2
working_dir: /work/
entrypoint: golangci-lint
command: run -v
volumes:
- $HOME/go/pkg/:/go/pkg/ # Cache for go mod shared with the host
- .air/go-build:/root/.cache/go-build
- .:/work/
# run npm
npm:
profiles: ["tools"]
image: node:18-alpine
working_dir: /work
environment: ["NPM_CONFIG_UPDATE_NOTIFIER=false"]
entrypoint: "npm"
volumes:
- ./web:/work
- ./.air/node_modules/:/work/node_modules/

1607
docs/api/v1.md Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -6,35 +6,37 @@ Memos is built with a curated tech stack. It is optimized for developer experien
2. It requires zero config.
3. 1 command to start backend and 1 command to start frontend, both with live reload support.
## Tech Stack
![tech-stack](https://raw.githubusercontent.com/usememos/memos/main/resources/tech-stack.png)
## Prerequisites
- [Go](https://golang.org/doc/install)
- [Air](https://github.com/cosmtrek/air#installation) for backend live reload
- [Node.js](https://nodejs.org/)
- [yarn](https://yarnpkg.com/getting-started/install)
- [pnpm](https://pnpm.io/installation)
## Steps
1. pull source code
1. Pull the source code
```bash
git clone https://github.com/usememos/memos
```
2. start backend using air(with live reload)
2. Start backend server with `air`(with live reload)
```bash
air -c scripts/.air.toml
```
3. start frontend dev server
3. Install frontend dependencies and generate TypeScript code from protobuf
```bash
cd web && yarn && yarn dev
```
cd web && pnpm i && pnpm type-gen
```
Memos should now be running at [http://localhost:3000](http://localhost:3000) and change either frontend or backend code would trigger live reload.
4. Start the dev server of frontend
```bash
cd web && pnpm dev
```
Memos should now be running at [http://localhost:3001](http://localhost:3001) and change either frontend or backend code would trigger live reload.

113
docs/documenting-the-api.md Normal file
View File

@@ -0,0 +1,113 @@
# Documenting the API
## Principles
- The documentation is generated by [swaggo/swag](https://github.com/swaggo/swag) from comments in the API code.
- Documentation is written using [Declarative Comments Format](https://github.com/swaggo/swag#declarative-comments-format).
- The documentation is generated in the `./api/v1` folder as `docs.go`.
- [echo-swagger](https://github.com/swaggo/echo-swagger) is used to integrate with Echo framework and serve the documentation with [Swagger-UI](https://swagger.io/tools/swagger-ui/) at `http://memos.host:5230/api/index.html`
## Updating the documentation
1. Update or add API-related comments in the code. Make sure to follow the [Declarative Comments Format](https://github.com/swaggo/swag#declarative-comments-format):
```go
// signIn godoc
//
// @Summary Sign-in to memos.
// @Tags auth
// @Accept json
// @Produce json
// @Param body body SignIn true "Sign-in object"
// @Success 200 {object} store.User "User information"
// @Failure 400 {object} nil "Malformatted signin request"
// @Failure 401 {object} nil "Password login is deactivated | Incorrect login credentials, please try again"
// @Failure 403 {object} nil "User has been archived with username {username}"
// @Failure 500 {object} nil "Failed to find system setting | Failed to unmarshal system setting | Incorrect login credentials, please try again | Failed to generate tokens | Failed to create activity"
// @Router /api/v1/auth/signin [POST]
func (s *APIV1Service) signIn(c echo.Context) error {
...
```
> Sample from [api/v1/auth.go](https://github.com/usememos/memos/tree/main/api/v1/auth.go)
> You can check existing comments at [api/v1](https://github.com/usememos/memos/tree/main/api/v1)
2. Run one of the following provided scripts:
- Linux: `./scripts/gen-api-v1-docs.sh` (remember to `chmod +x` the script first)
- Windows: `./scripts/gen-api-v1-docs.ps1`
> The scripts will install swag if needed (via go install), then run `swag fmt` and `swag init` commands.
3. That's it! The documentation is updated. You can check it at `http://memos.host:5230/api/index.html`
### Extra tips
- If you reference a custom Go struct from outside the API file, use a relative definition, like `store.IdentityProvider`. This works because `./` is passed to swag at `--dir` argument. If swag can't resolve the reference, it will fail.
- If the API grows or you need to reference some type from another location, remember to update ./scripts/gen-api-v1-docs.cfg file with the new paths.
- It's possible to list multiple errors for the same code using enum-like structs, that will show a proper, spec-conformant model with all entries at Swagger-UI. The drawback is that this approach requires a major refactoring and will add a lot of boilerplate code, as there are inconsistencies between API methods error responses.
```go
type signInInternalServerError string
const signInErrorFailedToFindSystemSetting signInInternalServerError = "Failed to find system setting"
const signInErrorFailedToUnmarshalSystemSetting signInInternalServerError = "Failed to unmarshal system setting"
const signInErrorIncorrectLoginCredentials signInInternalServerError = "Incorrect login credentials, please try again"
const signInErrorFailedToGenerateTokens signInInternalServerError = "Failed to generate tokens"
const signInErrorFailedToCreateActivity signInInternalServerError = "Failed to create activity"
type signInUnauthorized string
const signInErrorPasswordLoginDeactivated signInUnauthorized = "Password login is deactivated"
const signInErrorIncorrectCredentials signInUnauthorized = "Incorrect login credentials, please try again"
// signIn godoc
//
// @Summary Sign-in to memos.
// @Tags auth
// @Accept json
// @Produce json
// @Param body body SignIn true "Sign-in object"
// @Success 200 {object} store.User "User information"
// @Failure 400 {object} nil "Malformatted signin request"
// @Failure 401 {object} signInUnauthorized
// @Failure 403 {object} nil "User has been archived with username {username}"
// @Failure 500 {object} signInInternalServerError
// @Router /api/v1/auth/signin [POST]
func (s *APIV1Service) signIn(c echo.Context) error {
...
```
### Step-by-step (no scripts)
#### Required tools
```bash
# Swag v1.8.12 or newer
# Also updates swag if needed
$ go install github.com/swaggo/swag/cmd/swag@latest
```
If `$HOME/go/bin` is not in your `PATH`, you can call `swag` directly at `$HOME/go/bin/swag`.
#### Generate the documentation
1. Run `swag fmt` to format the comments
```bash
swag fmt --dir ./api/v1 && go fmt
```
2. Run `swag init` to generate the documentation
```bash
cd <project-root>
swag init --output ./api/v1 --generalInfo ./api/v1/v1.go --dir ./,./api/v1
```
> If the API gets a new version, you'll need to add the file system path to swag's `--dir` parameter.

98
docs/windows-service.md Normal file
View File

@@ -0,0 +1,98 @@
# Installing memos as a service on Windows
While memos first-class support is for Docker, you may also install memos as a Windows service. It will run under SYSTEM account and start automatically at system boot.
❗ All service management methods requires admin privileges. Use [gsudo](https://gerardog.github.io/gsudo/docs/install), or open a new PowerShell terminal as admin:
```powershell
Start-Process powershell -Verb RunAs
```
## Choose one of the following methods
### 1. Using [NSSM](https://nssm.cc/download)
NSSM is a lightweight service wrapper.
You may put `nssm.exe` in the same directory as `memos.exe`, or add its directory to your system PATH. Prefer the latest 64-bit version of `nssm.exe`.
```powershell
# Install memos as a service
nssm install memos "C:\path\to\memos.exe" --mode prod --port 5230
# Delay auto start
nssm set memos DisplayName "memos service"
# Configure extra service parameters
nssm set memos Description "A lightweight, self-hosted memo hub. https://usememos.com/"
# Delay auto start
nssm set memos Start SERVICE_DELAYED_AUTO_START
# Edit service using NSSM GUI
nssm edit memos
# Start the service
nssm start memos
# Remove the service, if ever needed
nssm remove memos confirm
```
### 2. Using [WinSW](https://github.com/winsw/winsw)
Find the latest release tag and download the asset `WinSW-net46x.exe`. Then, put it in the same directory as `memos.exe` and rename it to `memos-service.exe`.
Now, in the same directory, create the service configuration file `memos-service.xml`:
```xml
<service>
<id>memos</id>
<name>memos service</name>
<description>A lightweight, self-hosted memo hub. https://usememos.com/</description>
<onfailure action="restart" delay="10 sec"/>
<executable>%BASE%\memos.exe</executable>
<arguments>--mode prod --port 5230</arguments>
<delayedAutoStart>true</delayedAutoStart>
<log mode="none" />
</service>
```
Then, install the service:
```powershell
# Install the service
.\memos-service.exe install
# Start the service
.\memos-service.exe start
# Remove the service, if ever needed
.\memos-service.exe uninstall
```
### Manage the service
You may use the `net` command to manage the service:
```powershell
net start memos
net stop memos
```
Also, by using one of the provided methods, the service will appear in the Windows Services Manager `services.msc`.
## Notes
- On Windows, memos store its data in the following directory:
```powershell
$env:ProgramData\memos
# Typically, this will resolve to C:\ProgramData\memos
```
You may specify a custom directory by appending `--data <path>` to the service command line.
- If the service fails to start, you should inspect the Windows Event Viewer `eventvwr.msc`.
- Memos will be accessible at [http://localhost:5230](http://localhost:5230) by default.

147
go.mod
View File

@@ -1,38 +1,123 @@
module github.com/usememos/memos
go 1.17
require github.com/mattn/go-sqlite3 v1.14.9
require github.com/google/uuid v1.3.0
go 1.21
require (
github.com/aws/aws-sdk-go-v2 v1.22.1
github.com/aws/aws-sdk-go-v2/config v1.22.1
github.com/aws/aws-sdk-go-v2/credentials v1.15.1
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.13.2
github.com/aws/aws-sdk-go-v2/service/s3 v1.42.0
github.com/disintegration/imaging v1.6.2
github.com/go-sql-driver/mysql v1.7.1
github.com/google/cel-go v0.18.1
github.com/google/uuid v1.4.0
github.com/gorilla/feeds v1.1.2
github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1
github.com/improbable-eng/grpc-web v0.15.0
github.com/labstack/echo/v4 v4.11.2
github.com/microcosm-cc/bluemonday v1.0.26
github.com/pkg/errors v0.9.1
github.com/spf13/cobra v1.8.0
github.com/spf13/viper v1.17.0
github.com/stretchr/testify v1.8.4
github.com/swaggo/echo-swagger v1.4.1
github.com/swaggo/swag v1.16.2
github.com/yuin/goldmark v1.6.0
go.uber.org/zap v1.26.0
golang.org/x/crypto v0.14.0
golang.org/x/exp v0.0.0-20231006140011-7918f672742d
golang.org/x/mod v0.14.0
golang.org/x/net v0.17.0
golang.org/x/oauth2 v0.13.0
google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17
google.golang.org/grpc v1.59.0
modernc.org/sqlite v1.27.0
)
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230305170008-8188dc5388df // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
github.com/go-openapi/jsonpointer v0.20.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/spec v0.20.9 // indirect
github.com/go-openapi/swag v0.22.4 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rs/cors v1.10.1 // indirect
github.com/sagikazarmark/locafero v0.3.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/stoewer/go-strcase v1.3.0 // indirect
github.com/swaggo/files/v2 v2.0.0 // indirect
golang.org/x/image v0.13.0 // indirect
golang.org/x/tools v0.14.0 // indirect
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
lukechampine.com/uint128 v1.3.0 // indirect
modernc.org/cc/v3 v3.41.0 // indirect
modernc.org/ccgo/v3 v3.16.15 // indirect
modernc.org/libc v1.30.0 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
nhooyr.io/websocket v1.8.10 // indirect
)
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.0 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.5.0 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.0 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.17.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.19.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.25.0 // indirect
github.com/aws/smithy-go v1.16.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/golang-jwt/jwt/v4 v4.5.0
github.com/golang/protobuf v1.5.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/labstack/gommon v0.4.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/posthog/posthog-go v0.0.0-20230801140217-d607812dee69
github.com/spf13/afero v1.10.0 // indirect
github.com/spf13/cast v1.5.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.1 // indirect
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa
golang.org/x/net v0.0.0-20220728030405-41545e8bf201 // indirect
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 // indirect
)
require (
github.com/gorilla/context v1.1.1 // indirect
github.com/labstack/echo/v4 v4.9.0
github.com/labstack/gommon v0.3.1 // indirect
)
require (
github.com/VictoriaMetrics/fastcache v1.10.0
github.com/gorilla/securecookie v1.1.1
github.com/gorilla/sessions v1.2.1
github.com/labstack/echo-contrib v0.13.0
)
require (
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sys v0.14.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.4.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.31.0
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

712
go.sum

File diff suppressed because it is too large Load Diff

179
internal/cron/cron.go Normal file
View File

@@ -0,0 +1,179 @@
// Package cron implements a crontab-like service to execute and schedule repeative tasks/jobs.
package cron
// Example:
//
// c := cron.New()
// c.MustAdd("dailyReport", "0 0 * * *", func() { ... })
// c.Start()
import (
"sync"
"time"
"github.com/pkg/errors"
)
type job struct {
schedule *Schedule
run func()
}
// Cron is a crontab-like struct for tasks/jobs scheduling.
type Cron struct {
sync.RWMutex
interval time.Duration
timezone *time.Location
ticker *time.Ticker
jobs map[string]*job
}
// New create a new Cron struct with default tick interval of 1 minute
// and timezone in UTC.
//
// You can change the default tick interval with Cron.SetInterval().
// You can change the default timezone with Cron.SetTimezone().
func New() *Cron {
return &Cron{
interval: 1 * time.Minute,
timezone: time.UTC,
jobs: map[string]*job{},
}
}
// SetInterval changes the current cron tick interval
// (it usually should be >= 1 minute).
func (c *Cron) SetInterval(d time.Duration) {
// update interval
c.Lock()
wasStarted := c.ticker != nil
c.interval = d
c.Unlock()
// restart the ticker
if wasStarted {
c.Start()
}
}
// SetTimezone changes the current cron tick timezone.
func (c *Cron) SetTimezone(l *time.Location) {
c.Lock()
defer c.Unlock()
c.timezone = l
}
// MustAdd is similar to Add() but panic on failure.
func (c *Cron) MustAdd(jobID string, cronExpr string, run func()) {
if err := c.Add(jobID, cronExpr, run); err != nil {
panic(err)
}
}
// Add registers a single cron job.
//
// If there is already a job with the provided id, then the old job
// will be replaced with the new one.
//
// cronExpr is a regular cron expression, eg. "0 */3 * * *" (aka. at minute 0 past every 3rd hour).
// Check cron.NewSchedule() for the supported tokens.
func (c *Cron) Add(jobID string, cronExpr string, run func()) error {
if run == nil {
return errors.New("failed to add new cron job: run must be non-nil function")
}
c.Lock()
defer c.Unlock()
schedule, err := NewSchedule(cronExpr)
if err != nil {
return errors.Wrap(err, "failed to add new cron job")
}
c.jobs[jobID] = &job{
schedule: schedule,
run: run,
}
return nil
}
// Remove removes a single cron job by its id.
func (c *Cron) Remove(jobID string) {
c.Lock()
defer c.Unlock()
delete(c.jobs, jobID)
}
// RemoveAll removes all registered cron jobs.
func (c *Cron) RemoveAll() {
c.Lock()
defer c.Unlock()
c.jobs = map[string]*job{}
}
// Total returns the current total number of registered cron jobs.
func (c *Cron) Total() int {
c.RLock()
defer c.RUnlock()
return len(c.jobs)
}
// Stop stops the current cron ticker (if not already).
//
// You can resume the ticker by calling Start().
func (c *Cron) Stop() {
c.Lock()
defer c.Unlock()
if c.ticker == nil {
return // already stopped
}
c.ticker.Stop()
c.ticker = nil
}
// Start starts the cron ticker.
//
// Calling Start() on already started cron will restart the ticker.
func (c *Cron) Start() {
c.Stop()
c.Lock()
c.ticker = time.NewTicker(c.interval)
c.Unlock()
go func() {
for t := range c.ticker.C {
c.runDue(t)
}
}()
}
// HasStarted checks whether the current Cron ticker has been started.
func (c *Cron) HasStarted() bool {
c.RLock()
defer c.RUnlock()
return c.ticker != nil
}
// runDue runs all registered jobs that are scheduled for the provided time.
func (c *Cron) runDue(t time.Time) {
c.RLock()
defer c.RUnlock()
moment := NewMoment(t.In(c.timezone))
for _, j := range c.jobs {
if j.schedule.IsDue(moment) {
go j.run()
}
}
}

249
internal/cron/cron_test.go Normal file
View File

@@ -0,0 +1,249 @@
package cron
import (
"encoding/json"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestCronNew(t *testing.T) {
c := New()
expectedInterval := 1 * time.Minute
if c.interval != expectedInterval {
t.Fatalf("Expected default interval %v, got %v", expectedInterval, c.interval)
}
expectedTimezone := time.UTC
if c.timezone.String() != expectedTimezone.String() {
t.Fatalf("Expected default timezone %v, got %v", expectedTimezone, c.timezone)
}
if len(c.jobs) != 0 {
t.Fatalf("Expected no jobs by default, got \n%v", c.jobs)
}
if c.ticker != nil {
t.Fatal("Expected the ticker NOT to be initialized")
}
}
func TestCronSetInterval(t *testing.T) {
c := New()
interval := 2 * time.Minute
c.SetInterval(interval)
if c.interval != interval {
t.Fatalf("Expected interval %v, got %v", interval, c.interval)
}
}
func TestCronSetTimezone(t *testing.T) {
c := New()
timezone, _ := time.LoadLocation("Asia/Tokyo")
c.SetTimezone(timezone)
if c.timezone.String() != timezone.String() {
t.Fatalf("Expected timezone %v, got %v", timezone, c.timezone)
}
}
func TestCronAddAndRemove(t *testing.T) {
c := New()
if err := c.Add("test0", "* * * * *", nil); err == nil {
t.Fatal("Expected nil function error")
}
if err := c.Add("test1", "invalid", func() {}); err == nil {
t.Fatal("Expected invalid cron expression error")
}
if err := c.Add("test2", "* * * * *", func() {}); err != nil {
t.Fatal(err)
}
if err := c.Add("test3", "* * * * *", func() {}); err != nil {
t.Fatal(err)
}
if err := c.Add("test4", "* * * * *", func() {}); err != nil {
t.Fatal(err)
}
// overwrite test2
if err := c.Add("test2", "1 2 3 4 5", func() {}); err != nil {
t.Fatal(err)
}
if err := c.Add("test5", "1 2 3 4 5", func() {}); err != nil {
t.Fatal(err)
}
// mock job deletion
c.Remove("test4")
// try to remove non-existing (should be no-op)
c.Remove("missing")
// check job keys
{
expectedKeys := []string{"test3", "test2", "test5"}
if v := len(c.jobs); v != len(expectedKeys) {
t.Fatalf("Expected %d jobs, got %d", len(expectedKeys), v)
}
for _, k := range expectedKeys {
if c.jobs[k] == nil {
t.Fatalf("Expected job with key %s, got nil", k)
}
}
}
// check the jobs schedule
{
expectedSchedules := map[string]string{
"test2": `{"minutes":{"1":{}},"hours":{"2":{}},"days":{"3":{}},"months":{"4":{}},"daysOfWeek":{"5":{}}}`,
"test3": `{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
"test5": `{"minutes":{"1":{}},"hours":{"2":{}},"days":{"3":{}},"months":{"4":{}},"daysOfWeek":{"5":{}}}`,
}
for k, v := range expectedSchedules {
raw, err := json.Marshal(c.jobs[k].schedule)
if err != nil {
t.Fatal(err)
}
if string(raw) != v {
t.Fatalf("Expected %q schedule \n%s, \ngot \n%s", k, v, raw)
}
}
}
}
func TestCronMustAdd(t *testing.T) {
c := New()
defer func() {
if r := recover(); r == nil {
t.Errorf("test1 didn't panic")
}
}()
c.MustAdd("test1", "* * * * *", nil)
c.MustAdd("test2", "* * * * *", func() {})
if _, ok := c.jobs["test2"]; !ok {
t.Fatal("Couldn't find job test2")
}
}
func TestCronRemoveAll(t *testing.T) {
c := New()
if err := c.Add("test1", "* * * * *", func() {}); err != nil {
t.Fatal(err)
}
if err := c.Add("test2", "* * * * *", func() {}); err != nil {
t.Fatal(err)
}
if err := c.Add("test3", "* * * * *", func() {}); err != nil {
t.Fatal(err)
}
if v := len(c.jobs); v != 3 {
t.Fatalf("Expected %d jobs, got %d", 3, v)
}
c.RemoveAll()
if v := len(c.jobs); v != 0 {
t.Fatalf("Expected %d jobs, got %d", 0, v)
}
}
func TestCronTotal(t *testing.T) {
c := New()
if v := c.Total(); v != 0 {
t.Fatalf("Expected 0 jobs, got %v", v)
}
if err := c.Add("test1", "* * * * *", func() {}); err != nil {
t.Fatal(err)
}
if err := c.Add("test2", "* * * * *", func() {}); err != nil {
t.Fatal(err)
}
// overwrite
if err := c.Add("test1", "* * * * *", func() {}); err != nil {
t.Fatal(err)
}
if v := c.Total(); v != 2 {
t.Fatalf("Expected 2 jobs, got %v", v)
}
}
func TestCronStartStop(t *testing.T) {
c := New()
c.SetInterval(1 * time.Second)
test1 := 0
test2 := 0
err := c.Add("test1", "* * * * *", func() {
test1++
})
require.NoError(t, err)
err = c.Add("test2", "* * * * *", func() {
test2++
})
require.NoError(t, err)
expectedCalls := 3
// call twice Start to check if the previous ticker will be reseted
c.Start()
c.Start()
time.Sleep(3250 * time.Millisecond)
// call twice Stop to ensure that the second stop is no-op
c.Stop()
c.Stop()
if test1 != expectedCalls {
t.Fatalf("Expected %d test1, got %d", expectedCalls, test1)
}
if test2 != expectedCalls {
t.Fatalf("Expected %d test2, got %d", expectedCalls, test2)
}
// resume for ~5 seconds
c.Start()
time.Sleep(5250 * time.Millisecond)
c.Stop()
expectedCalls += 5
if test1 != expectedCalls {
t.Fatalf("Expected %d test1, got %d", expectedCalls, test1)
}
if test2 != expectedCalls {
t.Fatalf("Expected %d test2, got %d", expectedCalls, test2)
}
}

194
internal/cron/schedule.go Normal file
View File

@@ -0,0 +1,194 @@
package cron
import (
"strconv"
"strings"
"time"
"github.com/pkg/errors"
)
// Moment represents a parsed single time moment.
type Moment struct {
Minute int `json:"minute"`
Hour int `json:"hour"`
Day int `json:"day"`
Month int `json:"month"`
DayOfWeek int `json:"dayOfWeek"`
}
// NewMoment creates a new Moment from the specified time.
func NewMoment(t time.Time) *Moment {
return &Moment{
Minute: t.Minute(),
Hour: t.Hour(),
Day: t.Day(),
Month: int(t.Month()),
DayOfWeek: int(t.Weekday()),
}
}
// Schedule stores parsed information for each time component when a cron job should run.
type Schedule struct {
Minutes map[int]struct{} `json:"minutes"`
Hours map[int]struct{} `json:"hours"`
Days map[int]struct{} `json:"days"`
Months map[int]struct{} `json:"months"`
DaysOfWeek map[int]struct{} `json:"daysOfWeek"`
}
// IsDue checks whether the provided Moment satisfies the current Schedule.
func (s *Schedule) IsDue(m *Moment) bool {
if _, ok := s.Minutes[m.Minute]; !ok {
return false
}
if _, ok := s.Hours[m.Hour]; !ok {
return false
}
if _, ok := s.Days[m.Day]; !ok {
return false
}
if _, ok := s.DaysOfWeek[m.DayOfWeek]; !ok {
return false
}
if _, ok := s.Months[m.Month]; !ok {
return false
}
return true
}
// NewSchedule creates a new Schedule from a cron expression.
//
// A cron expression is consisted of 5 segments separated by space,
// representing: minute, hour, day of the month, month and day of the week.
//
// Each segment could be in the following formats:
// - wildcard: *
// - range: 1-30
// - step: */n or 1-30/n
// - list: 1,2,3,10-20/n
func NewSchedule(cronExpr string) (*Schedule, error) {
segments := strings.Split(cronExpr, " ")
if len(segments) != 5 {
return nil, errors.New("invalid cron expression - must have exactly 5 space separated segments")
}
minutes, err := parseCronSegment(segments[0], 0, 59)
if err != nil {
return nil, err
}
hours, err := parseCronSegment(segments[1], 0, 23)
if err != nil {
return nil, err
}
days, err := parseCronSegment(segments[2], 1, 31)
if err != nil {
return nil, err
}
months, err := parseCronSegment(segments[3], 1, 12)
if err != nil {
return nil, err
}
daysOfWeek, err := parseCronSegment(segments[4], 0, 6)
if err != nil {
return nil, err
}
return &Schedule{
Minutes: minutes,
Hours: hours,
Days: days,
Months: months,
DaysOfWeek: daysOfWeek,
}, nil
}
// parseCronSegment parses a single cron expression segment and
// returns its time schedule slots.
func parseCronSegment(segment string, min int, max int) (map[int]struct{}, error) {
slots := map[int]struct{}{}
list := strings.Split(segment, ",")
for _, p := range list {
stepParts := strings.Split(p, "/")
// step (*/n, 1-30/n)
var step int
switch len(stepParts) {
case 1:
step = 1
case 2:
parsedStep, err := strconv.Atoi(stepParts[1])
if err != nil {
return nil, err
}
if parsedStep < 1 || parsedStep > max {
return nil, errors.Errorf("invalid segment step boundary - the step must be between 1 and the %d", max)
}
step = parsedStep
default:
return nil, errors.New("invalid segment step format - must be in the format */n or 1-30/n")
}
// find the min and max range of the segment part
var rangeMin, rangeMax int
if stepParts[0] == "*" {
rangeMin = min
rangeMax = max
} else {
// single digit (1) or range (1-30)
rangeParts := strings.Split(stepParts[0], "-")
switch len(rangeParts) {
case 1:
if step != 1 {
return nil, errors.New("invalid segement step - step > 1 could be used only with the wildcard or range format")
}
parsed, err := strconv.Atoi(rangeParts[0])
if err != nil {
return nil, err
}
if parsed < min || parsed > max {
return nil, errors.New("invalid segment value - must be between the min and max of the segment")
}
rangeMin = parsed
rangeMax = rangeMin
case 2:
parsedMin, err := strconv.Atoi(rangeParts[0])
if err != nil {
return nil, err
}
if parsedMin < min || parsedMin > max {
return nil, errors.Errorf("invalid segment range minimum - must be between %d and %d", min, max)
}
rangeMin = parsedMin
parsedMax, err := strconv.Atoi(rangeParts[1])
if err != nil {
return nil, err
}
if parsedMax < parsedMin || parsedMax > max {
return nil, errors.Errorf("invalid segment range maximum - must be between %d and %d", rangeMin, max)
}
rangeMax = parsedMax
default:
return nil, errors.New("invalid segment range format - the range must have 1 or 2 parts")
}
}
// fill the slots
for i := rangeMin; i <= rangeMax; i += step {
slots[i] = struct{}{}
}
}
return slots, nil
}

View File

@@ -0,0 +1,361 @@
package cron_test
import (
"encoding/json"
"testing"
"time"
"github.com/usememos/memos/internal/cron"
)
func TestNewMoment(t *testing.T) {
date, err := time.Parse("2006-01-02 15:04", "2023-05-09 15:20")
if err != nil {
t.Fatal(err)
}
m := cron.NewMoment(date)
if m.Minute != 20 {
t.Fatalf("Expected m.Minute %d, got %d", 20, m.Minute)
}
if m.Hour != 15 {
t.Fatalf("Expected m.Hour %d, got %d", 15, m.Hour)
}
if m.Day != 9 {
t.Fatalf("Expected m.Day %d, got %d", 9, m.Day)
}
if m.Month != 5 {
t.Fatalf("Expected m.Month %d, got %d", 5, m.Month)
}
if m.DayOfWeek != 2 {
t.Fatalf("Expected m.DayOfWeek %d, got %d", 2, m.DayOfWeek)
}
}
func TestNewSchedule(t *testing.T) {
scenarios := []struct {
cronExpr string
expectError bool
expectSchedule string
}{
{
"invalid",
true,
"",
},
{
"* * * *",
true,
"",
},
{
"* * * * * *",
true,
"",
},
{
"2/3 * * * *",
true,
"",
},
{
"* * * * *",
false,
`{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
},
{
"*/2 */3 */5 */4 */2",
false,
`{"minutes":{"0":{},"10":{},"12":{},"14":{},"16":{},"18":{},"2":{},"20":{},"22":{},"24":{},"26":{},"28":{},"30":{},"32":{},"34":{},"36":{},"38":{},"4":{},"40":{},"42":{},"44":{},"46":{},"48":{},"50":{},"52":{},"54":{},"56":{},"58":{},"6":{},"8":{}},"hours":{"0":{},"12":{},"15":{},"18":{},"21":{},"3":{},"6":{},"9":{}},"days":{"1":{},"11":{},"16":{},"21":{},"26":{},"31":{},"6":{}},"months":{"1":{},"5":{},"9":{}},"daysOfWeek":{"0":{},"2":{},"4":{},"6":{}}}`,
},
// minute segment
{
"-1 * * * *",
true,
"",
},
{
"60 * * * *",
true,
"",
},
{
"0 * * * *",
false,
`{"minutes":{"0":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
},
{
"59 * * * *",
false,
`{"minutes":{"59":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
},
{
"1,2,5,7,40-50/2 * * * *",
false,
`{"minutes":{"1":{},"2":{},"40":{},"42":{},"44":{},"46":{},"48":{},"5":{},"50":{},"7":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
},
// hour segment
{
"* -1 * * *",
true,
"",
},
{
"* 24 * * *",
true,
"",
},
{
"* 0 * * *",
false,
`{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"0":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
},
{
"* 23 * * *",
false,
`{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"23":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
},
{
"* 3,4,8-16/3,7 * * *",
false,
`{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"11":{},"14":{},"3":{},"4":{},"7":{},"8":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
},
// day segment
{
"* * 0 * *",
true,
"",
},
{
"* * 32 * *",
true,
"",
},
{
"* * 1 * *",
false,
`{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"1":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
},
{
"* * 31 * *",
false,
`{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"31":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
},
{
"* * 5,6,20-30/3,1 * *",
false,
`{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"1":{},"20":{},"23":{},"26":{},"29":{},"5":{},"6":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
},
// month segment
{
"* * * 0 *",
true,
"",
},
{
"* * * 13 *",
true,
"",
},
{
"* * * 1 *",
false,
`{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"1":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
},
{
"* * * 12 *",
false,
`{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"12":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
},
{
"* * * 1,4,5-10/2 *",
false,
`{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"1":{},"4":{},"5":{},"7":{},"9":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
},
// day of week segment
{
"* * * * -1",
true,
"",
},
{
"* * * * 7",
true,
"",
},
{
"* * * * 0",
false,
`{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"0":{}}}`,
},
{
"* * * * 6",
false,
`{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"6":{}}}`,
},
{
"* * * * 1,2-5/2",
false,
`{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"1":{},"2":{},"4":{}}}`,
},
}
for _, s := range scenarios {
schedule, err := cron.NewSchedule(s.cronExpr)
hasErr := err != nil
if hasErr != s.expectError {
t.Fatalf("[%s] Expected hasErr to be %v, got %v (%v)", s.cronExpr, s.expectError, hasErr, err)
}
if hasErr {
continue
}
encoded, err := json.Marshal(schedule)
if err != nil {
t.Fatalf("[%s] Failed to marshalize the result schedule: %v", s.cronExpr, err)
}
encodedStr := string(encoded)
if encodedStr != s.expectSchedule {
t.Fatalf("[%s] Expected \n%s, \ngot \n%s", s.cronExpr, s.expectSchedule, encodedStr)
}
}
}
func TestScheduleIsDue(t *testing.T) {
scenarios := []struct {
cronExpr string
moment *cron.Moment
expected bool
}{
{
"* * * * *",
&cron.Moment{},
false,
},
{
"* * * * *",
&cron.Moment{
Minute: 1,
Hour: 1,
Day: 1,
Month: 1,
DayOfWeek: 1,
},
true,
},
{
"5 * * * *",
&cron.Moment{
Minute: 1,
Hour: 1,
Day: 1,
Month: 1,
DayOfWeek: 1,
},
false,
},
{
"5 * * * *",
&cron.Moment{
Minute: 5,
Hour: 1,
Day: 1,
Month: 1,
DayOfWeek: 1,
},
true,
},
{
"* 2-6 * * 2,3",
&cron.Moment{
Minute: 1,
Hour: 2,
Day: 1,
Month: 1,
DayOfWeek: 1,
},
false,
},
{
"* 2-6 * * 2,3",
&cron.Moment{
Minute: 1,
Hour: 2,
Day: 1,
Month: 1,
DayOfWeek: 3,
},
true,
},
{
"* * 1,2,5,15-18 * *",
&cron.Moment{
Minute: 1,
Hour: 1,
Day: 6,
Month: 1,
DayOfWeek: 1,
},
false,
},
{
"* * 1,2,5,15-18/2 * *",
&cron.Moment{
Minute: 1,
Hour: 1,
Day: 2,
Month: 1,
DayOfWeek: 1,
},
true,
},
{
"* * 1,2,5,15-18/2 * *",
&cron.Moment{
Minute: 1,
Hour: 1,
Day: 18,
Month: 1,
DayOfWeek: 1,
},
false,
},
{
"* * 1,2,5,15-18/2 * *",
&cron.Moment{
Minute: 1,
Hour: 1,
Day: 17,
Month: 1,
DayOfWeek: 1,
},
true,
},
}
for i, s := range scenarios {
schedule, err := cron.NewSchedule(s.cronExpr)
if err != nil {
t.Fatalf("[%d-%s] Unexpected cron error: %v", i, s.cronExpr, err)
}
result := schedule.IsDue(s.moment)
if result != s.expected {
t.Fatalf("[%d-%s] Expected %v, got %v", i, s.cronExpr, s.expected, result)
}
}
}

66
internal/log/logger.go Normal file
View File

@@ -0,0 +1,66 @@
package log
import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var (
// `gl` is the global logger.
// Other packages should use public methods such as Info/Error to do the logging.
// For other types of logging, e.g. logging to a separate file, they should use their own loggers.
gl *zap.Logger
gLevel zap.AtomicLevel
)
// Initializes the global console logger.
func init() {
gLevel = zap.NewAtomicLevelAt(zap.InfoLevel)
gl, _ = zap.Config{
Level: gLevel,
Development: true,
// Use "console" to print readable stacktrace.
Encoding: "console",
EncoderConfig: zap.NewDevelopmentEncoderConfig(),
OutputPaths: []string{"stderr"},
ErrorOutputPaths: []string{"stderr"},
}.Build(
// Skip one caller stack to locate the correct caller.
zap.AddCallerSkip(1),
)
}
// SetLevel wraps the zap Level's SetLevel method.
func SetLevel(level zapcore.Level) {
gLevel.SetLevel(level)
}
// EnabledLevel wraps the zap Level's Enabled method.
func EnabledLevel(level zapcore.Level) bool {
return gLevel.Enabled(level)
}
// Debug wraps the zap Logger's Debug method.
func Debug(msg string, fields ...zap.Field) {
gl.Debug(msg, fields...)
}
// Info wraps the zap Logger's Info method.
func Info(msg string, fields ...zap.Field) {
gl.Info(msg, fields...)
}
// Warn wraps the zap Logger's Warn method.
func Warn(msg string, fields ...zap.Field) {
gl.Warn(msg, fields...)
}
// Error wraps the zap Logger's Error method.
func Error(msg string, fields ...zap.Field) {
gl.Error(msg, fields...)
}
// Sync wraps the zap Logger's Sync method.
func Sync() {
_ = gl.Sync()
}

70
internal/util/util.go Normal file
View File

@@ -0,0 +1,70 @@
package util
import (
"crypto/rand"
"math/big"
"net/mail"
"strconv"
"strings"
"github.com/google/uuid"
)
// ConvertStringToInt32 converts a string to int32.
func ConvertStringToInt32(src string) (int32, error) {
parsed, err := strconv.ParseInt(src, 10, 32)
if err != nil {
return 0, err
}
return int32(parsed), nil
}
// HasPrefixes returns true if the string s has any of the given prefixes.
func HasPrefixes(src string, prefixes ...string) bool {
for _, prefix := range prefixes {
if strings.HasPrefix(src, prefix) {
return true
}
}
return false
}
// ValidateEmail validates the email.
func ValidateEmail(email string) bool {
if _, err := mail.ParseAddress(email); err != nil {
return false
}
return true
}
func GenUUID() string {
return uuid.New().String()
}
func Min(x, y int) int {
if x < y {
return x
}
return y
}
var letters = []rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
// RandomString returns a random string with length n.
func RandomString(n int) (string, error) {
var sb strings.Builder
sb.Grow(n)
for i := 0; i < n; i++ {
// The reason for using crypto/rand instead of math/rand is that
// the former relies on hardware to generate random numbers and
// thus has a stronger source of random numbers.
randNum, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
if err != nil {
return "", err
}
if _, err := sb.WriteRune(letters[randNum.Uint64()]); err != nil {
return "", err
}
}
return sb.String(), nil
}

View File

@@ -1,4 +1,4 @@
package common
package util
import (
"testing"

15
main.go Normal file
View File

@@ -0,0 +1,15 @@
package main
import (
_ "github.com/go-sql-driver/mysql"
_ "modernc.org/sqlite"
"github.com/usememos/memos/cmd"
)
func main() {
err := cmd.Execute()
if err != nil {
panic(err)
}
}

3
plugin/gomark/README.md Normal file
View File

@@ -0,0 +1,3 @@
# gomark
A markdown parser for memos. WIP

19
plugin/gomark/ast/ast.go Normal file
View File

@@ -0,0 +1,19 @@
package ast
type Node struct {
Type string
Text string
Children []*Node
}
type Document struct {
Nodes []*Node
}
func NewDocument() *Document {
return &Document{}
}
func (d *Document) AddNode(node *Node) {
d.Nodes = append(d.Nodes, node)
}

12
plugin/gomark/ast/node.go Normal file
View File

@@ -0,0 +1,12 @@
package ast
func NewNode(tp, text string) *Node {
return &Node{
Type: tp,
Text: text,
}
}
func (n *Node) AddChild(child *Node) {
n.Children = append(n.Children, child)
}

1
plugin/gomark/gomark.go Normal file
View File

@@ -0,0 +1 @@
package gomark

View File

@@ -0,0 +1,49 @@
package parser
import (
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
)
type BoldParser struct {
ContentTokens []*tokenizer.Token
}
func NewBoldParser() *BoldParser {
return &BoldParser{}
}
func (*BoldParser) Match(tokens []*tokenizer.Token) *BoldParser {
if len(tokens) < 5 {
return nil
}
prefixTokens := tokens[:2]
if prefixTokens[0].Type != prefixTokens[1].Type {
return nil
}
prefixTokenType := prefixTokens[0].Type
if prefixTokenType != tokenizer.Star && prefixTokenType != tokenizer.Underline {
return nil
}
contentTokens := []*tokenizer.Token{}
cursor, matched := 2, false
for ; cursor < len(tokens)-1; cursor++ {
token, nextToken := tokens[cursor], tokens[cursor+1]
if token.Type == tokenizer.Newline || nextToken.Type == tokenizer.Newline {
return nil
}
if token.Type == prefixTokenType && nextToken.Type == prefixTokenType {
matched = true
break
}
contentTokens = append(contentTokens, token)
}
if !matched {
return nil
}
return &BoldParser{
ContentTokens: contentTokens,
}
}

View File

@@ -0,0 +1,89 @@
package parser
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
)
func TestBoldParser(t *testing.T) {
tests := []struct {
text string
bold *BoldParser
}{
{
text: "*Hello world!",
bold: nil,
},
{
text: "**Hello**",
bold: &BoldParser{
ContentTokens: []*tokenizer.Token{
{
Type: tokenizer.Text,
Value: "Hello",
},
},
},
},
{
text: "** Hello **",
bold: &BoldParser{
ContentTokens: []*tokenizer.Token{
{
Type: tokenizer.Space,
Value: " ",
},
{
Type: tokenizer.Text,
Value: "Hello",
},
{
Type: tokenizer.Space,
Value: " ",
},
},
},
},
{
text: "** Hello * *",
bold: nil,
},
{
text: "* * Hello **",
bold: nil,
},
{
text: `** Hello
**`,
bold: nil,
},
{
text: `**Hello \n**`,
bold: &BoldParser{
ContentTokens: []*tokenizer.Token{
{
Type: tokenizer.Text,
Value: "Hello",
},
{
Type: tokenizer.Space,
Value: " ",
},
{
Type: tokenizer.Text,
Value: `\n`,
},
},
},
},
}
for _, test := range tests {
tokens := tokenizer.Tokenize(test.text)
bold := NewBoldParser()
require.Equal(t, test.bold, bold.Match(tokens))
}
}

View File

@@ -0,0 +1,38 @@
package parser
import "github.com/usememos/memos/plugin/gomark/parser/tokenizer"
type CodeParser struct {
Content string
}
func NewCodeParser() *CodeParser {
return &CodeParser{}
}
func (*CodeParser) Match(tokens []*tokenizer.Token) *CodeParser {
if len(tokens) < 3 {
return nil
}
if tokens[0].Type != tokenizer.Backtick {
return nil
}
content, matched := "", false
for _, token := range tokens[1:] {
if token.Type == tokenizer.Newline {
return nil
}
if token.Type == tokenizer.Backtick {
matched = true
break
}
content += token.Value
}
if !matched || len(content) == 0 {
return nil
}
return &CodeParser{
Content: content,
}
}

View File

@@ -0,0 +1,52 @@
package parser
import "github.com/usememos/memos/plugin/gomark/parser/tokenizer"
type CodeBlockParser struct {
Language string
Content string
}
func NewCodeBlockParser() *CodeBlockParser {
return &CodeBlockParser{}
}
func (*CodeBlockParser) Match(tokens []*tokenizer.Token) *CodeBlockParser {
if len(tokens) < 9 {
return nil
}
if tokens[0].Type != tokenizer.Backtick || tokens[1].Type != tokenizer.Backtick || tokens[2].Type != tokenizer.Backtick {
return nil
}
if tokens[3].Type != tokenizer.Newline && tokens[4].Type != tokenizer.Newline {
return nil
}
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
}
}
content += tokens[cursor].Value
}
if !matched {
return nil
}
return &CodeBlockParser{
Language: language,
Content: content,
}
}

View File

@@ -0,0 +1,63 @@
package parser
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
)
func TestCodeBlockParser(t *testing.T) {
tests := []struct {
text string
codeBlock *CodeBlockParser
}{
{
text: "```Hello world!```",
codeBlock: nil,
},
{
text: "```\nHello\n```",
codeBlock: &CodeBlockParser{
Language: "",
Content: "Hello",
},
},
{
text: "```\nHello world!\n```",
codeBlock: &CodeBlockParser{
Language: "",
Content: "Hello world!",
},
},
{
text: "```java\nHello \n world!\n```",
codeBlock: &CodeBlockParser{
Language: "java",
Content: "Hello \n world!",
},
},
{
text: "```java\nHello \n world!\n```111",
codeBlock: nil,
},
{
text: "```java\nHello \n world!\n``` 111",
codeBlock: nil,
},
{
text: "```java\nHello \n world!\n```\n123123",
codeBlock: &CodeBlockParser{
Language: "java",
Content: "Hello \n world!",
},
},
}
for _, test := range tests {
tokens := tokenizer.Tokenize(test.text)
codeBlock := NewCodeBlockParser()
require.Equal(t, test.codeBlock, codeBlock.Match(tokens))
}
}

View File

@@ -0,0 +1,37 @@
package parser
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
)
func TestCodeParser(t *testing.T) {
tests := []struct {
text string
code *CodeParser
}{
{
text: "`Hello world!",
code: nil,
},
{
text: "`Hello world!`",
code: &CodeParser{
Content: "Hello world!",
},
},
{
text: "`Hello \nworld!`",
code: nil,
},
}
for _, test := range tests {
tokens := tokenizer.Tokenize(test.text)
code := NewCodeParser()
require.Equal(t, test.code, code.Match(tokens))
}
}

View File

@@ -0,0 +1,53 @@
package parser
import (
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
)
type HeadingParser struct {
Level int
ContentTokens []*tokenizer.Token
}
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
}
}
if len(tokens) <= cursor+1 {
return nil
}
if tokens[cursor].Type != tokenizer.Space {
return nil
}
level := cursor
if level == 0 || level > 6 {
return nil
}
cursor++
contentTokens := []*tokenizer.Token{}
for _, token := range tokens[cursor:] {
if token.Type == tokenizer.Newline {
break
}
contentTokens = append(contentTokens, token)
cursor++
}
if len(contentTokens) == 0 {
return nil
}
return &HeadingParser{
Level: level,
ContentTokens: contentTokens,
}
}

Some files were not shown because too many files have changed in this diff Show More