Compare commits

...

293 Commits

Author SHA1 Message Date
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
396 changed files with 25567 additions and 15455 deletions

View File

@@ -23,7 +23,8 @@ jobs:
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
args: -v
version: v1.52.0
args: -v --timeout=3m
skip-cache: true
go-tests:

View File

@@ -9,6 +9,9 @@ on:
jobs:
build-and-push-release-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v3
@@ -27,6 +30,13 @@ 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
@@ -34,12 +44,26 @@ jobs:
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 }}
type=semver,pattern={{major}},value=${{ env.VERSION }}
- name: Build and Push
id: docker_build
uses: docker/build-push-action@v3
with:
context: ./
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
tags: neosmemo/memos:latest, neosmemo/memos:${{ env.VERSION }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -7,6 +7,9 @@ on:
jobs:
build-and-push-test-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v3
@@ -19,6 +22,13 @@ 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
@@ -26,6 +36,18 @@ jobs:
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
@@ -34,4 +56,5 @@ jobs:
file: ./Dockerfile
platforms: linux/amd64
push: true
tags: neosmemo/memos:test
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

87
.github/workflows/build-artifacts.yml vendored Normal file
View File

@@ -0,0 +1,87 @@
name: build-artifacts
on:
push:
branches:
# Run on pushing branches like `release/1.0.0`
- "release/*.*.*"
jobs:
build-artifacts:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
goarch: [amd64, arm64]
include:
- os: windows-latest
goos: windows
goarch: amd64
cgo_env: CC=x86_64-w64-mingw32-gcc
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 1
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Clone Memos
run: git clone https://github.com/usememos/memos.git
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: "18"
- name: Build frontend (Windows)
if: matrix.os == 'windows-latest'
shell: pwsh
run: |
cd memos/web
npm install -g pnpm
pnpm i --frozen-lockfile
pnpm build
Remove-Item -Path ../server/dist -Recurse -Force
mv dist ../server/
- name: Build frontend (non-Windows)
if: matrix.os != 'windows-latest'
run: |
cd memos/web
npm install -g pnpm
pnpm i --frozen-lockfile
pnpm build
rm -rf ../server/dist
mv dist ../server/
- name: Setup Go
uses: actions/setup-go@v3
with:
go-version: 1.19
- name: Install mingw-w64 (Windows)
if: matrix.os == 'windows-latest'
run: |
choco install mingw
echo ${{ matrix.cgo_env }} >> $GITHUB_ENV
- name: Install gcc-aarch64-linux-gnu (Ubuntu ARM64)
if: matrix.os == 'ubuntu-latest' && matrix.goarch == 'arm64'
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu
echo "CC=aarch64-linux-gnu-gcc" >> $GITHUB_ENV
- name: Build backend
run: |
cd memos
go build -o memos-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.os == 'windows-latest' && '.exe' || '' }} ./main.go
- name: Upload artifacts
uses: actions/upload-artifact@v2
with:
name: memos-binary-${{ matrix.os }}-${{ matrix.goarch }}
path: memos/memos-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.os == 'windows-latest' && '.exe' || '' }}

View File

@@ -11,28 +11,34 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2.2.4
with:
version: 8
- uses: actions/setup-node@v3
with:
node-version: "18"
cache: yarn
cache-dependency-path: "web/yarn.lock"
- run: yarn
cache: pnpm
cache-dependency-path: "web/pnpm-lock.yaml"
- run: pnpm install
working-directory: web
- name: Run eslint check
run: yarn lint
run: pnpm lint
working-directory: web
frontend-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2.2.4
with:
version: 8
- uses: actions/setup-node@v3
with:
node-version: "18"
cache: yarn
cache-dependency-path: "web/yarn.lock"
- run: yarn
cache: pnpm
cache-dependency-path: "web/pnpm-lock.yaml"
- run: pnpm install
working-directory: web
- name: Run frontend build
run: yarn build
run: pnpm build
working-directory: web

View File

@@ -64,17 +64,11 @@ jobs:
name: preview-spec
path: docker-compose.rendered.yml
retention-days: 2
- name: Serialize PR Event to File
run: |
cat << EOF > event.json
${{ toJSON(github.event) }}
EOF
- name: Upload PR Event as Artifact
uses: actions/upload-artifact@v3
with:
name: preview-spec
path: event.json
path: ${{github.event_path}}
retention-days: 2
delete-preview:
@@ -83,15 +77,9 @@ jobs:
if: ${{ github.event.action == 'closed' }}
steps:
# If this PR is closing, we will not render a compose file nor pass it to the next workflow.
- name: Serialize PR Event to File
run: |
cat << EOF > event.json
${{ toJSON(github.event) }}
EOF
- name: Upload PR Event as Artifact
uses: actions/upload-artifact@v3
with:
name: preview-spec
path: event.json
path: ${{github.event_path}}
retention-days: 2

View File

@@ -45,7 +45,7 @@ jobs:
run: |
echo 'EVENT_JSON<<EOF' >> $GITHUB_ENV
cat event.json >> $GITHUB_ENV
echo 'EOF' >> $GITHUB_ENV
echo -e '\nEOF' >> $GITHUB_ENV
- name: Hash Rendered Compose File
id: hash

View File

@@ -1,5 +1,9 @@
{
"json.schemaDownload.enable":true,
"go.lintOnSave": "workspace",
"go.lintTool": "golangci-lint",
"go.inferGopath": false
"go.inferGopath": false,
"go.toolsEnvVars": {
"GO111MODULE": "on"
}
}

View File

