Compare commits

...

164 Commits

Author SHA1 Message Date
boojack ef7381f032 chore: upgrade version to 0.12.1 (#1499) 2023-04-09 11:51:43 +08:00
boojack df30304d00 chore: update share memo buttons (#1498) 2023-04-09 11:38:30 +08:00
boojack 91a24ef9ce chore: update memo header (#1497)
* chore: update memo header

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

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

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

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

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

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

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

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

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

* Update LocaleSelect.tsx

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

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

* feat: add reducer to add resource

* support fetch all resource when first search

* beautify the fetch ui

* restore file

* feat: add all resource before clear resource

* eslint

* i18n

* chore:change the nane

* chore: change the name of param

* eslint

* feat: setIsComplete to true when first loading resource fully

* fix the bug of fetch

* feat change finally to then

* feat: add await and async to clear and search

* feat: return all resource when fetch

* chore: change variable name

* Update web/src/pages/ResourcesDashboard.tsx

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

* fix missing const value

---------

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

* fix: eslint issues

* change the position of deps

---------

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

* feat: add URLSuffix resource option with S3

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

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

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

* Update web/src/pages/ResourcesDashboard.tsx

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

---------

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

* feat: add style button

* feat: add style of list

* feat: add checkbox for list

* feat: support file upload by drag

* feat: beautify the ui

* feat: support file upload

* stash

* fix: the resource is incorrectly when upload multiple files

* feat: beautify the ui

* chore: reduce unused line

* stash

* chore: deleted unused line

* chore: deleted unused line

* chore

* chore: change the function declare

* feat: support to prompt file is too large

* feat:drop prompt to cover all element

* fix: eslint

* fix: the name of i18n

* chore: refactor the import deps

* feat: beautify the ui

* feat: support the style of button

* feat: beautify the switch ui

* chore: refactor the component

* chore: refactor the resource item dropdown

* feat: use memo to reduce unused computing in drop

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

* chore:change name

* Update web/src/locales/en.json

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

* chore: the import of  deps

* fix: the window size of fecting data

* feat: support to save the state of style

* remove pnpm-lock

* merge main

* chore: simpify the statement

* fix: delete conflict marker

* feat: add i18n for select

* feat:support dark mode

* eslint

* feat: add more file icon

* feat: delete the storage of resource style

* Update web/src/components/ResourceCover.tsx

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

---------

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

* feat: add style button

* feat: add style of list

* feat: add checkbox for list

* feat: support file upload by drag

* feat: beautify the ui

* feat: support file upload

* stash

* fix: the resource is incorrectly when upload multiple files

* feat: beautify the ui

* chore: reduce unused line

* stash

* chore: deleted unused line

* chore: deleted unused line

* chore

* chore: change the function declare

* feat: support to prompt file is too large

* feat:drop prompt to cover all element

* fix: eslint

* fix: the name of i18n

* chore: refactor the import deps

* feat: beautify the ui

* feat: support the style of button

* feat: beautify the switch ui

* chore: refactor the component

* chore: refactor the resource item dropdown

* feat: use memo to reduce unused computing in drop

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

* chore:change name

* Update web/src/locales/en.json

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

* chore: the import of  deps

* fix: the window size of fecting data

* feat: support to save the state of style

* remove pnpm-lock

* merge main

* chore: simpify the statement

* fix: delete conflict marker

* feat: add i18n for select

* feat:support dark mode

* eslint

* feat: delete the storage of resource style

---------

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

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

---------

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

* feat: support file upload by drag

* feat: beautify the ui

* feat: support file upload

* stash

* fix: the resource is incorrectly when upload multiple files

* feat: beautify the ui

* chore: reduce unused line

* stash

* chore: deleted unused line

* chore: deleted unused line

* chore

* chore: change the function declare

* feat: support to prompt file is too large

* feat:drop prompt to cover all element

* fix: eslint

* fix: the name of i18n

* chore: refactor the import deps

* feat: beautify the ui

* Update web/src/locales/en.json

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

* chore: the import of  deps

* fix: the window size of fecting data

---------

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

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

* update

* update

* update

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

* chore: update args

* chore: update

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

* fix go-static-check

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

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

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

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

* feat: support select resouce file

* feat: suppor delete select files

* feat: support share menu, implement rename and delete

* chore: change the color of hover

* chore: refator file dashboard to page

* feat: add i18n for button

* feat: beautify the button

* fix: the error position of button

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

* feat: beautify file dashboard

* chore: factor the filecard code

* feat: using dropdown component intead of component

* feat: add i18n for delete selected resource button

* feat: delete the unused style of title

* chore: refactor file cover

* feat: support more type file cover

* feat: use memo to reduce unused computing in filecover

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

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

* chore: delete unused code

* feat: refactor the file card

* chore: delete unused style file

* chore: change file to resource

* chore: delete unused import

* chore: fix the typo

* fix: the error of handle check click

* fix: the error of handle of uncheck

* chore: change the name of selectList to selectedList

* chore: change the name of selectList to selectedList

* chore: change the name of selectList to selectedList

* chore: delete unused import

* feat: support Responsive Design

* feat: min display two card in a line

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

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

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

* chore: refactor resource cover css to reduce code

* chore: delete unnessnary change

* chore: change the type of callback function

* chore: delete unused css code

* feat: add zh-hant i18n

* feat: change the position of buttons

* feat: add title for the icon button

* feat: add opacity for icon

* feat: refactor searchbar

* feat:move Debounce to search

* feat: new resource search bar

* feat: reduce the size of cover

* support file search

* Update web/src/pages/ResourcesDashboard.tsx

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

* Update web/src/components/ResourceCard.tsx

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

* chore: reduce css code

* feat: support lowcase and uppercase search

* chore: reserve the searchbar

* feat: refator resource Search bar

* chore: change the param name

* feat: resource bar support dark mode

* feat: beautify the UI of dashboard

* chore: extract positionClassName from actionsClassName

* feat: reduce the length of search bar

---------

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

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

* fix:expand btn display issue

* restore Dockerfile

* change Header z-index to 2

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

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

---------

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

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

* sort imports

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

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

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

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

---------

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

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

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

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

* feat: support set openai api host

* fix css

* fix eslint

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

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

* fix go-static-checks

* add tips on the frontend

* fix eslint check

* remove yarn.lock

* remove Config.Path

* update tips

* fix

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

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

* chroe: rename languageCodeCovert to convertLanguageCodeToLocale

---------

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

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

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

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

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

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

* fix typo and switch positions with Path and Bucket

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

* updated file for trLocale

* Updated for Turkish Locale

* Update i18n.ts

* Update i18n.ts

* Update package.json

* Update package.json

* Update i18n.d.ts

* Update user_setting.go

* Update package.json

* Update web/src/components/LocaleSelect.tsx

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

* Update package.json

* Update LocaleSelect.tsx

* Update LocaleSelect.tsx

* Update i18n.ts

* Update i18n.ts

---------

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

* fix bug

* changes

* Revert "changes"

This reverts commit 2b2084c7bd.

* should close the toast if its error also

* no internal errors + sso

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

* Update doc/CustomThemes.md

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

* Update doc/CustomThemes.md

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

* Update doc/CustomThemes.md

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

* Update CustomThemes.md

* Update CustomThemes.md

---------

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

* Remove change in Home.tsx

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

Currently translated at 19.5% (41 of 210 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 33.3% (70 of 210 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (210 of 210 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (210 of 210 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (210 of 210 strings)

Translated using Weblate (Turkish)

Currently translated at 1.4% (3 of 210 strings)

Translated using Weblate (Turkish)

Currently translated at 0.4% (1 of 210 strings)

Translated using Weblate (Turkish)

Currently translated at 0.0% (0 of 0 strings)

Added translation using Weblate (Turkish)

Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (210 of 210 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 91.4% (192 of 210 strings)









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

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

Currently translated at 25.7% (54 of 210 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 0.0% (0 of 0 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (210 of 210 strings)

Added translation using Weblate (Portuguese (Brazil))





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

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

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

* remove the validator on frontend

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

Currently translated at 99.0% (208 of 210 strings)


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

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

“handleAddFilterBenClick” -> "handleAddFilterBtnClick"

[#1147 ](https://github.com/usememos/memos/issues/1147)
2023-02-24 18:09:31 +08:00
266 changed files with 10117 additions and 7657 deletions
+2 -1
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:
+2 -14
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
+1 -1
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
+10 -1
View File
@@ -2,9 +2,13 @@
FROM node:18.12.1-alpine3.16 AS frontend
WORKDIR /frontend-build
COPY ./web/package.json ./web/yarn.lock ./
RUN yarn
COPY ./web/ .
RUN yarn && yarn build
RUN yarn build
# Build backend exec file.
FROM golang:1.19.3-alpine3.16 AS backend
@@ -21,8 +25,13 @@ RUN go build -o memos ./main.go
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
+34 -65
View File
@@ -1,94 +1,63 @@
<p align="center"><a href="https://usememos.com"><img height="64px" src="https://raw.githubusercontent.com/usememos/memos/main/resources/logo-full.webp" alt="✍️ memos" /></a></p>
# memos
<p align="center">
<img height="72px" src="https://usememos.com/logo.webp" alt="✍️ memos" align="right" />
A lightweight, self-hosted memo hub. Open Source and Free forever.
<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>
<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>
<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)
![demo](./resources/demo.webp#gh-light-mode-only)
## Key points
![demo-dark](./resources/demo-dark.webp#gh-dark-mode-only)
## Features
- 🦄 Open source and free forever
- 🚀 Support for self-hosting with `Docker` in seconds
- 📜 Plain textarea first and support some useful Markdown syntax
- 👥 Set memo private or public to others
- 🧑‍💻 RESTful API for self-service
- 📋 Embed memos on other sites using iframe
- #⃣ Hashtags for organizing memos
- 📆 Interactive calendar view
- 💾 Easy data migration and backups
- Open source and free forever
- Self-hosting with Docker in seconds
- Markdown support
- Customizable and sharable
- RESTful API for self-service
## Deploy with Docker in seconds
### Docker Run
```docker
```bash
docker run -d --name memos -p 5230:5230 -v ~/.memos/:/var/opt/memos neosmemo/memos:latest
```
> `~/.memos/` will be used as the data directory in your machine and `/var/opt/memos` is the directory of the volume in Docker and should not be modified.
> The `~/.memos/` directory will be used as the data directory on your local machine, while `/var/opt/memos` is the directory of the volume in Docker and should not be modified.
### Docker Compose
Learn more about [other installation methods](https://usememos.com/docs/install).
- Provided docker compose YAML file: [`docker-compose.yaml`](./docker-compose.yaml).
## Contribution
- You can upgrade to the latest version memos with:
```sh
docker-compose down && docker image rm neosmemo/memos:latest && docker-compose up -d
```
### Other installation methods
- [Deploy on render.com](./docs/deploy-with-render.md)
- [Deploy on fly.io](https://github.com/hu3rror/memos-on-fly)
## Contribute
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated. 🥰
Learn more about contributing in [development guide](./docs/development.md).
### 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
- [Rabithua/memos_wmp](https://github.com/Rabithua/memos_wmp) - WeChat MiniProgram
- [qazxcdswe123/telegramMemoBot](https://github.com/qazxcdswe123/telegramMemoBot) - Telegram bot
- [eallion/memos.top](https://github.com/eallion/memos.top) - A static page rendered with the Memos API
- [eindex/logseq-memos-sync](https://github.com/EINDEX/logseq-memos-sync) - A Logseq plugin
- [JakeLaoyu/memos-import-from-flomo](https://github.com/JakeLaoyu/memos-import-from-flomo) - Import data. Support from flomo, wechat reading.
- [Send to memos](https://sharecuts.cn/shortcut/12640) - A shortcut for iOS.
- [Memos Raycast Extension](https://www.raycast.com/JakeYu/memos) - Raycast extension, [source code](https://github.com/JakeLaoyu/memos-raycast).
### User stories
- [Memos - A Twitter Like Notes App You can Self Host](https://noted.lol/memos/)
### Join the community to build memos together!
Contributions are what make the open-source community such an amazing place to learn, inspire, and create. We greatly appreciate any contributions you make. Thank you for being a part of our community! 🥰
<a href="https://github.com/usememos/memos/graphs/contributors">
<img src="https://contrib.rocks/image?repo=usememos/memos" />
</a>
---
- [Moe Memos](https://memos.moe/) - Third party client for iOS and Android
- [lmm214/memos-bber](https://github.com/lmm214/memos-bber) - Chrome extension
- [Rabithua/memos_wmp](https://github.com/Rabithua/memos_wmp) - WeChat MiniProgram
- [qazxcdswe123/telegramMemoBot](https://github.com/qazxcdswe123/telegramMemoBot) - Telegram bot
- [eallion/memos.top](https://github.com/eallion/memos.top) - Static page rendered with the Memos API
- [eindex/logseq-memos-sync](https://github.com/EINDEX/logseq-memos-sync) - Logseq plugin
- [JakeLaoyu/memos-import-from-flomo](https://github.com/JakeLaoyu/memos-import-from-flomo) - Import data. Support from flomo, wechat reading
- [Send to memos](https://sharecuts.cn/shortcut/12640) - A shortcut for iOS
- [Memos Raycast Extension](https://www.raycast.com/JakeYu/memos) - Raycast extension
- [Memos Desktop](https://github.com/xudaolong/memos-desktop) - Third party client for MacOS and Windows
## Acknowledgements
- Thanks [Uffizzi](https://www.uffizzi.com/) for sponsoring preview environments for PRs.
## License
[MIT License](https://github.com/usememos/memos/blob/main/LICENSE)
## Star history
[![Star History Chart](https://api.star-history.com/svg?repos=usememos/memos&type=Date)](https://star-history.com/#usememos/memos&Date)
+1 -1
View File
@@ -42,7 +42,7 @@ type Memo struct {
Pinned bool `json:"pinned"`
// Related fields
Creator *User `json:"creator"`
CreatorName string `json:"creatorName"`
ResourceList []*Resource `json:"resourceList"`
}
+12 -1
View File
@@ -11,9 +11,11 @@ type Resource struct {
// Domain specific fields
Filename string `json:"filename"`
Blob []byte `json:"-"`
InternalPath string `json:"internalPath"`
ExternalLink string `json:"externalLink"`
Type string `json:"type"`
Size int64 `json:"size"`
PublicID string `json:"publicId"`
// Related fields
LinkedMemoAmount int `json:"linkedMemoAmount"`
@@ -26,9 +28,11 @@ type ResourceCreate struct {
// Domain specific fields
Filename string `json:"filename"`
Blob []byte `json:"-"`
InternalPath string `json:"internalPath"`
ExternalLink string `json:"externalLink"`
Type string `json:"type"`
Size int64 `json:"-"`
PublicID string `json:"publicId"`
}
type ResourceFind struct {
@@ -40,7 +44,12 @@ type ResourceFind struct {
// Domain specific fields
Filename *string `json:"filename"`
MemoID *int
PublicID *string `json:"publicId"`
GetBlob bool
// Pagination
Limit *int
Offset *int
}
type ResourcePatch struct {
@@ -50,7 +59,9 @@ type ResourcePatch struct {
UpdatedTs *int64
// Domain specific fields
Filename *string `json:"filename"`
Filename *string `json:"filename"`
ResetPublicID *bool `json:"resetPublicId"`
PublicID *string `json:"-"`
}
type ResourceDelete struct {
+9
View File
@@ -1,5 +1,12 @@
package api
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 (
@@ -12,11 +19,13 @@ type StorageConfig struct {
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 {
+6 -1
View File
@@ -10,6 +10,8 @@ type SystemStatus struct {
// System settings
// Allow sign up.
AllowSignUp bool `json:"allowSignUp"`
// Ignore upgrade
IgnoreUpgrade bool `json:"ignoreUpgrade"`
// Disable public memos.
DisablePublicMemos bool `json:"disablePublicMemos"`
// Additional style.
@@ -18,5 +20,8 @@ type SystemStatus struct {
AdditionalScript string `json:"additionalScript"`
// Customized server profile, including server name and external url.
CustomizedProfile CustomizedProfile `json:"customizedProfile"`
StorageServiceID int `json:"storageServiceId"`
// Storage service ID.
StorageServiceID int `json:"storageServiceId"`
// Local storage path
LocalStoragePath string `json:"localStoragePath"`
}
+64 -57
View File
@@ -11,22 +11,28 @@ import (
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"
// SystemSettingsDisablePublicMemos 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"
// 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"
// SystemSettingIgnoreUpgradeName is the name of ignore upgrade.
SystemSettingIgnoreUpgradeName SystemSettingName = "ignore-upgrade"
// SystemSettingDisablePublicMemosName is the name of disable public memos setting.
SystemSettingDisablePublicMemosName SystemSettingName = "disable-public-memos"
// 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"
// SystemSettingOpenAIConfigName is the name of OpenAI config.
SystemSettingOpenAIConfigName SystemSettingName = "openai-config"
)
// CustomizedProfile is the struct definition for SystemSettingCustomizedProfileName system setting item.
@@ -45,38 +51,44 @@ type CustomizedProfile struct {
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 SystemSettingServerIDName:
return "server-id"
case SystemSettingSecretSessionName:
return "secretSessionName"
return "secret-session"
case SystemSettingAllowSignUpName:
return "allowSignUp"
return "allow-signup"
case SystemSettingIgnoreUpgradeName:
return "ignore-upgrade"
case SystemSettingDisablePublicMemosName:
return "disablePublicMemos"
return "disable-public-memos"
case SystemSettingAdditionalStyleName:
return "additionalStyle"
return "additional-style"
case SystemSettingAdditionalScriptName:
return "additionalScript"
return "additional-script"
case SystemSettingCustomizedProfileName:
return "customizedProfile"
return "customized-profile"
case SystemSettingStorageServiceIDName:
return "storageServiceId"
return "storage-service-id"
case SystemSettingLocalStoragePathName:
return "local-storage-path"
case SystemSettingOpenAIConfigName:
return "openai-config"
}
return ""
}
var (
SystemSettingAllowSignUpValue = []bool{true, false}
SystemSettingDisbalePublicMemosValue = []bool{true, false}
)
type SystemSetting struct {
Name SystemSettingName
Name SystemSettingName `json:"name"`
// Value is a JSON string with basic value.
Value string
Description string
Value string `json:"value"`
Description string `json:"description"`
}
type SystemSettingUpsert struct {
@@ -86,7 +98,7 @@ type SystemSettingUpsert struct {
}
func (upsert SystemSettingUpsert) Validate() error {
if upsert.Name == SystemSettingServerID {
if upsert.Name == SystemSettingServerIDName {
return errors.New("update server id is not allowed")
} else if upsert.Name == SystemSettingAllowSignUpName {
value := false
@@ -94,16 +106,11 @@ func (upsert SystemSettingUpsert) Validate() error {
if err != nil {
return fmt.Errorf("failed to unmarshal system setting allow signup value")
}
invalid := true
for _, v := range SystemSettingAllowSignUpValue {
if value == v {
invalid = false
break
}
}
if invalid {
return fmt.Errorf("invalid system setting allow signup value")
} else if upsert.Name == SystemSettingIgnoreUpgradeName {
value := false
err := json.Unmarshal([]byte(upsert.Value), &value)
if err != nil {
return fmt.Errorf("failed to unmarshal system setting ignore upgrade value")
}
} else if upsert.Name == SystemSettingDisablePublicMemosName {
value := false
@@ -111,18 +118,6 @@ func (upsert SystemSettingUpsert) Validate() error {
if err != nil {
return fmt.Errorf("failed to unmarshal system setting disable public memos value")
}
invalid := true
for _, v := range SystemSettingDisbalePublicMemosValue {
if value == v {
invalid = false
break
}
}
if invalid {
return fmt.Errorf("invalid system setting disable public memos value")
}
} else if upsert.Name == SystemSettingAdditionalStyleName {
value := ""
err := json.Unmarshal([]byte(upsert.Value), &value)
@@ -155,12 +150,24 @@ func (upsert SystemSettingUpsert) Validate() error {
return fmt.Errorf("invalid appearance value")
}
} else if upsert.Name == SystemSettingStorageServiceIDName {
value := 0
value := DatabaseStorage
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 == SystemSettingLocalStoragePathName {
value := ""
err := json.Unmarshal([]byte(upsert.Value), &value)
if err != nil {
return fmt.Errorf("failed to unmarshal system setting local storage path value")
}
} 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")
}
+12
View File
@@ -67,6 +67,12 @@ func (create UserCreate) Validate() error {
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")
}
@@ -107,6 +113,12 @@ func (patch UserPatch) Validate() error {
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")
}
+4 -4
View File
@@ -15,7 +15,7 @@ const (
// 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"
UserSettingMemoVisibilityKey UserSettingKey = "memo-visibility"
)
// String returns the string format of UserSettingKey type.
@@ -26,13 +26,13 @@ func (key UserSettingKey) String() string {
case UserSettingAppearanceKey:
return "appearance"
case UserSettingMemoVisibilityKey:
return "memoVisibility"
return "memo-visibility"
}
return ""
}
var (
UserSettingLocaleValue = []string{"en", "zh", "vi", "fr", "nl", "sv", "de", "es", "uk", "ru", "it", "hant", "ko"}
UserSettingLocaleValue = []string{"en", "zh", "vi", "fr", "nl", "sv", "de", "es", "uk", "ru", "it", "hant", "tr", "ko", "sl"}
UserSettingAppearanceValue = []string{"system", "light", "dark"}
UserSettingMemoVisibilityValue = []Visibility{Private, Protected, Public}
)
@@ -88,7 +88,7 @@ func (upsert UserSettingUpsert) Validate() error {
type UserSettingFind struct {
UserID int
Key *UserSettingKey `json:"key"`
Key UserSettingKey `json:"key"`
}
type UserSettingDelete struct {

Before

Width:  |  Height:  |  Size: 374 KiB

After

Width:  |  Height:  |  Size: 374 KiB

+47
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 (
@@ -69,6 +73,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 +135,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 +159,8 @@ func initConfig() {
println("version:", profile.Version)
println("---")
}
const (
setupCmdFlagHostUsername = "host-username"
setupCmdFlagHostPassword = "host-password"
)
+18
View File
@@ -0,0 +1,18 @@
# Adding A Custom Theme
1. Open the Settings Dialog
2. Navigate to the System Tab
3. In the "Additional Styles" box add these lines of code:
```css
.memo-list-container {
background-color: #INSERT COLOR HERE;
}
.page-container {
background-color: #INSERT COLOR HERE;
}
```
It is recommended that you choose the same color for both options
4. Refresh the page and the background color of your memos app will successfully update to reflect your changes
+1 -1
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)
+1 -1
View File
@@ -8,7 +8,7 @@ Memos is built with a curated tech stack. It is optimized for developer experien
## Tech Stack
![tech-stack](https://raw.githubusercontent.com/usememos/memos/main/resources/tech-stack.png)
![tech-stack](https://raw.githubusercontent.com/usememos/memos/main/assets/tech-stack.png)
## Prerequisites
+6
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.
+20 -35
View File
@@ -2,21 +2,26 @@ module github.com/usememos/memos
go 1.19
require github.com/mattn/go-sqlite3 v1.14.9
require github.com/google/uuid v1.3.0
require (
golang.org/x/crypto v0.1.0
golang.org/x/net v0.6.0
)
require github.com/labstack/echo/v4 v4.9.0
require (
github.com/aws/aws-sdk-go-v2 v1.17.4
github.com/aws/aws-sdk-go-v2/config v1.18.12
github.com/aws/aws-sdk-go-v2/credentials v1.13.12
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.51
github.com/aws/aws-sdk-go-v2/service/s3 v1.30.3
github.com/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
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/net v0.7.0
golang.org/x/oauth2 v0.5.0
)
require (
@@ -34,13 +39,11 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.18.3 // indirect
github.com/aws/smithy-go v1.13.5 // indirect
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect
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
@@ -52,15 +55,14 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.0.6 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/segmentio/backo-go v1.0.1 // indirect
github.com/spf13/afero v1.9.3 // indirect
github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/subosito/gotenv v1.4.2 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.1 // indirect
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.8.0 // indirect
golang.org/x/sys v0.5.0 // indirect
@@ -72,20 +74,3 @@ require (
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
require (
github.com/aws/aws-sdk-go-v2 v1.17.4
github.com/aws/aws-sdk-go-v2/config v1.18.12
github.com/aws/aws-sdk-go-v2/credentials v1.13.12
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.51
github.com/aws/aws-sdk-go-v2/service/s3 v1.30.3
github.com/pkg/errors v0.9.1
github.com/segmentio/analytics-go v3.1.0+incompatible
github.com/spf13/cobra v1.6.1
github.com/spf13/viper v1.15.0
github.com/stretchr/testify v1.8.1
go.uber.org/zap v1.24.0
golang.org/x/exp v0.0.0-20230111222715-75897c7a292a
golang.org/x/mod v0.6.0
golang.org/x/oauth2 v0.5.0
)
+5 -18
View File
@@ -79,8 +79,6 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.18.3/go.mod h1:b+psTJn33Q4qGoDaM7ZiO
github.com/aws/smithy-go v1.13.5 h1:hgz0X/DX0dGqTYpGALqXJoRKRj5oQ7150i5FdTePzO8=
github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
@@ -108,6 +106,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=
@@ -170,14 +170,8 @@ 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=
@@ -202,8 +196,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=
@@ -232,10 +224,6 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR
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/segmentio/analytics-go v3.1.0+incompatible h1:IyiOfUgQFVHvsykKKbdI7ZsH374uv3/DfZUo9+G0Z80=
github.com/segmentio/analytics-go v3.1.0+incompatible/go.mod h1:C7CYBtQWk4vRk2RyLu0qOcbHJ18E3F1HV2C/8JvKN48=
github.com/segmentio/backo-go v1.0.1 h1:68RQccglxZeyURy93ASB/2kc9QudzgIDexJ927N++y4=
github.com/segmentio/backo-go v1.0.1/go.mod h1:9/Rh6yILuLysoQnZ2oNooD2g7aBnvM7r/fNVxRNWfBc=
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=
@@ -250,6 +238,7 @@ github.com/spf13/viper v1.15.0 h1:js3yy885G8xwJa6iOISGFwd+qlUo5AvyXb7CiihdtiU=
github.com/spf13/viper v1.15.0/go.mod h1:fFcTBJxvhhzSJiZy8n+PeW6t8l+KeT/uTARa0jHOQLA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
@@ -266,8 +255,6 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4=
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c h1:3lbZUMbMiGUW/LMkfsEABsc5zNT9+b1CvsJx47JzJ8g=
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c/go.mod h1:UrdRz5enIKZ63MEE3IF9l2/ebyx59GyGgPi+tICQdmM=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -363,8 +350,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.6.0 h1:L4ZwwTvKW9gr0ZMS1yrHD9GZhIuVjOBBnaKH+SPQK0Q=
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=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+19
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)
}
}
@@ -1,4 +1,4 @@
// getter is using to get resources from url.
// Package getter is using to get resources from url.
// * Get metadata for website;
// * Get image blob to avoid CORS;
package getter
-28
View File
@@ -1,28 +0,0 @@
package getter
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestGetHTMLMeta(t *testing.T) {
tests := []struct {
urlStr string
htmlMeta HTMLMeta
}{
{
urlStr: "https://www.bytebase.com/blog/sql-review-tool-for-devs",
htmlMeta: HTMLMeta{
Title: "The SQL Review Tool for Developers",
Description: "Reviewing SQL can be somewhat tedious, yet is essential to keep your database fleet reliable. At Bytebase, we are building a developer-first SQL review tool to empower the DevOps system.",
Image: "https://www.bytebase.com/static/blog/sql-review-tool-for-devs/dev-fighting-dba.webp",
},
},
}
for _, test := range tests {
metadata, err := GetHTMLMeta(test.urlStr)
require.NoError(t, err)
require.Equal(t, test.htmlMeta, *metadata)
}
}
-21
View File
@@ -1,21 +0,0 @@
package getter
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestGetImage(t *testing.T) {
tests := []struct {
urlStr string
}{
{
urlStr: "https://star-history.com/bytebase.webp",
},
}
for _, test := range tests {
_, err := GetImage(test.urlStr)
require.NoError(t, err)
}
}
-14
View File
@@ -1,14 +0,0 @@
package metric
// Metric is the API message for metric.
type Metric struct {
ID string
Name string
Labels map[string]string
}
// Collector is the interface definition for metric collector.
type Collector interface {
Identify(id string) error
Collect(metric *Metric) error
}
-45
View File
@@ -1,45 +0,0 @@
package segment
import (
"time"
"github.com/segmentio/analytics-go"
metric "github.com/usememos/memos/plugin/metrics"
)
// collector is the metrics collector https://segment.com/.
type collector struct {
client analytics.Client
}
// NewCollector creates a new instance of segment.
func NewCollector(key string) metric.Collector {
client := analytics.New(key)
return &collector{
client: client,
}
}
// Identify will identify the server caller.
func (c *collector) Identify(id string) error {
return c.client.Enqueue(analytics.Identify{
UserId: id,
Timestamp: time.Now().UTC(),
})
}
// Collect will exec all the segment collector.
func (c *collector) Collect(metric *metric.Metric) error {
properties := analytics.NewProperties()
for key, value := range metric.Labels {
properties.Set(key, value)
}
return c.client.Enqueue(analytics.Track{
UserId: metric.ID,
Timestamp: time.Now().UTC(),
Event: metric.Name,
Properties: properties,
})
}
+88
View File
@@ -0,0 +1,88 @@
package openai
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
)
type ChatCompletionMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type ChatCompletionChoice struct {
Message *ChatCompletionMessage `json:"message"`
}
type ChatCompletionResponse struct {
Error any `json:"error"`
Model string `json:"model"`
Choices []ChatCompletionChoice `json:"choices"`
}
func PostChatCompletion(messages []ChatCompletionMessage, apiKey string, apiHost string) (string, error) {
if apiHost == "" {
apiHost = "https://api.openai.com"
}
url, err := url.JoinPath(apiHost, "/v1/chat/completions")
if err != nil {
return "", err
}
values := map[string]any{
"model": "gpt-3.5-turbo",
"messages": messages,
"max_tokens": 2000,
"temperature": 0,
"frequency_penalty": 0.0,
"presence_penalty": 0.0,
}
jsonValue, err := json.Marshal(values)
if err != nil {
return "", err
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonValue))
if err != nil {
return "", err
}
// Set the API key in the request header
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
// Send the request to OpenAI's API
client := http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
// Read the response body
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
chatCompletionResponse := ChatCompletionResponse{}
err = json.Unmarshal(responseBody, &chatCompletionResponse)
if err != nil {
return "", err
}
if chatCompletionResponse.Error != nil {
errorBytes, err := json.Marshal(chatCompletionResponse.Error)
if err != nil {
return "", err
}
return "", errors.New(string(errorBytes))
}
if len(chatCompletionResponse.Choices) == 0 {
return "", nil
}
return chatCompletionResponse.Choices[0].Message.Content, nil
}
+83
View File
@@ -0,0 +1,83 @@
package openai
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
)
type TextCompletionChoice struct {
Text string `json:"text"`
}
type TextCompletionResponse struct {
Error any `json:"error"`
Model string `json:"model"`
Choices []TextCompletionChoice `json:"choices"`
}
func PostTextCompletion(prompt string, apiKey string, apiHost string) (string, error) {
if apiHost == "" {
apiHost = "https://api.openai.com"
}
url, err := url.JoinPath(apiHost, "/v1/chat/completions")
if err != nil {
return "", err
}
values := map[string]any{
"model": "gpt-3.5-turbo",
"prompt": prompt,
"temperature": 0.5,
"max_tokens": 100,
"n": 1,
"stop": ".",
}
jsonValue, err := json.Marshal(values)
if err != nil {
return "", err
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonValue))
if err != nil {
return "", err
}
// Set the API key in the request header
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
// Send the request to OpenAI's API
client := http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
// Read the response body
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
textCompletionResponse := TextCompletionResponse{}
err = json.Unmarshal(responseBody, &textCompletionResponse)
if err != nil {
return "", err
}
if textCompletionResponse.Error != nil {
errorBytes, err := json.Marshal(textCompletionResponse.Error)
if err != nil {
return "", err
}
return "", errors.New(string(errorBytes))
}
if len(textCompletionResponse.Choices) == 0 {
return "", nil
}
return textCompletionResponse.Choices[0].Text, nil
}
+13 -9
View File
@@ -20,6 +20,7 @@ type Config struct {
EndPoint string
Region string
URLPrefix string
URLSuffix string
}
type Client struct {
@@ -28,14 +29,14 @@ type Client struct {
}
func NewClient(ctx context.Context, config *Config) (*Client, error) {
resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...any) (aws.Endpoint, error) {
return aws.Endpoint{
URL: config.EndPoint,
SigningRegion: config.Region,
}, nil
})
cfg, err := s3config.LoadDefaultConfig(ctx,
awsConfig, err := s3config.LoadDefaultConfig(ctx,
s3config.WithEndpointResolverWithOptions(resolver),
s3config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(config.AccessKey, config.SecretKey, "")),
)
@@ -43,7 +44,7 @@ func NewClient(ctx context.Context, config *Config) (*Client, error) {
return nil, err
}
client := awss3.NewFromConfig(cfg)
client := awss3.NewFromConfig(awsConfig)
return &Client{
Client: client,
@@ -53,7 +54,7 @@ func NewClient(ctx context.Context, config *Config) (*Client, error) {
func (client *Client) UploadFile(ctx context.Context, filename string, fileType string, src io.Reader) (string, error) {
uploader := manager.NewUploader(client.Client)
resp, err := uploader.Upload(ctx, &awss3.PutObjectInput{
uploadOutput, err := uploader.Upload(ctx, &awss3.PutObjectInput{
Bucket: aws.String(client.Config.Bucket),
Key: aws.String(filename),
Body: src,
@@ -63,11 +64,14 @@ func (client *Client) UploadFile(ctx context.Context, filename string, fileType
if err != nil {
return "", err
}
var link string
if client.Config.URLPrefix == "" {
link = resp.Location
} else {
link = fmt.Sprintf("%s/%s", client.Config.URLPrefix, filename)
link := uploadOutput.Location
// If url prefix is set, use it as the file link.
if client.Config.URLPrefix != "" {
link = fmt.Sprintf("%s/%s%s", client.Config.URLPrefix, filename, client.Config.URLSuffix)
}
if link == "" {
return "", fmt.Errorf("failed to get file link")
}
return link, nil
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

-95
View File
@@ -1,95 +0,0 @@
package server
import (
"fmt"
"net/http"
"strconv"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
"github.com/gorilla/sessions"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
)
var (
userIDContextKey = "user-id"
sessionName = "memos_session"
)
func getUserIDContextKey() string {
return userIDContextKey
}
func setUserSession(ctx echo.Context, user *api.User) error {
sess, _ := session.Get(sessionName, ctx)
sess.Options = &sessions.Options{
Path: "/",
MaxAge: 3600 * 24 * 30,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
}
sess.Values[userIDContextKey] = user.ID
err := sess.Save(ctx.Request(), ctx.Response())
if err != nil {
return fmt.Errorf("failed to set session, err: %w", err)
}
return nil
}
func removeUserSession(ctx echo.Context) error {
sess, _ := session.Get(sessionName, ctx)
sess.Options = &sessions.Options{
Path: "/",
MaxAge: 0,
HttpOnly: true,
}
sess.Values[userIDContextKey] = nil
err := sess.Save(ctx.Request(), ctx.Response())
if err != nil {
return fmt.Errorf("failed to set session, err: %w", err)
}
return nil
}
func aclMiddleware(s *Server, next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Request().Context()
path := c.Path()
if s.defaultAuthSkipper(c) {
return next(c)
}
sess, _ := session.Get(sessionName, c)
userIDValue := sess.Values[userIDContextKey]
if userIDValue != nil {
userID, _ := strconv.Atoi(fmt.Sprintf("%v", userIDValue))
userFind := &api.UserFind{
ID: &userID,
}
user, err := s.Store.FindUser(ctx, userFind)
if err != nil && common.ErrorCode(err) != common.NotFound {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user by ID: %d", userID)).SetInternal(err)
}
if user != nil {
if user.RowStatus == api.Archived {
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with username %s", user.Username))
}
c.Set(getUserIDContextKey(), userID)
}
}
if common.HasPrefixes(path, "/api/ping", "/api/status", "/api/idp", "/api/user/:id", "/api/memo") && c.Request().Method == http.MethodGet {
return next(c)
}
userID := c.Get(getUserIDContextKey())
if userID == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
return next(c)
}
}
+13 -25
View File
@@ -11,14 +11,13 @@ import (
"github.com/usememos/memos/common"
"github.com/usememos/memos/plugin/idp"
"github.com/usememos/memos/plugin/idp/oauth2"
metric "github.com/usememos/memos/plugin/metrics"
"github.com/usememos/memos/store"
"github.com/labstack/echo/v4"
"golang.org/x/crypto/bcrypt"
)
func (s *Server) registerAuthRoutes(g *echo.Group) {
func (s *Server) registerAuthRoutes(g *echo.Group, secret string) {
g.POST("/auth/signin", func(c echo.Context) error {
ctx := c.Request().Context()
signin := &api.SignIn{}
@@ -31,10 +30,10 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
}
user, err := s.Store.FindUser(ctx, userFind)
if err != nil && common.ErrorCode(err) != common.NotFound {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user by username %s", signin.Username)).SetInternal(err)
return echo.NewHTTPError(http.StatusInternalServerError, "Incorrect login credentials, please try again")
}
if user == nil {
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("User not found with username %s", signin.Username))
return echo.NewHTTPError(http.StatusUnauthorized, "Incorrect login credentials, please try again")
} else if user.RowStatus == api.Archived {
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with username %s", signin.Username))
}
@@ -42,11 +41,11 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
// Compare the stored hashed password, with the hashed version of the password that was received.
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(signin.Password)); err != nil {
// If the two passwords don't match, return a 401 status.
return echo.NewHTTPError(http.StatusUnauthorized, "Incorrect password").SetInternal(err)
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 := GenerateTokensAndSetCookies(c, user, secret); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate tokens").SetInternal(err)
}
if err := s.createUserAuthSignInActivity(c, user); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
@@ -99,7 +98,7 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
Username: &userInfo.Identifier,
})
if err != nil && common.ErrorCode(err) != common.NotFound {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user by username %s", userInfo.Identifier)).SetInternal(err)
return echo.NewHTTPError(http.StatusInternalServerError, "Incorrect login credentials, please try again")
}
if user == nil {
userCreate := &api.UserCreate{
@@ -129,8 +128,8 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
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 := GenerateTokensAndSetCookies(c, user, secret); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate tokens").SetInternal(err)
}
if err := s.createUserAuthSignInActivity(c, user); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
@@ -197,23 +196,18 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
}
if err := GenerateTokensAndSetCookies(c, user, secret); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate tokens").SetInternal(err)
}
if err := s.createUserAuthSignUpActivity(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))
})
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)
}
RemoveTokensAndCookies(c)
return c.JSON(http.StatusOK, true)
})
}
@@ -237,9 +231,6 @@ func (s *Server) createUserAuthSignInActivity(c echo.Context, user *api.User) er
if err != nil || activity == nil {
return errors.Wrap(err, "failed to create activity")
}
s.Collector.Collect(ctx, &metric.Metric{
Name: string(activity.Type),
})
return err
}
@@ -262,8 +253,5 @@ func (s *Server) createUserAuthSignUpActivity(c echo.Context, user *api.User) er
if err != nil || activity == nil {
return errors.Wrap(err, "failed to create activity")
}
s.Collector.Collect(ctx, &metric.Metric{
Name: string(activity.Type),
})
return err
}
+87
View File
@@ -0,0 +1,87 @@
package auth
import (
"strconv"
"time"
"github.com/golang-jwt/jwt/v4"
)
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 = "access-token"
// RefreshTokenCookieName is the cookie name of refresh token.
RefreshTokenCookieName = "refresh-token"
// UserIDCookieName is the cookie name of user ID.
UserIDCookieName = "user"
)
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))
}
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
}
+2 -2
View File
@@ -9,10 +9,10 @@ import (
)
type response struct {
Data interface{} `json:"data"`
Data any `json:"data"`
}
func composeResponse(data interface{}) response {
func composeResponse(data any) response {
return response{
Data: data,
}
+1 -1
View File
@@ -6,7 +6,7 @@ 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) {
+258
View File
@@ -0,0 +1,258 @@
package server
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"
"github.com/usememos/memos/common"
"github.com/usememos/memos/server/auth"
)
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"
)
// 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 getUserIDContextKey() string {
return userIDContextKey
}
// GenerateTokensAndSetCookies generates jwt token and saves it to the http-only cookie.
func GenerateTokensAndSetCookies(c echo.Context, user *api.User, secret string) error {
accessToken, err := auth.GenerateAccessToken(user.Username, user.ID, secret)
if err != nil {
return errors.Wrap(err, "failed to generate access token")
}
cookieExp := time.Now().Add(auth.CookieExpDuration)
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
// We generate here a new refresh token and saving it to the cookie.
refreshToken, err := auth.GenerateRefreshToken(user.Username, user.ID, secret)
if err != nil {
return errors.Wrap(err, "failed to generate refresh token")
}
setTokenCookie(c, auth.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, auth.AccessTokenCookieName, "", cookieExp)
setTokenCookie(c, auth.RefreshTokenCookieName, "", cookieExp)
}
// Here we are creating a new cookie, which will store the valid JWT token.
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)
}
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
}
// 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 *Server, 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 common.HasPrefixes(path, "/api/ping", "/api/idp", "/api/user/:id") && method == http.MethodGet {
return next(c)
}
token := findAccessToken(c)
if token == "" {
// Allow the user to access the public endpoints.
if common.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 common.HasPrefixes(path, "/api/status", "/api/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 !audienceContains(claims.Audience, auth.AccessTokenAudienceName) {
return echo.NewHTTPError(http.StatusUnauthorized,
fmt.Sprintf("Invalid access token, audience mismatch, got %q, expected %q. you may send request to the wrong environment",
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 {
return &echo.HTTPError{
Code: http.StatusUnauthorized,
Message: "Invalid or expired access token",
Internal: err,
}
}
}
// 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.FindUser(ctx, &api.UserFind{
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 := 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 audienceContains(audience jwt.ClaimStrings, token string) bool {
for _, v := range audience {
if v == token {
return true
}
}
return false
}
+13 -24
View File
@@ -11,7 +11,6 @@ import (
"github.com/pkg/errors"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
metric "github.com/usememos/memos/plugin/metrics"
"github.com/labstack/echo/v4"
)
@@ -30,10 +29,9 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
}
if memoCreate.Visibility == "" {
userSettingMemoVisibilityKey := api.UserSettingMemoVisibilityKey
userMemoVisibilitySetting, err := s.Store.FindUserSetting(ctx, &api.UserSettingFind{
UserID: userID,
Key: &userSettingMemoVisibilityKey,
Key: api.UserSettingMemoVisibilityKey,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user setting").SetInternal(err)
@@ -66,7 +64,18 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting").SetInternal(err)
}
if disablePublicMemos {
memoCreate.Visibility = api.Private
// Allow if the user is an admin.
user, err := s.Store.FindUser(ctx, &api.UserFind{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
// Only enforce private if you're a regular user.
// Admins should know what they're doing.
if user.Role == "USER" {
memoCreate.Visibility = api.Private
}
}
}
@@ -334,23 +343,6 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
return c.JSON(http.StatusOK, composeResponse(resourceList))
})
g.GET("/memo/amount", func(c echo.Context) error {
ctx := c.Request().Context()
normalRowStatus := api.Normal
memoFind := &api.MemoFind{
RowStatus: &normalRowStatus,
}
if userID, err := strconv.Atoi(c.QueryParam("userId")); err == nil {
memoFind.CreatorID = &userID
}
memoList, err := s.Store.FindMemoList(ctx, memoFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
}
return c.JSON(http.StatusOK, composeResponse(memoList))
})
g.GET("/memo/stats", func(c echo.Context) error {
ctx := c.Request().Context()
normalStatus := api.Normal
@@ -522,8 +514,5 @@ func (s *Server) createMemoCreateActivity(c echo.Context, memo *api.Memo) error
if err != nil || activity == nil {
return errors.Wrap(err, "failed to create activity")
}
s.Collector.Collect(ctx, &metric.Metric{
Name: string(activity.Type),
})
return err
}
-62
View File
@@ -1,62 +0,0 @@
package server
import (
"context"
"fmt"
metric "github.com/usememos/memos/plugin/metrics"
"github.com/usememos/memos/plugin/metrics/segment"
"github.com/usememos/memos/server/profile"
"github.com/usememos/memos/server/version"
)
// MetricCollector is the metric collector.
type MetricCollector struct {
collector metric.Collector
ID string
Enabled bool
Profile *profile.Profile
}
const (
segmentMetricWriteKey = "NbPruMMmfqfD2AMCw3pkxZTsszVS3hKq"
)
func (s *Server) registerMetricCollector() {
c := segment.NewCollector(segmentMetricWriteKey)
mc := &MetricCollector{
collector: c,
ID: s.ID,
Enabled: false,
Profile: s.Profile,
}
s.Collector = mc
}
func (mc *MetricCollector) Identify(_ context.Context) {
if !mc.Enabled {
return
}
err := mc.collector.Identify(mc.ID)
if err != nil {
fmt.Printf("Failed to request segment, error: %+v\n", err)
}
}
func (mc *MetricCollector) Collect(_ context.Context, metric *metric.Metric) {
if !mc.Enabled {
return
}
if metric.Labels == nil {
metric.Labels = map[string]string{}
}
metric.Labels["mode"] = mc.Profile.Mode
metric.Labels["version"] = version.GetCurrentVersion(mc.Profile.Mode)
metric.ID = mc.ID
err := mc.collector.Collect(metric)
if err != nil {
fmt.Printf("Failed to request segment, error: %+v\n", err)
}
}
+72
View File
@@ -0,0 +1,72 @@
package server
import (
"encoding/json"
"net/http"
"github.com/labstack/echo/v4"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
"github.com/usememos/memos/plugin/openai"
)
func (s *Server) registerOpenAIRoutes(g *echo.Group) {
g.POST("/openai/chat-completion", func(c echo.Context) error {
ctx := c.Request().Context()
openAIConfigSetting, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{
Name: api.SystemSettingOpenAIConfigName,
})
if err != nil && common.ErrorCode(err) != common.NotFound {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find openai key").SetInternal(err)
}
openAIConfig := api.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, composeResponse(result))
})
g.GET("/openai/enabled", func(c echo.Context) error {
ctx := c.Request().Context()
openAIConfigSetting, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{
Name: api.SystemSettingOpenAIConfigName,
})
if err != nil && common.ErrorCode(err) != common.NotFound {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find openai key").SetInternal(err)
}
openAIConfig := api.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, composeResponse(openAIConfig.Key != ""))
})
}
+5 -1
View File
@@ -24,6 +24,10 @@ type Profile struct {
Version string `json:"version"`
}
func (p *Profile) IsDev() bool {
return p.Mode != "prod"
}
func checkDSN(dataDir string) (string, error) {
// Convert to absolute path if relative path is supplied.
if !filepath.IsAbs(dataDir) {
@@ -44,7 +48,7 @@ func checkDSN(dataDir string) (string, error) {
return dataDir, nil
}
// GetDevProfile will return a profile for dev or prod.
// GetProfile will return a profile for dev or prod.
func GetProfile() (*Profile, error) {
profile := Profile{}
err := viper.Unmarshal(&profile)
+132 -80
View File
@@ -7,6 +7,10 @@ import (
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
@@ -15,8 +19,9 @@ import (
"github.com/pkg/errors"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
metric "github.com/usememos/memos/plugin/metrics"
"github.com/usememos/memos/common/log"
"github.com/usememos/memos/plugin/storage/s3"
"go.uber.org/zap"
)
const (
@@ -24,6 +29,8 @@ const (
maxFileSize = 32 << 20
)
var fileKeyPattern = regexp.MustCompile(`\{[a-z]{1,9}\}`)
func (s *Server) registerResourceRoutes(g *echo.Group) {
g.POST("/resource", func(c echo.Context) error {
ctx := c.Request().Context()
@@ -42,6 +49,7 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
if resourceCreate.ExternalLink != "" && !strings.HasPrefix(resourceCreate.ExternalLink, "http") {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid external link")
}
resource, err := s.Store.CreateResource(ctx, resourceCreate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err)
@@ -71,40 +79,76 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, "Upload file not found").SetInternal(err)
}
filename := file.Filename
filetype := file.Header.Get("Content-Type")
size := file.Size
src, err := file.Open()
sourceFile, err := file.Open()
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to open file").SetInternal(err)
}
defer src.Close()
defer sourceFile.Close()
systemSetting, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{Name: api.SystemSettingStorageServiceIDName})
var resourceCreate *api.ResourceCreate
systemSettingStorageServiceID, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{Name: api.SystemSettingStorageServiceIDName})
if err != nil && common.ErrorCode(err) != common.NotFound {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find storage").SetInternal(err)
}
storageServiceID := 0
if systemSetting != nil {
err = json.Unmarshal([]byte(systemSetting.Value), &storageServiceID)
storageServiceID := api.DatabaseStorage
if systemSettingStorageServiceID != nil {
err = json.Unmarshal([]byte(systemSettingStorageServiceID.Value), &storageServiceID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal storage service id").SetInternal(err)
}
}
var resourceCreate *api.ResourceCreate
if storageServiceID == 0 {
fileBytes, err := io.ReadAll(src)
if storageServiceID == api.DatabaseStorage {
fileBytes, err := io.ReadAll(sourceFile)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to read file").SetInternal(err)
}
resourceCreate = &api.ResourceCreate{
CreatorID: userID,
Filename: filename,
Filename: file.Filename,
Type: filetype,
Size: size,
Blob: fileBytes,
}
} else if storageServiceID == api.LocalStorage {
systemSettingLocalStoragePath, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{Name: api.SystemSettingLocalStoragePathName})
if err != nil && common.ErrorCode(err) != common.NotFound {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find local storage path setting").SetInternal(err)
}
localStoragePath := ""
if systemSettingLocalStoragePath != nil {
err = json.Unmarshal([]byte(systemSettingLocalStoragePath.Value), &localStoragePath)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal local storage path setting").SetInternal(err)
}
}
filePath := localStoragePath
if !strings.Contains(filePath, "{filename}") {
filePath = path.Join(filePath, "{filename}")
}
filePath = path.Join(s.Profile.Data, replacePathTemplate(filePath, file.Filename))
dir, filename := filepath.Split(filePath)
if err = os.MkdirAll(dir, os.ModePerm); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create directory").SetInternal(err)
}
dst, err := os.Create(filePath)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create file").SetInternal(err)
}
defer dst.Close()
_, err = io.Copy(dst, sourceFile)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to copy file").SetInternal(err)
}
resourceCreate = &api.ResourceCreate{
CreatorID: userID,
Filename: filename,
Type: filetype,
Size: size,
InternalPath: filePath,
}
} else {
storage, err := s.Store.FindStorage(ctx, &api.StorageFind{ID: &storageServiceID})
if err != nil {
@@ -113,19 +157,26 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
if storage.Type == api.StorageS3 {
s3Config := storage.Config.S3Config
s3client, err := s3.NewClient(ctx, &s3.Config{
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 echo.NewHTTPError(http.StatusInternalServerError, "Failed to new s3 client").SetInternal(err)
}
link, err := s3client.UploadFile(ctx, filename, filetype, src)
filePath := s3Config.Path
if !strings.Contains(filePath, "{filename}") {
filePath = path.Join(filePath, "{filename}")
}
filePath = replacePathTemplate(filePath, file.Filename)
_, filename := filepath.Split(filePath)
link, err := s3Client.UploadFile(ctx, filePath, filetype, sourceFile)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upload via s3 client").SetInternal(err)
}
@@ -140,6 +191,8 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
}
}
publicID := common.GenUUID()
resourceCreate.PublicID = publicID
resource, err := s.Store.CreateResource(ctx, resourceCreate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err)
@@ -159,69 +212,20 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
resourceFind := &api.ResourceFind{
CreatorID: &userID,
}
if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
resourceFind.Limit = &limit
}
if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil {
resourceFind.Offset = &offset
}
list, err := s.Store.FindResourceList(ctx, resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
}
for _, resource := range list {
memoResourceList, err := s.Store.FindMemoResourceList(ctx, &api.MemoResourceFind{
ResourceID: &resource.ID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo resource list").SetInternal(err)
}
resource.LinkedMemoAmount = len(memoResourceList)
}
return c.JSON(http.StatusOK, composeResponse(list))
})
g.GET("/resource/:resourceId", 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)
}
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
resourceFind := &api.ResourceFind{
ID: &resourceID,
CreatorID: &userID,
GetBlob: true,
}
resource, err := s.Store.FindResource(ctx, resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource").SetInternal(err)
}
return c.JSON(http.StatusOK, composeResponse(resource))
})
g.GET("/resource/:resourceId/blob", 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)
}
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
resourceFind := &api.ResourceFind{
ID: &resourceID,
CreatorID: &userID,
GetBlob: true,
}
resource, err := s.Store.FindResource(ctx, resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource").SetInternal(err)
}
return c.Stream(http.StatusOK, resource.Type, bytes.NewReader(resource.Blob))
})
g.PATCH("/resource/:resourceId", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
@@ -253,6 +257,11 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch resource request").SetInternal(err)
}
if resourcePatch.ResetPublicID != nil && *resourcePatch.ResetPublicID {
publicID := common.GenUUID()
resourcePatch.PublicID = &publicID
}
resourcePatch.ID = resourceID
resource, err = s.Store.PatchResource(ctx, resourcePatch)
if err != nil {
@@ -284,6 +293,13 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
if resource.InternalPath != "" {
err := os.Remove(resource.InternalPath)
if err != nil {
log.Warn(fmt.Sprintf("failed to delete local file with path %s", resource.InternalPath), zap.Error(err))
}
}
resourceDelete := &api.ResourceDelete{
ID: resourceID,
}
@@ -298,19 +314,19 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
}
func (s *Server) registerResourcePublicRoutes(g *echo.Group) {
g.GET("/r/:resourceId/:filename", func(c echo.Context) error {
g.GET("/r/:resourceId/:publicId", 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)
}
filename, err := url.QueryUnescape(c.Param("filename"))
publicID, err := url.QueryUnescape(c.Param("publicId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("filename is invalid: %s", c.Param("filename"))).SetInternal(err)
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("publicID is invalid: %s", c.Param("publicId"))).SetInternal(err)
}
resourceFind := &api.ResourceFind{
ID: &resourceID,
Filename: &filename,
PublicID: &publicID,
GetBlob: true,
}
resource, err := s.Store.FindResource(ctx, resourceFind)
@@ -318,16 +334,29 @@ func (s *Server) registerResourcePublicRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find resource by ID: %v", resourceID)).SetInternal(err)
}
blob := resource.Blob
if resource.InternalPath != "" {
src, err := os.Open(resource.InternalPath)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to open the local resource: %s", resource.InternalPath)).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", resource.InternalPath)).SetInternal(err)
}
}
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(resource.Blob))
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(resource.Blob))
return c.Stream(http.StatusOK, resourceType, bytes.NewReader(blob))
})
}
@@ -351,8 +380,31 @@ func (s *Server) createResourceCreateActivity(c echo.Context, resource *api.Reso
if err != nil || activity == nil {
return errors.Wrap(err, "failed to create activity")
}
s.Collector.Collect(ctx, &metric.Metric{
Name: string(activity.Type),
})
return err
}
func replacePathTemplate(path string, 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
}
+21 -62
View File
@@ -11,6 +11,7 @@ import (
"github.com/gorilla/feeds"
"github.com/labstack/echo/v4"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
)
func (s *Server) registerRSSRoutes(g *echo.Group) {
@@ -35,7 +36,7 @@ func (s *Server) registerRSSRoutes(g *echo.Group) {
}
baseURL := c.Scheme() + "://" + c.Request().Host
rss, err := generateRSSFromMemoList(memoList, baseURL, &systemCustomizedProfile)
rss, err := generateRSSFromMemoList(memoList, baseURL, systemCustomizedProfile)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate rss").SetInternal(err)
}
@@ -72,7 +73,7 @@ func (s *Server) registerRSSRoutes(g *echo.Group) {
baseURL := c.Scheme() + "://" + c.Request().Host
rss, err := generateRSSFromMemoList(memoList, baseURL, &systemCustomizedProfile)
rss, err := generateRSSFromMemoList(memoList, baseURL, systemCustomizedProfile)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate rss").SetInternal(err)
}
@@ -92,13 +93,10 @@ func generateRSSFromMemoList(memoList []*api.Memo, baseURL string, profile *api.
Created: time.Now(),
}
var itemCountLimit = min(len(memoList), MaxRSSItemCount)
var itemCountLimit = common.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)},
@@ -114,64 +112,25 @@ 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: "",
},
func getSystemCustomizedProfile(ctx context.Context, s *Server) (*api.CustomizedProfile, error) {
customizedProfile := &api.CustomizedProfile{
Name: "memos",
LogoURL: "",
Description: "",
Locale: "en",
Appearance: "system",
ExternalURL: "",
}
systemSettingList, err := s.Store.FindSystemSettingList(ctx, &api.SystemSettingFind{})
if err != nil {
return api.CustomizedProfile{}, err
systemSetting, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{
Name: api.SystemSettingCustomizedProfileName,
})
if err != nil && common.ErrorCode(err) != common.NotFound {
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)
}
}
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 +139,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 = common.Min(len(title), MaxRSSItemTitleLength)
if titleLengthLimit < len(title) {
title = title[:titleLengthLimit] + "..."
}
+14 -22
View File
@@ -9,13 +9,10 @@ import (
"github.com/pkg/errors"
"github.com/usememos/memos/api"
metric "github.com/usememos/memos/plugin/metrics"
"github.com/usememos/memos/server/profile"
"github.com/usememos/memos/store"
"github.com/usememos/memos/store/db"
"github.com/gorilla/sessions"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
@@ -24,10 +21,9 @@ type Server struct {
e *echo.Echo
db *sql.DB
ID string
Profile *profile.Profile
Store *store.Store
Collector *MetricCollector
ID string
Profile *profile.Profile
Store *store.Store
}
func NewServer(ctx context.Context, profile *profile.Profile) (*Server, error) {
@@ -83,33 +79,32 @@ func NewServer(ctx context.Context, profile *profile.Profile) (*Server, error) {
}
s.ID = serverID
secretSessionName := "usememos"
embedFrontend(e)
secret := "usememos"
if profile.Mode == "prod" {
secretSessionName, err = s.getSystemSecretSessionName(ctx)
secret, err = s.getSystemSecretSessionName(ctx)
if err != nil {
return nil, err
}
}
e.Use(session.Middleware(sessions.NewCookieStore([]byte(secretSessionName))))
embedFrontend(e)
// Register MetricCollector to server.
s.registerMetricCollector()
rootGroup := e.Group("")
s.registerRSSRoutes(rootGroup)
publicGroup := e.Group("/o")
s.registerResourcePublicRoutes(publicGroup)
publicGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return JWTMiddleware(s, next, secret)
})
registerGetterPublicRoutes(publicGroup)
s.registerResourcePublicRoutes(publicGroup)
apiGroup := e.Group("/api")
apiGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return aclMiddleware(s, next)
return JWTMiddleware(s, next, secret)
})
s.registerSystemRoutes(apiGroup)
s.registerAuthRoutes(apiGroup)
s.registerAuthRoutes(apiGroup, secret)
s.registerUserRoutes(apiGroup)
s.registerMemoRoutes(apiGroup)
s.registerShortcutRoutes(apiGroup)
@@ -117,6 +112,7 @@ func NewServer(ctx context.Context, profile *profile.Profile) (*Server, error) {
s.registerTagRoutes(apiGroup)
s.registerStorageRoutes(apiGroup)
s.registerIdentityProviderRoutes(apiGroup)
s.registerOpenAIRoutes(apiGroup)
return s, nil
}
@@ -125,7 +121,6 @@ func (s *Server) Start(ctx context.Context) error {
if err := s.createServerStartActivity(ctx); err != nil {
return errors.Wrap(err, "failed to create activity")
}
s.Collector.Identify(ctx)
return s.e.Start(fmt.Sprintf(":%d", s.Profile.Port))
}
@@ -164,8 +159,5 @@ func (s *Server) createServerStartActivity(ctx context.Context) error {
if err != nil || activity == nil {
return errors.Wrap(err, "failed to create activity")
}
s.Collector.Collect(ctx, &metric.Metric{
Name: string(activity.Type),
})
return err
}
+1 -1
View File
@@ -129,7 +129,7 @@ func (s *Server) registerStorageRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find storage").SetInternal(err)
}
if systemSetting != nil {
storageServiceID := 0
storageServiceID := api.DatabaseStorage
err = json.Unmarshal([]byte(systemSetting.Value), &storageServiceID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal storage service id").SetInternal(err)
+26 -31
View File
@@ -9,6 +9,8 @@ import (
"github.com/google/uuid"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
"github.com/usememos/memos/common/log"
"go.uber.org/zap"
"github.com/labstack/echo/v4"
)
@@ -40,6 +42,7 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
Profile: *s.Profile,
DBSize: 0,
AllowSignUp: false,
IgnoreUpgrade: false,
DisablePublicMemos: false,
AdditionalStyle: "",
AdditionalScript: "",
@@ -51,7 +54,8 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
Appearance: "system",
ExternalURL: "",
},
StorageServiceID: 0,
StorageServiceID: api.DatabaseStorage,
LocalStoragePath: "",
}
systemSettingList, err := s.Store.FindSystemSettingList(ctx, &api.SystemSettingFind{})
@@ -59,47 +63,38 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting list").SetInternal(err)
}
for _, systemSetting := range systemSettingList {
if systemSetting.Name == api.SystemSettingServerID || systemSetting.Name == api.SystemSettingSecretSessionName {
if systemSetting.Name == api.SystemSettingServerIDName || systemSetting.Name == api.SystemSettingSecretSessionName || systemSetting.Name == api.SystemSettingOpenAIConfigName {
continue
}
var value interface{}
err := json.Unmarshal([]byte(systemSetting.Value), &value)
var baseValue any
err := json.Unmarshal([]byte(systemSetting.Value), &baseValue)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting value").SetInternal(err)
log.Warn("Failed to unmarshal system setting value", zap.String("setting name", systemSetting.Name.String()))
continue
}
if systemSetting.Name == api.SystemSettingAllowSignUpName {
systemStatus.AllowSignUp = value.(bool)
systemStatus.AllowSignUp = baseValue.(bool)
} else if systemSetting.Name == api.SystemSettingIgnoreUpgradeName {
systemStatus.IgnoreUpgrade = baseValue.(bool)
} else if systemSetting.Name == api.SystemSettingDisablePublicMemosName {
systemStatus.DisablePublicMemos = value.(bool)
systemStatus.DisablePublicMemos = baseValue.(bool)
} else if systemSetting.Name == api.SystemSettingAdditionalStyleName {
systemStatus.AdditionalStyle = value.(string)
systemStatus.AdditionalStyle = baseValue.(string)
} else if systemSetting.Name == api.SystemSettingAdditionalScriptName {
systemStatus.AdditionalScript = value.(string)
systemStatus.AdditionalScript = baseValue.(string)
} else 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 := api.CustomizedProfile{}
err := json.Unmarshal([]byte(systemSetting.Value), &customizedProfile)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting customized profile value").SetInternal(err)
}
systemStatus.CustomizedProfile = customizedProfile
} else if systemSetting.Name == api.SystemSettingStorageServiceIDName {
systemStatus.StorageServiceID = int(value.(float64))
systemStatus.StorageServiceID = int(baseValue.(float64))
} else if systemSetting.Name == api.SystemSettingLocalStoragePathName {
systemStatus.LocalStoragePath = baseValue.(string)
}
}
@@ -205,14 +200,14 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
func (s *Server) getSystemServerID(ctx context.Context) (string, error) {
serverIDValue, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{
Name: api.SystemSettingServerID,
Name: api.SystemSettingServerIDName,
})
if err != nil && common.ErrorCode(err) != common.NotFound {
return "", err
}
if serverIDValue == nil || serverIDValue.Value == "" {
serverIDValue, err = s.Store.UpsertSystemSetting(ctx, &api.SystemSettingUpsert{
Name: api.SystemSettingServerID,
Name: api.SystemSettingServerIDName,
Value: uuid.NewString(),
})
if err != nil {
+4 -7
View File
@@ -10,7 +10,6 @@ import (
"github.com/pkg/errors"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
metric "github.com/usememos/memos/plugin/metrics"
"github.com/labstack/echo/v4"
"golang.org/x/crypto/bcrypt"
@@ -178,9 +177,6 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch user request").SetInternal(err)
}
userPatch.ID = userID
if err := userPatch.Validate(); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user patch format").SetInternal(err)
}
if userPatch.Password != nil && *userPatch.Password != "" {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(*userPatch.Password), bcrypt.DefaultCost)
@@ -197,6 +193,10 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
userPatch.OpenID = &openID
}
if err := userPatch.Validate(); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user patch format").SetInternal(err)
}
user, err := s.Store.PatchUser(ctx, userPatch)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch user").SetInternal(err)
@@ -269,8 +269,5 @@ func (s *Server) createUserCreateActivity(c echo.Context, user *api.User) error
if err != nil || activity == nil {
return errors.Wrap(err, "failed to create activity")
}
s.Collector.Collect(ctx, &metric.Metric{
Name: string(activity.Type),
})
return err
}
+3 -3
View File
@@ -9,10 +9,10 @@ import (
// Version is the service current released version.
// Semantic versioning: https://semver.org/
var Version = "0.11.0"
var Version = "0.12.1"
// DevVersion is the service current development version.
var DevVersion = "0.11.0"
var DevVersion = "0.12.1"
func GetCurrentVersion(mode string) string {
if mode == "dev" || mode == "demo" {
@@ -34,7 +34,7 @@ func GetSchemaVersion(version string) string {
return minorVersion + ".0"
}
// IsVersionGreaterThanOrEqualTo returns true if version is greater than or equal to target.
// IsVersionGreaterOrEqualThan returns true if version is greater than or equal to target.
func IsVersionGreaterOrEqualThan(version, target string) bool {
return semver.Compare(fmt.Sprintf("v%s", version), fmt.Sprintf("v%s", target)) > -1
}
+90
View File
@@ -0,0 +1,90 @@
package setup
import (
"context"
"errors"
"fmt"
"golang.org/x/crypto/bcrypt"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
)
func Execute(
ctx context.Context,
store store,
hostUsername, hostPassword string,
) error {
s := setupService{store: store}
return s.Setup(ctx, hostUsername, hostPassword)
}
type store interface {
FindUserList(ctx context.Context, find *api.UserFind) ([]*api.User, error)
CreateUser(ctx context.Context, create *api.UserCreate) (*api.User, error)
}
type setupService struct {
store store
}
func (s setupService) Setup(
ctx context.Context,
hostUsername, hostPassword string,
) error {
if err := s.makeSureHostUserNotExists(ctx); err != nil {
return err
}
if err := s.createUser(ctx, hostUsername, hostPassword); err != nil {
return fmt.Errorf("create user: %w", err)
}
return nil
}
func (s setupService) makeSureHostUserNotExists(ctx context.Context) error {
hostUserType := api.Host
existedHostUsers, err := s.store.FindUserList(ctx, &api.UserFind{
Role: &hostUserType,
})
if err != nil {
return fmt.Errorf("find user list: %w", err)
}
if len(existedHostUsers) != 0 {
return errors.New("host user already exists")
}
return nil
}
func (s setupService) createUser(
ctx context.Context,
hostUsername, hostPassword string,
) error {
userCreate := &api.UserCreate{
Username: hostUsername,
// The new signup user should be normal user by default.
Role: api.Host,
Nickname: hostUsername,
Password: hostPassword,
OpenID: common.GenUUID(),
}
if err := userCreate.Validate(); err != nil {
return fmt.Errorf("validate: %w", err)
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(hostPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
userCreate.PasswordHash = string(passwordHash)
if _, err := s.store.CreateUser(ctx, userCreate); err != nil {
return fmt.Errorf("create user: %w", err)
}
return nil
}
+181
View File
@@ -0,0 +1,181 @@
package setup
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/usememos/memos/api"
)
func TestSetupServiceMakeSureHostUserNotExists(t *testing.T) {
cc := map[string]struct {
setupStore func(*storeMock)
expectedErr string
}{
"failed to get list": {
setupStore: func(m *storeMock) {
hostUserType := api.Host
m.
On("FindUserList", mock.Anything, &api.UserFind{
Role: &hostUserType,
}).
Return(nil, errors.New("fake error"))
},
expectedErr: "find user list: fake error",
},
"success, not empty": {
setupStore: func(m *storeMock) {
hostUserType := api.Host
m.
On("FindUserList", mock.Anything, &api.UserFind{
Role: &hostUserType,
}).
Return([]*api.User{
{},
}, nil)
},
expectedErr: "host user already exists",
},
"success, empty": {
setupStore: func(m *storeMock) {
hostUserType := api.Host
m.
On("FindUserList", mock.Anything, &api.UserFind{
Role: &hostUserType,
}).
Return(nil, nil)
},
},
}
for n, c := range cc {
c := c
t.Run(n, func(t *testing.T) {
sm := newStoreMock(t)
if c.setupStore != nil {
c.setupStore(sm)
}
srv := setupService{store: sm}
err := srv.makeSureHostUserNotExists(context.Background())
if c.expectedErr == "" {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, c.expectedErr)
}
})
}
}
func TestSetupServiceCreateUser(t *testing.T) {
expectedCreated := &api.UserCreate{
Username: "demohero",
Role: api.Host,
Nickname: "demohero",
Password: "123456",
}
userCreateMatcher := mock.MatchedBy(func(arg *api.UserCreate) bool {
return arg.Username == expectedCreated.Username &&
arg.Role == expectedCreated.Role &&
arg.Nickname == expectedCreated.Nickname &&
arg.Password == expectedCreated.Password &&
arg.PasswordHash != ""
})
cc := map[string]struct {
setupStore func(*storeMock)
hostUsername, hostPassword string
expectedErr string
}{
`username == "", password == ""`: {
expectedErr: "validate: username is too short, minimum length is 3",
},
`username == "", password != ""`: {
hostPassword: expectedCreated.Password,
expectedErr: "validate: username is too short, minimum length is 3",
},
`username != "", password == ""`: {
hostUsername: expectedCreated.Username,
expectedErr: "validate: password is too short, minimum length is 6",
},
"failed to create": {
setupStore: func(m *storeMock) {
m.
On("CreateUser", mock.Anything, userCreateMatcher).
Return(nil, errors.New("fake error"))
},
hostUsername: expectedCreated.Username,
hostPassword: expectedCreated.Password,
expectedErr: "create user: fake error",
},
"success": {
setupStore: func(m *storeMock) {
m.
On("CreateUser", mock.Anything, userCreateMatcher).
Return(nil, nil)
},
hostUsername: expectedCreated.Username,
hostPassword: expectedCreated.Password,
},
}
for n, c := range cc {
c := c
t.Run(n, func(t *testing.T) {
sm := newStoreMock(t)
if c.setupStore != nil {
c.setupStore(sm)
}
srv := setupService{store: sm}
err := srv.createUser(context.Background(), c.hostUsername, c.hostPassword)
if c.expectedErr == "" {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, c.expectedErr)
}
})
}
}
type storeMock struct {
mock.Mock
}
func (m *storeMock) FindUserList(ctx context.Context, find *api.UserFind) ([]*api.User, error) {
ret := m.Called(ctx, find)
var u []*api.User
ret1 := ret.Get(0)
if ret1 != nil {
u = ret1.([]*api.User)
}
return u, ret.Error(1)
}
func (m *storeMock) CreateUser(ctx context.Context, create *api.UserCreate) (*api.User, error) {
ret := m.Called(ctx, create)
var u *api.User
ret1 := ret.Get(0)
if ret1 != nil {
u = ret1.(*api.User)
}
return u, ret.Error(1)
}
func newStoreMock(t *testing.T) *storeMock {
m := &storeMock{}
m.Mock.Test(t)
t.Cleanup(func() { m.AssertExpectations(t) })
return m
}
+1 -1
View File
@@ -79,7 +79,7 @@ func createActivity(ctx context.Context, tx *sql.Tx, create *api.ActivityCreate)
&activityRaw.Type,
&activityRaw.Level,
&activityRaw.Payload,
&activityRaw.CreatedTs,
&activityRaw.CreatorID,
&activityRaw.CreatedTs,
); err != nil {
return nil, FormatError(err)
+4 -1
View File
@@ -76,7 +76,10 @@ CREATE TABLE resource (
blob BLOB DEFAULT NULL,
external_link TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0
size INTEGER NOT NULL DEFAULT 0,
internal_path TEXT NOT NULL DEFAULT '',
public_id TEXT NOT NULL DEFAULT '',
UNIQUE(id, public_id)
);
-- memo_resource
@@ -0,0 +1,6 @@
UPDATE
user_setting
SET
key = 'memo-visibility'
WHERE
key = 'memoVisibility';
@@ -0,0 +1,69 @@
UPDATE
system_setting
SET
name = 'server-id'
WHERE
name = 'serverId';
UPDATE
system_setting
SET
name = 'secret-session'
WHERE
name = 'secretSessionName';
UPDATE
system_setting
SET
name = 'allow-signup'
WHERE
name = 'allowSignUp';
UPDATE
system_setting
SET
name = 'disable-public-memos'
WHERE
name = 'disablePublicMemos';
UPDATE
system_setting
SET
name = 'additional-style'
WHERE
name = 'additionalStyle';
UPDATE
system_setting
SET
name = 'additional-script'
WHERE
name = 'additionalScript';
UPDATE
system_setting
SET
name = 'customized-profile'
WHERE
name = 'customizedProfile';
UPDATE
system_setting
SET
name = 'storage-service-id'
WHERE
name = 'storageServiceId';
UPDATE
system_setting
SET
name = 'local-storage-path'
WHERE
name = 'localStoragePath';
UPDATE
system_setting
SET
name = 'openai-config'
WHERE
name = 'openAIConfig';
@@ -0,0 +1,4 @@
ALTER TABLE
resource
ADD
COLUMN internal_path TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,19 @@
ALTER TABLE
resource
ADD
COLUMN public_id TEXT NOT NULL DEFAULT '';
-- TODO(steven): remove this in next release.
CREATE UNIQUE INDEX resource_id_public_id_unique_index ON resource (id, public_id);
UPDATE
resource
SET
public_id = printf (
'%s-%s-%s-%s-%s',
lower(hex(randomblob(4))),
lower(hex(randomblob(2))),
lower(hex(randomblob(2))),
lower(hex(randomblob(2))),
lower(hex(randomblob(6)))
);
@@ -0,0 +1,7 @@
INSERT
OR IGNORE INTO system_setting(name, value)
VALUES
(
'local-storage-path',
'"assets/{timestamp}_{filename}"'
);
+4 -1
View File
@@ -76,7 +76,10 @@ CREATE TABLE resource (
blob BLOB DEFAULT NULL,
external_link TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0
size INTEGER NOT NULL DEFAULT 0,
internal_path TEXT NOT NULL DEFAULT '',
public_id TEXT NOT NULL DEFAULT '',
UNIQUE(id, public_id)
);
-- memo_resource
+1 -1
View File
@@ -54,7 +54,7 @@ func (db *DB) UpsertMigrationHistory(ctx context.Context, upsert *MigrationHisto
}
func findMigrationHistoryList(ctx context.Context, tx *sql.Tx, find *MigrationHistoryFind) ([]*MigrationHistory, error) {
where, args := []string{"1 = 1"}, []interface{}{}
where, args := []string{"1 = 1"}, []any{}
if v := find.Version; v != nil {
where, args = append(where, "version = ?"), append(args, *v)
+4 -1
View File
@@ -36,7 +36,10 @@ INSERT INTO
VALUES
(
1003,
"**[star-history.com](https://star-history.com/)**: The missing GitHub star history graph of GitHub repos.
"**[SQL Chat](https://www.sqlchat.ai)**: Chat-based SQL Client
![](https://www.sqlchat.ai/chat-logo-and-text.webp)
**[star-history.com](https://star-history.com)**: The missing GitHub star history graph of GitHub repos.
![](https://api.star-history.com/svg?repos=usememos/memos&type=Date)",
101,
'PUBLIC'
+1 -1
View File
@@ -1,4 +1,4 @@
INSERT INTO
system_setting (`name`, `value`, `description`)
VALUES
('allowSignUp', 'true', '');
('allow-signup', 'true', '');
+7 -3
View File
@@ -157,7 +157,7 @@ func (s *Store) UpdateIdentityProvider(ctx context.Context, update *UpdateIdenti
}
defer tx.Rollback()
set, args := []string{}, []interface{}{}
set, args := []string{}, []any{}
if v := update.Name; v != nil {
set, args = append(set, "name = ?"), append(args, *v)
}
@@ -220,7 +220,7 @@ func (s *Store) DeleteIdentityProvider(ctx context.Context, delete *DeleteIdenti
}
defer tx.Rollback()
where, args := []string{"id = ?"}, []interface{}{delete.ID}
where, args := []string{"id = ?"}, []any{delete.ID}
stmt := `DELETE FROM idp WHERE ` + strings.Join(where, " AND ")
result, err := tx.ExecContext(ctx, stmt, args...)
if err != nil {
@@ -242,7 +242,7 @@ func (s *Store) DeleteIdentityProvider(ctx context.Context, delete *DeleteIdenti
}
func listIdentityProviders(ctx context.Context, tx *sql.Tx, find *FindIdentityProviderMessage) ([]*IdentityProviderMessage, error) {
where, args := []string{"TRUE"}, []interface{}{}
where, args := []string{"TRUE"}, []any{}
if v := find.ID; v != nil {
where, args = append(where, fmt.Sprintf("id = $%d", len(args)+1)), append(args, *v)
}
@@ -290,5 +290,9 @@ func listIdentityProviders(ctx context.Context, tx *sql.Tx, find *FindIdentityPr
identityProviderMessages = append(identityProviderMessages, &identityProviderMessage)
}
if err := rows.Err(); err != nil {
return nil, FormatError(err)
}
return identityProviderMessages, nil
}
+7 -7
View File
@@ -193,7 +193,7 @@ func (s *Store) DeleteMemo(ctx context.Context, delete *api.MemoDelete) error {
func createMemoRaw(ctx context.Context, tx *sql.Tx, create *api.MemoCreate) (*memoRaw, error) {
set := []string{"creator_id", "content", "visibility"}
args := []interface{}{create.CreatorID, create.Content, create.Visibility}
args := []any{create.CreatorID, create.Content, create.Visibility}
placeholder := []string{"?", "?", "?"}
if v := create.CreatedTs; v != nil {
@@ -224,7 +224,7 @@ func createMemoRaw(ctx context.Context, tx *sql.Tx, create *api.MemoCreate) (*me
}
func patchMemoRaw(ctx context.Context, tx *sql.Tx, patch *api.MemoPatch) (*memoRaw, error) {
set, args := []string{}, []interface{}{}
set, args := []string{}, []any{}
if v := patch.CreatedTs; v != nil {
set, args = append(set, "created_ts = ?"), append(args, *v)
@@ -267,7 +267,7 @@ func patchMemoRaw(ctx context.Context, tx *sql.Tx, patch *api.MemoPatch) (*memoR
}
func findMemoRawList(ctx context.Context, tx *sql.Tx, find *api.MemoFind) ([]*memoRaw, error) {
where, args := []string{"1 = 1"}, []interface{}{}
where, args := []string{"1 = 1"}, []any{}
if v := find.ID; v != nil {
where, args = append(where, "memo.id = ?"), append(args, *v)
@@ -302,11 +302,11 @@ func findMemoRawList(ctx context.Context, tx *sql.Tx, find *api.MemoFind) ([]*me
memo.row_status,
memo.content,
memo.visibility,
memo_organizer.pinned
IFNULL(memo_organizer.pinned, 0) AS pinned
FROM memo
LEFT JOIN memo_organizer ON memo_organizer.memo_id = memo.id
LEFT JOIN memo_organizer ON memo_organizer.memo_id = memo.id AND memo_organizer.user_id = memo.creator_id
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY memo_organizer.pinned DESC, memo.created_ts DESC
ORDER BY pinned DESC, memo.created_ts DESC
`
if find.Limit != nil {
query = fmt.Sprintf("%s LIMIT %d", query, *find.Limit)
@@ -352,7 +352,7 @@ func findMemoRawList(ctx context.Context, tx *sql.Tx, find *api.MemoFind) ([]*me
}
func deleteMemo(ctx context.Context, tx *sql.Tx, delete *api.MemoDelete) error {
where, args := []string{"id = ?"}, []interface{}{delete.ID}
where, args := []string{"id = ?"}, []any{delete.ID}
stmt := `DELETE FROM memo WHERE ` + strings.Join(where, " AND ")
result, err := tx.ExecContext(ctx, stmt, args...)
+1 -1
View File
@@ -148,7 +148,7 @@ func upsertMemoOrganizer(ctx context.Context, tx *sql.Tx, upsert *api.MemoOrgani
}
func deleteMemoOrganizer(ctx context.Context, tx *sql.Tx, delete *api.MemoOrganizerDelete) error {
where, args := []string{}, []interface{}{}
where, args := []string{}, []any{}
if v := delete.MemoID; v != nil {
where, args = append(where, "memo_id = ?"), append(args, *v)
+3 -3
View File
@@ -108,7 +108,7 @@ func (s *Store) DeleteMemoResource(ctx context.Context, delete *api.MemoResource
}
func findMemoResourceList(ctx context.Context, tx *sql.Tx, find *api.MemoResourceFind) ([]*memoResourceRaw, error) {
where, args := []string{"1 = 1"}, []interface{}{}
where, args := []string{"1 = 1"}, []any{}
if v := find.MemoID; v != nil {
where, args = append(where, "memo_id = ?"), append(args, *v)
@@ -157,7 +157,7 @@ func findMemoResourceList(ctx context.Context, tx *sql.Tx, find *api.MemoResourc
func upsertMemoResource(ctx context.Context, tx *sql.Tx, upsert *api.MemoResourceUpsert) (*memoResourceRaw, error) {
set := []string{"memo_id", "resource_id"}
args := []interface{}{upsert.MemoID, upsert.ResourceID}
args := []any{upsert.MemoID, upsert.ResourceID}
placeholder := []string{"?", "?"}
if v := upsert.UpdatedTs; v != nil {
@@ -188,7 +188,7 @@ func upsertMemoResource(ctx context.Context, tx *sql.Tx, upsert *api.MemoResourc
}
func deleteMemoResource(ctx context.Context, tx *sql.Tx, delete *api.MemoResourceDelete) error {
where, args := []string{}, []interface{}{}
where, args := []string{}, []any{}
if v := delete.MemoID; v != nil {
where, args = append(where, "memo_id = ?"), append(args, *v)
+74 -51
View File
@@ -22,11 +22,14 @@ type resourceRaw struct {
UpdatedTs int64
// Domain specific fields
Filename string
Blob []byte
ExternalLink string
Type string
Size int64
Filename string
Blob []byte
InternalPath string
ExternalLink string
Type string
Size int64
PublicID string
LinkedMemoAmount int
}
func (raw *resourceRaw) toResource() *api.Resource {
@@ -39,11 +42,14 @@ func (raw *resourceRaw) toResource() *api.Resource {
UpdatedTs: raw.UpdatedTs,
// Domain specific fields
Filename: raw.Filename,
Blob: raw.Blob,
ExternalLink: raw.ExternalLink,
Type: raw.Type,
Size: raw.Size,
Filename: raw.Filename,
Blob: raw.Blob,
InternalPath: raw.InternalPath,
ExternalLink: raw.ExternalLink,
Type: raw.Type,
Size: raw.Size,
PublicID: raw.PublicID,
LinkedMemoAmount: raw.LinkedMemoAmount,
}
}
@@ -88,7 +94,7 @@ func (s *Store) CreateResource(ctx context.Context, create *api.ResourceCreate)
}
defer tx.Rollback()
resourceRaw, err := createResource(ctx, tx, create)
resourceRaw, err := createResourceImpl(ctx, tx, create)
if err != nil {
return nil, err
}
@@ -109,7 +115,7 @@ func (s *Store) FindResourceList(ctx context.Context, find *api.ResourceFind) ([
}
defer tx.Rollback()
resourceRawList, err := findResourceList(ctx, tx, find)
resourceRawList, err := findResourceListImpl(ctx, tx, find)
if err != nil {
return nil, err
}
@@ -129,7 +135,7 @@ func (s *Store) FindResource(ctx context.Context, find *api.ResourceFind) (*api.
}
defer tx.Rollback()
list, err := findResourceList(ctx, tx, find)
list, err := findResourceListImpl(ctx, tx, find)
if err != nil {
return nil, err
}
@@ -172,7 +178,7 @@ func (s *Store) PatchResource(ctx context.Context, patch *api.ResourcePatch) (*a
}
defer tx.Rollback()
resourceRaw, err := patchResource(ctx, tx, patch)
resourceRaw, err := patchResourceImpl(ctx, tx, patch)
if err != nil {
return nil, err
}
@@ -186,21 +192,19 @@ func (s *Store) PatchResource(ctx context.Context, patch *api.ResourcePatch) (*a
return resource, nil
}
func createResource(ctx context.Context, tx *sql.Tx, create *api.ResourceCreate) (*resourceRaw, error) {
func createResourceImpl(ctx context.Context, tx *sql.Tx, create *api.ResourceCreate) (*resourceRaw, error) {
fields := []string{"filename", "blob", "external_link", "type", "size", "creator_id", "internal_path", "public_id"}
values := []any{create.Filename, create.Blob, create.ExternalLink, create.Type, create.Size, create.CreatorID, create.InternalPath, create.PublicID}
placeholders := []string{"?", "?", "?", "?", "?", "?", "?", "?"}
query := `
INSERT INTO resource (
filename,
blob,
external_link,
type,
size,
creator_id
` + strings.Join(fields, ",") + `
)
VALUES (?, ?, ?, ?, ?, ?)
RETURNING id, filename, blob, external_link, type, size, creator_id, created_ts, updated_ts
VALUES (` + strings.Join(placeholders, ",") + `)
RETURNING id, ` + strings.Join(fields, ",") + `, created_ts, updated_ts
`
var resourceRaw resourceRaw
if err := tx.QueryRowContext(ctx, query, create.Filename, create.Blob, create.ExternalLink, create.Type, create.Size, create.CreatorID).Scan(
dests := []any{
&resourceRaw.ID,
&resourceRaw.Filename,
&resourceRaw.Blob,
@@ -208,17 +212,19 @@ func createResource(ctx context.Context, tx *sql.Tx, create *api.ResourceCreate)
&resourceRaw.Type,
&resourceRaw.Size,
&resourceRaw.CreatorID,
&resourceRaw.CreatedTs,
&resourceRaw.UpdatedTs,
); err != nil {
&resourceRaw.InternalPath,
&resourceRaw.PublicID,
}
dests = append(dests, []any{&resourceRaw.CreatedTs, &resourceRaw.UpdatedTs}...)
if err := tx.QueryRowContext(ctx, query, values...).Scan(dests...); err != nil {
return nil, FormatError(err)
}
return &resourceRaw, nil
}
func patchResource(ctx context.Context, tx *sql.Tx, patch *api.ResourcePatch) (*resourceRaw, error) {
set, args := []string{}, []interface{}{}
func patchResourceImpl(ctx context.Context, tx *sql.Tx, patch *api.ResourcePatch) (*resourceRaw, error) {
set, args := []string{}, []any{}
if v := patch.UpdatedTs; v != nil {
set, args = append(set, "updated_ts = ?"), append(args, *v)
@@ -226,61 +232,78 @@ func patchResource(ctx context.Context, tx *sql.Tx, patch *api.ResourcePatch) (*
if v := patch.Filename; v != nil {
set, args = append(set, "filename = ?"), append(args, *v)
}
if v := patch.PublicID; v != nil {
set, args = append(set, "public_id = ?"), append(args, *v)
}
args = append(args, patch.ID)
fields := []string{"id", "filename", "external_link", "type", "size", "creator_id", "created_ts", "updated_ts", "internal_path", "public_id"}
query := `
UPDATE resource
SET ` + strings.Join(set, ", ") + `
WHERE id = ?
RETURNING id, filename, blob, external_link, type, size, creator_id, created_ts, updated_ts
`
RETURNING ` + strings.Join(fields, ", ")
var resourceRaw resourceRaw
if err := tx.QueryRowContext(ctx, query, args...).Scan(
dests := []any{
&resourceRaw.ID,
&resourceRaw.Filename,
&resourceRaw.Blob,
&resourceRaw.ExternalLink,
&resourceRaw.Type,
&resourceRaw.Size,
&resourceRaw.CreatorID,
&resourceRaw.CreatedTs,
&resourceRaw.UpdatedTs,
); err != nil {
&resourceRaw.InternalPath,
&resourceRaw.PublicID,
}
if err := tx.QueryRowContext(ctx, query, args...).Scan(dests...); err != nil {
return nil, FormatError(err)
}
return &resourceRaw, nil
}
func findResourceList(ctx context.Context, tx *sql.Tx, find *api.ResourceFind) ([]*resourceRaw, error) {
where, args := []string{"1 = 1"}, []interface{}{}
func findResourceListImpl(ctx context.Context, tx *sql.Tx, find *api.ResourceFind) ([]*resourceRaw, error) {
where, args := []string{"1 = 1"}, []any{}
if v := find.ID; v != nil {
where, args = append(where, "id = ?"), append(args, *v)
where, args = append(where, "resource.id = ?"), append(args, *v)
}
if v := find.CreatorID; v != nil {
where, args = append(where, "creator_id = ?"), append(args, *v)
where, args = append(where, "resource.creator_id = ?"), append(args, *v)
}
if v := find.Filename; v != nil {
where, args = append(where, "filename = ?"), append(args, *v)
where, args = append(where, "resource.filename = ?"), append(args, *v)
}
if v := find.MemoID; v != nil {
where, args = append(where, "id in (SELECT resource_id FROM memo_resource WHERE memo_id = ?)"), append(args, *v)
where, args = append(where, "resource.id in (SELECT resource_id FROM memo_resource WHERE memo_id = ?)"), append(args, *v)
}
if v := find.PublicID; v != nil {
where, args = append(where, "resource.public_id = ?"), append(args, *v)
}
fields := []string{"id", "filename", "external_link", "type", "size", "creator_id", "created_ts", "updated_ts"}
fields := []string{"resource.id", "resource.filename", "resource.external_link", "resource.type", "resource.size", "resource.creator_id", "resource.created_ts", "resource.updated_ts", "internal_path", "public_id"}
if find.GetBlob {
fields = append(fields, "blob")
fields = append(fields, "resource.blob")
}
query := fmt.Sprintf(`
SELECT
COUNT(DISTINCT memo_resource.memo_id) AS linked_memo_amount,
%s
FROM resource
LEFT JOIN memo_resource ON resource.id = memo_resource.resource_id
WHERE %s
ORDER BY id DESC
GROUP BY resource.id
ORDER BY resource.id DESC
`, strings.Join(fields, ", "), strings.Join(where, " AND "))
if find.Limit != nil {
query = fmt.Sprintf("%s LIMIT %d", query, *find.Limit)
if find.Offset != nil {
query = fmt.Sprintf("%s OFFSET %d", query, *find.Offset)
}
}
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, FormatError(err)
@@ -290,7 +313,8 @@ func findResourceList(ctx context.Context, tx *sql.Tx, find *api.ResourceFind) (
resourceRawList := make([]*resourceRaw, 0)
for rows.Next() {
var resourceRaw resourceRaw
dest := []interface{}{
dests := []any{
&resourceRaw.LinkedMemoAmount,
&resourceRaw.ID,
&resourceRaw.Filename,
&resourceRaw.ExternalLink,
@@ -299,16 +323,15 @@ func findResourceList(ctx context.Context, tx *sql.Tx, find *api.ResourceFind) (
&resourceRaw.CreatorID,
&resourceRaw.CreatedTs,
&resourceRaw.UpdatedTs,
&resourceRaw.InternalPath,
&resourceRaw.PublicID,
}
if find.GetBlob {
dest = append(dest, &resourceRaw.Blob)
dests = append(dests, &resourceRaw.Blob)
}
if err := rows.Scan(
dest...,
); err != nil {
if err := rows.Scan(dests...); err != nil {
return nil, FormatError(err)
}
resourceRawList = append(resourceRawList, &resourceRaw)
}
@@ -320,7 +343,7 @@ func findResourceList(ctx context.Context, tx *sql.Tx, find *api.ResourceFind) (
}
func deleteResource(ctx context.Context, tx *sql.Tx, delete *api.ResourceDelete) error {
where, args := []string{"id = ?"}, []interface{}{delete.ID}
where, args := []string{"id = ?"}, []any{delete.ID}
stmt := `DELETE FROM resource WHERE ` + strings.Join(where, " AND ")
result, err := tx.ExecContext(ctx, stmt, args...)
+3 -3
View File
@@ -180,7 +180,7 @@ func createShortcut(ctx context.Context, tx *sql.Tx, create *api.ShortcutCreate)
}
func patchShortcut(ctx context.Context, tx *sql.Tx, patch *api.ShortcutPatch) (*shortcutRaw, error) {
set, args := []string{}, []interface{}{}
set, args := []string{}, []any{}
if v := patch.UpdatedTs; v != nil {
set, args = append(set, "updated_ts = ?"), append(args, *v)
@@ -220,7 +220,7 @@ func patchShortcut(ctx context.Context, tx *sql.Tx, patch *api.ShortcutPatch) (*
}
func findShortcutList(ctx context.Context, tx *sql.Tx, find *api.ShortcutFind) ([]*shortcutRaw, error) {
where, args := []string{"1 = 1"}, []interface{}{}
where, args := []string{"1 = 1"}, []any{}
if v := find.ID; v != nil {
where, args = append(where, "id = ?"), append(args, *v)
@@ -277,7 +277,7 @@ func findShortcutList(ctx context.Context, tx *sql.Tx, find *api.ShortcutFind) (
}
func deleteShortcut(ctx context.Context, tx *sql.Tx, delete *api.ShortcutDelete) error {
where, args := []string{}, []interface{}{}
where, args := []string{}, []any{}
if v := delete.ID; v != nil {
where, args = append(where, "id = ?"), append(args, *v)
+4 -4
View File
@@ -125,7 +125,7 @@ func (s *Store) DeleteStorage(ctx context.Context, delete *api.StorageDelete) er
func createStorageRaw(ctx context.Context, tx *sql.Tx, create *api.StorageCreate) (*storageRaw, error) {
set := []string{"name", "type", "config"}
args := []interface{}{create.Name, create.Type}
args := []any{create.Name, create.Type}
placeholder := []string{"?", "?", "?"}
var configBytes []byte
@@ -162,7 +162,7 @@ func createStorageRaw(ctx context.Context, tx *sql.Tx, create *api.StorageCreate
}
func patchStorageRaw(ctx context.Context, tx *sql.Tx, patch *api.StoragePatch) (*storageRaw, error) {
set, args := []string{}, []interface{}{}
set, args := []string{}, []any{}
if v := patch.Name; v != nil {
set, args = append(set, "name = ?"), append(args, *v)
}
@@ -213,7 +213,7 @@ func patchStorageRaw(ctx context.Context, tx *sql.Tx, patch *api.StoragePatch) (
}
func findStorageRawList(ctx context.Context, tx *sql.Tx, find *api.StorageFind) ([]*storageRaw, error) {
where, args := []string{"1 = 1"}, []interface{}{}
where, args := []string{"1 = 1"}, []any{}
if v := find.ID; v != nil {
where, args = append(where, "id = ?"), append(args, *v)
@@ -269,7 +269,7 @@ func findStorageRawList(ctx context.Context, tx *sql.Tx, find *api.StorageFind)
}
func deleteStorage(ctx context.Context, tx *sql.Tx, delete *api.StorageDelete) error {
where, args := []string{"id = ?"}, []interface{}{delete.ID}
where, args := []string{"id = ?"}, []any{delete.ID}
stmt := `DELETE FROM storage WHERE ` + strings.Join(where, " AND ")
result, err := tx.ExecContext(ctx, stmt, args...)
+1 -1
View File
@@ -113,7 +113,7 @@ func upsertSystemSetting(ctx context.Context, tx *sql.Tx, upsert *api.SystemSett
}
func findSystemSettingList(ctx context.Context, tx *sql.Tx, find *api.SystemSettingFind) ([]*systemSettingRaw, error) {
where, args := []string{"1 = 1"}, []interface{}{}
where, args := []string{"1 = 1"}, []any{}
if find.Name.String() != "" {
where, args = append(where, "name = ?"), append(args, find.Name.String())
}
+2 -2
View File
@@ -104,7 +104,7 @@ func upsertTag(ctx context.Context, tx *sql.Tx, upsert *api.TagUpsert) (*tagRaw,
}
func findTagList(ctx context.Context, tx *sql.Tx, find *api.TagFind) ([]*tagRaw, error) {
where, args := []string{"creator_id = ?"}, []interface{}{find.CreatorID}
where, args := []string{"creator_id = ?"}, []any{find.CreatorID}
query := `
SELECT
@@ -141,7 +141,7 @@ func findTagList(ctx context.Context, tx *sql.Tx, find *api.TagFind) ([]*tagRaw,
}
func deleteTag(ctx context.Context, tx *sql.Tx, delete *api.TagDelete) error {
where, args := []string{"name = ?", "creator_id = ?"}, []interface{}{delete.Name, delete.CreatorID}
where, args := []string{"name = ?", "creator_id = ?"}, []any{delete.Name, delete.CreatorID}
stmt := `DELETE FROM tag WHERE ` + strings.Join(where, " AND ")
result, err := tx.ExecContext(ctx, stmt, args...)
+7 -6
View File
@@ -56,10 +56,11 @@ func (s *Store) ComposeMemoCreator(ctx context.Context, memo *api.Memo) error {
return err
}
user.Email = ""
user.OpenID = ""
user.UserSettingList = nil
memo.Creator = user
if user.Nickname != "" {
memo.CreatorName = user.Nickname
} else {
memo.CreatorName = user.Username
}
return nil
}
func (s *Store) CreateUser(ctx context.Context, create *api.UserCreate) (*api.User, error) {
@@ -215,7 +216,7 @@ func createUser(ctx context.Context, tx *sql.Tx, create *api.UserCreate) (*userR
}
func patchUser(ctx context.Context, tx *sql.Tx, patch *api.UserPatch) (*userRaw, error) {
set, args := []string{}, []interface{}{}
set, args := []string{}, []any{}
if v := patch.UpdatedTs; v != nil {
set, args = append(set, "updated_ts = ?"), append(args, *v)
@@ -271,7 +272,7 @@ func patchUser(ctx context.Context, tx *sql.Tx, patch *api.UserPatch) (*userRaw,
}
func findUserList(ctx context.Context, tx *sql.Tx, find *api.UserFind) ([]*userRaw, error) {
where, args := []string{"1 = 1"}, []interface{}{}
where, args := []string{"1 = 1"}, []any{}
if v := find.ID; v != nil {
where, args = append(where, "id = ?"), append(args, *v)
+3 -3
View File
@@ -118,10 +118,10 @@ func upsertUserSetting(ctx context.Context, tx *sql.Tx, upsert *api.UserSettingU
}
func findUserSettingList(ctx context.Context, tx *sql.Tx, find *api.UserSettingFind) ([]*userSettingRaw, error) {
where, args := []string{"1 = 1"}, []interface{}{}
where, args := []string{"1 = 1"}, []any{}
if v := find.Key; v != nil {
where, args = append(where, "key = ?"), append(args, v.String())
if v := find.Key.String(); v != "" {
where, args = append(where, "key = ?"), append(args, v)
}
where, args = append(where, "user_id = ?"), append(args, find.UserID)
+42
View File
@@ -0,0 +1,42 @@
package teststore
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/usememos/memos/api"
)
func TestMemoStore(t *testing.T) {
ctx := context.Background()
store := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, store)
require.NoError(t, err)
memoCreate := &api.MemoCreate{
CreatorID: user.ID,
Content: "test_content",
Visibility: api.Public,
}
memo, err := store.CreateMemo(ctx, memoCreate)
require.NoError(t, err)
require.Equal(t, memoCreate.Content, memo.Content)
memoPatchContent := "test_content_2"
memoPatch := &api.MemoPatch{
ID: memo.ID,
Content: &memoPatchContent,
}
memo, err = store.PatchMemo(ctx, memoPatch)
require.NoError(t, err)
require.Equal(t, memoPatchContent, memo.Content)
memoList, err := store.FindMemoList(ctx, &api.MemoFind{
CreatorID: &user.ID,
})
require.NoError(t, err)
require.Equal(t, 1, len(memoList))
require.Equal(t, memo, memoList[0])
err = store.DeleteMemo(ctx, &api.MemoDelete{
ID: memo.ID,
})
require.NoError(t, err)
}
+25
View File
@@ -0,0 +1,25 @@
package teststore
import (
"context"
"fmt"
"testing"
"github.com/usememos/memos/store"
"github.com/usememos/memos/store/db"
"github.com/usememos/memos/test"
// sqlite3 driver.
_ "github.com/mattn/go-sqlite3"
)
func NewTestingStore(ctx context.Context, t *testing.T) *store.Store {
profile := test.GetTestingProfile(t)
db := db.NewDB(profile)
if err := db.Open(ctx); err != nil {
fmt.Printf("failed to open db, error: %+v\n", err)
}
store := store.New(db.DBInstance, profile)
return store
}
+35
View File
@@ -0,0 +1,35 @@
package teststore
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/usememos/memos/api"
)
func TestSystemSettingStore(t *testing.T) {
ctx := context.Background()
store := NewTestingStore(ctx, t)
_, err := store.UpsertSystemSetting(ctx, &api.SystemSettingUpsert{
Name: api.SystemSettingServerIDName,
Value: "test_server_id",
})
require.NoError(t, err)
_, err = store.UpsertSystemSetting(ctx, &api.SystemSettingUpsert{
Name: api.SystemSettingSecretSessionName,
Value: "test_secret_session_name",
})
require.NoError(t, err)
_, err = store.UpsertSystemSetting(ctx, &api.SystemSettingUpsert{
Name: api.SystemSettingAllowSignUpName,
Value: "true",
})
require.NoError(t, err)
_, err = store.UpsertSystemSetting(ctx, &api.SystemSettingUpsert{
Name: api.SystemSettingLocalStoragePathName,
Value: "/tmp/memos",
})
require.NoError(t, err)
}
+56
View File
@@ -0,0 +1,56 @@
package teststore
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/usememos/memos/api"
"github.com/usememos/memos/store"
"golang.org/x/crypto/bcrypt"
)
func TestUserStore(t *testing.T) {
ctx := context.Background()
store := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, store)
require.NoError(t, err)
users, err := store.FindUserList(ctx, &api.UserFind{})
require.NoError(t, err)
require.Equal(t, 1, len(users))
require.Equal(t, api.Host, users[0].Role)
require.Equal(t, user, users[0])
userPatchNickname := "test_nickname_2"
userPatch := &api.UserPatch{
ID: user.ID,
Nickname: &userPatchNickname,
}
user, err = store.PatchUser(ctx, userPatch)
require.NoError(t, err)
require.Equal(t, userPatchNickname, user.Nickname)
err = store.DeleteUser(ctx, &api.UserDelete{
ID: user.ID,
})
require.NoError(t, err)
users, err = store.FindUserList(ctx, &api.UserFind{})
require.NoError(t, err)
require.Equal(t, 0, len(users))
}
func createTestingHostUser(ctx context.Context, store *store.Store) (*api.User, error) {
userCreate := &api.UserCreate{
Username: "test",
Role: api.Host,
Email: "test@test.com",
Nickname: "test_nickname",
Password: "test_password",
OpenID: "test_open_id",
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(userCreate.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
userCreate.PasswordHash = string(passwordHash)
user, err := store.CreateUser(ctx, userCreate)
return user, err
}
+22
View File
@@ -0,0 +1,22 @@
package test
import (
"fmt"
"testing"
"github.com/usememos/memos/server/profile"
"github.com/usememos/memos/server/version"
)
func GetTestingProfile(t *testing.T) *profile.Profile {
// Get a temporary directory for the test data.
dir := t.TempDir()
mode := "prod"
return &profile.Profile{
Mode: mode,
Port: 8082,
Data: dir,
DSN: fmt.Sprintf("%s/memos_%s.db", dir, mode),
Version: version.GetCurrentVersion(mode),
}
}
+2 -1
View File
@@ -24,7 +24,8 @@
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-explicit-any": ["off"],
"react/react-in-jsx-scope": "off"
"react/react-in-jsx-scope": "off",
"react/jsx-no-target-blank": "off"
},
"settings": {
"react": {
+5 -1
View File
@@ -5,5 +5,9 @@
},
"i18n-ally.localesPaths": [
"src/locales"
]
],
"files.associations": {
"*.less": "postcss"
},
"i18n-ally.keystyle": "nested"
}
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/logo.png" type="image/*" />
<link rel="icon" href="/logo.webp" type="image/*" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f4f4f5" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#27272a" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
+10 -3
View File
@@ -3,31 +3,38 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint --ext .js,.ts,.tsx, src"
"lint": "eslint --ext .js,.ts,.tsx, src",
"lint-fix": "eslint --ext .js,.ts,.tsx, src --fix"
},
"dependencies": {
"@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5",
"@mui/joy": "^5.0.0-alpha.67",
"@radix-ui/react-tooltip": "^1.0.5",
"@reduxjs/toolkit": "^1.8.1",
"axios": "^0.27.2",
"copy-to-clipboard": "^3.3.2",
"dayjs": "^1.11.3",
"highlight.js": "^11.6.0",
"i18next": "^21.9.2",
"i18next-browser-languagedetector": "^7.0.1",
"lodash-es": "^4.17.21",
"lucide-react": "^0.105.0",
"qrcode.react": "^3.1.0",
"qs": "^6.11.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.0",
"react-i18next": "^11.18.6",
"react-redux": "^8.0.1",
"react-router-dom": "^6.4.0",
"react-router-dom": "^6.8.2",
"react-use": "^17.4.0",
"semver": "^7.3.8",
"tailwindcss": "^3.2.4"
"tailwindcss": "^3.2.4",
"zustand": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/line-clamp": "^0.4.2",
"@types/lodash-es": "^4.17.5",
"@types/node": "^18.0.3",
"@types/qs": "^6.9.7",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+2 -2
View File
@@ -4,8 +4,8 @@
"description": "usememos/memos",
"icons": [
{
"src": "/logo.png",
"type": "image/png",
"src": "/logo.webp",
"type": "image/webp",
"sizes": "520x520"
}
],
+6 -10
View File
@@ -1,9 +1,11 @@
import dayjs from "dayjs";
import { useColorScheme } from "@mui/joy";
import { useEffect, Suspense } from "react";
import { Toaster } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { RouterProvider } from "react-router-dom";
import router from "./router";
import { useLocationStore, useGlobalStore } from "./store/module";
import { useGlobalStore } from "./store/module";
import * as storage from "./helpers/storage";
import { getSystemColorScheme } from "./helpers/utils";
import Loading from "./pages/Loading";
@@ -11,17 +13,9 @@ import Loading from "./pages/Loading";
const App = () => {
const { i18n } = useTranslation();
const globalStore = useGlobalStore();
const locationStore = useLocationStore();
const { mode, setMode } = useColorScheme();
const { appearance, locale, systemStatus } = globalStore.state;
useEffect(() => {
locationStore.updateStateWithLocation();
window.onpopstate = () => {
locationStore.updateStateWithLocation();
};
}, []);
useEffect(() => {
const darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleColorSchemeChange = (e: MediaQueryListEvent) => {
@@ -59,12 +53,13 @@ const App = () => {
// dynamic update metadata with customized profile.
document.title = systemStatus.customizedProfile.name;
const link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
link.href = systemStatus.customizedProfile.logoUrl || "/logo.png";
link.href = systemStatus.customizedProfile.logoUrl || "/logo.webp";
}, [systemStatus]);
useEffect(() => {
document.documentElement.setAttribute("lang", locale);
i18n.changeLanguage(locale);
dayjs.locale(locale);
storage.set({
locale: locale,
});
@@ -95,6 +90,7 @@ const App = () => {
return (
<Suspense fallback={<Loading />}>
<RouterProvider router={router} />
<Toaster position="top-right" />
</Suspense>
);
};
+16 -9
View File
@@ -1,9 +1,8 @@
import { useTranslation } from "react-i18next";
import { useGlobalStore } from "../store/module";
import { useGlobalStore } from "@/store/module";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import GitHubBadge from "./GitHubBadge";
import "../less/about-site-dialog.less";
type Props = DialogProps;
@@ -27,22 +26,26 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
<Icon.X />
</button>
</div>
<div className="dialog-content-container">
<div className="flex flex-col justify-start items-start max-w-full w-96">
<p className="text-sm">{customizedProfile.description || "No description"}</p>
<div className="mt-4 flex flex-row text-sm justify-start items-center">
<div className="mt-4 w-full flex flex-row text-sm justify-start items-center">
<div className="flex flex-row justify-start items-center mr-2">
Powered by
<a href="https://usememos.com" className="flex flex-row justify-start items-center mr-1 hover:underline">
<img className="w-6 h-auto" src="/logo.png" alt="" />
<a href="https://usememos.com" target="_blank" className="flex flex-row justify-start items-center mx-1 hover:underline">
<img className="w-6 h-auto rounded-full mr-1" src="/logo.webp" alt="" />
memos
</a>
<span>v{profile.version}</span>
</div>
<GitHubBadge />
</div>
<div className="border-t mt-3 pt-2 text-sm flex flex-row justify-start items-center">
<span className="text-gray-500 mr-2">Other projects:</span>
<a href="https://github.com/boojack/sticky-notes" className="flex items-center underline text-blue-600 hover:opacity-80">
<div className="border-t w-full mt-3 pt-2 text-sm flex flex-row justify-start items-center space-x-2">
<span className="text-gray-500">Other projects:</span>
<a
href="https://github.com/boojack/sticky-notes"
target="_blank"
className="flex items-center underline text-blue-600 hover:opacity-80"
>
<img
className="w-4 h-auto mr-1"
src="https://raw.githubusercontent.com/boojack/sticky-notes/main/public/sticky-notes.ico"
@@ -50,6 +53,10 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
/>
<span>Sticky notes</span>
</a>
<a href="https://star-history.com" target="_blank" className="flex items-center underline text-blue-600 hover:opacity-80">
<img className="w-4 h-auto mr-1" src="https://star-history.com/icon.png" alt="" />
<span>Star history</span>
</a>
</div>
</div>
</>
+4 -4
View File
@@ -15,11 +15,11 @@ const AppearanceSelect: FC<Props> = (props: Props) => {
const { onChange, value, className } = props;
const { t } = useTranslation();
const getPrefixIcon = (apperance: Appearance) => {
const getPrefixIcon = (appearance: Appearance) => {
const className = "w-4 h-auto";
if (apperance === "light") {
if (appearance === "light") {
return <Icon.Sun className={className} />;
} else if (apperance === "dark") {
} else if (appearance === "dark") {
return <Icon.Moon className={className} />;
} else {
return <Icon.Smile className={className} />;
@@ -43,7 +43,7 @@ const AppearanceSelect: FC<Props> = (props: Props) => {
>
{appearanceList.map((item) => (
<Option key={item} value={item} className="whitespace-nowrap">
{t(`setting.apperance-option.${item}`)}
{t(`setting.appearance-option.${item}`)}
</Option>
))}
</Select>
+9 -9
View File
@@ -1,11 +1,11 @@
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useMemoStore } from "../store/module";
import * as utils from "../helpers/utils";
import useToggle from "../hooks/useToggle";
import toastHelper from "./Toast";
import { useMemoStore } from "@/store/module";
import * as utils from "@/helpers/utils";
import useToggle from "@/hooks/useToggle";
import MemoContent from "./MemoContent";
import MemoResources from "./MemoResources";
import "../less/memo.less";
import "@/less/memo.less";
interface Props {
memo: Memo;
@@ -23,7 +23,7 @@ const ArchivedMemo: React.FC<Props> = (props: Props) => {
await memoStore.deleteMemoById(memo.id);
} catch (error: any) {
console.error(error);
toastHelper.error(error.response.data.message);
toast.error(error.response.data.message);
}
} else {
toggleConfirmDeleteBtn();
@@ -37,10 +37,10 @@ const ArchivedMemo: React.FC<Props> = (props: Props) => {
rowStatus: "NORMAL",
});
await memoStore.fetchMemos();
toastHelper.info(t("message.restored-successfully"));
toast(t("message.restored-successfully"));
} catch (error: any) {
console.error(error);
toastHelper.error(error.response.data.message);
toast.error(error.response.data.message);
}
};
@@ -54,7 +54,7 @@ const ArchivedMemo: React.FC<Props> = (props: Props) => {
<div className={`memo-wrapper archived ${"memos-" + memo.id}`} onMouseLeave={handleMouseLeaveMemoWrapper}>
<div className="memo-top-wrapper">
<span className="time-text">
{t("common.archived-at")} {utils.getDateTimeString(memo.updatedTs)}
{t("memo.archived-at")} {utils.getDateTimeString(memo.updatedTs)}
</span>
<div className="btns-container">
<span className="btn-text" onClick={handleRestoreMemoClick}>
+8 -8
View File
@@ -1,12 +1,12 @@
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useMemoStore } from "../store/module";
import useLoading from "../hooks/useLoading";
import { useMemoStore } from "@/store/module";
import useLoading from "@/hooks/useLoading";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import toastHelper from "./Toast";
import ArchivedMemo from "./ArchivedMemo";
import "../less/archived-memo-dialog.less";
import "@/less/archived-memo-dialog.less";
type Props = DialogProps;
@@ -26,7 +26,7 @@ const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
})
.catch((error) => {
console.error(error);
toastHelper.error(error.response.data.message);
toast.error(error.response.data.message);
})
.finally(() => {
loadingState.setFinish();
@@ -36,7 +36,7 @@ const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
return (
<>
<div className="dialog-header-container">
<p className="title-text">{t("archived.archived-memos")}</p>
<p className="title-text">{t("memo.archived-memos")}</p>
<button className="btn close-btn" onClick={destroy}>
<Icon.X className="icon-img" />
</button>
@@ -44,11 +44,11 @@ const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
<div className="dialog-content-container">
{loadingState.isLoading ? (
<div className="tip-text-container">
<p className="tip-text">{t("archived.fetching-data")}</p>
<p className="tip-text">{t("memo.fetching-data")}</p>
</div>
) : archivedMemos.length === 0 ? (
<div className="tip-text-container">
<p className="tip-text">{t("archived.no-archived-memos")}</p>
<p className="tip-text">{t("memo.no-archived-memos")}</p>
</div>
) : (
<div className="archived-memos-container">
+159
View File
@@ -0,0 +1,159 @@
import { Button, Textarea } from "@mui/joy";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import * as api from "@/helpers/api";
import useLoading from "@/hooks/useLoading";
import { marked } from "@/labs/marked";
import { useMessageStore } from "@/store/zustand/message";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import showSettingDialog from "./SettingDialog";
type Props = DialogProps;
const AskAIDialog: React.FC<Props> = (props: Props) => {
const { t } = useTranslation();
const { destroy, hide } = props;
const fetchingState = useLoading(false);
const messageStore = useMessageStore();
const [isEnabled, setIsEnabled] = useState<boolean>(true);
const [isInIME, setIsInIME] = useState(false);
const [question, setQuestion] = useState<string>("");
const messageList = messageStore.messageList;
useEffect(() => {
api.checkOpenAIEnabled().then(({ data }) => {
const { data: enabled } = data;
setIsEnabled(enabled);
});
}, []);
const handleGotoSystemSetting = () => {
showSettingDialog("system");
destroy();
};
const handleQuestionTextareaChange = async (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setQuestion(event.currentTarget.value);
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Enter" && !event.shiftKey && !isInIME) {
event.preventDefault();
handleSendQuestionButtonClick();
}
};
const handleSendQuestionButtonClick = async () => {
if (!question) {
return;
}
fetchingState.setLoading();
setQuestion("");
messageStore.addMessage({
role: "user",
content: question,
});
try {
await fetchChatCompletion();
} catch (error: any) {
console.error(error);
toast.error(error.response.data.error);
}
fetchingState.setFinish();
};
const fetchChatCompletion = async () => {
const messageList = messageStore.getState().messageList;
const {
data: { data: answer },
} = await api.postChatCompletion(messageList);
messageStore.addMessage({
role: "assistant",
content: answer.replace(/^\n\n/, ""),
});
};
return (
<>
<div className="dialog-header-container">
<p className="title-text flex flex-row items-center">
<Icon.Bot className="mr-1 w-5 h-auto opacity-80" />
{t("ask-ai.title")}
</p>
<button className="btn close-btn" onClick={() => hide()}>
<Icon.X />
</button>
</div>
<div className="dialog-content-container !w-112 max-w-full">
{messageList.map((message, index) => (
<div key={index} className="w-full flex flex-col justify-start items-start mt-4 space-y-2">
{message.role === "user" ? (
<div className="w-full flex flex-row justify-end items-start pl-6">
<span className="word-break shadow rounded-lg rounded-tr-none px-3 py-2 opacity-80 bg-gray-100 dark:bg-zinc-700">
{message.content}
</span>
</div>
) : (
<div className="w-full flex flex-row justify-start items-start pr-8 space-x-2">
<Icon.Bot className="mt-2 flex-shrink-0 mr-1 w-6 h-auto opacity-80" />
<div className="memo-content-wrapper !w-auto flex flex-col justify-start items-start shadow rounded-lg rounded-tl-none px-3 py-2 bg-gray-100 dark:bg-zinc-700">
<div className="memo-content-text">{marked(message.content)}</div>
</div>
</div>
)}
</div>
))}
{fetchingState.isLoading && (
<p className="w-full py-2 mt-4 flex flex-row justify-center items-center">
<Icon.Loader className="w-5 h-auto animate-spin" />
</p>
)}
{!isEnabled && (
<div className="w-full flex flex-col justify-center items-center mt-4 space-y-2">
<p>{t("ask-ai.not_enabled")}</p>
<Button onClick={() => handleGotoSystemSetting()}>{t("ask-ai.go-to-settings")}</Button>
</div>
)}
<div className="w-full relative mt-4">
<Textarea
className="w-full"
placeholder={t("ask-ai.placeholder")}
value={question}
minRows={1}
maxRows={5}
onChange={handleQuestionTextareaChange}
onCompositionStart={() => setIsInIME(true)}
onCompositionEnd={() => setIsInIME(false)}
onKeyDown={handleKeyDown}
/>
<Icon.Send
className="cursor-pointer w-7 p-1 h-auto rounded-md bg-gray-100 dark:bg-zinc-800 absolute right-2 bottom-1.5 shadow hover:opacity-80"
onClick={handleSendQuestionButtonClick}
/>
</div>
</div>
</>
);
};
function showAskAIDialog() {
const dialogname = "ask-ai-dialog";
const dialogElement = document.body.querySelector(`div.${dialogname}`);
if (dialogElement) {
dialogElement.classList.remove("showoff");
dialogElement.classList.add("showup");
} else {
generateDialog(
{
className: dialogname,
dialogName: dialogname,
},
AskAIDialog
);
}
}
export default showAskAIDialog;
@@ -1,17 +1,9 @@
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useUserStore } from "../store/module";
import { validate, ValidatorConfig } from "../helpers/validator";
import { useUserStore } from "@/store/module";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import toastHelper from "./Toast";
const validateConfig: ValidatorConfig = {
minLength: 4,
maxLength: 320,
noSpace: true,
noChinese: true,
};
interface Props extends DialogProps {
user: User;
@@ -44,32 +36,26 @@ const ChangeMemberPasswordDialog: React.FC<Props> = (props: Props) => {
const handleSaveBtnClick = async () => {
if (newPassword === "" || newPasswordAgain === "") {
toastHelper.error(t("message.fill-all"));
toast.error(t("message.fill-all"));
return;
}
if (newPassword !== newPasswordAgain) {
toastHelper.error(t("message.new-password-not-match"));
toast.error(t("message.new-password-not-match"));
setNewPasswordAgain("");
return;
}
const passwordValidResult = validate(newPassword, validateConfig);
if (!passwordValidResult.result) {
toastHelper.error(`${t("common.password")} ${t(passwordValidResult.reason as string)}`);
return;
}
try {
await userStore.patchUser({
id: propsUser.id,
password: newPassword,
});
toastHelper.info(t("message.password-changed"));
toast(t("message.password-changed"));
handleCloseBtnClick();
} catch (error: any) {
console.error(error);
toastHelper.error(error.response.data.message);
toast.error(error.response.data.message);
}
};
@@ -84,19 +70,19 @@ const ChangeMemberPasswordDialog: React.FC<Props> = (props: Props) => {
</button>
</div>
<div className="dialog-content-container">
<p className="text-sm mb-1">{t("common.new-password")}</p>
<p className="text-sm mb-1">{t("auth.new-password")}</p>
<input
type="password"
className="input-text"
placeholder={t("common.repeat-new-password")}
placeholder={t("auth.repeat-new-password")}
value={newPassword}
onChange={handleNewPasswordChanged}
/>
<p className="text-sm mb-1 mt-2">{t("common.repeat-new-password")}</p>
<p className="text-sm mb-1 mt-2">{t("auth.repeat-new-password")}</p>
<input
type="password"
className="input-text"
placeholder={t("common.repeat-new-password")}
placeholder={t("auth.repeat-new-password")}
value={newPasswordAgain}
onChange={handleNewPasswordAgainChanged}
/>
@@ -1,11 +1,10 @@
import dayjs from "dayjs";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useMemoStore } from "../store/module";
import { useMemoStore } from "@/store/module";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import toastHelper from "./Toast";
import "../less/change-memo-created-ts-dialog.less";
interface Props extends DialogProps {
memoId: MemoId;
@@ -24,7 +23,7 @@ const ChangeMemoCreatedTsDialog: React.FC<Props> = (props: Props) => {
const datetime = dayjs(memo.createdTs).format("YYYY-MM-DDTHH:mm");
setCreatedAt(datetime);
} else {
toastHelper.error(t("message.memo-not-found"));
toast.error(t("message.memo-not-found"));
destroy();
}
});
@@ -44,7 +43,7 @@ const ChangeMemoCreatedTsDialog: React.FC<Props> = (props: Props) => {
const createdTs = dayjs(createdAt).unix();
if (createdTs > nowTs) {
toastHelper.error(t("message.invalid-created-datetime"));
toast.error(t("message.invalid-created-datetime"));
return;
}
@@ -53,11 +52,11 @@ const ChangeMemoCreatedTsDialog: React.FC<Props> = (props: Props) => {
id: memoId,
createdTs,
});
toastHelper.info(t("message.memo-updated-datetime"));
toast.success(t("message.memo-updated-datetime"));
handleCloseBtnClick();
} catch (error: any) {
console.error(error);
toastHelper.error(error.response.data.message);
toast.error(error.response.data.message);
}
};
@@ -69,7 +68,7 @@ const ChangeMemoCreatedTsDialog: React.FC<Props> = (props: Props) => {
<Icon.X />
</button>
</div>
<div className="dialog-content-container">
<div className="flex flex-col justify-start items-start !w-72 max-w-full">
<p className="w-full bg-yellow-100 border border-yellow-400 rounded p-2 text-xs leading-4">
THIS IS NOT A NORMAL BEHAVIOR. PLEASE MAKE SURE YOU REALLY NEED IT.
</p>
@@ -80,7 +79,7 @@ const ChangeMemoCreatedTsDialog: React.FC<Props> = (props: Props) => {
max={maxDatetimeValue}
onChange={handleDatetimeInputChange}
/>
<div className="btns-container">
<div className="flex flex-row justify-end items-center mt-2 w-full">
<span className="btn-text" onClick={handleCloseBtnClick}>
{t("common.cancel")}
</span>
+10 -24
View File
@@ -1,17 +1,9 @@
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useUserStore } from "../store/module";
import { validate, ValidatorConfig } from "../helpers/validator";
import { useUserStore } from "@/store/module";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import toastHelper from "./Toast";
const validateConfig: ValidatorConfig = {
minLength: 4,
maxLength: 320,
noSpace: true,
noChinese: true,
};
type Props = DialogProps;
@@ -41,33 +33,27 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
const handleSaveBtnClick = async () => {
if (newPassword === "" || newPasswordAgain === "") {
toastHelper.error(t("message.fill-all"));
toast.error(t("message.fill-all"));
return;
}
if (newPassword !== newPasswordAgain) {
toastHelper.error(t("message.new-password-not-match"));
toast.error(t("message.new-password-not-match"));
setNewPasswordAgain("");
return;
}
const passwordValidResult = validate(newPassword, validateConfig);
if (!passwordValidResult.result) {
toastHelper.error(`${t("common.password")} ${t(passwordValidResult.reason as string)}`);
return;
}
try {
const user = userStore.getState().user as User;
await userStore.patchUser({
id: user.id,
password: newPassword,
});
toastHelper.info(t("message.password-changed"));
toast.success(t("message.password-changed"));
handleCloseBtnClick();
} catch (error: any) {
console.error(error);
toastHelper.error(error.response.data.message);
toast.error(error.response.data.message);
}
};
@@ -80,21 +66,21 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
</button>
</div>
<div className="dialog-content-container">
<p className="text-sm mb-1">{t("common.new-password")}</p>
<p className="text-sm mb-1">{t("auth.new-password")}</p>
<input
type="password"
autoComplete="new-password"
className="input-text"
placeholder={t("common.repeat-new-password")}
placeholder={t("auth.repeat-new-password")}
value={newPassword}
onChange={handleNewPasswordChanged}
/>
<p className="text-sm mb-1 mt-2">{t("common.repeat-new-password")}</p>
<p className="text-sm mb-1 mt-2">{t("auth.repeat-new-password")}</p>
<input
type="password"
autoComplete="new-password"
className="input-text"
placeholder={t("common.repeat-new-password")}
placeholder={t("auth.repeat-new-password")}
value={newPasswordAgain}
onChange={handleNewPasswordAgainChanged}
/>
@@ -1,10 +1,10 @@
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useResourceStore } from "../store/module";
import { useResourceStore } from "@/store/module";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import toastHelper from "./Toast";
import "../less/change-resource-filename-dialog.less";
import "@/less/change-resource-filename-dialog.less";
interface Props extends DialogProps {
resourceId: ResourceId;
@@ -44,7 +44,7 @@ const ChangeResourceFilenameDialog: React.FC<Props> = (props: Props) => {
return;
}
if (!validateFilename(filename)) {
toastHelper.error(t("message.invalid-resource-filename"));
toast.error(t("message.invalid-resource-filename"));
return;
}
try {
@@ -52,11 +52,11 @@ const ChangeResourceFilenameDialog: React.FC<Props> = (props: Props) => {
id: resourceId,
filename: filename,
});
toastHelper.info(t("message.resource-filename-updated"));
toast.success(t("message.resource-filename-updated"));
handleCloseBtnClick();
} catch (error: any) {
console.error(error);
toastHelper.error(error.response.data.message);
toast.error(error.response.data.message);
}
};
@@ -1,11 +1,11 @@
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { Button, Divider, Input, Radio, RadioGroup, Typography } from "@mui/joy";
import * as api from "../helpers/api";
import { UNKNOWN_ID } from "../helpers/consts";
import { absolutifyLink } from "../helpers/utils";
import * as api from "@/helpers/api";
import { UNKNOWN_ID } from "@/helpers/consts";
import { absolutifyLink } from "@/helpers/utils";
import { generateDialog } from "./Dialog";
import Icon from "./Icon";
import toastHelper from "./Toast";
interface Props extends DialogProps {
identityProvider?: IdentityProvider;
@@ -34,6 +34,27 @@ const templateList: IdentityProvider[] = [
},
},
},
{
id: UNKNOWN_ID,
name: "GitLab",
type: "OAUTH2",
identifierFilter: "",
config: {
oauth2Config: {
clientId: "",
clientSecret: "",
authUrl: "https://gitlab.com/oauth/authorize",
tokenUrl: "https://gitlab.com/oauth/token",
userInfoUrl: "https://gitlab.com/oauth/userinfo",
scopes: ["openid"],
fieldMapping: {
identifier: "name",
displayName: "name",
email: "email",
},
},
},
},
{
id: UNKNOWN_ID,
name: "Google",
@@ -172,7 +193,7 @@ const CreateIdentityProviderDialog: React.FC<Props> = (props: Props) => {
},
},
});
toastHelper.info(`SSO ${basicInfo.name} created`);
toast.success(`SSO ${basicInfo.name} created`);
} else {
await api.patchIdentityProvider({
id: identityProvider?.id,
@@ -185,11 +206,11 @@ const CreateIdentityProviderDialog: React.FC<Props> = (props: Props) => {
},
},
});
toastHelper.info(`SSO ${basicInfo.name} updated`);
toast.success(`SSO ${basicInfo.name} updated`);
}
} catch (error: any) {
console.error(error);
toastHelper.error(error.response.data.message);
toast.error(error.response.data.message);
}
if (confirmCallback) {
confirmCallback();
@@ -275,10 +296,7 @@ const CreateIdentityProviderDialog: React.FC<Props> = (props: Props) => {
{type === "OAUTH2" && (
<>
{isCreating && (
<p className="border rounded-md p-2 text-sm w-full mb-2 break-all">
Redirect URL: {absolutifyLink("/auth/callback")}{" "}
RedirectRedirectRedirectRedirectRedirectRedirectRedirectRedirectRedirectRedirectRedirect
</p>
<p className="border rounded-md p-2 text-sm w-full mb-2 break-all">Redirect URL: {absolutifyLink("/auth/callback")}</p>
)}
<Typography className="!mb-1" level="body2">
Client ID<span className="text-red-600">*</span>

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