Compare commits

...

69 Commits

Author SHA1 Message Date
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
203 changed files with 6728 additions and 4872 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,18 @@
<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)
## Key points
@@ -40,7 +41,7 @@ Contributions are what make the open-source community such an amazing place to l
<img src="https://contrib.rocks/image?repo=usememos/memos" />
</a>
Here are some products made by our community:
---
- [Moe Memos](https://memos.moe/) - Third party client for iOS and Android
- [lmm214/memos-bber](https://github.com/lmm214/memos-bber) - Chrome extension

View File

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

View File

@@ -9,12 +9,13 @@ type Resource struct {
UpdatedTs int64 `json:"updatedTs"`
// Domain specific fields
Filename string `json:"filename"`
Blob []byte `json:"-"`
ExternalLink string `json:"externalLink"`
Type string `json:"type"`
Size int64 `json:"size"`
Visibility Visibility `json:"visibility"`
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"`
@@ -25,12 +26,13 @@ type ResourceCreate struct {
CreatorID int `json:"-"`
// Domain specific fields
Filename string `json:"filename"`
Blob []byte `json:"-"`
ExternalLink string `json:"externalLink"`
Type string `json:"type"`
Size int64 `json:"-"`
Visibility Visibility `json:"visibility"`
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 {
@@ -42,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 {
@@ -52,8 +59,9 @@ type ResourcePatch struct {
UpdatedTs *int64
// Domain specific fields
Filename *string `json:"filename"`
Visibility *Visibility `json:"visibility"`
Filename *string `json:"filename"`
ResetPublicID *bool `json:"resetPublicId"`
PublicID *string `json:"-"`
}
type ResourceDelete struct {

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 (
@@ -18,6 +25,7 @@ type StorageS3Config struct {
SecretKey string `json:"secretKey"`
Bucket string `json:"bucket"`
URLPrefix string `json:"urlPrefix"`
URLSuffix string `json:"urlSuffix"`
}
type Storage struct {

View File

@@ -18,5 +18,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"`
}

View File

@@ -11,24 +11,26 @@ 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"
// SystemSettingDisablePublicMemosName is the key type of disable public memos setting.
SystemSettingDisablePublicMemosName SystemSettingName = "disablePublicMemos"
// SystemSettingAdditionalStyleName is the key type of additional style.
SystemSettingAdditionalStyleName SystemSettingName = "additionalStyle"
// SystemSettingAdditionalScriptName is the key type of additional script.
SystemSettingAdditionalScriptName SystemSettingName = "additionalScript"
// SystemSettingCustomizedProfileName is the key type of customized server profile.
SystemSettingCustomizedProfileName SystemSettingName = "customizedProfile"
// SystemSettingStorageServiceIDName is the key type of storage service ID.
SystemSettingStorageServiceIDName SystemSettingName = "storageServiceId"
// SystemSettingOpenAIConfigName is the key type of OpenAI config.
SystemSettingOpenAIConfigName SystemSettingName = "openAIConfig"
// SystemSettingServerIDName is the name of server id.
SystemSettingServerIDName SystemSettingName = "server-id"
// SystemSettingSecretSessionName is the name of secret session.
SystemSettingSecretSessionName SystemSettingName = "secret-session"
// SystemSettingAllowSignUpName is the name of allow signup setting.
SystemSettingAllowSignUpName SystemSettingName = "allow-signup"
// SystemSettingDisablePublicMemosName is the name of disable public memos setting.
SystemSettingDisablePublicMemosName SystemSettingName = "disable-public-memos"
// 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.
@@ -54,24 +56,26 @@ type OpenAIConfig struct {
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 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 "openAIConfig"
return "openai-config"
}
return ""
}
@@ -90,7 +94,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
@@ -136,12 +140,18 @@ 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)

View File

@@ -15,9 +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"
// UserSettingResourceVisibilityKey is the key type for user preference resource default visibility.
UserSettingResourceVisibilityKey UserSettingKey = "resourceVisibility"
UserSettingMemoVisibilityKey UserSettingKey = "memo-visibility"
)
// String returns the string format of UserSettingKey type.
@@ -28,18 +26,15 @@ func (key UserSettingKey) String() string {
case UserSettingAppearanceKey:
return "appearance"
case UserSettingMemoVisibilityKey:
return "memoVisibility"
case UserSettingResourceVisibilityKey:
return "resourceVisibility"
return "memo-visibility"
}
return ""
}
var (
UserSettingLocaleValue = []string{"en", "zh", "vi", "fr", "nl", "sv", "de", "es", "uk", "ru", "it", "hant", "tr", "ko"}
UserSettingAppearanceValue = []string{"system", "light", "dark"}
UserSettingMemoVisibilityValue = []Visibility{Private, Protected, Public}
UserSettingResourceVisibilityValue = []Visibility{Private, Protected, Public}
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}
)
type UserSetting struct {
@@ -83,15 +78,6 @@ func (upsert UserSettingUpsert) Validate() error {
if !slices.Contains(UserSettingMemoVisibilityValue, memoVisibilityValue) {
return fmt.Errorf("invalid user setting memo visibility value")
}
} else if upsert.Key == UserSettingResourceVisibilityKey {
resourceVisibilityValue := Private
err := json.Unmarshal([]byte(upsert.Value), &resourceVisibilityValue)
if err != nil {
return fmt.Errorf("failed to unmarshal user setting resource visibility value")
}
if !slices.Contains(UserSettingResourceVisibilityValue, resourceVisibilityValue) {
return fmt.Errorf("invalid user setting resource visibility value")
}
} else {
return fmt.Errorf("invalid user setting key")
}

View File

Before

Width:  |  Height:  |  Size: 374 KiB

After

Width:  |  Height:  |  Size: 374 KiB

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

View File

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

View File

@@ -2,7 +2,7 @@
written by [AJ](https://memos.ajstephens.website/) (also a noob)
<img height="64px" src="https://raw.githubusercontent.com/usememos/memos/main/resources/logo-full.webp" alt="✍️ memos" />
<img height="64px" src="https://usememos.com/logo-full.png" alt="✍️ memos" />
[Live Demo](https://demo.usememos.com) • [Official Website](https://usememos.com) • [Source Code](https://github.com/usememos/memos)

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
docs/setup.md Normal file
View File

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

6
go.mod
View File

@@ -10,8 +10,6 @@ require (
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
@@ -44,9 +42,8 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang-jwt/jwt/v4 v4.5.0
github.com/golang/protobuf v1.5.2 // indirect
github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.0.1 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
@@ -62,6 +59,7 @@ require (
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

11
go.sum
View File

@@ -106,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=
@@ -168,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=
@@ -200,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=
@@ -244,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=

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

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

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

View File

@@ -19,12 +19,12 @@ type ChatCompletionChoice struct {
}
type ChatCompletionResponse struct {
Error interface{} `json:"error"`
Error any `json:"error"`
Model string `json:"model"`
Choices []ChatCompletionChoice `json:"choices"`
}
func PostChatCompletion(prompt string, apiKey string, apiHost string) (string, error) {
func PostChatCompletion(messages []ChatCompletionMessage, apiKey string, apiHost string) (string, error) {
if apiHost == "" {
apiHost = "https://api.openai.com"
}
@@ -33,9 +33,13 @@ func PostChatCompletion(prompt string, apiKey string, apiHost string) (string, e
return "", err
}
values := map[string]interface{}{
"model": "gpt-3.5-turbo",
"messages": []map[string]string{{"role": "user", "content": prompt}},
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 {

View File

@@ -14,7 +14,7 @@ type TextCompletionChoice struct {
}
type TextCompletionResponse struct {
Error interface{} `json:"error"`
Error any `json:"error"`
Model string `json:"model"`
Choices []TextCompletionChoice `json:"choices"`
}
@@ -28,7 +28,7 @@ func PostTextCompletion(prompt string, apiKey string, apiHost string) (string, e
return "", err
}
values := map[string]interface{}{
values := map[string]any{
"model": "gpt-3.5-turbo",
"prompt": prompt,
"temperature": 0.5,

View File

@@ -20,6 +20,7 @@ type Config struct {
EndPoint string
Region string
URLPrefix string
URLSuffix string
}
type Client struct {
@@ -28,7 +29,7 @@ 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,
@@ -67,7 +68,7 @@ func (client *Client) UploadFile(ctx context.Context, filename string, fileType
link := uploadOutput.Location
// If url prefix is set, use it as the file link.
if client.Config.URLPrefix != "" {
link = fmt.Sprintf("%s/%s", client.Config.URLPrefix, filename)
link = fmt.Sprintf("%s/%s%s", client.Config.URLPrefix, filename, client.Config.URLSuffix)
}
if link == "" {
return "", fmt.Errorf("failed to get file link")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

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

View File

@@ -17,7 +17,7 @@ import (
"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{}
@@ -44,8 +44,8 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusUnauthorized, "Incorrect login credentials, please try again")
}
if err = setUserSession(c, user); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set signin session").SetInternal(err)
if err := GenerateTokensAndSetCookies(c, user, s.Profile.Mode, 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)
@@ -128,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, s.Profile.Mode, 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)
@@ -196,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, s.Profile.Mode, 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)
})
}

88
server/auth/auth.go Normal file
View File

@@ -0,0 +1,88 @@
package auth
import (
"fmt"
"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"
// AccessTokenAudienceFmt is the format of the acccess token audience.
AccessTokenAudienceFmt = "user.access.%s"
// RefreshTokenAudienceFmt is the format of the refresh token audience.
RefreshTokenAudienceFmt = "user.refresh.%s"
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, mode string, secret string) (string, error) {
expirationTime := time.Now().Add(apiTokenDuration)
return generateToken(userName, userID, fmt.Sprintf(AccessTokenAudienceFmt, mode), expirationTime, []byte(secret))
}
// GenerateAccessToken generates an access token for web.
func GenerateAccessToken(userName string, userID int, mode string, secret string) (string, error) {
expirationTime := time.Now().Add(accessTokenDuration)
return generateToken(userName, userID, fmt.Sprintf(AccessTokenAudienceFmt, mode), expirationTime, []byte(secret))
}
// GenerateRefreshToken generates a refresh token for web.
func GenerateRefreshToken(userName string, userID int, mode string, secret string) (string, error) {
expirationTime := time.Now().Add(refreshTokenDuration)
return generateToken(userName, userID, fmt.Sprintf(RefreshTokenAudienceFmt, mode), 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
}

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

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

260
server/jwt.go Normal file
View File

@@ -0,0 +1,260 @@
package server
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/labstack/echo/v4"
pkgerrors "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, mode string, secret string) error {
accessToken, err := auth.GenerateAccessToken(user.Username, user.ID, mode, secret)
if err != nil {
return pkgerrors.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, mode, secret)
if err != nil {
return pkgerrors.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
mode := server.Profile.Mode
if server.defaultAuthSkipper(c) {
return next(c)
}
// Skip validation for server status endpoints.
if common.HasPrefixes(path, "/api/ping", "/api/status", "/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/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, pkgerrors.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, pkgerrors.Errorf("unexpected access token kid=%v", t.Header["kid"])
})
if !audienceContains(claims.Audience, fmt.Sprintf(auth.AccessTokenAudienceFmt, mode)) {
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,
fmt.Sprintf(auth.AccessTokenAudienceFmt, mode),
))
}
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, pkgerrors.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, pkgerrors.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, fmt.Sprintf(auth.RefreshTokenAudienceFmt, mode)) {
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,
fmt.Sprintf(auth.RefreshTokenAudienceFmt, mode),
))
}
// 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, mode, 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
}

View File

@@ -31,15 +31,15 @@ func (s *Server) registerOpenAIRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, "OpenAI API key not set")
}
completionRequest := api.OpenAICompletionRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(&completionRequest); err != nil {
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 completionRequest.Prompt == "" {
return echo.NewHTTPError(http.StatusBadRequest, "Prompt is required")
if len(messages) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "No messages provided")
}
result, err := openai.PostChatCompletion(completionRequest.Prompt, openAIConfig.Key, openAIConfig.Host)
result, err := openai.PostChatCompletion(messages, openAIConfig.Key, openAIConfig.Host)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to post chat completion").SetInternal(err)
}
@@ -47,42 +47,6 @@ func (s *Server) registerOpenAIRoutes(g *echo.Group) {
return c.JSON(http.StatusOK, composeResponse(result))
})
g.POST("/openai/text-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")
}
textCompletion := api.OpenAICompletionRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(&textCompletion); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post text completion request").SetInternal(err)
}
if textCompletion.Prompt == "" {
return echo.NewHTTPError(http.StatusBadRequest, "Prompt is required")
}
result, err := openai.PostTextCompletion(textCompletion.Prompt, openAIConfig.Key, openAIConfig.Host)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to post text 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{

View File

@@ -7,7 +7,9 @@ import (
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
@@ -17,7 +19,9 @@ import (
"github.com/pkg/errors"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
"github.com/usememos/memos/common/log"
"github.com/usememos/memos/plugin/storage/s3"
"go.uber.org/zap"
)
const (
@@ -45,27 +49,6 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
if resourceCreate.ExternalLink != "" && !strings.HasPrefix(resourceCreate.ExternalLink, "http") {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid external link")
}
if resourceCreate.Visibility == "" {
userResourceVisibilitySetting, err := s.Store.FindUserSetting(ctx, &api.UserSettingFind{
UserID: userID,
Key: api.UserSettingResourceVisibilityKey,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user setting").SetInternal(err)
}
if userResourceVisibilitySetting != nil {
resourceVisibility := api.Private
err := json.Unmarshal([]byte(userResourceVisibilitySetting.Value), &resourceVisibility)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal user setting value").SetInternal(err)
}
resourceCreate.Visibility = resourceVisibility
} else {
// Private is the default resource visibility.
resourceCreate.Visibility = api.Private
}
}
resource, err := s.Store.CreateResource(ctx, resourceCreate)
if err != nil {
@@ -96,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 {
@@ -138,53 +157,26 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
if storage.Type == api.StorageS3 {
s3Config := storage.Config.S3Config
t := time.Now()
var s3FileKey string
if s3Config.Path == "" {
s3FileKey = filename
} else {
s3FileKey = fileKeyPattern.ReplaceAllStringFunc(s3Config.Path, func(s string) string {
switch s {
case "{filename}":
return filename
case "{filetype}":
return filetype
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
})
if !strings.Contains(s3Config.Path, "{filename}") {
s3FileKey = path.Join(s3FileKey, filename)
}
}
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, s3FileKey, 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)
}
@@ -199,28 +191,8 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
}
}
if resourceCreate.Visibility == "" {
userResourceVisibilitySetting, err := s.Store.FindUserSetting(ctx, &api.UserSettingFind{
UserID: userID,
Key: api.UserSettingResourceVisibilityKey,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user setting").SetInternal(err)
}
if userResourceVisibilitySetting != nil {
resourceVisibility := api.Private
err := json.Unmarshal([]byte(userResourceVisibilitySetting.Value), &resourceVisibility)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal user setting value").SetInternal(err)
}
resourceCreate.Visibility = resourceVisibility
} else {
// Private is the default resource visibility.
resourceCreate.Visibility = api.Private
}
}
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)
@@ -240,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)
@@ -334,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 {
@@ -365,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,
}
@@ -379,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)
@@ -399,16 +334,33 @@ func (s *Server) registerResourcePublicRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find resource by ID: %v", resourceID)).SetInternal(err)
}
if resource.ExternalLink != "" {
return c.Redirect(http.StatusSeeOther, resource.ExternalLink)
}
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))
})
}
@@ -434,3 +386,29 @@ func (s *Server) createResourceCreateActivity(c echo.Context, resource *api.Reso
}
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
}

