Compare commits
164 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef7381f032 | |||
| df30304d00 | |||
| 91a24ef9ce | |||
| d11083d3b9 | |||
| 680b8ede6c | |||
| 4e023e2500 | |||
| 3eac19d258 | |||
| ab867b68d3 | |||
| 8cdc662745 | |||
| 204c03e772 | |||
| d0ddac296f | |||
| 609366da6e | |||
| f48d91539e | |||
| cc23f69f66 | |||
| 6ceafc1827 | |||
| 8c2224ae39 | |||
| 6ff7cfddda | |||
| 5361f76b11 | |||
| bdc00d67b2 | |||
| 5caa8cdec5 | |||
| 9ede3da882 | |||
| 836e496ee0 | |||
| 5aa4ba32c9 | |||
| 4419b4d4ae | |||
| 1cab30f32f | |||
| c9a5df81ce | |||
| 4f2adfef7b | |||
| 8a33290722 | |||
| 11cd9b21de | |||
| 41c50e758a | |||
| d71bfce1a0 | |||
| 1ea65c0b60 | |||
| c7a57191bd | |||
| e3fc23ccf9 | |||
| 0baf6b0e19 | |||
| 0cddb358c1 | |||
| 741eeb7835 | |||
| 424f10e180 | |||
| fab3dac70a | |||
| 89ab57d738 | |||
| b03778fa73 | |||
| d21abfc60c | |||
| 8eed9c267c | |||
| 3c2578f666 | |||
| 526fbbba45 | |||
| 993ea024fd | |||
| bc595b40e7 | |||
| e7ee181a91 | |||
| 6b703c4678 | |||
| dbb095fff4 | |||
| adf01ed511 | |||
| 17ca97ebd1 | |||
| 7d89fcc892 | |||
| e84d562146 | |||
| 2e14561bfc | |||
| 166e57f1ef | |||
| eeff159a2d | |||
| 547f25178b | |||
| 9c0a3ff83c | |||
| 2ba54c9168 | |||
| 026fb3e50e | |||
| af3d3c2c9b | |||
| 27a1792e78 | |||
| 63e0716457 | |||
| f3090b115d | |||
| a21ff5c2e3 | |||
| ff8851fd9f | |||
| 573f07ec82 | |||
| 8b20cb9fd2 | |||
| 7529296dd5 | |||
| 7f44a73fd0 | |||
| eb835948b7 | |||
| 70e32637b0 | |||
| c04a31dcda | |||
| e526cef754 | |||
| 2ba0dbf50b | |||
| 4ee8cf08c6 | |||
| f1f9140afc | |||
| c189654cd9 | |||
| 0a66c5c269 | |||
| e129b122a4 | |||
| 7f30e2e6ff | |||
| 29f784cc20 | |||
| 28242d3268 | |||
| 8d88477538 | |||
| 89053e86b3 | |||
| 50f36e3ed5 | |||
| ca6839f593 | |||
| e5cbb8cd56 | |||
| 7c92805aac | |||
| a9218ed5f0 | |||
| f3f0efba1e | |||
| ccdcd3d154 | |||
| 8c774316ae | |||
| 25da3c073b | |||
| 6866b6c30d | |||
| f86816fea2 | |||
| df6b4b0607 | |||
| 2428e6e190 | |||
| 2ff0e71272 | |||
| 93609ca731 | |||
| 70a187cc18 | |||
| 390e29f850 | |||
| 3a466ad2a1 | |||
| ccf6af4dc3 | |||
| ce7564a91b | |||
| 483c1d5782 | |||
| 65850dfd03 | |||
| d1bafd66c8 | |||
| daa1e9edfb | |||
| 008d6a0c81 | |||
| 7c5fae68fe | |||
| c57cea1aaa | |||
| 595dbdb0ec | |||
| 003161ea54 | |||
| fd99c5461c | |||
| 37366dc2e1 | |||
| c1903df374 | |||
| 54374bca05 | |||
| ddf1eb0219 | |||
| 8c5ba63f8c | |||
| f7cd039819 | |||
| 4335897367 | |||
| 6201dcf1aa | |||
| 5d24fe189d | |||
| 6d9ead80b2 | |||
| bf46a9af68 | |||
| c6d43581f9 | |||
| 31399fe475 | |||
| e150599274 | |||
| b70117e9f4 | |||
| df04e852bf | |||
| dd625d8edc | |||
| 6ab58f294e | |||
| 9d4bb5b3af | |||
| e062c9b4a7 | |||
| 1b0629bf0f | |||
| e83ea7fd76 | |||
| 6dab43523d | |||
| 4a59965d7a | |||
| 71de6613d3 | |||
| e43e04b478 | |||
| 4ab32d4c2c | |||
| 1f05b52c4e | |||
| 3e7fbac926 | |||
| eda27a60be | |||
| 107a2dbe90 | |||
| 9577f6dbe8 | |||
| ae61ade2b1 | |||
| 977e7f55e5 | |||
| c399ff86e0 | |||
| d43b806c5e | |||
| 7b7061846c | |||
| d81cf5cc1b | |||
| 4284fd0469 | |||
| 039b6b247a | |||
| a09b2c4eea | |||
| 50a99e9120 | |||
| 57479b250a | |||
| e64245099c | |||
| d6e4b5e889 | |||
| 6e2e7ac782 | |||
| 904a6bd97f | |||
| c24b7097fa |
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
FROM node:18.12.1-alpine3.16 AS frontend
|
||||
WORKDIR /frontend-build
|
||||
|
||||
COPY ./web/package.json ./web/yarn.lock ./
|
||||
|
||||
RUN yarn
|
||||
|
||||
COPY ./web/ .
|
||||
|
||||
RUN yarn && yarn build
|
||||
RUN yarn build
|
||||
|
||||
# Build backend exec file.
|
||||
FROM golang:1.19.3-alpine3.16 AS backend
|
||||
@@ -21,8 +25,13 @@ RUN go build -o memos ./main.go
|
||||
FROM alpine:3.16 AS monolithic
|
||||
WORKDIR /usr/local/memos
|
||||
|
||||
RUN apk add --no-cache tzdata
|
||||
ENV TZ="UTC"
|
||||
|
||||
COPY --from=backend /backend-build/memos /usr/local/memos/
|
||||
|
||||
EXPOSE 5230
|
||||
|
||||
# Directory to store the data, which can be referenced as the mounting point.
|
||||
RUN mkdir -p /var/opt/memos
|
||||
|
||||
|
||||
@@ -1,94 +1,63 @@
|
||||
<p align="center"><a href="https://usememos.com"><img height="64px" src="https://raw.githubusercontent.com/usememos/memos/main/resources/logo-full.webp" alt="✍️ memos" /></a></p>
|
||||
# memos
|
||||
|
||||
<p align="center">
|
||||
<img height="72px" src="https://usememos.com/logo.webp" alt="✍️ memos" align="right" />
|
||||
|
||||
A lightweight, self-hosted memo hub. Open Source and Free forever.
|
||||
|
||||
<a href="https://demo.usememos.com/">Live Demo</a> •
|
||||
Discuss in <a href="https://t.me/+-_tNF1k70UU4ZTc9">Telegram</a> / <a href="https://discord.gg/tfPJa4UmAv">Discord</a>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/usememos/memos/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/usememos/memos" /></a>
|
||||
<a href="https://hub.docker.com/r/neosmemo/memos"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/neosmemo/memos.svg" /></a>
|
||||
<a href="https://hosted.weblate.org/engage/memos/"><img src="https://hosted.weblate.org/widgets/memos/-/svg-badge.svg" alt="Translation status" /></a>
|
||||
<a href="https://discord.gg/tfPJa4UmAv"><img alt="Discord" src="https://img.shields.io/badge/discord-chat-5865f2?logo=discord&logoColor=f5f5f5" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://demo.usememos.com/">Live Demo</a> •
|
||||
Discuss in <a href="https://t.me/+-_tNF1k70UU4ZTc9">Telegram</a> / <a href="https://discord.gg/tfPJa4UmAv">Discord</a>
|
||||
</p>
|
||||

|
||||
|
||||