@@ -2,30 +2,39 @@
FROM node:18.12.1-alpine3.16 AS frontend
WORKDIR /frontend-build
COPY ./web/package.json ./web/pnpm-lock.yaml ./
RUN corepack enable && pnpm i --frozen-lockfile
COPY ./web/ .
RUN yarn && yarn build
RUN pnpm build
# Build backend exec file.
FROM golang:1.19.3-alpine3.16 AS backend
WORKDIR /backend-build
RUN apk update && apk add --no-cache gcc musl-dev
COPY . .
COPY --from=frontend /frontend-build/dist ./server/dist
RUN go build -o memos ./main.go
RUN CGO_ENABLED=0 go build -o memos ./main.go
# Make workspace with above generated files.
FROM alpine:3.16 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,36 +1,37 @@
<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">
<a href="https://github.com/usememos/memos/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/usememos/memos" /></a>
<a href="https://hub.docker.com/r/neosmemo/memos"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/neosmemo/memos.svg" /></a>
<a href="https://hosted.weblate.org/engage/memos/"><img src="https://hosted.weblate.org/widgets/memos/-/svg-badge.svg" alt="Translation status" /></a>
<img height="72px" src="https://usememos.com/logo.webp" alt="✍️ memos" align="right" />
A privacy-first, lightweight note-taking service. Easily capture and share your great thoughts.
<a href="https://usememos.com/docs">Documentation</a> •
<a href="https://demo.usememos.com/">Live Demo</a> •
Discuss in <a href="https://discord.gg/tfPJa4UmAv">Discord</a> / <a href="https://t.me/+-_tNF1k70UU4ZTc9">Telegram</a>
<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://discord.gg/tfPJa4UmAv"><img alt="Discord" src="https://img.shields.io/badge/discord-chat-5865f2?logo=discord&logoColor=f5f5f5" /></a>
</p>
<p align="center">
<a href="https://demo.usememos.com/">Live Demo</a> •
Discuss in <a href="https://t.me/+-_tNF1k70UU4ZTc9">Telegram</a> / <a href="https://discord.gg/tfPJa4UmAv">Discord</a>
</p>
![demo](https://usememos.com/demo.webp)
## Key points
- Open source and free forever
- Self-hosting with Docker in seconds
- Markdown support
- Customizable and sharable
- RESTful API for self-service
- **Open source and free forever**. Embrace a future where creativity knows no boundaries with our open-source solution free today, tomorrow, and always.
- **Self-hosting with Docker in just seconds**. Enjoy the flexibility, scalability, and ease of setup that Docker provides, allowing you to have full control over your data and privacy.
- **Pure text with added Markdown support.** Say goodbye to the overwhelming mental burden of rich formatting and embrace a minimalist approach.
- **Customize and share your notes effortlessly**. With our intuitive sharing features, you can easily collaborate and distribute your notes with others.
- **RESTful API for third-party services.** Embrace the power of integration and unleash new possibilities with our RESTful API support.
## Deploy with Docker in seconds
```bash
docker run -d --name memos -p 5230:5230 -v ~/.memos/:/var/opt/memos neosmemo/memos:latest
docker run -d --name memos -p 5230:5230 -v ~/.memos/:/var/opt/memos ghcr.io/usememos/memos:latest
```
> The `~/.memos/` directory will be used as the data directory on your local machine, while `/var/opt/memos` is the directory of the volume in Docker and should not be modified.
Learn more about [other installation methods](https://usememos.com/docs/install).
Learn more about [other installation methods](https://usememos.com/docs#installation).
## Contribution
@@ -40,7 +41,7 @@ Contributions are what make the open-source community such an amazing place to l
<img src="https://contrib.rocks/image?repo=usememos/memos" />
</a>
Here are some products made by our community:
---
- [Moe Memos](https://memos.moe/) - Third party client for iOS and Android
- [lmm214/memos-bber](https://github.com/lmm214/memos-bber) - Chrome extension
@@ -52,6 +53,7 @@ Here are some products made by our community:
- [Send to memos](https://sharecuts.cn/shortcut/12640) - A shortcut for iOS
- [Memos Raycast Extension](https://www.raycast.com/JakeYu/memos) - Raycast extension
- [Memos Desktop](https://github.com/xudaolong/memos-desktop) - Third party client for MacOS and Windows
- [MemosGallery](https://github.com/BarryYangi/MemosGallery) - A static Gallery rendered with the Memos API
## Acknowledgements

View File

@@ -1,17 +0,0 @@
package api
type SignIn struct {
Username string `json:"username"`
Password string `json:"password"`
}
type SSOSignIn struct {
IdentityProviderID int `json:"identityProviderId"`
Code string `json:"code"`
RedirectURI string `json:"redirectUri"`
}
type SignUp struct {
Username string `json:"username"`
Password string `json:"password"`
}

View File

@@ -1,58 +0,0 @@
package api
type IdentityProviderType string
const (
IdentityProviderOAuth2 IdentityProviderType = "OAUTH2"
)
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 int `json:"id"`
Name string `json:"name"`
Type IdentityProviderType `json:"type"`
IdentifierFilter string `json:"identifierFilter"`
Config *IdentityProviderConfig `json:"config"`
}
type IdentityProviderCreate struct {
Name string `json:"name"`
Type IdentityProviderType `json:"type"`
IdentifierFilter string `json:"identifierFilter"`
Config *IdentityProviderConfig `json:"config"`
}
type IdentityProviderFind struct {
ID *int
}
type IdentityProviderPatch struct {
ID int
Type IdentityProviderType `json:"type"`
Name *string `json:"name"`
IdentifierFilter *string `json:"identifierFilter"`
Config *IdentityProviderConfig `json:"config"`
}
type IdentityProviderDelete struct {
ID int
}

View File

@@ -1,97 +0,0 @@
package api
// MaxContentLength means the max memo content bytes is 1MB.
const MaxContentLength = 1 << 30
// Visibility is the type of a visibility.
type Visibility string
const (
// Public is the PUBLIC visibility.
Public Visibility = "PUBLIC"
// Protected is the PROTECTED visibility.
Protected Visibility = "PROTECTED"
// Private is the PRIVATE visibility.
Private Visibility = "PRIVATE"
)
func (e Visibility) String() string {
switch e {
case Public:
return "PUBLIC"
case Protected:
return "PROTECTED"
case Private:
return "PRIVATE"
}
return "PRIVATE"
}
type Memo struct {
ID int `json:"id"`
// Standard fields
RowStatus RowStatus `json:"rowStatus"`
CreatorID int `json:"creatorId"`
CreatedTs int64 `json:"createdTs"`
UpdatedTs int64 `json:"updatedTs"`
// Domain specific fields
Content string `json:"content"`
Visibility Visibility `json:"visibility"`
Pinned bool `json:"pinned"`
// Related fields
CreatorName string `json:"creatorName"`
ResourceList []*Resource `json:"resourceList"`
}
type MemoCreate struct {
// Standard fields
CreatorID int `json:"-"`
CreatedTs *int64 `json:"createdTs"`
// Domain specific fields
Visibility Visibility `json:"visibility"`
Content string `json:"content"`
// Related fields
ResourceIDList []int `json:"resourceIdList"`
}
type MemoPatch struct {
ID int `json:"-"`
// Standard fields
CreatedTs *int64 `json:"createdTs"`
UpdatedTs *int64
RowStatus *RowStatus `json:"rowStatus"`
// Domain specific fields
Content *string `json:"content"`
Visibility *Visibility `json:"visibility"`
// Related fields
ResourceIDList []int `json:"resourceIdList"`
}
type MemoFind struct {
ID *int
// Standard fields
RowStatus *RowStatus
CreatorID *int
// Domain specific fields
Pinned *bool
ContentSearch *string
VisibilityList []Visibility
// Pagination
Limit *int
Offset *int
}
type MemoDelete struct {
ID int
}

View File

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

View File

@@ -1,24 +0,0 @@
package api
type MemoResource struct {
MemoID int
ResourceID int
CreatedTs int64
UpdatedTs int64
}
type MemoResourceUpsert struct {
MemoID int `json:"-"`
ResourceID int
UpdatedTs *int64
}
type MemoResourceFind struct {
MemoID *int
ResourceID *int
}
type MemoResourceDelete struct {
MemoID *int
ResourceID *int
}

View File

@@ -1,5 +0,0 @@
package api
type OpenAICompletionRequest struct {
Prompt string `json:"prompt"`
}

View File

@@ -1,61 +0,0 @@
package api
type Resource struct {
ID int `json:"id"`
// Standard fields
CreatorID int `json:"creatorId"`
CreatedTs int64 `json:"createdTs"`
UpdatedTs int64 `json:"updatedTs"`
// Domain specific fields
Filename string `json:"filename"`
Blob []byte `json:"-"`
ExternalLink string `json:"externalLink"`
Type string `json:"type"`
Size int64 `json:"size"`
Visibility Visibility `json:"visibility"`
// Related fields
LinkedMemoAmount int `json:"linkedMemoAmount"`
}
type ResourceCreate struct {
// Standard fields
CreatorID int `json:"-"`
// Domain specific fields
Filename string `json:"filename"`
Blob []byte `json:"-"`
ExternalLink string `json:"externalLink"`
Type string `json:"type"`
Size int64 `json:"-"`
Visibility Visibility `json:"visibility"`
}
type ResourceFind struct {
ID *int `json:"id"`
// Standard fields
CreatorID *int `json:"creatorId"`
// Domain specific fields
Filename *string `json:"filename"`
MemoID *int
GetBlob bool
}
type ResourcePatch struct {
ID int `json:"-"`
// Standard fields
UpdatedTs *int64
// Domain specific fields
Filename *string `json:"filename"`
Visibility *Visibility `json:"visibility"`
}
type ResourceDelete struct {
ID int
}

View File

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

View File

@@ -1,49 +0,0 @@
package api
type StorageType string
const (
StorageS3 StorageType = "S3"
)
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"`
}
type Storage struct {
ID int `json:"id"`
Name string `json:"name"`
Type StorageType `json:"type"`
Config *StorageConfig `json:"config"`
}
type StorageCreate struct {
Name string `json:"name"`
Type StorageType `json:"type"`
Config *StorageConfig `json:"config"`
}
type StoragePatch struct {
ID int `json:"id"`
Type StorageType `json:"type"`
Name *string `json:"name"`
Config *StorageConfig `json:"config"`
}
type StorageFind struct {
ID *int `json:"id"`
}
type StorageDelete struct {
ID int `json:"id"`
}

View File

@@ -1,22 +0,0 @@
package api
import "github.com/usememos/memos/server/profile"
type SystemStatus struct {
Host *User `json:"host"`
Profile profile.Profile `json:"profile"`
DBSize int64 `json:"dbSize"`
// System settings
// Allow sign up.
AllowSignUp bool `json:"allowSignUp"`
// Disable public memos.
DisablePublicMemos bool `json:"disablePublicMemos"`
// 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"`
StorageServiceID int `json:"storageServiceId"`
}

View File

@@ -1,160 +0,0 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"golang.org/x/exp/slices"
)
type SystemSettingName string
const (
// SystemSettingServerID is the key type of server id.
SystemSettingServerID SystemSettingName = "serverId"
// SystemSettingSecretSessionName is the key type of secret session name.
SystemSettingSecretSessionName SystemSettingName = "secretSessionName"
// SystemSettingAllowSignUpName is the key type of allow signup setting.
SystemSettingAllowSignUpName SystemSettingName = "allowSignUp"
// SystemSettingDisablePublicMemosName is the key type of disable public memos setting.
SystemSettingDisablePublicMemosName SystemSettingName = "disablePublicMemos"
// SystemSettingAdditionalStyleName is the key type of additional style.
SystemSettingAdditionalStyleName SystemSettingName = "additionalStyle"
// SystemSettingAdditionalScriptName is the key type of additional script.
SystemSettingAdditionalScriptName SystemSettingName = "additionalScript"
// SystemSettingCustomizedProfileName is the key type of customized server profile.
SystemSettingCustomizedProfileName SystemSettingName = "customizedProfile"
// SystemSettingStorageServiceIDName is the key type of storage service ID.
SystemSettingStorageServiceIDName SystemSettingName = "storageServiceId"
// SystemSettingOpenAIConfigName is the key type of OpenAI config.
SystemSettingOpenAIConfigName SystemSettingName = "openAIConfig"
)
// 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"`
}
type OpenAIConfig struct {
Key string `json:"key"`
Host string `json:"host"`
}
func (key SystemSettingName) String() string {
switch key {
case SystemSettingServerID:
return "serverId"
case SystemSettingSecretSessionName:
return "secretSessionName"
case SystemSettingAllowSignUpName:
return "allowSignUp"
case SystemSettingDisablePublicMemosName:
return "disablePublicMemos"
case SystemSettingAdditionalStyleName:
return "additionalStyle"
case SystemSettingAdditionalScriptName:
return "additionalScript"
case SystemSettingCustomizedProfileName:
return "customizedProfile"
case SystemSettingStorageServiceIDName:
return "storageServiceId"
case SystemSettingOpenAIConfigName:
return "openAIConfig"
}
return ""
}
type SystemSetting struct {
Name SystemSettingName `json:"name"`
// Value is a JSON string with basic value.
Value string `json:"value"`
Description string `json:"description"`
}
type SystemSettingUpsert struct {
Name SystemSettingName `json:"name"`
Value string `json:"value"`
Description string `json:"description"`
}
func (upsert SystemSettingUpsert) Validate() error {
if upsert.Name == SystemSettingServerID {
return errors.New("update server id is not allowed")
} else if upsert.Name == SystemSettingAllowSignUpName {
value := false
err := json.Unmarshal([]byte(upsert.Value), &value)
if err != nil {
return fmt.Errorf("failed to unmarshal system setting allow signup value")
}
} else if upsert.Name == SystemSettingDisablePublicMemosName {
value := false
err := json.Unmarshal([]byte(upsert.Value), &value)
if err != nil {
return fmt.Errorf("failed to unmarshal system setting disable public memos value")
}
} else if upsert.Name == SystemSettingAdditionalStyleName {
value := ""
err := json.Unmarshal([]byte(upsert.Value), &value)
if err != nil {
return fmt.Errorf("failed to unmarshal system setting additional style value")
}
} else if upsert.Name == SystemSettingAdditionalScriptName {
value := ""
err := json.Unmarshal([]byte(upsert.Value), &value)
if err != nil {
return fmt.Errorf("failed to unmarshal system setting additional script value")
}
} else if upsert.Name == SystemSettingCustomizedProfileName {
customizedProfile := CustomizedProfile{
Name: "memos",
LogoURL: "",
Description: "",
Locale: "en",
Appearance: "system",
ExternalURL: "",
}
err := json.Unmarshal([]byte(upsert.Value), &customizedProfile)
if err != nil {
return fmt.Errorf("failed to unmarshal system setting customized profile value")
}
if !slices.Contains(UserSettingLocaleValue, customizedProfile.Locale) {
return fmt.Errorf("invalid locale value")
}
if !slices.Contains(UserSettingAppearanceValue, customizedProfile.Appearance) {
return fmt.Errorf("invalid appearance value")
}
} else if upsert.Name == SystemSettingStorageServiceIDName {
value := 0
err := json.Unmarshal([]byte(upsert.Value), &value)
if err != nil {
return fmt.Errorf("failed to unmarshal system setting storage service id value")
}
return nil
} else if upsert.Name == SystemSettingOpenAIConfigName {
value := OpenAIConfig{}
err := json.Unmarshal([]byte(upsert.Value), &value)
if err != nil {
return fmt.Errorf("failed to unmarshal system setting openai api config value")
}
} else {
return fmt.Errorf("invalid system setting name")
}
return nil
}
type SystemSettingFind struct {
Name SystemSettingName `json:"name"`
}

View File

@@ -1,20 +0,0 @@
package api
type Tag struct {
Name string
CreatorID int
}
type TagUpsert struct {
Name string
CreatorID int `json:"-"`
}
type TagFind struct {
CreatorID int
}
type TagDelete struct {
Name string `json:"name"`
CreatorID int
}

View File

@@ -1,158 +0,0 @@
package api
import (
"fmt"
"github.com/usememos/memos/common"
)
// Role is the type of a role.
type Role string
const (
// Host is the HOST role.
Host Role = "HOST"
// Admin is the ADMIN role.
Admin Role = "ADMIN"
// NormalUser is the USER role.
NormalUser Role = "USER"
)
func (e Role) String() string {
switch e {
case Host:
return "HOST"
case Admin:
return "ADMIN"
case NormalUser:
return "USER"
}
return "USER"
}
type User struct {
ID int `json:"id"`
// Standard fields
RowStatus RowStatus `json:"rowStatus"`
CreatedTs int64 `json:"createdTs"`
UpdatedTs int64 `json:"updatedTs"`
// Domain specific fields
Username string `json:"username"`
Role Role `json:"role"`
Email string `json:"email"`
Nickname string `json:"nickname"`
PasswordHash string `json:"-"`
OpenID string `json:"openId"`
AvatarURL string `json:"avatarUrl"`
UserSettingList []*UserSetting `json:"userSettingList"`
}
type UserCreate struct {
// Domain specific fields
Username string `json:"username"`
Role Role `json:"role"`
Email string `json:"email"`
Nickname string `json:"nickname"`
Password string `json:"password"`
PasswordHash string
OpenID string
}
func (create UserCreate) Validate() error {
if len(create.Username) < 3 {
return fmt.Errorf("username is too short, minimum length is 3")
}
if len(create.Username) > 32 {
return fmt.Errorf("username is too long, maximum length is 32")
}
if len(create.Password) < 3 {
return fmt.Errorf("password is too short, minimum length is 6")
}
if len(create.Password) > 512 {
return fmt.Errorf("password is too long, maximum length is 512")
}
if len(create.Nickname) > 64 {
return fmt.Errorf("nickname is too long, maximum length is 64")
}
if create.Email != "" {
if len(create.Email) > 256 {
return fmt.Errorf("email is too long, maximum length is 256")
}
if !common.ValidateEmail(create.Email) {
return fmt.Errorf("invalid email format")
}
}
return nil
}
type UserPatch struct {
ID int `json:"-"`
// Standard fields
UpdatedTs *int64
RowStatus *RowStatus `json:"rowStatus"`
// Domain specific fields
Username *string `json:"username"`
Email *string `json:"email"`
Nickname *string `json:"nickname"`
Password *string `json:"password"`
ResetOpenID *bool `json:"resetOpenId"`
AvatarURL *string `json:"avatarUrl"`
PasswordHash *string
OpenID *string
}
func (patch UserPatch) Validate() error {
if patch.Username != nil && len(*patch.Username) < 3 {
return fmt.Errorf("username is too short, minimum length is 3")
}
if patch.Username != nil && len(*patch.Username) > 32 {
return fmt.Errorf("username is too long, maximum length is 32")
}
if patch.Password != nil && len(*patch.Password) < 3 {
return fmt.Errorf("password is too short, minimum length is 6")
}
if patch.Password != nil && len(*patch.Password) > 512 {
return fmt.Errorf("password is too long, maximum length is 512")
}
if patch.Nickname != nil && len(*patch.Nickname) > 64 {
return fmt.Errorf("nickname is too long, maximum length is 64")
}
if patch.AvatarURL != nil {
if len(*patch.AvatarURL) > 2<<20 {
return fmt.Errorf("avatar is too large, maximum is 2MB")
}
}
if patch.Email != nil && *patch.Email != "" {
if len(*patch.Email) > 256 {
return fmt.Errorf("email is too long, maximum length is 256")
}
if !common.ValidateEmail(*patch.Email) {
return fmt.Errorf("invalid email format")
}
}
return nil
}
type UserFind struct {
ID *int `json:"id"`
// Standard fields
RowStatus *RowStatus `json:"rowStatus"`
// Domain specific fields
Username *string `json:"username"`
Role *Role
Email *string `json:"email"`
Nickname *string `json:"nickname"`
OpenID *string
}
type UserDelete struct {
ID int
}

View File

@@ -1,110 +0,0 @@
package api
import (
"encoding/json"
"fmt"
"golang.org/x/exp/slices"
)
type UserSettingKey string
const (
// UserSettingLocaleKey is the key type for user locale.
UserSettingLocaleKey UserSettingKey = "locale"
// UserSettingAppearanceKey is the key type for user appearance.
UserSettingAppearanceKey UserSettingKey = "appearance"
// UserSettingMemoVisibilityKey is the key type for user preference memo default visibility.
UserSettingMemoVisibilityKey UserSettingKey = "memoVisibility"
// UserSettingResourceVisibilityKey is the key type for user preference resource default visibility.
UserSettingResourceVisibilityKey UserSettingKey = "resourceVisibility"
)
// String returns the string format of UserSettingKey type.
func (key UserSettingKey) String() string {
switch key {
case UserSettingLocaleKey:
return "locale"
case UserSettingAppearanceKey:
return "appearance"
case UserSettingMemoVisibilityKey:
return "memoVisibility"
case UserSettingResourceVisibilityKey:
return "resourceVisibility"
}
return ""
}
var (
UserSettingLocaleValue = []string{"en", "zh", "vi", "fr", "nl", "sv", "de", "es", "uk", "ru", "it", "hant", "tr", "ko"}
UserSettingAppearanceValue = []string{"system", "light", "dark"}
UserSettingMemoVisibilityValue = []Visibility{Private, Protected, Public}
UserSettingResourceVisibilityValue = []Visibility{Private, Protected, Public}
)
type UserSetting struct {
UserID int
Key UserSettingKey `json:"key"`
// Value is a JSON string with basic value
Value string `json:"value"`
}
type UserSettingUpsert struct {
UserID int `json:"-"`
Key UserSettingKey `json:"key"`
Value string `json:"value"`
}
func (upsert UserSettingUpsert) Validate() error {
if upsert.Key == UserSettingLocaleKey {
localeValue := "en"
err := json.Unmarshal([]byte(upsert.Value), &localeValue)
if err != nil {
return fmt.Errorf("failed to unmarshal user setting locale value")
}
if !slices.Contains(UserSettingLocaleValue, localeValue) {
return fmt.Errorf("invalid user setting locale value")
}
} else if upsert.Key == UserSettingAppearanceKey {
appearanceValue := "system"
err := json.Unmarshal([]byte(upsert.Value), &appearanceValue)
if err != nil {
return fmt.Errorf("failed to unmarshal user setting appearance value")
}
if !slices.Contains(UserSettingAppearanceValue, appearanceValue) {
return fmt.Errorf("invalid user setting appearance value")
}
} else if upsert.Key == UserSettingMemoVisibilityKey {
memoVisibilityValue := Private
err := json.Unmarshal([]byte(upsert.Value), &memoVisibilityValue)
if err != nil {
return fmt.Errorf("failed to unmarshal user setting memo visibility value")
}
if !slices.Contains(UserSettingMemoVisibilityValue, memoVisibilityValue) {
return fmt.Errorf("invalid user setting memo visibility value")
}
} else if upsert.Key == UserSettingResourceVisibilityKey {
resourceVisibilityValue := Private
err := json.Unmarshal([]byte(upsert.Value), &resourceVisibilityValue)
if err != nil {
return fmt.Errorf("failed to unmarshal user setting resource visibility value")
}
if !slices.Contains(UserSettingResourceVisibilityValue, resourceVisibilityValue) {
return fmt.Errorf("invalid user setting resource visibility value")
}
} else {
return fmt.Errorf("invalid user setting key")
}
return nil
}
type UserSettingFind struct {
UserID int
Key UserSettingKey `json:"key"`
}
type UserSettingDelete struct {
UserID int
}

View File

@@ -1,4 +1,4 @@
package api
package v1
import "github.com/usememos/memos/server/profile"
@@ -59,6 +59,10 @@ const (
ActivityServerStart ActivityType = "server.start"
)
func (t ActivityType) String() string {
return string(t)
}
// ActivityLevel is the level of activities.
type ActivityLevel string
@@ -71,6 +75,10 @@ const (
ActivityError ActivityLevel = "ERROR"
)
func (l ActivityLevel) String() string {
return string(l)
}
type ActivityUserCreatePayload struct {
UserID int `json:"userId"`
Username string `json:"username"`

View File

@@ -1,4 +1,4 @@
package server
package v1
import (
"encoding/json"
@@ -6,35 +6,50 @@ import (
"net/http"
"regexp"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
"github.com/usememos/memos/api/v1/auth"
"github.com/usememos/memos/common/util"
"github.com/usememos/memos/plugin/idp"
"github.com/usememos/memos/plugin/idp/oauth2"
"github.com/usememos/memos/store"
"github.com/labstack/echo/v4"
"golang.org/x/crypto/bcrypt"
)
func (s *Server) registerAuthRoutes(g *echo.Group) {
type SignIn struct {
Username string `json:"username"`
Password string `json:"password"`
}
type SSOSignIn struct {
IdentityProviderID int `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) {
// POST /auth/signin - Sign in.
g.POST("/auth/signin", func(c echo.Context) error {
ctx := c.Request().Context()
signin := &api.SignIn{}
signin := &SignIn{}
if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signin request").SetInternal(err)
}
userFind := &api.UserFind{
user, err := s.Store.GetUser(ctx, &store.FindUser{
Username: &signin.Username,
}
user, err := s.Store.FindUser(ctx, userFind)
if err != nil && common.ErrorCode(err) != common.NotFound {
})
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 == api.Archived {
} else if user.RowStatus == store.Archived {
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with username %s", signin.Username))
}
@@ -44,32 +59,36 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusUnauthorized, "Incorrect login credentials, please try again")
}
if err = setUserSession(c, user); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set signin session").SetInternal(err)
if err := auth.GenerateTokensAndSetCookies(c, user, s.Secret); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate tokens").SetInternal(err)
}
if err := s.createUserAuthSignInActivity(c, user); err != nil {
if err := s.createAuthSignInActivity(c, user); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
}
return c.JSON(http.StatusOK, composeResponse(user))
return c.JSON(http.StatusOK, user)
})
// POST /auth/signin/sso - Sign in with SSO
g.POST("/auth/signin/sso", func(c echo.Context) error {
ctx := c.Request().Context()
signin := &api.SSOSignIn{}
signin := &SSOSignIn{}
if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signin request").SetInternal(err)
}
identityProviderMessage, err := s.Store.GetIdentityProvider(ctx, &store.FindIdentityProviderMessage{
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 identityProviderMessage.Type == store.IdentityProviderOAuth2 {
oauth2IdentityProvider, err := oauth2.NewIdentityProvider(identityProviderMessage.Config.OAuth2Config)
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)
}
@@ -83,7 +102,7 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
}
}
identifierFilter := identityProviderMessage.IdentifierFilter
identifierFilter := identityProvider.IdentifierFilter
if identifierFilter != "" {
identifierFilterRegex, err := regexp.Compile(identifierFilter)
if err != nil {
@@ -94,23 +113,22 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
}
}
user, err := s.Store.FindUser(ctx, &api.UserFind{
user, err := s.Store.GetUser(ctx, &store.FindUser{
Username: &userInfo.Identifier,
})
if err != nil && common.ErrorCode(err) != common.NotFound {
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Incorrect login credentials, please try again")
}
if user == nil {
userCreate := &api.UserCreate{
userCreate := &store.User{
Username: userInfo.Identifier,
// The new signup user should be normal user by default.
Role: api.NormalUser,
Role: store.RoleUser,
Nickname: userInfo.DisplayName,
Email: userInfo.Email,
Password: userInfo.Email,
OpenID: common.GenUUID(),
OpenID: util.GenUUID(),
}
password, err := common.RandomString(20)
password, err := util.RandomString(20)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate random password").SetInternal(err)
}
@@ -124,49 +142,50 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
}
}
if user.RowStatus == api.Archived {
if user.RowStatus == store.Archived {
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with username %s", userInfo.Identifier))
}
if err = setUserSession(c, user); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set signin session").SetInternal(err)
if err := auth.GenerateTokensAndSetCookies(c, user, s.Secret); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate tokens").SetInternal(err)
}
if err := s.createUserAuthSignInActivity(c, user); err != nil {
if err := s.createAuthSignInActivity(c, user); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
}
return c.JSON(http.StatusOK, composeResponse(user))
return c.JSON(http.StatusOK, user)
})
// POST /auth/signup - Sign up a new user.
g.POST("/auth/signup", func(c echo.Context) error {
ctx := c.Request().Context()
signup := &api.SignUp{}
signup := &SignUp{}
if err := json.NewDecoder(c.Request().Body).Decode(signup); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signup request").SetInternal(err)
}
userCreate := &api.UserCreate{
Username: signup.Username,
// The new signup user should be normal user by default.
Role: api.NormalUser,
Nickname: signup.Username,
Password: signup.Password,
OpenID: common.GenUUID(),
}
hostUserType := api.Host
existedHostUsers, err := s.Store.FindUserList(ctx, &api.UserFind{
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)
}
userCreate := &store.User{
Username: signup.Username,
// The new signup user should be normal user by default.
Role: store.RoleUser,
Nickname: signup.Username,
OpenID: util.GenUUID(),
}
if len(existedHostUsers) == 0 {
// Change the default role to host if there is no host user.
userCreate.Role = api.Host
userCreate.Role = store.RoleHost
} else {
allowSignUpSetting, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{
Name: api.SystemSettingAllowSignUpName,
allowSignUpSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
Name: SystemSettingAllowSignUpName.String(),
})
if err != nil && common.ErrorCode(err) != common.NotFound {
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting").SetInternal(err)
}
@@ -182,10 +201,6 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
}
}
if err := userCreate.Validate(); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format").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)
@@ -196,30 +211,26 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
}
if err := s.createUserAuthSignUpActivity(c, user); err != nil {
if err := auth.GenerateTokensAndSetCookies(c, user, s.Secret); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate tokens").SetInternal(err)
}
if err := s.createAuthSignUpActivity(c, user); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
}
err = setUserSession(c, user)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set signup session").SetInternal(err)
}
return c.JSON(http.StatusOK, composeResponse(user))
return c.JSON(http.StatusOK, user)
})
// POST /auth/signout - Sign out.
g.POST("/auth/signout", func(c echo.Context) error {
err := removeUserSession(c)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set sign out session").SetInternal(err)
}
auth.RemoveTokensAndCookies(c)
return c.JSON(http.StatusOK, true)
})
}
func (s *Server) createUserAuthSignInActivity(c echo.Context, user *api.User) error {
func (s *APIV1Service) createAuthSignInActivity(c echo.Context, user *store.User) error {
ctx := c.Request().Context()
payload := api.ActivityUserAuthSignInPayload{
payload := ActivityUserAuthSignInPayload{
UserID: user.ID,
IP: echo.ExtractIPFromRealIPHeader()(c.Request()),
}
@@ -227,10 +238,10 @@ func (s *Server) createUserAuthSignInActivity(c echo.Context, user *api.User) er
if err != nil {
return errors.Wrap(err, "failed to marshal activity payload")
}
activity, err := s.Store.CreateActivity(ctx, &api.ActivityCreate{
activity, err := s.Store.CreateActivity(ctx, &store.Activity{
CreatorID: user.ID,
Type: api.ActivityUserAuthSignIn,
Level: api.ActivityInfo,
Type: string(ActivityUserAuthSignIn),
Level: string(ActivityInfo),
Payload: string(payloadBytes),
})
if err != nil || activity == nil {
@@ -239,9 +250,9 @@ func (s *Server) createUserAuthSignInActivity(c echo.Context, user *api.User) er
return err
}
func (s *Server) createUserAuthSignUpActivity(c echo.Context, user *api.User) error {
func (s *APIV1Service) createAuthSignUpActivity(c echo.Context, user *store.User) error {
ctx := c.Request().Context()
payload := api.ActivityUserAuthSignUpPayload{
payload := ActivityUserAuthSignUpPayload{
Username: user.Username,
IP: echo.ExtractIPFromRealIPHeader()(c.Request()),
}
@@ -249,10 +260,10 @@ func (s *Server) createUserAuthSignUpActivity(c echo.Context, user *api.User) er
if err != nil {
return errors.Wrap(err, "failed to marshal activity payload")
}
activity, err := s.Store.CreateActivity(ctx, &api.ActivityCreate{
activity, err := s.Store.CreateActivity(ctx, &store.Activity{
CreatorID: user.ID,
Type: api.ActivityUserAuthSignUp,
Level: api.ActivityInfo,
Type: string(ActivityUserAuthSignUp),
Level: string(ActivityInfo),
Payload: string(payloadBytes),
})
if err != nil || activity == nil {

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

@@ -0,0 +1,131 @@
package auth
import (
"net/http"
"strconv"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"github.com/usememos/memos/store"
)
const (
issuer = "memos"
// Signing key section. For now, this is only used for signing, not for verifying since we only
// have 1 version. But it will be used to maintain backward compatibility if we change the signing mechanism.
keyID = "v1"
// AccessTokenAudienceName is the audience name of the access token.
AccessTokenAudienceName = "user.access-token"
// RefreshTokenAudienceName is the audience name of the refresh token.
RefreshTokenAudienceName = "user.refresh-token"
apiTokenDuration = 2 * time.Hour
accessTokenDuration = 24 * time.Hour
refreshTokenDuration = 7 * 24 * time.Hour
// RefreshThresholdDuration is the threshold duration for refreshing token.
RefreshThresholdDuration = 1 * time.Hour
// CookieExpDuration expires slightly earlier than the jwt expiration. Client would be logged out if the user
// cookie expires, thus the client would always logout first before attempting to make a request with the expired jwt.
// Suppose we have a valid refresh token, we will refresh the token in 2 cases:
// 1. The access token is about to expire in <<refreshThresholdDuration>>
// 2. The access token has already expired, we refresh the token so that the ongoing request can pass through.
CookieExpDuration = refreshTokenDuration - 1*time.Minute
// AccessTokenCookieName is the cookie name of access token.
AccessTokenCookieName = "memos.access-token"
// RefreshTokenCookieName is the cookie name of refresh token.
RefreshTokenCookieName = "memos.refresh-token"
)
type claimsMessage struct {
Name string `json:"name"`
jwt.RegisteredClaims
}
// GenerateAPIToken generates an API token.
func GenerateAPIToken(userName string, userID int, secret string) (string, error) {
expirationTime := time.Now().Add(apiTokenDuration)
return generateToken(userName, userID, AccessTokenAudienceName, expirationTime, []byte(secret))
}
// GenerateAccessToken generates an access token for web.
func GenerateAccessToken(userName string, userID int, secret string) (string, error) {
expirationTime := time.Now().Add(accessTokenDuration)
return generateToken(userName, userID, AccessTokenAudienceName, expirationTime, []byte(secret))
}
// GenerateRefreshToken generates a refresh token for web.
func GenerateRefreshToken(userName string, userID int, secret string) (string, error) {
expirationTime := time.Now().Add(refreshTokenDuration)
return generateToken(userName, userID, RefreshTokenAudienceName, expirationTime, []byte(secret))
}
// GenerateTokensAndSetCookies generates jwt token and saves it to the http-only cookie.
func GenerateTokensAndSetCookies(c echo.Context, user *store.User, secret string) error {
accessToken, err := GenerateAccessToken(user.Username, user.ID, secret)
if err != nil {
return errors.Wrap(err, "failed to generate access token")
}
cookieExp := time.Now().Add(CookieExpDuration)
setTokenCookie(c, AccessTokenCookieName, accessToken, cookieExp)
// We generate here a new refresh token and saving it to the cookie.
refreshToken, err := GenerateRefreshToken(user.Username, user.ID, secret)
if err != nil {
return errors.Wrap(err, "failed to generate refresh token")
}
setTokenCookie(c, RefreshTokenCookieName, refreshToken, cookieExp)
return nil
}
// RemoveTokensAndCookies removes the jwt token and refresh token from the cookies.
func RemoveTokensAndCookies(c echo.Context) {
// We set the expiration time to the past, so that the cookie will be removed.
cookieExp := time.Now().Add(-1 * time.Hour)
setTokenCookie(c, AccessTokenCookieName, "", cookieExp)
setTokenCookie(c, RefreshTokenCookieName, "", cookieExp)
}
// setTokenCookie sets the token to the cookie.
func setTokenCookie(c echo.Context, name, token string, expiration time.Time) {
cookie := new(http.Cookie)
cookie.Name = name
cookie.Value = token
cookie.Expires = expiration
cookie.Path = "/"
// Http-only helps mitigate the risk of client side script accessing the protected cookie.
cookie.HttpOnly = true
cookie.SameSite = http.SameSiteStrictMode
c.SetCookie(cookie)
}
// generateToken generates a jwt token.
func generateToken(username string, userID int, aud string, expirationTime time.Time, secret []byte) (string, error) {
// Create the JWT claims, which includes the username and expiry time.
claims := &claimsMessage{
Name: username,
RegisteredClaims: jwt.RegisteredClaims{
Audience: jwt.ClaimStrings{aud},
// In JWT, the expiry time is expressed as unix milliseconds.
ExpiresAt: jwt.NewNumericDate(expirationTime),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: issuer,
Subject: strconv.Itoa(userID),
},
}
// Declare the token with the HS256 algorithm used for signing, and the claims.
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
token.Header["kid"] = keyID
// Create the JWT string.
tokenString, err := token.SignedString(secret)
if err != nil {
return "", err
}
return tokenString, nil
}

View File

@@ -1,4 +1,4 @@
package api
package v1
// UnknownID is the ID for unknowns.
const UnknownID = -1
@@ -13,12 +13,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)
}

View File

@@ -1,4 +1,4 @@
package server
package v1
import (
"fmt"
@@ -6,10 +6,11 @@ import (
"net/url"
"github.com/labstack/echo/v4"
getter "github.com/usememos/memos/plugin/http_getter"
getter "github.com/usememos/memos/plugin/http-getter"
)
func registerGetterPublicRoutes(g *echo.Group) {
func (*APIV1Service) registerGetterPublicRoutes(g *echo.Group) {
// GET /get/httpmeta?url={url} - Get website meta.
g.GET("/get/httpmeta", func(c echo.Context) error {
urlStr := c.QueryParam("url")
if urlStr == "" {
@@ -23,9 +24,10 @@ func registerGetterPublicRoutes(g *echo.Group) {
if err != nil {
return echo.NewHTTPError(http.StatusNotAcceptable, fmt.Sprintf("Failed to get website meta with url: %s", urlStr)).SetInternal(err)
}
return c.JSON(http.StatusOK, composeResponse(htmlMeta))
return c.JSON(http.StatusOK, htmlMeta)
})
// GET /get/image?url={url} - Get image.
g.GET("/get/image", func(c echo.Context) error {
urlStr := c.QueryParam("url")
if urlStr == "" {

View File

@@ -1,4 +1,4 @@
package server
package v1
import (
"encoding/json"
@@ -7,12 +7,63 @@ import (
"strconv"
"github.com/labstack/echo/v4"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
"github.com/usememos/memos/store"
)
func (s *Server) registerIdentityProviderRoutes(g *echo.Group) {
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 int `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 int `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.POST("/idp", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
@@ -20,22 +71,22 @@ func (s *Server) registerIdentityProviderRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
user, err := s.Store.FindUser(ctx, &api.UserFind{
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 != api.Host {
if user == nil || user.Role != store.RoleHost {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
identityProviderCreate := &api.IdentityProviderCreate{}
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)
}
identityProviderMessage, err := s.Store.CreateIdentityProvider(ctx, &store.IdentityProviderMessage{
identityProvider, err := s.Store.CreateIdentityProvider(ctx, &store.IdentityProvider{
Name: identityProviderCreate.Name,
Type: store.IdentityProviderType(identityProviderCreate.Type),
IdentifierFilter: identityProviderCreate.IdentifierFilter,
@@ -44,7 +95,7 @@ func (s *Server) registerIdentityProviderRoutes(g *echo.Group) {
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create identity provider").SetInternal(err)
}
return c.JSON(http.StatusOK, composeResponse(convertIdentityProviderFromStore(identityProviderMessage)))
return c.JSON(http.StatusOK, convertIdentityProviderFromStore(identityProvider))
})
g.PATCH("/idp/:idpId", func(c echo.Context) error {
@@ -54,13 +105,13 @@ func (s *Server) registerIdentityProviderRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
user, err := s.Store.FindUser(ctx, &api.UserFind{
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 != api.Host {
if user == nil || user.Role != store.RoleHost {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
@@ -69,14 +120,14 @@ func (s *Server) registerIdentityProviderRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("idpId"))).SetInternal(err)
}
identityProviderPatch := &api.IdentityProviderPatch{
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)
}
identityProviderMessage, err := s.Store.UpdateIdentityProvider(ctx, &store.UpdateIdentityProviderMessage{
identityProvider, err := s.Store.UpdateIdentityProvider(ctx, &store.UpdateIdentityProvider{
ID: identityProviderPatch.ID,
Type: store.IdentityProviderType(identityProviderPatch.Type),
Name: identityProviderPatch.Name,
@@ -86,12 +137,12 @@ func (s *Server) registerIdentityProviderRoutes(g *echo.Group) {
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch identity provider").SetInternal(err)
}
return c.JSON(http.StatusOK, composeResponse(convertIdentityProviderFromStore(identityProviderMessage)))
return c.JSON(http.StatusOK, convertIdentityProviderFromStore(identityProvider))
})
g.GET("/idp", func(c echo.Context) error {
ctx := c.Request().Context()
identityProviderMessageList, err := s.Store.ListIdentityProviders(ctx, &store.FindIdentityProviderMessage{})
list, err := s.Store.ListIdentityProviders(ctx, &store.FindIdentityProvider{})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find identity provider list").SetInternal(err)
}
@@ -99,27 +150,27 @@ func (s *Server) registerIdentityProviderRoutes(g *echo.Group) {
userID, ok := c.Get(getUserIDContextKey()).(int)
isHostUser := false
if ok {
user, err := s.Store.FindUser(ctx, &api.UserFind{
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil && common.ErrorCode(err) != common.NotFound {
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if user != nil && user.Role == api.Host {
if user == nil || user.Role == store.RoleHost {
isHostUser = true
}
}
identityProviderList := []*api.IdentityProvider{}
for _, identityProviderMessage := range identityProviderMessageList {
identityProvider := convertIdentityProviderFromStore(identityProviderMessage)
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, composeResponse(identityProviderList))
return c.JSON(http.StatusOK, identityProviderList)
})
g.GET("/idp/:idpId", func(c echo.Context) error {
@@ -129,14 +180,13 @@ func (s *Server) registerIdentityProviderRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
user, err := s.Store.FindUser(ctx, &api.UserFind{
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 identity provider list to host user.
if user == nil || user.Role != api.Host {
if user == nil || user.Role != store.RoleHost {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
@@ -144,13 +194,17 @@ func (s *Server) registerIdentityProviderRoutes(g *echo.Group) {
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("idpId"))).SetInternal(err)
}
identityProviderMessage, err := s.Store.GetIdentityProvider(ctx, &store.FindIdentityProviderMessage{
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)
}
return c.JSON(http.StatusOK, composeResponse(convertIdentityProviderFromStore(identityProviderMessage)))
if identityProvider == nil {
return echo.NewHTTPError(http.StatusNotFound, "Identity provider not found")
}
return c.JSON(http.StatusOK, convertIdentityProviderFromStore(identityProvider))
})
g.DELETE("/idp/:idpId", func(c echo.Context) error {
@@ -160,13 +214,13 @@ func (s *Server) registerIdentityProviderRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
user, err := s.Store.FindUser(ctx, &api.UserFind{
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 != api.Host {
if user == nil || user.Role != store.RoleHost {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
@@ -175,36 +229,33 @@ func (s *Server) registerIdentityProviderRoutes(g *echo.Group) {
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.DeleteIdentityProviderMessage{ID: identityProviderID}); err != nil {
if common.ErrorCode(err) == common.NotFound {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Identity provider ID not found: %d", identityProviderID))
}
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)
})
}
func convertIdentityProviderFromStore(identityProviderMessage *store.IdentityProviderMessage) *api.IdentityProvider {
return &api.IdentityProvider{
ID: identityProviderMessage.ID,
Name: identityProviderMessage.Name,
Type: api.IdentityProviderType(identityProviderMessage.Type),
IdentifierFilter: identityProviderMessage.IdentifierFilter,
Config: convertIdentityProviderConfigFromStore(identityProviderMessage.Config),
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) *api.IdentityProviderConfig {
return &api.IdentityProviderConfig{
OAuth2Config: &api.IdentityProviderOAuth2Config{
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: &api.FieldMapping{
FieldMapping: &FieldMapping{
Identifier: config.OAuth2Config.FieldMapping.Identifier,
DisplayName: config.OAuth2Config.FieldMapping.DisplayName,
Email: config.OAuth2Config.FieldMapping.Email,
@@ -213,7 +264,7 @@ func convertIdentityProviderConfigFromStore(config *store.IdentityProviderConfig
}
}
func convertIdentityProviderConfigToStore(config *api.IdentityProviderConfig) *store.IdentityProviderConfig {
func convertIdentityProviderConfigToStore(config *IdentityProviderConfig) *store.IdentityProviderConfig {
return &store.IdentityProviderConfig{
OAuth2Config: &store.IdentityProviderOAuth2Config{
ClientID: config.OAuth2Config.ClientID,

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

@@ -0,0 +1,241 @@
package v1
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"github.com/usememos/memos/api/v1/auth"
"github.com/usememos/memos/common/util"
"github.com/usememos/memos/store"
)
const (
// Context section
// The key name used to store user id in the context
// user id is extracted from the jwt token subject field.
userIDContextKey = "user-id"
)
func getUserIDContextKey() string {
return userIDContextKey
}
// Claims creates a struct that will be encoded to a JWT.
// We add jwt.RegisteredClaims as an embedded type, to provide fields such as name.
type Claims struct {
Name string `json:"name"`
jwt.RegisteredClaims
}
func extractTokenFromHeader(c echo.Context) (string, error) {
authHeader := c.Request().Header.Get("Authorization")
if authHeader == "" {
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 {
accessToken := ""
cookie, _ := c.Cookie(auth.AccessTokenCookieName)
if cookie != nil {
accessToken = cookie.Value
}
if accessToken == "" {
accessToken, _ = extractTokenFromHeader(c)
}
return accessToken
}
func audienceContains(audience jwt.ClaimStrings, token string) bool {
for _, v := range audience {
if v == token {
return true
}
}
return false
}
// JWTMiddleware validates the access token.
// If the access token is about to expire or has expired and the request has a valid refresh token, it
// will try to generate new access token and refresh token.
func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) echo.HandlerFunc {
return func(c echo.Context) error {
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/:id") && method == http.MethodGet {
return next(c)
}
token := findAccessToken(c)
if token == "" {
// 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")
}
claims := &Claims{}
accessToken, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
}
if kid, ok := t.Header["kid"].(string); ok {
if kid == "v1" {
return []byte(secret), nil
}
}
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
})
if !accessToken.Valid {
auth.RemoveTokensAndCookies(c)
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid access token.")
}
if !audienceContains(claims.Audience, auth.AccessTokenAudienceName) {
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Invalid access token, audience mismatch, got %q, expected %q.", claims.Audience, auth.AccessTokenAudienceName))
}
generateToken := time.Until(claims.ExpiresAt.Time) < auth.RefreshThresholdDuration
if err != nil {
var ve *jwt.ValidationError
if errors.As(err, &ve) {
// If expiration error is the only error, we will clear the err
// and generate new access token and refresh token
if ve.Errors == jwt.ValidationErrorExpired {
generateToken = true
}
} else {
auth.RemoveTokensAndCookies(c)
return echo.NewHTTPError(http.StatusUnauthorized, errors.Wrap(err, "Invalid or expired access token"))
}
}
// We either have a valid access token or we will attempt to generate new access token and refresh token
ctx := c.Request().Context()
userID, err := strconv.Atoi(claims.Subject)
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Malformed ID in the 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))
}
if generateToken {
generateTokenFunc := func() error {
rc, err := c.Cookie(auth.RefreshTokenCookieName)
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Failed to generate access token. Missing refresh token.")
}
// Parses token and checks if it's valid.
refreshTokenClaims := &Claims{}
refreshToken, err := jwt.ParseWithClaims(rc.Value, refreshTokenClaims, func(t *jwt.Token) (any, error) {
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
return nil, errors.Errorf("unexpected refresh token signing method=%v, expected %v", t.Header["alg"], jwt.SigningMethodHS256)
}
if kid, ok := t.Header["kid"].(string); ok {
if kid == "v1" {
return []byte(secret), nil
}
}
return nil, errors.Errorf("unexpected refresh token kid=%v", t.Header["kid"])
})
if err != nil {
if err == jwt.ErrSignatureInvalid {
return echo.NewHTTPError(http.StatusUnauthorized, "Failed to generate access token. Invalid refresh token signature.")
}
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to refresh expired token. User Id %d", userID)).SetInternal(err)
}
if !audienceContains(refreshTokenClaims.Audience, auth.RefreshTokenAudienceName) {
return echo.NewHTTPError(http.StatusUnauthorized,
fmt.Sprintf("Invalid refresh token, audience mismatch, got %q, expected %q. you may send request to the wrong environment",
refreshTokenClaims.Audience,
auth.RefreshTokenAudienceName,
))
}
// If we have a valid refresh token, we will generate new access token and refresh token
if refreshToken != nil && refreshToken.Valid {
if err := auth.GenerateTokensAndSetCookies(c, user, secret); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to refresh expired token. User Id %d", userID)).SetInternal(err)
}
}
return nil
}
// It may happen that we still have a valid access token, but we encounter issue when trying to generate new token
// In such case, we won't return the error.
if err := generateTokenFunc(); err != nil && !accessToken.Valid {
return err
}
}
// Stores userID into context.
c.Set(getUserIDContextKey(), userID)
return next(c)
}
}
func (s *APIV1Service) defaultAuthSkipper(c echo.Context) bool {
ctx := c.Request().Context()
path := c.Path()
// Skip auth.
if util.HasPrefixes(path, "/api/v1/auth") {
return true
}
// If there is openId in query string and related user is found, then skip auth.
openID := c.QueryParam("openId")
if openID != "" {
user, err := s.Store.GetUser(ctx, &store.FindUser{
OpenID: &openID,
})
if err != nil {
return false
}
if user != nil {
// Stores userID into context.
c.Set(getUserIDContextKey(), user.ID)
return true
}
}
return false
}

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

@@ -0,0 +1,761 @@
package v1
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"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 int `json:"id"`
// Standard fields
RowStatus RowStatus `json:"rowStatus"`
CreatorID int `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
CreatorName string `json:"creatorName"`
ResourceList []*Resource `json:"resourceList"`
RelationList []*MemoRelation `json:"relationList"`
}
type CreateMemoRequest struct {
// Standard fields
CreatorID int `json:"-"`
CreatedTs *int64 `json:"createdTs"`
// Domain specific fields
Visibility Visibility `json:"visibility"`
Content string `json:"content"`
// Related fields
ResourceIDList []int `json:"resourceIdList"`
RelationList []*UpsertMemoRelationRequest `json:"relationList"`
}
type PatchMemoRequest struct {
ID int `json:"-"`
// Standard fields
CreatedTs *int64 `json:"createdTs"`
UpdatedTs *int64
RowStatus *RowStatus `json:"rowStatus"`
// Domain specific fields
Content *string `json:"content"`
Visibility *Visibility `json:"visibility"`
// Related fields
ResourceIDList []int `json:"resourceIdList"`
RelationList []*UpsertMemoRelationRequest `json:"relationList"`
}
type FindMemoRequest struct {
ID *int
// Standard fields
RowStatus *RowStatus
CreatorID *int
// 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.POST("/memo", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
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)
}
if err := s.createMemoCreateActivity(ctx, memo); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
}
for _, resourceID := range createMemoRequest.ResourceIDList {
if _, err := s.Store.UpsertMemoResource(ctx, &store.UpsertMemoResource{
MemoID: memo.ID,
ResourceID: resourceID,
}); 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)
}
}
memo, 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 memo == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memo.ID))
}
memoResponse, err := s.convertMemoFromStore(ctx, memo)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err)
}
return c.JSON(http.StatusOK, memoResponse)
})
g.PATCH("/memo/:memoId", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("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.UpsertMemoResource(ctx, &store.UpsertMemoResource{
MemoID: memo.ID,
ResourceID: resourceID,
}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err)
}
}
for _, resourceID := range removedResourceIDList {
if err := s.Store.DeleteMemoResource(ctx, &store.DeleteMemoResource{
MemoID: &memo.ID,
ResourceID: &resourceID,
}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete memo 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)
})
g.GET("/memo", func(c echo.Context) error {
ctx := c.Request().Context()
findMemoMessage := &store.FindMemo{}
if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
findMemoMessage.CreatorID = &userID
}
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
// Anonymous use should only fetch PUBLIC memos with specified user
if findMemoMessage.CreatorID == nil {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find memo")
}
findMemoMessage.VisibilityList = []store.Visibility{store.Public}
} else {
// Authorized user can fetch all PUBLIC/PROTECTED memo
visibilityList := []store.Visibility{store.Public, store.Protected}
// If Creator is authorized user (as default), PRIVATE memo is OK
if findMemoMessage.CreatorID == nil || *findMemoMessage.CreatorID == currentUserID {
findMemoMessage.CreatorID = &currentUserID
visibilityList = append(visibilityList, store.Private)
}
findMemoMessage.VisibilityList = visibilityList
}
rowStatus := store.RowStatus(c.QueryParam("rowStatus"))
if rowStatus != "" {
findMemoMessage.RowStatus = &rowStatus
}
pinnedStr := c.QueryParam("pinned")
if pinnedStr != "" {
pinned := pinnedStr == "true"
findMemoMessage.Pinned = &pinned
}
contentSearch := []string{}
tag := c.QueryParam("tag")
if tag != "" {
contentSearch = append(contentSearch, "#"+tag)
}
contentSlice := c.QueryParams()["content"]
if len(contentSlice) > 0 {
contentSearch = append(contentSearch, contentSlice...)
}
findMemoMessage.ContentSearch = contentSearch
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
}
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 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)
})
g.GET("/memo/:memoId", func(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
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(getUserIDContextKey()).(int)
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)
})
g.GET("/memo/stats", func(c echo.Context) error {
ctx := c.Request().Context()
normalStatus := store.Normal
findMemoMessage := &store.FindMemo{
RowStatus: &normalStatus,
}
if creatorID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
findMemoMessage.CreatorID = &creatorID
}
if findMemoMessage.CreatorID == nil {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find memo")
}
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
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)
}
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)
}
displayTsList := []int64{}
for _, memo := range memoResponseList {
displayTsList = append(displayTsList, memo.DisplayTs)
}
return c.JSON(http.StatusOK, displayTsList)
})
g.GET("/memo/all", func(c echo.Context) error {
ctx := c.Request().Context()
findMemoMessage := &store.FindMemo{}
_, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
findMemoMessage.VisibilityList = []store.Visibility{store.Public}
} else {
findMemoMessage.VisibilityList = []store.Visibility{store.Public, store.Protected}
}
pinnedStr := c.QueryParam("pinned")
if pinnedStr != "" {
pinned := pinnedStr == "true"
findMemoMessage.Pinned = &pinned
}
contentSearch := []string{}
tag := c.QueryParam("tag")
if tag != "" {
contentSearch = append(contentSearch, "#"+tag+" ")
}
contentSlice := c.QueryParams()["content"]
if len(contentSlice) > 0 {
contentSearch = append(contentSearch, contentSlice...)
}
findMemoMessage.ContentSearch = contentSearch
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)
})
g.DELETE("/memo/:memoId", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("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)
})
}
func (s *APIV1Service) createMemoCreateActivity(ctx context.Context, memo *store.Memo) error {
payload := ActivityMemoCreatePayload{
Content: memo.Content,
Visibility: memo.Visibility.String(),
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return errors.Wrap(err, "failed to marshal activity payload")
}
activity, err := s.Store.CreateActivity(ctx, &store.Activity{
CreatorID: memo.CreatorID,
Type: ActivityMemoCreate.String(),
Level: ActivityInfo.String(),
Payload: string(payloadBytes),
})
if err != nil || activity == nil {
return errors.Wrap(err, "failed to create activity")
}
return err
}
func (s *APIV1Service) 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
}
// 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 err != nil {
return nil, err
}
if resource != nil {
resourceList = append(resourceList, convertResourceFromStore(resource))
}
}
memoResponse.ResourceList = resourceList
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 []int) (addedList, removedList []int) {
oldMap := map[int]bool{}
for _, id := range oldList {
oldMap[id] = true
}
newMap := map[int]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
}

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