View File

@@ -35,7 +35,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 +72,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)
}
@@ -114,57 +114,27 @@ func generateRSSFromMemoList(memoList []*api.Memo, baseURL string, profile *api.
return rss, nil
}
func getSystemCustomizedProfile(ctx context.Context, s *Server) (api.CustomizedProfile, error) {
systemStatus := api.SystemStatus{
CustomizedProfile: api.CustomizedProfile{
Name: "memos",
LogoURL: "",
Description: "",
Locale: "en",
Appearance: "system",
ExternalURL: "",
},
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{})
systemSetting, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{
Name: api.SystemSettingCustomizedProfileName,
})
if err != nil {
return api.CustomizedProfile{}, err
return customizedProfile, 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)
}
}
err = json.Unmarshal([]byte(systemSetting.Value), customizedProfile)
if err != nil {
return customizedProfile, err
}
return systemStatus.CustomizedProfile, nil
return customizedProfile, nil
}
func min(a, b int) int {

View File

@@ -13,8 +13,6 @@ import (
"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"
)
@@ -81,30 +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)
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)

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)

View File

@@ -51,7 +51,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,11 +60,11 @@ 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 || systemSetting.Name == api.SystemSettingOpenAIConfigName {
if systemSetting.Name == api.SystemSettingServerIDName || systemSetting.Name == api.SystemSettingSecretSessionName || systemSetting.Name == api.SystemSettingOpenAIConfigName {
continue
}
var baseValue interface{}
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)
@@ -86,6 +87,8 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
systemStatus.CustomizedProfile = customizedProfile
} else if systemSetting.Name == api.SystemSettingStorageServiceIDName {
systemStatus.StorageServiceID = int(baseValue.(float64))
} else if systemSetting.Name == api.SystemSettingLocalStoragePathName {
systemStatus.LocalStoragePath = baseValue.(string)
}
}
@@ -191,14 +194,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 {

View File

@@ -9,10 +9,10 @@ import (
// Version is the service current released version.
// Semantic versioning: https://semver.org/
var Version = "0.11.2"
var Version = "0.12.0"
// DevVersion is the service current development version.
var DevVersion = "0.11.2"
var DevVersion = "0.12.0"
func GetCurrentVersion(mode string) string {
if mode == "dev" || mode == "demo" {

90
setup/setup.go Normal file
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
setup/setup_test.go Normal file
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
}

View File

@@ -77,7 +77,9 @@ CREATE TABLE resource (
external_link TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC', 'PROTECTED', 'PRIVATE')) DEFAULT 'PRIVATE'
internal_path TEXT NOT NULL DEFAULT '',
public_id TEXT NOT NULL DEFAULT '',
UNIQUE(id, public_id)
);
-- memo_resource

View File

@@ -0,0 +1,6 @@
UPDATE
user_setting
SET
key = 'memo-visibility'
WHERE
key = 'memoVisibility';

View File

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

View File

@@ -0,0 +1,4 @@
ALTER TABLE
resource
ADD
COLUMN internal_path TEXT NOT NULL DEFAULT '';

View File

@@ -0,0 +1,21 @@
ALTER TABLE
resource
ADD
COLUMN public_id TEXT NOT NULL DEFAULT '';
CREATE UNIQUE INDEX resource_id_public_id_unique_index ON resource (id, public_id);
UPDATE
resource
SET
public_id = (
SELECT
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)))
) as uuid
);

