mirror of
https://github.com/usememos/memos.git
synced 2025-06-05 22:09:59 +02:00
Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
3
.github/workflows/backend-tests.yml
vendored
3
.github/workflows/backend-tests.yml
vendored
@@ -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:
|
||||
|
||||
16
.github/workflows/uffizzi-build.yml
vendored
16
.github/workflows/uffizzi-build.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/uffizzi-preview.yml
vendored
2
.github/workflows/uffizzi-preview.yml
vendored
@@ -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
|
||||
|
||||
19
README.md
19
README.md
@@ -1,17 +1,18 @@
|
||||
<p align="center"><a href="https://usememos.com"><img height="64px" src="https://raw.githubusercontent.com/usememos/memos/main/resources/logo-full.webp" alt="✍️ memos" /></a></p>
|
||||
# memos
|
||||
|
||||
<p align="center">
|
||||
<img height="72px" src="https://usememos.com/logo.webp" alt="✍️ memos" align="right" />
|
||||
|
||||
A lightweight, self-hosted memo hub. Open Source and Free forever.
|
||||
|
||||
<a href="https://demo.usememos.com/">Live Demo</a> •
|
||||
Discuss in <a href="https://t.me/+-_tNF1k70UU4ZTc9">Telegram</a> / <a href="https://discord.gg/tfPJa4UmAv">Discord</a>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/usememos/memos/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/usememos/memos" /></a>
|
||||
<a href="https://hub.docker.com/r/neosmemo/memos"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/neosmemo/memos.svg" /></a>
|
||||
<a href="https://hosted.weblate.org/engage/memos/"><img src="https://hosted.weblate.org/widgets/memos/-/svg-badge.svg" alt="Translation status" /></a>
|
||||
<a href="https://discord.gg/tfPJa4UmAv"><img alt="Discord" src="https://img.shields.io/badge/discord-chat-5865f2?logo=discord&logoColor=f5f5f5" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://demo.usememos.com/">Live Demo</a> •
|
||||
Discuss in <a href="https://t.me/+-_tNF1k70UU4ZTc9">Telegram</a> / <a href="https://discord.gg/tfPJa4UmAv">Discord</a>
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
## Key points
|
||||
@@ -40,7 +41,7 @@ Contributions are what make the open-source community such an amazing place to l
|
||||
<img src="https://contrib.rocks/image?repo=usememos/memos" />
|
||||
</a>
|
||||
|
||||
Here are some products made by our community:
|
||||
---
|
||||
|
||||
- [Moe Memos](https://memos.moe/) - Third party client for iOS and Android
|
||||
- [lmm214/memos-bber](https://github.com/lmm214/memos-bber) - Chrome extension
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
package api
|
||||
|
||||
type OpenAICompletionRequest struct {
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
@@ -9,12 +9,13 @@ type Resource struct {
|
||||
UpdatedTs int64 `json:"updatedTs"`
|
||||
|
||||
// Domain specific fields
|
||||
Filename string `json:"filename"`
|
||||
Blob []byte `json:"-"`
|
||||
ExternalLink string `json:"externalLink"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
Visibility Visibility `json:"visibility"`
|
||||
Filename string `json:"filename"`
|
||||
Blob []byte `json:"-"`
|
||||
InternalPath string `json:"internalPath"`
|
||||
ExternalLink string `json:"externalLink"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
PublicID string `json:"publicId"`
|
||||
|
||||
// Related fields
|
||||
LinkedMemoAmount int `json:"linkedMemoAmount"`
|
||||
@@ -25,12 +26,13 @@ type ResourceCreate struct {
|
||||
CreatorID int `json:"-"`
|
||||
|
||||
// Domain specific fields
|
||||
Filename string `json:"filename"`
|
||||
Blob []byte `json:"-"`
|
||||
ExternalLink string `json:"externalLink"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"-"`
|
||||
Visibility Visibility `json:"visibility"`
|
||||
Filename string `json:"filename"`
|
||||
Blob []byte `json:"-"`
|
||||
InternalPath string `json:"internalPath"`
|
||||
ExternalLink string `json:"externalLink"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"-"`
|
||||
PublicID string `json:"publicId"`
|
||||
}
|
||||
|
||||
type ResourceFind struct {
|
||||
@@ -42,7 +44,12 @@ type ResourceFind struct {
|
||||
// Domain specific fields
|
||||
Filename *string `json:"filename"`
|
||||
MemoID *int
|
||||
PublicID *string `json:"publicId"`
|
||||
GetBlob bool
|
||||
|
||||
// Pagination
|
||||
Limit *int
|
||||
Offset *int
|
||||
}
|
||||
|
||||
type ResourcePatch struct {
|
||||
@@ -52,8 +59,9 @@ type ResourcePatch struct {
|
||||
UpdatedTs *int64
|
||||
|
||||
// Domain specific fields
|
||||
Filename *string `json:"filename"`
|
||||
Visibility *Visibility `json:"visibility"`
|
||||
Filename *string `json:"filename"`
|
||||
ResetPublicID *bool `json:"resetPublicId"`
|
||||
PublicID *string `json:"-"`
|
||||
}
|
||||
|
||||
type ResourceDelete struct {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
package api
|
||||
|
||||
const (
|
||||
// LocalStorage means the storage service is local file system.
|
||||
LocalStorage = -1
|
||||
// DatabaseStorage means the storage service is database.
|
||||
DatabaseStorage = 0
|
||||
)
|
||||
|
||||
type StorageType string
|
||||
|
||||
const (
|
||||
@@ -18,6 +25,7 @@ type StorageS3Config struct {
|
||||
SecretKey string `json:"secretKey"`
|
||||
Bucket string `json:"bucket"`
|
||||
URLPrefix string `json:"urlPrefix"`
|
||||
URLSuffix string `json:"urlSuffix"`
|
||||
}
|
||||
|
||||
type Storage struct {
|
||||
|
||||
@@ -18,5 +18,8 @@ type SystemStatus struct {
|
||||
AdditionalScript string `json:"additionalScript"`
|
||||
// Customized server profile, including server name and external url.
|
||||
CustomizedProfile CustomizedProfile `json:"customizedProfile"`
|
||||
StorageServiceID int `json:"storageServiceId"`
|
||||
// Storage service ID.
|
||||
StorageServiceID int `json:"storageServiceId"`
|
||||
// Local storage path
|
||||
LocalStoragePath string `json:"localStoragePath"`
|
||||
}
|
||||
|
||||
@@ -11,24 +11,26 @@ import (
|
||||
type SystemSettingName string
|
||||
|
||||
const (
|
||||
// SystemSettingServerID is the key type of server id.
|
||||
SystemSettingServerID SystemSettingName = "serverId"
|
||||
// SystemSettingSecretSessionName is the key type of secret session name.
|
||||
SystemSettingSecretSessionName SystemSettingName = "secretSessionName"
|
||||
// SystemSettingAllowSignUpName is the key type of allow signup setting.
|
||||
SystemSettingAllowSignUpName SystemSettingName = "allowSignUp"
|
||||
// SystemSettingDisablePublicMemosName is the key type of disable public memos setting.
|
||||
SystemSettingDisablePublicMemosName SystemSettingName = "disablePublicMemos"
|
||||
// SystemSettingAdditionalStyleName is the key type of additional style.
|
||||
SystemSettingAdditionalStyleName SystemSettingName = "additionalStyle"
|
||||
// SystemSettingAdditionalScriptName is the key type of additional script.
|
||||
SystemSettingAdditionalScriptName SystemSettingName = "additionalScript"
|
||||
// SystemSettingCustomizedProfileName is the key type of customized server profile.
|
||||
SystemSettingCustomizedProfileName SystemSettingName = "customizedProfile"
|
||||
// SystemSettingStorageServiceIDName is the key type of storage service ID.
|
||||
SystemSettingStorageServiceIDName SystemSettingName = "storageServiceId"
|
||||
// SystemSettingOpenAIConfigName is the key type of OpenAI config.
|
||||
SystemSettingOpenAIConfigName SystemSettingName = "openAIConfig"
|
||||
// SystemSettingServerIDName is the name of server id.
|
||||
SystemSettingServerIDName SystemSettingName = "server-id"
|
||||
// SystemSettingSecretSessionName is the name of secret session.
|
||||
SystemSettingSecretSessionName SystemSettingName = "secret-session"
|
||||
// SystemSettingAllowSignUpName is the name of allow signup setting.
|
||||
SystemSettingAllowSignUpName SystemSettingName = "allow-signup"
|
||||
// SystemSettingDisablePublicMemosName is the name of disable public memos setting.
|
||||
SystemSettingDisablePublicMemosName SystemSettingName = "disable-public-memos"
|
||||
// SystemSettingAdditionalStyleName is the name of additional style.
|
||||
SystemSettingAdditionalStyleName SystemSettingName = "additional-style"
|
||||
// SystemSettingAdditionalScriptName is the name of additional script.
|
||||
SystemSettingAdditionalScriptName SystemSettingName = "additional-script"
|
||||
// SystemSettingCustomizedProfileName is the name of customized server profile.
|
||||
SystemSettingCustomizedProfileName SystemSettingName = "customized-profile"
|
||||
// SystemSettingStorageServiceIDName is the name of storage service ID.
|
||||
SystemSettingStorageServiceIDName SystemSettingName = "storage-service-id"
|
||||
// SystemSettingLocalStoragePathName is the name of local storage path.
|
||||
SystemSettingLocalStoragePathName SystemSettingName = "local-storage-path"
|
||||
// SystemSettingOpenAIConfigName is the name of OpenAI config.
|
||||
SystemSettingOpenAIConfigName SystemSettingName = "openai-config"
|
||||
)
|
||||
|
||||
// CustomizedProfile is the struct definition for SystemSettingCustomizedProfileName system setting item.
|
||||
@@ -54,24 +56,26 @@ type OpenAIConfig struct {
|
||||
|
||||
func (key SystemSettingName) String() string {
|
||||
switch key {
|
||||
case SystemSettingServerID:
|
||||
return "serverId"
|
||||
case SystemSettingServerIDName:
|
||||
return "server-id"
|
||||
case SystemSettingSecretSessionName:
|
||||
return "secretSessionName"
|
||||
return "secret-session"
|
||||
case SystemSettingAllowSignUpName:
|
||||
return "allowSignUp"
|
||||
return "allow-signup"
|
||||
case SystemSettingDisablePublicMemosName:
|
||||
return "disablePublicMemos"
|
||||
return "disable-public-memos"
|
||||
case SystemSettingAdditionalStyleName:
|
||||
return "additionalStyle"
|
||||
return "additional-style"
|
||||
case SystemSettingAdditionalScriptName:
|
||||
return "additionalScript"
|
||||
return "additional-script"
|
||||
case SystemSettingCustomizedProfileName:
|
||||
return "customizedProfile"
|
||||
return "customized-profile"
|
||||
case SystemSettingStorageServiceIDName:
|
||||
return "storageServiceId"
|
||||
return "storage-service-id"
|
||||
case SystemSettingLocalStoragePathName:
|
||||
return "local-storage-path"
|
||||
case SystemSettingOpenAIConfigName:
|
||||
return "openAIConfig"
|
||||
return "openai-config"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -90,7 +94,7 @@ type SystemSettingUpsert struct {
|
||||
}
|
||||
|
||||
func (upsert SystemSettingUpsert) Validate() error {
|
||||
if upsert.Name == SystemSettingServerID {
|
||||
if upsert.Name == SystemSettingServerIDName {
|
||||
return errors.New("update server id is not allowed")
|
||||
} else if upsert.Name == SystemSettingAllowSignUpName {
|
||||
value := false
|
||||
@@ -136,12 +140,18 @@ func (upsert SystemSettingUpsert) Validate() error {
|
||||
return fmt.Errorf("invalid appearance value")
|
||||
}
|
||||
} else if upsert.Name == SystemSettingStorageServiceIDName {
|
||||
value := 0
|
||||
value := DatabaseStorage
|
||||
err := json.Unmarshal([]byte(upsert.Value), &value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal system setting storage service id value")
|
||||
}
|
||||
return nil
|
||||
} else if upsert.Name == SystemSettingLocalStoragePathName {
|
||||
value := ""
|
||||
err := json.Unmarshal([]byte(upsert.Value), &value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal system setting local storage path value")
|
||||
}
|
||||
} else if upsert.Name == SystemSettingOpenAIConfigName {
|
||||
value := OpenAIConfig{}
|
||||
err := json.Unmarshal([]byte(upsert.Value), &value)
|
||||
|
||||
@@ -15,9 +15,7 @@ const (
|
||||
// UserSettingAppearanceKey is the key type for user appearance.
|
||||
UserSettingAppearanceKey UserSettingKey = "appearance"
|
||||
// UserSettingMemoVisibilityKey is the key type for user preference memo default visibility.
|
||||
UserSettingMemoVisibilityKey UserSettingKey = "memoVisibility"
|
||||
// UserSettingResourceVisibilityKey is the key type for user preference resource default visibility.
|
||||
UserSettingResourceVisibilityKey UserSettingKey = "resourceVisibility"
|
||||
UserSettingMemoVisibilityKey UserSettingKey = "memo-visibility"
|
||||
)
|
||||
|
||||
// String returns the string format of UserSettingKey type.
|
||||
@@ -28,18 +26,15 @@ func (key UserSettingKey) String() string {
|
||||
case UserSettingAppearanceKey:
|
||||
return "appearance"
|
||||
case UserSettingMemoVisibilityKey:
|
||||
return "memoVisibility"
|
||||
case UserSettingResourceVisibilityKey:
|
||||
return "resourceVisibility"
|
||||
return "memo-visibility"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var (
|
||||
UserSettingLocaleValue = []string{"en", "zh", "vi", "fr", "nl", "sv", "de", "es", "uk", "ru", "it", "hant", "tr", "ko"}
|
||||
UserSettingAppearanceValue = []string{"system", "light", "dark"}
|
||||
UserSettingMemoVisibilityValue = []Visibility{Private, Protected, Public}
|
||||
UserSettingResourceVisibilityValue = []Visibility{Private, Protected, Public}
|
||||
UserSettingLocaleValue = []string{"en", "zh", "vi", "fr", "nl", "sv", "de", "es", "uk", "ru", "it", "hant", "tr", "ko", "sl"}
|
||||
UserSettingAppearanceValue = []string{"system", "light", "dark"}
|
||||
UserSettingMemoVisibilityValue = []Visibility{Private, Protected, Public}
|
||||
)
|
||||
|
||||
type UserSetting struct {
|
||||
@@ -83,15 +78,6 @@ func (upsert UserSettingUpsert) Validate() error {
|
||||
if !slices.Contains(UserSettingMemoVisibilityValue, memoVisibilityValue) {
|
||||
return fmt.Errorf("invalid user setting memo visibility value")
|
||||
}
|
||||
} else if upsert.Key == UserSettingResourceVisibilityKey {
|
||||
resourceVisibilityValue := Private
|
||||
err := json.Unmarshal([]byte(upsert.Value), &resourceVisibilityValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal user setting resource visibility value")
|
||||
}
|
||||
if !slices.Contains(UserSettingResourceVisibilityValue, resourceVisibilityValue) {
|
||||
return fmt.Errorf("invalid user setting resource visibility value")
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("invalid user setting key")
|
||||
}
|
||||
|
||||
|
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"
|
||||
)
|
||||
@@ -4,11 +4,15 @@
|
||||
2. Navigate to the System Tab
|
||||
3. In the "Additional Styles" box add these lines of code:
|
||||
|
||||
```css
|
||||
.memo-list-container {background-color: #INSERT COLOR HERE;}
|
||||
.page-container {background-color: #INSERT COLOR HERE;}
|
||||
```
|
||||
```css
|
||||
.memo-list-container {
|
||||
background-color: #INSERT COLOR HERE;
|
||||
}
|
||||
.page-container {
|
||||
background-color: #INSERT COLOR HERE;
|
||||
}
|
||||
```
|
||||
|
||||
It is recommended that you choose the same color for both options
|
||||
It is recommended that you choose the same color for both options
|
||||
|
||||
4. Refresh the page and the background color of your memos app will successfully update to reflect your changes
|
||||
@@ -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
|
||||
|
||||
|
||||
6
docs/setup.md
Normal file
6
docs/setup.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Setup
|
||||
|
||||
After deploying and running Memos in `prod` mode, you should create "host" user. There are two ways to do this:
|
||||
|
||||
1. Navigate to the Memos application URL, such as `http://localhost:5230`, and follow the prompts to create a username and password for the "host" user.
|
||||
2. Use the command `memos setup --host-username=$USERNAME --host-password=$PASSWORD --mode=prod` to set up the host user. This method may be more convenient for deploying through Ansible or other provisioning softwares.
|
||||
6
go.mod
6
go.mod
@@ -10,8 +10,6 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.30.3
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/gorilla/feeds v1.1.1
|
||||
github.com/gorilla/sessions v1.2.1
|
||||
github.com/labstack/echo-contrib v0.13.0
|
||||
github.com/labstack/echo/v4 v4.9.0
|
||||
github.com/mattn/go-sqlite3 v1.14.9
|
||||
github.com/pkg/errors v0.9.1
|
||||
@@ -44,9 +42,8 @@ require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.6.0 // indirect
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/gorilla/context v1.1.1 // indirect
|
||||
github.com/gorilla/securecookie v1.1.1 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.0.1 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
@@ -62,6 +59,7 @@ require (
|
||||
github.com/spf13/cast v1.5.0 // indirect
|
||||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/stretchr/objx v0.5.0 // indirect
|
||||
github.com/subosito/gotenv v1.4.2 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.1 // indirect
|
||||
|
||||
11
go.sum
11
go.sum
@@ -106,6 +106,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
@@ -168,14 +170,8 @@ github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
|
||||
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
|
||||
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
||||
github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY=
|
||||
github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
|
||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
@@ -200,8 +196,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/labstack/echo-contrib v0.13.0 h1:bzSG0SpuZZd7BmJLvsWtPfU23W0Enh3K0tok3aENVKA=
|
||||
github.com/labstack/echo-contrib v0.13.0/go.mod h1:IF9+MJu22ADOZEHD+bAV67XMIO3vNXUy7Naz/ABPHEs=
|
||||
github.com/labstack/echo/v4 v4.9.0 h1:wPOF1CE6gvt/kmbMR4dGzWvHMPT+sAEUJOwOTtvITVY=
|
||||
github.com/labstack/echo/v4 v4.9.0/go.mod h1:xkCDAdFCIf8jsFQ5NnbK7oqaF/yU1A1X20Ltm0OvSks=
|
||||
github.com/labstack/gommon v0.3.1 h1:OomWaJXm7xR6L1HmEtGyQf26TEn7V6X88mktX9kee9o=
|
||||
@@ -244,6 +238,7 @@ github.com/spf13/viper v1.15.0 h1:js3yy885G8xwJa6iOISGFwd+qlUo5AvyXb7CiihdtiU=
|
||||
github.com/spf13/viper v1.15.0/go.mod h1:fFcTBJxvhhzSJiZy8n+PeW6t8l+KeT/uTARa0jHOQLA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
|
||||
19
plugin/http-getter/html_meta_test.go
Normal file
19
plugin/http-getter/html_meta_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package getter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetHTMLMeta(t *testing.T) {
|
||||
tests := []struct {
|
||||
urlStr string
|
||||
htmlMeta HTMLMeta
|
||||
}{}
|
||||
for _, test := range tests {
|
||||
metadata, err := GetHTMLMeta(test.urlStr)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, test.htmlMeta, *metadata)
|
||||
}
|
||||
}
|
||||
@@ -1,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)
|
||||
}
|
||||
}
|
||||
@@ -19,12 +19,12 @@ type ChatCompletionChoice struct {
|
||||
}
|
||||
|
||||
type ChatCompletionResponse struct {
|
||||
Error interface{} `json:"error"`
|
||||
Error any `json:"error"`
|
||||
Model string `json:"model"`
|
||||
Choices []ChatCompletionChoice `json:"choices"`
|
||||
}
|
||||
|
||||
func PostChatCompletion(prompt string, apiKey string, apiHost string) (string, error) {
|
||||
func PostChatCompletion(messages []ChatCompletionMessage, apiKey string, apiHost string) (string, error) {
|
||||
if apiHost == "" {
|
||||
apiHost = "https://api.openai.com"
|
||||
}
|
||||
@@ -33,9 +33,13 @@ func PostChatCompletion(prompt string, apiKey string, apiHost string) (string, e
|
||||
return "", err
|
||||
}
|
||||
|
||||
values := map[string]interface{}{
|
||||
"model": "gpt-3.5-turbo",
|
||||
"messages": []map[string]string{{"role": "user", "content": prompt}},
|
||||
values := map[string]any{
|
||||
"model": "gpt-3.5-turbo",
|
||||
"messages": messages,
|
||||
"max_tokens": 2000,
|
||||
"temperature": 0,
|
||||
"frequency_penalty": 0.0,
|
||||
"presence_penalty": 0.0,
|
||||
}
|
||||
jsonValue, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
|
||||
@@ -14,7 +14,7 @@ type TextCompletionChoice struct {
|
||||
}
|
||||
|
||||
type TextCompletionResponse struct {
|
||||
Error interface{} `json:"error"`
|
||||
Error any `json:"error"`
|
||||
Model string `json:"model"`
|
||||
Choices []TextCompletionChoice `json:"choices"`
|
||||
}
|
||||
@@ -28,7 +28,7 @@ func PostTextCompletion(prompt string, apiKey string, apiHost string) (string, e
|
||||
return "", err
|
||||
}
|
||||
|
||||
values := map[string]interface{}{
|
||||
values := map[string]any{
|
||||
"model": "gpt-3.5-turbo",
|
||||
"prompt": prompt,
|
||||
"temperature": 0.5,
|
||||
|
||||
@@ -20,6 +20,7 @@ type Config struct {
|
||||
EndPoint string
|
||||
Region string
|
||||
URLPrefix string
|
||||
URLSuffix string
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
@@ -28,7 +29,7 @@ type Client struct {
|
||||
}
|
||||
|
||||
func NewClient(ctx context.Context, config *Config) (*Client, error) {
|
||||
resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
|
||||
resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...any) (aws.Endpoint, error) {
|
||||
return aws.Endpoint{
|
||||
URL: config.EndPoint,
|
||||
SigningRegion: config.Region,
|
||||
@@ -67,7 +68,7 @@ func (client *Client) UploadFile(ctx context.Context, filename string, fileType
|
||||
link := uploadOutput.Location
|
||||
// If url prefix is set, use it as the file link.
|
||||
if client.Config.URLPrefix != "" {
|
||||
link = fmt.Sprintf("%s/%s", client.Config.URLPrefix, filename)
|
||||
link = fmt.Sprintf("%s/%s%s", client.Config.URLPrefix, filename, client.Config.URLSuffix)
|
||||
}
|
||||
if link == "" {
|
||||
return "", fmt.Errorf("failed to get file link")
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 63 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
func (s *Server) registerAuthRoutes(g *echo.Group, secret string) {
|
||||
g.POST("/auth/signin", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
signin := &api.SignIn{}
|
||||
@@ -44,8 +44,8 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Incorrect login credentials, please try again")
|
||||
}
|
||||
|
||||
if err = setUserSession(c, user); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set signin session").SetInternal(err)
|
||||
if err := GenerateTokensAndSetCookies(c, user, s.Profile.Mode, secret); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate tokens").SetInternal(err)
|
||||
}
|
||||
if err := s.createUserAuthSignInActivity(c, user); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
@@ -128,8 +128,8 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with username %s", userInfo.Identifier))
|
||||
}
|
||||
|
||||
if err = setUserSession(c, user); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set signin session").SetInternal(err)
|
||||
if err := GenerateTokensAndSetCookies(c, user, s.Profile.Mode, secret); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate tokens").SetInternal(err)
|
||||
}
|
||||
if err := s.createUserAuthSignInActivity(c, user); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
@@ -196,23 +196,18 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
|
||||
}
|
||||
if err := GenerateTokensAndSetCookies(c, user, s.Profile.Mode, secret); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate tokens").SetInternal(err)
|
||||
}
|
||||
if err := s.createUserAuthSignUpActivity(c, user); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
|
||||
err = setUserSession(c, user)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set signup session").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, composeResponse(user))
|
||||
})
|
||||
|
||||
g.POST("/auth/signout", func(c echo.Context) error {
|
||||
err := removeUserSession(c)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set sign out session").SetInternal(err)
|
||||
}
|
||||
|
||||
RemoveTokensAndCookies(c)
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
}
|
||||
|
||||
88
server/auth/auth.go
Normal file
88
server/auth/auth.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
issuer = "memos"
|
||||
// Signing key section. For now, this is only used for signing, not for verifying since we only
|
||||
// have 1 version. But it will be used to maintain backward compatibility if we change the signing mechanism.
|
||||
keyID = "v1"
|
||||
// AccessTokenAudienceFmt is the format of the acccess token audience.
|
||||
AccessTokenAudienceFmt = "user.access.%s"
|
||||
// RefreshTokenAudienceFmt is the format of the refresh token audience.
|
||||
RefreshTokenAudienceFmt = "user.refresh.%s"
|
||||
apiTokenDuration = 2 * time.Hour
|
||||
accessTokenDuration = 24 * time.Hour
|
||||
refreshTokenDuration = 7 * 24 * time.Hour
|
||||
// RefreshThresholdDuration is the threshold duration for refreshing token.
|
||||
RefreshThresholdDuration = 1 * time.Hour
|
||||
|
||||
// CookieExpDuration expires slightly earlier than the jwt expiration. Client would be logged out if the user
|
||||
// cookie expires, thus the client would always logout first before attempting to make a request with the expired jwt.
|
||||
// Suppose we have a valid refresh token, we will refresh the token in 2 cases:
|
||||
// 1. The access token is about to expire in <<refreshThresholdDuration>>
|
||||
// 2. The access token has already expired, we refresh the token so that the ongoing request can pass through.
|
||||
CookieExpDuration = refreshTokenDuration - 1*time.Minute
|
||||
// AccessTokenCookieName is the cookie name of access token.
|
||||
AccessTokenCookieName = "access-token"
|
||||
// RefreshTokenCookieName is the cookie name of refresh token.
|
||||
RefreshTokenCookieName = "refresh-token"
|
||||
// UserIDCookieName is the cookie name of user ID.
|
||||
UserIDCookieName = "user"
|
||||
)
|
||||
|
||||
type claimsMessage struct {
|
||||
Name string `json:"name"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// GenerateAPIToken generates an API token.
|
||||
func GenerateAPIToken(userName string, userID int, mode string, secret string) (string, error) {
|
||||
expirationTime := time.Now().Add(apiTokenDuration)
|
||||
return generateToken(userName, userID, fmt.Sprintf(AccessTokenAudienceFmt, mode), expirationTime, []byte(secret))
|
||||
}
|
||||
|
||||
// GenerateAccessToken generates an access token for web.
|
||||
func GenerateAccessToken(userName string, userID int, mode string, secret string) (string, error) {
|
||||
expirationTime := time.Now().Add(accessTokenDuration)
|
||||
return generateToken(userName, userID, fmt.Sprintf(AccessTokenAudienceFmt, mode), expirationTime, []byte(secret))
|
||||
}
|
||||
|
||||
// GenerateRefreshToken generates a refresh token for web.
|
||||
func GenerateRefreshToken(userName string, userID int, mode string, secret string) (string, error) {
|
||||
expirationTime := time.Now().Add(refreshTokenDuration)
|
||||
return generateToken(userName, userID, fmt.Sprintf(RefreshTokenAudienceFmt, mode), expirationTime, []byte(secret))
|
||||
}
|
||||
|
||||
func generateToken(username string, userID int, aud string, expirationTime time.Time, secret []byte) (string, error) {
|
||||
// Create the JWT claims, which includes the username and expiry time.
|
||||
claims := &claimsMessage{
|
||||
Name: username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Audience: jwt.ClaimStrings{aud},
|
||||
// In JWT, the expiry time is expressed as unix milliseconds.
|
||||
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: issuer,
|
||||
Subject: strconv.Itoa(userID),
|
||||
},
|
||||
}
|
||||
|
||||
// Declare the token with the HS256 algorithm used for signing, and the claims.
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
token.Header["kid"] = keyID
|
||||
|
||||
// Create the JWT string.
|
||||
tokenString, err := token.SignedString(secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return tokenString, nil
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
260
server/jwt.go
Normal file
260
server/jwt.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/labstack/echo/v4"
|
||||
pkgerrors "github.com/pkg/errors"
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
"github.com/usememos/memos/server/auth"
|
||||
)
|
||||
|
||||
const (
|
||||
// Context section
|
||||
// The key name used to store user id in the context
|
||||
// user id is extracted from the jwt token subject field.
|
||||
userIDContextKey = "user-id"
|
||||
)
|
||||
|
||||
// Claims creates a struct that will be encoded to a JWT.
|
||||
// We add jwt.RegisteredClaims as an embedded type, to provide fields such as name.
|
||||
type Claims struct {
|
||||
Name string `json:"name"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func getUserIDContextKey() string {
|
||||
return userIDContextKey
|
||||
}
|
||||
|
||||
// GenerateTokensAndSetCookies generates jwt token and saves it to the http-only cookie.
|
||||
func GenerateTokensAndSetCookies(c echo.Context, user *api.User, mode string, secret string) error {
|
||||
accessToken, err := auth.GenerateAccessToken(user.Username, user.ID, mode, secret)
|
||||
if err != nil {
|
||||
return pkgerrors.Wrap(err, "failed to generate access token")
|
||||
}
|
||||
|
||||
cookieExp := time.Now().Add(auth.CookieExpDuration)
|
||||
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
||||
|
||||
// We generate here a new refresh token and saving it to the cookie.
|
||||
refreshToken, err := auth.GenerateRefreshToken(user.Username, user.ID, mode, secret)
|
||||
if err != nil {
|
||||
return pkgerrors.Wrap(err, "failed to generate refresh token")
|
||||
}
|
||||
setTokenCookie(c, auth.RefreshTokenCookieName, refreshToken, cookieExp)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveTokensAndCookies removes the jwt token and refresh token from the cookies.
|
||||
func RemoveTokensAndCookies(c echo.Context) {
|
||||
// We set the expiration time to the past, so that the cookie will be removed.
|
||||
cookieExp := time.Now().Add(-1 * time.Hour)
|
||||
setTokenCookie(c, auth.AccessTokenCookieName, "", cookieExp)
|
||||
setTokenCookie(c, auth.RefreshTokenCookieName, "", cookieExp)
|
||||
}
|
||||
|
||||
// Here we are creating a new cookie, which will store the valid JWT token.
|
||||
func setTokenCookie(c echo.Context, name, token string, expiration time.Time) {
|
||||
cookie := new(http.Cookie)
|
||||
cookie.Name = name
|
||||
cookie.Value = token
|
||||
cookie.Expires = expiration
|
||||
cookie.Path = "/"
|
||||
// Http-only helps mitigate the risk of client side script accessing the protected cookie.
|
||||
cookie.HttpOnly = true
|
||||
cookie.SameSite = http.SameSiteStrictMode
|
||||
c.SetCookie(cookie)
|
||||
}
|
||||
|
||||
func extractTokenFromHeader(c echo.Context) (string, error) {
|
||||
authHeader := c.Request().Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
authHeaderParts := strings.Fields(authHeader)
|
||||
if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" {
|
||||
return "", errors.New("Authorization header format must be Bearer {token}")
|
||||
}
|
||||
|
||||
return authHeaderParts[1], nil
|
||||
}
|
||||
|
||||
func findAccessToken(c echo.Context) string {
|
||||
accessToken := ""
|
||||
cookie, _ := c.Cookie(auth.AccessTokenCookieName)
|
||||
if cookie != nil {
|
||||
accessToken = cookie.Value
|
||||
}
|
||||
if accessToken == "" {
|
||||
accessToken, _ = extractTokenFromHeader(c)
|
||||
}
|
||||
|
||||
return accessToken
|
||||
}
|
||||
|
||||
// JWTMiddleware validates the access token.
|
||||
// If the access token is about to expire or has expired and the request has a valid refresh token, it
|
||||
// will try to generate new access token and refresh token.
|
||||
func JWTMiddleware(server *Server, next echo.HandlerFunc, secret string) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
path := c.Request().URL.Path
|
||||
method := c.Request().Method
|
||||
mode := server.Profile.Mode
|
||||
|
||||
if server.defaultAuthSkipper(c) {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Skip validation for server status endpoints.
|
||||
if common.HasPrefixes(path, "/api/ping", "/api/status", "/api/idp", "/api/user/:id") && method == http.MethodGet {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
token := findAccessToken(c)
|
||||
if token == "" {
|
||||
// Allow the user to access the public endpoints.
|
||||
if common.HasPrefixes(path, "/o") {
|
||||
return next(c)
|
||||
}
|
||||
// When the request is not authenticated, we allow the user to access the memo endpoints for those public memos.
|
||||
if common.HasPrefixes(path, "/api/memo") && method == http.MethodGet {
|
||||
return next(c)
|
||||
}
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing access token")
|
||||
}
|
||||
|
||||
claims := &Claims{}
|
||||
accessToken, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
|
||||
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
||||
return nil, pkgerrors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
||||
}
|
||||
if kid, ok := t.Header["kid"].(string); ok {
|
||||
if kid == "v1" {
|
||||
return []byte(secret), nil
|
||||
}
|
||||
}
|
||||
return nil, pkgerrors.Errorf("unexpected access token kid=%v", t.Header["kid"])
|
||||
})
|
||||
|
||||
if !audienceContains(claims.Audience, fmt.Sprintf(auth.AccessTokenAudienceFmt, mode)) {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized,
|
||||
fmt.Sprintf("Invalid access token, audience mismatch, got %q, expected %q. you may send request to the wrong environment",
|
||||
claims.Audience,
|
||||
fmt.Sprintf(auth.AccessTokenAudienceFmt, mode),
|
||||
))
|
||||
}
|
||||
|
||||
generateToken := time.Until(claims.ExpiresAt.Time) < auth.RefreshThresholdDuration
|
||||
if err != nil {
|
||||
var ve *jwt.ValidationError
|
||||
if errors.As(err, &ve) {
|
||||
// If expiration error is the only error, we will clear the err
|
||||
// and generate new access token and refresh token
|
||||
if ve.Errors == jwt.ValidationErrorExpired {
|
||||
generateToken = true
|
||||
}
|
||||
} else {
|
||||
return &echo.HTTPError{
|
||||
Code: http.StatusUnauthorized,
|
||||
Message: "Invalid or expired access token",
|
||||
Internal: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We either have a valid access token or we will attempt to generate new access token and refresh token
|
||||
ctx := c.Request().Context()
|
||||
userID, err := strconv.Atoi(claims.Subject)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Malformed ID in the token.")
|
||||
}
|
||||
|
||||
// Even if there is no error, we still need to make sure the user still exists.
|
||||
user, err := server.Store.FindUser(ctx, &api.UserFind{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to find user ID: %d", userID)).SetInternal(err)
|
||||
}
|
||||
if user == nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Failed to find user ID: %d", userID))
|
||||
}
|
||||
|
||||
if generateToken {
|
||||
generateTokenFunc := func() error {
|
||||
rc, err := c.Cookie(auth.RefreshTokenCookieName)
|
||||
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Failed to generate access token. Missing refresh token.")
|
||||
}
|
||||
|
||||
// Parses token and checks if it's valid.
|
||||
refreshTokenClaims := &Claims{}
|
||||
refreshToken, err := jwt.ParseWithClaims(rc.Value, refreshTokenClaims, func(t *jwt.Token) (any, error) {
|
||||
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
||||
return nil, pkgerrors.Errorf("unexpected refresh token signing method=%v, expected %v", t.Header["alg"], jwt.SigningMethodHS256)
|
||||
}
|
||||
|
||||
if kid, ok := t.Header["kid"].(string); ok {
|
||||
if kid == "v1" {
|
||||
return []byte(secret), nil
|
||||
}
|
||||
}
|
||||
return nil, pkgerrors.Errorf("unexpected refresh token kid=%v", t.Header["kid"])
|
||||
})
|
||||
if err != nil {
|
||||
if err == jwt.ErrSignatureInvalid {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Failed to generate access token. Invalid refresh token signature.")
|
||||
}
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to refresh expired token. User Id %d", userID)).SetInternal(err)
|
||||
}
|
||||
|
||||
if !audienceContains(refreshTokenClaims.Audience, fmt.Sprintf(auth.RefreshTokenAudienceFmt, mode)) {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized,
|
||||
fmt.Sprintf("Invalid refresh token, audience mismatch, got %q, expected %q. you may send request to the wrong environment",
|
||||
refreshTokenClaims.Audience,
|
||||
fmt.Sprintf(auth.RefreshTokenAudienceFmt, mode),
|
||||
))
|
||||
}
|
||||
|
||||
// If we have a valid refresh token, we will generate new access token and refresh token
|
||||
if refreshToken != nil && refreshToken.Valid {
|
||||
if err := GenerateTokensAndSetCookies(c, user, mode, secret); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to refresh expired token. User Id %d", userID)).SetInternal(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// It may happen that we still have a valid access token, but we encounter issue when trying to generate new token
|
||||
// In such case, we won't return the error.
|
||||
if err := generateTokenFunc(); err != nil && !accessToken.Valid {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Stores userID into context.
|
||||
c.Set(getUserIDContextKey(), userID)
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
func audienceContains(audience jwt.ClaimStrings, token string) bool {
|
||||
for _, v := range audience {
|
||||
if v == token {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -31,15 +31,15 @@ func (s *Server) registerOpenAIRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "OpenAI API key not set")
|
||||
}
|
||||
|
||||
completionRequest := api.OpenAICompletionRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(&completionRequest); err != nil {
|
||||
messages := []openai.ChatCompletionMessage{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(&messages); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post chat completion request").SetInternal(err)
|
||||
}
|
||||
if completionRequest.Prompt == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Prompt is required")
|
||||
if len(messages) == 0 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "No messages provided")
|
||||
}
|
||||
|
||||
result, err := openai.PostChatCompletion(completionRequest.Prompt, openAIConfig.Key, openAIConfig.Host)
|
||||
result, err := openai.PostChatCompletion(messages, openAIConfig.Key, openAIConfig.Host)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to post chat completion").SetInternal(err)
|
||||
}
|
||||
@@ -47,42 +47,6 @@ func (s *Server) registerOpenAIRoutes(g *echo.Group) {
|
||||
return c.JSON(http.StatusOK, composeResponse(result))
|
||||
})
|
||||
|
||||
g.POST("/openai/text-completion", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
openAIConfigSetting, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{
|
||||
Name: api.SystemSettingOpenAIConfigName,
|
||||
})
|
||||
if err != nil && common.ErrorCode(err) != common.NotFound {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find openai key").SetInternal(err)
|
||||
}
|
||||
|
||||
openAIConfig := api.OpenAIConfig{}
|
||||
if openAIConfigSetting != nil {
|
||||
err = json.Unmarshal([]byte(openAIConfigSetting.Value), &openAIConfig)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal openai system setting value").SetInternal(err)
|
||||
}
|
||||
}
|
||||
if openAIConfig.Key == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "OpenAI API key not set")
|
||||
}
|
||||
|
||||
textCompletion := api.OpenAICompletionRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(&textCompletion); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post text completion request").SetInternal(err)
|
||||
}
|
||||
if textCompletion.Prompt == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Prompt is required")
|
||||
}
|
||||
|
||||
result, err := openai.PostTextCompletion(textCompletion.Prompt, openAIConfig.Key, openAIConfig.Host)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to post text completion").SetInternal(err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, composeResponse(result))
|
||||
})
|
||||
|
||||
g.GET("/openai/enabled", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
openAIConfigSetting, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{
|
||||
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -17,7 +19,9 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
"github.com/usememos/memos/common/log"
|
||||
"github.com/usememos/memos/plugin/storage/s3"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -45,27 +49,6 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
if resourceCreate.ExternalLink != "" && !strings.HasPrefix(resourceCreate.ExternalLink, "http") {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid external link")
|
||||
}
|
||||
if resourceCreate.Visibility == "" {
|
||||
userResourceVisibilitySetting, err := s.Store.FindUserSetting(ctx, &api.UserSettingFind{
|
||||
UserID: userID,
|
||||
Key: api.UserSettingResourceVisibilityKey,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user setting").SetInternal(err)
|
||||
}
|
||||
|
||||
if userResourceVisibilitySetting != nil {
|
||||
resourceVisibility := api.Private
|
||||
err := json.Unmarshal([]byte(userResourceVisibilitySetting.Value), &resourceVisibility)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal user setting value").SetInternal(err)
|
||||
}
|
||||
resourceCreate.Visibility = resourceVisibility
|
||||
} else {
|
||||
// Private is the default resource visibility.
|
||||
resourceCreate.Visibility = api.Private
|
||||
}
|
||||
}
|
||||
|
||||
resource, err := s.Store.CreateResource(ctx, resourceCreate)
|
||||
if err != nil {
|
||||
@@ -96,40 +79,76 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Upload file not found").SetInternal(err)
|
||||
}
|
||||
|
||||
filename := file.Filename
|
||||
filetype := file.Header.Get("Content-Type")
|
||||
size := file.Size
|
||||
src, err := file.Open()
|
||||
sourceFile, err := file.Open()
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to open file").SetInternal(err)
|
||||
}
|
||||
defer src.Close()
|
||||
defer sourceFile.Close()
|
||||
|
||||
systemSetting, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{Name: api.SystemSettingStorageServiceIDName})
|
||||
var resourceCreate *api.ResourceCreate
|
||||
systemSettingStorageServiceID, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{Name: api.SystemSettingStorageServiceIDName})
|
||||
if err != nil && common.ErrorCode(err) != common.NotFound {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find storage").SetInternal(err)
|
||||
}
|
||||
storageServiceID := 0
|
||||
if systemSetting != nil {
|
||||
err = json.Unmarshal([]byte(systemSetting.Value), &storageServiceID)
|
||||
storageServiceID := api.DatabaseStorage
|
||||
if systemSettingStorageServiceID != nil {
|
||||
err = json.Unmarshal([]byte(systemSettingStorageServiceID.Value), &storageServiceID)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal storage service id").SetInternal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var resourceCreate *api.ResourceCreate
|
||||
if storageServiceID == 0 {
|
||||
fileBytes, err := io.ReadAll(src)
|
||||
if storageServiceID == api.DatabaseStorage {
|
||||
fileBytes, err := io.ReadAll(sourceFile)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to read file").SetInternal(err)
|
||||
}
|
||||
resourceCreate = &api.ResourceCreate{
|
||||
CreatorID: userID,
|
||||
Filename: filename,
|
||||
Filename: file.Filename,
|
||||
Type: filetype,
|
||||
Size: size,
|
||||
Blob: fileBytes,
|
||||
}
|
||||
} else if storageServiceID == api.LocalStorage {
|
||||
systemSettingLocalStoragePath, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{Name: api.SystemSettingLocalStoragePathName})
|
||||
if err != nil && common.ErrorCode(err) != common.NotFound {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find local storage path setting").SetInternal(err)
|
||||
}
|
||||
localStoragePath := ""
|
||||
if systemSettingLocalStoragePath != nil {
|
||||
err = json.Unmarshal([]byte(systemSettingLocalStoragePath.Value), &localStoragePath)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal local storage path setting").SetInternal(err)
|
||||
}
|
||||
}
|
||||
filePath := localStoragePath
|
||||
if !strings.Contains(filePath, "{filename}") {
|
||||
filePath = path.Join(filePath, "{filename}")
|
||||
}
|
||||
filePath = path.Join(s.Profile.Data, replacePathTemplate(filePath, file.Filename))
|
||||
dir, filename := filepath.Split(filePath)
|
||||
if err = os.MkdirAll(dir, os.ModePerm); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create directory").SetInternal(err)
|
||||
}
|
||||
dst, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create file").SetInternal(err)
|
||||
}
|
||||
defer dst.Close()
|
||||
_, err = io.Copy(dst, sourceFile)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to copy file").SetInternal(err)
|
||||
}
|
||||
|
||||
resourceCreate = &api.ResourceCreate{
|
||||
CreatorID: userID,
|
||||
Filename: filename,
|
||||
Type: filetype,
|
||||
Size: size,
|
||||
InternalPath: filePath,
|
||||
}
|
||||
} else {
|
||||
storage, err := s.Store.FindStorage(ctx, &api.StorageFind{ID: &storageServiceID})
|
||||
if err != nil {
|
||||
@@ -138,53 +157,26 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
|
||||
if storage.Type == api.StorageS3 {
|
||||
s3Config := storage.Config.S3Config
|
||||
t := time.Now()
|
||||
var s3FileKey string
|
||||
if s3Config.Path == "" {
|
||||
s3FileKey = filename
|
||||
} else {
|
||||
s3FileKey = fileKeyPattern.ReplaceAllStringFunc(s3Config.Path, func(s string) string {
|
||||
switch s {
|
||||
case "{filename}":
|
||||
return filename
|
||||
case "{filetype}":
|
||||
return filetype
|
||||
case "{timestamp}":
|
||||
return fmt.Sprintf("%d", t.Unix())
|
||||
case "{year}":
|
||||
return fmt.Sprintf("%d", t.Year())
|
||||
case "{month}":
|
||||
return fmt.Sprintf("%02d", t.Month())
|
||||
case "{day}":
|
||||
return fmt.Sprintf("%02d", t.Day())
|
||||
case "{hour}":
|
||||
return fmt.Sprintf("%02d", t.Hour())
|
||||
case "{minute}":
|
||||
return fmt.Sprintf("%02d", t.Minute())
|
||||
case "{second}":
|
||||
return fmt.Sprintf("%02d", t.Second())
|
||||
}
|
||||
return s
|
||||
})
|
||||
|
||||
if !strings.Contains(s3Config.Path, "{filename}") {
|
||||
s3FileKey = path.Join(s3FileKey, filename)
|
||||
}
|
||||
}
|
||||
|
||||
s3client, err := s3.NewClient(ctx, &s3.Config{
|
||||
s3Client, err := s3.NewClient(ctx, &s3.Config{
|
||||
AccessKey: s3Config.AccessKey,
|
||||
SecretKey: s3Config.SecretKey,
|
||||
EndPoint: s3Config.EndPoint,
|
||||
Region: s3Config.Region,
|
||||
Bucket: s3Config.Bucket,
|
||||
URLPrefix: s3Config.URLPrefix,
|
||||
URLSuffix: s3Config.URLSuffix,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to new s3 client").SetInternal(err)
|
||||
}
|
||||
|
||||
link, err := s3client.UploadFile(ctx, s3FileKey, filetype, src)
|
||||
filePath := s3Config.Path
|
||||
if !strings.Contains(filePath, "{filename}") {
|
||||
filePath = path.Join(filePath, "{filename}")
|
||||
}
|
||||
filePath = replacePathTemplate(filePath, file.Filename)
|
||||
_, filename := filepath.Split(filePath)
|
||||
link, err := s3Client.UploadFile(ctx, filePath, filetype, sourceFile)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upload via s3 client").SetInternal(err)
|
||||
}
|
||||
@@ -199,28 +191,8 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
}
|
||||
}
|
||||
|
||||
if resourceCreate.Visibility == "" {
|
||||
userResourceVisibilitySetting, err := s.Store.FindUserSetting(ctx, &api.UserSettingFind{
|
||||
UserID: userID,
|
||||
Key: api.UserSettingResourceVisibilityKey,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user setting").SetInternal(err)
|
||||
}
|
||||
|
||||
if userResourceVisibilitySetting != nil {
|
||||
resourceVisibility := api.Private
|
||||
err := json.Unmarshal([]byte(userResourceVisibilitySetting.Value), &resourceVisibility)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal user setting value").SetInternal(err)
|
||||
}
|
||||
resourceCreate.Visibility = resourceVisibility
|
||||
} else {
|
||||
// Private is the default resource visibility.
|
||||
resourceCreate.Visibility = api.Private
|
||||
}
|
||||
}
|
||||
|
||||
publicID := common.GenUUID()
|
||||
resourceCreate.PublicID = publicID
|
||||
resource, err := s.Store.CreateResource(ctx, resourceCreate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err)
|
||||
@@ -240,69 +212,20 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
resourceFind := &api.ResourceFind{
|
||||
CreatorID: &userID,
|
||||
}
|
||||
if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
|
||||
resourceFind.Limit = &limit
|
||||
}
|
||||
if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil {
|
||||
resourceFind.Offset = &offset
|
||||
}
|
||||
|
||||
list, err := s.Store.FindResourceList(ctx, resourceFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
|
||||
}
|
||||
|
||||
for _, resource := range list {
|
||||
memoResourceList, err := s.Store.FindMemoResourceList(ctx, &api.MemoResourceFind{
|
||||
ResourceID: &resource.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo resource list").SetInternal(err)
|
||||
}
|
||||
resource.LinkedMemoAmount = len(memoResourceList)
|
||||
}
|
||||
return c.JSON(http.StatusOK, composeResponse(list))
|
||||
})
|
||||
|
||||
g.GET("/resource/:resourceId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
resourceID, err := strconv.Atoi(c.Param("resourceId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
resourceFind := &api.ResourceFind{
|
||||
ID: &resourceID,
|
||||
CreatorID: &userID,
|
||||
GetBlob: true,
|
||||
}
|
||||
resource, err := s.Store.FindResource(ctx, resourceFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, composeResponse(resource))
|
||||
})
|
||||
|
||||
g.GET("/resource/:resourceId/blob", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
resourceID, err := strconv.Atoi(c.Param("resourceId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
resourceFind := &api.ResourceFind{
|
||||
ID: &resourceID,
|
||||
CreatorID: &userID,
|
||||
GetBlob: true,
|
||||
}
|
||||
resource, err := s.Store.FindResource(ctx, resourceFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource").SetInternal(err)
|
||||
}
|
||||
return c.Stream(http.StatusOK, resource.Type, bytes.NewReader(resource.Blob))
|
||||
})
|
||||
|
||||
g.PATCH("/resource/:resourceId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
@@ -334,6 +257,11 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch resource request").SetInternal(err)
|
||||
}
|
||||
|
||||
if resourcePatch.ResetPublicID != nil && *resourcePatch.ResetPublicID {
|
||||
publicID := common.GenUUID()
|
||||
resourcePatch.PublicID = &publicID
|
||||
}
|
||||
|
||||
resourcePatch.ID = resourceID
|
||||
resource, err = s.Store.PatchResource(ctx, resourcePatch)
|
||||
if err != nil {
|
||||
@@ -365,6 +293,13 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
if resource.InternalPath != "" {
|
||||
err := os.Remove(resource.InternalPath)
|
||||
if err != nil {
|
||||
log.Warn(fmt.Sprintf("failed to delete local file with path %s", resource.InternalPath), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
resourceDelete := &api.ResourceDelete{
|
||||
ID: resourceID,
|
||||
}
|
||||
@@ -379,19 +314,19 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
}
|
||||
|
||||
func (s *Server) registerResourcePublicRoutes(g *echo.Group) {
|
||||
g.GET("/r/:resourceId/:filename", func(c echo.Context) error {
|
||||
g.GET("/r/:resourceId/:publicId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
resourceID, err := strconv.Atoi(c.Param("resourceId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
|
||||
}
|
||||
filename, err := url.QueryUnescape(c.Param("filename"))
|
||||
publicID, err := url.QueryUnescape(c.Param("publicId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("filename is invalid: %s", c.Param("filename"))).SetInternal(err)
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("publicID is invalid: %s", c.Param("publicId"))).SetInternal(err)
|
||||
}
|
||||
resourceFind := &api.ResourceFind{
|
||||
ID: &resourceID,
|
||||
Filename: &filename,
|
||||
PublicID: &publicID,
|
||||
GetBlob: true,
|
||||
}
|
||||
resource, err := s.Store.FindResource(ctx, resourceFind)
|
||||
@@ -399,16 +334,33 @@ func (s *Server) registerResourcePublicRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find resource by ID: %v", resourceID)).SetInternal(err)
|
||||
}
|
||||
|
||||
if resource.ExternalLink != "" {
|
||||
return c.Redirect(http.StatusSeeOther, resource.ExternalLink)
|
||||
}
|
||||
|
||||
blob := resource.Blob
|
||||
if resource.InternalPath != "" {
|
||||
src, err := os.Open(resource.InternalPath)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to open the local resource: %s", resource.InternalPath)).SetInternal(err)
|
||||
}
|
||||
defer src.Close()
|
||||
blob, err = io.ReadAll(src)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to read the local resource: %s", resource.InternalPath)).SetInternal(err)
|
||||
}
|
||||
}
|
||||
|
||||
c.Response().Writer.Header().Set(echo.HeaderCacheControl, "max-age=31536000, immutable")
|
||||
c.Response().Writer.Header().Set(echo.HeaderContentSecurityPolicy, "default-src 'self'")
|
||||
resourceType := strings.ToLower(resource.Type)
|
||||
if strings.HasPrefix(resourceType, "text") {
|
||||
resourceType = echo.MIMETextPlainCharsetUTF8
|
||||
} else if strings.HasPrefix(resourceType, "video") || strings.HasPrefix(resourceType, "audio") {
|
||||
http.ServeContent(c.Response(), c.Request(), resource.Filename, time.Unix(resource.UpdatedTs, 0), bytes.NewReader(resource.Blob))
|
||||
http.ServeContent(c.Response(), c.Request(), resource.Filename, time.Unix(resource.UpdatedTs, 0), bytes.NewReader(blob))
|
||||
return nil
|
||||
}
|
||||
return c.Stream(http.StatusOK, resourceType, bytes.NewReader(resource.Blob))
|
||||
return c.Stream(http.StatusOK, resourceType, bytes.NewReader(blob))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -434,3 +386,29 @@ func (s *Server) createResourceCreateActivity(c echo.Context, resource *api.Reso
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func replacePathTemplate(path string, filename string) string {
|
||||
t := time.Now()
|
||||
path = fileKeyPattern.ReplaceAllStringFunc(path, func(s string) string {
|
||||
switch s {
|
||||
case "{filename}":
|
||||
return filename
|
||||
case "{timestamp}":
|
||||
return fmt.Sprintf("%d", t.Unix())
|
||||
case "{year}":
|
||||
return fmt.Sprintf("%d", t.Year())
|
||||
case "{month}":
|
||||
return fmt.Sprintf("%02d", t.Month())
|
||||
case "{day}":
|
||||
return fmt.Sprintf("%02d", t.Day())
|
||||
case "{hour}":
|
||||
return fmt.Sprintf("%02d", t.Hour())
|
||||
case "{minute}":
|
||||
return fmt.Sprintf("%02d", t.Minute())
|
||||
case "{second}":
|
||||
return fmt.Sprintf("%02d", t.Second())
|
||||
}
|
||||
return s
|
||||
})
|
||||
return path
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ func (s *Server) registerRSSRoutes(g *echo.Group) {
|
||||
}
|
||||
|
||||
baseURL := c.Scheme() + "://" + c.Request().Host
|
||||
rss, err := generateRSSFromMemoList(memoList, baseURL, &systemCustomizedProfile)
|
||||
rss, err := generateRSSFromMemoList(memoList, baseURL, systemCustomizedProfile)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate rss").SetInternal(err)
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func (s *Server) registerRSSRoutes(g *echo.Group) {
|
||||
|
||||
baseURL := c.Scheme() + "://" + c.Request().Host
|
||||
|
||||
rss, err := generateRSSFromMemoList(memoList, baseURL, &systemCustomizedProfile)
|
||||
rss, err := generateRSSFromMemoList(memoList, baseURL, systemCustomizedProfile)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate rss").SetInternal(err)
|
||||
}
|
||||
@@ -114,57 +114,27 @@ func generateRSSFromMemoList(memoList []*api.Memo, baseURL string, profile *api.
|
||||
return rss, nil
|
||||
}
|
||||
|
||||
func getSystemCustomizedProfile(ctx context.Context, s *Server) (api.CustomizedProfile, error) {
|
||||
systemStatus := api.SystemStatus{
|
||||
CustomizedProfile: api.CustomizedProfile{
|
||||
Name: "memos",
|
||||
LogoURL: "",
|
||||
Description: "",
|
||||
Locale: "en",
|
||||
Appearance: "system",
|
||||
ExternalURL: "",
|
||||
},
|
||||
func getSystemCustomizedProfile(ctx context.Context, s *Server) (*api.CustomizedProfile, error) {
|
||||
customizedProfile := &api.CustomizedProfile{
|
||||
Name: "memos",
|
||||
LogoURL: "",
|
||||
Description: "",
|
||||
Locale: "en",
|
||||
Appearance: "system",
|
||||
ExternalURL: "",
|
||||
}
|
||||
|
||||
systemSettingList, err := s.Store.FindSystemSettingList(ctx, &api.SystemSettingFind{})
|
||||
systemSetting, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{
|
||||
Name: api.SystemSettingCustomizedProfileName,
|
||||
})
|
||||
if err != nil {
|
||||
return api.CustomizedProfile{}, err
|
||||
return customizedProfile, err
|
||||
}
|
||||
for _, systemSetting := range systemSettingList {
|
||||
if systemSetting.Name == api.SystemSettingServerID || systemSetting.Name == api.SystemSettingSecretSessionName {
|
||||
continue
|
||||
}
|
||||
|
||||
var value interface{}
|
||||
err := json.Unmarshal([]byte(systemSetting.Value), &value)
|
||||
if err != nil {
|
||||
return api.CustomizedProfile{}, err
|
||||
}
|
||||
|
||||
if systemSetting.Name == api.SystemSettingCustomizedProfileName {
|
||||
valueMap := value.(map[string]interface{})
|
||||
systemStatus.CustomizedProfile = api.CustomizedProfile{}
|
||||
if v := valueMap["name"]; v != nil {
|
||||
systemStatus.CustomizedProfile.Name = v.(string)
|
||||
}
|
||||
if v := valueMap["logoUrl"]; v != nil {
|
||||
systemStatus.CustomizedProfile.LogoURL = v.(string)
|
||||
}
|
||||
if v := valueMap["description"]; v != nil {
|
||||
systemStatus.CustomizedProfile.Description = v.(string)
|
||||
}
|
||||
if v := valueMap["locale"]; v != nil {
|
||||
systemStatus.CustomizedProfile.Locale = v.(string)
|
||||
}
|
||||
if v := valueMap["appearance"]; v != nil {
|
||||
systemStatus.CustomizedProfile.Appearance = v.(string)
|
||||
}
|
||||
if v := valueMap["externalUrl"]; v != nil {
|
||||
systemStatus.CustomizedProfile.ExternalURL = v.(string)
|
||||
}
|
||||
}
|
||||
err = json.Unmarshal([]byte(systemSetting.Value), customizedProfile)
|
||||
if err != nil {
|
||||
return customizedProfile, err
|
||||
}
|
||||
return systemStatus.CustomizedProfile, nil
|
||||
return customizedProfile, nil
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
|
||||
@@ -13,8 +13,6 @@ import (
|
||||
"github.com/usememos/memos/store"
|
||||
"github.com/usememos/memos/store/db"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/labstack/echo-contrib/session"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
@@ -81,30 +79,32 @@ func NewServer(ctx context.Context, profile *profile.Profile) (*Server, error) {
|
||||
}
|
||||
s.ID = serverID
|
||||
|
||||
secretSessionName := "usememos"
|
||||
embedFrontend(e)
|
||||
|
||||
secret := "usememos"
|
||||
if profile.Mode == "prod" {
|
||||
secretSessionName, err = s.getSystemSecretSessionName(ctx)
|
||||
secret, err = s.getSystemSecretSessionName(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
e.Use(session.Middleware(sessions.NewCookieStore([]byte(secretSessionName))))
|
||||
|
||||
embedFrontend(e)
|
||||
|
||||
rootGroup := e.Group("")
|
||||
s.registerRSSRoutes(rootGroup)
|
||||
|
||||
publicGroup := e.Group("/o")
|
||||
s.registerResourcePublicRoutes(publicGroup)
|
||||
publicGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return JWTMiddleware(s, next, secret)
|
||||
})
|
||||
registerGetterPublicRoutes(publicGroup)
|
||||
s.registerResourcePublicRoutes(publicGroup)
|
||||
|
||||
apiGroup := e.Group("/api")
|
||||
apiGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return aclMiddleware(s, next)
|
||||
return JWTMiddleware(s, next, secret)
|
||||
})
|
||||
s.registerSystemRoutes(apiGroup)
|
||||
s.registerAuthRoutes(apiGroup)
|
||||
s.registerAuthRoutes(apiGroup, secret)
|
||||
s.registerUserRoutes(apiGroup)
|
||||
s.registerMemoRoutes(apiGroup)
|
||||
s.registerShortcutRoutes(apiGroup)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -51,7 +51,8 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
|
||||
Appearance: "system",
|
||||
ExternalURL: "",
|
||||
},
|
||||
StorageServiceID: 0,
|
||||
StorageServiceID: api.DatabaseStorage,
|
||||
LocalStoragePath: "",
|
||||
}
|
||||
|
||||
systemSettingList, err := s.Store.FindSystemSettingList(ctx, &api.SystemSettingFind{})
|
||||
@@ -59,11 +60,11 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting list").SetInternal(err)
|
||||
}
|
||||
for _, systemSetting := range systemSettingList {
|
||||
if systemSetting.Name == api.SystemSettingServerID || systemSetting.Name == api.SystemSettingSecretSessionName || systemSetting.Name == api.SystemSettingOpenAIConfigName {
|
||||
if systemSetting.Name == api.SystemSettingServerIDName || systemSetting.Name == api.SystemSettingSecretSessionName || systemSetting.Name == api.SystemSettingOpenAIConfigName {
|
||||
continue
|
||||
}
|
||||
|
||||
var baseValue interface{}
|
||||
var baseValue any
|
||||
err := json.Unmarshal([]byte(systemSetting.Value), &baseValue)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting value").SetInternal(err)
|
||||
@@ -86,6 +87,8 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
|
||||
systemStatus.CustomizedProfile = customizedProfile
|
||||
} else if systemSetting.Name == api.SystemSettingStorageServiceIDName {
|
||||
systemStatus.StorageServiceID = int(baseValue.(float64))
|
||||
} else if systemSetting.Name == api.SystemSettingLocalStoragePathName {
|
||||
systemStatus.LocalStoragePath = baseValue.(string)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,14 +194,14 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
|
||||
|
||||
func (s *Server) getSystemServerID(ctx context.Context) (string, error) {
|
||||
serverIDValue, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{
|
||||
Name: api.SystemSettingServerID,
|
||||
Name: api.SystemSettingServerIDName,
|
||||
})
|
||||
if err != nil && common.ErrorCode(err) != common.NotFound {
|
||||
return "", err
|
||||
}
|
||||
if serverIDValue == nil || serverIDValue.Value == "" {
|
||||
serverIDValue, err = s.Store.UpsertSystemSetting(ctx, &api.SystemSettingUpsert{
|
||||
Name: api.SystemSettingServerID,
|
||||
Name: api.SystemSettingServerIDName,
|
||||
Value: uuid.NewString(),
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -9,10 +9,10 @@ import (
|
||||
|
||||
// Version is the service current released version.
|
||||
// Semantic versioning: https://semver.org/
|
||||
var Version = "0.11.2"
|
||||
var Version = "0.12.0"
|
||||
|
||||
// DevVersion is the service current development version.
|
||||
var DevVersion = "0.11.2"
|
||||
var DevVersion = "0.12.0"
|
||||
|
||||
func GetCurrentVersion(mode string) string {
|
||||
if mode == "dev" || mode == "demo" {
|
||||
|
||||
90
setup/setup.go
Normal file
90
setup/setup.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
)
|
||||
|
||||
func Execute(
|
||||
ctx context.Context,
|
||||
store store,
|
||||
hostUsername, hostPassword string,
|
||||
) error {
|
||||
s := setupService{store: store}
|
||||
return s.Setup(ctx, hostUsername, hostPassword)
|
||||
}
|
||||
|
||||
type store interface {
|
||||
FindUserList(ctx context.Context, find *api.UserFind) ([]*api.User, error)
|
||||
CreateUser(ctx context.Context, create *api.UserCreate) (*api.User, error)
|
||||
}
|
||||
|
||||
type setupService struct {
|
||||
store store
|
||||
}
|
||||
|
||||
func (s setupService) Setup(
|
||||
ctx context.Context,
|
||||
hostUsername, hostPassword string,
|
||||
) error {
|
||||
if err := s.makeSureHostUserNotExists(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.createUser(ctx, hostUsername, hostPassword); err != nil {
|
||||
return fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s setupService) makeSureHostUserNotExists(ctx context.Context) error {
|
||||
hostUserType := api.Host
|
||||
existedHostUsers, err := s.store.FindUserList(ctx, &api.UserFind{
|
||||
Role: &hostUserType,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("find user list: %w", err)
|
||||
}
|
||||
|
||||
if len(existedHostUsers) != 0 {
|
||||
return errors.New("host user already exists")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s setupService) createUser(
|
||||
ctx context.Context,
|
||||
hostUsername, hostPassword string,
|
||||
) error {
|
||||
userCreate := &api.UserCreate{
|
||||
Username: hostUsername,
|
||||
// The new signup user should be normal user by default.
|
||||
Role: api.Host,
|
||||
Nickname: hostUsername,
|
||||
Password: hostPassword,
|
||||
OpenID: common.GenUUID(),
|
||||
}
|
||||
|
||||
if err := userCreate.Validate(); err != nil {
|
||||
return fmt.Errorf("validate: %w", err)
|
||||
}
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(hostPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
|
||||
userCreate.PasswordHash = string(passwordHash)
|
||||
if _, err := s.store.CreateUser(ctx, userCreate); err != nil {
|
||||
return fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
181
setup/setup_test.go
Normal file
181
setup/setup_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
)
|
||||
|
||||
func TestSetupServiceMakeSureHostUserNotExists(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
setupStore func(*storeMock)
|
||||
expectedErr string
|
||||
}{
|
||||
"failed to get list": {
|
||||
setupStore: func(m *storeMock) {
|
||||
hostUserType := api.Host
|
||||
m.
|
||||
On("FindUserList", mock.Anything, &api.UserFind{
|
||||
Role: &hostUserType,
|
||||
}).
|
||||
Return(nil, errors.New("fake error"))
|
||||
},
|
||||
expectedErr: "find user list: fake error",
|
||||
},
|
||||
"success, not empty": {
|
||||
setupStore: func(m *storeMock) {
|
||||
hostUserType := api.Host
|
||||
m.
|
||||
On("FindUserList", mock.Anything, &api.UserFind{
|
||||
Role: &hostUserType,
|
||||
}).
|
||||
Return([]*api.User{
|
||||
{},
|
||||
}, nil)
|
||||
},
|
||||
expectedErr: "host user already exists",
|
||||
},
|
||||
"success, empty": {
|
||||
setupStore: func(m *storeMock) {
|
||||
hostUserType := api.Host
|
||||
m.
|
||||
On("FindUserList", mock.Anything, &api.UserFind{
|
||||
Role: &hostUserType,
|
||||
}).
|
||||
Return(nil, nil)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for n, c := range cc {
|
||||
c := c
|
||||
t.Run(n, func(t *testing.T) {
|
||||
sm := newStoreMock(t)
|
||||
if c.setupStore != nil {
|
||||
c.setupStore(sm)
|
||||
}
|
||||
|
||||
srv := setupService{store: sm}
|
||||
err := srv.makeSureHostUserNotExists(context.Background())
|
||||
if c.expectedErr == "" {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.EqualError(t, err, c.expectedErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupServiceCreateUser(t *testing.T) {
|
||||
expectedCreated := &api.UserCreate{
|
||||
Username: "demohero",
|
||||
Role: api.Host,
|
||||
Nickname: "demohero",
|
||||
Password: "123456",
|
||||
}
|
||||
|
||||
userCreateMatcher := mock.MatchedBy(func(arg *api.UserCreate) bool {
|
||||
return arg.Username == expectedCreated.Username &&
|
||||
arg.Role == expectedCreated.Role &&
|
||||
arg.Nickname == expectedCreated.Nickname &&
|
||||
arg.Password == expectedCreated.Password &&
|
||||
arg.PasswordHash != ""
|
||||
})
|
||||
|
||||
cc := map[string]struct {
|
||||
setupStore func(*storeMock)
|
||||
hostUsername, hostPassword string
|
||||
expectedErr string
|
||||
}{
|
||||
`username == "", password == ""`: {
|
||||
expectedErr: "validate: username is too short, minimum length is 3",
|
||||
},
|
||||
`username == "", password != ""`: {
|
||||
hostPassword: expectedCreated.Password,
|
||||
expectedErr: "validate: username is too short, minimum length is 3",
|
||||
},
|
||||
`username != "", password == ""`: {
|
||||
hostUsername: expectedCreated.Username,
|
||||
expectedErr: "validate: password is too short, minimum length is 6",
|
||||
},
|
||||
"failed to create": {
|
||||
setupStore: func(m *storeMock) {
|
||||
m.
|
||||
On("CreateUser", mock.Anything, userCreateMatcher).
|
||||
Return(nil, errors.New("fake error"))
|
||||
},
|
||||
hostUsername: expectedCreated.Username,
|
||||
hostPassword: expectedCreated.Password,
|
||||
expectedErr: "create user: fake error",
|
||||
},
|
||||
"success": {
|
||||
setupStore: func(m *storeMock) {
|
||||
m.
|
||||
On("CreateUser", mock.Anything, userCreateMatcher).
|
||||
Return(nil, nil)
|
||||
},
|
||||
hostUsername: expectedCreated.Username,
|
||||
hostPassword: expectedCreated.Password,
|
||||
},
|
||||
}
|
||||
|
||||
for n, c := range cc {
|
||||
c := c
|
||||
t.Run(n, func(t *testing.T) {
|
||||
sm := newStoreMock(t)
|
||||
if c.setupStore != nil {
|
||||
c.setupStore(sm)
|
||||
}
|
||||
|
||||
srv := setupService{store: sm}
|
||||
err := srv.createUser(context.Background(), c.hostUsername, c.hostPassword)
|
||||
if c.expectedErr == "" {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.EqualError(t, err, c.expectedErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type storeMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *storeMock) FindUserList(ctx context.Context, find *api.UserFind) ([]*api.User, error) {
|
||||
ret := m.Called(ctx, find)
|
||||
|
||||
var u []*api.User
|
||||
ret1 := ret.Get(0)
|
||||
if ret1 != nil {
|
||||
u = ret1.([]*api.User)
|
||||
}
|
||||
|
||||
return u, ret.Error(1)
|
||||
}
|
||||
|
||||
func (m *storeMock) CreateUser(ctx context.Context, create *api.UserCreate) (*api.User, error) {
|
||||
ret := m.Called(ctx, create)
|
||||
|
||||
var u *api.User
|
||||
ret1 := ret.Get(0)
|
||||
if ret1 != nil {
|
||||
u = ret1.(*api.User)
|
||||
}
|
||||
|
||||
return u, ret.Error(1)
|
||||
}
|
||||
|
||||
func newStoreMock(t *testing.T) *storeMock {
|
||||
m := &storeMock{}
|
||||
m.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { m.AssertExpectations(t) })
|
||||
|
||||
return m
|
||||
}
|
||||
@@ -77,7 +77,9 @@ CREATE TABLE resource (
|
||||
external_link TEXT NOT NULL DEFAULT '',
|
||||
type TEXT NOT NULL DEFAULT '',
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC', 'PROTECTED', 'PRIVATE')) DEFAULT 'PRIVATE'
|
||||
internal_path TEXT NOT NULL DEFAULT '',
|
||||
public_id TEXT NOT NULL DEFAULT '',
|
||||
UNIQUE(id, public_id)
|
||||
);
|
||||
|
||||
-- memo_resource
|
||||
|
||||
6
store/db/migration/prod/0.12/00__user_setting.sql
Normal file
6
store/db/migration/prod/0.12/00__user_setting.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
UPDATE
|
||||
user_setting
|
||||
SET
|
||||
key = 'memo-visibility'
|
||||
WHERE
|
||||
key = 'memoVisibility';
|
||||
69
store/db/migration/prod/0.12/01__system_setting.sql
Normal file
69
store/db/migration/prod/0.12/01__system_setting.sql
Normal file
@@ -0,0 +1,69 @@
|
||||
UPDATE
|
||||
system_setting
|
||||
SET
|
||||
name = 'server-id'
|
||||
WHERE
|
||||
name = 'serverId';
|
||||
|
||||
UPDATE
|
||||
system_setting
|
||||
SET
|
||||
name = 'secret-session'
|
||||
WHERE
|
||||
name = 'secretSessionName';
|
||||
|
||||
UPDATE
|
||||
system_setting
|
||||
SET
|
||||
name = 'allow-signup'
|
||||
WHERE
|
||||
name = 'allowSignUp';
|
||||
|
||||
UPDATE
|
||||
system_setting
|
||||
SET
|
||||
name = 'disable-public-memos'
|
||||
WHERE
|
||||
name = 'disablePublicMemos';
|
||||
|
||||
UPDATE
|
||||
system_setting
|
||||
SET
|
||||
name = 'additional-style'
|
||||
WHERE
|
||||
name = 'additionalStyle';
|
||||
|
||||
UPDATE
|
||||
system_setting
|
||||
SET
|
||||
name = 'additional-script'
|
||||
WHERE
|
||||
name = 'additionalScript';
|
||||
|
||||
UPDATE
|
||||
system_setting
|
||||
SET
|
||||
name = 'customized-profile'
|
||||
WHERE
|
||||
name = 'customizedProfile';
|
||||
|
||||
UPDATE
|
||||
system_setting
|
||||
SET
|
||||
name = 'storage-service-id'
|
||||
WHERE
|
||||
name = 'storageServiceId';
|
||||
|
||||
UPDATE
|
||||
system_setting
|
||||
SET
|
||||
name = 'local-storage-path'
|
||||
WHERE
|
||||
name = 'localStoragePath';
|
||||
|
||||
UPDATE
|
||||
system_setting
|
||||
SET
|
||||
name = 'openai-config'
|
||||
WHERE
|
||||
name = 'openAIConfig';
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE
|
||||
resource
|
||||
ADD
|
||||
COLUMN internal_path TEXT NOT NULL DEFAULT '';
|
||||
21
store/db/migration/prod/0.12/04__resource_public_id.sql
Normal file
21
store/db/migration/prod/0.12/04__resource_public_id.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
ALTER TABLE
|
||||
resource
|
||||
ADD
|
||||
COLUMN public_id TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE UNIQUE INDEX resource_id_public_id_unique_index ON resource (id, public_id);
|
||||
|
||||
UPDATE
|
||||
resource
|
||||
SET
|
||||
public_id = (
|
||||
SELECT
|
||||
printf(
|
||||
'%s-%s-%s-%s-%s',
|
||||
lower(hex(randomblob(4))),
|
||||
lower(hex(randomblob(2))),
|
||||
lower(hex(randomblob(2))),
|
||||
lower(hex(randomblob(2))),
|
||||
lower(hex(randomblob(6)))
|
||||
) as uuid
|
||||
);
|
||||
@@ -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', '');
|
||||
10
store/idp.go
10
store/idp.go
@@ -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)
|
||||
@@ -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,12 +22,14 @@ type resourceRaw struct {
|
||||
UpdatedTs int64
|
||||
|
||||
// Domain specific fields
|
||||
Filename string
|
||||
Blob []byte
|
||||
ExternalLink string
|
||||
Type string
|
||||
Size int64
|
||||
Visibility api.Visibility
|
||||
Filename string
|
||||
Blob []byte
|
||||
InternalPath string
|
||||
ExternalLink string
|
||||
Type string
|
||||
Size int64
|
||||
PublicID string
|
||||
LinkedMemoAmount int
|
||||
}
|
||||
|
||||
func (raw *resourceRaw) toResource() *api.Resource {
|
||||
@@ -40,12 +42,14 @@ func (raw *resourceRaw) toResource() *api.Resource {
|
||||
UpdatedTs: raw.UpdatedTs,
|
||||
|
||||
// Domain specific fields
|
||||
Filename: raw.Filename,
|
||||
Blob: raw.Blob,
|
||||
ExternalLink: raw.ExternalLink,
|
||||
Type: raw.Type,
|
||||
Size: raw.Size,
|
||||
Visibility: raw.Visibility,
|
||||
Filename: raw.Filename,
|
||||
Blob: raw.Blob,
|
||||
InternalPath: raw.InternalPath,
|
||||
ExternalLink: raw.ExternalLink,
|
||||
Type: raw.Type,
|
||||
Size: raw.Size,
|
||||
PublicID: raw.PublicID,
|
||||
LinkedMemoAmount: raw.LinkedMemoAmount,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +94,7 @@ func (s *Store) CreateResource(ctx context.Context, create *api.ResourceCreate)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
resourceRaw, err := s.createResourceImpl(ctx, tx, create)
|
||||
resourceRaw, err := createResourceImpl(ctx, tx, create)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -111,7 +115,7 @@ func (s *Store) FindResourceList(ctx context.Context, find *api.ResourceFind) ([
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
resourceRawList, err := s.findResourceListImpl(ctx, tx, find)
|
||||
resourceRawList, err := findResourceListImpl(ctx, tx, find)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -131,7 +135,7 @@ func (s *Store) FindResource(ctx context.Context, find *api.ResourceFind) (*api.
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
list, err := s.findResourceListImpl(ctx, tx, find)
|
||||
list, err := findResourceListImpl(ctx, tx, find)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -174,7 +178,7 @@ func (s *Store) PatchResource(ctx context.Context, patch *api.ResourcePatch) (*a
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
resourceRaw, err := s.patchResourceImpl(ctx, tx, patch)
|
||||
resourceRaw, err := patchResourceImpl(ctx, tx, patch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -188,16 +192,10 @@ func (s *Store) PatchResource(ctx context.Context, patch *api.ResourcePatch) (*a
|
||||
return resource, nil
|
||||
}
|
||||
|
||||
func (s *Store) createResourceImpl(ctx context.Context, tx *sql.Tx, create *api.ResourceCreate) (*resourceRaw, error) {
|
||||
fields := []string{"filename", "blob", "external_link", "type", "size", "creator_id"}
|
||||
values := []interface{}{create.Filename, create.Blob, create.ExternalLink, create.Type, create.Size, create.CreatorID}
|
||||
placeholders := []string{"?", "?", "?", "?", "?", "?"}
|
||||
if s.profile.IsDev() {
|
||||
fields = append(fields, "visibility")
|
||||
values = append(values, create.Visibility)
|
||||
placeholders = append(placeholders, "?")
|
||||
}
|
||||
|
||||
func createResourceImpl(ctx context.Context, tx *sql.Tx, create *api.ResourceCreate) (*resourceRaw, error) {
|
||||
fields := []string{"filename", "blob", "external_link", "type", "size", "creator_id", "internal_path", "public_id"}
|
||||
values := []any{create.Filename, create.Blob, create.ExternalLink, create.Type, create.Size, create.CreatorID, create.InternalPath, create.PublicID}
|
||||
placeholders := []string{"?", "?", "?", "?", "?", "?", "?", "?"}
|
||||
query := `
|
||||
INSERT INTO resource (
|
||||
` + strings.Join(fields, ",") + `
|
||||
@@ -206,7 +204,7 @@ func (s *Store) createResourceImpl(ctx context.Context, tx *sql.Tx, create *api.
|
||||
RETURNING id, ` + strings.Join(fields, ",") + `, created_ts, updated_ts
|
||||
`
|
||||
var resourceRaw resourceRaw
|
||||
dests := []interface{}{
|
||||
dests := []any{
|
||||
&resourceRaw.ID,
|
||||
&resourceRaw.Filename,
|
||||
&resourceRaw.Blob,
|
||||
@@ -214,11 +212,10 @@ func (s *Store) createResourceImpl(ctx context.Context, tx *sql.Tx, create *api.
|
||||
&resourceRaw.Type,
|
||||
&resourceRaw.Size,
|
||||
&resourceRaw.CreatorID,
|
||||
&resourceRaw.InternalPath,
|
||||
&resourceRaw.PublicID,
|
||||
}
|
||||
if s.profile.IsDev() {
|
||||
dests = append(dests, &resourceRaw.Visibility)
|
||||
}
|
||||
dests = append(dests, []interface{}{&resourceRaw.CreatedTs, &resourceRaw.UpdatedTs}...)
|
||||
dests = append(dests, []any{&resourceRaw.CreatedTs, &resourceRaw.UpdatedTs}...)
|
||||
if err := tx.QueryRowContext(ctx, query, values...).Scan(dests...); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
@@ -226,8 +223,8 @@ func (s *Store) createResourceImpl(ctx context.Context, tx *sql.Tx, create *api.
|
||||
return &resourceRaw, nil
|
||||
}
|
||||
|
||||
func (s *Store) patchResourceImpl(ctx context.Context, tx *sql.Tx, patch *api.ResourcePatch) (*resourceRaw, error) {
|
||||
set, args := []string{}, []interface{}{}
|
||||
func patchResourceImpl(ctx context.Context, tx *sql.Tx, patch *api.ResourcePatch) (*resourceRaw, error) {
|
||||
set, args := []string{}, []any{}
|
||||
|
||||
if v := patch.UpdatedTs; v != nil {
|
||||
set, args = append(set, "updated_ts = ?"), append(args, *v)
|
||||
@@ -235,26 +232,19 @@ func (s *Store) patchResourceImpl(ctx context.Context, tx *sql.Tx, patch *api.Re
|
||||
if v := patch.Filename; v != nil {
|
||||
set, args = append(set, "filename = ?"), append(args, *v)
|
||||
}
|
||||
if s.profile.IsDev() {
|
||||
if v := patch.Visibility; v != nil {
|
||||
set, args = append(set, "visibility = ?"), append(args, *v)
|
||||
}
|
||||
if v := patch.PublicID; v != nil {
|
||||
set, args = append(set, "public_id = ?"), append(args, *v)
|
||||
}
|
||||
|
||||
args = append(args, patch.ID)
|
||||
|
||||
fields := []string{"id", "filename", "external_link", "type", "size", "creator_id", "created_ts", "updated_ts"}
|
||||
if s.profile.IsDev() {
|
||||
fields = append(fields, "visibility")
|
||||
}
|
||||
|
||||
fields := []string{"id", "filename", "external_link", "type", "size", "creator_id", "created_ts", "updated_ts", "internal_path", "public_id"}
|
||||
query := `
|
||||
UPDATE resource
|
||||
SET ` + strings.Join(set, ", ") + `
|
||||
WHERE id = ?
|
||||
RETURNING ` + strings.Join(fields, ", ")
|
||||
var resourceRaw resourceRaw
|
||||
dests := []interface{}{
|
||||
dests := []any{
|
||||
&resourceRaw.ID,
|
||||
&resourceRaw.Filename,
|
||||
&resourceRaw.ExternalLink,
|
||||
@@ -263,9 +253,8 @@ func (s *Store) patchResourceImpl(ctx context.Context, tx *sql.Tx, patch *api.Re
|
||||
&resourceRaw.CreatorID,
|
||||
&resourceRaw.CreatedTs,
|
||||
&resourceRaw.UpdatedTs,
|
||||
}
|
||||
if s.profile.IsDev() {
|
||||
dests = append(dests, &resourceRaw.Visibility)
|
||||
&resourceRaw.InternalPath,
|
||||
&resourceRaw.PublicID,
|
||||
}
|
||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(dests...); err != nil {
|
||||
return nil, FormatError(err)
|
||||
@@ -274,37 +263,47 @@ func (s *Store) patchResourceImpl(ctx context.Context, tx *sql.Tx, patch *api.Re
|
||||
return &resourceRaw, nil
|
||||
}
|
||||
|
||||
func (s *Store) findResourceListImpl(ctx context.Context, tx *sql.Tx, find *api.ResourceFind) ([]*resourceRaw, error) {
|
||||
where, args := []string{"1 = 1"}, []interface{}{}
|
||||
func findResourceListImpl(ctx context.Context, tx *sql.Tx, find *api.ResourceFind) ([]*resourceRaw, error) {
|
||||
where, args := []string{"1 = 1"}, []any{}
|
||||
|
||||
if v := find.ID; v != nil {
|
||||
where, args = append(where, "id = ?"), append(args, *v)
|
||||
where, args = append(where, "resource.id = ?"), append(args, *v)
|
||||
}
|
||||
if v := find.CreatorID; v != nil {
|
||||
where, args = append(where, "creator_id = ?"), append(args, *v)
|
||||
where, args = append(where, "resource.creator_id = ?"), append(args, *v)
|
||||
}
|
||||
if v := find.Filename; v != nil {
|
||||
where, args = append(where, "filename = ?"), append(args, *v)
|
||||
where, args = append(where, "resource.filename = ?"), append(args, *v)
|
||||
}
|
||||
if v := find.MemoID; v != nil {
|
||||
where, args = append(where, "id in (SELECT resource_id FROM memo_resource WHERE memo_id = ?)"), append(args, *v)
|
||||
where, args = append(where, "resource.id in (SELECT resource_id FROM memo_resource WHERE memo_id = ?)"), append(args, *v)
|
||||
}
|
||||
if v := find.PublicID; v != nil {
|
||||
where, args = append(where, "resource.public_id = ?"), append(args, *v)
|
||||
}
|
||||
|
||||
fields := []string{"id", "filename", "external_link", "type", "size", "creator_id", "created_ts", "updated_ts"}
|
||||
fields := []string{"resource.id", "resource.filename", "resource.external_link", "resource.type", "resource.size", "resource.creator_id", "resource.created_ts", "resource.updated_ts", "internal_path", "public_id"}
|
||||
if find.GetBlob {
|
||||
fields = append(fields, "blob")
|
||||
}
|
||||
if s.profile.IsDev() {
|
||||
fields = append(fields, "visibility")
|
||||
fields = append(fields, "resource.blob")
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
COUNT(DISTINCT memo_resource.memo_id) AS linked_memo_amount,
|
||||
%s
|
||||
FROM resource
|
||||
LEFT JOIN memo_resource ON resource.id = memo_resource.resource_id
|
||||
WHERE %s
|
||||
ORDER BY id DESC
|
||||
GROUP BY resource.id
|
||||
ORDER BY resource.id DESC
|
||||
`, strings.Join(fields, ", "), strings.Join(where, " AND "))
|
||||
if find.Limit != nil {
|
||||
query = fmt.Sprintf("%s LIMIT %d", query, *find.Limit)
|
||||
if find.Offset != nil {
|
||||
query = fmt.Sprintf("%s OFFSET %d", query, *find.Offset)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := tx.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, FormatError(err)
|
||||
@@ -314,7 +313,8 @@ func (s *Store) findResourceListImpl(ctx context.Context, tx *sql.Tx, find *api.
|
||||
resourceRawList := make([]*resourceRaw, 0)
|
||||
for rows.Next() {
|
||||
var resourceRaw resourceRaw
|
||||
dests := []interface{}{
|
||||
dests := []any{
|
||||
&resourceRaw.LinkedMemoAmount,
|
||||
&resourceRaw.ID,
|
||||
&resourceRaw.Filename,
|
||||
&resourceRaw.ExternalLink,
|
||||
@@ -323,17 +323,15 @@ func (s *Store) findResourceListImpl(ctx context.Context, tx *sql.Tx, find *api.
|
||||
&resourceRaw.CreatorID,
|
||||
&resourceRaw.CreatedTs,
|
||||
&resourceRaw.UpdatedTs,
|
||||
&resourceRaw.InternalPath,
|
||||
&resourceRaw.PublicID,
|
||||
}
|
||||
if find.GetBlob {
|
||||
dests = append(dests, &resourceRaw.Blob)
|
||||
}
|
||||
if s.profile.IsDev() {
|
||||
dests = append(dests, &resourceRaw.Visibility)
|
||||
}
|
||||
if err := rows.Scan(dests...); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
resourceRawList = append(resourceRawList, &resourceRaw)
|
||||
}
|
||||
|
||||
@@ -345,7 +343,7 @@ func (s *Store) findResourceListImpl(ctx context.Context, tx *sql.Tx, find *api.
|
||||
}
|
||||
|
||||
func deleteResource(ctx context.Context, tx *sql.Tx, delete *api.ResourceDelete) error {
|
||||
where, args := []string{"id = ?"}, []interface{}{delete.ID}
|
||||
where, args := []string{"id = ?"}, []any{delete.ID}
|
||||
|
||||
stmt := `DELETE FROM resource WHERE ` + strings.Join(where, " AND ")
|
||||
result, err := tx.ExecContext(ctx, stmt, args...)
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -216,7 +216,7 @@ func createUser(ctx context.Context, tx *sql.Tx, create *api.UserCreate) (*userR
|
||||
}
|
||||
|
||||
func patchUser(ctx context.Context, tx *sql.Tx, patch *api.UserPatch) (*userRaw, error) {
|
||||
set, args := []string{}, []interface{}{}
|
||||
set, args := []string{}, []any{}
|
||||
|
||||
if v := patch.UpdatedTs; v != nil {
|
||||
set, args = append(set, "updated_ts = ?"), append(args, *v)
|
||||
@@ -272,7 +272,7 @@ func patchUser(ctx context.Context, tx *sql.Tx, patch *api.UserPatch) (*userRaw,
|
||||
}
|
||||
|
||||
func findUserList(ctx context.Context, tx *sql.Tx, find *api.UserFind) ([]*userRaw, error) {
|
||||
where, args := []string{"1 = 1"}, []interface{}{}
|
||||
where, args := []string{"1 = 1"}, []any{}
|
||||
|
||||
if v := find.ID; v != nil {
|
||||
where, args = append(where, "id = ?"), append(args, *v)
|
||||
|
||||
@@ -118,7 +118,7 @@ func upsertUserSetting(ctx context.Context, tx *sql.Tx, upsert *api.UserSettingU
|
||||
}
|
||||
|
||||
func findUserSettingList(ctx context.Context, tx *sql.Tx, find *api.UserSettingFind) ([]*userSettingRaw, error) {
|
||||
where, args := []string{"1 = 1"}, []interface{}{}
|
||||
where, args := []string{"1 = 1"}, []any{}
|
||||
|
||||
if v := find.Key.String(); v != "" {
|
||||
where, args = append(where, "key = ?"), append(args, v)
|
||||
|
||||
42
test/store/memo_test.go
Normal file
42
test/store/memo_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package teststore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/usememos/memos/api"
|
||||
)
|
||||
|
||||
func TestMemoStore(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewTestingStore(ctx, t)
|
||||
user, err := createTestingHostUser(ctx, store)
|
||||
require.NoError(t, err)
|
||||
memoCreate := &api.MemoCreate{
|
||||
CreatorID: user.ID,
|
||||
Content: "test_content",
|
||||
Visibility: api.Public,
|
||||
}
|
||||
memo, err := store.CreateMemo(ctx, memoCreate)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, memoCreate.Content, memo.Content)
|
||||
memoPatchContent := "test_content_2"
|
||||
memoPatch := &api.MemoPatch{
|
||||
ID: memo.ID,
|
||||
Content: &memoPatchContent,
|
||||
}
|
||||
memo, err = store.PatchMemo(ctx, memoPatch)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, memoPatchContent, memo.Content)
|
||||
memoList, err := store.FindMemoList(ctx, &api.MemoFind{
|
||||
CreatorID: &user.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, len(memoList))
|
||||
require.Equal(t, memo, memoList[0])
|
||||
err = store.DeleteMemo(ctx, &api.MemoDelete{
|
||||
ID: memo.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
25
test/store/store.go
Normal file
25
test/store/store.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package teststore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/usememos/memos/store"
|
||||
"github.com/usememos/memos/store/db"
|
||||
"github.com/usememos/memos/test"
|
||||
|
||||
// sqlite3 driver.
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func NewTestingStore(ctx context.Context, t *testing.T) *store.Store {
|
||||
profile := test.GetTestingProfile(t)
|
||||
db := db.NewDB(profile)
|
||||
if err := db.Open(ctx); err != nil {
|
||||
fmt.Printf("failed to open db, error: %+v\n", err)
|
||||
}
|
||||
|
||||
store := store.New(db.DBInstance, profile)
|
||||
return store
|
||||
}
|
||||
35
test/store/system_setting_test.go
Normal file
35
test/store/system_setting_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package teststore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
)
|
||||
|
||||
func TestSystemSettingStore(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewTestingStore(ctx, t)
|
||||
_, err := store.UpsertSystemSetting(ctx, &api.SystemSettingUpsert{
|
||||
Name: api.SystemSettingServerIDName,
|
||||
Value: "test_server_id",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = store.UpsertSystemSetting(ctx, &api.SystemSettingUpsert{
|
||||
Name: api.SystemSettingSecretSessionName,
|
||||
Value: "test_secret_session_name",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = store.UpsertSystemSetting(ctx, &api.SystemSettingUpsert{
|
||||
Name: api.SystemSettingAllowSignUpName,
|
||||
Value: "true",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = store.UpsertSystemSetting(ctx, &api.SystemSettingUpsert{
|
||||
Name: api.SystemSettingLocalStoragePathName,
|
||||
Value: "/tmp/memos",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
56
test/store/user_test.go
Normal file
56
test/store/user_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package teststore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestUserStore(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewTestingStore(ctx, t)
|
||||
user, err := createTestingHostUser(ctx, store)
|
||||
require.NoError(t, err)
|
||||
users, err := store.FindUserList(ctx, &api.UserFind{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, len(users))
|
||||
require.Equal(t, api.Host, users[0].Role)
|
||||
require.Equal(t, user, users[0])
|
||||
userPatchNickname := "test_nickname_2"
|
||||
userPatch := &api.UserPatch{
|
||||
ID: user.ID,
|
||||
Nickname: &userPatchNickname,
|
||||
}
|
||||
user, err = store.PatchUser(ctx, userPatch)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, userPatchNickname, user.Nickname)
|
||||
err = store.DeleteUser(ctx, &api.UserDelete{
|
||||
ID: user.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
users, err = store.FindUserList(ctx, &api.UserFind{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, len(users))
|
||||
}
|
||||
|
||||
func createTestingHostUser(ctx context.Context, store *store.Store) (*api.User, error) {
|
||||
userCreate := &api.UserCreate{
|
||||
Username: "test",
|
||||
Role: api.Host,
|
||||
Email: "test@test.com",
|
||||
Nickname: "test_nickname",
|
||||
Password: "test_password",
|
||||
OpenID: "test_open_id",
|
||||
}
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(userCreate.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userCreate.PasswordHash = string(passwordHash)
|
||||
user, err := store.CreateUser(ctx, userCreate)
|
||||
return user, err
|
||||
}
|
||||
22
test/test.go
Normal file
22
test/test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/usememos/memos/server/profile"
|
||||
"github.com/usememos/memos/server/version"
|
||||
)
|
||||
|
||||
func GetTestingProfile(t *testing.T) *profile.Profile {
|
||||
// Get a temporary directory for the test data.
|
||||
dir := t.TempDir()
|
||||
mode := "prod"
|
||||
return &profile.Profile{
|
||||
Mode: mode,
|
||||
Port: 8082,
|
||||
Data: dir,
|
||||
DSN: fmt.Sprintf("%s/memos_%s.db", dir, mode),
|
||||
Version: version.GetCurrentVersion(mode),
|
||||
}
|
||||
}
|
||||
3
web/.vscode/settings.json
vendored
3
web/.vscode/settings.json
vendored
@@ -8,5 +8,6 @@
|
||||
],
|
||||
"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" />
|
||||
|
||||
@@ -29,9 +29,11 @@
|
||||
"react-router-dom": "^6.8.2",
|
||||
"react-use": "^17.4.0",
|
||||
"semver": "^7.3.8",
|
||||
"tailwindcss": "^3.2.4"
|
||||
"tailwindcss": "^3.2.4",
|
||||
"zustand": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/line-clamp": "^0.4.2",
|
||||
"@types/lodash-es": "^4.17.5",
|
||||
"@types/node": "^18.0.3",
|
||||
"@types/qs": "^6.9.7",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 114 KiB |
BIN
web/public/logo.webp
Normal file
BIN
web/public/logo.webp
Normal file
Binary file not shown.
|
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,3 +1,4 @@
|
||||
import dayjs from "dayjs";
|
||||
import { useColorScheme } from "@mui/joy";
|
||||
import { useEffect, Suspense } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
@@ -52,12 +53,13 @@ const App = () => {
|
||||
// dynamic update metadata with customized profile.
|
||||
document.title = systemStatus.customizedProfile.name;
|
||||
const link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
|
||||
link.href = systemStatus.customizedProfile.logoUrl || "/logo.png";
|
||||
link.href = systemStatus.customizedProfile.logoUrl || "/logo.webp";
|
||||
}, [systemStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("lang", locale);
|
||||
i18n.changeLanguage(locale);
|
||||
dayjs.locale(locale);
|
||||
storage.set({
|
||||
locale: locale,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGlobalStore } from "../store/module";
|
||||
import { useGlobalStore } from "@/store/module";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import GitHubBadge from "./GitHubBadge";
|
||||
@@ -31,8 +31,8 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
<div className="mt-4 w-full flex flex-row text-sm justify-start items-center">
|
||||
<div className="flex flex-row justify-start items-center mr-2">
|
||||
Powered by
|
||||
<a href="https://usememos.com" target="_blank" className="flex flex-row justify-start items-center mr-1 hover:underline">
|
||||
<img className="w-6 h-auto" src="/logo.png" alt="" />
|
||||
<a href="https://usememos.com" target="_blank" className="flex flex-row justify-start items-center mx-1 hover:underline">
|
||||
<img className="w-6 h-auto rounded-full mr-1" src="/logo.webp" alt="" />
|
||||
memos
|
||||
</a>
|
||||
<span>v{profile.version}</span>
|
||||
|
||||
@@ -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 { 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;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemoStore } from "../store/module";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { useMemoStore } from "@/store/module";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import ArchivedMemo from "./ArchivedMemo";
|
||||
import "../less/archived-memo-dialog.less";
|
||||
import "@/less/archived-memo-dialog.less";
|
||||
|
||||
type Props = DialogProps;
|
||||
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import { Button, Textarea } from "@mui/joy";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import * as api from "../helpers/api";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { marked } from "../labs/marked";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as api from "@/helpers/api";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { marked } from "@/labs/marked";
|
||||
import { useMessageStore } from "@/store/zustand/message";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import showSettingDialog from "./SettingDialog";
|
||||
|
||||
type Props = DialogProps;
|
||||
|
||||
interface History {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
const AskAIDialog: React.FC<Props> = (props: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { destroy, hide } = props;
|
||||
const fetchingState = useLoading(false);
|
||||
const [historyList, setHistoryList] = useState<History[]>([]);
|
||||
const messageStore = useMessageStore();
|
||||
const [isEnabled, setIsEnabled] = useState<boolean>(true);
|
||||
const [isInIME, setIsInIME] = useState(false);
|
||||
const [question, setQuestion] = useState<string>("");
|
||||
const messageList = messageStore.messageList;
|
||||
|
||||
useEffect(() => {
|
||||
api.checkOpenAIEnabled().then(({ data }) => {
|
||||
@@ -38,33 +38,42 @@ const AskAIDialog: React.FC<Props> = (props: Props) => {
|
||||
setQuestion(event.currentTarget.value);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Enter" && !event.shiftKey && !isInIME) {
|
||||
event.preventDefault();
|
||||
handleSendQuestionButtonClick();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendQuestionButtonClick = async () => {
|
||||
if (!question) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetchingState.setLoading();
|
||||
setQuestion("");
|
||||
messageStore.addMessage({
|
||||
role: "user",
|
||||
content: question,
|
||||
});
|
||||
try {
|
||||
await askQuestion(question);
|
||||
await fetchChatCompletion();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.response.data.error);
|
||||
}
|
||||
setQuestion("");
|
||||
fetchingState.setFinish();
|
||||
};
|
||||
|
||||
const askQuestion = async (question: string) => {
|
||||
if (question === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchChatCompletion = async () => {
|
||||
const messageList = messageStore.getState().messageList;
|
||||
const {
|
||||
data: { data: answer },
|
||||
} = await api.postChatCompletion(question);
|
||||
setHistoryList([
|
||||
{
|
||||
question,
|
||||
answer: answer.replace(/^\n\n/, ""),
|
||||
},
|
||||
...historyList,
|
||||
]);
|
||||
} = await api.postChatCompletion(messageList);
|
||||
messageStore.addMessage({
|
||||
role: "assistant",
|
||||
content: answer.replace(/^\n\n/, ""),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -72,46 +81,59 @@ const AskAIDialog: React.FC<Props> = (props: Props) => {
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text flex flex-row items-center">
|
||||
<Icon.Bot className="mr-1 w-5 h-auto opacity-80" />
|
||||
Ask AI
|
||||
{t("ask-ai.title")}
|
||||
</p>
|
||||
<button className="btn close-btn" onClick={() => hide()}>
|
||||
<Icon.X />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container !w-112 max-w-full">
|
||||
<div className="w-full relative">
|
||||
<Textarea className="w-full" placeholder="Ask anything…" value={question} onChange={handleQuestionTextareaChange} />
|
||||
<Icon.Send
|
||||
className="cursor-pointer w-7 p-1 h-auto rounded-md bg-gray-100 dark:bg-zinc-800 absolute right-2 bottom-1.5 shadow hover:opacity-80"
|
||||
onClick={handleSendQuestionButtonClick}
|
||||
/>
|
||||
</div>
|
||||
{messageList.map((message, index) => (
|
||||
<div key={index} className="w-full flex flex-col justify-start items-start mt-4 space-y-2">
|
||||
{message.role === "user" ? (
|
||||
<div className="w-full flex flex-row justify-end items-start pl-6">
|
||||
<span className="word-break shadow rounded-lg rounded-tr-none px-3 py-2 opacity-80 bg-gray-100 dark:bg-zinc-700">
|
||||
{message.content}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full flex flex-row justify-start items-start pr-8 space-x-2">
|
||||
<Icon.Bot className="mt-2 flex-shrink-0 mr-1 w-6 h-auto opacity-80" />
|
||||
<div className="memo-content-wrapper !w-auto flex flex-col justify-start items-start shadow rounded-lg rounded-tl-none px-3 py-2 bg-gray-100 dark:bg-zinc-700">
|
||||
<div className="memo-content-text">{marked(message.content)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{fetchingState.isLoading && (
|
||||
<p className="w-full py-2 mt-4 flex flex-row justify-center items-center">
|
||||
<Icon.Loader className="w-5 h-auto animate-spin" />
|
||||
</p>
|
||||
)}
|
||||
{historyList.map((history, index) => (
|
||||
<div key={index} className="w-full flex flex-col justify-start items-start mt-4 space-y-2">
|
||||
<div className="w-full flex flex-row justify-start items-start pr-6">
|
||||
<span className="word-break rounded-lg rounded-tl-none px-3 py-2 opacity-80 bg-gray-100 dark:bg-zinc-700">
|
||||
{history.question}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-end items-start pl-8 space-x-2">
|
||||
<div className="memo-content-wrapper !w-auto flex flex-col justify-start items-start rounded-lg rounded-tr-none px-3 py-2 bg-gray-100 dark:bg-zinc-700">
|
||||
<div className="memo-content-text">{marked(history.answer)}</div>
|
||||
</div>
|
||||
<Icon.Bot className="mt-2 flex-shrink-0 mr-1 w-6 h-auto opacity-80" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!isEnabled && (
|
||||
<div className="w-full flex flex-col justify-center items-center mt-4 space-y-2">
|
||||
<p>You have not set up your OpenAI API key.</p>
|
||||
<Button onClick={() => handleGotoSystemSetting()}>Go to settings</Button>
|
||||
<p>{t("ask-ai.not_enabled")}</p>
|
||||
<Button onClick={() => handleGotoSystemSetting()}>{t("ask-ai.go-to-settings")}</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full relative mt-4">
|
||||
<Textarea
|
||||
className="w-full"
|
||||
placeholder={t("ask-ai.placeholder")}
|
||||
value={question}
|
||||
minRows={1}
|
||||
maxRows={5}
|
||||
onChange={handleQuestionTextareaChange}
|
||||
onCompositionStart={() => setIsInIME(true)}
|
||||
onCompositionEnd={() => setIsInIME(false)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<Icon.Send
|
||||
className="cursor-pointer w-7 p-1 h-auto rounded-md bg-gray-100 dark:bg-zinc-800 absolute right-2 bottom-1.5 shadow hover:opacity-80"
|
||||
onClick={handleSendQuestionButtonClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUserStore } from "../store/module";
|
||||
import { useUserStore } from "@/store/module";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import dayjs from "dayjs";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemoStore } from "../store/module";
|
||||
import { useMemoStore } from "@/store/module";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUserStore } from "../store/module";
|
||||
import { useUserStore } from "@/store/module";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useResourceStore } from "../store/module";
|
||||
import { useResourceStore } from "@/store/module";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import "../less/change-resource-filename-dialog.less";
|
||||
import "@/less/change-resource-filename-dialog.less";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
resourceId: ResourceId;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button, Divider, Input, Radio, RadioGroup, Typography } from "@mui/joy";
|
||||
import * as api from "../helpers/api";
|
||||
import { UNKNOWN_ID } from "../helpers/consts";
|
||||
import { absolutifyLink } from "../helpers/utils";
|
||||
import * as api from "@/helpers/api";
|
||||
import { UNKNOWN_ID } from "@/helpers/consts";
|
||||
import { absolutifyLink } from "@/helpers/utils";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import Icon from "./Icon";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button, Input, Select, Option, Typography, List, ListItem, Autocomplete, Tooltip } from "@mui/joy";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useResourceStore } from "../store/module";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
@@ -20,6 +21,7 @@ interface State {
|
||||
}
|
||||
|
||||
const CreateResourceDialog: React.FC<Props> = (props: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { destroy, onCancel, onConfirm } = props;
|
||||
const resourceStore = useResourceStore();
|
||||
const [state, setState] = useState<State>({
|
||||
@@ -144,14 +146,14 @@ const CreateResourceDialog: React.FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">Create Resource</p>
|
||||
<p className="title-text">{t("resources.create-dialog.title")}</p>
|
||||
<button className="btn close-btn" onClick={handleCloseDialog}>
|
||||
<Icon.X />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container !w-80">
|
||||
<Typography className="!mb-1" level="body2">
|
||||
Upload method
|
||||
{t("resources.create-dialog.upload-method")}
|
||||
</Typography>
|
||||
<Select
|
||||
className="w-full mb-2"
|
||||
@@ -159,15 +161,15 @@ const CreateResourceDialog: React.FC<Props> = (props: Props) => {
|
||||
value={state.selectedMode}
|
||||
startDecorator={<Icon.File className="w-4 h-auto" />}
|
||||
>
|
||||
<Option value="local-file">Local file</Option>
|
||||
<Option value="external-link">External link</Option>
|
||||
<Option value="local-file">{t("resources.create-dialog.local-file.option")}</Option>
|
||||
<Option value="external-link">{t("resources.create-dialog.external-link.option")}</Option>
|
||||
</Select>
|
||||
|
||||
{state.selectedMode === "local-file" && (
|
||||
<>
|
||||
<div className="w-full relative bg-blue-50 dark:bg-zinc-900 rounded-md flex flex-row justify-center items-center py-8">
|
||||
<label htmlFor="files" className="p-2 px-4 text-sm text-white cursor-pointer bg-blue-500 block rounded hover:opacity-80">
|
||||
Choose a file...
|
||||
{t("resources.create-dialog.local-file.choose")}
|
||||
</label>
|
||||
<input
|
||||
className="absolute inset-0 w-full h-full opacity-0"
|
||||
@@ -194,7 +196,7 @@ const CreateResourceDialog: React.FC<Props> = (props: Props) => {
|
||||
{state.selectedMode === "external-link" && (
|
||||
<>
|
||||
<Typography className="!mb-1" level="body2">
|
||||
Link
|
||||
{t("resources.create-dialog.external-link.link")}
|
||||
</Typography>
|
||||
<Input
|
||||
className="mb-2"
|
||||
@@ -204,16 +206,22 @@ const CreateResourceDialog: React.FC<Props> = (props: Props) => {
|
||||
fullWidth
|
||||
/>
|
||||
<Typography className="!mb-1" level="body2">
|
||||
File name
|
||||
{t("resources.create-dialog.external-link.file-name")}
|
||||
</Typography>
|
||||
<Input className="mb-2" placeholder="File name" value={resourceCreate.filename} onChange={handleFileNameChanged} fullWidth />
|
||||
<Input
|
||||
className="mb-2"
|
||||
placeholder={t("resources.create-dialog.external-link.file-name-placeholder")}
|
||||
value={resourceCreate.filename}
|
||||
onChange={handleFileNameChanged}
|
||||
fullWidth
|
||||
/>
|
||||
<Typography className="!mb-1" level="body2">
|
||||
Type
|
||||
{t("resources.create-dialog.external-link.type")}
|
||||
</Typography>
|
||||
<Autocomplete
|
||||
className="w-full"
|
||||
size="sm"
|
||||
placeholder="File type"
|
||||
placeholder={t("resources.create-dialog.external-link.type-placeholder")}
|
||||
freeSolo={true}
|
||||
options={fileTypeAutocompleteOptions}
|
||||
onChange={(_, value) => handleFileTypeChanged(value || "")}
|
||||
@@ -223,10 +231,10 @@ const CreateResourceDialog: React.FC<Props> = (props: Props) => {
|
||||
|
||||
<div className="mt-2 w-full flex flex-row justify-end items-center space-x-1">
|
||||
<Button variant="plain" color="neutral" onClick={handleCloseDialog}>
|
||||
Cancel
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleConfirmBtnClick} loading={state.uploadingFlag} disabled={!allowConfirmAction()}>
|
||||
Create
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,13 +2,13 @@ import dayjs from "dayjs";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useShortcutStore, useTagStore } from "../store/module";
|
||||
import { filterConsts, getDefaultFilter, relationConsts } from "../helpers/filter";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { useShortcutStore, useTagStore } from "@/store/module";
|
||||
import { filterConsts, getDefaultFilter, relationConsts } from "@/helpers/filter";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import Selector from "./base/Selector";
|
||||
import "../less/create-shortcut-dialog.less";
|
||||
import "@/less/create-shortcut-dialog.less";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
shortcutId?: ShortcutId;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Button, Input, Typography } from "@mui/joy";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button, Input, Typography } from "@mui/joy";
|
||||
import * as api from "../helpers/api";
|
||||
import * as api from "@/helpers/api";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import Icon from "./Icon";
|
||||
import RequiredBadge from "./RequiredBadge";
|
||||
import LearnMore from "./LearnMore";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
storage?: ObjectStorage;
|
||||
@@ -24,6 +26,7 @@ const CreateStorageServiceDialog: React.FC<Props> = (props: Props) => {
|
||||
path: "",
|
||||
bucket: "",
|
||||
urlPrefix: "",
|
||||
urlSuffix: "",
|
||||
});
|
||||
const isCreating = storage === undefined;
|
||||
|
||||
@@ -48,7 +51,13 @@ const CreateStorageServiceDialog: React.FC<Props> = (props: Props) => {
|
||||
return false;
|
||||
}
|
||||
if (type === "S3") {
|
||||
if (s3Config.endPoint === "" || s3Config.region === "" || s3Config.accessKey === "" || s3Config.bucket === "") {
|
||||
if (
|
||||
s3Config.endPoint === "" ||
|
||||
s3Config.region === "" ||
|
||||
s3Config.accessKey === "" ||
|
||||
s3Config.secretKey === "" ||
|
||||
s3Config.bucket === ""
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -97,14 +106,7 @@ const CreateStorageServiceDialog: React.FC<Props> = (props: Props) => {
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">
|
||||
{isCreating ? "Create storage" : "Update storage"}
|
||||
<a
|
||||
className="ml-2 text-sm text-blue-600 hover:opacity-80 hover:underline"
|
||||
href="https://usememos.com/docs/storage"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more
|
||||
<Icon.ExternalLink className="inline -mt-1 ml-1 w-4 h-auto opacity-80" />
|
||||
</a>
|
||||
<LearnMore className="ml-2" url="https://usememos.com/docs/storage" />
|
||||
</p>
|
||||
<button className="btn close-btn" onClick={handleCloseBtnClick}>
|
||||
<Icon.X />
|
||||
@@ -113,6 +115,7 @@ const CreateStorageServiceDialog: React.FC<Props> = (props: Props) => {
|
||||
<div className="dialog-content-container">
|
||||
<Typography className="!mb-1" level="body2">
|
||||
Name
|
||||
<RequiredBadge />
|
||||
</Typography>
|
||||
<Input
|
||||
className="mb-2"
|
||||
@@ -128,6 +131,7 @@ const CreateStorageServiceDialog: React.FC<Props> = (props: Props) => {
|
||||
/>
|
||||
<Typography className="!mb-1" level="body2">
|
||||
EndPoint
|
||||
<RequiredBadge />
|
||||
<span className="text-sm text-gray-400 ml-1">(S3-compatible server URL)</span>
|
||||
</Typography>
|
||||
<Input
|
||||
@@ -139,6 +143,7 @@ const CreateStorageServiceDialog: React.FC<Props> = (props: Props) => {
|
||||
/>
|
||||
<Typography className="!mb-1" level="body2">
|
||||
Region
|
||||
<RequiredBadge />
|
||||
<span className="text-sm text-gray-400 ml-1">(Region name)</span>
|
||||
</Typography>
|
||||
<Input
|
||||
@@ -150,6 +155,7 @@ const CreateStorageServiceDialog: React.FC<Props> = (props: Props) => {
|
||||
/>
|
||||
<Typography className="!mb-1" level="body2">
|
||||
AccessKey
|
||||
<RequiredBadge />
|
||||
<span className="text-sm text-gray-400 ml-1">(Access Key / Access ID)</span>
|
||||
</Typography>
|
||||
<Input
|
||||
@@ -161,6 +167,7 @@ const CreateStorageServiceDialog: React.FC<Props> = (props: Props) => {
|
||||
/>
|
||||
<Typography className="!mb-1" level="body2">
|
||||
SecretKey
|
||||
<RequiredBadge />
|
||||
<span className="text-sm text-gray-400 ml-1">(Secret Key / Secret Access Key)</span>
|
||||
</Typography>
|
||||
<Input
|
||||
@@ -172,6 +179,7 @@ const CreateStorageServiceDialog: React.FC<Props> = (props: Props) => {
|
||||
/>
|
||||
<Typography className="!mb-1" level="body2">
|
||||
Bucket
|
||||
<RequiredBadge />
|
||||
<span className="text-sm text-gray-400 ml-1">(Bucket name)</span>
|
||||
</Typography>
|
||||
<Input
|
||||
@@ -187,8 +195,8 @@ const CreateStorageServiceDialog: React.FC<Props> = (props: Props) => {
|
||||
</Typography>
|
||||
<Typography className="!mb-1" level="body2">
|
||||
<p className="text-sm text-gray-400 ml-1">{"You can use {year}, {month}, {day}, {hour}, {minute}, {second},"}</p>
|
||||
<p className="text-sm text-gray-400 ml-1">{"{filetype}, {filename}, {timestamp} and any other words."}</p>
|
||||
<p className="text-sm text-gray-400 ml-1">{"e.g., {year}/{month}/{day}/your/path/{filename}.{filetype}"}</p>
|
||||
<p className="text-sm text-gray-400 ml-1">{"{filename}, {timestamp} and any other words."}</p>
|
||||
<p className="text-sm text-gray-400 ml-1">{"e.g., {year}/{month}/{day}/your/path/{filename}"}</p>
|
||||
</Typography>
|
||||
<Input
|
||||
className="mb-2"
|
||||
@@ -208,6 +216,17 @@ const CreateStorageServiceDialog: React.FC<Props> = (props: Props) => {
|
||||
onChange={(e) => setPartialS3Config({ urlPrefix: e.target.value })}
|
||||
fullWidth
|
||||
/>
|
||||
<Typography className="!mb-1" level="body2">
|
||||
URLSuffix
|
||||
<span className="text-sm text-gray-400 ml-1">(Custom URL suffix; Optional)</span>
|
||||
</Typography>
|
||||
<Input
|
||||
className="mb-2"
|
||||
placeholder="URLSuffix"
|
||||
value={s3Config.urlSuffix}
|
||||
onChange={(e) => setPartialS3Config({ urlSuffix: e.target.value })}
|
||||
fullWidth
|
||||
/>
|
||||
<div className="mt-2 w-full flex flex-row justify-end items-center space-x-1">
|
||||
<Button variant="plain" color="neutral" onClick={handleCloseBtnClick}>
|
||||
Cancel
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Input } from "@mui/joy";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTagStore } from "../store/module";
|
||||
import { getTagSuggestionList } from "../helpers/api";
|
||||
import { matcher } from "../labs/marked/matcher";
|
||||
import Tag from "../labs/marked/parser/Tag";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTagStore } from "@/store/module";
|
||||
import { getTagSuggestionList } from "@/helpers/api";
|
||||
import { matcher } from "@/labs/marked/matcher";
|
||||
import Tag from "@/labs/marked/parser/Tag";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
|
||||
@@ -21,6 +22,7 @@ const validateTagName = (tagName: string): boolean => {
|
||||
const CreateTagDialog: React.FC<Props> = (props: Props) => {
|
||||
const { destroy } = props;
|
||||
const tagStore = useTagStore();
|
||||
const { t } = useTranslation();
|
||||
const [tagName, setTagName] = useState<string>("");
|
||||
const [suggestTagNameList, setSuggestTagNameList] = useState<string[]>([]);
|
||||
const [showTagSuggestions, setShowTagSuggestions] = useState<boolean>(false);
|
||||
@@ -82,7 +84,7 @@ const CreateTagDialog: React.FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">Create Tag</p>
|
||||
<p className="title-text">{t("tag-list.create-tag")}</p>
|
||||
<button className="btn close-btn" onClick={() => destroy()}>
|
||||
<Icon.X />
|
||||
</button>
|
||||
@@ -91,7 +93,7 @@ const CreateTagDialog: React.FC<Props> = (props: Props) => {
|
||||
<Input
|
||||
className="mb-2"
|
||||
size="md"
|
||||
placeholder="TAG_NAME"
|
||||
placeholder={t("tag-list.tag-name")}
|
||||
value={tagName}
|
||||
onChange={handleTagNameChanged}
|
||||
onKeyDown={handleTagNameInputKeyDown}
|
||||
@@ -101,7 +103,7 @@ const CreateTagDialog: React.FC<Props> = (props: Props) => {
|
||||
/>
|
||||
{tagNameList.length > 0 && (
|
||||
<>
|
||||
<p className="w-full mt-2 mb-1 text-sm text-gray-400">All tags</p>
|
||||
<p className="w-full mt-2 mb-1 text-sm text-gray-400">{t("tag-list.all-tags")}</p>
|
||||
<div className="w-full flex flex-row justify-start items-start flex-wrap">
|
||||
{Array.from(tagNameList)
|
||||
.sort()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as utils from "../helpers/utils";
|
||||
import * as utils from "@/helpers/utils";
|
||||
import MemoContent from "./MemoContent";
|
||||
import MemoResources from "./MemoResources";
|
||||
import "../less/daily-memo.less";
|
||||
import "@/less/daily-memo.less";
|
||||
|
||||
interface Props {
|
||||
memo: Memo;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { CssVarsProvider } from "@mui/joy";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Provider } from "react-redux";
|
||||
import { ANIMATION_DURATION } from "../../helpers/consts";
|
||||
import store from "../../store";
|
||||
import { useDialogStore } from "../../store/module";
|
||||
import { CssVarsProvider } from "@mui/joy";
|
||||
import theme from "../../theme";
|
||||
import "../../less/base-dialog.less";
|
||||
import { ANIMATION_DURATION } from "@/helpers/consts";
|
||||
import store from "@/store";
|
||||
import { useDialogStore } from "@/store/module";
|
||||
import theme from "@/theme";
|
||||
import "@/less/base-dialog.less";
|
||||
|
||||
interface DialogConfig {
|
||||
dialogName: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Icon from "../Icon";
|
||||
import { generateDialog } from "./BaseDialog";
|
||||
import "../../less/common-dialog.less";
|
||||
import "@/less/common-dialog.less";
|
||||
|
||||
type DialogStyle = "info" | "warning";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { forwardRef, ReactNode, useCallback, useEffect, useImperativeHandle, useRef } from "react";
|
||||
import "../../less/editor.less";
|
||||
import "@/less/editor.less";
|
||||
|
||||
export interface EditorRefActions {
|
||||
focus: FunctionType;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import React from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
|
||||
@@ -9,6 +10,7 @@ interface Props extends DialogProps {
|
||||
}
|
||||
|
||||
const EmbedMemoDialog: React.FC<Props> = (props: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { memoId, destroy } = props;
|
||||
|
||||
const memoEmbeddedCode = () => {
|
||||
@@ -23,20 +25,20 @@ const EmbedMemoDialog: React.FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">Embed Memo</p>
|
||||
<p className="title-text">{t("embed-memo.title")}</p>
|
||||
<button className="btn close-btn" onClick={() => destroy()}>
|
||||
<Icon.X />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container !w-80">
|
||||
<p className="text-base leading-6 mb-2">Copy and paste the below codes into your blog or website.</p>
|
||||
<p className="text-base leading-6 mb-2">{t("embed-memo.text")}</p>
|
||||
<pre className="w-full font-mono text-sm p-3 border rounded-lg">
|
||||
<code className="w-full break-all whitespace-pre-wrap">{memoEmbeddedCode()}</code>
|
||||
</pre>
|
||||
<p className="w-full text-sm leading-6 flex flex-row justify-between items-center mt-2">
|
||||
<span className="italic opacity-80">* Only the public memo supports.</span>
|
||||
<span className="italic opacity-80">{t("embed-memo.only-public-supported")}</span>
|
||||
<span className="btn-primary" onClick={handleCopyCode}>
|
||||
Copy
|
||||
{t("embed-memo.copy")}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import * as api from "../helpers/api";
|
||||
import * as api from "@/helpers/api";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const GitHubBadge = () => {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useEffect } from "react";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLayoutStore, useUserStore } from "../store/module";
|
||||
import { resolution } from "../utils/layout";
|
||||
import { useLayoutStore, useUserStore } from "@/store/module";
|
||||
import { resolution } from "@/utils/layout";
|
||||
import Icon from "./Icon";
|
||||
import showResourcesDialog from "./ResourcesDialog";
|
||||
import showSettingDialog from "./SettingDialog";
|
||||
import showAskAIDialog from "./AskAIDialog";
|
||||
import showArchivedMemoDialog from "./ArchivedMemoDialog";
|
||||
@@ -33,7 +32,7 @@ const Header = () => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed sm:sticky top-0 left-0 w-full sm:w-56 h-full flex-shrink-0 pointer-events-none sm:pointer-events-auto z-10 ${
|
||||
className={`fixed sm:sticky top-0 left-0 w-full sm:w-56 h-full flex-shrink-0 pointer-events-none sm:pointer-events-auto z-20 ${
|
||||
showHeader && "pointer-events-auto"
|
||||
}`}
|
||||
>
|
||||
@@ -54,6 +53,7 @@ const Header = () => {
|
||||
<>
|
||||
<NavLink
|
||||
to="/"
|
||||
id="header-home"
|
||||
className={({ isActive }) =>
|
||||
`${
|
||||
isActive && "bg-white dark:bg-zinc-700 shadow"
|
||||
@@ -66,6 +66,7 @@ const Header = () => {
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/review"
|
||||
id="header-review"
|
||||
className={({ isActive }) =>
|
||||
`${
|
||||
isActive && "bg-white dark:bg-zinc-700 shadow"
|
||||
@@ -76,10 +77,24 @@ const Header = () => {
|
||||
<Icon.Calendar className="mr-4 w-6 h-auto opacity-80" /> {t("common.daily-review")}
|
||||
</>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/resources"
|
||||
id="header-resources"
|
||||
className={({ isActive }) =>
|
||||
`${
|
||||
isActive && "bg-white dark:bg-zinc-700 shadow"
|
||||
} px-4 pr-5 py-2 rounded-lg flex flex-row items-center text-lg dark:text-gray-200 hover:bg-white hover:shadow dark:hover:bg-zinc-700`
|
||||
}
|
||||
>
|
||||
<>
|
||||
<Icon.Paperclip className="mr-4 w-6 h-auto opacity-80" /> {t("common.resources")}
|
||||
</>
|
||||
</NavLink>
|
||||
</>
|
||||
)}
|
||||
<NavLink
|
||||
to="/explore"
|
||||
id="header-explore"
|
||||
className={({ isActive }) =>
|
||||
`${
|
||||
isActive && "bg-white dark:bg-zinc-700 shadow"
|
||||
@@ -93,24 +108,21 @@ const Header = () => {
|
||||
{!isVisitorMode && (
|
||||
<>
|
||||
<button
|
||||
id="header-ask-ai"
|
||||
className="px-4 pr-5 py-2 rounded-lg flex flex-row items-center text-lg dark:text-gray-200 hover:bg-white hover:shadow dark:hover:bg-zinc-700"
|
||||
onClick={() => showAskAIDialog()}
|
||||
>
|
||||
<Icon.Bot className="mr-4 w-6 h-auto opacity-80" /> Ask AI
|
||||
</button>
|
||||
<button
|
||||
className="px-4 pr-5 py-2 rounded-lg flex flex-row items-center text-lg dark:text-gray-200 hover:bg-white hover:shadow dark:hover:bg-zinc-700"
|
||||
onClick={() => showResourcesDialog()}
|
||||
>
|
||||
<Icon.Paperclip className="mr-4 w-6 h-auto opacity-80" /> {t("common.resources")}
|
||||
<Icon.Bot className="mr-4 w-6 h-auto opacity-80" /> {t("common.ask-ai")}
|
||||
</button>
|
||||
<button
|
||||
id="header-archived-memo"
|
||||
className="px-4 pr-5 py-2 rounded-lg flex flex-row items-center text-lg dark:text-gray-200 hover:bg-white hover:shadow dark:hover:bg-zinc-700"
|
||||
onClick={() => showArchivedMemoDialog()}
|
||||
>
|
||||
<Icon.Archive className="mr-4 w-6 h-auto opacity-80" /> {t("common.archived")}
|
||||
</button>
|
||||
<button
|
||||
id="header-settings"
|
||||
className="px-4 pr-5 py-2 rounded-lg flex flex-row items-center text-lg dark:text-gray-200 hover:bg-white hover:shadow dark:hover:bg-zinc-700"
|
||||
onClick={() => showSettingDialog()}
|
||||
>
|
||||
@@ -122,6 +134,7 @@ const Header = () => {
|
||||
<>
|
||||
<NavLink
|
||||
to="/auth"
|
||||
id="header-auth"
|
||||
className={({ isActive }) =>
|
||||
`${
|
||||
isActive && "bg-white dark:bg-zinc-700 shadow"
|
||||
@@ -133,6 +146,7 @@ const Header = () => {
|
||||
</>
|
||||
</NavLink>
|
||||
<button
|
||||
id="header-about"
|
||||
className="px-4 pr-5 py-2 rounded-lg flex flex-row items-center text-lg dark:text-gray-200 hover:bg-white hover:shadow dark:hover:bg-zinc-700"
|
||||
onClick={() => showAboutSiteDialog()}
|
||||
>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user