@@ -0,0 +1,80 @@
package v1
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/labstack/echo/v4"
"github.com/usememos/memos/store"
)
type MemoOrganizer struct {
MemoID int `json:"memoId"`
UserID int `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", func(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
ID: &memoID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err)
}
if memo == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %v", memoID))
}
if memo.CreatorID != userID {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
request := &UpsertMemoOrganizerRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo organizer request").SetInternal(err)
}
upsert := &store.MemoOrganizer{
MemoID: memoID,
UserID: userID,
Pinned: request.Pinned,
}
_, err = s.Store.UpsertMemoOrganizer(ctx, upsert)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo organizer").SetInternal(err)
}
memo, err = s.Store.GetMemo(ctx, &store.FindMemo{
ID: &memoID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find memo by ID: %v", memoID)).SetInternal(err)
}
if memo == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %v", memoID))
}
memoResponse, err := s.convertMemoFromStore(ctx, memo)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err)
}
return c.JSON(http.StatusOK, memoResponse)
})
}

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

@@ -0,0 +1,100 @@
package v1
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/labstack/echo/v4"
"github.com/usememos/memos/store"
)
type MemoRelationType string
const (
MemoRelationReference MemoRelationType = "REFERENCE"
MemoRelationAdditional MemoRelationType = "ADDITIONAL"
)
type MemoRelation struct {
MemoID int `json:"memoId"`
RelatedMemoID int `json:"relatedMemoId"`
Type MemoRelationType `json:"type"`
}
type UpsertMemoRelationRequest struct {
RelatedMemoID int `json:"relatedMemoId"`
Type MemoRelationType `json:"type"`
}
func (s *APIV1Service) registerMemoRelationRoutes(g *echo.Group) {
g.POST("/memo/:memoId/relation", func(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
request := &UpsertMemoRelationRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo relation request").SetInternal(err)
}
memoRelation, err := s.Store.UpsertMemoRelation(ctx, &store.MemoRelation{
MemoID: memoID,
RelatedMemoID: request.RelatedMemoID,
Type: store.MemoRelationType(request.Type),
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo relation").SetInternal(err)
}
return c.JSON(http.StatusOK, memoRelation)
})
g.GET("/memo/:memoId/relation", func(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
memoRelationList, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{
MemoID: &memoID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to list memo relations").SetInternal(err)
}
return c.JSON(http.StatusOK, memoRelationList)
})
g.DELETE("/memo/:memoId/relation/:relatedMemoId/type/:relationType", func(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Memo ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
relatedMemoID, err := strconv.Atoi(c.Param("relatedMemoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Related memo ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
relationType := store.MemoRelationType(c.Param("relationType"))
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),
}
}

134
api/v1/memo_resource.go Normal file
View File

@@ -0,0 +1,134 @@
package v1
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/labstack/echo/v4"
"github.com/usememos/memos/store"
)
type MemoResource struct {
MemoID int `json:"memoId"`
ResourceID int `json:"resourceId"`
CreatedTs int64 `json:"createdTs"`
UpdatedTs int64 `json:"updatedTs"`
}
type UpsertMemoResourceRequest struct {
ResourceID int `json:"resourceId"`
UpdatedTs *int64 `json:"updatedTs"`
}
type MemoResourceFind struct {
MemoID *int
ResourceID *int
}
type MemoResourceDelete struct {
MemoID *int
ResourceID *int
}
func (s *APIV1Service) registerMemoResourceRoutes(g *echo.Group) {
g.POST("/memo/:memoId/resource", func(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
request := &UpsertMemoResourceRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo resource request").SetInternal(err)
}
resource, err := s.Store.GetResource(ctx, &store.FindResource{
ID: &request.ResourceID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource").SetInternal(err)
}
if resource == nil {
return echo.NewHTTPError(http.StatusBadRequest, "Resource not found").SetInternal(err)
} else if resource.CreatorID != userID {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized to bind this resource").SetInternal(err)
}
upsert := &store.UpsertMemoResource{
MemoID: memoID,
ResourceID: request.ResourceID,
CreatedTs: time.Now().Unix(),
}
if request.UpdatedTs != nil {
upsert.UpdatedTs = request.UpdatedTs
}
if _, err := s.Store.UpsertMemoResource(ctx, upsert); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err)
}
return c.JSON(http.StatusOK, true)
})
g.GET("/memo/:memoId/resource", func(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
list, err := s.Store.ListResources(ctx, &store.FindResource{
MemoID: &memoID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
}
resourceList := []*Resource{}
for _, resource := range list {
resourceList = append(resourceList, convertResourceFromStore(resource))
}
return c.JSON(http.StatusOK, resourceList)
})
g.DELETE("/memo/:memoId/resource/:resourceId", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Memo ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
resourceID, err := strconv.Atoi(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Resource ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
ID: &memoID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err)
}
if memo == nil {
return echo.NewHTTPError(http.StatusBadRequest, "Memo not found")
}
if memo.CreatorID != userID {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
if err := s.Store.DeleteMemoResource(ctx, &store.DeleteMemoResource{
MemoID: &memoID,
ResourceID: &resourceID,
}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
}
return c.JSON(http.StatusOK, true)
})
}

130
api/v1/openai.go Normal file
View File

@@ -0,0 +1,130 @@
package v1
import (
"encoding/json"
"net/http"
"time"
echosse "github.com/CorrectRoadH/echo-sse"
"github.com/PullRequestInc/go-gpt3"
"github.com/labstack/echo/v4"
"github.com/usememos/memos/plugin/openai"
"github.com/usememos/memos/store"
)
func (s *APIV1Service) registerOpenAIRoutes(g *echo.Group) {
g.POST("/openai/chat-completion", func(c echo.Context) error {
ctx := c.Request().Context()
openAIConfigSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
Name: SystemSettingOpenAIConfigName.String(),
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find openai key").SetInternal(err)
}
openAIConfig := OpenAIConfig{}
if openAIConfigSetting != nil {
err = json.Unmarshal([]byte(openAIConfigSetting.Value), &openAIConfig)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal openai system setting value").SetInternal(err)
}
}
if openAIConfig.Key == "" {
return echo.NewHTTPError(http.StatusBadRequest, "OpenAI API key not set")
}
messages := []openai.ChatCompletionMessage{}
if err := json.NewDecoder(c.Request().Body).Decode(&messages); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post chat completion request").SetInternal(err)
}
if len(messages) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "No messages provided")
}
result, err := openai.PostChatCompletion(messages, openAIConfig.Key, openAIConfig.Host)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to post chat completion").SetInternal(err)
}
return c.JSON(http.StatusOK, result)
})
g.POST("/openai/chat-streaming", func(c echo.Context) error {
messages := []gpt3.ChatCompletionRequestMessage{}
if err := json.NewDecoder(c.Request().Body).Decode(&messages); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post chat completion request").SetInternal(err)
}
if len(messages) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "No messages provided")
}
ctx := c.Request().Context()
openAIConfigSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
Name: SystemSettingOpenAIConfigName.String(),
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find openai key").SetInternal(err)
}
openAIConfig := OpenAIConfig{}
if openAIConfigSetting != nil {
err = json.Unmarshal([]byte(openAIConfigSetting.Value), &openAIConfig)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal openai system setting value").SetInternal(err)
}
}
if openAIConfig.Key == "" {
return echo.NewHTTPError(http.StatusBadRequest, "OpenAI API key not set")
}
sse := echosse.NewSSEClint(c)
// to do these things in server may not elegant.
// But move it to openai plugin will break the simple. Because it is a streaming. We must use a channel to do it.
// And we can think it is a forward proxy. So it in here is not a bad idea.
client := gpt3.NewClient(openAIConfig.Key)
err = client.ChatCompletionStream(ctx, gpt3.ChatCompletionRequest{
Model: gpt3.GPT3Dot5Turbo,
Messages: messages,
Stream: true,
},
func(resp *gpt3.ChatCompletionStreamResponse) {
// _ is for to pass the golangci-lint check
_ = sse.SendEvent(resp.Choices[0].Delta.Content)
// to delay 0.5 s
time.Sleep(50 * time.Millisecond)
// the delay is a very good way to make the chatbot more comfortable
// otherwise the chatbot will reply too fast. Believe me it is not good.🤔
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to chat with OpenAI").SetInternal(err)
}
return nil
})
g.GET("/openai/enabled", func(c echo.Context) error {
ctx := c.Request().Context()
openAIConfigSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
Name: SystemSettingOpenAIConfigName.String(),
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find openai key").SetInternal(err)
}
openAIConfig := OpenAIConfig{}
if openAIConfigSetting != nil {
err = json.Unmarshal([]byte(openAIConfigSetting.Value), &openAIConfig)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal openai system setting value").SetInternal(err)
}
}
if openAIConfig.Key == "" {
return echo.NewHTTPError(http.StatusBadRequest, "OpenAI API key not set")
}
return c.JSON(http.StatusOK, openAIConfig.Key != "")
})
}

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

@@ -0,0 +1,671 @@
package v1
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/disintegration/imaging"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"github.com/usememos/memos/common/log"
"github.com/usememos/memos/common/util"
"github.com/usememos/memos/plugin/storage/s3"
"github.com/usememos/memos/store"
"go.uber.org/zap"
)
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:"-"`
InternalPath string `json:"-"`
ExternalLink string `json:"externalLink"`
Type string `json:"type"`
Size int64 `json:"size"`
// Related fields
LinkedMemoAmount int `json:"linkedMemoAmount"`
}
type CreateResourceRequest struct {
Filename string `json:"filename"`
InternalPath string `json:"internalPath"`
ExternalLink string `json:"externalLink"`
Type string `json:"type"`
DownloadToLocal bool `json:"downloadToLocal"`
}
type FindResourceRequest struct {
ID *int `json:"id"`
CreatorID *int `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
// thumbnailImagePath is the directory to store image thumbnails.
thumbnailImagePath = ".thumbnail_cache"
)
var fileKeyPattern = regexp.MustCompile(`\{[a-z]{1,9}\}`)
func (s *APIV1Service) registerResourceRoutes(g *echo.Group) {
g.POST("/resource", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
request := &CreateResourceRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post resource request").SetInternal(err)
}
create := &store.Resource{
CreatorID: userID,
Filename: request.Filename,
ExternalLink: request.ExternalLink,
Type: request.Type,
}
if request.ExternalLink != "" {
// Only allow those external links scheme with http/https
linkURL, err := url.Parse(request.ExternalLink)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid external link").SetInternal(err)
}
if linkURL.Scheme != "http" && linkURL.Scheme != "https" {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid external link scheme")
}
if request.DownloadToLocal {
resp, err := http.Get(linkURL.String())
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to request %s", request.ExternalLink))
}
defer resp.Body.Close()
blob, err := io.ReadAll(resp.Body)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to read %s", request.ExternalLink))
}
mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to read mime from %s", request.ExternalLink))
}
create.Type = mediaType
filename := path.Base(linkURL.Path)
if path.Ext(filename) == "" {
extensions, _ := mime.ExtensionsByType(mediaType)
if len(extensions) > 0 {
filename += extensions[0]
}
}
create.Filename = filename
create.ExternalLink = ""
create.Size = int64(len(blob))
err = SaveResourceBlob(ctx, s.Store, create, bytes.NewReader(blob))
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to save resource").SetInternal(err)
}
}
}
resource, err := s.Store.CreateResource(ctx, create)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err)
}
if err := s.createResourceCreateActivity(ctx, resource); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
}
return c.JSON(http.StatusOK, convertResourceFromStore(resource))
})
g.POST("/resource/blob", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
// This is the backend default max upload size limit.
maxUploadSetting := s.Store.GetSystemSettingValueWithDefault(&ctx, SystemSettingMaxUploadSizeMiBName.String(), "32")
var settingMaxUploadSizeBytes int
if settingMaxUploadSizeMiB, err := strconv.Atoi(maxUploadSetting); err == nil {
settingMaxUploadSizeBytes = settingMaxUploadSizeMiB * MebiByte
} else {
log.Warn("Failed to parse max upload size", zap.Error(err))
settingMaxUploadSizeBytes = 0
}
file, err := c.FormFile("file")
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get uploading file").SetInternal(err)
}
if file == nil {
return echo.NewHTTPError(http.StatusBadRequest, "Upload file not found").SetInternal(err)
}
if file.Size > int64(settingMaxUploadSizeBytes) {
message := fmt.Sprintf("File size exceeds allowed limit of %d MiB", settingMaxUploadSizeBytes/MebiByte)
return echo.NewHTTPError(http.StatusBadRequest, message).SetInternal(err)
}
if err := c.Request().ParseMultipartForm(maxUploadBufferSizeBytes); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Failed to parse upload data").SetInternal(err)
}
sourceFile, err := file.Open()
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to open file").SetInternal(err)
}
defer sourceFile.Close()
create := &store.Resource{
CreatorID: userID,
Filename: file.Filename,
Type: file.Header.Get("Content-Type"),
Size: file.Size,
}
err = SaveResourceBlob(ctx, s.Store, create, sourceFile)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to save resource").SetInternal(err)
}
resource, err := s.Store.CreateResource(ctx, create)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err)
}
if err := s.createResourceCreateActivity(ctx, resource); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
}
return c.JSON(http.StatusOK, convertResourceFromStore(resource))
})
g.GET("/resource", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
find := &store.FindResource{
CreatorID: &userID,
}
if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
find.Limit = &limit
}
if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil {
find.Offset = &offset
}
list, err := s.Store.ListResources(ctx, find)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
}
resourceMessageList := []*Resource{}
for _, resource := range list {
resourceMessageList = append(resourceMessageList, convertResourceFromStore(resource))
}
return c.JSON(http.StatusOK, resourceMessageList)
})
g.PATCH("/resource/:resourceId", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
resourceID, err := strconv.Atoi(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
resource, err := s.Store.GetResource(ctx, &store.FindResource{
ID: &resourceID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find resource").SetInternal(err)
}
if resource == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource not found: %d", resourceID))
}
if resource.CreatorID != userID {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
request := &UpdateResourceRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch resource request").SetInternal(err)
}
currentTs := time.Now().Unix()
update := &store.UpdateResource{
ID: resourceID,
UpdatedTs: &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))
})
g.DELETE("/resource/:resourceId", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
resourceID, err := strconv.Atoi(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
resource, err := s.Store.GetResource(ctx, &store.FindResource{
ID: &resourceID,
CreatorID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find resource").SetInternal(err)
}
if resource == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource not found: %d", resourceID))
}
if resource.InternalPath != "" {
if err := os.Remove(resource.InternalPath); err != nil {
log.Warn(fmt.Sprintf("failed to delete local file with path %s", resource.InternalPath), zap.Error(err))
}
}
ext := filepath.Ext(resource.Filename)
thumbnailPath := filepath.Join(s.Profile.Data, thumbnailImagePath, fmt.Sprintf("%d%s", resource.ID, ext))
if err := os.Remove(thumbnailPath); err != nil {
log.Warn(fmt.Sprintf("failed to delete local thumbnail with path %s", thumbnailPath), zap.Error(err))
}
if err := s.Store.DeleteResource(ctx, &store.DeleteResource{
ID: resourceID,
}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete resource").SetInternal(err)
}
return c.JSON(http.StatusOK, true)
})
}
func (s *APIV1Service) registerResourcePublicRoutes(g *echo.Group) {
f := func(c echo.Context) error {
ctx := c.Request().Context()
resourceID, err := strconv.Atoi(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
resourceVisibility, err := checkResourceVisibility(ctx, s.Store, resourceID)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Failed to get resource visibility").SetInternal(err)
}
// Protected resource require a logined user
userID, ok := c.Get(getUserIDContextKey()).(int)
if resourceVisibility == store.Protected && (!ok || userID <= 0) {
return echo.NewHTTPError(http.StatusUnauthorized, "Resource visibility not match").SetInternal(err)
}
resource, err := s.Store.GetResource(ctx, &store.FindResource{
ID: &resourceID,
GetBlob: true,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find resource by ID: %v", resourceID)).SetInternal(err)
}
if resource == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource not found: %d", resourceID))
}
// Private resource require logined user is the creator
if resourceVisibility == store.Private && (!ok || userID != resource.CreatorID) {
return echo.NewHTTPError(http.StatusUnauthorized, "Resource visibility not match").SetInternal(err)
}
blob := resource.Blob
if resource.InternalPath != "" {
resourcePath := resource.InternalPath
src, err := os.Open(resourcePath)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to open the local resource: %s", resourcePath)).SetInternal(err)
}
defer src.Close()
blob, err = io.ReadAll(src)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to read the local resource: %s", resourcePath)).SetInternal(err)
}
}
if c.QueryParam("thumbnail") == "1" && util.HasPrefixes(resource.Type, "image/png", "image/jpeg") {
ext := filepath.Ext(resource.Filename)
thumbnailPath := filepath.Join(s.Profile.Data, thumbnailImagePath, fmt.Sprintf("%d%s", resource.ID, ext))
thumbnailBlob, err := getOrGenerateThumbnailImage(blob, thumbnailPath)
if err != nil {
log.Warn(fmt.Sprintf("failed to get or generate local thumbnail with path %s", thumbnailPath), zap.Error(err))
} else {
blob = thumbnailBlob
}
}
c.Response().Writer.Header().Set(echo.HeaderCacheControl, "max-age=31536000, immutable")
c.Response().Writer.Header().Set(echo.HeaderContentSecurityPolicy, "default-src 'self'")
resourceType := strings.ToLower(resource.Type)
if strings.HasPrefix(resourceType, "text") {
resourceType = echo.MIMETextPlainCharsetUTF8
} else if strings.HasPrefix(resourceType, "video") || strings.HasPrefix(resourceType, "audio") {
http.ServeContent(c.Response(), c.Request(), resource.Filename, time.Unix(resource.UpdatedTs, 0), bytes.NewReader(blob))
return nil
}
return c.Stream(http.StatusOK, resourceType, bytes.NewReader(blob))
}
g.GET("/r/:resourceId", f)
g.GET("/r/:resourceId/*", f)
}
func (s *APIV1Service) createResourceCreateActivity(ctx context.Context, resource *store.Resource) error {
payload := ActivityResourceCreatePayload{
Filename: resource.Filename,
Type: resource.Type,
Size: resource.Size,
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return errors.Wrap(err, "failed to marshal activity payload")
}
activity, err := s.Store.CreateActivity(ctx, &store.Activity{
CreatorID: resource.CreatorID,
Type: ActivityResourceCreate.String(),
Level: ActivityInfo.String(),
Payload: string(payloadBytes),
})
if err != nil || activity == nil {
return errors.Wrap(err, "failed to create activity")
}
return err
}
func replacePathTemplate(path, filename string) string {
t := time.Now()
path = fileKeyPattern.ReplaceAllStringFunc(path, func(s string) string {
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
}
var availableGeneratorAmount int32 = 32
func getOrGenerateThumbnailImage(srcBlob []byte, dstPath string) ([]byte, error) {
if _, err := os.Stat(dstPath); err != nil {
if !errors.Is(err, os.ErrNotExist) {
return nil, errors.Wrap(err, "failed to check thumbnail image stat")
}
if atomic.LoadInt32(&availableGeneratorAmount) <= 0 {
return nil, errors.New("not enough available generator amount")
}
atomic.AddInt32(&availableGeneratorAmount, -1)
defer func() {
atomic.AddInt32(&availableGeneratorAmount, 1)
}()
reader := bytes.NewReader(srcBlob)
src, err := imaging.Decode(reader, imaging.AutoOrientation(true))
if err != nil {
return nil, errors.Wrap(err, "failed to decode thumbnail image")
}
thumbnailImage := imaging.Resize(src, 512, 0, imaging.Lanczos)
dstDir := path.Dir(dstPath)
if err := os.MkdirAll(dstDir, os.ModePerm); err != nil {
return nil, errors.Wrap(err, "failed to create thumbnail dir")
}
if err := imaging.Save(thumbnailImage, dstPath); err != nil {
return nil, errors.Wrap(err, "failed to resize thumbnail image")
}
}
dstFile, err := os.Open(dstPath)
if err != nil {
return nil, errors.Wrap(err, "failed to open the local resource")
}
defer dstFile.Close()
dstBlob, err := io.ReadAll(dstFile)
if err != nil {
return nil, errors.Wrap(err, "failed to read the local resource")
}
return dstBlob, nil
}
func checkResourceVisibility(ctx context.Context, s *store.Store, resourceID int) (store.Visibility, error) {
memoResources, err := s.ListMemoResources(ctx, &store.FindMemoResource{
ResourceID: &resourceID,
})
if err != nil {
return store.Private, err
}
// If resource is belongs to no memo, it'll always PRIVATE.
if len(memoResources) == 0 {
return store.Private, nil
}
memoIDs := make([]int, 0, len(memoResources))
for _, memoResource := range memoResources {
memoIDs = append(memoIDs, memoResource.MemoID)
}
visibilityList, err := s.FindMemosVisibilityList(ctx, memoIDs)
if err != nil {
return store.Private, err
}
var isProtected bool
for _, visibility := range visibilityList {
// If any memo is PUBLIC, resource should be PUBLIC too.
if visibility == store.Public {
return store.Public, nil
}
if visibility == store.Protected {
isProtected = true
}
}
if isProtected {
return store.Protected, nil
}
return store.Private, nil
}
func convertResourceFromStore(resource *store.Resource) *Resource {
return &Resource{
ID: resource.ID,
CreatorID: resource.CreatorID,
CreatedTs: resource.CreatedTs,
UpdatedTs: resource.UpdatedTs,
Filename: resource.Filename,
Blob: resource.Blob,
InternalPath: resource.InternalPath,
ExternalLink: resource.ExternalLink,
Type: resource.Type,
Size: resource.Size,
LinkedMemoAmount: resource.LinkedMemoAmount,
}
}
// 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 fmt.Errorf("Failed to find SystemSettingStorageServiceIDName: %s", err)
}
storageServiceID := DatabaseStorage
if systemSettingStorageServiceID != nil {
err = json.Unmarshal([]byte(systemSettingStorageServiceID.Value), &storageServiceID)
if err != nil {
return fmt.Errorf("Failed to unmarshal storage service id: %s", err)
}
}
// `DatabaseStorage` means store blob into database
if storageServiceID == DatabaseStorage {
fileBytes, err := io.ReadAll(r)
if err != nil {
return fmt.Errorf("Failed to read file: %s", err)
}
create.Blob = fileBytes
return nil
}
// `LocalStorage` means save blob into local disk
if storageServiceID == LocalStorage {
systemSettingLocalStoragePath, err := s.GetSystemSetting(ctx, &store.FindSystemSetting{Name: SystemSettingLocalStoragePathName.String()})
if err != nil {
return fmt.Errorf("Failed to find SystemSettingLocalStoragePathName: %s", err)
}
localStoragePath := "assets/{filename}"
if systemSettingLocalStoragePath != nil && systemSettingLocalStoragePath.Value != "" {
err = json.Unmarshal([]byte(systemSettingLocalStoragePath.Value), &localStoragePath)
if err != nil {
return fmt.Errorf("Failed to unmarshal SystemSettingLocalStoragePathName: %s", err)
}
}
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 fmt.Errorf("Failed to create directory: %s", err)
}
dst, err := os.Create(filePath)
if err != nil {
return fmt.Errorf("Failed to create file: %s", err)
}
defer dst.Close()
_, err = io.Copy(dst, r)
if err != nil {
return fmt.Errorf("Failed to copy file: %s", err)
}
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 fmt.Errorf("Failed to find StorageServiceID: %s", err)
}
if storage == nil {
return fmt.Errorf("Storage %d not found", storageServiceID)
}
storageMessage, err := ConvertStorageFromStore(storage)
if err != nil {
return fmt.Errorf("Failed to ConvertStorageFromStore: %s", err)
}
if storageMessage.Type != StorageS3 {
return fmt.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 fmt.Errorf("Failed to create s3 client: %s", err)
}
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 fmt.Errorf("Failed to upload via s3 client: %s", err)
}
create.ExternalLink = link
return nil
}

View File

@@ -1,8 +1,10 @@
package server
package v1
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
@@ -10,69 +12,66 @@ import (
"github.com/gorilla/feeds"
"github.com/labstack/echo/v4"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common/util"
"github.com/usememos/memos/store"
"github.com/yuin/goldmark"
)
func (s *Server) registerRSSRoutes(g *echo.Group) {
const maxRSSItemCount = 100
const maxRSSItemTitleLength = 100
func (s *APIV1Service) registerRSSRoutes(g *echo.Group) {
g.GET("/explore/rss.xml", func(c echo.Context) error {
ctx := c.Request().Context()
systemCustomizedProfile, err := getSystemCustomizedProfile(ctx, s)
systemCustomizedProfile, err := s.getSystemCustomizedProfile(ctx)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get system customized profile").SetInternal(err)
}
normalStatus := api.Normal
memoFind := api.MemoFind{
RowStatus: &normalStatus,
VisibilityList: []api.Visibility{
api.Public,
},
normalStatus := store.Normal
memoFind := store.FindMemo{
RowStatus: &normalStatus,
VisibilityList: []store.Visibility{store.Public},
}
memoList, err := s.Store.FindMemoList(ctx, &memoFind)
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 := generateRSSFromMemoList(memoList, baseURL, &systemCustomizedProfile)
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)
})
g.GET("/u/:id/rss.xml", func(c echo.Context) error {
ctx := c.Request().Context()
systemCustomizedProfile, err := getSystemCustomizedProfile(ctx, s)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get system customized profile").SetInternal(err)
}
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "User id is not a number").SetInternal(err)
}
normalStatus := api.Normal
memoFind := api.MemoFind{
CreatorID: &id,
RowStatus: &normalStatus,
VisibilityList: []api.Visibility{
api.Public,
},
systemCustomizedProfile, err := s.getSystemCustomizedProfile(ctx)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get system customized profile").SetInternal(err)
}
memoList, err := s.Store.FindMemoList(ctx, &memoFind)
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 := generateRSSFromMemoList(memoList, baseURL, &systemCustomizedProfile)
rss, err := s.generateRSSFromMemoList(ctx, memoList, baseURL, systemCustomizedProfile)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate rss").SetInternal(err)
}
@@ -81,10 +80,7 @@ func (s *Server) registerRSSRoutes(g *echo.Group) {
})
}
const MaxRSSItemCount = 100
const MaxRSSItemTitleLength = 100
func generateRSSFromMemoList(memoList []*api.Memo, baseURL string, profile *api.CustomizedProfile) (string, error) {
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},
@@ -92,18 +88,37 @@ func generateRSSFromMemoList(memoList []*api.Memo, baseURL string, profile *api.
Created: time.Now(),
}
var itemCountLimit = min(len(memoList), MaxRSSItemCount)
var itemCountLimit = util.Min(len(memoList), maxRSSItemCount)
feed.Items = make([]*feeds.Item, itemCountLimit)
for i := 0; i < itemCountLimit; i++ {
memo := memoList[i]
feed.Items[i] = &feeds.Item{
Title: getRSSItemTitle(memo.Content),
Link: &feeds.Link{Href: baseURL + "/m/" + strconv.Itoa(memo.ID)},
Description: getRSSItemDescription(memo.Content),
Created: time.Unix(memo.CreatedTs, 0),
Enclosure: &feeds.Enclosure{Url: baseURL + "/m/" + strconv.Itoa(memo.ID) + "/image"},
}
if len(memo.ResourceIDList) > 0 {
resourceID := memo.ResourceIDList[0]
resource, err := s.Store.GetResource(ctx, &store.FindResource{
ID: &resourceID,
})
if err != nil {
return "", err
}
if resource == nil {
return "", fmt.Errorf("Resource not found: %d", resourceID)
}
enclosure := feeds.Enclosure{}
if resource.ExternalLink != "" {
enclosure.Url = resource.ExternalLink
} else {
enclosure.Url = baseURL + "/o/r/" + strconv.Itoa(resource.ID)
}
enclosure.Length = strconv.Itoa(int(resource.Size))
enclosure.Type = resource.Type
feed.Items[i].Enclosure = &enclosure
}
}
@@ -114,64 +129,27 @@ func generateRSSFromMemoList(memoList []*api.Memo, baseURL string, profile *api.
return rss, nil
}
func getSystemCustomizedProfile(ctx context.Context, s *Server) (api.CustomizedProfile, error) {
systemStatus := api.SystemStatus{
CustomizedProfile: api.CustomizedProfile{
Name: "memos",
LogoURL: "",
Description: "",
Locale: "en",
Appearance: "system",
ExternalURL: "",
},
}
systemSettingList, err := s.Store.FindSystemSettingList(ctx, &api.SystemSettingFind{})
func (s *APIV1Service) getSystemCustomizedProfile(ctx context.Context) (*CustomizedProfile, error) {
systemSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
Name: SystemSettingCustomizedProfileName.String(),
})
if err != nil {
return api.CustomizedProfile{}, err
return nil, err
}
for _, systemSetting := range systemSettingList {
if systemSetting.Name == api.SystemSettingServerID || systemSetting.Name == api.SystemSettingSecretSessionName {
continue
}
var value interface{}
err := json.Unmarshal([]byte(systemSetting.Value), &value)
if err != nil {
return api.CustomizedProfile{}, err
}
if systemSetting.Name == api.SystemSettingCustomizedProfileName {
valueMap := value.(map[string]interface{})
systemStatus.CustomizedProfile = api.CustomizedProfile{}
if v := valueMap["name"]; v != nil {
systemStatus.CustomizedProfile.Name = v.(string)
}
if v := valueMap["logoUrl"]; v != nil {
systemStatus.CustomizedProfile.LogoURL = v.(string)
}
if v := valueMap["description"]; v != nil {
systemStatus.CustomizedProfile.Description = v.(string)
}
if v := valueMap["locale"]; v != nil {
systemStatus.CustomizedProfile.Locale = v.(string)
}
if v := valueMap["appearance"]; v != nil {
systemStatus.CustomizedProfile.Appearance = v.(string)
}
if v := valueMap["externalUrl"]; v != nil {
systemStatus.CustomizedProfile.ExternalURL = v.(string)
}
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 systemStatus.CustomizedProfile, nil
}
func min(a, b int) int {
if a < b {
return a
}
return b
return customizedProfile, nil
}
func getRSSItemTitle(content string) string {
@@ -180,7 +158,7 @@ func getRSSItemTitle(content string) string {
title = strings.Split(content, "\n")[0][2:]
} else {
title = strings.Split(content, "\n")[0]
var titleLengthLimit = min(len(title), MaxRSSItemTitleLength)
var titleLengthLimit = util.Min(len(title), maxRSSItemTitleLength)
if titleLengthLimit < len(title) {
title = title[:titleLengthLimit] + "..."
}
@@ -196,7 +174,13 @@ func getRSSItemDescription(content string) string {
} else {
description = content
}
return description
// 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 {

View File

@@ -1,4 +1,4 @@
package server
package v1
import (
"encoding/json"
@@ -7,34 +7,79 @@ import (
"strconv"
"time"
"github.com/pkg/errors"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"github.com/usememos/memos/store"
)
func (s *Server) registerShortcutRoutes(g *echo.Group) {
type Shortcut struct {
ID int `json:"id"`
// Standard fields
RowStatus RowStatus `json:"rowStatus"`
CreatorID int `json:"creatorId"`
CreatedTs int64 `json:"createdTs"`
UpdatedTs int64 `json:"updatedTs"`
// Domain specific fields
Title string `json:"title"`
Payload string `json:"payload"`
}
type CreateShortcutRequest struct {
Title string `json:"title"`
Payload string `json:"payload"`
}
type UpdateShortcutRequest struct {
RowStatus *RowStatus `json:"rowStatus"`
Title *string `json:"title"`
Payload *string `json:"payload"`
}
type ShortcutFind struct {
ID *int
// Standard fields
CreatorID *int
// Domain specific fields
Title *string `json:"title"`
}
type ShortcutDelete struct {
ID *int
// Standard fields
CreatorID *int
}
func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
g.POST("/shortcut", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
shortcutCreate := &api.ShortcutCreate{}
shortcutCreate := &CreateShortcutRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(shortcutCreate); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post shortcut request").SetInternal(err)
}
shortcutCreate.CreatorID = userID
shortcut, err := s.Store.CreateShortcut(ctx, shortcutCreate)
shortcut, err := s.Store.CreateShortcut(ctx, &store.Shortcut{
CreatorID: userID,
Title: shortcutCreate.Title,
Payload: shortcutCreate.Payload,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create shortcut").SetInternal(err)
}
if err := s.createShortcutCreateActivity(c, shortcut); err != nil {
shortcutMessage := convertShortcutFromStore(shortcut)
if err := s.createShortcutCreateActivity(c, shortcutMessage); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
}
return c.JSON(http.StatusOK, composeResponse(shortcut))
return c.JSON(http.StatusOK, shortcutMessage)
})
g.PATCH("/shortcut/:shortcutId", func(c echo.Context) error {
@@ -48,31 +93,45 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
}
shortcutFind := &api.ShortcutFind{
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
ID: &shortcutID,
}
shortcut, err := s.Store.FindShortcut(ctx, shortcutFind)
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find shortcut").SetInternal(err)
}
if shortcut == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Shortcut not found: %d", shortcutID))
}
if shortcut.CreatorID != userID {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
currentTs := time.Now().Unix()
shortcutPatch := &api.ShortcutPatch{
UpdatedTs: &currentTs,
}
if err := json.NewDecoder(c.Request().Body).Decode(shortcutPatch); err != nil {
request := &UpdateShortcutRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch shortcut request").SetInternal(err)
}
shortcutPatch.ID = shortcutID
shortcut, err = s.Store.PatchShortcut(ctx, shortcutPatch)
currentTs := time.Now().Unix()
shortcutUpdate := &store.UpdateShortcut{
ID: shortcutID,
UpdatedTs: &currentTs,
}
if request.RowStatus != nil {
rowStatus := store.RowStatus(*request.RowStatus)
shortcutUpdate.RowStatus = &rowStatus
}
if request.Title != nil {
shortcutUpdate.Title = request.Title
}
if request.Payload != nil {
shortcutUpdate.Payload = request.Payload
}
shortcut, err = s.Store.UpdateShortcut(ctx, shortcutUpdate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch shortcut").SetInternal(err)
}
return c.JSON(http.StatusOK, composeResponse(shortcut))
return c.JSON(http.StatusOK, convertShortcutFromStore(shortcut))
})
g.GET("/shortcut", func(c echo.Context) error {
@@ -82,14 +141,17 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find shortcut")
}
shortcutFind := &api.ShortcutFind{
list, err := s.Store.ListShortcuts(ctx, &store.FindShortcut{
CreatorID: &userID,
}
list, err := s.Store.FindShortcutList(ctx, shortcutFind)
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch shortcut list").SetInternal(err)
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get shortcut list").SetInternal(err)
}
return c.JSON(http.StatusOK, composeResponse(list))
shortcutMessageList := make([]*Shortcut, 0, len(list))
for _, shortcut := range list {
shortcutMessageList = append(shortcutMessageList, convertShortcutFromStore(shortcut))
}
return c.JSON(http.StatusOK, shortcutMessageList)
})
g.GET("/shortcut/:shortcutId", func(c echo.Context) error {
@@ -99,14 +161,16 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
}
shortcutFind := &api.ShortcutFind{
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
ID: &shortcutID,
}
shortcut, err := s.Store.FindShortcut(ctx, shortcutFind)
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to fetch shortcut by ID %d", *shortcutFind.ID)).SetInternal(err)
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to fetch shortcut by ID %d", shortcutID)).SetInternal(err)
}
return c.JSON(http.StatusOK, composeResponse(shortcut))
if shortcut == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Shortcut not found: %d", shortcutID))
}
return c.JSON(http.StatusOK, convertShortcutFromStore(shortcut))
})
g.DELETE("/shortcut/:shortcutId", func(c echo.Context) error {
@@ -120,33 +184,31 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
}
shortcutFind := &api.ShortcutFind{
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
ID: &shortcutID,
}
shortcut, err := s.Store.FindShortcut(ctx, shortcutFind)
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find shortcut").SetInternal(err)
}
if shortcut == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Shortcut not found: %d", shortcutID))
}
if shortcut.CreatorID != userID {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
shortcutDelete := &api.ShortcutDelete{
if err := s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{
ID: &shortcutID,
}
if err := s.Store.DeleteShortcut(ctx, shortcutDelete); err != nil {
if common.ErrorCode(err) == common.NotFound {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Shortcut ID not found: %d", shortcutID))
}
}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete shortcut").SetInternal(err)
}
return c.JSON(http.StatusOK, true)
})
}
func (s *Server) createShortcutCreateActivity(c echo.Context, shortcut *api.Shortcut) error {
func (s *APIV1Service) createShortcutCreateActivity(c echo.Context, shortcut *Shortcut) error {
ctx := c.Request().Context()
payload := api.ActivityShortcutCreatePayload{
payload := ActivityShortcutCreatePayload{
Title: shortcut.Title,
Payload: shortcut.Payload,
}
@@ -154,10 +216,10 @@ func (s *Server) createShortcutCreateActivity(c echo.Context, shortcut *api.Shor
if err != nil {
return errors.Wrap(err, "failed to marshal activity payload")
}
activity, err := s.Store.CreateActivity(ctx, &api.ActivityCreate{
activity, err := s.Store.CreateActivity(ctx, &store.Activity{
CreatorID: shortcut.CreatorID,
Type: api.ActivityShortcutCreate,
Level: api.ActivityInfo,
Type: ActivityShortcutCreate.String(),
Level: ActivityInfo.String(),
Payload: string(payloadBytes),
})
if err != nil || activity == nil {
@@ -165,3 +227,15 @@ func (s *Server) createShortcutCreateActivity(c echo.Context, shortcut *api.Shor
}
return err
}
func convertShortcutFromStore(shortcut *store.Shortcut) *Shortcut {
return &Shortcut{
ID: shortcut.ID,
RowStatus: RowStatus(shortcut.RowStatus),
CreatorID: shortcut.CreatorID,
Title: shortcut.Title,
Payload: shortcut.Payload,
CreatedTs: shortcut.CreatedTs,
UpdatedTs: shortcut.UpdatedTs,
}
}

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

@@ -0,0 +1,260 @@
package v1
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/labstack/echo/v4"
"github.com/usememos/memos/store"
)
const (
// LocalStorage means the storage service is local file system.
LocalStorage = -1
// DatabaseStorage means the storage service is database.
DatabaseStorage = 0
)
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 int `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.POST("/storage", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
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)
})
g.PATCH("/storage/:storageId", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if user == nil || user.Role != store.RoleHost {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
storageID, err := strconv.Atoi(c.Param("storageId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("storageId"))).SetInternal(err)
}
update := &UpdateStorageRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(update); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch storage request").SetInternal(err)
}
storageUpdate := &store.UpdateStorage{
ID: storageID,
}
if update.Name != nil {
storageUpdate.Name = update.Name
}
if update.Config != nil {
if update.Type == StorageS3 {
configBytes, err := json.Marshal(update.Config.S3Config)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post storage request").SetInternal(err)
}
configString := string(configBytes)
storageUpdate.Config = &configString
}
}
storage, err := s.Store.UpdateStorage(ctx, storageUpdate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch storage").SetInternal(err)
}
storageMessage, err := ConvertStorageFromStore(storage)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to convert storage").SetInternal(err)
}
return c.JSON(http.StatusOK, storageMessage)
})
g.GET("/storage", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
// We should only show storage list to host user.
if user == nil || user.Role != store.RoleHost {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
list, err := s.Store.ListStorages(ctx, &store.FindStorage{})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find storage list").SetInternal(err)
}
storageList := []*Storage{}
for _, storage := range list {
storageMessage, err := ConvertStorageFromStore(storage)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to convert storage").SetInternal(err)
}
storageList = append(storageList, storageMessage)
}
return c.JSON(http.StatusOK, storageList)
})
g.DELETE("/storage/:storageId", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if user == nil || user.Role != store.RoleHost {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
storageID, err := strconv.Atoi(c.Param("storageId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("storageId"))).SetInternal(err)
}
systemSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{Name: SystemSettingStorageServiceIDName.String()})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find storage").SetInternal(err)
}
if systemSetting != nil {
storageServiceID := DatabaseStorage
err = json.Unmarshal([]byte(systemSetting.Value), &storageServiceID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal storage service id").SetInternal(err)
}
if storageServiceID == storageID {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Storage service %d is using", storageID))
}
}
if err = s.Store.DeleteStorage(ctx, &store.DeleteStorage{ID: storageID}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete storage").SetInternal(err)
}
return c.JSON(http.StatusOK, true)
})
}
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
}

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