View File

@@ -0,0 +1,7 @@
INSERT
OR IGNORE INTO system_setting(name, value)
VALUES
(
'local-storage-path',
'"assets/{timestamp}_{filename}"'
);

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

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)

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'

View File

@@ -1,4 +1,4 @@
INSERT INTO
system_setting (`name`, `value`, `description`)
VALUES
('allowSignUp', 'true', '');
('allow-signup', 'true', '');

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
}

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

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)

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)

View File

@@ -22,12 +22,14 @@ type resourceRaw struct {
UpdatedTs int64
// Domain specific fields
Filename string
Blob []byte
ExternalLink string
Type string
Size int64
Visibility api.Visibility
Filename string
Blob []byte
InternalPath string
ExternalLink string
Type string
Size int64
PublicID string
LinkedMemoAmount int
}
func (raw *resourceRaw) toResource() *api.Resource {
@@ -40,12 +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,
Visibility: raw.Visibility,
Filename: raw.Filename,
Blob: raw.Blob,
InternalPath: raw.InternalPath,
ExternalLink: raw.ExternalLink,
Type: raw.Type,
Size: raw.Size,
PublicID: raw.PublicID,
LinkedMemoAmount: raw.LinkedMemoAmount,
}
}
@@ -90,7 +94,7 @@ func (s *Store) CreateResource(ctx context.Context, create *api.ResourceCreate)
}
defer tx.Rollback()
resourceRaw, err := s.createResourceImpl(ctx, tx, create)
resourceRaw, err := createResourceImpl(ctx, tx, create)
if err != nil {
return nil, err
}
@@ -111,7 +115,7 @@ func (s *Store) FindResourceList(ctx context.Context, find *api.ResourceFind) ([
}
defer tx.Rollback()
resourceRawList, err := s.findResourceListImpl(ctx, tx, find)
resourceRawList, err := findResourceListImpl(ctx, tx, find)
if err != nil {
return nil, err
}
@@ -131,7 +135,7 @@ func (s *Store) FindResource(ctx context.Context, find *api.ResourceFind) (*api.
}
defer tx.Rollback()
list, err := s.findResourceListImpl(ctx, tx, find)
list, err := findResourceListImpl(ctx, tx, find)
if err != nil {
return nil, err
}
@@ -174,7 +178,7 @@ func (s *Store) PatchResource(ctx context.Context, patch *api.ResourcePatch) (*a
}
defer tx.Rollback()
resourceRaw, err := s.patchResourceImpl(ctx, tx, patch)
resourceRaw, err := patchResourceImpl(ctx, tx, patch)
if err != nil {
return nil, err
}
@@ -188,16 +192,10 @@ func (s *Store) PatchResource(ctx context.Context, patch *api.ResourcePatch) (*a
return resource, nil
}
func (s *Store) createResourceImpl(ctx context.Context, tx *sql.Tx, create *api.ResourceCreate) (*resourceRaw, error) {
fields := []string{"filename", "blob", "external_link", "type", "size", "creator_id"}
values := []interface{}{create.Filename, create.Blob, create.ExternalLink, create.Type, create.Size, create.CreatorID}
placeholders := []string{"?", "?", "?", "?", "?", "?"}
if s.profile.IsDev() {
fields = append(fields, "visibility")
values = append(values, create.Visibility)
placeholders = append(placeholders, "?")
}
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 (
` + strings.Join(fields, ",") + `
@@ -206,7 +204,7 @@ func (s *Store) createResourceImpl(ctx context.Context, tx *sql.Tx, create *api.
RETURNING id, ` + strings.Join(fields, ",") + `, created_ts, updated_ts
`
var resourceRaw resourceRaw
dests := []interface{}{
dests := []any{
&resourceRaw.ID,
&resourceRaw.Filename,
&resourceRaw.Blob,
@@ -214,11 +212,10 @@ func (s *Store) createResourceImpl(ctx context.Context, tx *sql.Tx, create *api.
&resourceRaw.Type,
&resourceRaw.Size,
&resourceRaw.CreatorID,
&resourceRaw.InternalPath,
&resourceRaw.PublicID,
}
if s.profile.IsDev() {
dests = append(dests, &resourceRaw.Visibility)
}
dests = append(dests, []interface{}{&resourceRaw.CreatedTs, &resourceRaw.UpdatedTs}...)
dests = append(dests, []any{&resourceRaw.CreatedTs, &resourceRaw.UpdatedTs}...)
if err := tx.QueryRowContext(ctx, query, values...).Scan(dests...); err != nil {
return nil, FormatError(err)
}
@@ -226,8 +223,8 @@ func (s *Store) createResourceImpl(ctx context.Context, tx *sql.Tx, create *api.
return &resourceRaw, nil
}
func (s *Store) patchResourceImpl(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)
@@ -235,26 +232,19 @@ func (s *Store) patchResourceImpl(ctx context.Context, tx *sql.Tx, patch *api.Re
if v := patch.Filename; v != nil {
set, args = append(set, "filename = ?"), append(args, *v)
}
if s.profile.IsDev() {
if v := patch.Visibility; v != nil {
set, args = append(set, "visibility = ?"), 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"}
if s.profile.IsDev() {
fields = append(fields, "visibility")
}
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 ` + strings.Join(fields, ", ")
var resourceRaw resourceRaw
dests := []interface{}{
dests := []any{
&resourceRaw.ID,
&resourceRaw.Filename,
&resourceRaw.ExternalLink,
@@ -263,9 +253,8 @@ func (s *Store) patchResourceImpl(ctx context.Context, tx *sql.Tx, patch *api.Re
&resourceRaw.CreatorID,
&resourceRaw.CreatedTs,
&resourceRaw.UpdatedTs,
}
if s.profile.IsDev() {
dests = append(dests, &resourceRaw.Visibility)
&resourceRaw.InternalPath,
&resourceRaw.PublicID,
}
if err := tx.QueryRowContext(ctx, query, args...).Scan(dests...); err != nil {
return nil, FormatError(err)
@@ -274,37 +263,47 @@ func (s *Store) patchResourceImpl(ctx context.Context, tx *sql.Tx, patch *api.Re
return &resourceRaw, nil
}
func (s *Store) findResourceListImpl(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")
}
if s.profile.IsDev() {
fields = append(fields, "visibility")
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)
@@ -314,7 +313,8 @@ func (s *Store) findResourceListImpl(ctx context.Context, tx *sql.Tx, find *api.
resourceRawList := make([]*resourceRaw, 0)
for rows.Next() {
var resourceRaw resourceRaw
dests := []interface{}{
dests := []any{
&resourceRaw.LinkedMemoAmount,
&resourceRaw.ID,
&resourceRaw.Filename,
&resourceRaw.ExternalLink,
@@ -323,17 +323,15 @@ func (s *Store) findResourceListImpl(ctx context.Context, tx *sql.Tx, find *api.
&resourceRaw.CreatorID,
&resourceRaw.CreatedTs,
&resourceRaw.UpdatedTs,
&resourceRaw.InternalPath,
&resourceRaw.PublicID,
}
if find.GetBlob {
dests = append(dests, &resourceRaw.Blob)
}
if s.profile.IsDev() {
dests = append(dests, &resourceRaw.Visibility)
}
if err := rows.Scan(dests...); err != nil {
return nil, FormatError(err)
}
resourceRawList = append(resourceRawList, &resourceRaw)
}
@@ -345,7 +343,7 @@ func (s *Store) findResourceListImpl(ctx context.Context, tx *sql.Tx, find *api.
}
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...)

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)

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

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

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

View File

@@ -216,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)
@@ -272,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)

View File

@@ -118,7 +118,7 @@ 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.String(); v != "" {
where, args = append(where, "key = ?"), append(args, v)

42
test/store/memo_test.go Normal file
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
test/store/store.go Normal file
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
}

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
test/store/user_test.go Normal file
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
test/test.go Normal file
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),
}
}

View File

@@ -8,5 +8,6 @@
],
"files.associations": {
"*.less": "postcss"
}
},
"i18n-ally.keystyle": "nested"
}

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