|
||||
## Key points
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- 🦄 Open source and free forever
|
||||
- 🚀 Support for self-hosting with `Docker` in seconds
|
||||
- 📜 Plain textarea first and support some useful Markdown syntax
|
||||
- 👥 Set memo private or public to others
|
||||
- 🧑💻 RESTful API for self-service
|
||||
- 📋 Embed memos on other sites using iframe
|
||||
- #️⃣ Hashtags for organizing memos
|
||||
- 📆 Interactive calendar view
|
||||
- 💾 Easy data migration and backups
|
||||
- Open source and free forever
|
||||
- Self-hosting with Docker in seconds
|
||||
- Markdown support
|
||||
- Customizable and sharable
|
||||
- RESTful API for self-service
|
||||
|
||||
## Deploy with Docker in seconds
|
||||
|
||||
### Docker Run
|
||||
|
||||
```docker
|
||||
```bash
|
||||
docker run -d --name memos -p 5230:5230 -v ~/.memos/:/var/opt/memos neosmemo/memos:latest
|
||||
```
|
||||
|
||||
> `~/.memos/` will be used as the data directory in your machine and `/var/opt/memos` is the directory of the volume in Docker and should not be modified.
|
||||
> The `~/.memos/` directory will be used as the data directory on your local machine, while `/var/opt/memos` is the directory of the volume in Docker and should not be modified.
|
||||
|
||||
### Docker Compose
|
||||
Learn more about [other installation methods](https://usememos.com/docs/install).
|
||||
|
||||
- Provided docker compose YAML file: [`docker-compose.yaml`](./docker-compose.yaml).
|
||||
## Contribution
|
||||
|
||||
- You can upgrade to the latest version memos with:
|
||||
|
||||
```sh
|
||||
docker-compose down && docker image rm neosmemo/memos:latest && docker-compose up -d
|
||||
```
|
||||
|
||||
### Other installation methods
|
||||
|
||||
- [Deploy on render.com](./docs/deploy-with-render.md)
|
||||
- [Deploy on fly.io](https://github.com/hu3rror/memos-on-fly)
|
||||
|
||||
## Contribute
|
||||
|
||||
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated. 🥰
|
||||
|
||||
Learn more about contributing in [development guide](./docs/development.md).
|
||||
|
||||
### Products made by our Community
|
||||
|
||||
- [Moe Memos](https://memos.moe/) - Third party client for iOS and Android
|
||||
- [lmm214/memos-bber](https://github.com/lmm214/memos-bber) - Chrome extension
|
||||
- [Rabithua/memos_wmp](https://github.com/Rabithua/memos_wmp) - WeChat MiniProgram
|
||||
- [qazxcdswe123/telegramMemoBot](https://github.com/qazxcdswe123/telegramMemoBot) - Telegram bot
|
||||
- [eallion/memos.top](https://github.com/eallion/memos.top) - A static page rendered with the Memos API
|
||||
- [eindex/logseq-memos-sync](https://github.com/EINDEX/logseq-memos-sync) - A Logseq plugin
|
||||
- [JakeLaoyu/memos-import-from-flomo](https://github.com/JakeLaoyu/memos-import-from-flomo) - Import data. Support from flomo, wechat reading.
|
||||
- [Send to memos](https://sharecuts.cn/shortcut/12640) - A shortcut for iOS.
|
||||
- [Memos Raycast Extension](https://www.raycast.com/JakeYu/memos) - Raycast extension, [source code](https://github.com/JakeLaoyu/memos-raycast).
|
||||
|
||||
### User stories
|
||||
|
||||
- [Memos - A Twitter Like Notes App You can Self Host](https://noted.lol/memos/)
|
||||
|
||||
### Join the community to build memos together!
|
||||
Contributions are what make the open-source community such an amazing place to learn, inspire, and create. We greatly appreciate any contributions you make. Thank you for being a part of our community! 🥰
|
||||
|
||||
<a href="https://github.com/usememos/memos/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=usememos/memos" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
- [Moe Memos](https://memos.moe/) - Third party client for iOS and Android
|
||||
- [lmm214/memos-bber](https://github.com/lmm214/memos-bber) - Chrome extension
|
||||
- [Rabithua/memos_wmp](https://github.com/Rabithua/memos_wmp) - WeChat MiniProgram
|
||||
- [qazxcdswe123/telegramMemoBot](https://github.com/qazxcdswe123/telegramMemoBot) - Telegram bot
|
||||
- [eallion/memos.top](https://github.com/eallion/memos.top) - Static page rendered with the Memos API
|
||||
- [eindex/logseq-memos-sync](https://github.com/EINDEX/logseq-memos-sync) - Logseq plugin
|
||||
- [JakeLaoyu/memos-import-from-flomo](https://github.com/JakeLaoyu/memos-import-from-flomo) - Import data. Support from flomo, wechat reading
|
||||
- [Send to memos](https://sharecuts.cn/shortcut/12640) - A shortcut for iOS
|
||||
- [Memos Raycast Extension](https://www.raycast.com/JakeYu/memos) - Raycast extension
|
||||
- [Memos Desktop](https://github.com/xudaolong/memos-desktop) - Third party client for MacOS and Windows
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
- Thanks [Uffizzi](https://www.uffizzi.com/) for sponsoring preview environments for PRs.
|
||||
|
||||
## License
|
||||
|
||||
[MIT License](https://github.com/usememos/memos/blob/main/LICENSE)
|
||||
|
||||
## Star history
|
||||
|
||||
[](https://star-history.com/#usememos/memos&Date)
|
||||
|
||||
@@ -42,7 +42,7 @@ type Memo struct {
|
||||
Pinned bool `json:"pinned"`
|
||||
|
||||
// Related fields
|
||||
Creator *User `json:"creator"`
|
||||
CreatorName string `json:"creatorName"`
|
||||
ResourceList []*Resource `json:"resourceList"`
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,11 @@ type Resource struct {
|
||||
// Domain specific fields
|
||||
Filename string `json:"filename"`
|
||||
Blob []byte `json:"-"`
|
||||
InternalPath string `json:"internalPath"`
|
||||
ExternalLink string `json:"externalLink"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
PublicID string `json:"publicId"`
|
||||
|
||||
// Related fields
|
||||
LinkedMemoAmount int `json:"linkedMemoAmount"`
|
||||
@@ -26,9 +28,11 @@ type ResourceCreate struct {
|
||||
// Domain specific fields
|
||||
Filename string `json:"filename"`
|
||||
Blob []byte `json:"-"`
|
||||
InternalPath string `json:"internalPath"`
|
||||
ExternalLink string `json:"externalLink"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"-"`
|
||||
PublicID string `json:"publicId"`
|
||||
}
|
||||
|
||||
type ResourceFind struct {
|
||||
@@ -40,7 +44,12 @@ type ResourceFind struct {
|
||||
// Domain specific fields
|
||||
Filename *string `json:"filename"`
|
||||
MemoID *int
|
||||
PublicID *string `json:"publicId"`
|
||||
GetBlob bool
|
||||
|
||||
// Pagination
|
||||
Limit *int
|
||||
Offset *int
|
||||
}
|
||||
|
||||
type ResourcePatch struct {
|
||||
@@ -50,7 +59,9 @@ type ResourcePatch struct {
|
||||
UpdatedTs *int64
|
||||
|
||||
// Domain specific fields
|
||||
Filename *string `json:"filename"`
|
||||
Filename *string `json:"filename"`
|
||||
ResetPublicID *bool `json:"resetPublicId"`
|
||||
PublicID *string `json:"-"`
|
||||
}
|
||||
|
||||
type ResourceDelete struct {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
package api
|
||||
|
||||
const (
|
||||
// LocalStorage means the storage service is local file system.
|
||||
LocalStorage = -1
|
||||
// DatabaseStorage means the storage service is database.
|
||||
DatabaseStorage = 0
|
||||
)
|
||||
|
||||
type StorageType string
|
||||
|
||||
const (
|
||||
@@ -12,11 +19,13 @@ type StorageConfig struct {
|
||||
|
||||
type StorageS3Config struct {
|
||||
EndPoint string `json:"endPoint"`
|
||||
Path string `json:"path"`
|
||||
Region string `json:"region"`
|
||||
AccessKey string `json:"accessKey"`
|
||||
SecretKey string `json:"secretKey"`
|
||||
Bucket string `json:"bucket"`
|
||||
URLPrefix string `json:"urlPrefix"`
|
||||
URLSuffix string `json:"urlSuffix"`
|
||||
}
|
||||
|
||||
type Storage struct {
|
||||
|
||||
@@ -10,6 +10,8 @@ type SystemStatus struct {
|
||||
// System settings
|
||||
// Allow sign up.
|
||||
AllowSignUp bool `json:"allowSignUp"`
|
||||
// Ignore upgrade
|
||||
IgnoreUpgrade bool `json:"ignoreUpgrade"`
|
||||
// Disable public memos.
|
||||
DisablePublicMemos bool `json:"disablePublicMemos"`
|
||||
// Additional style.
|
||||
@@ -18,5 +20,8 @@ type SystemStatus struct {
|
||||
AdditionalScript string `json:"additionalScript"`
|
||||
// Customized server profile, including server name and external url.
|
||||
CustomizedProfile CustomizedProfile `json:"customizedProfile"`
|
||||
StorageServiceID int `json:"storageServiceId"`
|
||||
// Storage service ID.
|
||||
StorageServiceID int `json:"storageServiceId"`
|
||||
// Local storage path
|
||||
LocalStoragePath string `json:"localStoragePath"`
|
||||
}
|
||||
|
||||
@@ -11,22 +11,28 @@ import (
|
||||
type SystemSettingName string
|
||||
|
||||
const (
|
||||
// SystemSettingServerID is the key type of server id.
|
||||
SystemSettingServerID SystemSettingName = "serverId"
|
||||
// SystemSettingSecretSessionName is the key type of secret session name.
|
||||
SystemSettingSecretSessionName SystemSettingName = "secretSessionName"
|
||||
// SystemSettingAllowSignUpName is the key type of allow signup setting.
|
||||
SystemSettingAllowSignUpName SystemSettingName = "allowSignUp"
|
||||
// SystemSettingsDisablePublicMemos is the key type of disable public memos setting.
|
||||
SystemSettingDisablePublicMemosName SystemSettingName = "disablePublicMemos"
|
||||
// SystemSettingAdditionalStyleName is the key type of additional style.
|
||||
SystemSettingAdditionalStyleName SystemSettingName = "additionalStyle"
|
||||
// SystemSettingAdditionalScriptName is the key type of additional script.
|
||||
SystemSettingAdditionalScriptName SystemSettingName = "additionalScript"
|
||||
// SystemSettingCustomizedProfileName is the key type of customized server profile.
|
||||
SystemSettingCustomizedProfileName SystemSettingName = "customizedProfile"
|
||||
// SystemSettingStorageServiceIDName is the key type of storage service ID.
|
||||
SystemSettingStorageServiceIDName SystemSettingName = "storageServiceId"
|
||||
// SystemSettingServerIDName is the name of server id.
|
||||
SystemSettingServerIDName SystemSettingName = "server-id"
|
||||
// SystemSettingSecretSessionName is the name of secret session.
|
||||
SystemSettingSecretSessionName SystemSettingName = "secret-session"
|
||||
// SystemSettingAllowSignUpName is the name of allow signup setting.
|
||||
SystemSettingAllowSignUpName SystemSettingName = "allow-signup"
|
||||
// SystemSettingIgnoreUpgradeName is the name of ignore upgrade.
|
||||
SystemSettingIgnoreUpgradeName SystemSettingName = "ignore-upgrade"
|
||||
// SystemSettingDisablePublicMemosName is the name of disable public memos setting.
|
||||
SystemSettingDisablePublicMemosName SystemSettingName = "disable-public-memos"
|
||||
// SystemSettingAdditionalStyleName is the name of additional style.
|
||||
SystemSettingAdditionalStyleName SystemSettingName = "additional-style"
|
||||
// SystemSettingAdditionalScriptName is the name of additional script.
|
||||
SystemSettingAdditionalScriptName SystemSettingName = "additional-script"
|
||||
// SystemSettingCustomizedProfileName is the name of customized server profile.
|
||||
SystemSettingCustomizedProfileName SystemSettingName = "customized-profile"
|
||||
// SystemSettingStorageServiceIDName is the name of storage service ID.
|
||||
SystemSettingStorageServiceIDName SystemSettingName = "storage-service-id"
|
||||
// SystemSettingLocalStoragePathName is the name of local storage path.
|
||||
SystemSettingLocalStoragePathName SystemSettingName = "local-storage-path"
|
||||
// SystemSettingOpenAIConfigName is the name of OpenAI config.
|
||||
SystemSettingOpenAIConfigName SystemSettingName = "openai-config"
|
||||
)
|
||||
|
||||
// CustomizedProfile is the struct definition for SystemSettingCustomizedProfileName system setting item.
|
||||
@@ -45,38 +51,44 @@ type CustomizedProfile struct {
|
||||
ExternalURL string `json:"externalUrl"`
|
||||
}
|
||||
|
||||
type OpenAIConfig struct {
|
||||
Key string `json:"key"`
|
||||
Host string `json:"host"`
|
||||
}
|
||||
|
||||
func (key SystemSettingName) String() string {
|
||||
switch key {
|
||||
case SystemSettingServerID:
|
||||
return "serverId"
|
||||
case SystemSettingServerIDName:
|
||||
return "server-id"
|
||||
case SystemSettingSecretSessionName:
|
||||
return "secretSessionName"
|
||||
return "secret-session"
|
||||
case SystemSettingAllowSignUpName:
|
||||
return "allowSignUp"
|
||||
return "allow-signup"
|
||||
case SystemSettingIgnoreUpgradeName:
|
||||
return "ignore-upgrade"
|
||||
case SystemSettingDisablePublicMemosName:
|
||||
return "disablePublicMemos"
|
||||
return "disable-public-memos"
|
||||
case SystemSettingAdditionalStyleName:
|
||||
return "additionalStyle"
|
||||
return "additional-style"
|
||||
case SystemSettingAdditionalScriptName:
|
||||
return "additionalScript"
|
||||
return "additional-script"
|
||||
case SystemSettingCustomizedProfileName:
|
||||
return "customizedProfile"
|
||||
return "customized-profile"
|
||||
case SystemSettingStorageServiceIDName:
|
||||
return "storageServiceId"
|
||||
return "storage-service-id"
|
||||
case SystemSettingLocalStoragePathName:
|
||||
return "local-storage-path"
|
||||
case SystemSettingOpenAIConfigName:
|
||||
return "openai-config"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var (
|
||||
SystemSettingAllowSignUpValue = []bool{true, false}
|
||||
SystemSettingDisbalePublicMemosValue = []bool{true, false}
|
||||
)
|
||||
|
||||
type SystemSetting struct {
|
||||
Name SystemSettingName
|
||||
Name SystemSettingName `json:"name"`
|
||||
// Value is a JSON string with basic value.
|
||||
Value string
|
||||
Description string
|
||||
Value string `json:"value"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type SystemSettingUpsert struct {
|
||||
@@ -86,7 +98,7 @@ type SystemSettingUpsert struct {
|
||||
}
|
||||
|
||||
func (upsert SystemSettingUpsert) Validate() error {
|
||||
if upsert.Name == SystemSettingServerID {
|
||||
if upsert.Name == SystemSettingServerIDName {
|
||||
return errors.New("update server id is not allowed")
|
||||
} else if upsert.Name == SystemSettingAllowSignUpName {
|
||||
value := false
|
||||
@@ -94,16 +106,11 @@ func (upsert SystemSettingUpsert) Validate() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal system setting allow signup value")
|
||||
}
|
||||
|
||||
invalid := true
|
||||
for _, v := range SystemSettingAllowSignUpValue {
|
||||
if value == v {
|
||||
invalid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if invalid {
|
||||
return fmt.Errorf("invalid system setting allow signup value")
|
||||
} else if upsert.Name == SystemSettingIgnoreUpgradeName {
|
||||
value := false
|
||||
err := json.Unmarshal([]byte(upsert.Value), &value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal system setting ignore upgrade value")
|
||||
}
|
||||
} else if upsert.Name == SystemSettingDisablePublicMemosName {
|
||||
value := false
|
||||
@@ -111,18 +118,6 @@ func (upsert SystemSettingUpsert) Validate() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal system setting disable public memos value")
|
||||
}
|
||||
|
||||
invalid := true
|
||||
for _, v := range SystemSettingDisbalePublicMemosValue {
|
||||
if value == v {
|
||||
invalid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if invalid {
|
||||
return fmt.Errorf("invalid system setting disable public memos value")
|
||||
}
|
||||
} else if upsert.Name == SystemSettingAdditionalStyleName {
|
||||
value := ""
|
||||
err := json.Unmarshal([]byte(upsert.Value), &value)
|
||||
@@ -155,12 +150,24 @@ func (upsert SystemSettingUpsert) Validate() error {
|
||||
return fmt.Errorf("invalid appearance value")
|
||||
}
|
||||
} else if upsert.Name == SystemSettingStorageServiceIDName {
|
||||
value := 0
|
||||
value := DatabaseStorage
|
||||
err := json.Unmarshal([]byte(upsert.Value), &value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal system setting storage service id value")
|
||||
}
|
||||
return nil
|
||||
} else if upsert.Name == SystemSettingLocalStoragePathName {
|
||||
value := ""
|
||||
err := json.Unmarshal([]byte(upsert.Value), &value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal system setting local storage path value")
|
||||
}
|
||||
} else if upsert.Name == SystemSettingOpenAIConfigName {
|
||||
value := OpenAIConfig{}
|
||||
err := json.Unmarshal([]byte(upsert.Value), &value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal system setting openai api config value")
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("invalid system setting name")
|
||||
}
|
||||
|
||||
@@ -67,6 +67,12 @@ func (create UserCreate) Validate() error {
|
||||
if len(create.Username) > 32 {
|
||||
return fmt.Errorf("username is too long, maximum length is 32")
|
||||
}
|
||||
if len(create.Password) < 3 {
|
||||
return fmt.Errorf("password is too short, minimum length is 6")
|
||||
}
|
||||
if len(create.Password) > 512 {
|
||||
return fmt.Errorf("password is too long, maximum length is 512")
|
||||
}
|
||||
if len(create.Nickname) > 64 {
|
||||
return fmt.Errorf("nickname is too long, maximum length is 64")
|
||||
}
|
||||
@@ -107,6 +113,12 @@ func (patch UserPatch) Validate() error {
|
||||
if patch.Username != nil && len(*patch.Username) > 32 {
|
||||
return fmt.Errorf("username is too long, maximum length is 32")
|
||||
}
|
||||
if patch.Password != nil && len(*patch.Password) < 3 {
|
||||
return fmt.Errorf("password is too short, minimum length is 6")
|
||||
}
|
||||
if patch.Password != nil && len(*patch.Password) > 512 {
|
||||
return fmt.Errorf("password is too long, maximum length is 512")
|
||||
}
|
||||
if patch.Nickname != nil && len(*patch.Nickname) > 64 {
|
||||
return fmt.Errorf("nickname is too long, maximum length is 64")
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ const (
|
||||
// UserSettingAppearanceKey is the key type for user appearance.
|
||||
UserSettingAppearanceKey UserSettingKey = "appearance"
|
||||
// UserSettingMemoVisibilityKey is the key type for user preference memo default visibility.
|
||||
UserSettingMemoVisibilityKey UserSettingKey = "memoVisibility"
|
||||
UserSettingMemoVisibilityKey UserSettingKey = "memo-visibility"
|
||||
)
|
||||
|
||||
// String returns the string format of UserSettingKey type.
|
||||
@@ -26,13 +26,13 @@ func (key UserSettingKey) String() string {
|
||||
case UserSettingAppearanceKey:
|
||||
return "appearance"
|
||||
case UserSettingMemoVisibilityKey:
|
||||
return "memoVisibility"
|
||||
return "memo-visibility"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var (
|
||||
UserSettingLocaleValue = []string{"en", "zh", "vi", "fr", "nl", "sv", "de", "es", "uk", "ru", "it", "hant", "ko"}
|
||||
UserSettingLocaleValue = []string{"en", "zh", "vi", "fr", "nl", "sv", "de", "es", "uk", "ru", "it", "hant", "tr", "ko", "sl"}
|
||||
UserSettingAppearanceValue = []string{"system", "light", "dark"}
|
||||
UserSettingMemoVisibilityValue = []Visibility{Private, Protected, Public}
|
||||
)
|
||||
@@ -88,7 +88,7 @@ func (upsert UserSettingUpsert) Validate() error {
|
||||
type UserSettingFind struct {
|
||||
UserID int
|
||||
|
||||
Key *UserSettingKey `json:"key"`
|
||||
Key UserSettingKey `json:"key"`
|
||||
}
|
||||
|
||||
type UserSettingDelete struct {
|
||||
|
||||
|
Before Width: | Height: | Size: 374 KiB After Width: | Height: | Size: 374 KiB |
@@ -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"
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
# Adding A Custom Theme
|
||||
|
||||
1. Open the Settings Dialog
|
||||
2. Navigate to the System Tab
|
||||
3. In the "Additional Styles" box add these lines of code:
|
||||
|
||||
```css
|
||||
.memo-list-container {
|
||||
background-color: #INSERT COLOR HERE;
|
||||
}
|
||||
.page-container {
|
||||
background-color: #INSERT COLOR HERE;
|
||||
}
|
||||
```
|
||||
|
||||
It is recommended that you choose the same color for both options
|
||||
|
||||
4. Refresh the page and the background color of your memos app will successfully update to reflect your changes
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Memos is built with a curated tech stack. It is optimized for developer experien
|
||||
|
||||
## Tech Stack
|
||||
|
||||

|
||||

|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -2,21 +2,26 @@ module github.com/usememos/memos
|
||||
|
||||
go 1.19
|
||||
|
||||
require github.com/mattn/go-sqlite3 v1.14.9
|
||||
|
||||
require github.com/google/uuid v1.3.0
|
||||
|
||||
require (
|
||||
golang.org/x/crypto v0.1.0
|
||||
golang.org/x/net v0.6.0
|
||||
)
|
||||
|
||||
require github.com/labstack/echo/v4 v4.9.0
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.17.4
|
||||
github.com/aws/aws-sdk-go-v2/config v1.18.12
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.13.12
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.51
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.30.3
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/gorilla/feeds v1.1.1
|
||||
github.com/gorilla/sessions v1.2.1
|
||||
github.com/labstack/echo-contrib v0.13.0
|
||||
github.com/labstack/echo/v4 v4.9.0
|
||||
github.com/mattn/go-sqlite3 v1.14.9
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/spf13/cobra v1.6.1
|
||||
github.com/spf13/viper v1.15.0
|
||||
github.com/stretchr/testify v1.8.1
|
||||
go.uber.org/zap v1.24.0
|
||||
golang.org/x/crypto v0.1.0
|
||||
golang.org/x/exp v0.0.0-20230111222715-75897c7a292a
|
||||
golang.org/x/mod v0.6.0
|
||||
golang.org/x/net v0.7.0
|
||||
golang.org/x/oauth2 v0.5.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -34,13 +39,11 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.18.3 // indirect
|
||||
github.com/aws/smithy-go v1.13.5 // indirect
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.6.0 // indirect
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/gorilla/context v1.1.1 // indirect
|
||||
github.com/gorilla/securecookie v1.1.1 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.0.1 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
@@ -52,15 +55,14 @@ require (
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.6 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/segmentio/backo-go v1.0.1 // indirect
|
||||
github.com/spf13/afero v1.9.3 // indirect
|
||||
github.com/spf13/cast v1.5.0 // indirect
|
||||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/stretchr/objx v0.5.0 // indirect
|
||||
github.com/subosito/gotenv v1.4.2 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.1 // indirect
|
||||
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.8.0 // indirect
|
||||
golang.org/x/sys v0.5.0 // indirect
|
||||
@@ -72,20 +74,3 @@ require (
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.17.4
|
||||
github.com/aws/aws-sdk-go-v2/config v1.18.12
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.13.12
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.51
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.30.3
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/segmentio/analytics-go v3.1.0+incompatible
|
||||
github.com/spf13/cobra v1.6.1
|
||||
github.com/spf13/viper v1.15.0
|
||||
github.com/stretchr/testify v1.8.1
|
||||
go.uber.org/zap v1.24.0
|
||||
golang.org/x/exp v0.0.0-20230111222715-75897c7a292a
|
||||
golang.org/x/mod v0.6.0
|
||||
golang.org/x/oauth2 v0.5.0
|
||||
)
|
||||
|
||||
@@ -79,8 +79,6 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.18.3/go.mod h1:b+psTJn33Q4qGoDaM7ZiO
|
||||
github.com/aws/smithy-go v1.13.5 h1:hgz0X/DX0dGqTYpGALqXJoRKRj5oQ7150i5FdTePzO8=
|
||||
github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
|
||||
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
@@ -108,6 +106,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
@@ -170,14 +170,8 @@ github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
|
||||
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
|
||||
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
||||
github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY=
|
||||
github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
|
||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
@@ -202,8 +196,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/labstack/echo-contrib v0.13.0 h1:bzSG0SpuZZd7BmJLvsWtPfU23W0Enh3K0tok3aENVKA=
|
||||
github.com/labstack/echo-contrib v0.13.0/go.mod h1:IF9+MJu22ADOZEHD+bAV67XMIO3vNXUy7Naz/ABPHEs=
|
||||
github.com/labstack/echo/v4 v4.9.0 h1:wPOF1CE6gvt/kmbMR4dGzWvHMPT+sAEUJOwOTtvITVY=
|
||||
github.com/labstack/echo/v4 v4.9.0/go.mod h1:xkCDAdFCIf8jsFQ5NnbK7oqaF/yU1A1X20Ltm0OvSks=
|
||||
github.com/labstack/gommon v0.3.1 h1:OomWaJXm7xR6L1HmEtGyQf26TEn7V6X88mktX9kee9o=
|
||||
@@ -232,10 +224,6 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/segmentio/analytics-go v3.1.0+incompatible h1:IyiOfUgQFVHvsykKKbdI7ZsH374uv3/DfZUo9+G0Z80=
|
||||
github.com/segmentio/analytics-go v3.1.0+incompatible/go.mod h1:C7CYBtQWk4vRk2RyLu0qOcbHJ18E3F1HV2C/8JvKN48=
|
||||
github.com/segmentio/backo-go v1.0.1 h1:68RQccglxZeyURy93ASB/2kc9QudzgIDexJ927N++y4=
|
||||
github.com/segmentio/backo-go v1.0.1/go.mod h1:9/Rh6yILuLysoQnZ2oNooD2g7aBnvM7r/fNVxRNWfBc=
|
||||
github.com/spf13/afero v1.9.3 h1:41FoI0fD7OR7mGcKE/aOiLkGreyf8ifIOQmJANWogMk=
|
||||
github.com/spf13/afero v1.9.3/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y=
|
||||
github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w=
|
||||
@@ -250,6 +238,7 @@ github.com/spf13/viper v1.15.0 h1:js3yy885G8xwJa6iOISGFwd+qlUo5AvyXb7CiihdtiU=
|
||||
github.com/spf13/viper v1.15.0/go.mod h1:fFcTBJxvhhzSJiZy8n+PeW6t8l+KeT/uTARa0jHOQLA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
@@ -266,8 +255,6 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4=
|
||||
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c h1:3lbZUMbMiGUW/LMkfsEABsc5zNT9+b1CvsJx47JzJ8g=
|
||||
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c/go.mod h1:UrdRz5enIKZ63MEE3IF9l2/ebyx59GyGgPi+tICQdmM=
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
@@ -363,8 +350,8 @@ golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwY
|
||||
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.6.0 h1:L4ZwwTvKW9gr0ZMS1yrHD9GZhIuVjOBBnaKH+SPQK0Q=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package getter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetHTMLMeta(t *testing.T) {
|
||||
tests := []struct {
|
||||
urlStr string
|
||||
htmlMeta HTMLMeta
|
||||
}{}
|
||||
for _, test := range tests {
|
||||
metadata, err := GetHTMLMeta(test.urlStr)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, test.htmlMeta, *metadata)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// getter is using to get resources from url.
|
||||
// Package getter is using to get resources from url.
|
||||
// * Get metadata for website;
|
||||
// * Get image blob to avoid CORS;
|
||||
package getter
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package metric
|
||||
|
||||
// Metric is the API message for metric.
|
||||
type Metric struct {
|
||||
ID string
|
||||
Name string
|
||||
Labels map[string]string
|
||||
}
|
||||
|
||||
// Collector is the interface definition for metric collector.
|
||||
type Collector interface {
|
||||
Identify(id string) error
|
||||
Collect(metric *Metric) error
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package segment
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/segmentio/analytics-go"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
)
|
||||
|
||||
// collector is the metrics collector https://segment.com/.
|
||||
type collector struct {
|
||||
client analytics.Client
|
||||
}
|
||||
|
||||
// NewCollector creates a new instance of segment.
|
||||
func NewCollector(key string) metric.Collector {
|
||||
client := analytics.New(key)
|
||||
|
||||
return &collector{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// Identify will identify the server caller.
|
||||
func (c *collector) Identify(id string) error {
|
||||
return c.client.Enqueue(analytics.Identify{
|
||||
UserId: id,
|
||||
Timestamp: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
// Collect will exec all the segment collector.
|
||||
func (c *collector) Collect(metric *metric.Metric) error {
|
||||
properties := analytics.NewProperties()
|
||||
for key, value := range metric.Labels {
|
||||
properties.Set(key, value)
|
||||
}
|
||||
|
||||
return c.client.Enqueue(analytics.Track{
|
||||
UserId: metric.ID,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Event: metric.Name,
|
||||
Properties: properties,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type ChatCompletionMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type ChatCompletionChoice struct {
|
||||
Message *ChatCompletionMessage `json:"message"`
|
||||
}
|
||||
|
||||
type ChatCompletionResponse struct {
|
||||
Error any `json:"error"`
|
||||
Model string `json:"model"`
|
||||
Choices []ChatCompletionChoice `json:"choices"`
|
||||
}
|
||||
|
||||
func PostChatCompletion(messages []ChatCompletionMessage, apiKey string, apiHost string) (string, error) {
|
||||
if apiHost == "" {
|
||||
apiHost = "https://api.openai.com"
|
||||
}
|
||||
url, err := url.JoinPath(apiHost, "/v1/chat/completions")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
values := map[string]any{
|
||||
"model": "gpt-3.5-turbo",
|
||||
"messages": messages,
|
||||
"max_tokens": 2000,
|
||||
"temperature": 0,
|
||||
"frequency_penalty": 0.0,
|
||||
"presence_penalty": 0.0,
|
||||
}
|
||||
jsonValue, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonValue))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Set the API key in the request header
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
// Send the request to OpenAI's API
|
||||
client := http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read the response body
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
chatCompletionResponse := ChatCompletionResponse{}
|
||||
err = json.Unmarshal(responseBody, &chatCompletionResponse)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if chatCompletionResponse.Error != nil {
|
||||
errorBytes, err := json.Marshal(chatCompletionResponse.Error)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", errors.New(string(errorBytes))
|
||||
}
|
||||
if len(chatCompletionResponse.Choices) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return chatCompletionResponse.Choices[0].Message.Content, nil
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type TextCompletionChoice struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type TextCompletionResponse struct {
|
||||
Error any `json:"error"`
|
||||
Model string `json:"model"`
|
||||
Choices []TextCompletionChoice `json:"choices"`
|
||||
}
|
||||
|
||||
func PostTextCompletion(prompt string, apiKey string, apiHost string) (string, error) {
|
||||
if apiHost == "" {
|
||||
apiHost = "https://api.openai.com"
|
||||
}
|
||||
url, err := url.JoinPath(apiHost, "/v1/chat/completions")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
values := map[string]any{
|
||||
"model": "gpt-3.5-turbo",
|
||||
"prompt": prompt,
|
||||
"temperature": 0.5,
|
||||
"max_tokens": 100,
|
||||
"n": 1,
|
||||
"stop": ".",
|
||||
}
|
||||
jsonValue, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonValue))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Set the API key in the request header
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
// Send the request to OpenAI's API
|
||||
client := http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read the response body
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
textCompletionResponse := TextCompletionResponse{}
|
||||
err = json.Unmarshal(responseBody, &textCompletionResponse)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if textCompletionResponse.Error != nil {
|
||||
errorBytes, err := json.Marshal(textCompletionResponse.Error)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", errors.New(string(errorBytes))
|
||||
}
|
||||
if len(textCompletionResponse.Choices) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return textCompletionResponse.Choices[0].Text, nil
|
||||
}
|
||||
@@ -20,6 +20,7 @@ type Config struct {
|
||||
EndPoint string
|
||||
Region string
|
||||
URLPrefix string
|
||||
URLSuffix string
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
@@ -28,14 +29,14 @@ type Client struct {
|
||||
}
|
||||
|
||||
func NewClient(ctx context.Context, config *Config) (*Client, error) {
|
||||
resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
|
||||
resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...any) (aws.Endpoint, error) {
|
||||
return aws.Endpoint{
|
||||
URL: config.EndPoint,
|
||||
SigningRegion: config.Region,
|
||||
}, nil
|
||||
})
|
||||
|
||||
cfg, err := s3config.LoadDefaultConfig(ctx,
|
||||
awsConfig, err := s3config.LoadDefaultConfig(ctx,
|
||||
s3config.WithEndpointResolverWithOptions(resolver),
|
||||
s3config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(config.AccessKey, config.SecretKey, "")),
|
||||
)
|
||||
@@ -43,7 +44,7 @@ func NewClient(ctx context.Context, config *Config) (*Client, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := awss3.NewFromConfig(cfg)
|
||||
client := awss3.NewFromConfig(awsConfig)
|
||||
|
||||
return &Client{
|
||||
Client: client,
|
||||
@@ -53,7 +54,7 @@ func NewClient(ctx context.Context, config *Config) (*Client, error) {
|
||||
|
||||
func (client *Client) UploadFile(ctx context.Context, filename string, fileType string, src io.Reader) (string, error) {
|
||||
uploader := manager.NewUploader(client.Client)
|
||||
resp, err := uploader.Upload(ctx, &awss3.PutObjectInput{
|
||||
uploadOutput, err := uploader.Upload(ctx, &awss3.PutObjectInput{
|
||||
Bucket: aws.String(client.Config.Bucket),
|
||||
Key: aws.String(filename),
|
||||
Body: src,
|
||||
@@ -63,11 +64,14 @@ func (client *Client) UploadFile(ctx context.Context, filename string, fileType
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var link string
|
||||
if client.Config.URLPrefix == "" {
|
||||
link = resp.Location
|
||||
} else {
|
||||
link = fmt.Sprintf("%s/%s", client.Config.URLPrefix, filename)
|
||||
|
||||
link := uploadOutput.Location
|
||||
// If url prefix is set, use it as the file link.
|
||||
if client.Config.URLPrefix != "" {
|
||||
link = fmt.Sprintf("%s/%s%s", client.Config.URLPrefix, filename, client.Config.URLSuffix)
|
||||
}
|
||||
if link == "" {
|
||||
return "", fmt.Errorf("failed to get file link")
|
||||
}
|
||||
return link, nil
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 16 KiB |
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,13 @@ import (
|
||||
"github.com/usememos/memos/common"
|
||||
"github.com/usememos/memos/plugin/idp"
|
||||
"github.com/usememos/memos/plugin/idp/oauth2"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
"github.com/usememos/memos/store"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
func (s *Server) registerAuthRoutes(g *echo.Group, secret string) {
|
||||
g.POST("/auth/signin", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
signin := &api.SignIn{}
|
||||
@@ -31,10 +30,10 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
}
|
||||
user, err := s.Store.FindUser(ctx, userFind)
|
||||
if err != nil && common.ErrorCode(err) != common.NotFound {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user by username %s", signin.Username)).SetInternal(err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Incorrect login credentials, please try again")
|
||||
}
|
||||
if user == nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("User not found with username %s", signin.Username))
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Incorrect login credentials, please try again")
|
||||
} else if user.RowStatus == api.Archived {
|
||||
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with username %s", signin.Username))
|
||||
}
|
||||
@@ -42,11 +41,11 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
// Compare the stored hashed password, with the hashed version of the password that was received.
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(signin.Password)); err != nil {
|
||||
// If the two passwords don't match, return a 401 status.
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Incorrect password").SetInternal(err)
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Incorrect login credentials, please try again")
|
||||
}
|
||||
|
||||
if err = setUserSession(c, user); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set signin session").SetInternal(err)
|
||||
if err := GenerateTokensAndSetCookies(c, user, secret); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate tokens").SetInternal(err)
|
||||
}
|
||||
if err := s.createUserAuthSignInActivity(c, user); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
@@ -99,7 +98,7 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
Username: &userInfo.Identifier,
|
||||
})
|
||||
if err != nil && common.ErrorCode(err) != common.NotFound {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user by username %s", userInfo.Identifier)).SetInternal(err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Incorrect login credentials, please try again")
|
||||
}
|
||||
if user == nil {
|
||||
userCreate := &api.UserCreate{
|
||||
@@ -129,8 +128,8 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with username %s", userInfo.Identifier))
|
||||
}
|
||||
|
||||
if err = setUserSession(c, user); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set signin session").SetInternal(err)
|
||||
if err := GenerateTokensAndSetCookies(c, user, secret); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate tokens").SetInternal(err)
|
||||
}
|
||||
if err := s.createUserAuthSignInActivity(c, user); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
@@ -197,23 +196,18 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
|
||||
}
|
||||
if err := GenerateTokensAndSetCookies(c, user, secret); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate tokens").SetInternal(err)
|
||||
}
|
||||
if err := s.createUserAuthSignUpActivity(c, user); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
|
||||
err = setUserSession(c, user)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set signup session").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, composeResponse(user))
|
||||
})
|
||||
|
||||
g.POST("/auth/signout", func(c echo.Context) error {
|
||||
err := removeUserSession(c)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set sign out session").SetInternal(err)
|
||||
}
|
||||
|
||||
RemoveTokensAndCookies(c)
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
}
|
||||
@@ -237,9 +231,6 @@ func (s *Server) createUserAuthSignInActivity(c echo.Context, user *api.User) er
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: string(activity.Type),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -262,8 +253,5 @@ func (s *Server) createUserAuthSignUpActivity(c echo.Context, user *api.User) er
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: string(activity.Type),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
issuer = "memos"
|
||||
// Signing key section. For now, this is only used for signing, not for verifying since we only
|
||||
// have 1 version. But it will be used to maintain backward compatibility if we change the signing mechanism.
|
||||
keyID = "v1"
|
||||
// AccessTokenAudienceName is the audience name of the access token.
|
||||
AccessTokenAudienceName = "user.access-token"
|
||||
// RefreshTokenAudienceName is the audience name of the refresh token.
|
||||
RefreshTokenAudienceName = "user.refresh-token"
|
||||
apiTokenDuration = 2 * time.Hour
|
||||
accessTokenDuration = 24 * time.Hour
|
||||
refreshTokenDuration = 7 * 24 * time.Hour
|
||||
// RefreshThresholdDuration is the threshold duration for refreshing token.
|
||||
RefreshThresholdDuration = 1 * time.Hour
|
||||
|
||||
// CookieExpDuration expires slightly earlier than the jwt expiration. Client would be logged out if the user
|
||||
// cookie expires, thus the client would always logout first before attempting to make a request with the expired jwt.
|
||||
// Suppose we have a valid refresh token, we will refresh the token in 2 cases:
|
||||
// 1. The access token is about to expire in <<refreshThresholdDuration>>
|
||||
// 2. The access token has already expired, we refresh the token so that the ongoing request can pass through.
|
||||
CookieExpDuration = refreshTokenDuration - 1*time.Minute
|
||||
// AccessTokenCookieName is the cookie name of access token.
|
||||
AccessTokenCookieName = "access-token"
|
||||
// RefreshTokenCookieName is the cookie name of refresh token.
|
||||
RefreshTokenCookieName = "refresh-token"
|
||||
// UserIDCookieName is the cookie name of user ID.
|
||||
UserIDCookieName = "user"
|
||||
)
|
||||
|
||||
type claimsMessage struct {
|
||||
Name string `json:"name"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// GenerateAPIToken generates an API token.
|
||||
func GenerateAPIToken(userName string, userID int, secret string) (string, error) {
|
||||
expirationTime := time.Now().Add(apiTokenDuration)
|
||||
return generateToken(userName, userID, AccessTokenAudienceName, expirationTime, []byte(secret))
|
||||
}
|
||||
|
||||
// GenerateAccessToken generates an access token for web.
|
||||
func GenerateAccessToken(userName string, userID int, secret string) (string, error) {
|
||||
expirationTime := time.Now().Add(accessTokenDuration)
|
||||
return generateToken(userName, userID, AccessTokenAudienceName, expirationTime, []byte(secret))
|
||||
}
|
||||
|
||||
// GenerateRefreshToken generates a refresh token for web.
|
||||
func GenerateRefreshToken(userName string, userID int, secret string) (string, error) {
|
||||
expirationTime := time.Now().Add(refreshTokenDuration)
|
||||
return generateToken(userName, userID, RefreshTokenAudienceName, expirationTime, []byte(secret))
|
||||
}
|
||||
|
||||
func generateToken(username string, userID int, aud string, expirationTime time.Time, secret []byte) (string, error) {
|
||||
// Create the JWT claims, which includes the username and expiry time.
|
||||
claims := &claimsMessage{
|
||||
Name: username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Audience: jwt.ClaimStrings{aud},
|
||||
// In JWT, the expiry time is expressed as unix milliseconds.
|
||||
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: issuer,
|
||||
Subject: strconv.Itoa(userID),
|
||||
},
|
||||
}
|
||||
|
||||
// Declare the token with the HS256 algorithm used for signing, and the claims.
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
token.Header["kid"] = keyID
|
||||
|
||||
// Create the JWT string.
|
||||
tokenString, err := token.SignedString(secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return tokenString, nil
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
"github.com/usememos/memos/server/auth"
|
||||
)
|
||||
|
||||
const (
|
||||
// Context section
|
||||
// The key name used to store user id in the context
|
||||
// user id is extracted from the jwt token subject field.
|
||||
userIDContextKey = "user-id"
|
||||
)
|
||||
|
||||
// Claims creates a struct that will be encoded to a JWT.
|
||||
// We add jwt.RegisteredClaims as an embedded type, to provide fields such as name.
|
||||
type Claims struct {
|
||||
Name string `json:"name"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func getUserIDContextKey() string {
|
||||
return userIDContextKey
|
||||
}
|
||||
|
||||
// GenerateTokensAndSetCookies generates jwt token and saves it to the http-only cookie.
|
||||
func GenerateTokensAndSetCookies(c echo.Context, user *api.User, secret string) error {
|
||||
accessToken, err := auth.GenerateAccessToken(user.Username, user.ID, secret)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to generate access token")
|
||||
}
|
||||
|
||||
cookieExp := time.Now().Add(auth.CookieExpDuration)
|
||||
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
||||
|
||||
// We generate here a new refresh token and saving it to the cookie.
|
||||
refreshToken, err := auth.GenerateRefreshToken(user.Username, user.ID, secret)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to generate refresh token")
|
||||
}
|
||||
setTokenCookie(c, auth.RefreshTokenCookieName, refreshToken, cookieExp)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveTokensAndCookies removes the jwt token and refresh token from the cookies.
|
||||
func RemoveTokensAndCookies(c echo.Context) {
|
||||
// We set the expiration time to the past, so that the cookie will be removed.
|
||||
cookieExp := time.Now().Add(-1 * time.Hour)
|
||||
setTokenCookie(c, auth.AccessTokenCookieName, "", cookieExp)
|
||||
setTokenCookie(c, auth.RefreshTokenCookieName, "", cookieExp)
|
||||
}
|
||||
|
||||
// Here we are creating a new cookie, which will store the valid JWT token.
|
||||
func setTokenCookie(c echo.Context, name, token string, expiration time.Time) {
|
||||
cookie := new(http.Cookie)
|
||||
cookie.Name = name
|
||||
cookie.Value = token
|
||||
cookie.Expires = expiration
|
||||
cookie.Path = "/"
|
||||
// Http-only helps mitigate the risk of client side script accessing the protected cookie.
|
||||
cookie.HttpOnly = true
|
||||
cookie.SameSite = http.SameSiteStrictMode
|
||||
c.SetCookie(cookie)
|
||||
}
|
||||
|
||||
func extractTokenFromHeader(c echo.Context) (string, error) {
|
||||
authHeader := c.Request().Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
authHeaderParts := strings.Fields(authHeader)
|
||||
if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" {
|
||||
return "", errors.New("Authorization header format must be Bearer {token}")
|
||||
}
|
||||
|
||||
return authHeaderParts[1], nil
|
||||
}
|
||||
|
||||
func findAccessToken(c echo.Context) string {
|
||||
accessToken := ""
|
||||
cookie, _ := c.Cookie(auth.AccessTokenCookieName)
|
||||
if cookie != nil {
|
||||
accessToken = cookie.Value
|
||||
}
|
||||
if accessToken == "" {
|
||||
accessToken, _ = extractTokenFromHeader(c)
|
||||
}
|
||||
|
||||
return accessToken
|
||||
}
|
||||
|
||||
// JWTMiddleware validates the access token.
|
||||
// If the access token is about to expire or has expired and the request has a valid refresh token, it
|
||||
// will try to generate new access token and refresh token.
|
||||
func JWTMiddleware(server *Server, next echo.HandlerFunc, secret string) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
path := c.Request().URL.Path
|
||||
method := c.Request().Method
|
||||
|
||||
if server.defaultAuthSkipper(c) {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Skip validation for server status endpoints.
|
||||
if common.HasPrefixes(path, "/api/ping", "/api/idp", "/api/user/:id") && method == http.MethodGet {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
token := findAccessToken(c)
|
||||
if token == "" {
|
||||
// Allow the user to access the public endpoints.
|
||||
if common.HasPrefixes(path, "/o") {
|
||||
return next(c)
|
||||
}
|
||||
// When the request is not authenticated, we allow the user to access the memo endpoints for those public memos.
|
||||
if common.HasPrefixes(path, "/api/status", "/api/memo") && method == http.MethodGet {
|
||||
return next(c)
|
||||
}
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing access token")
|
||||
}
|
||||
|
||||
claims := &Claims{}
|
||||
accessToken, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
|
||||
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
||||
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
||||
}
|
||||
if kid, ok := t.Header["kid"].(string); ok {
|
||||
if kid == "v1" {
|
||||
return []byte(secret), nil
|
||||
}
|
||||
}
|
||||
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
|
||||
})
|
||||
|
||||
if !audienceContains(claims.Audience, auth.AccessTokenAudienceName) {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized,
|
||||
fmt.Sprintf("Invalid access token, audience mismatch, got %q, expected %q. you may send request to the wrong environment",
|
||||
claims.Audience,
|
||||
auth.AccessTokenAudienceName,
|
||||
))
|
||||
}
|
||||
|
||||
generateToken := time.Until(claims.ExpiresAt.Time) < auth.RefreshThresholdDuration
|
||||
if err != nil {
|
||||
var ve *jwt.ValidationError
|
||||
if errors.As(err, &ve) {
|
||||
// If expiration error is the only error, we will clear the err
|
||||
// and generate new access token and refresh token
|
||||
if ve.Errors == jwt.ValidationErrorExpired {
|
||||
generateToken = true
|
||||
}
|
||||
} else {
|
||||
return &echo.HTTPError{
|
||||
Code: http.StatusUnauthorized,
|
||||
Message: "Invalid or expired access token",
|
||||
Internal: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We either have a valid access token or we will attempt to generate new access token and refresh token
|
||||
ctx := c.Request().Context()
|
||||
userID, err := strconv.Atoi(claims.Subject)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Malformed ID in the token.")
|
||||
}
|
||||
|
||||
// Even if there is no error, we still need to make sure the user still exists.
|
||||
user, err := server.Store.FindUser(ctx, &api.UserFind{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to find user ID: %d", userID)).SetInternal(err)
|
||||
}
|
||||
if user == nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Failed to find user ID: %d", userID))
|
||||
}
|
||||
|
||||
if generateToken {
|
||||
generateTokenFunc := func() error {
|
||||
rc, err := c.Cookie(auth.RefreshTokenCookieName)
|
||||
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Failed to generate access token. Missing refresh token.")
|
||||
}
|
||||
|
||||
// Parses token and checks if it's valid.
|
||||
refreshTokenClaims := &Claims{}
|
||||
refreshToken, err := jwt.ParseWithClaims(rc.Value, refreshTokenClaims, func(t *jwt.Token) (any, error) {
|
||||
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
||||
return nil, errors.Errorf("unexpected refresh token signing method=%v, expected %v", t.Header["alg"], jwt.SigningMethodHS256)
|
||||
}
|
||||
|
||||
if kid, ok := t.Header["kid"].(string); ok {
|
||||
if kid == "v1" {
|
||||
return []byte(secret), nil
|
||||
}
|
||||
}
|
||||
return nil, errors.Errorf("unexpected refresh token kid=%v", t.Header["kid"])
|
||||
})
|
||||
if err != nil {
|
||||
if err == jwt.ErrSignatureInvalid {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Failed to generate access token. Invalid refresh token signature.")
|
||||
}
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to refresh expired token. User Id %d", userID)).SetInternal(err)
|
||||
}
|
||||
|
||||
if !audienceContains(refreshTokenClaims.Audience, auth.RefreshTokenAudienceName) {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized,
|
||||
fmt.Sprintf("Invalid refresh token, audience mismatch, got %q, expected %q. you may send request to the wrong environment",
|
||||
refreshTokenClaims.Audience,
|
||||
auth.RefreshTokenAudienceName,
|
||||
))
|
||||
}
|
||||
|
||||
// If we have a valid refresh token, we will generate new access token and refresh token
|
||||
if refreshToken != nil && refreshToken.Valid {
|
||||
if err := GenerateTokensAndSetCookies(c, user, secret); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to refresh expired token. User Id %d", userID)).SetInternal(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// It may happen that we still have a valid access token, but we encounter issue when trying to generate new token
|
||||
// In such case, we won't return the error.
|
||||
if err := generateTokenFunc(); err != nil && !accessToken.Valid {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Stores userID into context.
|
||||
c.Set(getUserIDContextKey(), userID)
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
func audienceContains(audience jwt.ClaimStrings, token string) bool {
|
||||
for _, v := range audience {
|
||||
if v == token {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
@@ -30,10 +29,9 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
}
|
||||
|
||||
if memoCreate.Visibility == "" {
|
||||
userSettingMemoVisibilityKey := api.UserSettingMemoVisibilityKey
|
||||
userMemoVisibilitySetting, err := s.Store.FindUserSetting(ctx, &api.UserSettingFind{
|
||||
UserID: userID,
|
||||
Key: &userSettingMemoVisibilityKey,
|
||||
Key: api.UserSettingMemoVisibilityKey,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user setting").SetInternal(err)
|
||||
@@ -66,7 +64,18 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting").SetInternal(err)
|
||||
}
|
||||
if disablePublicMemos {
|
||||
memoCreate.Visibility = api.Private
|
||||
// Allow if the user is an admin.
|
||||
user, err := s.Store.FindUser(ctx, &api.UserFind{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||
}
|
||||
// Only enforce private if you're a regular user.
|
||||
// Admins should know what they're doing.
|
||||
if user.Role == "USER" {
|
||||
memoCreate.Visibility = api.Private
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,23 +343,6 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
return c.JSON(http.StatusOK, composeResponse(resourceList))
|
||||
})
|
||||
|
||||
g.GET("/memo/amount", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
normalRowStatus := api.Normal
|
||||
memoFind := &api.MemoFind{
|
||||
RowStatus: &normalRowStatus,
|
||||
}
|
||||
if userID, err := strconv.Atoi(c.QueryParam("userId")); err == nil {
|
||||
memoFind.CreatorID = &userID
|
||||
}
|
||||
|
||||
memoList, err := s.Store.FindMemoList(ctx, memoFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, composeResponse(memoList))
|
||||
})
|
||||
|
||||
g.GET("/memo/stats", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
normalStatus := api.Normal
|
||||
@@ -522,8 +514,5 @@ func (s *Server) createMemoCreateActivity(c echo.Context, memo *api.Memo) error
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: string(activity.Type),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
"github.com/usememos/memos/plugin/metrics/segment"
|
||||
"github.com/usememos/memos/server/profile"
|
||||
"github.com/usememos/memos/server/version"
|
||||
)
|
||||
|
||||
// MetricCollector is the metric collector.
|
||||
type MetricCollector struct {
|
||||
collector metric.Collector
|
||||
ID string
|
||||
Enabled bool
|
||||
Profile *profile.Profile
|
||||
}
|
||||
|
||||
const (
|
||||
segmentMetricWriteKey = "NbPruMMmfqfD2AMCw3pkxZTsszVS3hKq"
|
||||
)
|
||||
|
||||
func (s *Server) registerMetricCollector() {
|
||||
c := segment.NewCollector(segmentMetricWriteKey)
|
||||
mc := &MetricCollector{
|
||||
collector: c,
|
||||
ID: s.ID,
|
||||
Enabled: false,
|
||||
Profile: s.Profile,
|
||||
}
|
||||
s.Collector = mc
|
||||
}
|
||||
|
||||
func (mc *MetricCollector) Identify(_ context.Context) {
|
||||
if !mc.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
err := mc.collector.Identify(mc.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to request segment, error: %+v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (mc *MetricCollector) Collect(_ context.Context, metric *metric.Metric) {
|
||||
if !mc.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
if metric.Labels == nil {
|
||||
metric.Labels = map[string]string{}
|
||||
}
|
||||
metric.Labels["mode"] = mc.Profile.Mode
|
||||
metric.Labels["version"] = version.GetCurrentVersion(mc.Profile.Mode)
|
||||
metric.ID = mc.ID
|
||||
err := mc.collector.Collect(metric)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to request segment, error: %+v\n", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
"github.com/usememos/memos/plugin/openai"
|
||||
)
|
||||
|
||||
func (s *Server) registerOpenAIRoutes(g *echo.Group) {
|
||||
g.POST("/openai/chat-completion", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
openAIConfigSetting, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{
|
||||
Name: api.SystemSettingOpenAIConfigName,
|
||||
})
|
||||
if err != nil && common.ErrorCode(err) != common.NotFound {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find openai key").SetInternal(err)
|
||||
}
|
||||
|
||||
openAIConfig := api.OpenAIConfig{}
|
||||
if openAIConfigSetting != nil {
|
||||
err = json.Unmarshal([]byte(openAIConfigSetting.Value), &openAIConfig)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal openai system setting value").SetInternal(err)
|
||||
}
|
||||
}
|
||||
if openAIConfig.Key == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "OpenAI API key not set")
|
||||
}
|
||||
|
||||
messages := []openai.ChatCompletionMessage{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(&messages); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post chat completion request").SetInternal(err)
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "No messages provided")
|
||||
}
|
||||
|
||||
result, err := openai.PostChatCompletion(messages, openAIConfig.Key, openAIConfig.Host)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to post chat completion").SetInternal(err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, composeResponse(result))
|
||||
})
|
||||
|
||||
g.GET("/openai/enabled", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
openAIConfigSetting, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{
|
||||
Name: api.SystemSettingOpenAIConfigName,
|
||||
})
|
||||
if err != nil && common.ErrorCode(err) != common.NotFound {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find openai key").SetInternal(err)
|
||||
}
|
||||
|
||||
openAIConfig := api.OpenAIConfig{}
|
||||
if openAIConfigSetting != nil {
|
||||
err = json.Unmarshal([]byte(openAIConfigSetting.Value), &openAIConfig)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal openai system setting value").SetInternal(err)
|
||||
}
|
||||
}
|
||||
if openAIConfig.Key == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "OpenAI API key not set")
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, composeResponse(openAIConfig.Key != ""))
|
||||
})
|
||||
}
|
||||
@@ -24,6 +24,10 @@ type Profile struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
func (p *Profile) IsDev() bool {
|
||||
return p.Mode != "prod"
|
||||
}
|
||||
|
||||
func checkDSN(dataDir string) (string, error) {
|
||||
// Convert to absolute path if relative path is supplied.
|
||||
if !filepath.IsAbs(dataDir) {
|
||||
@@ -44,7 +48,7 @@ func checkDSN(dataDir string) (string, error) {
|
||||
return dataDir, nil
|
||||
}
|
||||
|
||||
// GetDevProfile will return a profile for dev or prod.
|
||||
// GetProfile will return a profile for dev or prod.
|
||||
func GetProfile() (*Profile, error) {
|
||||
profile := Profile{}
|
||||
err := viper.Unmarshal(&profile)
|
||||
|
||||
@@ -7,6 +7,10 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -15,8 +19,9 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
"github.com/usememos/memos/common/log"
|
||||
"github.com/usememos/memos/plugin/storage/s3"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -24,6 +29,8 @@ const (
|
||||
maxFileSize = 32 << 20
|
||||
)
|
||||
|
||||
var fileKeyPattern = regexp.MustCompile(`\{[a-z]{1,9}\}`)
|
||||
|
||||
func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
g.POST("/resource", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
@@ -42,6 +49,7 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
if resourceCreate.ExternalLink != "" && !strings.HasPrefix(resourceCreate.ExternalLink, "http") {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid external link")
|
||||
}
|
||||
|
||||
resource, err := s.Store.CreateResource(ctx, resourceCreate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err)
|
||||
@@ -71,40 +79,76 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Upload file not found").SetInternal(err)
|
||||
}
|
||||
|
||||
filename := file.Filename
|
||||
filetype := file.Header.Get("Content-Type")
|
||||
size := file.Size
|
||||
src, err := file.Open()
|
||||
sourceFile, err := file.Open()
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to open file").SetInternal(err)
|
||||
}
|
||||
defer src.Close()
|
||||
defer sourceFile.Close()
|
||||
|
||||
systemSetting, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{Name: api.SystemSettingStorageServiceIDName})
|
||||
var resourceCreate *api.ResourceCreate
|
||||
systemSettingStorageServiceID, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{Name: api.SystemSettingStorageServiceIDName})
|
||||
if err != nil && common.ErrorCode(err) != common.NotFound {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find storage").SetInternal(err)
|
||||
}
|
||||
storageServiceID := 0
|
||||
if systemSetting != nil {
|
||||
err = json.Unmarshal([]byte(systemSetting.Value), &storageServiceID)
|
||||
storageServiceID := api.DatabaseStorage
|
||||
if systemSettingStorageServiceID != nil {
|
||||
err = json.Unmarshal([]byte(systemSettingStorageServiceID.Value), &storageServiceID)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal storage service id").SetInternal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var resourceCreate *api.ResourceCreate
|
||||
if storageServiceID == 0 {
|
||||
fileBytes, err := io.ReadAll(src)
|
||||
if storageServiceID == api.DatabaseStorage {
|
||||
fileBytes, err := io.ReadAll(sourceFile)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to read file").SetInternal(err)
|
||||
}
|
||||
resourceCreate = &api.ResourceCreate{
|
||||
CreatorID: userID,
|
||||
Filename: filename,
|
||||
Filename: file.Filename,
|
||||
Type: filetype,
|
||||
Size: size,
|
||||
Blob: fileBytes,
|
||||
}
|
||||
} else if storageServiceID == api.LocalStorage {
|
||||
systemSettingLocalStoragePath, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{Name: api.SystemSettingLocalStoragePathName})
|
||||
if err != nil && common.ErrorCode(err) != common.NotFound {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find local storage path setting").SetInternal(err)
|
||||
}
|
||||
localStoragePath := ""
|
||||
if systemSettingLocalStoragePath != nil {
|
||||
err = json.Unmarshal([]byte(systemSettingLocalStoragePath.Value), &localStoragePath)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal local storage path setting").SetInternal(err)
|
||||
}
|
||||
}
|
||||
filePath := localStoragePath
|
||||
if !strings.Contains(filePath, "{filename}") {
|
||||
filePath = path.Join(filePath, "{filename}")
|
||||
}
|
||||
filePath = path.Join(s.Profile.Data, replacePathTemplate(filePath, file.Filename))
|
||||
dir, filename := filepath.Split(filePath)
|
||||
if err = os.MkdirAll(dir, os.ModePerm); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create directory").SetInternal(err)
|
||||
}
|
||||
dst, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create file").SetInternal(err)
|
||||
}
|
||||
defer dst.Close()
|
||||
_, err = io.Copy(dst, sourceFile)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to copy file").SetInternal(err)
|
||||
}
|
||||
|
||||
resourceCreate = &api.ResourceCreate{
|
||||
CreatorID: userID,
|
||||
Filename: filename,
|
||||
Type: filetype,
|
||||
Size: size,
|
||||
InternalPath: filePath,
|
||||
}
|
||||
} else {
|
||||
storage, err := s.Store.FindStorage(ctx, &api.StorageFind{ID: &storageServiceID})
|
||||
if err != nil {
|
||||
@@ -113,19 +157,26 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
|
||||
if storage.Type == api.StorageS3 {
|
||||
s3Config := storage.Config.S3Config
|
||||
s3client, err := s3.NewClient(ctx, &s3.Config{
|
||||
s3Client, err := s3.NewClient(ctx, &s3.Config{
|
||||
AccessKey: s3Config.AccessKey,
|
||||
SecretKey: s3Config.SecretKey,
|
||||
EndPoint: s3Config.EndPoint,
|
||||
Region: s3Config.Region,
|
||||
Bucket: s3Config.Bucket,
|
||||
URLPrefix: s3Config.URLPrefix,
|
||||
URLSuffix: s3Config.URLSuffix,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to new s3 client").SetInternal(err)
|
||||
}
|
||||
|
||||
link, err := s3client.UploadFile(ctx, filename, filetype, src)
|
||||
filePath := s3Config.Path
|
||||
if !strings.Contains(filePath, "{filename}") {
|
||||
filePath = path.Join(filePath, "{filename}")
|
||||
}
|
||||
filePath = replacePathTemplate(filePath, file.Filename)
|
||||
_, filename := filepath.Split(filePath)
|
||||
link, err := s3Client.UploadFile(ctx, filePath, filetype, sourceFile)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upload via s3 client").SetInternal(err)
|
||||
}
|
||||
@@ -140,6 +191,8 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
}
|
||||
}
|
||||
|
||||
publicID := common.GenUUID()
|
||||
resourceCreate.PublicID = publicID
|
||||
resource, err := s.Store.CreateResource(ctx, resourceCreate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err)
|
||||
@@ -159,69 +212,20 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
resourceFind := &api.ResourceFind{
|
||||
CreatorID: &userID,
|
||||
}
|
||||
if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
|
||||
resourceFind.Limit = &limit
|
||||
}
|
||||
if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil {
|
||||
resourceFind.Offset = &offset
|
||||
}
|
||||
|
||||
list, err := s.Store.FindResourceList(ctx, resourceFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
|
||||
}
|
||||
|
||||
for _, resource := range list {
|
||||
memoResourceList, err := s.Store.FindMemoResourceList(ctx, &api.MemoResourceFind{
|
||||
ResourceID: &resource.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo resource list").SetInternal(err)
|
||||
}
|
||||
resource.LinkedMemoAmount = len(memoResourceList)
|
||||
}
|
||||
return c.JSON(http.StatusOK, composeResponse(list))
|
||||
})
|
||||
|
||||
g.GET("/resource/:resourceId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
resourceID, err := strconv.Atoi(c.Param("resourceId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
resourceFind := &api.ResourceFind{
|
||||
ID: &resourceID,
|
||||
CreatorID: &userID,
|
||||
GetBlob: true,
|
||||
}
|
||||
resource, err := s.Store.FindResource(ctx, resourceFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, composeResponse(resource))
|
||||
})
|
||||
|
||||
g.GET("/resource/:resourceId/blob", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
resourceID, err := strconv.Atoi(c.Param("resourceId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
resourceFind := &api.ResourceFind{
|
||||
ID: &resourceID,
|
||||
CreatorID: &userID,
|
||||
GetBlob: true,
|
||||
}
|
||||
resource, err := s.Store.FindResource(ctx, resourceFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource").SetInternal(err)
|
||||
}
|
||||
return c.Stream(http.StatusOK, resource.Type, bytes.NewReader(resource.Blob))
|
||||
})
|
||||
|
||||
g.PATCH("/resource/:resourceId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
@@ -253,6 +257,11 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch resource request").SetInternal(err)
|
||||
}
|
||||
|
||||
if resourcePatch.ResetPublicID != nil && *resourcePatch.ResetPublicID {
|
||||
publicID := common.GenUUID()
|
||||
resourcePatch.PublicID = &publicID
|
||||
}
|
||||
|
||||
resourcePatch.ID = resourceID
|
||||
resource, err = s.Store.PatchResource(ctx, resourcePatch)
|
||||
if err != nil {
|
||||
@@ -284,6 +293,13 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
if resource.InternalPath != "" {
|
||||
err := os.Remove(resource.InternalPath)
|
||||
if err != nil {
|
||||
log.Warn(fmt.Sprintf("failed to delete local file with path %s", resource.InternalPath), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
resourceDelete := &api.ResourceDelete{
|
||||
ID: resourceID,
|
||||
}
|
||||
@@ -298,19 +314,19 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
}
|
||||
|
||||
func (s *Server) registerResourcePublicRoutes(g *echo.Group) {
|
||||
g.GET("/r/:resourceId/:filename", func(c echo.Context) error {
|
||||
g.GET("/r/:resourceId/:publicId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
resourceID, err := strconv.Atoi(c.Param("resourceId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
|
||||
}
|
||||
filename, err := url.QueryUnescape(c.Param("filename"))
|
||||
publicID, err := url.QueryUnescape(c.Param("publicId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("filename is invalid: %s", c.Param("filename"))).SetInternal(err)
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("publicID is invalid: %s", c.Param("publicId"))).SetInternal(err)
|
||||
}
|
||||
resourceFind := &api.ResourceFind{
|
||||
ID: &resourceID,
|
||||
Filename: &filename,
|
||||
PublicID: &publicID,
|
||||
GetBlob: true,
|
||||
}
|
||||
resource, err := s.Store.FindResource(ctx, resourceFind)
|
||||
@@ -318,16 +334,29 @@ func (s *Server) registerResourcePublicRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find resource by ID: %v", resourceID)).SetInternal(err)
|
||||
}
|
||||
|
||||
blob := resource.Blob
|
||||
if resource.InternalPath != "" {
|
||||
src, err := os.Open(resource.InternalPath)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to open the local resource: %s", resource.InternalPath)).SetInternal(err)
|
||||
}
|
||||
defer src.Close()
|
||||
blob, err = io.ReadAll(src)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to read the local resource: %s", resource.InternalPath)).SetInternal(err)
|
||||
}
|
||||
}
|
||||
|
||||
c.Response().Writer.Header().Set(echo.HeaderCacheControl, "max-age=31536000, immutable")
|
||||
c.Response().Writer.Header().Set(echo.HeaderContentSecurityPolicy, "default-src 'self'")
|
||||
resourceType := strings.ToLower(resource.Type)
|
||||
if strings.HasPrefix(resourceType, "text") {
|
||||
resourceType = echo.MIMETextPlainCharsetUTF8
|
||||
} else if strings.HasPrefix(resourceType, "video") || strings.HasPrefix(resourceType, "audio") {
|
||||
http.ServeContent(c.Response(), c.Request(), resource.Filename, time.Unix(resource.UpdatedTs, 0), bytes.NewReader(resource.Blob))
|
||||
http.ServeContent(c.Response(), c.Request(), resource.Filename, time.Unix(resource.UpdatedTs, 0), bytes.NewReader(blob))
|
||||
return nil
|
||||
}
|
||||
return c.Stream(http.StatusOK, resourceType, bytes.NewReader(resource.Blob))
|
||||
return c.Stream(http.StatusOK, resourceType, bytes.NewReader(blob))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -351,8 +380,31 @@ func (s *Server) createResourceCreateActivity(c echo.Context, resource *api.Reso
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: string(activity.Type),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func replacePathTemplate(path string, filename string) string {
|
||||
t := time.Now()
|
||||
path = fileKeyPattern.ReplaceAllStringFunc(path, func(s string) string {
|
||||
switch s {
|
||||
case "{filename}":
|
||||
return filename
|
||||
case "{timestamp}":
|
||||
return fmt.Sprintf("%d", t.Unix())
|
||||
case "{year}":
|
||||
return fmt.Sprintf("%d", t.Year())
|
||||
case "{month}":
|
||||
return fmt.Sprintf("%02d", t.Month())
|
||||
case "{day}":
|
||||
return fmt.Sprintf("%02d", t.Day())
|
||||
case "{hour}":
|
||||
return fmt.Sprintf("%02d", t.Hour())
|
||||
case "{minute}":
|
||||
return fmt.Sprintf("%02d", t.Minute())
|
||||
case "{second}":
|
||||
return fmt.Sprintf("%02d", t.Second())
|
||||
}
|
||||
return s
|
||||
})
|
||||
return path
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/gorilla/feeds"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
)
|
||||
|
||||
func (s *Server) registerRSSRoutes(g *echo.Group) {
|
||||
@@ -35,7 +36,7 @@ func (s *Server) registerRSSRoutes(g *echo.Group) {
|
||||
}
|
||||
|
||||
baseURL := c.Scheme() + "://" + c.Request().Host
|
||||
rss, err := generateRSSFromMemoList(memoList, baseURL, &systemCustomizedProfile)
|
||||
rss, err := generateRSSFromMemoList(memoList, baseURL, systemCustomizedProfile)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate rss").SetInternal(err)
|
||||
}
|
||||
@@ -72,7 +73,7 @@ func (s *Server) registerRSSRoutes(g *echo.Group) {
|
||||
|
||||
baseURL := c.Scheme() + "://" + c.Request().Host
|
||||
|
||||
rss, err := generateRSSFromMemoList(memoList, baseURL, &systemCustomizedProfile)
|
||||
rss, err := generateRSSFromMemoList(memoList, baseURL, systemCustomizedProfile)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate rss").SetInternal(err)
|
||||
}
|
||||
@@ -92,13 +93,10 @@ func generateRSSFromMemoList(memoList []*api.Memo, baseURL string, profile *api.
|
||||
Created: time.Now(),
|
||||
}
|
||||
|
||||
var itemCountLimit = min(len(memoList), MaxRSSItemCount)
|
||||
|
||||
var itemCountLimit = common.Min(len(memoList), MaxRSSItemCount)
|
||||
feed.Items = make([]*feeds.Item, itemCountLimit)
|
||||
|
||||
for i := 0; i < itemCountLimit; i++ {
|
||||
memo := memoList[i]
|
||||
|
||||
feed.Items[i] = &feeds.Item{
|
||||
Title: getRSSItemTitle(memo.Content),
|
||||
Link: &feeds.Link{Href: baseURL + "/m/" + strconv.Itoa(memo.ID)},
|
||||
@@ -114,64 +112,25 @@ func generateRSSFromMemoList(memoList []*api.Memo, baseURL string, profile *api.
|
||||
return rss, nil
|
||||
}
|
||||
|
||||
func getSystemCustomizedProfile(ctx context.Context, s *Server) (api.CustomizedProfile, error) {
|
||||
systemStatus := api.SystemStatus{
|
||||
CustomizedProfile: api.CustomizedProfile{
|
||||
Name: "memos",
|
||||
LogoURL: "",
|
||||
Description: "",
|
||||
Locale: "en",
|
||||
Appearance: "system",
|
||||
ExternalURL: "",
|
||||
},
|
||||
func getSystemCustomizedProfile(ctx context.Context, s *Server) (*api.CustomizedProfile, error) {
|
||||
customizedProfile := &api.CustomizedProfile{
|
||||
Name: "memos",
|
||||
LogoURL: "",
|
||||
Description: "",
|
||||
Locale: "en",
|
||||
Appearance: "system",
|
||||
ExternalURL: "",
|
||||
}
|
||||
|
||||
systemSettingList, err := s.Store.FindSystemSettingList(ctx, &api.SystemSettingFind{})
|
||||
if err != nil {
|
||||
return api.CustomizedProfile{}, err
|
||||
systemSetting, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{
|
||||
Name: api.SystemSettingCustomizedProfileName,
|
||||
})
|
||||
if err != nil && common.ErrorCode(err) != common.NotFound {
|
||||
return nil, err
|
||||
}
|
||||
for _, systemSetting := range systemSettingList {
|
||||
if systemSetting.Name == api.SystemSettingServerID || systemSetting.Name == api.SystemSettingSecretSessionName {
|
||||
continue
|
||||
}
|
||||
|
||||
var value interface{}
|
||||
err := json.Unmarshal([]byte(systemSetting.Value), &value)
|
||||
if err != nil {
|
||||
return api.CustomizedProfile{}, err
|
||||
}
|
||||
|
||||
if systemSetting.Name == api.SystemSettingCustomizedProfileName {
|
||||
valueMap := value.(map[string]interface{})
|
||||
systemStatus.CustomizedProfile = api.CustomizedProfile{}
|
||||
if v := valueMap["name"]; v != nil {
|
||||
systemStatus.CustomizedProfile.Name = v.(string)
|
||||
}
|
||||
if v := valueMap["logoUrl"]; v != nil {
|
||||
systemStatus.CustomizedProfile.LogoURL = v.(string)
|
||||
}
|
||||
if v := valueMap["description"]; v != nil {
|
||||
systemStatus.CustomizedProfile.Description = v.(string)
|
||||
}
|
||||
if v := valueMap["locale"]; v != nil {
|
||||
systemStatus.CustomizedProfile.Locale = v.(string)
|
||||
}
|
||||
if v := valueMap["appearance"]; v != nil {
|
||||
systemStatus.CustomizedProfile.Appearance = v.(string)
|
||||
}
|
||||
if v := valueMap["externalUrl"]; v != nil {
|
||||
systemStatus.CustomizedProfile.ExternalURL = v.(string)
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal([]byte(systemSetting.Value), customizedProfile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return systemStatus.CustomizedProfile, nil
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
return customizedProfile, nil
|
||||
}
|
||||
|
||||
func getRSSItemTitle(content string) string {
|
||||
@@ -180,7 +139,7 @@ func getRSSItemTitle(content string) string {
|
||||
title = strings.Split(content, "\n")[0][2:]
|
||||
} else {
|
||||
title = strings.Split(content, "\n")[0]
|
||||
var titleLengthLimit = min(len(title), MaxRSSItemTitleLength)
|
||||
var titleLengthLimit = common.Min(len(title), MaxRSSItemTitleLength)
|
||||
if titleLengthLimit < len(title) {
|
||||
title = title[:titleLengthLimit] + "..."
|
||||
}
|
||||
|
||||
@@ -9,13 +9,10 @@ import (
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/api"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
"github.com/usememos/memos/server/profile"
|
||||
"github.com/usememos/memos/store"
|
||||
"github.com/usememos/memos/store/db"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/labstack/echo-contrib/session"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
@@ -24,10 +21,9 @@ type Server struct {
|
||||
e *echo.Echo
|
||||
db *sql.DB
|
||||
|
||||
ID string
|
||||
Profile *profile.Profile
|
||||
Store *store.Store
|
||||
Collector *MetricCollector
|
||||
ID string
|
||||
Profile *profile.Profile
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
func NewServer(ctx context.Context, profile *profile.Profile) (*Server, error) {
|
||||
@@ -83,33 +79,32 @@ func NewServer(ctx context.Context, profile *profile.Profile) (*Server, error) {
|
||||
}
|
||||
s.ID = serverID
|
||||
|
||||
secretSessionName := "usememos"
|
||||
embedFrontend(e)
|
||||
|
||||
secret := "usememos"
|
||||
if profile.Mode == "prod" {
|
||||
secretSessionName, err = s.getSystemSecretSessionName(ctx)
|
||||
secret, err = s.getSystemSecretSessionName(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
e.Use(session.Middleware(sessions.NewCookieStore([]byte(secretSessionName))))
|
||||
|
||||
embedFrontend(e)
|
||||
|
||||
// Register MetricCollector to server.
|
||||
s.registerMetricCollector()
|
||||
|
||||
rootGroup := e.Group("")
|
||||
s.registerRSSRoutes(rootGroup)
|
||||
|
||||
publicGroup := e.Group("/o")
|
||||
s.registerResourcePublicRoutes(publicGroup)
|
||||
publicGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return JWTMiddleware(s, next, secret)
|
||||
})
|
||||
registerGetterPublicRoutes(publicGroup)
|
||||
s.registerResourcePublicRoutes(publicGroup)
|
||||
|
||||
apiGroup := e.Group("/api")
|
||||
apiGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return aclMiddleware(s, next)
|
||||
return JWTMiddleware(s, next, secret)
|
||||
})
|
||||
s.registerSystemRoutes(apiGroup)
|
||||
s.registerAuthRoutes(apiGroup)
|
||||
s.registerAuthRoutes(apiGroup, secret)
|
||||
s.registerUserRoutes(apiGroup)
|
||||
s.registerMemoRoutes(apiGroup)
|
||||
s.registerShortcutRoutes(apiGroup)
|
||||
@@ -117,6 +112,7 @@ func NewServer(ctx context.Context, profile *profile.Profile) (*Server, error) {
|
||||
s.registerTagRoutes(apiGroup)
|
||||
s.registerStorageRoutes(apiGroup)
|
||||
s.registerIdentityProviderRoutes(apiGroup)
|
||||
s.registerOpenAIRoutes(apiGroup)
|
||||
|
||||
return s, nil
|
||||
}
|
||||
@@ -125,7 +121,6 @@ func (s *Server) Start(ctx context.Context) error {
|
||||
if err := s.createServerStartActivity(ctx); err != nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
s.Collector.Identify(ctx)
|
||||
return s.e.Start(fmt.Sprintf(":%d", s.Profile.Port))
|
||||
}
|
||||
|
||||
@@ -164,8 +159,5 @@ func (s *Server) createServerStartActivity(ctx context.Context) error {
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: string(activity.Type),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
"github.com/usememos/memos/common/log"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
@@ -40,6 +42,7 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
|
||||
Profile: *s.Profile,
|
||||
DBSize: 0,
|
||||
AllowSignUp: false,
|
||||
IgnoreUpgrade: false,
|
||||
DisablePublicMemos: false,
|
||||
AdditionalStyle: "",
|
||||
AdditionalScript: "",
|
||||
@@ -51,7 +54,8 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
|
||||
Appearance: "system",
|
||||
ExternalURL: "",
|
||||
},
|
||||
StorageServiceID: 0,
|
||||
StorageServiceID: api.DatabaseStorage,
|
||||
LocalStoragePath: "",
|
||||
}
|
||||
|
||||
systemSettingList, err := s.Store.FindSystemSettingList(ctx, &api.SystemSettingFind{})
|
||||
@@ -59,47 +63,38 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting list").SetInternal(err)
|
||||
}
|
||||
for _, systemSetting := range systemSettingList {
|
||||
if systemSetting.Name == api.SystemSettingServerID || systemSetting.Name == api.SystemSettingSecretSessionName {
|
||||
if systemSetting.Name == api.SystemSettingServerIDName || systemSetting.Name == api.SystemSettingSecretSessionName || systemSetting.Name == api.SystemSettingOpenAIConfigName {
|
||||
continue
|
||||
}
|
||||
|
||||
var value interface{}
|
||||
err := json.Unmarshal([]byte(systemSetting.Value), &value)
|
||||
var baseValue any
|
||||
err := json.Unmarshal([]byte(systemSetting.Value), &baseValue)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting value").SetInternal(err)
|
||||
log.Warn("Failed to unmarshal system setting value", zap.String("setting name", systemSetting.Name.String()))
|
||||
continue
|
||||
}
|
||||
|
||||
if systemSetting.Name == api.SystemSettingAllowSignUpName {
|
||||
systemStatus.AllowSignUp = value.(bool)
|
||||
systemStatus.AllowSignUp = baseValue.(bool)
|
||||
} else if systemSetting.Name == api.SystemSettingIgnoreUpgradeName {
|
||||
systemStatus.IgnoreUpgrade = baseValue.(bool)
|
||||
} else if systemSetting.Name == api.SystemSettingDisablePublicMemosName {
|
||||
systemStatus.DisablePublicMemos = value.(bool)
|
||||
systemStatus.DisablePublicMemos = baseValue.(bool)
|
||||
} else if systemSetting.Name == api.SystemSettingAdditionalStyleName {
|
||||
systemStatus.AdditionalStyle = value.(string)
|
||||
systemStatus.AdditionalStyle = baseValue.(string)
|
||||
} else if systemSetting.Name == api.SystemSettingAdditionalScriptName {
|
||||
systemStatus.AdditionalScript = value.(string)
|
||||
systemStatus.AdditionalScript = baseValue.(string)
|
||||
} else if systemSetting.Name == api.SystemSettingCustomizedProfileName {
|
||||
valueMap := value.(map[string]interface{})
|
||||
systemStatus.CustomizedProfile = api.CustomizedProfile{}
|
||||
if v := valueMap["name"]; v != nil {
|
||||
systemStatus.CustomizedProfile.Name = v.(string)
|
||||
}
|
||||
if v := valueMap["logoUrl"]; v != nil {
|
||||
systemStatus.CustomizedProfile.LogoURL = v.(string)
|
||||
}
|
||||
if v := valueMap["description"]; v != nil {
|
||||
systemStatus.CustomizedProfile.Description = v.(string)
|
||||
}
|
||||
if v := valueMap["locale"]; v != nil {
|
||||
systemStatus.CustomizedProfile.Locale = v.(string)
|
||||
}
|
||||
if v := valueMap["appearance"]; v != nil {
|
||||
systemStatus.CustomizedProfile.Appearance = v.(string)
|
||||
}
|
||||
if v := valueMap["externalUrl"]; v != nil {
|
||||
systemStatus.CustomizedProfile.ExternalURL = v.(string)
|
||||
customizedProfile := api.CustomizedProfile{}
|
||||
err := json.Unmarshal([]byte(systemSetting.Value), &customizedProfile)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting customized profile value").SetInternal(err)
|
||||
}
|
||||
systemStatus.CustomizedProfile = customizedProfile
|
||||
} else if systemSetting.Name == api.SystemSettingStorageServiceIDName {
|
||||
systemStatus.StorageServiceID = int(value.(float64))
|
||||
systemStatus.StorageServiceID = int(baseValue.(float64))
|
||||
} else if systemSetting.Name == api.SystemSettingLocalStoragePathName {
|
||||
systemStatus.LocalStoragePath = baseValue.(string)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,14 +200,14 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
|
||||
|
||||
func (s *Server) getSystemServerID(ctx context.Context) (string, error) {
|
||||
serverIDValue, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{
|
||||
Name: api.SystemSettingServerID,
|
||||
Name: api.SystemSettingServerIDName,
|
||||
})
|
||||
if err != nil && common.ErrorCode(err) != common.NotFound {
|
||||
return "", err
|
||||
}
|
||||
if serverIDValue == nil || serverIDValue.Value == "" {
|
||||
serverIDValue, err = s.Store.UpsertSystemSetting(ctx, &api.SystemSettingUpsert{
|
||||
Name: api.SystemSettingServerID,
|
||||
Name: api.SystemSettingServerIDName,
|
||||
Value: uuid.NewString(),
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -178,9 +177,6 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch user request").SetInternal(err)
|
||||
}
|
||||
userPatch.ID = userID
|
||||
if err := userPatch.Validate(); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user patch format").SetInternal(err)
|
||||
}
|
||||
|
||||
if userPatch.Password != nil && *userPatch.Password != "" {
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(*userPatch.Password), bcrypt.DefaultCost)
|
||||
@@ -197,6 +193,10 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
|
||||
userPatch.OpenID = &openID
|
||||
}
|
||||
|
||||
if err := userPatch.Validate(); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user patch format").SetInternal(err)
|
||||
}
|
||||
|
||||
user, err := s.Store.PatchUser(ctx, userPatch)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch user").SetInternal(err)
|
||||
@@ -269,8 +269,5 @@ func (s *Server) createUserCreateActivity(c echo.Context, user *api.User) error
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: string(activity.Type),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -9,10 +9,10 @@ import (
|
||||
|
||||
// Version is the service current released version.
|
||||
// Semantic versioning: https://semver.org/
|
||||
var Version = "0.11.0"
|
||||
var Version = "0.12.1"
|
||||
|
||||
// DevVersion is the service current development version.
|
||||
var DevVersion = "0.11.0"
|
||||
var DevVersion = "0.12.1"
|
||||
|
||||
func GetCurrentVersion(mode string) string {
|
||||
if mode == "dev" || mode == "demo" {
|
||||
@@ -34,7 +34,7 @@ func GetSchemaVersion(version string) string {
|
||||
return minorVersion + ".0"
|
||||
}
|
||||
|
||||
// IsVersionGreaterThanOrEqualTo returns true if version is greater than or equal to target.
|
||||
// IsVersionGreaterOrEqualThan returns true if version is greater than or equal to target.
|
||||
func IsVersionGreaterOrEqualThan(version, target string) bool {
|
||||
return semver.Compare(fmt.Sprintf("v%s", version), fmt.Sprintf("v%s", target)) > -1
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func createActivity(ctx context.Context, tx *sql.Tx, create *api.ActivityCreate)
|
||||
&activityRaw.Type,
|
||||
&activityRaw.Level,
|
||||
&activityRaw.Payload,
|
||||
&activityRaw.CreatedTs,
|
||||
&activityRaw.CreatorID,
|
||||
&activityRaw.CreatedTs,
|
||||
); err != nil {
|
||||
return nil, FormatError(err)
|
||||
|
||||
@@ -76,7 +76,10 @@ CREATE TABLE resource (
|
||||
blob BLOB DEFAULT NULL,
|
||||
external_link TEXT NOT NULL DEFAULT '',
|
||||
type TEXT NOT NULL DEFAULT '',
|
||||
size INTEGER NOT NULL DEFAULT 0
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
internal_path TEXT NOT NULL DEFAULT '',
|
||||
public_id TEXT NOT NULL DEFAULT '',
|
||||
UNIQUE(id, public_id)
|
||||
);
|
||||
|
||||
-- memo_resource
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
UPDATE
|
||||
user_setting
|
||||
SET
|
||||
key = 'memo-visibility'
|
||||
WHERE
|
||||
key = 'memoVisibility';
|
||||
@@ -0,0 +1,69 @@
|
||||
UPDATE
|
||||
system_setting
|
||||
SET
|
||||
name = 'server-id'
|
||||
WHERE
|
||||
name = 'serverId';
|
||||
|
||||
UPDATE
|
||||
system_setting
|
||||
SET
|
||||
name = 'secret-session'
|
||||
WHERE
|
||||
name = 'secretSessionName';
|
||||
|
||||
UPDATE
|
||||
system_setting
|
||||
SET
|
||||
name = 'allow-signup'
|
||||
WHERE
|
||||
name = 'allowSignUp';
|
||||
|
||||
UPDATE
|
||||
system_setting
|
||||
SET
|
||||
name = 'disable-public-memos'
|
||||
WHERE
|
||||
name = 'disablePublicMemos';
|
||||
|
||||
UPDATE
|
||||
system_setting
|
||||
SET
|
||||
name = 'additional-style'
|
||||
WHERE
|
||||
name = 'additionalStyle';
|
||||
|
||||
UPDATE
|
||||
system_setting
|
||||
SET
|
||||
name = 'additional-script'
|
||||
WHERE
|
||||
name = 'additionalScript';
|
||||
|
||||
UPDATE
|
||||
system_setting
|
||||
SET
|
||||
name = 'customized-profile'
|
||||
WHERE
|
||||
name = 'customizedProfile';
|
||||
|
||||
UPDATE
|
||||
system_setting
|
||||
SET
|
||||
name = 'storage-service-id'
|
||||
WHERE
|
||||
name = 'storageServiceId';
|
||||
|
||||
UPDATE
|
||||
system_setting
|
||||
SET
|
||||
name = 'local-storage-path'
|
||||
WHERE
|
||||
name = 'localStoragePath';
|
||||
|
||||
UPDATE
|
||||
system_setting
|
||||
SET
|
||||
name = 'openai-config'
|
||||
WHERE
|
||||
name = 'openAIConfig';
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE
|
||||
resource
|
||||
ADD
|
||||
COLUMN internal_path TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,19 @@
|
||||
ALTER TABLE
|
||||
resource
|
||||
ADD
|
||||
COLUMN public_id TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- TODO(steven): remove this in next release.
|
||||
CREATE UNIQUE INDEX resource_id_public_id_unique_index ON resource (id, public_id);
|
||||
|
||||
UPDATE
|
||||
resource
|
||||
SET
|
||||
public_id = printf (
|
||||
'%s-%s-%s-%s-%s',
|
||||
lower(hex(randomblob(4))),
|
||||
lower(hex(randomblob(2))),
|
||||
lower(hex(randomblob(2))),
|
||||
lower(hex(randomblob(2))),
|
||||
lower(hex(randomblob(6)))
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
INSERT
|
||||
OR IGNORE INTO system_setting(name, value)
|
||||
VALUES
|
||||
(
|
||||
'local-storage-path',
|
||||
'"assets/{timestamp}_{filename}"'
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||

|
||||
|
||||
**[star-history.com](https://star-history.com)**: The missing GitHub star history graph of GitHub repos.
|
||||
",
|
||||
101,
|
||||
'PUBLIC'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
INSERT INTO
|
||||
system_setting (`name`, `value`, `description`)
|
||||
VALUES
|
||||
('allowSignUp', 'true', '');
|
||||
('allow-signup', 'true', '');
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ func (s *Store) DeleteMemo(ctx context.Context, delete *api.MemoDelete) error {
|
||||
|
||||
func createMemoRaw(ctx context.Context, tx *sql.Tx, create *api.MemoCreate) (*memoRaw, error) {
|
||||
set := []string{"creator_id", "content", "visibility"}
|
||||
args := []interface{}{create.CreatorID, create.Content, create.Visibility}
|
||||
args := []any{create.CreatorID, create.Content, create.Visibility}
|
||||
placeholder := []string{"?", "?", "?"}
|
||||
|
||||
if v := create.CreatedTs; v != nil {
|
||||
@@ -224,7 +224,7 @@ func createMemoRaw(ctx context.Context, tx *sql.Tx, create *api.MemoCreate) (*me
|
||||
}
|
||||
|
||||
func patchMemoRaw(ctx context.Context, tx *sql.Tx, patch *api.MemoPatch) (*memoRaw, error) {
|
||||
set, args := []string{}, []interface{}{}
|
||||
set, args := []string{}, []any{}
|
||||
|
||||
if v := patch.CreatedTs; v != nil {
|
||||
set, args = append(set, "created_ts = ?"), append(args, *v)
|
||||
@@ -267,7 +267,7 @@ func patchMemoRaw(ctx context.Context, tx *sql.Tx, patch *api.MemoPatch) (*memoR
|
||||
}
|
||||
|
||||
func findMemoRawList(ctx context.Context, tx *sql.Tx, find *api.MemoFind) ([]*memoRaw, error) {
|
||||
where, args := []string{"1 = 1"}, []interface{}{}
|
||||
where, args := []string{"1 = 1"}, []any{}
|
||||
|
||||
if v := find.ID; v != nil {
|
||||
where, args = append(where, "memo.id = ?"), append(args, *v)
|
||||
@@ -302,11 +302,11 @@ func findMemoRawList(ctx context.Context, tx *sql.Tx, find *api.MemoFind) ([]*me
|
||||
memo.row_status,
|
||||
memo.content,
|
||||
memo.visibility,
|
||||
memo_organizer.pinned
|
||||
IFNULL(memo_organizer.pinned, 0) AS pinned
|
||||
FROM memo
|
||||
LEFT JOIN memo_organizer ON memo_organizer.memo_id = memo.id
|
||||
LEFT JOIN memo_organizer ON memo_organizer.memo_id = memo.id AND memo_organizer.user_id = memo.creator_id
|
||||
WHERE ` + strings.Join(where, " AND ") + `
|
||||
ORDER BY memo_organizer.pinned DESC, memo.created_ts DESC
|
||||
ORDER BY pinned DESC, memo.created_ts DESC
|
||||
`
|
||||
if find.Limit != nil {
|
||||
query = fmt.Sprintf("%s LIMIT %d", query, *find.Limit)
|
||||
@@ -352,7 +352,7 @@ func findMemoRawList(ctx context.Context, tx *sql.Tx, find *api.MemoFind) ([]*me
|
||||
}
|
||||
|
||||
func deleteMemo(ctx context.Context, tx *sql.Tx, delete *api.MemoDelete) error {
|
||||
where, args := []string{"id = ?"}, []interface{}{delete.ID}
|
||||
where, args := []string{"id = ?"}, []any{delete.ID}
|
||||
|
||||
stmt := `DELETE FROM memo WHERE ` + strings.Join(where, " AND ")
|
||||
result, err := tx.ExecContext(ctx, stmt, args...)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -22,11 +22,14 @@ type resourceRaw struct {
|
||||
UpdatedTs int64
|
||||
|
||||
// Domain specific fields
|
||||
Filename string
|
||||
Blob []byte
|
||||
ExternalLink string
|
||||
Type string
|
||||
Size int64
|
||||
Filename string
|
||||
Blob []byte
|
||||
InternalPath string
|
||||
ExternalLink string
|
||||
Type string
|
||||
Size int64
|
||||
PublicID string
|
||||
LinkedMemoAmount int
|
||||
}
|
||||
|
||||
func (raw *resourceRaw) toResource() *api.Resource {
|
||||
@@ -39,11 +42,14 @@ func (raw *resourceRaw) toResource() *api.Resource {
|
||||
UpdatedTs: raw.UpdatedTs,
|
||||
|
||||
// Domain specific fields
|
||||
Filename: raw.Filename,
|
||||
Blob: raw.Blob,
|
||||
ExternalLink: raw.ExternalLink,
|
||||
Type: raw.Type,
|
||||
Size: raw.Size,
|
||||
Filename: raw.Filename,
|
||||
Blob: raw.Blob,
|
||||
InternalPath: raw.InternalPath,
|
||||
ExternalLink: raw.ExternalLink,
|
||||
Type: raw.Type,
|
||||
Size: raw.Size,
|
||||
PublicID: raw.PublicID,
|
||||
LinkedMemoAmount: raw.LinkedMemoAmount,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +94,7 @@ func (s *Store) CreateResource(ctx context.Context, create *api.ResourceCreate)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
resourceRaw, err := createResource(ctx, tx, create)
|
||||
resourceRaw, err := createResourceImpl(ctx, tx, create)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -109,7 +115,7 @@ func (s *Store) FindResourceList(ctx context.Context, find *api.ResourceFind) ([
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
resourceRawList, err := findResourceList(ctx, tx, find)
|
||||
resourceRawList, err := findResourceListImpl(ctx, tx, find)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -129,7 +135,7 @@ func (s *Store) FindResource(ctx context.Context, find *api.ResourceFind) (*api.
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
list, err := findResourceList(ctx, tx, find)
|
||||
list, err := findResourceListImpl(ctx, tx, find)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -172,7 +178,7 @@ func (s *Store) PatchResource(ctx context.Context, patch *api.ResourcePatch) (*a
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
resourceRaw, err := patchResource(ctx, tx, patch)
|
||||
resourceRaw, err := patchResourceImpl(ctx, tx, patch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -186,21 +192,19 @@ func (s *Store) PatchResource(ctx context.Context, patch *api.ResourcePatch) (*a
|
||||
return resource, nil
|
||||
}
|
||||
|
||||
func createResource(ctx context.Context, tx *sql.Tx, create *api.ResourceCreate) (*resourceRaw, error) {
|
||||
func createResourceImpl(ctx context.Context, tx *sql.Tx, create *api.ResourceCreate) (*resourceRaw, error) {
|
||||
fields := []string{"filename", "blob", "external_link", "type", "size", "creator_id", "internal_path", "public_id"}
|
||||
values := []any{create.Filename, create.Blob, create.ExternalLink, create.Type, create.Size, create.CreatorID, create.InternalPath, create.PublicID}
|
||||
placeholders := []string{"?", "?", "?", "?", "?", "?", "?", "?"}
|
||||
query := `
|
||||
INSERT INTO resource (
|
||||
filename,
|
||||
blob,
|
||||
external_link,
|
||||
type,
|
||||
size,
|
||||
creator_id
|
||||
` + strings.Join(fields, ",") + `
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
RETURNING id, filename, blob, external_link, type, size, creator_id, created_ts, updated_ts
|
||||
VALUES (` + strings.Join(placeholders, ",") + `)
|
||||
RETURNING id, ` + strings.Join(fields, ",") + `, created_ts, updated_ts
|
||||
`
|
||||
var resourceRaw resourceRaw
|
||||
if err := tx.QueryRowContext(ctx, query, create.Filename, create.Blob, create.ExternalLink, create.Type, create.Size, create.CreatorID).Scan(
|
||||
dests := []any{
|
||||
&resourceRaw.ID,
|
||||
&resourceRaw.Filename,
|
||||
&resourceRaw.Blob,
|
||||
@@ -208,17 +212,19 @@ func createResource(ctx context.Context, tx *sql.Tx, create *api.ResourceCreate)
|
||||
&resourceRaw.Type,
|
||||
&resourceRaw.Size,
|
||||
&resourceRaw.CreatorID,
|
||||
&resourceRaw.CreatedTs,
|
||||
&resourceRaw.UpdatedTs,
|
||||
); err != nil {
|
||||
&resourceRaw.InternalPath,
|
||||
&resourceRaw.PublicID,
|
||||
}
|
||||
dests = append(dests, []any{&resourceRaw.CreatedTs, &resourceRaw.UpdatedTs}...)
|
||||
if err := tx.QueryRowContext(ctx, query, values...).Scan(dests...); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
return &resourceRaw, nil
|
||||
}
|
||||
|
||||
func patchResource(ctx context.Context, tx *sql.Tx, patch *api.ResourcePatch) (*resourceRaw, error) {
|
||||
set, args := []string{}, []interface{}{}
|
||||
func patchResourceImpl(ctx context.Context, tx *sql.Tx, patch *api.ResourcePatch) (*resourceRaw, error) {
|
||||
set, args := []string{}, []any{}
|
||||
|
||||
if v := patch.UpdatedTs; v != nil {
|
||||
set, args = append(set, "updated_ts = ?"), append(args, *v)
|
||||
@@ -226,61 +232,78 @@ func patchResource(ctx context.Context, tx *sql.Tx, patch *api.ResourcePatch) (*
|
||||
if v := patch.Filename; v != nil {
|
||||
set, args = append(set, "filename = ?"), append(args, *v)
|
||||
}
|
||||
if v := patch.PublicID; v != nil {
|
||||
set, args = append(set, "public_id = ?"), append(args, *v)
|
||||
}
|
||||
|
||||
args = append(args, patch.ID)
|
||||
|
||||
fields := []string{"id", "filename", "external_link", "type", "size", "creator_id", "created_ts", "updated_ts", "internal_path", "public_id"}
|
||||
query := `
|
||||
UPDATE resource
|
||||
SET ` + strings.Join(set, ", ") + `
|
||||
WHERE id = ?
|
||||
RETURNING id, filename, blob, external_link, type, size, creator_id, created_ts, updated_ts
|
||||
`
|
||||
RETURNING ` + strings.Join(fields, ", ")
|
||||
var resourceRaw resourceRaw
|
||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(
|
||||
dests := []any{
|
||||
&resourceRaw.ID,
|
||||
&resourceRaw.Filename,
|
||||
&resourceRaw.Blob,
|
||||
&resourceRaw.ExternalLink,
|
||||
&resourceRaw.Type,
|
||||
&resourceRaw.Size,
|
||||
&resourceRaw.CreatorID,
|
||||
&resourceRaw.CreatedTs,
|
||||
&resourceRaw.UpdatedTs,
|
||||
); err != nil {
|
||||
&resourceRaw.InternalPath,
|
||||
&resourceRaw.PublicID,
|
||||
}
|
||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(dests...); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
return &resourceRaw, nil
|
||||
}
|
||||
|
||||
func findResourceList(ctx context.Context, tx *sql.Tx, find *api.ResourceFind) ([]*resourceRaw, error) {
|
||||
where, args := []string{"1 = 1"}, []interface{}{}
|
||||
func findResourceListImpl(ctx context.Context, tx *sql.Tx, find *api.ResourceFind) ([]*resourceRaw, error) {
|
||||
where, args := []string{"1 = 1"}, []any{}
|
||||
|
||||
if v := find.ID; v != nil {
|
||||
where, args = append(where, "id = ?"), append(args, *v)
|
||||
where, args = append(where, "resource.id = ?"), append(args, *v)
|
||||
}
|
||||
if v := find.CreatorID; v != nil {
|
||||
where, args = append(where, "creator_id = ?"), append(args, *v)
|
||||
where, args = append(where, "resource.creator_id = ?"), append(args, *v)
|
||||
}
|
||||
if v := find.Filename; v != nil {
|
||||
where, args = append(where, "filename = ?"), append(args, *v)
|
||||
where, args = append(where, "resource.filename = ?"), append(args, *v)
|
||||
}
|
||||
if v := find.MemoID; v != nil {
|
||||
where, args = append(where, "id in (SELECT resource_id FROM memo_resource WHERE memo_id = ?)"), append(args, *v)
|
||||
where, args = append(where, "resource.id in (SELECT resource_id FROM memo_resource WHERE memo_id = ?)"), append(args, *v)
|
||||
}
|
||||
if v := find.PublicID; v != nil {
|
||||
where, args = append(where, "resource.public_id = ?"), append(args, *v)
|
||||
}
|
||||
|
||||
fields := []string{"id", "filename", "external_link", "type", "size", "creator_id", "created_ts", "updated_ts"}
|
||||
fields := []string{"resource.id", "resource.filename", "resource.external_link", "resource.type", "resource.size", "resource.creator_id", "resource.created_ts", "resource.updated_ts", "internal_path", "public_id"}
|
||||
if find.GetBlob {
|
||||
fields = append(fields, "blob")
|
||||
fields = append(fields, "resource.blob")
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
COUNT(DISTINCT memo_resource.memo_id) AS linked_memo_amount,
|
||||
%s
|
||||
FROM resource
|
||||
LEFT JOIN memo_resource ON resource.id = memo_resource.resource_id
|
||||
WHERE %s
|
||||
ORDER BY id DESC
|
||||
GROUP BY resource.id
|
||||
ORDER BY resource.id DESC
|
||||
`, strings.Join(fields, ", "), strings.Join(where, " AND "))
|
||||
if find.Limit != nil {
|
||||
query = fmt.Sprintf("%s LIMIT %d", query, *find.Limit)
|
||||
if find.Offset != nil {
|
||||
query = fmt.Sprintf("%s OFFSET %d", query, *find.Offset)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := tx.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, FormatError(err)
|
||||
@@ -290,7 +313,8 @@ func findResourceList(ctx context.Context, tx *sql.Tx, find *api.ResourceFind) (
|
||||
resourceRawList := make([]*resourceRaw, 0)
|
||||
for rows.Next() {
|
||||
var resourceRaw resourceRaw
|
||||
dest := []interface{}{
|
||||
dests := []any{
|
||||
&resourceRaw.LinkedMemoAmount,
|
||||
&resourceRaw.ID,
|
||||
&resourceRaw.Filename,
|
||||
&resourceRaw.ExternalLink,
|
||||
@@ -299,16 +323,15 @@ func findResourceList(ctx context.Context, tx *sql.Tx, find *api.ResourceFind) (
|
||||
&resourceRaw.CreatorID,
|
||||
&resourceRaw.CreatedTs,
|
||||
&resourceRaw.UpdatedTs,
|
||||
&resourceRaw.InternalPath,
|
||||
&resourceRaw.PublicID,
|
||||
}
|
||||
if find.GetBlob {
|
||||
dest = append(dest, &resourceRaw.Blob)
|
||||
dests = append(dests, &resourceRaw.Blob)
|
||||
}
|
||||
if err := rows.Scan(
|
||||
dest...,
|
||||
); err != nil {
|
||||
if err := rows.Scan(dests...); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
resourceRawList = append(resourceRawList, &resourceRaw)
|
||||
}
|
||||
|
||||
@@ -320,7 +343,7 @@ func findResourceList(ctx context.Context, tx *sql.Tx, find *api.ResourceFind) (
|
||||
}
|
||||
|
||||
func deleteResource(ctx context.Context, tx *sql.Tx, delete *api.ResourceDelete) error {
|
||||
where, args := []string{"id = ?"}, []interface{}{delete.ID}
|
||||
where, args := []string{"id = ?"}, []any{delete.ID}
|
||||
|
||||
stmt := `DELETE FROM resource WHERE ` + strings.Join(where, " AND ")
|
||||
result, err := tx.ExecContext(ctx, stmt, args...)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -56,10 +56,11 @@ func (s *Store) ComposeMemoCreator(ctx context.Context, memo *api.Memo) error {
|
||||
return err
|
||||
}
|
||||
|
||||
user.Email = ""
|
||||
user.OpenID = ""
|
||||
user.UserSettingList = nil
|
||||
memo.Creator = user
|
||||
if user.Nickname != "" {
|
||||
memo.CreatorName = user.Nickname
|
||||
} else {
|
||||
memo.CreatorName = user.Username
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Store) CreateUser(ctx context.Context, create *api.UserCreate) (*api.User, error) {
|
||||
@@ -215,7 +216,7 @@ func createUser(ctx context.Context, tx *sql.Tx, create *api.UserCreate) (*userR
|
||||
}
|
||||
|
||||
func patchUser(ctx context.Context, tx *sql.Tx, patch *api.UserPatch) (*userRaw, error) {
|
||||
set, args := []string{}, []interface{}{}
|
||||
set, args := []string{}, []any{}
|
||||
|
||||
if v := patch.UpdatedTs; v != nil {
|
||||
set, args = append(set, "updated_ts = ?"), append(args, *v)
|
||||
@@ -271,7 +272,7 @@ func patchUser(ctx context.Context, tx *sql.Tx, patch *api.UserPatch) (*userRaw,
|
||||
}
|
||||
|
||||
func findUserList(ctx context.Context, tx *sql.Tx, find *api.UserFind) ([]*userRaw, error) {
|
||||
where, args := []string{"1 = 1"}, []interface{}{}
|
||||
where, args := []string{"1 = 1"}, []any{}
|
||||
|
||||
if v := find.ID; v != nil {
|
||||
where, args = append(where, "id = ?"), append(args, *v)
|
||||
|
||||
@@ -118,10 +118,10 @@ func upsertUserSetting(ctx context.Context, tx *sql.Tx, upsert *api.UserSettingU
|
||||
}
|
||||
|
||||
func findUserSettingList(ctx context.Context, tx *sql.Tx, find *api.UserSettingFind) ([]*userSettingRaw, error) {
|
||||
where, args := []string{"1 = 1"}, []interface{}{}
|
||||
where, args := []string{"1 = 1"}, []any{}
|
||||
|
||||
if v := find.Key; v != nil {
|
||||
where, args = append(where, "key = ?"), append(args, v.String())
|
||||
if v := find.Key.String(); v != "" {
|
||||
where, args = append(where, "key = ?"), append(args, v)
|
||||
}
|
||||
|
||||
where, args = append(where, "user_id = ?"), append(args, find.UserID)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,8 @@
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"@typescript-eslint/no-explicit-any": ["off"],
|
||||
"react/react-in-jsx-scope": "off"
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/jsx-no-target-blank": "off"
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
|
||||
@@ -5,5 +5,9 @@
|
||||
},
|
||||
"i18n-ally.localesPaths": [
|
||||
"src/locales"
|
||||
]
|
||||
],
|
||||
"files.associations": {
|
||||
"*.less": "postcss"
|
||||
},
|
||||
"i18n-ally.keystyle": "nested"
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -3,31 +3,38 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint --ext .js,.ts,.tsx, src"
|
||||
"lint": "eslint --ext .js,.ts,.tsx, src",
|
||||
"lint-fix": "eslint --ext .js,.ts,.tsx, src --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.10.5",
|
||||
"@emotion/styled": "^11.10.5",
|
||||
"@mui/joy": "^5.0.0-alpha.67",
|
||||
"@radix-ui/react-tooltip": "^1.0.5",
|
||||
"@reduxjs/toolkit": "^1.8.1",
|
||||
"axios": "^0.27.2",
|
||||
"copy-to-clipboard": "^3.3.2",
|
||||
"dayjs": "^1.11.3",
|
||||
"highlight.js": "^11.6.0",
|
||||
"i18next": "^21.9.2",
|
||||
"i18next-browser-languagedetector": "^7.0.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-react": "^0.105.0",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"qs": "^6.11.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hot-toast": "^2.4.0",
|
||||
"react-i18next": "^11.18.6",
|
||||
"react-redux": "^8.0.1",
|
||||
"react-router-dom": "^6.4.0",
|
||||
"react-router-dom": "^6.8.2",
|
||||
"react-use": "^17.4.0",
|
||||
"semver": "^7.3.8",
|
||||
"tailwindcss": "^3.2.4"
|
||||
"tailwindcss": "^3.2.4",
|
||||
"zustand": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/line-clamp": "^0.4.2",
|
||||
"@types/lodash-es": "^4.17.5",
|
||||
"@types/node": "^18.0.3",
|
||||
"@types/qs": "^6.9.7",
|
||||
|
||||
|
Before Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 56 KiB |
@@ -4,8 +4,8 @@
|
||||
"description": "usememos/memos",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/logo.png",
|
||||
"type": "image/png",
|
||||
"src": "/logo.webp",
|
||||
"type": "image/webp",
|
||||
"sizes": "520x520"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import dayjs from "dayjs";
|
||||
import { useColorScheme } from "@mui/joy";
|
||||
import { useEffect, Suspense } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import router from "./router";
|
||||
import { useLocationStore, useGlobalStore } from "./store/module";
|
||||
import { useGlobalStore } from "./store/module";
|
||||
import * as storage from "./helpers/storage";
|
||||
import { getSystemColorScheme } from "./helpers/utils";
|
||||
import Loading from "./pages/Loading";
|
||||
@@ -11,17 +13,9 @@ import Loading from "./pages/Loading";
|
||||
const App = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const globalStore = useGlobalStore();
|
||||
const locationStore = useLocationStore();
|
||||
const { mode, setMode } = useColorScheme();
|
||||
const { appearance, locale, systemStatus } = globalStore.state;
|
||||
|
||||
useEffect(() => {
|
||||
locationStore.updateStateWithLocation();
|
||||
window.onpopstate = () => {
|
||||
locationStore.updateStateWithLocation();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleColorSchemeChange = (e: MediaQueryListEvent) => {
|
||||
@@ -59,12 +53,13 @@ const App = () => {
|
||||
// dynamic update metadata with customized profile.
|
||||
document.title = systemStatus.customizedProfile.name;
|
||||
const link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
|
||||
link.href = systemStatus.customizedProfile.logoUrl || "/logo.png";
|
||||
link.href = systemStatus.customizedProfile.logoUrl || "/logo.webp";
|
||||
}, [systemStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("lang", locale);
|
||||
i18n.changeLanguage(locale);
|
||||
dayjs.locale(locale);
|
||||
storage.set({
|
||||
locale: locale,
|
||||
});
|
||||
@@ -95,6 +90,7 @@ const App = () => {
|
||||
return (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster position="top-right" />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGlobalStore } from "../store/module";
|
||||
import { useGlobalStore } from "@/store/module";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import GitHubBadge from "./GitHubBadge";
|
||||
import "../less/about-site-dialog.less";
|
||||
|
||||
type Props = DialogProps;
|
||||
|
||||
@@ -27,22 +26,26 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
<Icon.X />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container">
|
||||
<div className="flex flex-col justify-start items-start max-w-full w-96">
|
||||
<p className="text-sm">{customizedProfile.description || "No description"}</p>
|
||||
<div className="mt-4 flex flex-row text-sm justify-start items-center">
|
||||
<div className="mt-4 w-full flex flex-row text-sm justify-start items-center">
|
||||
<div className="flex flex-row justify-start items-center mr-2">
|
||||
Powered by
|
||||
<a href="https://usememos.com" className="flex flex-row justify-start items-center mr-1 hover:underline">
|
||||
<img className="w-6 h-auto" src="/logo.png" alt="" />
|
||||
<a href="https://usememos.com" target="_blank" className="flex flex-row justify-start items-center mx-1 hover:underline">
|
||||
<img className="w-6 h-auto rounded-full mr-1" src="/logo.webp" alt="" />
|
||||
memos
|
||||
</a>
|
||||
<span>v{profile.version}</span>
|
||||
</div>
|
||||
<GitHubBadge />
|
||||
</div>
|
||||
<div className="border-t mt-3 pt-2 text-sm flex flex-row justify-start items-center">
|
||||
<span className="text-gray-500 mr-2">Other projects:</span>
|
||||
<a href="https://github.com/boojack/sticky-notes" className="flex items-center underline text-blue-600 hover:opacity-80">
|
||||
<div className="border-t w-full mt-3 pt-2 text-sm flex flex-row justify-start items-center space-x-2">
|
||||
<span className="text-gray-500">Other projects:</span>
|
||||
<a
|
||||
href="https://github.com/boojack/sticky-notes"
|
||||
target="_blank"
|
||||
className="flex items-center underline text-blue-600 hover:opacity-80"
|
||||
>
|
||||
<img
|
||||
className="w-4 h-auto mr-1"
|
||||
src="https://raw.githubusercontent.com/boojack/sticky-notes/main/public/sticky-notes.ico"
|
||||
@@ -50,6 +53,10 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
/>
|
||||
<span>Sticky notes</span>
|
||||
</a>
|
||||
<a href="https://star-history.com" target="_blank" className="flex items-center underline text-blue-600 hover:opacity-80">
|
||||
<img className="w-4 h-auto mr-1" src="https://star-history.com/icon.png" alt="" />
|
||||
<span>Star history</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemoStore } from "../store/module";
|
||||
import * as utils from "../helpers/utils";
|
||||
import useToggle from "../hooks/useToggle";
|
||||
import toastHelper from "./Toast";
|
||||
import { useMemoStore } from "@/store/module";
|
||||
import * as utils from "@/helpers/utils";
|
||||
import useToggle from "@/hooks/useToggle";
|
||||
import MemoContent from "./MemoContent";
|
||||
import MemoResources from "./MemoResources";
|
||||
import "../less/memo.less";
|
||||
import "@/less/memo.less";
|
||||
|
||||
interface Props {
|
||||
memo: Memo;
|
||||
@@ -23,7 +23,7 @@ const ArchivedMemo: React.FC<Props> = (props: Props) => {
|
||||
await memoStore.deleteMemoById(memo.id);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
} else {
|
||||
toggleConfirmDeleteBtn();
|
||||
@@ -37,10 +37,10 @@ const ArchivedMemo: React.FC<Props> = (props: Props) => {
|
||||
rowStatus: "NORMAL",
|
||||
});
|
||||
await memoStore.fetchMemos();
|
||||
toastHelper.info(t("message.restored-successfully"));
|
||||
toast(t("message.restored-successfully"));
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -54,7 +54,7 @@ const ArchivedMemo: React.FC<Props> = (props: Props) => {
|
||||
<div className={`memo-wrapper archived ${"memos-" + memo.id}`} onMouseLeave={handleMouseLeaveMemoWrapper}>
|
||||
<div className="memo-top-wrapper">
|
||||
<span className="time-text">
|
||||
{t("common.archived-at")} {utils.getDateTimeString(memo.updatedTs)}
|
||||
{t("memo.archived-at")} {utils.getDateTimeString(memo.updatedTs)}
|
||||
</span>
|
||||
<div className="btns-container">
|
||||
<span className="btn-text" onClick={handleRestoreMemoClick}>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemoStore } from "../store/module";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { useMemoStore } from "@/store/module";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
import ArchivedMemo from "./ArchivedMemo";
|
||||
import "../less/archived-memo-dialog.less";
|
||||
import "@/less/archived-memo-dialog.less";
|
||||
|
||||
type Props = DialogProps;
|
||||
|
||||
@@ -26,7 +26,7 @@ const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
toast.error(error.response.data.message);
|
||||
})
|
||||
.finally(() => {
|
||||
loadingState.setFinish();
|
||||
@@ -36,7 +36,7 @@ const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">{t("archived.archived-memos")}</p>
|
||||
<p className="title-text">{t("memo.archived-memos")}</p>
|
||||
<button className="btn close-btn" onClick={destroy}>
|
||||
<Icon.X className="icon-img" />
|
||||
</button>
|
||||
@@ -44,11 +44,11 @@ const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
|
||||
<div className="dialog-content-container">
|
||||
{loadingState.isLoading ? (
|
||||
<div className="tip-text-container">
|
||||
<p className="tip-text">{t("archived.fetching-data")}</p>
|
||||
<p className="tip-text">{t("memo.fetching-data")}</p>
|
||||
</div>
|
||||
) : archivedMemos.length === 0 ? (
|
||||
<div className="tip-text-container">
|
||||
<p className="tip-text">{t("archived.no-archived-memos")}</p>
|
||||
<p className="tip-text">{t("memo.no-archived-memos")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="archived-memos-container">
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Button, Textarea } from "@mui/joy";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as api from "@/helpers/api";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { marked } from "@/labs/marked";
|
||||
import { useMessageStore } from "@/store/zustand/message";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import showSettingDialog from "./SettingDialog";
|
||||
|
||||
type Props = DialogProps;
|
||||
|
||||
const AskAIDialog: React.FC<Props> = (props: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { destroy, hide } = props;
|
||||
const fetchingState = useLoading(false);
|
||||
const messageStore = useMessageStore();
|
||||
const [isEnabled, setIsEnabled] = useState<boolean>(true);
|
||||
const [isInIME, setIsInIME] = useState(false);
|
||||
const [question, setQuestion] = useState<string>("");
|
||||
const messageList = messageStore.messageList;
|
||||
|
||||
useEffect(() => {
|
||||
api.checkOpenAIEnabled().then(({ data }) => {
|
||||
const { data: enabled } = data;
|
||||
setIsEnabled(enabled);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleGotoSystemSetting = () => {
|
||||
showSettingDialog("system");
|
||||
destroy();
|
||||
};
|
||||
|
||||
const handleQuestionTextareaChange = async (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setQuestion(event.currentTarget.value);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Enter" && !event.shiftKey && !isInIME) {
|
||||
event.preventDefault();
|
||||
handleSendQuestionButtonClick();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendQuestionButtonClick = async () => {
|
||||
if (!question) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetchingState.setLoading();
|
||||
setQuestion("");
|
||||
messageStore.addMessage({
|
||||
role: "user",
|
||||
content: question,
|
||||
});
|
||||
try {
|
||||
await fetchChatCompletion();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.response.data.error);
|
||||
}
|
||||
fetchingState.setFinish();
|
||||
};
|
||||
|
||||
const fetchChatCompletion = async () => {
|
||||
const messageList = messageStore.getState().messageList;
|
||||
const {
|
||||
data: { data: answer },
|
||||
} = await api.postChatCompletion(messageList);
|
||||
messageStore.addMessage({
|
||||
role: "assistant",
|
||||
content: answer.replace(/^\n\n/, ""),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text flex flex-row items-center">
|
||||
<Icon.Bot className="mr-1 w-5 h-auto opacity-80" />
|
||||
{t("ask-ai.title")}
|
||||
</p>
|
||||
<button className="btn close-btn" onClick={() => hide()}>
|
||||
<Icon.X />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container !w-112 max-w-full">
|
||||
{messageList.map((message, index) => (
|
||||
<div key={index} className="w-full flex flex-col justify-start items-start mt-4 space-y-2">
|
||||
{message.role === "user" ? (
|
||||
<div className="w-full flex flex-row justify-end items-start pl-6">
|
||||
<span className="word-break shadow rounded-lg rounded-tr-none px-3 py-2 opacity-80 bg-gray-100 dark:bg-zinc-700">
|
||||
{message.content}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full flex flex-row justify-start items-start pr-8 space-x-2">
|
||||
<Icon.Bot className="mt-2 flex-shrink-0 mr-1 w-6 h-auto opacity-80" />
|
||||
<div className="memo-content-wrapper !w-auto flex flex-col justify-start items-start shadow rounded-lg rounded-tl-none px-3 py-2 bg-gray-100 dark:bg-zinc-700">
|
||||
<div className="memo-content-text">{marked(message.content)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{fetchingState.isLoading && (
|
||||
<p className="w-full py-2 mt-4 flex flex-row justify-center items-center">
|
||||
<Icon.Loader className="w-5 h-auto animate-spin" />
|
||||
</p>
|
||||
)}
|
||||
{!isEnabled && (
|
||||
<div className="w-full flex flex-col justify-center items-center mt-4 space-y-2">
|
||||
<p>{t("ask-ai.not_enabled")}</p>
|
||||
<Button onClick={() => handleGotoSystemSetting()}>{t("ask-ai.go-to-settings")}</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full relative mt-4">
|
||||
<Textarea
|
||||
className="w-full"
|
||||
placeholder={t("ask-ai.placeholder")}
|
||||
value={question}
|
||||
minRows={1}
|
||||
maxRows={5}
|
||||
onChange={handleQuestionTextareaChange}
|
||||
onCompositionStart={() => setIsInIME(true)}
|
||||
onCompositionEnd={() => setIsInIME(false)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<Icon.Send
|
||||
className="cursor-pointer w-7 p-1 h-auto rounded-md bg-gray-100 dark:bg-zinc-800 absolute right-2 bottom-1.5 shadow hover:opacity-80"
|
||||
onClick={handleSendQuestionButtonClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function showAskAIDialog() {
|
||||
const dialogname = "ask-ai-dialog";
|
||||
const dialogElement = document.body.querySelector(`div.${dialogname}`);
|
||||
if (dialogElement) {
|
||||
dialogElement.classList.remove("showoff");
|
||||
dialogElement.classList.add("showup");
|
||||
} else {
|
||||
generateDialog(
|
||||
{
|
||||
className: dialogname,
|
||||
dialogName: dialogname,
|
||||
},
|
||||
AskAIDialog
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default showAskAIDialog;
|
||||
@@ -1,17 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUserStore } from "../store/module";
|
||||
import { validate, ValidatorConfig } from "../helpers/validator";
|
||||
import { useUserStore } from "@/store/module";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
|
||||
const validateConfig: ValidatorConfig = {
|
||||
minLength: 4,
|
||||
maxLength: 320,
|
||||
noSpace: true,
|
||||
noChinese: true,
|
||||
};
|
||||
|
||||
interface Props extends DialogProps {
|
||||
user: User;
|
||||
@@ -44,32 +36,26 @@ const ChangeMemberPasswordDialog: React.FC<Props> = (props: Props) => {
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (newPassword === "" || newPasswordAgain === "") {
|
||||
toastHelper.error(t("message.fill-all"));
|
||||
toast.error(t("message.fill-all"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== newPasswordAgain) {
|
||||
toastHelper.error(t("message.new-password-not-match"));
|
||||
toast.error(t("message.new-password-not-match"));
|
||||
setNewPasswordAgain("");
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordValidResult = validate(newPassword, validateConfig);
|
||||
if (!passwordValidResult.result) {
|
||||
toastHelper.error(`${t("common.password")} ${t(passwordValidResult.reason as string)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await userStore.patchUser({
|
||||
id: propsUser.id,
|
||||
password: newPassword,
|
||||
});
|
||||
toastHelper.info(t("message.password-changed"));
|
||||
toast(t("message.password-changed"));
|
||||
handleCloseBtnClick();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -84,19 +70,19 @@ const ChangeMemberPasswordDialog: React.FC<Props> = (props: Props) => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container">
|
||||
<p className="text-sm mb-1">{t("common.new-password")}</p>
|
||||
<p className="text-sm mb-1">{t("auth.new-password")}</p>
|
||||
<input
|
||||
type="password"
|
||||
className="input-text"
|
||||
placeholder={t("common.repeat-new-password")}
|
||||
placeholder={t("auth.repeat-new-password")}
|
||||
value={newPassword}
|
||||
onChange={handleNewPasswordChanged}
|
||||
/>
|
||||
<p className="text-sm mb-1 mt-2">{t("common.repeat-new-password")}</p>
|
||||
<p className="text-sm mb-1 mt-2">{t("auth.repeat-new-password")}</p>
|
||||
<input
|
||||
type="password"
|
||||
className="input-text"
|
||||
placeholder={t("common.repeat-new-password")}
|
||||
placeholder={t("auth.repeat-new-password")}
|
||||
value={newPasswordAgain}
|
||||
onChange={handleNewPasswordAgainChanged}
|
||||
/>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import dayjs from "dayjs";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemoStore } from "../store/module";
|
||||
import { useMemoStore } from "@/store/module";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
import "../less/change-memo-created-ts-dialog.less";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
memoId: MemoId;
|
||||
@@ -24,7 +23,7 @@ const ChangeMemoCreatedTsDialog: React.FC<Props> = (props: Props) => {
|
||||
const datetime = dayjs(memo.createdTs).format("YYYY-MM-DDTHH:mm");
|
||||
setCreatedAt(datetime);
|
||||
} else {
|
||||
toastHelper.error(t("message.memo-not-found"));
|
||||
toast.error(t("message.memo-not-found"));
|
||||
destroy();
|
||||
}
|
||||
});
|
||||
@@ -44,7 +43,7 @@ const ChangeMemoCreatedTsDialog: React.FC<Props> = (props: Props) => {
|
||||
const createdTs = dayjs(createdAt).unix();
|
||||
|
||||
if (createdTs > nowTs) {
|
||||
toastHelper.error(t("message.invalid-created-datetime"));
|
||||
toast.error(t("message.invalid-created-datetime"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -53,11 +52,11 @@ const ChangeMemoCreatedTsDialog: React.FC<Props> = (props: Props) => {
|
||||
id: memoId,
|
||||
createdTs,
|
||||
});
|
||||
toastHelper.info(t("message.memo-updated-datetime"));
|
||||
toast.success(t("message.memo-updated-datetime"));
|
||||
handleCloseBtnClick();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -69,7 +68,7 @@ const ChangeMemoCreatedTsDialog: React.FC<Props> = (props: Props) => {
|
||||
<Icon.X />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container">
|
||||
<div className="flex flex-col justify-start items-start !w-72 max-w-full">
|
||||
<p className="w-full bg-yellow-100 border border-yellow-400 rounded p-2 text-xs leading-4">
|
||||
THIS IS NOT A NORMAL BEHAVIOR. PLEASE MAKE SURE YOU REALLY NEED IT.
|
||||
</p>
|
||||
@@ -80,7 +79,7 @@ const ChangeMemoCreatedTsDialog: React.FC<Props> = (props: Props) => {
|
||||
max={maxDatetimeValue}
|
||||
onChange={handleDatetimeInputChange}
|
||||
/>
|
||||
<div className="btns-container">
|
||||
<div className="flex flex-row justify-end items-center mt-2 w-full">
|
||||
<span className="btn-text" onClick={handleCloseBtnClick}>
|
||||
{t("common.cancel")}
|
||||
</span>
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUserStore } from "../store/module";
|
||||
import { validate, ValidatorConfig } from "../helpers/validator";
|
||||
import { useUserStore } from "@/store/module";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
|
||||
const validateConfig: ValidatorConfig = {
|
||||
minLength: 4,
|
||||
maxLength: 320,
|
||||
noSpace: true,
|
||||
noChinese: true,
|
||||
};
|
||||
|
||||
type Props = DialogProps;
|
||||
|
||||
@@ -41,33 +33,27 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (newPassword === "" || newPasswordAgain === "") {
|
||||
toastHelper.error(t("message.fill-all"));
|
||||
toast.error(t("message.fill-all"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== newPasswordAgain) {
|
||||
toastHelper.error(t("message.new-password-not-match"));
|
||||
toast.error(t("message.new-password-not-match"));
|
||||
setNewPasswordAgain("");
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordValidResult = validate(newPassword, validateConfig);
|
||||
if (!passwordValidResult.result) {
|
||||
toastHelper.error(`${t("common.password")} ${t(passwordValidResult.reason as string)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = userStore.getState().user as User;
|
||||
await userStore.patchUser({
|
||||
id: user.id,
|
||||
password: newPassword,
|
||||
});
|
||||
toastHelper.info(t("message.password-changed"));
|
||||
toast.success(t("message.password-changed"));
|
||||
handleCloseBtnClick();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -80,21 +66,21 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container">
|
||||
<p className="text-sm mb-1">{t("common.new-password")}</p>
|
||||
<p className="text-sm mb-1">{t("auth.new-password")}</p>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className="input-text"
|
||||
placeholder={t("common.repeat-new-password")}
|
||||
placeholder={t("auth.repeat-new-password")}
|
||||
value={newPassword}
|
||||
onChange={handleNewPasswordChanged}
|
||||
/>
|
||||
<p className="text-sm mb-1 mt-2">{t("common.repeat-new-password")}</p>
|
||||
<p className="text-sm mb-1 mt-2">{t("auth.repeat-new-password")}</p>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className="input-text"
|
||||
placeholder={t("common.repeat-new-password")}
|
||||
placeholder={t("auth.repeat-new-password")}
|
||||
value={newPasswordAgain}
|
||||
onChange={handleNewPasswordAgainChanged}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useResourceStore } from "../store/module";
|
||||
import { useResourceStore } from "@/store/module";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
import "../less/change-resource-filename-dialog.less";
|
||||
import "@/less/change-resource-filename-dialog.less";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
resourceId: ResourceId;
|
||||
@@ -44,7 +44,7 @@ const ChangeResourceFilenameDialog: React.FC<Props> = (props: Props) => {
|
||||
return;
|
||||
}
|
||||
if (!validateFilename(filename)) {
|
||||
toastHelper.error(t("message.invalid-resource-filename"));
|
||||
toast.error(t("message.invalid-resource-filename"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -52,11 +52,11 @@ const ChangeResourceFilenameDialog: React.FC<Props> = (props: Props) => {
|
||||
id: resourceId,
|
||||
filename: filename,
|
||||
});
|
||||
toastHelper.info(t("message.resource-filename-updated"));
|
||||
toast.success(t("message.resource-filename-updated"));
|
||||
handleCloseBtnClick();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button, Divider, Input, Radio, RadioGroup, Typography } from "@mui/joy";
|
||||
import * as api from "../helpers/api";
|
||||
import { UNKNOWN_ID } from "../helpers/consts";
|
||||
import { absolutifyLink } from "../helpers/utils";
|
||||
import * as api from "@/helpers/api";
|
||||
import { UNKNOWN_ID } from "@/helpers/consts";
|
||||
import { absolutifyLink } from "@/helpers/utils";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import Icon from "./Icon";
|
||||
import toastHelper from "./Toast";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
identityProvider?: IdentityProvider;
|
||||
@@ -34,6 +34,27 @@ const templateList: IdentityProvider[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: UNKNOWN_ID,
|
||||
name: "GitLab",
|
||||
type: "OAUTH2",
|
||||
identifierFilter: "",
|
||||
config: {
|
||||
oauth2Config: {
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
authUrl: "https://gitlab.com/oauth/authorize",
|
||||
tokenUrl: "https://gitlab.com/oauth/token",
|
||||
userInfoUrl: "https://gitlab.com/oauth/userinfo",
|
||||
scopes: ["openid"],
|
||||
fieldMapping: {
|
||||
identifier: "name",
|
||||
displayName: "name",
|
||||
email: "email",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: UNKNOWN_ID,
|
||||
name: "Google",
|
||||
@@ -172,7 +193,7 @@ const CreateIdentityProviderDialog: React.FC<Props> = (props: Props) => {
|
||||
},
|
||||
},
|
||||
});
|
||||
toastHelper.info(`SSO ${basicInfo.name} created`);
|
||||
toast.success(`SSO ${basicInfo.name} created`);
|
||||
} else {
|
||||
await api.patchIdentityProvider({
|
||||
id: identityProvider?.id,
|
||||
@@ -185,11 +206,11 @@ const CreateIdentityProviderDialog: React.FC<Props> = (props: Props) => {
|
||||
},
|
||||
},
|
||||
});
|
||||
toastHelper.info(`SSO ${basicInfo.name} updated`);
|
||||
toast.success(`SSO ${basicInfo.name} updated`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
if (confirmCallback) {
|
||||
confirmCallback();
|
||||
@@ -275,10 +296,7 @@ const CreateIdentityProviderDialog: React.FC<Props> = (props: Props) => {
|
||||
{type === "OAUTH2" && (
|
||||
<>
|
||||
{isCreating && (
|
||||
<p className="border rounded-md p-2 text-sm w-full mb-2 break-all">
|
||||
Redirect URL: {absolutifyLink("/auth/callback")}{" "}
|
||||
RedirectRedirectRedirectRedirectRedirectRedirectRedirectRedirectRedirectRedirectRedirect
|
||||
</p>
|
||||
<p className="border rounded-md p-2 text-sm w-full mb-2 break-all">Redirect URL: {absolutifyLink("/auth/callback")}</p>
|
||||
)}
|
||||
<Typography className="!mb-1" level="body2">
|
||||
Client ID<span className="text-red-600">*</span>
|
||||
|
||||