@@ -0,0 +1,152 @@
package v1
import (
"encoding/json"
"net/http"
"github.com/labstack/echo/v4"
"github.com/usememos/memos/common/log"
"github.com/usememos/memos/server/profile"
"github.com/usememos/memos/store"
"go.uber.org/zap"
)
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 public memos.
DisablePublicMemos bool `json:"disablePublicMemos"`
// Max upload size.
MaxUploadSizeMiB int `json:"maxUploadSizeMiB"`
// 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 int `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", func(c echo.Context) error {
return c.JSON(http.StatusOK, s.Profile)
})
g.GET("/status", func(c echo.Context) error {
ctx := c.Request().Context()
systemStatus := SystemStatus{
Profile: *s.Profile,
DBSize: 0,
AllowSignUp: false,
DisablePublicMemos: false,
MaxUploadSizeMiB: 32,
AdditionalStyle: "",
AdditionalScript: "",
CustomizedProfile: CustomizedProfile{
Name: "memos",
LogoURL: "",
Description: "",
Locale: "en",
Appearance: "system",
ExternalURL: "",
},
StorageServiceID: DatabaseStorage,
LocalStoragePath: "assets/{timestamp}_{filename}",
MemoDisplayWithUpdatedTs: false,
}
hostUserType := store.RoleHost
hostUser, err := s.Store.GetUser(ctx, &store.FindUser{
Role: &hostUserType,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find host user").SetInternal(err)
}
if hostUser != nil {
systemStatus.Host = convertUserFromStore(hostUser)
// data desensitize
systemStatus.Host.OpenID = ""
systemStatus.Host.Email = ""
systemStatus.Host.AvatarURL = ""
}
systemSettingList, err := s.Store.ListSystemSettings(ctx, &store.FindSystemSetting{})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting list").SetInternal(err)
}
for _, systemSetting := range systemSettingList {
if systemSetting.Name == SystemSettingServerIDName.String() || systemSetting.Name == SystemSettingSecretSessionName.String() || systemSetting.Name == SystemSettingTelegramBotTokenName.String() {
continue
}
var baseValue any
err := json.Unmarshal([]byte(systemSetting.Value), &baseValue)
if err != nil {
log.Warn("Failed to unmarshal system setting value", zap.String("setting name", systemSetting.Name))
continue
}
switch systemSetting.Name {
case SystemSettingAllowSignUpName.String():
systemStatus.AllowSignUp = baseValue.(bool)
case SystemSettingDisablePublicMemosName.String():
systemStatus.DisablePublicMemos = baseValue.(bool)
case SystemSettingMaxUploadSizeMiBName.String():
systemStatus.MaxUploadSizeMiB = int(baseValue.(float64))
case SystemSettingAdditionalStyleName.String():
systemStatus.AdditionalStyle = baseValue.(string)
case SystemSettingAdditionalScriptName.String():
systemStatus.AdditionalScript = baseValue.(string)
case SystemSettingCustomizedProfileName.String():
customizedProfile := CustomizedProfile{}
if err := json.Unmarshal([]byte(systemSetting.Value), &customizedProfile); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting customized profile value").SetInternal(err)
}
systemStatus.CustomizedProfile = customizedProfile
case SystemSettingStorageServiceIDName.String():
systemStatus.StorageServiceID = int(baseValue.(float64))
case SystemSettingLocalStoragePathName.String():
systemStatus.LocalStoragePath = baseValue.(string)
case SystemSettingMemoDisplayWithUpdatedTsName.String():
systemStatus.MemoDisplayWithUpdatedTs = baseValue.(bool)
default:
log.Warn("Unknown system setting name", zap.String("setting name", systemSetting.Name))
}
}
return c.JSON(http.StatusOK, systemStatus)
})
g.POST("/system/vacuum", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if user == nil || user.Role != store.RoleHost {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
if err := s.Store.Vacuum(ctx); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to vacuum database").SetInternal(err)
}
return c.JSON(http.StatusOK, true)
})
}

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