View File

@@ -29,9 +29,11 @@
"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

BIN
web/public/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -4,8 +4,8 @@
"description": "usememos/memos",
"icons": [
{
"src": "/logo.png",
"type": "image/png",
"src": "/logo.webp",
"type": "image/webp",
"sizes": "520x520"
}
],

View File

@@ -1,3 +1,4 @@
import dayjs from "dayjs";
import { useColorScheme } from "@mui/joy";
import { useEffect, Suspense } from "react";
import { Toaster } from "react-hot-toast";
@@ -52,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,
});

View File

@@ -1,5 +1,5 @@
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";
@@ -31,8 +31,8 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
<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" target="_blank" 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>

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>

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 { 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;

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 ArchivedMemo from "./ArchivedMemo";
import "../less/archived-memo-dialog.less";
import "@/less/archived-memo-dialog.less";
type Props = DialogProps;

View File

@@ -1,26 +1,26 @@
import { Button, Textarea } from "@mui/joy";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import * as api from "../helpers/api";
import useLoading from "../hooks/useLoading";
import { marked } from "../labs/marked";
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;
interface History {
question: string;
answer: string;
}
const AskAIDialog: React.FC<Props> = (props: Props) => {
const { t } = useTranslation();
const { destroy, hide } = props;
const fetchingState = useLoading(false);
const [historyList, setHistoryList] = useState<History[]>([]);
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 }) => {
@@ -38,33 +38,42 @@ const AskAIDialog: React.FC<Props> = (props: Props) => {
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 askQuestion(question);
await fetchChatCompletion();
} catch (error: any) {
console.error(error);
toast.error(error.response.data.error);
}
setQuestion("");
fetchingState.setFinish();
};
const askQuestion = async (question: string) => {
if (question === "") {
return;
}
const fetchChatCompletion = async () => {
const messageList = messageStore.getState().messageList;
const {
data: { data: answer },
} = await api.postChatCompletion(question);
setHistoryList([
{
question,
answer: answer.replace(/^\n\n/, ""),
},
...historyList,
]);
} = await api.postChatCompletion(messageList);
messageStore.addMessage({
role: "assistant",
content: answer.replace(/^\n\n/, ""),
});
};
return (
@@ -72,46 +81,59 @@ const AskAIDialog: React.FC<Props> = (props: Props) => {
<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" />
Ask AI
{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">
<div className="w-full relative">
<Textarea className="w-full" placeholder="Ask anything…" value={question} onChange={handleQuestionTextareaChange} />
<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>
{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>
)}
{historyList.map((history, index) => (
<div key={index} className="w-full flex flex-col justify-start items-start mt-4 space-y-2">
<div className="w-full flex flex-row justify-start items-start pr-6">
<span className="word-break rounded-lg rounded-tl-none px-3 py-2 opacity-80 bg-gray-100 dark:bg-zinc-700">
{history.question}
</span>
</div>
<div className="w-full flex flex-row justify-end items-start pl-8 space-x-2">
<div className="memo-content-wrapper !w-auto flex flex-col justify-start items-start rounded-lg rounded-tr-none px-3 py-2 bg-gray-100 dark:bg-zinc-700">
<div className="memo-content-text">{marked(history.answer)}</div>
</div>
<Icon.Bot className="mt-2 flex-shrink-0 mr-1 w-6 h-auto opacity-80" />
</div>
</div>
))}
{!isEnabled && (
<div className="w-full flex flex-col justify-center items-center mt-4 space-y-2">
<p>You have not set up your OpenAI API key.</p>
<Button onClick={() => handleGotoSystemSetting()}>Go to settings</Button>
<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>
</>
);

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useUserStore } from "../store/module";
import { useUserStore } from "@/store/module";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";

View File

@@ -2,7 +2,7 @@ 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";

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useUserStore } from "../store/module";
import { useUserStore } from "@/store/module";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";

View File

@@ -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 "../less/change-resource-filename-dialog.less";
import "@/less/change-resource-filename-dialog.less";
interface Props extends DialogProps {
resourceId: ResourceId;

View File

@@ -1,9 +1,9 @@
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";

View File

@@ -1,6 +1,7 @@
import { Button, Input, Select, Option, Typography, List, ListItem, Autocomplete, Tooltip } from "@mui/joy";
import React, { useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useResourceStore } from "../store/module";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
@@ -20,6 +21,7 @@ interface State {
}
const CreateResourceDialog: React.FC<Props> = (props: Props) => {
const { t } = useTranslation();
const { destroy, onCancel, onConfirm } = props;
const resourceStore = useResourceStore();
const [state, setState] = useState<State>({
@@ -144,14 +146,14 @@ const CreateResourceDialog: React.FC<Props> = (props: Props) => {
return (
<>
<div className="dialog-header-container">
<p className="title-text">Create Resource</p>
<p className="title-text">{t("resources.create-dialog.title")}</p>
<button className="btn close-btn" onClick={handleCloseDialog}>
<Icon.X />
</button>
</div>
<div className="dialog-content-container !w-80">
<Typography className="!mb-1" level="body2">
Upload method
{t("resources.create-dialog.upload-method")}
</Typography>
<Select
className="w-full mb-2"
@@ -159,15 +161,15 @@ const CreateResourceDialog: React.FC<Props> = (props: Props) => {
value={state.selectedMode}
startDecorator={<Icon.File className="w-4 h-auto" />}
>
<Option value="local-file">Local file</Option>
<Option value="external-link">External link</Option>
<Option value="local-file">{t("resources.create-dialog.local-file.option")}</Option>
<Option value="external-link">{t("resources.create-dialog.external-link.option")}</Option>
</Select>
{state.selectedMode === "local-file" && (
<>
<div className="w-full relative bg-blue-50 dark:bg-zinc-900 rounded-md flex flex-row justify-center items-center py-8">
<label htmlFor="files" className="p-2 px-4 text-sm text-white cursor-pointer bg-blue-500 block rounded hover:opacity-80">
Choose a file...
{t("resources.create-dialog.local-file.choose")}
</label>
<input
className="absolute inset-0 w-full h-full opacity-0"
@@ -194,7 +196,7 @@ const CreateResourceDialog: React.FC<Props> = (props: Props) => {
{state.selectedMode === "external-link" && (
<>
<Typography className="!mb-1" level="body2">
Link
{t("resources.create-dialog.external-link.link")}
</Typography>
<Input
className="mb-2"
@@ -204,16 +206,22 @@ const CreateResourceDialog: React.FC<Props> = (props: Props) => {
fullWidth
/>
<Typography className="!mb-1" level="body2">
File name
{t("resources.create-dialog.external-link.file-name")}
</Typography>
<Input className="mb-2" placeholder="File name" value={resourceCreate.filename} onChange={handleFileNameChanged} fullWidth />
<Input
className="mb-2"
placeholder={t("resources.create-dialog.external-link.file-name-placeholder")}
value={resourceCreate.filename}
onChange={handleFileNameChanged}
fullWidth
/>
<Typography className="!mb-1" level="body2">
Type
{t("resources.create-dialog.external-link.type")}
</Typography>
<Autocomplete
className="w-full"
size="sm"
placeholder="File type"
placeholder={t("resources.create-dialog.external-link.type-placeholder")}
freeSolo={true}
options={fileTypeAutocompleteOptions}
onChange={(_, value) => handleFileTypeChanged(value || "")}
@@ -223,10 +231,10 @@ const CreateResourceDialog: React.FC<Props> = (props: Props) => {
<div className="mt-2 w-full flex flex-row justify-end items-center space-x-1">
<Button variant="plain" color="neutral" onClick={handleCloseDialog}>
Cancel
{t("common.cancel")}
</Button>
<Button onClick={handleConfirmBtnClick} loading={state.uploadingFlag} disabled={!allowConfirmAction()}>
Create
{t("common.create")}
</Button>
</div>
</div>

View File

@@ -2,13 +2,13 @@ import dayjs from "dayjs";
import { useCallback, useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useShortcutStore, useTagStore } from "../store/module";
import { filterConsts, getDefaultFilter, relationConsts } from "../helpers/filter";
import useLoading from "../hooks/useLoading";
import { useShortcutStore, useTagStore } from "@/store/module";
import { filterConsts, getDefaultFilter, relationConsts } from "@/helpers/filter";
import useLoading from "@/hooks/useLoading";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import Selector from "./base/Selector";
import "../less/create-shortcut-dialog.less";
import "@/less/create-shortcut-dialog.less";
interface Props extends DialogProps {
shortcutId?: ShortcutId;

View File

@@ -1,9 +1,11 @@
import { Button, Input, Typography } from "@mui/joy";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { Button, Input, Typography } from "@mui/joy";
import * as api from "../helpers/api";
import * as api from "@/helpers/api";
import { generateDialog } from "./Dialog";
import Icon from "./Icon";
import RequiredBadge from "./RequiredBadge";
import LearnMore from "./LearnMore";
interface Props extends DialogProps {
storage?: ObjectStorage;
@@ -24,6 +26,7 @@ const CreateStorageServiceDialog: React.FC<Props> = (props: Props) => {
path: "",
bucket: "",
urlPrefix: "",
urlSuffix: "",
});
const isCreating = storage === undefined;
@@ -48,7 +51,13 @@ const CreateStorageServiceDialog: React.FC<Props> = (props: Props) => {
return false;
}
if (type === "S3") {
if (s3Config.endPoint === "" || s3Config.region === "" || s3Config.accessKey === "" || s3Config.bucket === "") {
if (
s3Config.endPoint === "" ||
s3Config.region === "" ||
s3Config.accessKey === "" ||
s3Config.secretKey === "" ||
s3Config.bucket === ""
) {
return false;
}
}
@@ -97,14 +106,7 @@ const CreateStorageServiceDialog: React.FC<Props> = (props: Props) => {
<div className="dialog-header-container">
<p className="title-text">
{isCreating ? "Create storage" : "Update storage"}
<a
className="ml-2 text-sm text-blue-600 hover:opacity-80 hover:underline"
href="https://usememos.com/docs/storage"
target="_blank"
>
Learn more
<Icon.ExternalLink className="inline -mt-1 ml-1 w-4 h-auto opacity-80" />
</a>
<LearnMore className="ml-2" url="https://usememos.com/docs/storage" />
</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<Icon.X />
@@ -113,6 +115,7 @@ const CreateStorageServiceDialog: React.FC<Props> = (props: Props) => {
<div className="dialog-content-container">
<Typography className="!mb-1" level="body2">
Name
<RequiredBadge />
</Typography>
<Input
className="mb-2"
@@ -128,6 +131,7 @@ const CreateStorageServiceDialog: React.FC<Props> = (props: Props) => {
/>
<Typography className="!mb-1" level="body2">
EndPoint
<RequiredBadge />
<span className="text-sm text-gray-400 ml-1">(S3-compatible server URL)</span>
</Typography>
<Input
@@ -139,6 +143,7 @@ const CreateStorageServiceDialog: React.FC<Props> = (props: Props) => {
/>
<Typography className="!mb-1" level="body2">
Region
<RequiredBadge />
<span className="text-sm text-gray-400 ml-1">(Region name)</span>
</Typography>
<Input
@@ -150,6 +155,7 @@ const CreateStorageServiceDialog: React.FC<Props> = (props: Props) => {
/>
<Typography className="!mb-1" level="body2">
AccessKey
<RequiredBadge />
<span className="text-sm text-gray-400 ml-1">(Access Key / Access ID)</span>
</Typography>
<Input
@@ -161,6 +167,7 @@ const CreateStorageServiceDialog: React.FC<Props> = (props: Props) => {
/>
<Typography className="!mb-1" level="body2">
SecretKey
<RequiredBadge />
<span className="text-sm text-gray-400 ml-1">(Secret Key / Secret Access Key)</span>
</Typography>
<Input
@@ -172,6 +179,7 @@ const CreateStorageServiceDialog: React.FC<Props> = (props: Props) => {
/>
<Typography className="!mb-1" level="body2">
Bucket
<RequiredBadge />
<span className="text-sm text-gray-400 ml-1">(Bucket name)</span>
</Typography>
<Input
@@ -187,8 +195,8 @@ const CreateStorageServiceDialog: React.FC<Props> = (props: Props) => {
</Typography>
<Typography className="!mb-1" level="body2">
<p className="text-sm text-gray-400 ml-1">{"You can use {year}, {month}, {day}, {hour}, {minute}, {second},"}</p>
<p className="text-sm text-gray-400 ml-1">{"{filetype}, {filename}, {timestamp} and any other words."}</p>
<p className="text-sm text-gray-400 ml-1">{"e.g., {year}/{month}/{day}/your/path/{filename}.{filetype}"}</p>
<p className="text-sm text-gray-400 ml-1">{"{filename}, {timestamp} and any other words."}</p>
<p className="text-sm text-gray-400 ml-1">{"e.g., {year}/{month}/{day}/your/path/{filename}"}</p>
</Typography>
<Input
className="mb-2"
@@ -208,6 +216,17 @@ const CreateStorageServiceDialog: React.FC<Props> = (props: Props) => {
onChange={(e) => setPartialS3Config({ urlPrefix: e.target.value })}
fullWidth
/>
<Typography className="!mb-1" level="body2">
URLSuffix
<span className="text-sm text-gray-400 ml-1">(Custom URL suffix; Optional)</span>
</Typography>
<Input
className="mb-2"
placeholder="URLSuffix"
value={s3Config.urlSuffix}
onChange={(e) => setPartialS3Config({ urlSuffix: e.target.value })}
fullWidth
/>
<div className="mt-2 w-full flex flex-row justify-end items-center space-x-1">
<Button variant="plain" color="neutral" onClick={handleCloseBtnClick}>
Cancel

View File

@@ -1,10 +1,11 @@
import { Input } from "@mui/joy";
import React, { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { useTagStore } from "../store/module";
import { getTagSuggestionList } from "../helpers/api";
import { matcher } from "../labs/marked/matcher";
import Tag from "../labs/marked/parser/Tag";
import { useTranslation } from "react-i18next";
import { useTagStore } from "@/store/module";
import { getTagSuggestionList } from "@/helpers/api";
import { matcher } from "@/labs/marked/matcher";
import Tag from "@/labs/marked/parser/Tag";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
@@ -21,6 +22,7 @@ const validateTagName = (tagName: string): boolean => {
const CreateTagDialog: React.FC<Props> = (props: Props) => {
const { destroy } = props;
const tagStore = useTagStore();
const { t } = useTranslation();
const [tagName, setTagName] = useState<string>("");
const [suggestTagNameList, setSuggestTagNameList] = useState<string[]>([]);
const [showTagSuggestions, setShowTagSuggestions] = useState<boolean>(false);
@@ -82,7 +84,7 @@ const CreateTagDialog: React.FC<Props> = (props: Props) => {
return (
<>
<div className="dialog-header-container">
<p className="title-text">Create Tag</p>
<p className="title-text">{t("tag-list.create-tag")}</p>
<button className="btn close-btn" onClick={() => destroy()}>
<Icon.X />
</button>
@@ -91,7 +93,7 @@ const CreateTagDialog: React.FC<Props> = (props: Props) => {
<Input
className="mb-2"
size="md"
placeholder="TAG_NAME"
placeholder={t("tag-list.tag-name")}
value={tagName}
onChange={handleTagNameChanged}
onKeyDown={handleTagNameInputKeyDown}
@@ -101,7 +103,7 @@ const CreateTagDialog: React.FC<Props> = (props: Props) => {
/>
{tagNameList.length > 0 && (
<>
<p className="w-full mt-2 mb-1 text-sm text-gray-400">All tags</p>
<p className="w-full mt-2 mb-1 text-sm text-gray-400">{t("tag-list.all-tags")}</p>
<div className="w-full flex flex-row justify-start items-start flex-wrap">
{Array.from(tagNameList)
.sort()

View File

@@ -1,7 +1,7 @@
import * as utils from "../helpers/utils";
import * as utils from "@/helpers/utils";
import MemoContent from "./MemoContent";
import MemoResources from "./MemoResources";
import "../less/daily-memo.less";
import "@/less/daily-memo.less";
interface Props {
memo: Memo;

View File

@@ -1,12 +1,12 @@
import { CssVarsProvider } from "@mui/joy";
import { useEffect, useRef } from "react";
import { createRoot } from "react-dom/client";
import { Provider } from "react-redux";
import { ANIMATION_DURATION } from "../../helpers/consts";
import store from "../../store";
import { useDialogStore } from "../../store/module";
import { CssVarsProvider } from "@mui/joy";
import theme from "../../theme";
import "../../less/base-dialog.less";
import { ANIMATION_DURATION } from "@/helpers/consts";
import store from "@/store";
import { useDialogStore } from "@/store/module";
import theme from "@/theme";
import "@/less/base-dialog.less";
interface DialogConfig {
dialogName: string;

View File

@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import Icon from "../Icon";
import { generateDialog } from "./BaseDialog";
import "../../less/common-dialog.less";
import "@/less/common-dialog.less";
type DialogStyle = "info" | "warning";

View File

@@ -1,5 +1,5 @@
import { forwardRef, ReactNode, useCallback, useEffect, useImperativeHandle, useRef } from "react";
import "../../less/editor.less";
import "@/less/editor.less";
export interface EditorRefActions {
focus: FunctionType;

View File

@@ -1,6 +1,7 @@
import copy from "copy-to-clipboard";
import React from "react";
import { toast } from "react-hot-toast";
import copy from "copy-to-clipboard";
import { useTranslation } from "react-i18next";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
@@ -9,6 +10,7 @@ interface Props extends DialogProps {
}
const EmbedMemoDialog: React.FC<Props> = (props: Props) => {
const { t } = useTranslation();
const { memoId, destroy } = props;
const memoEmbeddedCode = () => {
@@ -23,20 +25,20 @@ const EmbedMemoDialog: React.FC<Props> = (props: Props) => {
return (
<>
<div className="dialog-header-container">
<p className="title-text">Embed Memo</p>
<p className="title-text">{t("embed-memo.title")}</p>
<button className="btn close-btn" onClick={() => destroy()}>
<Icon.X />
</button>
</div>
<div className="dialog-content-container !w-80">
<p className="text-base leading-6 mb-2">Copy and paste the below codes into your blog or website.</p>
<p className="text-base leading-6 mb-2">{t("embed-memo.text")}</p>
<pre className="w-full font-mono text-sm p-3 border rounded-lg">
<code className="w-full break-all whitespace-pre-wrap">{memoEmbeddedCode()}</code>
</pre>
<p className="w-full text-sm leading-6 flex flex-row justify-between items-center mt-2">
<span className="italic opacity-80">* Only the public memo supports.</span>
<span className="italic opacity-80">{t("embed-memo.only-public-supported")}</span>
<span className="btn-primary" onClick={handleCopyCode}>
Copy
{t("embed-memo.copy")}
</span>
</p>
</div>

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import * as api from "../helpers/api";
import * as api from "@/helpers/api";
import Icon from "./Icon";
const GitHubBadge = () => {

View File

@@ -1,10 +1,9 @@
import { useEffect } from "react";
import { NavLink, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useLayoutStore, useUserStore } from "../store/module";
import { resolution } from "../utils/layout";
import { useLayoutStore, useUserStore } from "@/store/module";
import { resolution } from "@/utils/layout";
import Icon from "./Icon";
import showResourcesDialog from "./ResourcesDialog";
import showSettingDialog from "./SettingDialog";
import showAskAIDialog from "./AskAIDialog";
import showArchivedMemoDialog from "./ArchivedMemoDialog";
@@ -33,7 +32,7 @@ const Header = () => {
return (
<div
className={`fixed sm:sticky top-0 left-0 w-full sm:w-56 h-full flex-shrink-0 pointer-events-none sm:pointer-events-auto z-10 ${
className={`fixed sm:sticky top-0 left-0 w-full sm:w-56 h-full flex-shrink-0 pointer-events-none sm:pointer-events-auto z-20 ${
showHeader && "pointer-events-auto"
}`}
>
@@ -54,6 +53,7 @@ const Header = () => {
<>
<NavLink
to="/"
id="header-home"
className={({ isActive }) =>
`${
isActive && "bg-white dark:bg-zinc-700 shadow"
@@ -66,6 +66,7 @@ const Header = () => {
</NavLink>
<NavLink
to="/review"
id="header-review"
className={({ isActive }) =>
`${
isActive && "bg-white dark:bg-zinc-700 shadow"
@@ -76,10 +77,24 @@ const Header = () => {
<Icon.Calendar className="mr-4 w-6 h-auto opacity-80" /> {t("common.daily-review")}
</>
</NavLink>
<NavLink
to="/resources"
id="header-resources"
className={({ isActive }) =>
`${
isActive && "bg-white dark:bg-zinc-700 shadow"
} px-4 pr-5 py-2 rounded-lg flex flex-row items-center text-lg dark:text-gray-200 hover:bg-white hover:shadow dark:hover:bg-zinc-700`
}
>
<>
<Icon.Paperclip className="mr-4 w-6 h-auto opacity-80" /> {t("common.resources")}
</>
</NavLink>
</>
)}
<NavLink
to="/explore"
id="header-explore"
className={({ isActive }) =>
`${
isActive && "bg-white dark:bg-zinc-700 shadow"
@@ -93,24 +108,21 @@ const Header = () => {
{!isVisitorMode && (
<>
<button
id="header-ask-ai"
className="px-4 pr-5 py-2 rounded-lg flex flex-row items-center text-lg dark:text-gray-200 hover:bg-white hover:shadow dark:hover:bg-zinc-700"
onClick={() => showAskAIDialog()}
>
<Icon.Bot className="mr-4 w-6 h-auto opacity-80" /> Ask AI
</button>
<button
className="px-4 pr-5 py-2 rounded-lg flex flex-row items-center text-lg dark:text-gray-200 hover:bg-white hover:shadow dark:hover:bg-zinc-700"
onClick={() => showResourcesDialog()}
>
<Icon.Paperclip className="mr-4 w-6 h-auto opacity-80" /> {t("common.resources")}
<Icon.Bot className="mr-4 w-6 h-auto opacity-80" /> {t("common.ask-ai")}
</button>
<button
id="header-archived-memo"
className="px-4 pr-5 py-2 rounded-lg flex flex-row items-center text-lg dark:text-gray-200 hover:bg-white hover:shadow dark:hover:bg-zinc-700"
onClick={() => showArchivedMemoDialog()}
>
<Icon.Archive className="mr-4 w-6 h-auto opacity-80" /> {t("common.archived")}
</button>
<button
id="header-settings"
className="px-4 pr-5 py-2 rounded-lg flex flex-row items-center text-lg dark:text-gray-200 hover:bg-white hover:shadow dark:hover:bg-zinc-700"
onClick={() => showSettingDialog()}
>
@@ -122,6 +134,7 @@ const Header = () => {
<>
<NavLink
to="/auth"
id="header-auth"
className={({ isActive }) =>
`${
isActive && "bg-white dark:bg-zinc-700 shadow"
@@ -133,6 +146,7 @@ const Header = () => {
</>
</NavLink>
<button
id="header-about"
className="px-4 pr-5 py-2 rounded-lg flex flex-row items-center text-lg dark:text-gray-200 hover:bg-white hover:shadow dark:hover:bg-zinc-700"
onClick={() => showAboutSiteDialog()}
>

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