@@ -0,0 +1,259 @@
package v1
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/labstack/echo/v4"
"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"
// 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"
// SystemSettingTelegramBotToken is the name of Telegram Bot Token.
SystemSettingTelegramBotTokenName SystemSettingName = "telegram-bot-token"
// SystemSettingMemoDisplayWithUpdatedTsName is the name of memo display with updated ts.
SystemSettingMemoDisplayWithUpdatedTsName SystemSettingName = "memo-display-with-updated-ts"
// SystemSettingOpenAIConfigName is the name of OpenAI config.
SystemSettingOpenAIConfigName SystemSettingName = "openai-config"
// SystemSettingAutoBackupIntervalName is the name of auto backup interval as seconds.
SystemSettingAutoBackupIntervalName SystemSettingName = "auto-backup-interval"
)
// 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 OpenAIConfig struct {
Key string `json:"key"`
Host string `json:"host"`
}
type UpsertSystemSettingRequest struct {
Name SystemSettingName `json:"name"`
Value string `json:"value"`
Description string `json:"description"`
}
const systemSettingUnmarshalError = `failed to unmarshal value from system setting "%v"`
func (upsert UpsertSystemSettingRequest) Validate() error {
switch settingName := upsert.Name; settingName {
case SystemSettingServerIDName:
return fmt.Errorf("updating %v is not allowed", settingName)
case SystemSettingAllowSignUpName:
var value bool
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return fmt.Errorf(systemSettingUnmarshalError, settingName)
}
case SystemSettingDisablePublicMemosName:
var value bool
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return fmt.Errorf(systemSettingUnmarshalError, settingName)
}
case SystemSettingMaxUploadSizeMiBName:
var value int
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return fmt.Errorf(systemSettingUnmarshalError, settingName)
}
case SystemSettingAdditionalStyleName:
var value string
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return fmt.Errorf(systemSettingUnmarshalError, settingName)
}
case SystemSettingAdditionalScriptName:
var value string
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return fmt.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 fmt.Errorf(systemSettingUnmarshalError, settingName)
}
case SystemSettingStorageServiceIDName:
// Note: 0 is the default value(database) for storage service ID.
value := 0
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return fmt.Errorf(systemSettingUnmarshalError, settingName)
}
return nil
case SystemSettingLocalStoragePathName:
value := ""
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return fmt.Errorf(systemSettingUnmarshalError, settingName)
}
case SystemSettingOpenAIConfigName:
value := OpenAIConfig{}
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return fmt.Errorf(systemSettingUnmarshalError, settingName)
}
case SystemSettingAutoBackupIntervalName:
var value string
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return fmt.Errorf(systemSettingUnmarshalError, settingName)
}
if value != "" {
v, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf(systemSettingUnmarshalError, settingName)
}
if v < 0 {
return fmt.Errorf("backup interval should > 0")
}
}
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 fmt.Errorf("token start with `http` must end with `/bot<token>`")
}
fragments := strings.Split(upsert.Value, ":")
if len(fragments) != 2 {
return fmt.Errorf(systemSettingUnmarshalError, settingName)
}
case SystemSettingMemoDisplayWithUpdatedTsName:
var value bool
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return fmt.Errorf(systemSettingUnmarshalError, settingName)
}
default:
return fmt.Errorf("invalid system setting name")
}
return nil
}
func (s *APIV1Service) registerSystemSettingRoutes(g *echo.Group) {
g.POST("/system/setting", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if user == nil || user.Role != store.RoleHost {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
systemSettingUpsert := &UpsertSystemSettingRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(systemSettingUpsert); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post system setting request").SetInternal(err)
}
if err := systemSettingUpsert.Validate(); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid system setting").SetInternal(err)
}
systemSetting, err := s.Store.UpsertSystemSetting(ctx, &store.SystemSetting{
Name: systemSettingUpsert.Name.String(),
Value: systemSettingUpsert.Value,
Description: systemSettingUpsert.Description,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert system setting").SetInternal(err)
}
return c.JSON(http.StatusOK, convertSystemSettingFromStore(systemSetting))
})
g.GET("/system/setting", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if user == nil || user.Role != store.RoleHost {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
list, err := s.Store.ListSystemSettings(ctx, &store.FindSystemSetting{})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting list").SetInternal(err)
}
systemSettingList := make([]*SystemSetting, 0, len(list))
for _, systemSetting := range list {
systemSettingList = append(systemSettingList, convertSystemSettingFromStore(systemSetting))
}
return c.JSON(http.StatusOK, systemSettingList)
})
}
func convertSystemSettingFromStore(systemSetting *store.SystemSetting) *SystemSetting {
return &SystemSetting{
Name: SystemSettingName(systemSetting.Name),
Value: systemSetting.Value,
Description: systemSetting.Description,
}
}

View File

@@ -1,4 +1,4 @@
package server
package v1
import (
"encoding/json"
@@ -7,15 +7,26 @@ import (
"regexp"
"sort"
"github.com/pkg/errors"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
"golang.org/x/exp/slices"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"github.com/usememos/memos/store"
"golang.org/x/exp/slices"
)
func (s *Server) registerTagRoutes(g *echo.Group) {
type Tag struct {
Name string
CreatorID int
}
type UpsertTagRequest struct {
Name string `json:"name"`
}
type DeleteTagRequest struct {
Name string `json:"name"`
}
func (s *APIV1Service) registerTagRoutes(g *echo.Group) {
g.POST("/tag", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
@@ -23,7 +34,7 @@ func (s *Server) registerTagRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
tagUpsert := &api.TagUpsert{}
tagUpsert := &UpsertTagRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(tagUpsert); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post tag request").SetInternal(err)
}
@@ -31,15 +42,18 @@ func (s *Server) registerTagRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, "Tag name shouldn't be empty")
}
tagUpsert.CreatorID = userID
tag, err := s.Store.UpsertTag(ctx, tagUpsert)
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)
}
if err := s.createTagCreateActivity(c, tag); err != nil {
tagMessage := convertTagFromStore(tag)
if err := s.createTagCreateActivity(c, tagMessage); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
}
return c.JSON(http.StatusOK, composeResponse(tag.Name))
return c.JSON(http.StatusOK, tagMessage.Name)
})
g.GET("/tag", func(c echo.Context) error {
@@ -49,19 +63,18 @@ func (s *Server) registerTagRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find tag")
}
tagFind := &api.TagFind{
list, err := s.Store.ListTags(ctx, &store.FindTag{
CreatorID: userID,
}
tagList, err := s.Store.FindTagList(ctx, tagFind)
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find tag list").SetInternal(err)
}
tagNameList := []string{}
for _, tag := range tagList {
for _, tag := range list {
tagNameList = append(tagNameList, tag.Name)
}
return c.JSON(http.StatusOK, composeResponse(tagNameList))
return c.JSON(http.StatusOK, tagNameList)
})
g.GET("/tag/suggestion", func(c echo.Context) error {
@@ -70,33 +83,31 @@ func (s *Server) registerTagRoutes(g *echo.Group) {
if !ok {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user session")
}
contentSearch := "#"
normalRowStatus := api.Normal
memoFind := api.MemoFind{
normalRowStatus := store.Normal
memoFind := &store.FindMemo{
CreatorID: &userID,
ContentSearch: &contentSearch,
ContentSearch: []string{"#"},
RowStatus: &normalRowStatus,
}
memoList, err := s.Store.FindMemoList(ctx, &memoFind)
memoMessageList, err := s.Store.ListMemos(ctx, memoFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
}
tagFind := &api.TagFind{
list, err := s.Store.ListTags(ctx, &store.FindTag{
CreatorID: userID,
}
existTagList, err := s.Store.FindTagList(ctx, tagFind)
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find tag list").SetInternal(err)
}
tagNameList := []string{}
for _, tag := range existTagList {
for _, tag := range list {
tagNameList = append(tagNameList, tag.Name)
}
tagMapSet := make(map[string]bool)
for _, memo := range memoList {
for _, memo := range memoMessageList {
for _, tag := range findTagListFromMemoContent(memo.Content) {
if !slices.Contains(tagNameList, tag) {
tagMapSet[tag] = true
@@ -108,7 +119,7 @@ func (s *Server) registerTagRoutes(g *echo.Group) {
tagList = append(tagList, tag)
}
sort.Strings(tagList)
return c.JSON(http.StatusOK, composeResponse(tagList))
return c.JSON(http.StatusOK, tagList)
})
g.POST("/tag/delete", func(c echo.Context) error {
@@ -118,7 +129,7 @@ func (s *Server) registerTagRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
tagDelete := &api.TagDelete{}
tagDelete := &DeleteTagRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(tagDelete); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post tag request").SetInternal(err)
}
@@ -126,18 +137,46 @@ func (s *Server) registerTagRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, "Tag name shouldn't be empty")
}
tagDelete.CreatorID = userID
if err := s.Store.DeleteTag(ctx, tagDelete); err != nil {
if common.ErrorCode(err) == common.NotFound {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Tag name not found: %s", tagDelete.Name))
}
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)
})
}
var tagRegexp = regexp.MustCompile(`#([^\s#]+)`)
func (s *APIV1Service) createTagCreateActivity(c echo.Context, tag *Tag) error {
ctx := c.Request().Context()
payload := ActivityTagCreatePayload{
TagName: tag.Name,
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return errors.Wrap(err, "failed to marshal activity payload")
}
activity, err := s.Store.CreateActivity(ctx, &store.Activity{
CreatorID: tag.CreatorID,
Type: ActivityTagCreate.String(),
Level: ActivityInfo.String(),
Payload: string(payloadBytes),
})
if err != nil || activity == nil {
return errors.Wrap(err, "failed to create activity")
}
return err
}
func convertTagFromStore(tag *store.Tag) *Tag {
return &Tag{
Name: tag.Name,
CreatorID: tag.CreatorID,
}
}
var tagRegexp = regexp.MustCompile(`#([^\s#,]+)`)
func findTagListFromMemoContent(memoContent string) []string {
tagMapSet := make(map[string]bool)
@@ -154,24 +193,3 @@ func findTagListFromMemoContent(memoContent string) []string {
sort.Strings(tagList)
return tagList
}
func (s *Server) createTagCreateActivity(c echo.Context, tag *api.Tag) error {
ctx := c.Request().Context()
payload := api.ActivityTagCreatePayload{
TagName: tag.Name,
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return errors.Wrap(err, "failed to marshal activity payload")
}
activity, err := s.Store.CreateActivity(ctx, &api.ActivityCreate{
CreatorID: tag.CreatorID,
Type: api.ActivityTagCreate,
Level: api.ActivityInfo,
Payload: string(payloadBytes),
})
if err != nil || activity == nil {
return errors.Wrap(err, "failed to create activity")
}
return err
}

View File

@@ -1,4 +1,4 @@
package server
package v1
import (
"testing"

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

@@ -0,0 +1,417 @@
package v1
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"github.com/usememos/memos/common/util"
"github.com/usememos/memos/store"
"golang.org/x/crypto/bcrypt"
)
// 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 int `json:"id"`
// Standard fields
RowStatus RowStatus `json:"rowStatus"`
CreatedTs int64 `json:"createdTs"`
UpdatedTs int64 `json:"updatedTs"`
// Domain specific fields
Username string `json:"username"`
Role Role `json:"role"`
Email string `json:"email"`
Nickname string `json:"nickname"`
PasswordHash string `json:"-"`
OpenID string `json:"openId"`
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"`
}
func (create CreateUserRequest) Validate() error {
if len(create.Username) < 3 {
return fmt.Errorf("username is too short, minimum length is 3")
}
if len(create.Username) > 32 {
return fmt.Errorf("username is too long, maximum length is 32")
}
if len(create.Password) < 3 {
return fmt.Errorf("password is too short, minimum length is 3")
}
if len(create.Password) > 512 {
return fmt.Errorf("password is too long, maximum length is 512")
}
if len(create.Nickname) > 64 {
return fmt.Errorf("nickname is too long, maximum length is 64")
}
if create.Email != "" {
if len(create.Email) > 256 {
return fmt.Errorf("email is too long, maximum length is 256")
}
if !util.ValidateEmail(create.Email) {
return fmt.Errorf("invalid email format")
}
}
return nil
}
type UpdateUserRequest struct {
RowStatus *RowStatus `json:"rowStatus"`
Username *string `json:"username"`
Email *string `json:"email"`
Nickname *string `json:"nickname"`
Password *string `json:"password"`
ResetOpenID *bool `json:"resetOpenId"`
AvatarURL *string `json:"avatarUrl"`
}
func (update UpdateUserRequest) Validate() error {
if update.Username != nil && len(*update.Username) < 3 {
return fmt.Errorf("username is too short, minimum length is 3")
}
if update.Username != nil && len(*update.Username) > 32 {
return fmt.Errorf("username is too long, maximum length is 32")
}
if update.Password != nil && len(*update.Password) < 3 {
return fmt.Errorf("password is too short, minimum length is 3")
}
if update.Password != nil && len(*update.Password) > 512 {
return fmt.Errorf("password is too long, maximum length is 512")
}
if update.Nickname != nil && len(*update.Nickname) > 64 {
return fmt.Errorf("nickname is too long, maximum length is 64")
}
if update.AvatarURL != nil {
if len(*update.AvatarURL) > 2<<20 {
return fmt.Errorf("avatar is too large, maximum is 2MB")
}
}
if update.Email != nil && *update.Email != "" {
if len(*update.Email) > 256 {
return fmt.Errorf("email is too long, maximum length is 256")
}
if !util.ValidateEmail(*update.Email) {
return fmt.Errorf("invalid email format")
}
}
return nil
}
func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
// POST /user - Create a new user.
g.POST("/user", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
}
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by id").SetInternal(err)
}
if currentUser == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
}
if currentUser.Role != store.RoleHost {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized to create user")
}
userCreate := &CreateUserRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(userCreate); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user request").SetInternal(err)
}
if err := userCreate.Validate(); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format").SetInternal(err)
}
// Disallow host user to be created.
if userCreate.Role == RoleHost {
return echo.NewHTTPError(http.StatusForbidden, "Could not create host user")
}
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),
OpenID: util.GenUUID(),
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
}
userMessage := convertUserFromStore(user)
if err := s.createUserCreateActivity(c, userMessage); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
}
return c.JSON(http.StatusOK, userMessage)
})
// GET /user - List all users.
g.GET("/user", func(c echo.Context) error {
ctx := c.Request().Context()
list, err := s.Store.ListUsers(ctx, &store.FindUser{})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch user list").SetInternal(err)
}
userMessageList := make([]*User, 0, len(list))
for _, user := range list {
userMessage := convertUserFromStore(user)
// data desensitize
userMessage.OpenID = ""
userMessage.Email = ""
userMessageList = append(userMessageList, userMessage)
}
return c.JSON(http.StatusOK, userMessageList)
})
// GET /user/me - Get current user.
g.GET("/user/me", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
}
user, err := s.Store.GetUser(ctx, &store.FindUser{ID: &userID})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if user == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
}
list, err := s.Store.ListUserSettings(ctx, &store.FindUserSetting{
UserID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find userSettingList").SetInternal(err)
}
userSettingList := []*UserSetting{}
for _, userSetting := range list {
userSettingList = append(userSettingList, convertUserSettingFromStore(userSetting))
}
userMessage := convertUserFromStore(user)
userMessage.UserSettingList = userSettingList
return c.JSON(http.StatusOK, userMessage)
})
// GET /user/:id - Get user by id.
g.GET("/user/:id", func(c echo.Context) error {
ctx := c.Request().Context()
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted user id").SetInternal(err)
}
user, err := s.Store.GetUser(ctx, &store.FindUser{ID: &id})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if user == nil {
return echo.NewHTTPError(http.StatusNotFound, "User not found")
}
userMessage := convertUserFromStore(user)
// data desensitize
userMessage.OpenID = ""
userMessage.Email = ""
return c.JSON(http.StatusOK, userMessage)
})
// PUT /user/:id - Update user by id.
g.PATCH("/user/:id", func(c echo.Context) error {
ctx := c.Request().Context()
userID, err := strconv.Atoi(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("id"))).SetInternal(err)
}
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{ID: &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 {
userUpdate.Username = request.Username
}
if request.Email != nil {
userUpdate.Email = request.Email
}
if request.Nickname != nil {
userUpdate.Nickname = request.Nickname
}
if request.Password != nil {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(*request.Password), bcrypt.DefaultCost)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
}
passwordHashStr := string(passwordHash)
userUpdate.PasswordHash = &passwordHashStr
}
if request.ResetOpenID != nil && *request.ResetOpenID {
openID := util.GenUUID()
userUpdate.OpenID = &openID
}
if request.AvatarURL != nil {
userUpdate.AvatarURL = request.AvatarURL
}
user, err := s.Store.UpdateUser(ctx, userUpdate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch user").SetInternal(err)
}
list, err := s.Store.ListUserSettings(ctx, &store.FindUserSetting{
UserID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find userSettingList").SetInternal(err)
}
userSettingList := []*UserSetting{}
for _, userSetting := range list {
userSettingList = append(userSettingList, convertUserSettingFromStore(userSetting))
}
userMessage := convertUserFromStore(user)
userMessage.UserSettingList = userSettingList
return c.JSON(http.StatusOK, userMessage)
})
// DELETE /user/:id - Delete user by id.
g.DELETE("/user/:id", func(c echo.Context) error {
ctx := c.Request().Context()
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &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 := strconv.Atoi(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("id"))).SetInternal(err)
}
userDelete := &store.DeleteUser{
ID: userID,
}
if err := s.Store.DeleteUser(ctx, userDelete); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete user").SetInternal(err)
}
return c.JSON(http.StatusOK, true)
})
}
func (s *APIV1Service) createUserCreateActivity(c echo.Context, user *User) error {
ctx := c.Request().Context()
payload := ActivityUserCreatePayload{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return errors.Wrap(err, "failed to marshal activity payload")
}
activity, err := s.Store.CreateActivity(ctx, &store.Activity{
CreatorID: user.ID,
Type: ActivityUserCreate.String(),
Level: ActivityInfo.String(),
Payload: string(payloadBytes),
})
if err != nil || activity == nil {
return errors.Wrap(err, "failed to create activity")
}
return err
}
func convertUserFromStore(user *store.User) *User {
return &User{
ID: user.ID,
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,
OpenID: user.OpenID,
AvatarURL: user.AvatarURL,
}
}

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

@@ -0,0 +1,157 @@
package v1
import (
"encoding/json"
"fmt"
"net/http"
"github.com/labstack/echo/v4"
"github.com/usememos/memos/store"
"golang.org/x/exp/slices"
)
type UserSettingKey string
const (
// UserSettingLocaleKey is the key type for user locale.
UserSettingLocaleKey UserSettingKey = "locale"
// UserSettingAppearanceKey is the key type for user appearance.
UserSettingAppearanceKey UserSettingKey = "appearance"
// UserSettingMemoVisibilityKey is the key type for user preference memo default visibility.
UserSettingMemoVisibilityKey UserSettingKey = "memo-visibility"
// UserSettingTelegramUserID is the key type for telegram UserID of memos user.
UserSettingTelegramUserIDKey UserSettingKey = "telegram-user-id"
)
// String returns the string format of UserSettingKey type.
func (key UserSettingKey) String() string {
switch key {
case UserSettingLocaleKey:
return "locale"
case UserSettingAppearanceKey:
return "appearance"
case UserSettingMemoVisibilityKey:
return "memo-visibility"
case UserSettingTelegramUserIDKey:
return "telegram-user-id"
}
return ""
}
var (
UserSettingLocaleValue = []string{
"de",
"en",
"es",
"fr",
"hr",
"it",
"ja",
"ko",
"nl",
"pl",
"pt-BR",
"ru",
"sl",
"sv",
"tr",
"uk",
"vi",
"zh-Hans",
"zh-Hant",
}
UserSettingAppearanceValue = []string{"system", "light", "dark"}
UserSettingMemoVisibilityValue = []Visibility{Private, Protected, Public}
)
type UserSetting struct {
UserID int `json:"userId"`
Key UserSettingKey `json:"key"`
Value string `json:"value"`
}
type UpsertUserSettingRequest struct {
UserID int `json:"-"`
Key UserSettingKey `json:"key"`
Value string `json:"value"`
}
func (upsert UpsertUserSettingRequest) Validate() error {
if upsert.Key == UserSettingLocaleKey {
localeValue := "en"
err := json.Unmarshal([]byte(upsert.Value), &localeValue)
if err != nil {
return fmt.Errorf("failed to unmarshal user setting locale value")
}
if !slices.Contains(UserSettingLocaleValue, localeValue) {
return fmt.Errorf("invalid user setting locale value")
}
} else if upsert.Key == UserSettingAppearanceKey {
appearanceValue := "system"
err := json.Unmarshal([]byte(upsert.Value), &appearanceValue)
if err != nil {
return fmt.Errorf("failed to unmarshal user setting appearance value")
}
if !slices.Contains(UserSettingAppearanceValue, appearanceValue) {
return fmt.Errorf("invalid user setting appearance value")
}
} else if upsert.Key == UserSettingMemoVisibilityKey {
memoVisibilityValue := Private
err := json.Unmarshal([]byte(upsert.Value), &memoVisibilityValue)
if err != nil {
return fmt.Errorf("failed to unmarshal user setting memo visibility value")
}
if !slices.Contains(UserSettingMemoVisibilityValue, memoVisibilityValue) {
return fmt.Errorf("invalid user setting memo visibility value")
}
} else if upsert.Key == UserSettingTelegramUserIDKey {
var key string
err := json.Unmarshal([]byte(upsert.Value), &key)
if err != nil {
return fmt.Errorf("invalid user setting telegram user id value")
}
} else {
return fmt.Errorf("invalid user setting key")
}
return nil
}
func (s *APIV1Service) registerUserSettingRoutes(g *echo.Group) {
g.POST("/user/setting", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
}
userSettingUpsert := &UpsertUserSettingRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(userSettingUpsert); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user setting upsert request").SetInternal(err)
}
if err := userSettingUpsert.Validate(); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user setting format").SetInternal(err)
}
userSettingUpsert.UserID = userID
userSetting, err := s.Store.UpsertUserSetting(ctx, &store.UserSetting{
UserID: userID,
Key: userSettingUpsert.Key.String(),
Value: userSettingUpsert.Value,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert user setting").SetInternal(err)
}
userSettingMessage := convertUserSettingFromStore(userSetting)
return c.JSON(http.StatusOK, userSettingMessage)
})
}
func convertUserSettingFromStore(userSetting *store.UserSetting) *UserSetting {
return &UserSetting{
UserID: userSetting.UserID,
Key: UserSettingKey(userSetting.Key),
Value: userSetting.Value,
}
}

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

@@ -0,0 +1,55 @@
package v1
import (
"github.com/labstack/echo/v4"
"github.com/usememos/memos/server/profile"
"github.com/usememos/memos/store"
)
type APIV1Service struct {
Secret string
Profile *profile.Profile
Store *store.Store
}
func NewAPIV1Service(secret string, profile *profile.Profile, store *store.Store) *APIV1Service {
return &APIV1Service{
Secret: secret,
Profile: profile,
Store: store,
}
}
func (s *APIV1Service) Register(rootGroup *echo.Group) {
// Register RSS routes.
s.registerRSSRoutes(rootGroup)
// Register API v1 routes.
apiV1Group := rootGroup.Group("/api/v1")
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.registerShortcutRoutes(apiV1Group)
s.registerStorageRoutes(apiV1Group)
s.registerResourceRoutes(apiV1Group)
s.registerMemoRoutes(apiV1Group)
s.registerMemoOrganizerRoutes(apiV1Group)
s.registerMemoResourceRoutes(apiV1Group)
s.registerMemoRelationRoutes(apiV1Group)
s.registerOpenAIRoutes(apiV1Group)
// Register public routes.
publicGroup := rootGroup.Group("/o")
publicGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return JWTMiddleware(s, next, s.Secret)
})
s.registerGetterPublicRoutes(publicGroup)
s.registerResourcePublicRoutes(publicGroup)
}

View File

@@ -7,12 +7,16 @@ import (
"os"
"os/signal"
"syscall"
"time"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/usememos/memos/server"
_profile "github.com/usememos/memos/server/profile"
"github.com/usememos/memos/setup"
"github.com/usememos/memos/store"
"github.com/usememos/memos/store/db"
)
const (
@@ -37,7 +41,15 @@ var (
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())
s, err := server.NewServer(ctx, profile)
db := db.NewDB(profile)
if err := db.Open(ctx); err != nil {
cancel()
fmt.Printf("failed to open db, error: %+v\n", err)
return
}
store := store.New(db.DBInstance, profile)
s, err := server.NewServer(ctx, profile, store)
if err != nil {
cancel()
fmt.Printf("failed to create server, error: %+v\n", err)
@@ -69,6 +81,39 @@ var (
<-ctx.Done()
},
}
setupCmd = &cobra.Command{
Use: "setup",
Short: "Make initial setup for memos",
Run: func(cmd *cobra.Command, _ []string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
hostUsername, err := cmd.Flags().GetString(setupCmdFlagHostUsername)
if err != nil {
fmt.Printf("failed to get owner username, error: %+v\n", err)
return
}
hostPassword, err := cmd.Flags().GetString(setupCmdFlagHostPassword)
if err != nil {
fmt.Printf("failed to get owner password, error: %+v\n", err)
return
}
db := db.NewDB(profile)
if err := db.Open(ctx); err != nil {
fmt.Printf("failed to open db, error: %+v\n", err)
return
}
store := store.New(db.DBInstance, profile)
if err := setup.Execute(ctx, store, hostUsername, hostPassword); err != nil {
fmt.Printf("failed to setup, error: %+v\n", err)
return
}
},
}
)
func Execute() error {
@@ -98,6 +143,11 @@ func init() {
viper.SetDefault("mode", "demo")
viper.SetDefault("port", 8081)
viper.SetEnvPrefix("memos")
setupCmd.Flags().String(setupCmdFlagHostUsername, "", "Owner username")
setupCmd.Flags().String(setupCmdFlagHostPassword, "", "Owner password")
rootCmd.AddCommand(setupCmd)
}
func initConfig() {
@@ -117,3 +167,8 @@ func initConfig() {
println("version:", profile.Version)
println("---")
}
const (
setupCmdFlagHostUsername = "host-username"
setupCmdFlagHostPassword = "host-password"
)

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,4 +1,4 @@
package common
package util
import (
"crypto/rand"

View File

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

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

@@ -0,0 +1,29 @@
# 1.Prepare your workspace by:
# docker compose run api go install github.com/cosmtrek/air@latest
# docker compose run web npm install
#
# 2. Start you work by:
# docker compose up -d
#
# 3. Check logs by:
# docker compose logs -f
#
services:
api:
image: golang:1.19.3-alpine3.16
working_dir: /work
command: air -c ./scripts/.air.toml
volumes:
- $HOME/go/pkg/:/go/pkg/ # Cache for go mod shared with the host
- ./.air/bin/:/go/bin/ # Cache for binary used only in container, such as *air*
- .:/work/
web:
image: node:18.12.1-alpine3.16
working_dir: /work
depends_on: ["api"]
ports: ["3001:3001"]
environment: ["DEV_PROXY_SERVER=http://api:8081/"]
command: npm run dev
volumes:
- ./web:/work
- ./.air/node_modules/:/work/node_modules/ # Cache for Node Modules

107
docs/api/auth.md Normal file
View File

@@ -0,0 +1,107 @@
# Authentication APIs
## Sign In
```
POST /api/v1/auth/signin
```
**Request Body**
```json
{
"username": "john",
"password": "password123"
}
```
**Response**
```json
{
"id": 123,
"username": "john",
"nickname": "John"
// other user fields
}
```
**Status Codes**
- 200: Sign in success
- 400: Invalid request
- 401: Incorrect credentials
- 403: User banned
- 500: Internal server error
## SSO Sign In
```
POST /api/v1/auth/signin/sso
```
**Request Body**
```json
{
"identityProviderId": 123,
"code": "abc123",
"redirectUri": "https://example.com/callback"
}
```
**Response**
Same as **Sign In**
**Status Codes**
- 200: Success
- 400: Invalid request
- 401: Authentication failed
- 403: User banned
- 404: Identity provider not found
- 500: Internal server error
## Sign Up
```
POST /api/v1/auth/signup
```
**Request Body**
```json
{
"username": "mary",
"password": "password456"
}
```
**Response**
Same as **Sign In**
**Status Codes**
- 200: Sign up success
- 400: Invalid request
- 401: Sign up disabled
- 500: Internal server error
## Sign Out
```
POST /api/v1/auth/signout
```
**Response**
```
true
```
**Status Codes**
- 200: Success
- 500: Internal server error

44
docs/api/how-to.md Normal file
View File

@@ -0,0 +1,44 @@
# Guide to Access Memos API with OpenID
Memos API supports using OpenID as the user identifier to access the API.
## What is OpenID
OpenID is a unique identifier assigned by Memos system to each user.
When a user registers or logs in via third-party OAuth through Memos system, the OpenID will be generated automatically.
## How to Get User's OpenID
You can get a user's OpenID through:
- User checks the personal profile page in Memos system
- Calling Memos API to get user details
- Retrieving from login API response after successful login
Example:
```
// GET /api/v1/user/me
{
"id": 123,
"username": "john",
"openId": "8613E04B4FA6603883F05A5E0A5E2517",
...
}
```
## How to Use OpenID to Access API
You can access the API on behalf of the user by appending `?openId=xxx` parameter to the API URL.
For example:
```
curl 'https://demo.usememos.com/api/v1/memo?openId=8613E04B4FA6603883F05A5E0A5E2517' -H 'Content-Type: application/json' --data-raw '{"content":"Hello world!"}'
```
The above request will create a Memo under the user with OpenID `8613E04B4FA6603883F05A5E0A5E2517`.
OpenID can be used in any API that requires user identity.

67
docs/api/memo-relation.md Normal file
View File

@@ -0,0 +1,67 @@
# Memo Relation APIs
## Create Memo Relation
```
POST /api/v1/memo/:memoId/relation
```
**Request Body**
```json
{
"relatedMemoId": 456,
"type": "REFERENCE"
}
```
**Response**
```json
{
"memoId": 123,
"relatedMemoId": 456,
"type": "REFERENCE"
}
```
**Status Codes**
- 200: OK
- 400: Invalid request
- 500: Internal server error
## Get Memo Relations
```
GET /api/v1/memo/:memoId/relation
```
**Response**
```json
[
{
"memoId": 123,
"relatedMemoId": 456,
"type": "REFERENCE"
}
]
```
**Status Codes**
- 200: OK
- 500: Internal server error
## Delete Memo Relation
```
DELETE /api/v1/memo/:memoId/relation/:relatedMemoId/type/:relationType
```
**Status Codes**
- 200: Deleted
- 400: Invalid request
- 500: Internal server error

65
docs/api/memo-resource.md Normal file
View File

@@ -0,0 +1,65 @@
# Memo Resource APIs
## Bind Resource to Memo
```
POST /api/v1/memo/:memoId/resource
```
**Request Body**
```json
{
"resourceId": 123
}
```
**Response**
```
true
```
**Status Codes**
- 200: OK
- 400: Invalid request
- 401: Unauthorized
- 404: Memo/Resource not found
- 500: Internal server error
## Get Memo Resources
```
GET /api/v1/memo/:memoId/resource
```
**Response**
```json
[
{
"id": 123,
"filename": "example.png"
// other resource fields
}
]
```
**Status Codes**
- 200: OK
- 500: Internal server error
## Unbind Resource from Memo
```
DELETE /api/v1/memo/:memoId/resource/:resourceId
```
**Status Codes**
- 200: OK
- 401: Unauthorized
- 404: Memo/Resource not found
- 500: Internal server error

135
docs/api/memo.md Normal file
View File

@@ -0,0 +1,135 @@
# Memo APIs
## Create Memo
```
POST /api/v1/memo
```
**Request Body**
```json
{
"content": "Memo content",
"visibility": "PUBLIC",
"resourceIdList": [123, 456],
"relationList": [{ "relatedMemoId": 789, "type": "REFERENCE" }]
}
```
**Response**
```json
{
"id": 1234,
"content": "Memo content",
"visibility": "PUBLIC"
// other fields
}
```
**Status Codes**
- 200: Created
- 400: Invalid request
- 401: Unauthorized
- 403: Forbidden to create public memo
- 500: Internal server error
## Get Memo List
```
GET /api/v1/memo
```
**Parameters**
- `creatorId` (optional): Filter by creator ID
- `visibility` (optional): Filter visibility, `PUBLIC`, `PROTECTED` or `PRIVATE`
- `pinned` (optional): Filter pinned memo, `true` or `false`
- `tag` (optional): Filter memo with tag
- `content` (optional): Search in content
- `limit` (optional): Limit number of results
- `offset` (optional): Offset of first result
**Response**
```json
[
{
"id": 1234,
"content": "Memo 1"
// other fields
},
{
"id": 5678,
"content": "Memo 2"
// other fields
}
]
```
## Get Memo By ID
```
GET /api/v1/memo/:memoId
```
**Response**
```json
{
"id": 1234,
"content": "Memo content"
// other fields
}
```
**Status Codes**
- 200: Success
- 403: Forbidden for private memo
- 404: Not found
- 500: Internal server error
## Update Memo
```
PATCH /api/v1/memo/:memoId
```
**Request Body**
```json
{
"content": "Updated content",
"visibility": "PRIVATE"
}
```
**Response**
Same as **Get Memo By ID**
**Status Codes**
- 200: Updated
- 400: Invalid request
- 401: Unauthorized
- 403: Forbidden
- 404: Not found
- 500: Internal server error
## Delete Memo
```
DELETE /api/v1/memo/:memoId
```
**Status Codes**
- 200: Deleted
- 401: Unauthorized
- 403: Forbidden
- 404: Not found
- 500: Internal server error

130
docs/api/resource.md Normal file
View File

@@ -0,0 +1,130 @@
# Resource APIs
## Upload Resource
### Upload File
```
POST /api/v1/resource/blob
```
**Request Form**
- `file`: Upload file
**Response**
```json
{
"id": 123,
"filename": "example.png"
// other fields
}
```
**Status Codes**
- 200: OK
- 400: Invalid request
- 401: Unauthorized
- 413: File too large
- 500: Internal server error
### Create Resource
```
POST /api/v1/resource
```
**Request Body**
```json
{
"filename": "example.png",
"externalLink": "https://example.com/image.png"
}
```
**Response**
Same as **Upload File**
**Status Codes**
- 200: OK
- 400: Invalid request
- 401: Unauthorized
- 500: Internal server error
## Get Resource List
```
GET /api/v1/resource
```
**Parameters**
- `limit` (optional): Limit number of results
- `offset` (optional): Offset of first result
**Response**
```json
[
{
"id": 123,
"filename": "example.png"
// other fields
},
{
"id": 456,
"filename": "doc.pdf"
// other fields
}
]
```
**Status Codes**
- 200: OK
- 401: Unauthorized
- 500: Internal server error
## Update Resource
```
PATCH /api/v1/resource/:resourceId
```
**Request Body**
```json
{
"filename": "new_name.png"
}
```
**Response**
Same as **Get Resource List**
**Status Codes**
- 200: OK
- 400: Invalid request
- 401: Unauthorized
- 404: Not found
- 500: Internal server error
## Delete Resource
```
DELETE /api/v1/resource/:resourceId
```
**Status Codes**
- 200: Deleted
- 401: Unauthorized
- 404: Not found
- 500: Internal server error

84
docs/api/tag.md Normal file
View File

@@ -0,0 +1,84 @@
# Tag APIs
## Create Tag
```
POST /api/v1/tag
```
**Request Body**
```json
{
"name": "python"
}
```
**Response**
```
"python"
```
**Status Codes**
- 200: Created
- 400: Invalid request
- 500: Internal server error
## Get Tag List
```
GET /api/v1/tag
```
**Response**
```json
["python", "golang", "javascript"]
```
**Status Codes**
- 200: OK
- 401: Unauthorized
- 500: Internal server error
## Suggest Tags
```
GET /api/v1/tag/suggestion
```
**Response**
```json
["django", "flask", "numpy"]
```
**Status Codes**
- 200: OK
- 401: Unauthorized
- 500: Internal server error
## Delete Tag
```
POST /api/v1/tag/delete
```
**Request Body**
```json
{
"name": "outdated_tag"
}
```
**Status Codes**
- 200: Deleted
- 400: Invalid request
- 401: Unauthorized
- 500: Internal server error

164
docs/api/user.md Normal file
View File

@@ -0,0 +1,164 @@
# User APIs
## Create User
```
POST /api/v1/user
```
**Request Body**
```json
{
"username": "john",
"role": "USER",
"email": "john@example.com",
"nickname": "John",
"password": "password123"
}
```
**Response**
```json
{
"id": 123,
"username": "john",
"role": "USER",
"email": "john@example.com",
"nickname": "John",
"avatarUrl": "",
"createdTs": 1596647800,
"updatedTs": 1596647800
}
```
**Status Codes**
- 200: Success
- 400: Validation error
- 401: Unauthorized
- 403: Forbidden to create host user
- 500: Internal server error
## Get User List
```
GET /api/v1/user
```
**Response**
```json
[
{
"id": 123,
"username": "john",
"role": "USER"
// other fields
},
{
"id": 456,
"username": "mary",
"role": "ADMIN"
// other fields
}
]
```
**Status Codes**
- 200: Success
- 500: Internal server error
## Get User By ID
```
GET /api/v1/user/:id
```
**Response**
```json
{
"id": 123,
"username": "john",
"role": "USER"
// other fields
}
```
**Status Codes**
- 200: Success
- 404: Not found
- 500: Internal server error
## Update User
```
PATCH /api/v1/user/:id
```
**Request Body**
```json
{
"username": "johnny",
"email": "johnny@example.com",
"nickname": "Johnny",
"avatarUrl": "https://avatars.example.com/u=123"
}
```
**Response**
```json
{
"id": 123,
"username": "johnny",
"role": "USER",
"email": "johnny@example.com",
"nickname": "Johnny",
"avatarUrl": "https://avatars.example.com/u=123",
"createdTs": 1596647800,
"updatedTs": 1596647900
}
```
**Status Codes**
- 200: Success
- 400: Validation error
- 403: Forbidden
- 404: Not found
- 500: Internal server error
## Delete User
```
DELETE /api/v1/user/:id
```
**Status Codes**
- 200: Success
- 403: Forbidden
- 404: Not found
- 500: Internal server error
## Get Current User
```
GET /api/v1/user/me
```
**Response**
Same as **Get User By ID**
**Status Codes**
- 200: Success
- 401: Unauthorized
- 500: Internal server error

View File

@@ -4,11 +4,15 @@
2. Navigate to the System Tab
3. In the "Additional Styles" box add these lines of code:
```css
.memo-list-container {background-color: #INSERT COLOR HERE;}
.page-container {background-color: #INSERT COLOR HERE;}
```
```css
.memo-list-container {
background-color: #INSERT COLOR HERE;
}
.page-container {
background-color: #INSERT COLOR HERE;
}
```
It is recommended that you choose the same color for both options
It is recommended that you choose the same color for both options
4. Refresh the page and the background color of your memos app will successfully update to reflect your changes

View File

@@ -2,7 +2,7 @@
written by [AJ](https://memos.ajstephens.website/) (also a noob)
<img height="64px" src="https://raw.githubusercontent.com/usememos/memos/main/resources/logo-full.webp" alt="✍️ memos" />
<img height="64px" src="https://usememos.com/logo-full.png" alt="✍️ memos" />
[Live Demo](https://demo.usememos.com) • [Official Website](https://usememos.com) • [Source Code](https://github.com/usememos/memos)
@@ -17,7 +17,7 @@ Someone who wants...
- to use memos
- to support the memos project
- a cost effective and simple way to host it on the cloud with reliablity and persistance
- a cost effective and simple way to host it on the cloud with reliability and persistance
- to share memos with friends
## Requirements

View File

@@ -0,0 +1,90 @@
# Development
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,16 +6,12 @@ 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
@@ -34,7 +30,7 @@ Memos is built with a curated tech stack. It is optimized for developer experien
3. start frontend dev server
```bash
cd web && yarn && yarn dev
cd web && pnpm i && 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.

6
docs/setup.md Normal file
View File

@@ -0,0 +1,6 @@
# Setup
After deploying and running Memos in `prod` mode, you should create "host" user. There are two ways to do this:
1. Navigate to the Memos application URL, such as `http://localhost:5230`, and follow the prompts to create a username and password for the "host" user.
2. Use the command `memos setup --host-username=$USERNAME --host-password=$PASSWORD --mode=prod` to set up the host user. This method may be more convenient for deploying through Ansible or other provisioning softwares.

11
docs/updates.md Normal file
View File

@@ -0,0 +1,11 @@
# Updating memos after deploying
## fly.io
### update to latest
Under the directory where you had your `fly.toml` file
```
flyctl deploy
```

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.

34
go.mod
View File

@@ -3,27 +3,46 @@ module github.com/usememos/memos
go 1.19
require (
github.com/CorrectRoadH/echo-sse v0.1.4
github.com/PullRequestInc/go-gpt3 v1.1.15
github.com/aws/aws-sdk-go-v2 v1.17.4
github.com/aws/aws-sdk-go-v2/config v1.18.12
github.com/aws/aws-sdk-go-v2/credentials v1.13.12
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.51
github.com/aws/aws-sdk-go-v2/service/s3 v1.30.3
github.com/disintegration/imaging v1.6.2
github.com/google/uuid v1.3.0
github.com/gorilla/feeds v1.1.1
github.com/gorilla/sessions v1.2.1
github.com/labstack/echo-contrib v0.13.0
github.com/labstack/echo/v4 v4.9.0
github.com/mattn/go-sqlite3 v1.14.9
github.com/pkg/errors v0.9.1
github.com/spf13/cobra v1.6.1
github.com/spf13/viper v1.15.0
github.com/stretchr/testify v1.8.1
github.com/yuin/goldmark v1.5.4
go.uber.org/zap v1.24.0
golang.org/x/crypto v0.1.0
golang.org/x/exp v0.0.0-20230111222715-75897c7a292a
golang.org/x/mod v0.6.0
golang.org/x/mod v0.8.0
golang.org/x/net v0.7.0
golang.org/x/oauth2 v0.5.0
modernc.org/sqlite v1.24.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/image v0.7.0 // indirect
golang.org/x/tools v0.6.0 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.40.0 // indirect
modernc.org/ccgo/v3 v3.16.13 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/token v1.0.1 // indirect
)
require (
@@ -44,9 +63,8 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang-jwt/jwt/v4 v4.5.0
github.com/golang/protobuf v1.5.2 // indirect
github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.0.1 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
@@ -54,7 +72,7 @@ require (
github.com/labstack/gommon v0.3.1 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.0.6 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
@@ -68,7 +86,7 @@ require (
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.8.0 // indirect
golang.org/x/sys v0.5.0 // indirect
golang.org/x/text v0.7.0 // indirect
golang.org/x/text v0.9.0 // indirect
golang.org/x/time v0.1.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.28.1 // indirect

101
go.sum
View File

@@ -38,6 +38,10 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/CorrectRoadH/echo-sse v0.1.4 h1:/g9vxJJasMTLFyeUT2q/TpGCgRvJuU9zx7laqPWppnY=
github.com/CorrectRoadH/echo-sse v0.1.4/go.mod h1:DRfO0yNv0gJLBFRysKKP7zfDmKfMuknakXBsTOVZUBI=
github.com/PullRequestInc/go-gpt3 v1.1.15 h1:pidXZbpqZVW0bp8NBNKDb+/++6PFdYfht9vw2CVpaUs=
github.com/PullRequestInc/go-gpt3 v1.1.15/go.mod h1:F9yzAy070LhkqHS2154/IH0HVj5xq5g83gLTj7xzyfw=
github.com/aws/aws-sdk-go-v2 v1.17.4 h1:wyC6p9Yfq6V2y98wfDsj6OnNQa4w2BLGCLIxzNhwOGY=
github.com/aws/aws-sdk-go-v2 v1.17.4/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 h1:dK82zF6kkPeCo8J1e+tGx4JdvDIQzj7ygIoLg8WMuGs=
@@ -92,6 +96,10 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -99,6 +107,7 @@ github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5y
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
@@ -106,6 +115,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -161,6 +172,7 @@ github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hf
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
@@ -168,18 +180,13 @@ github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY=
github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
@@ -188,8 +195,12 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/joefitzgerald/rainbow-reporter v0.1.0/go.mod h1:481CNgqmVHQZzdIbN52CupLJyoVwB10FQ/IQlF1pdL8=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -200,8 +211,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo-contrib v0.13.0 h1:bzSG0SpuZZd7BmJLvsWtPfU23W0Enh3K0tok3aENVKA=
github.com/labstack/echo-contrib v0.13.0/go.mod h1:IF9+MJu22ADOZEHD+bAV67XMIO3vNXUy7Naz/ABPHEs=
github.com/labstack/echo/v4 v4.9.0 h1:wPOF1CE6gvt/kmbMR4dGzWvHMPT+sAEUJOwOTtvITVY=
github.com/labstack/echo/v4 v4.9.0/go.mod h1:xkCDAdFCIf8jsFQ5NnbK7oqaF/yU1A1X20Ltm0OvSks=
github.com/labstack/gommon v0.3.1 h1:OomWaJXm7xR6L1HmEtGyQf26TEn7V6X88mktX9kee9o=
@@ -211,12 +220,16 @@ github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3v
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA=
github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/maxbrunsfeld/counterfeiter/v6 v6.2.3/go.mod h1:1ftk08SazyElaaNvmqAfZWGwJzshjCfBXDLoQtPAMNk=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=
github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU=
github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
@@ -226,10 +239,15 @@ github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qR
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sclevine/spec v1.2.0/go.mod h1:W4J29eT/Kzv7/b9IWLB055Z+qvVC9vt0Arko24q7p+U=
github.com/sclevine/spec v1.4.0/go.mod h1:LvpgJaFyvQzRvc1kaDs0bulYwzC70PbiYjC4QnFHkOM=
github.com/spf13/afero v1.9.3 h1:41FoI0fD7OR7mGcKE/aOiLkGreyf8ifIOQmJANWogMk=
github.com/spf13/afero v1.9.3/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y=
github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w=
@@ -249,6 +267,7 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@@ -264,6 +283,9 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU=
github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@@ -284,6 +306,7 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU=
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
@@ -301,6 +324,9 @@ golang.org/x/exp v0.0.0-20230111222715-75897c7a292a h1:/YWeLOBWYV5WAQORVPkZF3Pq9
golang.org/x/exp v0.0.0-20230111222715-75897c7a292a/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.7.0 h1:gzS29xtG1J5ybQlv0PuyfE3nmc6R4qB73m6LUUmvFuw=
golang.org/x/image v0.7.0/go.mod h1:nd/q4ef1AKKYl/4kft7g+6UyGbdiqWqTP1ZAbRoV7Rg=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -322,10 +348,12 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0 h1:b9gGHsz9/HhJ3HF5DHQytPpuwocVTChQJK3AvoLRD5I=
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -355,6 +383,8 @@ golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@@ -378,7 +408,11 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -386,6 +420,7 @@ golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -415,18 +450,25 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -462,6 +504,7 @@ golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapK
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200301222351-066e0c02454c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
@@ -479,6 +522,9 @@ golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -581,9 +627,12 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -597,6 +646,30 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.24.0 h1:EsClRIWHGhLTCX44p+Ri/JLD+vFGo0QGjasg2/F9TlI=
modernc.org/sqlite v1.24.0/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

View File

@@ -1,7 +1,7 @@
package main
import (
_ "github.com/mattn/go-sqlite3"
_ "modernc.org/sqlite"
"github.com/usememos/memos/cmd"
)

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,88 @@
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,62 @@
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,36 @@
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,
}
}

View File

@@ -0,0 +1,95 @@
package parser
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
)
func TestHeadingParser(t *testing.T) {
tests := []struct {
text string
heading *HeadingParser
}{
{
text: "*Hello world",
heading: nil,
},
{
text: "## Hello World",
heading: &HeadingParser{
Level: 2,
ContentTokens: []*tokenizer.Token{
{
Type: tokenizer.Text,
Value: "Hello",
},
{
Type: tokenizer.Space,
Value: " ",
},
{
Type: tokenizer.Text,
Value: "World",
},
},
},
},
{
text: "# # Hello World",
heading: &HeadingParser{
Level: 1,
ContentTokens: []*tokenizer.Token{
{
Type: tokenizer.Hash,
Value: "#",
},
{
Type: tokenizer.Space,
Value: " ",
},
{
Type: tokenizer.Text,
Value: "Hello",
},
{
Type: tokenizer.Space,
Value: " ",
},
{
Type: tokenizer.Text,
Value: "World",
},
},
},
},
{
text: " # 123123 Hello World",
heading: nil,
},
{
text: `# 123
Hello World`,
heading: &HeadingParser{
Level: 1,
ContentTokens: []*tokenizer.Token{
{
Type: tokenizer.Text,
Value: "123",
},
{
Type: tokenizer.Space,
Value: " ",
},
},
},
},
}
for _, test := range tests {
tokens := tokenizer.Tokenize(test.text)
heading := NewHeadingParser()
require.Equal(t, test.heading, heading.Match(tokens))
}
}

View File

@@ -0,0 +1,55 @@
package parser
import "github.com/usememos/memos/plugin/gomark/parser/tokenizer"
type ImageParser struct {
AltText string
URL string
}
func NewImageParser() *ImageParser {
return &ImageParser{}
}
func (*ImageParser) Match(tokens []*tokenizer.Token) *ImageParser {
if len(tokens) < 5 {
return nil
}
if tokens[0].Type != tokenizer.ExclamationMark {
return nil
}
if tokens[1].Type != tokenizer.LeftSquareBracket {
return nil
}
cursor, altText := 2, ""
for ; cursor < len(tokens)-2; cursor++ {
if tokens[cursor].Type == tokenizer.Newline {
return nil
}
if tokens[cursor].Type == tokenizer.RightSquareBracket {
break
}
altText += tokens[cursor].Value
}
if tokens[cursor+1].Type != tokenizer.LeftParenthesis {
return nil
}
matched, url := false, ""
for _, token := range tokens[cursor+2:] {
if token.Type == tokenizer.Newline || token.Type == tokenizer.Space {
return nil
}
if token.Type == tokenizer.RightParenthesis {
matched = true
break
}
url += token.Value
}
if !matched || url == "" {
return nil
}
return &ImageParser{
AltText: altText,
URL: url,
}
}

View File

@@ -0,0 +1,42 @@
package parser
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
)
func TestImageParser(t *testing.T) {
tests := []struct {
text string
image *ImageParser
}{
{
text: "![](https://example.com)",
image: &ImageParser{
AltText: "",
URL: "https://example.com",
},
},
{
text: "! [](https://example.com)",
image: nil,
},
{
text: "![alte]( htt ps :/ /example.com)",
image: nil,
},
{
text: "![al te](https://example.com)",
image: &ImageParser{
AltText: "al te",
URL: "https://example.com",
},
},
}
for _, test := range tests {
tokens := tokenizer.Tokenize(test.text)
require.Equal(t, test.image, NewImageParser().Match(tokens))
}
}

View File

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

View File

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

View File

@@ -0,0 +1,58 @@
package parser
import "github.com/usememos/memos/plugin/gomark/parser/tokenizer"
type LinkParser struct {
ContentTokens []*tokenizer.Token
URL string
}
func NewLinkParser() *LinkParser {
return &LinkParser{}
}
func (*LinkParser) Match(tokens []*tokenizer.Token) *LinkParser {
if len(tokens) < 4 {
return nil
}
if tokens[0].Type != tokenizer.LeftSquareBracket {
return nil
}
cursor, contentTokens := 1, []*tokenizer.Token{}
for ; cursor < len(tokens)-2; cursor++ {
if tokens[cursor].Type == tokenizer.Newline {
return nil
}
if tokens[cursor].Type == tokenizer.RightSquareBracket {
break
}
contentTokens = append(contentTokens, tokens[cursor])
}
if tokens[cursor+1].Type != tokenizer.LeftParenthesis {
return nil
}
matched, url := false, ""
for _, token := range tokens[cursor+2:] {
if token.Type == tokenizer.Newline || token.Type == tokenizer.Space {
return nil
}
if token.Type == tokenizer.RightParenthesis {
matched = true
break
}
url += token.Value
}
if !matched || url == "" {
return nil
}
if len(contentTokens) == 0 {
contentTokens = append(contentTokens, &tokenizer.Token{
Type: tokenizer.Text,
Value: url,
})
}
return &LinkParser{
ContentTokens: contentTokens,
URL: url,
}
}

View File

@@ -0,0 +1,60 @@
package parser
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
)
func TestLinkParser(t *testing.T) {
tests := []struct {
text string
link *LinkParser
}{
{
text: "[](https://example.com)",
link: &LinkParser{
ContentTokens: []*tokenizer.Token{
{
Type: tokenizer.Text,
Value: "https://example.com",
},
},
URL: "https://example.com",
},
},
{
text: "! [](https://example.com)",
link: nil,
},
{
text: "[alte]( htt ps :/ /example.com)",
link: nil,
},
{
text: "[hello world](https://example.com)",
link: &LinkParser{
ContentTokens: []*tokenizer.Token{
{
Type: tokenizer.Text,
Value: "hello",
},
{
Type: tokenizer.Space,
Value: " ",
},
{
Type: tokenizer.Text,
Value: "world",
},
},
URL: "https://example.com",
},
},
}
for _, test := range tests {
tokens := tokenizer.Tokenize(test.text)
require.Equal(t, test.link, NewLinkParser().Match(tokens))
}
}

View File

@@ -0,0 +1,30 @@
package parser
import "github.com/usememos/memos/plugin/gomark/parser/tokenizer"
type ParagraphParser struct {
ContentTokens []*tokenizer.Token
}
func NewParagraphParser() *ParagraphParser {
return &ParagraphParser{}
}
func (*ParagraphParser) Match(tokens []*tokenizer.Token) *ParagraphParser {
contentTokens := []*tokenizer.Token{}
cursor := 0
for ; cursor < len(tokens); cursor++ {
token := tokens[cursor]
if token.Type == tokenizer.Newline {
break
}
contentTokens = append(contentTokens, token)
}
if len(contentTokens) == 0 {
return nil
}
return &ParagraphParser{
ContentTokens: contentTokens,
}
}

View File

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

View File

@@ -0,0 +1 @@
package parser

View File

@@ -0,0 +1,34 @@
package parser
import "github.com/usememos/memos/plugin/gomark/parser/tokenizer"
type TagParser struct {
ContentTokens []*tokenizer.Token
}
func NewTagParser() *TagParser {
return &TagParser{}
}
func (*TagParser) Match(tokens []*tokenizer.Token) *TagParser {
if len(tokens) < 2 {
return nil
}
if tokens[0].Type != tokenizer.Hash {
return nil
}
contentTokens := []*tokenizer.Token{}
for _, token := range tokens[1:] {
if token.Type == tokenizer.Newline || token.Type == tokenizer.Space || token.Type == tokenizer.Hash {
break
}
contentTokens = append(contentTokens, token)
}
if len(contentTokens) == 0 {
return nil
}
return &TagParser{
ContentTokens: contentTokens,
}
}

View File

@@ -0,0 +1,51 @@
package parser
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
)
func TestTagParser(t *testing.T) {
tests := []struct {
text string
tag *TagParser
}{
{
text: "*Hello world",
tag: nil,
},
{
text: "# Hello World",
tag: nil,
},
{
text: "#tag",
tag: &TagParser{
ContentTokens: []*tokenizer.Token{
{
Type: tokenizer.Text,
Value: "tag",
},
},
},
},
{
text: "#tag/subtag",
tag: &TagParser{
ContentTokens: []*tokenizer.Token{
{
Type: tokenizer.Text,
Value: "tag/subtag",
},
},
},
},
}
for _, test := range tests {
tokens := tokenizer.Tokenize(test.text)
require.Equal(t, test.tag, NewTagParser().Match(tokens))
}
}

View File

@@ -0,0 +1,74 @@
package tokenizer
type TokenType = string
const (
Underline TokenType = "_"
Star TokenType = "*"
Hash TokenType = "#"
Backtick TokenType = "`"
LeftSquareBracket TokenType = "["
RightSquareBracket TokenType = "]"
LeftParenthesis TokenType = "("
RightParenthesis TokenType = ")"
ExclamationMark TokenType = "!"
Newline TokenType = "\n"
Space TokenType = " "
)
const (
Text TokenType = ""
)
type Token struct {
Type TokenType
Value string
}
func NewToken(tp, text string) *Token {
return &Token{
Type: tp,
Value: text,
}
}
func Tokenize(text string) []*Token {
tokens := []*Token{}
for _, c := range text {
switch c {
case '_':
tokens = append(tokens, NewToken(Underline, "_"))
case '*':
tokens = append(tokens, NewToken(Star, "*"))
case '#':
tokens = append(tokens, NewToken(Hash, "#"))
case '`':
tokens = append(tokens, NewToken(Backtick, "`"))
case '[':
tokens = append(tokens, NewToken(LeftSquareBracket, "["))
case ']':
tokens = append(tokens, NewToken(RightSquareBracket, "]"))
case '(':
tokens = append(tokens, NewToken(LeftParenthesis, "("))
case ')':
tokens = append(tokens, NewToken(RightParenthesis, ")"))
case '!':
tokens = append(tokens, NewToken(ExclamationMark, "!"))
case '\n':
tokens = append(tokens, NewToken(Newline, "\n"))
case ' ':
tokens = append(tokens, NewToken(Space, " "))
default:
var lastToken *Token
if len(tokens) > 0 {
lastToken = tokens[len(tokens)-1]
}
if lastToken == nil || lastToken.Type != Text {
tokens = append(tokens, NewToken(Text, string(c)))
} else {
lastToken.Value += string(c)
}
}
}
return tokens
}

View File

@@ -0,0 +1,79 @@
package tokenizer
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestTokenize(t *testing.T) {
tests := []struct {
text string
tokens []*Token
}{
{
text: "*Hello world!",
tokens: []*Token{
{
Type: Star,
Value: "*",
},
{
Type: Text,
Value: "Hello",
},
{
Type: Space,
Value: " ",
},
{
Type: Text,
Value: "world",
},
{
Type: ExclamationMark,
Value: "!",
},
},
},
{
text: `# hello
world`,
tokens: []*Token{
{
Type: Hash,
Value: "#",
},
{
Type: Space,
Value: " ",
},
{
Type: Text,
Value: "hello",
},
{
Type: Space,
Value: " ",
},
{
Type: Newline,
Value: "\n",
},
{
Type: Space,
Value: " ",
},
{
Type: Text,
Value: "world",
},
},
},
}
for _, test := range tests {
result := Tokenize(test.text)
require.Equal(t, test.tokens, result)
}
}

View File

@@ -0,0 +1 @@
package renderer

View File

@@ -0,0 +1,19 @@
package getter
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestGetHTMLMeta(t *testing.T) {
tests := []struct {
urlStr string
htmlMeta HTMLMeta
}{}
for _, test := range tests {
metadata, err := GetHTMLMeta(test.urlStr)
require.NoError(t, err)
require.Equal(t, test.htmlMeta, *metadata)
}
}

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