mirror of
https://github.com/usememos/memos.git
synced 2025-06-05 22:09:59 +02:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
936927f5bc | ||
|
|
e2e8130f4c | ||
|
|
46c13a4b7f | ||
|
|
96798e10b4 | ||
|
|
0f8ce3dd16 | ||
|
|
491859bbf6 | ||
|
|
f16123a624 | ||
|
|
d50ad9433f | ||
|
|
92a8a4ac0c | ||
|
|
62f53888ba | ||
|
|
79180928d4 | ||
|
|
e5550828a0 | ||
|
|
2e95f6824f | ||
|
|
5195012217 | ||
|
|
a797280e3f | ||
|
|
293f88e40c | ||
|
|
861eeb7b0f | ||
|
|
24b21aa9d7 | ||
|
|
51eac649c5 | ||
|
|
7670c95360 | ||
|
|
65e9fdead1 | ||
|
|
2b2792de73 | ||
|
|
c9bb2b785d | ||
|
|
64e5c343c5 | ||
|
|
9169b3f2cd | ||
|
|
b6f7a85a2a | ||
|
|
3556ae4e65 | ||
|
|
f888c62840 | ||
|
|
c160bed403 | ||
|
|
afc9709484 | ||
|
|
05b41804e3 | ||
|
|
2e2657b39d | ||
|
|
60ee602639 | ||
|
|
cac04e4406 | ||
|
|
278b4d21b4 | ||
|
|
27fd1e2880 | ||
|
|
fae9b3db46 | ||
|
|
49c7f49820 |
41
.github/workflows/backend-tests.yml
vendored
Normal file
41
.github/workflows/backend-tests.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: Backend Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- "release/*.*.*"
|
||||
|
||||
jobs:
|
||||
go-static-checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.19
|
||||
check-latest: true
|
||||
cache: true
|
||||
- name: Verify go.mod is tidy
|
||||
run: |
|
||||
go mod tidy -go=1.19
|
||||
git diff --exit-code
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
with:
|
||||
args: -v
|
||||
skip-cache: true
|
||||
|
||||
go-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.19
|
||||
check-latest: true
|
||||
cache: true
|
||||
- name: Run all tests
|
||||
run: go test -v ./... | tee test.log; exit ${PIPESTATUS[0]}
|
||||
- name: Pretty print tests running time
|
||||
run: grep --color=never -e '--- PASS:' -e '--- FAIL:' test.log | sed 's/[:()]//g' | awk '{print $2,$3,$4}' | sort -t' ' -nk3 -r | awk '{sum += $3; print $1,$2,$3,sum"s"}'
|
||||
@@ -1,12 +1,10 @@
|
||||
name: Test
|
||||
name: Frontend Test
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- "release/v*.*.*"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
- "release/*.*.*"
|
||||
|
||||
jobs:
|
||||
eslint-checks:
|
||||
@@ -53,36 +51,3 @@ jobs:
|
||||
- name: Run frontend build
|
||||
run: yarn build
|
||||
working-directory: web
|
||||
|
||||
go-static-checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.19
|
||||
check-latest: true
|
||||
cache: true
|
||||
- name: Verify go.mod is tidy
|
||||
run: |
|
||||
go mod tidy -go=1.19
|
||||
git diff --exit-code
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
with:
|
||||
args: -v
|
||||
skip-cache: true
|
||||
|
||||
go-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.19
|
||||
check-latest: true
|
||||
cache: true
|
||||
- name: Run all tests
|
||||
run: go test -v ./... | tee test.log; exit ${PIPESTATUS[0]}
|
||||
- name: Pretty print tests running time
|
||||
run: grep --color=never -e '--- PASS:' -e '--- FAIL:' test.log | sed 's/[:()]//g' | awk '{print $2,$3,$4}' | sort -t' ' -nk3 -r | awk '{sum += $3; print $1,$2,$3,sum"s"}'
|
||||
@@ -31,6 +31,8 @@
|
||||
docker run -d --name memos -p 5230:5230 -v ~/.memos/:/var/opt/memos neosmemo/memos:latest
|
||||
```
|
||||
|
||||
> The `~/.memos/` will be used as the data directory in your machine. And `/var/opt/memos` is the directory of the volume in docker and should not be modified.
|
||||
|
||||
If the `~/.memos/` does not have a `memos_prod.db` file, then memos will auto generate it. Memos will be running at [http://localhost:5230](http://localhost:5230).
|
||||
|
||||
### Docker Compose
|
||||
|
||||
137
api/activity.go
Normal file
137
api/activity.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package api
|
||||
|
||||
import "github.com/usememos/memos/server/profile"
|
||||
|
||||
// ActivityType is the type for an activity.
|
||||
type ActivityType string
|
||||
|
||||
const (
|
||||
// User related.
|
||||
|
||||
// ActivityUserCreate is the type for creating users.
|
||||
ActivityUserCreate ActivityType = "user.create"
|
||||
// ActivityUserUpdate is the type for updating users.
|
||||
ActivityUserUpdate ActivityType = "user.update"
|
||||
// ActivityUserDelete is the type for deleting users.
|
||||
ActivityUserDelete ActivityType = "user.delete"
|
||||
// ActivityUserAuthSignIn is the type for user signin.
|
||||
ActivityUserAuthSignIn ActivityType = "user.auth.signin"
|
||||
// ActivityUserAuthSignUp is the type for user signup.
|
||||
ActivityUserAuthSignUp ActivityType = "user.auth.signup"
|
||||
// ActivityUserSettingUpdate is the type for updating user settings.
|
||||
ActivityUserSettingUpdate ActivityType = "user.setting.update"
|
||||
|
||||
// Memo related.
|
||||
|
||||
// ActivityMemoCreate is the type for creating memos.
|
||||
ActivityMemoCreate ActivityType = "memo.create"
|
||||
// ActivityMemoUpdate is the type for updating memos.
|
||||
ActivityMemoUpdate ActivityType = "memo.update"
|
||||
// ActivityMemoDelete is the type for deleting memos.
|
||||
ActivityMemoDelete ActivityType = "memo.delete"
|
||||
|
||||
// Shortcut related.
|
||||
|
||||
// ActivityShortcutCreate is the type for creating shortcuts.
|
||||
ActivityShortcutCreate ActivityType = "shortcut.create"
|
||||
// ActivityShortcutUpdate is the type for updating shortcuts.
|
||||
ActivityShortcutUpdate ActivityType = "shortcut.update"
|
||||
// ActivityShortcutDelete is the type for deleting shortcuts.
|
||||
ActivityShortcutDelete ActivityType = "shortcut.delete"
|
||||
|
||||
// Resource related.
|
||||
|
||||
// ActivityResourceCreate is the type for creating resources.
|
||||
ActivityResourceCreate ActivityType = "resource.create"
|
||||
// ActivityResourceDelete is the type for deleting resources.
|
||||
ActivityResourceDelete ActivityType = "resource.delete"
|
||||
|
||||
// Tag related.
|
||||
|
||||
// ActivityTagCreate is the type for creating tags.
|
||||
ActivityTagCreate ActivityType = "tag.create"
|
||||
// ActivityTagDelete is the type for deleting tags.
|
||||
ActivityTagDelete ActivityType = "tag.delete"
|
||||
|
||||
// Server related.
|
||||
|
||||
// ActivityServerStart is the type for starting server.
|
||||
ActivityServerStart ActivityType = "server.start"
|
||||
)
|
||||
|
||||
// ActivityLevel is the level of activities.
|
||||
type ActivityLevel string
|
||||
|
||||
const (
|
||||
// ActivityInfo is the INFO level of activities.
|
||||
ActivityInfo ActivityLevel = "INFO"
|
||||
// ActivityWarn is the WARN level of activities.
|
||||
ActivityWarn ActivityLevel = "WARN"
|
||||
// ActivityError is the ERROR level of activities.
|
||||
ActivityError ActivityLevel = "ERROR"
|
||||
)
|
||||
|
||||
type ActivityUserCreatePayload struct {
|
||||
UserID int `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
Role Role `json:"role"`
|
||||
}
|
||||
|
||||
type ActivityUserAuthSignInPayload struct {
|
||||
UserID int `json:"userId"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
type ActivityUserAuthSignUpPayload struct {
|
||||
Username string `json:"username"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
type ActivityMemoCreatePayload struct {
|
||||
Content string `json:"content"`
|
||||
Visibility string `json:"visibility"`
|
||||
}
|
||||
|
||||
type ActivityShortcutCreatePayload struct {
|
||||
Title string `json:"title"`
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
|
||||
type ActivityResourceCreatePayload struct {
|
||||
Filename string `json:"filename"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
type ActivityTagCreatePayload struct {
|
||||
TagName string `json:"tagName"`
|
||||
}
|
||||
|
||||
type ActivityServerStartPayload struct {
|
||||
ServerID string `json:"serverId"`
|
||||
Profile *profile.Profile `json:"profile"`
|
||||
}
|
||||
|
||||
type Activity struct {
|
||||
ID int `json:"id"`
|
||||
|
||||
// Standard fields
|
||||
CreatorID int `json:"creatorId"`
|
||||
CreatedTs int64 `json:"createdTs"`
|
||||
|
||||
// Domain specific fields
|
||||
Type ActivityType `json:"type"`
|
||||
Level ActivityLevel `json:"level"`
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
|
||||
// ActivityCreate is the API message for creating an activity.
|
||||
type ActivityCreate struct {
|
||||
// Standard fields
|
||||
CreatorID int
|
||||
|
||||
// Domain specific fields
|
||||
Type ActivityType `json:"type"`
|
||||
Level ActivityLevel
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package api
|
||||
|
||||
// UnknownID is the ID for unknowns.
|
||||
const UnknownID = -1
|
||||
|
||||
// RowStatus is the status for a row.
|
||||
type RowStatus string
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package api
|
||||
|
||||
type Signin struct {
|
||||
type SignIn struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type Signup struct {
|
||||
type SignUp struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Role Role `json:"role"`
|
||||
|
||||
@@ -46,7 +46,7 @@ type Memo struct {
|
||||
|
||||
type MemoCreate struct {
|
||||
// Standard fields
|
||||
CreatorID int
|
||||
CreatorID int `json:"-"`
|
||||
|
||||
// Domain specific fields
|
||||
Visibility Visibility `json:"visibility"`
|
||||
@@ -73,11 +73,11 @@ type MemoPatch struct {
|
||||
}
|
||||
|
||||
type MemoFind struct {
|
||||
ID *int `json:"id"`
|
||||
ID *int
|
||||
|
||||
// Standard fields
|
||||
RowStatus *RowStatus `json:"rowStatus"`
|
||||
CreatorID *int `json:"creatorId"`
|
||||
RowStatus *RowStatus
|
||||
CreatorID *int
|
||||
|
||||
// Domain specific fields
|
||||
Pinned *bool
|
||||
|
||||
@@ -9,17 +9,17 @@ type MemoOrganizer struct {
|
||||
Pinned bool
|
||||
}
|
||||
|
||||
type MemoOrganizerUpsert struct {
|
||||
MemoID int `json:"-"`
|
||||
UserID int `json:"-"`
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
|
||||
type MemoOrganizerFind struct {
|
||||
MemoID int
|
||||
UserID int
|
||||
}
|
||||
|
||||
type MemoOrganizerUpsert struct {
|
||||
MemoID int
|
||||
UserID int
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
|
||||
type MemoOrganizerDelete struct {
|
||||
MemoID *int
|
||||
UserID *int
|
||||
|
||||
@@ -8,7 +8,7 @@ type MemoResource struct {
|
||||
}
|
||||
|
||||
type MemoResourceUpsert struct {
|
||||
MemoID int
|
||||
MemoID int `json:"-"`
|
||||
ResourceID int
|
||||
UpdatedTs *int64
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ type Resource struct {
|
||||
|
||||
type ResourceCreate struct {
|
||||
// Standard fields
|
||||
CreatorID int
|
||||
CreatorID int `json:"-"`
|
||||
|
||||
// Domain specific fields
|
||||
Filename string `json:"filename"`
|
||||
|
||||
@@ -16,7 +16,7 @@ type Shortcut struct {
|
||||
|
||||
type ShortcutCreate struct {
|
||||
// Standard fields
|
||||
CreatorID int
|
||||
CreatorID int `json:"-"`
|
||||
|
||||
// Domain specific fields
|
||||
Title string `json:"title"`
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/exp/slices"
|
||||
@@ -10,6 +11,10 @@ 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"
|
||||
// SystemSettingAdditionalStyleName is the key type of additional style.
|
||||
@@ -38,6 +43,10 @@ type CustomizedProfile struct {
|
||||
|
||||
func (key SystemSettingName) String() string {
|
||||
switch key {
|
||||
case SystemSettingServerID:
|
||||
return "serverId"
|
||||
case SystemSettingSecretSessionName:
|
||||
return "secretSessionName"
|
||||
case SystemSettingAllowSignUpName:
|
||||
return "allowSignUp"
|
||||
case SystemSettingAdditionalStyleName:
|
||||
@@ -56,7 +65,7 @@ var (
|
||||
|
||||
type SystemSetting struct {
|
||||
Name SystemSettingName
|
||||
// Value is a JSON string with basic value
|
||||
// Value is a JSON string with basic value.
|
||||
Value string
|
||||
Description string
|
||||
}
|
||||
@@ -68,7 +77,9 @@ type SystemSettingUpsert struct {
|
||||
}
|
||||
|
||||
func (upsert SystemSettingUpsert) Validate() error {
|
||||
if upsert.Name == SystemSettingAllowSignUpName {
|
||||
if upsert.Name == SystemSettingServerID {
|
||||
return errors.New("update server id is not allowed")
|
||||
} else if upsert.Name == SystemSettingAllowSignUpName {
|
||||
value := false
|
||||
err := json.Unmarshal([]byte(upsert.Value), &value)
|
||||
if err != nil {
|
||||
|
||||
@@ -7,7 +7,7 @@ type Tag struct {
|
||||
|
||||
type TagUpsert struct {
|
||||
Name string
|
||||
CreatorID int
|
||||
CreatorID int `json:"-"`
|
||||
}
|
||||
|
||||
type TagFind struct {
|
||||
|
||||
41
api/user.go
41
api/user.go
@@ -2,6 +2,8 @@ package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/usememos/memos/common"
|
||||
)
|
||||
|
||||
// Role is the type of a role.
|
||||
@@ -61,9 +63,23 @@ func (create UserCreate) Validate() error {
|
||||
if len(create.Username) < 4 {
|
||||
return fmt.Errorf("username is too short, minimum length is 4")
|
||||
}
|
||||
if len(create.Username) > 32 {
|
||||
return fmt.Errorf("username is too long, maximum length is 32")
|
||||
}
|
||||
if len(create.Password) < 4 {
|
||||
return fmt.Errorf("password is too short, minimum length is 4")
|
||||
}
|
||||
if len(create.Nickname) > 64 {
|
||||
return fmt.Errorf("nickname is too long, maximum length is 64")
|
||||
}
|
||||
if create.Email != "" {
|
||||
if len(create.Email) > 256 {
|
||||
return fmt.Errorf("email is too long, maximum length is 256")
|
||||
}
|
||||
if !common.ValidateEmail(create.Email) {
|
||||
return fmt.Errorf("invalid email format")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -85,6 +101,31 @@ type UserPatch struct {
|
||||
OpenID *string
|
||||
}
|
||||
|
||||
func (patch UserPatch) Validate() error {
|
||||
if patch.Username != nil && len(*patch.Username) < 4 {
|
||||
return fmt.Errorf("username is too short, minimum length is 4")
|
||||
}
|
||||
if patch.Username != nil && len(*patch.Username) > 32 {
|
||||
return fmt.Errorf("username is too long, maximum length is 32")
|
||||
}
|
||||
if patch.Password != nil && len(*patch.Password) < 4 {
|
||||
return fmt.Errorf("password is too short, minimum length is 4")
|
||||
}
|
||||
if patch.Nickname != nil && len(*patch.Nickname) > 64 {
|
||||
return fmt.Errorf("nickname is too long, maximum length is 64")
|
||||
}
|
||||
if patch.Email != nil {
|
||||
if len(*patch.Email) > 256 {
|
||||
return fmt.Errorf("email is too long, maximum length is 256")
|
||||
}
|
||||
if !common.ValidateEmail(*patch.Email) {
|
||||
return fmt.Errorf("invalid email format")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type UserFind struct {
|
||||
ID *int `json:"id"`
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ func (key UserSettingKey) String() string {
|
||||
}
|
||||
|
||||
var (
|
||||
UserSettingLocaleValue = []string{"en", "zh", "vi", "fr", "nl", "sv", "de", "es"}
|
||||
UserSettingLocaleValue = []string{"en", "zh", "vi", "fr", "nl", "sv", "de", "es", "uk", "ru", "it"}
|
||||
UserSettingAppearanceValue = []string{"system", "light", "dark"}
|
||||
UserSettingMemoVisibilityValue = []Visibility{Private, Protected, Public}
|
||||
UserSettingMemoDisplayTsOptionKeyValue = []string{"created_ts", "updated_ts"}
|
||||
@@ -50,7 +50,7 @@ type UserSetting struct {
|
||||
}
|
||||
|
||||
type UserSettingUpsert struct {
|
||||
UserID int
|
||||
UserID int `json:"-"`
|
||||
Key UserSettingKey `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
@@ -4,16 +4,13 @@ import (
|
||||
"os"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
"github.com/usememos/memos/server"
|
||||
"github.com/usememos/memos/server/profile"
|
||||
"github.com/usememos/memos/store"
|
||||
|
||||
DB "github.com/usememos/memos/store/db"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -27,38 +24,12 @@ const (
|
||||
`
|
||||
)
|
||||
|
||||
func run(profile *profile.Profile) error {
|
||||
func run() error {
|
||||
ctx := context.Background()
|
||||
|
||||
db := DB.NewDB(profile)
|
||||
if err := db.Open(ctx); err != nil {
|
||||
return fmt.Errorf("cannot open db: %w", err)
|
||||
}
|
||||
|
||||
serverInstance := server.NewServer(profile)
|
||||
storeInstance := store.New(db.Db, profile)
|
||||
serverInstance.Store = storeInstance
|
||||
|
||||
metricCollector := server.NewMetricCollector(profile, storeInstance)
|
||||
// Disable metrics collector.
|
||||
metricCollector.Enabled = false
|
||||
serverInstance.Collector = &metricCollector
|
||||
|
||||
println(greetingBanner)
|
||||
fmt.Printf("Version %s has started at :%d\n", profile.Version, profile.Port)
|
||||
metricCollector.Collect(ctx, &metric.Metric{
|
||||
Name: "service started",
|
||||
})
|
||||
|
||||
return serverInstance.Run()
|
||||
}
|
||||
|
||||
func execute() error {
|
||||
profile, err := profile.GetProfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
println("---")
|
||||
println("profile")
|
||||
println("mode:", profile.Mode)
|
||||
@@ -67,16 +38,19 @@ func execute() error {
|
||||
println("version:", profile.Version)
|
||||
println("---")
|
||||
|
||||
if err := run(profile); err != nil {
|
||||
fmt.Printf("error: %+v\n", err)
|
||||
return err
|
||||
serverInstance, err := server.NewServer(ctx, profile)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to start server")
|
||||
}
|
||||
|
||||
return nil
|
||||
println(greetingBanner)
|
||||
fmt.Printf("Version %s has started at :%d\n", profile.Version, profile.Port)
|
||||
return serverInstance.Run(ctx)
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := execute(); err != nil {
|
||||
if err := run(); err != nil {
|
||||
fmt.Printf("error: %+v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,4 +37,4 @@ Memos is built with a curated tech stack. It is optimized for developer experien
|
||||
cd web && yarn && yarn dev
|
||||
```
|
||||
|
||||
Memos should now be running at [http://localhost:3000](http://localhost:3000) and change either frontend or backend code would trigger live reload.
|
||||
Memos should now be running at [http://localhost:3001](http://localhost:3001) and change either frontend or backend code would trigger live reload.
|
||||
|
||||
3
go.mod
3
go.mod
@@ -16,7 +16,7 @@ require github.com/labstack/echo/v4 v4.9.0
|
||||
require (
|
||||
github.com/VictoriaMetrics/fastcache v1.10.0
|
||||
github.com/gorilla/feeds v1.1.1
|
||||
github.com/gorilla/securecookie v1.1.1
|
||||
github.com/gorilla/securecookie v1.1.1 // indirect
|
||||
github.com/gorilla/sessions v1.2.1
|
||||
github.com/labstack/echo-contrib v0.13.0
|
||||
github.com/stretchr/testify v1.8.1
|
||||
@@ -46,6 +46,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/segmentio/analytics-go v3.1.0+incompatible
|
||||
golang.org/x/exp v0.0.0-20221217163422-3c43f8badb15
|
||||
)
|
||||
|
||||
2
go.sum
2
go.sum
@@ -45,6 +45,8 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k
|
||||
github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA=
|
||||
github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
package metric
|
||||
|
||||
// Metric is the API message for metric.
|
||||
type Metric struct {
|
||||
ID string
|
||||
Name string
|
||||
Labels map[string]string
|
||||
}
|
||||
|
||||
// Collector is the interface definition for metric collector.
|
||||
type Collector interface {
|
||||
Identify(id string) error
|
||||
Collect(metric *Metric) error
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package metric
|
||||
|
||||
// Metric is the API message for metric.
|
||||
type Metric struct {
|
||||
Name string
|
||||
Labels map[string]string
|
||||
}
|
||||
@@ -3,15 +3,10 @@ package segment
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/segmentio/analytics-go"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
)
|
||||
|
||||
var (
|
||||
sessionUUID = uuid.NewString()
|
||||
)
|
||||
|
||||
// collector is the metrics collector https://segment.com/.
|
||||
type collector struct {
|
||||
client analytics.Client
|
||||
@@ -26,6 +21,14 @@ func NewCollector(key string) metric.Collector {
|
||||
}
|
||||
}
|
||||
|
||||
// Identify will identify the server caller.
|
||||
func (c *collector) Identify(id string) error {
|
||||
return c.client.Enqueue(analytics.Identify{
|
||||
UserId: id,
|
||||
Timestamp: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
// Collect will exec all the segment collector.
|
||||
func (c *collector) Collect(metric *metric.Metric) error {
|
||||
properties := analytics.NewProperties()
|
||||
@@ -34,9 +37,9 @@ func (c *collector) Collect(metric *metric.Metric) error {
|
||||
}
|
||||
|
||||
return c.client.Enqueue(analytics.Track{
|
||||
Event: string(metric.Name),
|
||||
AnonymousId: sessionUUID,
|
||||
Properties: properties,
|
||||
Timestamp: time.Now().UTC(),
|
||||
UserId: metric.ID,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Event: metric.Name,
|
||||
Properties: properties,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
var (
|
||||
userIDContextKey = "user-id"
|
||||
sessionName = "memos_session"
|
||||
)
|
||||
|
||||
func getUserIDContextKey() string {
|
||||
@@ -22,12 +23,12 @@ func getUserIDContextKey() string {
|
||||
}
|
||||
|
||||
func setUserSession(ctx echo.Context, user *api.User) error {
|
||||
sess, _ := session.Get("memos_session", ctx)
|
||||
sess, _ := session.Get(sessionName, ctx)
|
||||
sess.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: 3600 * 24 * 30,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
}
|
||||
sess.Values[userIDContextKey] = user.ID
|
||||
err := sess.Save(ctx.Request(), ctx.Response())
|
||||
@@ -38,7 +39,7 @@ func setUserSession(ctx echo.Context, user *api.User) error {
|
||||
}
|
||||
|
||||
func removeUserSession(ctx echo.Context) error {
|
||||
sess, _ := session.Get("memos_session", ctx)
|
||||
sess, _ := session.Get(sessionName, ctx)
|
||||
sess.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: 0,
|
||||
@@ -57,61 +58,33 @@ func aclMiddleware(s *Server, next echo.HandlerFunc) echo.HandlerFunc {
|
||||
ctx := c.Request().Context()
|
||||
path := c.Path()
|
||||
|
||||
// Skip auth.
|
||||
if common.HasPrefixes(path, "/api/auth") {
|
||||
if s.DefaultAuthSkipper(c) {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
{
|
||||
// If there is openId in query string and related user is found, then skip auth.
|
||||
openID := c.QueryParam("openId")
|
||||
if openID != "" {
|
||||
userFind := &api.UserFind{
|
||||
OpenID: &openID,
|
||||
}
|
||||
user, err := s.Store.FindUser(ctx, userFind)
|
||||
if err != nil && common.ErrorCode(err) != common.NotFound {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by open_id").SetInternal(err)
|
||||
}
|
||||
if user != nil {
|
||||
// Stores userID into context.
|
||||
c.Set(getUserIDContextKey(), user.ID)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
sess, _ := session.Get("memos_session", 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/user/:id", "/api/memo/all", "/api/memo/:memoId", "/api/memo/amount") && c.Request().Method == http.MethodGet {
|
||||
if common.HasPrefixes(path, "/api/ping", "/api/status", "/api/user/:id", "/api/memo") && c.Request().Method == http.MethodGet {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
if common.HasPrefixes(path, "/api/memo", "/api/tag", "/api/shortcut") && c.Request().Method == http.MethodGet {
|
||||
if _, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
userID := c.Get(getUserIDContextKey())
|
||||
if userID == nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
@@ -16,7 +17,7 @@ import (
|
||||
func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
g.POST("/auth/signin", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
signin := &api.Signin{}
|
||||
signin := &api.SignIn{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signin request").SetInternal(err)
|
||||
}
|
||||
@@ -43,9 +44,9 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
if err = setUserSession(c, user); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set signin session").SetInternal(err)
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: "user signed in",
|
||||
})
|
||||
if err := s.createUserAuthSignInActivity(c, user); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
|
||||
@@ -54,23 +55,9 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
return nil
|
||||
})
|
||||
|
||||
g.POST("/auth/logout", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
err := removeUserSession(c)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set logout session").SetInternal(err)
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: "user logout",
|
||||
})
|
||||
|
||||
c.Response().WriteHeader(http.StatusOK)
|
||||
return nil
|
||||
})
|
||||
|
||||
g.POST("/auth/signup", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
signup := &api.Signup{}
|
||||
signup := &api.SignUp{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(signup); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signup request").SetInternal(err)
|
||||
}
|
||||
@@ -84,7 +71,7 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find host user").SetInternal(err)
|
||||
}
|
||||
if signup.Role == api.Host && hostUser != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Site Host existed, please contact the site host to signin account firstly.").SetInternal(err)
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Site Host existed, please contact the site host to signin account firstly").SetInternal(err)
|
||||
}
|
||||
|
||||
systemSettingAllowSignUpName := api.SystemSettingAllowSignUpName
|
||||
@@ -103,7 +90,7 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
}
|
||||
}
|
||||
if !allowSignUpSettingValue && hostUser != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Site Host existed, please contact the site host to signin account firstly.").SetInternal(err)
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Site Host existed, please contact the site host to signin account firstly").SetInternal(err)
|
||||
}
|
||||
|
||||
userCreate := &api.UserCreate{
|
||||
@@ -114,7 +101,7 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
OpenID: common.GenUUID(),
|
||||
}
|
||||
if err := userCreate.Validate(); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format.").SetInternal(err)
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format").SetInternal(err)
|
||||
}
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(signup.Password), bcrypt.DefaultCost)
|
||||
@@ -128,9 +115,9 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: "user signed up",
|
||||
})
|
||||
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 {
|
||||
@@ -143,4 +130,63 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) createUserAuthSignInActivity(c echo.Context, user *api.User) error {
|
||||
ctx := c.Request().Context()
|
||||
payload := api.ActivityUserAuthSignInPayload{
|
||||
UserID: user.ID,
|
||||
IP: echo.ExtractIPFromRealIPHeader()(c.Request()),
|
||||
}
|
||||
payloadStr, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to marshal activity payload")
|
||||
}
|
||||
activity, err := s.Store.CreateActivity(ctx, &api.ActivityCreate{
|
||||
CreatorID: user.ID,
|
||||
Type: api.ActivityUserAuthSignIn,
|
||||
Level: api.ActivityInfo,
|
||||
Payload: string(payloadStr),
|
||||
})
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: string(activity.Type),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) createUserAuthSignUpActivity(c echo.Context, user *api.User) error {
|
||||
ctx := c.Request().Context()
|
||||
payload := api.ActivityUserAuthSignUpPayload{
|
||||
Username: user.Username,
|
||||
IP: echo.ExtractIPFromRealIPHeader()(c.Request()),
|
||||
}
|
||||
payloadStr, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to marshal activity payload")
|
||||
}
|
||||
activity, err := s.Store.CreateActivity(ctx, &api.ActivityCreate{
|
||||
CreatorID: user.ID,
|
||||
Type: api.ActivityUserAuthSignUp,
|
||||
Level: api.ActivityInfo,
|
||||
Payload: string(payloadStr),
|
||||
})
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: string(activity.Type),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,11 +1,52 @@
|
||||
package server
|
||||
|
||||
func composeResponse(data interface{}) interface{} {
|
||||
type R struct {
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
return R{
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
)
|
||||
|
||||
type response struct {
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
func composeResponse(data interface{}) response {
|
||||
return response{
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultGetRequestSkipper(c echo.Context) bool {
|
||||
return c.Request().Method == http.MethodGet
|
||||
}
|
||||
|
||||
func (server *Server) DefaultAuthSkipper(c echo.Context) bool {
|
||||
ctx := c.Request().Context()
|
||||
path := c.Path()
|
||||
|
||||
// Skip auth.
|
||||
if common.HasPrefixes(path, "/api/auth") {
|
||||
return true
|
||||
}
|
||||
|
||||
// If there is openId in query string and related user is found, then skip auth.
|
||||
openID := c.QueryParam("openId")
|
||||
if openID != "" {
|
||||
userFind := &api.UserFind{
|
||||
OpenID: &openID,
|
||||
}
|
||||
user, err := server.Store.FindUser(ctx, userFind)
|
||||
if err != nil && common.ErrorCode(err) != common.NotFound {
|
||||
return false
|
||||
}
|
||||
if user != nil {
|
||||
// Stores userID into context.
|
||||
c.Set(getUserIDContextKey(), user.ID)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -8,12 +8,10 @@ import (
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
getter "github.com/usememos/memos/plugin/http_getter"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
)
|
||||
|
||||
func (s *Server) registerGetterPublicRoutes(g *echo.Group) {
|
||||
func registerGetterPublicRoutes(g *echo.Group) {
|
||||
g.GET("/get/httpmeta", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
urlStr := c.QueryParam("url")
|
||||
if urlStr == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Missing website url")
|
||||
@@ -26,12 +24,6 @@ func (s *Server) registerGetterPublicRoutes(g *echo.Group) {
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusNotAcceptable, fmt.Sprintf("Failed to get website meta with url: %s", urlStr)).SetInternal(err)
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: "getter used",
|
||||
Labels: map[string]string{
|
||||
"type": "httpmeta",
|
||||
},
|
||||
})
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(htmlMeta)); err != nil {
|
||||
@@ -41,7 +33,6 @@ func (s *Server) registerGetterPublicRoutes(g *echo.Group) {
|
||||
})
|
||||
|
||||
g.GET("/get/image", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
urlStr := c.QueryParam("url")
|
||||
if urlStr == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Missing image url")
|
||||
@@ -54,12 +45,6 @@ func (s *Server) registerGetterPublicRoutes(g *echo.Group) {
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to get image url: %s", urlStr)).SetInternal(err)
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: "getter used",
|
||||
Labels: map[string]string{
|
||||
"type": "image",
|
||||
},
|
||||
})
|
||||
|
||||
c.Response().Writer.WriteHeader(http.StatusOK)
|
||||
c.Response().Writer.Header().Set("Content-Type", image.Mediatype)
|
||||
|
||||
402
server/memo.go
402
server/memo.go
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
@@ -24,9 +25,7 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
|
||||
memoCreate := &api.MemoCreate{
|
||||
CreatorID: userID,
|
||||
}
|
||||
memoCreate := &api.MemoCreate{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(memoCreate); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo request").SetInternal(err)
|
||||
}
|
||||
@@ -57,13 +56,14 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
}
|
||||
}
|
||||
|
||||
memoCreate.CreatorID = userID
|
||||
memo, err := s.Store.CreateMemo(ctx, memoCreate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create memo").SetInternal(err)
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: "memo created",
|
||||
})
|
||||
if err := s.createMemoCreateActivity(c, memo); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
|
||||
for _, resourceID := range memoCreate.ResourceIDList {
|
||||
if _, err := s.Store.UpsertMemoResource(ctx, &api.MemoResourceUpsert{
|
||||
@@ -98,13 +98,15 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
memoFind := &api.MemoFind{
|
||||
ID: &memoID,
|
||||
CreatorID: &userID,
|
||||
}
|
||||
if _, err := s.Store.FindMemo(ctx, memoFind); err != nil {
|
||||
memo, err := s.Store.FindMemo(ctx, &api.MemoFind{
|
||||
ID: &memoID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err)
|
||||
}
|
||||
if memo.CreatorID != userID {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
currentTs := time.Now().Unix()
|
||||
memoPatch := &api.MemoPatch{
|
||||
@@ -115,7 +117,7 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch memo request").SetInternal(err)
|
||||
}
|
||||
|
||||
memo, err := s.Store.PatchMemo(ctx, memoPatch)
|
||||
memo, err = s.Store.PatchMemo(ctx, memoPatch)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch memo").SetInternal(err)
|
||||
}
|
||||
@@ -173,7 +175,7 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
}
|
||||
tag := c.QueryParam("tag")
|
||||
if tag != "" {
|
||||
contentSearch := "#" + tag + " "
|
||||
contentSearch := "#" + tag
|
||||
memoFind.ContentSearch = &contentSearch
|
||||
}
|
||||
visibilityListStr := c.QueryParam("visibility")
|
||||
@@ -229,6 +231,148 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
return nil
|
||||
})
|
||||
|
||||
g.GET("/memo/:memoId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
memoID, err := strconv.Atoi(c.Param("memoId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
memoFind := &api.MemoFind{
|
||||
ID: &memoID,
|
||||
}
|
||||
memo, err := s.Store.FindMemo(ctx, memoFind)
|
||||
if err != nil {
|
||||
if common.ErrorCode(err) == common.NotFound {
|
||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo ID not found: %d", memoID)).SetInternal(err)
|
||||
}
|
||||
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find memo by ID: %v", memoID)).SetInternal(err)
|
||||
}
|
||||
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if memo.Visibility == api.Private {
|
||||
if !ok || memo.CreatorID != userID {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "this memo is private only")
|
||||
}
|
||||
} else if memo.Visibility == api.Protected {
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "this memo is protected, missing user in session")
|
||||
}
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(memo)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo response").SetInternal(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
g.POST("/memo/:memoId/organizer", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
memoID, err := strconv.Atoi(c.Param("memoId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
memoOrganizerUpsert := &api.MemoOrganizerUpsert{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(memoOrganizerUpsert); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo organizer request").SetInternal(err)
|
||||
}
|
||||
memoOrganizerUpsert.MemoID = memoID
|
||||
memoOrganizerUpsert.UserID = userID
|
||||
|
||||
err = s.Store.UpsertMemoOrganizer(ctx, memoOrganizerUpsert)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo organizer").SetInternal(err)
|
||||
}
|
||||
|
||||
memo, err := s.Store.FindMemo(ctx, &api.MemoFind{
|
||||
ID: &memoID,
|
||||
})
|
||||
if err != nil {
|
||||
if common.ErrorCode(err) == common.NotFound {
|
||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo ID not found: %d", memoID)).SetInternal(err)
|
||||
}
|
||||
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find memo by ID: %v", memoID)).SetInternal(err)
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(memo)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo response").SetInternal(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
g.POST("/memo/:memoId/resource", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
memoID, err := strconv.Atoi(c.Param("memoId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
memoResourceUpsert := &api.MemoResourceUpsert{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(memoResourceUpsert); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo resource request").SetInternal(err)
|
||||
}
|
||||
resourceFind := &api.ResourceFind{
|
||||
ID: &memoResourceUpsert.ResourceID,
|
||||
}
|
||||
resource, err := s.Store.FindResource(ctx, resourceFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource").SetInternal(err)
|
||||
}
|
||||
if resource == nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Resource not found").SetInternal(err)
|
||||
} else if resource.CreatorID != userID {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized to bind this resource").SetInternal(err)
|
||||
}
|
||||
|
||||
memoResourceUpsert.MemoID = memoID
|
||||
currentTs := time.Now().Unix()
|
||||
memoResourceUpsert.UpdatedTs = ¤tTs
|
||||
if _, err := s.Store.UpsertMemoResource(ctx, memoResourceUpsert); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err)
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(resource)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode resource response").SetInternal(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
g.GET("/memo/:memoId/resource", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
memoID, err := strconv.Atoi(c.Param("memoId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
resourceFind := &api.ResourceFind{
|
||||
MemoID: &memoID,
|
||||
}
|
||||
resourceList, err := s.Store.FindResourceList(ctx, resourceFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(resourceList)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode resource list response").SetInternal(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
g.GET("/memo/amount", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
normalRowStatus := api.Normal
|
||||
@@ -352,183 +496,26 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
return nil
|
||||
})
|
||||
|
||||
g.GET("/memo/:memoId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
memoID, err := strconv.Atoi(c.Param("memoId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
memoFind := &api.MemoFind{
|
||||
ID: &memoID,
|
||||
}
|
||||
memo, err := s.Store.FindMemo(ctx, memoFind)
|
||||
if err != nil {
|
||||
if common.ErrorCode(err) == common.NotFound {
|
||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo ID not found: %d", memoID)).SetInternal(err)
|
||||
}
|
||||
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find memo by ID: %v", memoID)).SetInternal(err)
|
||||
}
|
||||
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if memo.Visibility == api.Private {
|
||||
if !ok || memo.CreatorID != userID {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "this memo is private only")
|
||||
}
|
||||
} else if memo.Visibility == api.Protected {
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "this memo is protected, missing user in session")
|
||||
}
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(memo)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo response").SetInternal(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
g.POST("/memo/:memoId/organizer", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
memoID, err := strconv.Atoi(c.Param("memoId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
memoOrganizerUpsert := &api.MemoOrganizerUpsert{
|
||||
MemoID: memoID,
|
||||
UserID: userID,
|
||||
}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(memoOrganizerUpsert); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo organizer request").SetInternal(err)
|
||||
}
|
||||
|
||||
err = s.Store.UpsertMemoOrganizer(ctx, memoOrganizerUpsert)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo organizer").SetInternal(err)
|
||||
}
|
||||
|
||||
memo, err := s.Store.FindMemo(ctx, &api.MemoFind{
|
||||
ID: &memoID,
|
||||
})
|
||||
if err != nil {
|
||||
if common.ErrorCode(err) == common.NotFound {
|
||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo ID not found: %d", memoID)).SetInternal(err)
|
||||
}
|
||||
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find memo by ID: %v", memoID)).SetInternal(err)
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(memo)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo response").SetInternal(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
g.POST("/memo/:memoId/resource", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
memoID, err := strconv.Atoi(c.Param("memoId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
currentTs := time.Now().Unix()
|
||||
memoResourceUpsert := &api.MemoResourceUpsert{
|
||||
MemoID: memoID,
|
||||
UpdatedTs: ¤tTs,
|
||||
}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(memoResourceUpsert); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo resource request").SetInternal(err)
|
||||
}
|
||||
|
||||
if _, err := s.Store.UpsertMemoResource(ctx, memoResourceUpsert); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err)
|
||||
}
|
||||
|
||||
resourceFind := &api.ResourceFind{
|
||||
ID: &memoResourceUpsert.ResourceID,
|
||||
}
|
||||
resource, err := s.Store.FindResource(ctx, resourceFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource").SetInternal(err)
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(resource)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode resource response").SetInternal(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
g.GET("/memo/:memoId/resource", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
memoID, err := strconv.Atoi(c.Param("memoId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
resourceFind := &api.ResourceFind{
|
||||
MemoID: &memoID,
|
||||
}
|
||||
resourceList, err := s.Store.FindResourceList(ctx, resourceFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(resourceList)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode resource list response").SetInternal(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
g.DELETE("/memo/:memoId/resource/:resourceId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
memoID, err := strconv.Atoi(c.Param("memoId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Memo ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||
}
|
||||
resourceID, err := strconv.Atoi(c.Param("resourceId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Resource ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
memoResourceDelete := &api.MemoResourceDelete{
|
||||
MemoID: &memoID,
|
||||
ResourceID: &resourceID,
|
||||
}
|
||||
if err := s.Store.DeleteMemoResource(ctx, memoResourceDelete); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
|
||||
g.DELETE("/memo/:memoId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
|
||||
memoID, err := strconv.Atoi(c.Param("memoId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
memoFind := &api.MemoFind{
|
||||
ID: &memoID,
|
||||
CreatorID: &userID,
|
||||
}
|
||||
if _, err := s.Store.FindMemo(ctx, memoFind); err != nil {
|
||||
memo, err := s.Store.FindMemo(ctx, &api.MemoFind{
|
||||
ID: &memoID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err)
|
||||
}
|
||||
if memo.CreatorID != userID {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
memoDelete := &api.MemoDelete{
|
||||
ID: memoID,
|
||||
@@ -542,4 +529,65 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
|
||||
g.DELETE("/memo/:memoId/resource/:resourceId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
memoID, err := strconv.Atoi(c.Param("memoId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Memo ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||
}
|
||||
resourceID, err := strconv.Atoi(c.Param("resourceId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Resource ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
memo, err := s.Store.FindMemo(ctx, &api.MemoFind{
|
||||
ID: &memoID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err)
|
||||
}
|
||||
if memo.CreatorID != userID {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
memoResourceDelete := &api.MemoResourceDelete{
|
||||
MemoID: &memoID,
|
||||
ResourceID: &resourceID,
|
||||
}
|
||||
if err := s.Store.DeleteMemoResource(ctx, memoResourceDelete); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) createMemoCreateActivity(c echo.Context, memo *api.Memo) error {
|
||||
ctx := c.Request().Context()
|
||||
payload := api.ActivityMemoCreatePayload{
|
||||
Content: memo.Content,
|
||||
Visibility: memo.Visibility.String(),
|
||||
}
|
||||
payloadStr, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to marshal activity payload")
|
||||
}
|
||||
activity, err := s.Store.CreateActivity(ctx, &api.ActivityCreate{
|
||||
CreatorID: memo.CreatorID,
|
||||
Type: api.ActivityMemoCreate,
|
||||
Level: api.ActivityInfo,
|
||||
Payload: string(payloadStr),
|
||||
})
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: string(activity.Type),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -8,29 +8,39 @@ import (
|
||||
"github.com/usememos/memos/plugin/metrics/segment"
|
||||
"github.com/usememos/memos/server/profile"
|
||||
"github.com/usememos/memos/server/version"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
// MetricCollector is the metric collector.
|
||||
type MetricCollector struct {
|
||||
Collector metric.Collector
|
||||
collector metric.Collector
|
||||
ID string
|
||||
Enabled bool
|
||||
Profile *profile.Profile
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
const (
|
||||
segmentMetricWriteKey = "fTn5BumOkj352n3TGw9tu0ARH2dOkcoQ"
|
||||
segmentMetricWriteKey = "NbPruMMmfqfD2AMCw3pkxZTsszVS3hKq"
|
||||
)
|
||||
|
||||
func NewMetricCollector(profile *profile.Profile, store *store.Store) MetricCollector {
|
||||
func (s *Server) registerMetricCollector() {
|
||||
c := segment.NewCollector(segmentMetricWriteKey)
|
||||
|
||||
return MetricCollector{
|
||||
Collector: c,
|
||||
mc := &MetricCollector{
|
||||
collector: c,
|
||||
ID: s.ID,
|
||||
Enabled: true,
|
||||
Profile: profile,
|
||||
Store: store,
|
||||
Profile: s.Profile,
|
||||
}
|
||||
s.Collector = mc
|
||||
}
|
||||
|
||||
func (mc *MetricCollector) Identify(_ context.Context) {
|
||||
if !mc.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
err := mc.collector.Identify(mc.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to request segment, error: %+v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,16 +49,13 @@ func (mc *MetricCollector) Collect(_ context.Context, metric *metric.Metric) {
|
||||
return
|
||||
}
|
||||
|
||||
if mc.Profile.Mode == "dev" {
|
||||
return
|
||||
}
|
||||
|
||||
if metric.Labels == nil {
|
||||
metric.Labels = map[string]string{}
|
||||
}
|
||||
metric.Labels["mode"] = mc.Profile.Mode
|
||||
metric.Labels["version"] = version.GetCurrentVersion(mc.Profile.Mode)
|
||||
|
||||
err := mc.Collector.Collect(metric)
|
||||
metric.ID = mc.ID
|
||||
err := mc.collector.Collect(metric)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to request segment, error: %+v\n", err)
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ func checkDSN(dataDir string) (string, error) {
|
||||
func GetProfile() (*Profile, error) {
|
||||
profile := Profile{}
|
||||
flag.StringVar(&profile.Mode, "mode", "dev", "mode of server")
|
||||
flag.IntVar(&profile.Port, "port", 8080, "port of server")
|
||||
flag.IntVar(&profile.Port, "port", 8081, "port of server")
|
||||
flag.StringVar(&profile.Data, "data", "", "data directory")
|
||||
flag.Parse()
|
||||
|
||||
|
||||
@@ -7,8 +7,10 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
@@ -56,20 +58,19 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
}
|
||||
|
||||
resourceCreate := &api.ResourceCreate{
|
||||
CreatorID: userID,
|
||||
Filename: filename,
|
||||
Type: filetype,
|
||||
Size: size,
|
||||
Blob: fileBytes,
|
||||
CreatorID: userID,
|
||||
}
|
||||
|
||||
resource, err := s.Store.CreateResource(ctx, resourceCreate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err)
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: "resource created",
|
||||
})
|
||||
if err := s.createResourceCreateActivity(c, resource); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(resource)); err != nil {
|
||||
@@ -158,6 +159,7 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
|
||||
c.Response().Writer.WriteHeader(http.StatusOK)
|
||||
c.Response().Writer.Header().Set("Content-Type", resource.Type)
|
||||
c.Response().Writer.Header().Set(echo.HeaderContentSecurityPolicy, "default-src 'self'")
|
||||
if _, err := c.Response().Writer.Write(resource.Blob); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to write resource blob").SetInternal(err)
|
||||
}
|
||||
@@ -177,23 +179,26 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
}
|
||||
|
||||
resourceFind := &api.ResourceFind{
|
||||
ID: &resourceID,
|
||||
CreatorID: &userID,
|
||||
ID: &resourceID,
|
||||
}
|
||||
if _, err := s.Store.FindResource(ctx, resourceFind); err != nil {
|
||||
resource, err := s.Store.FindResource(ctx, resourceFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find resource").SetInternal(err)
|
||||
}
|
||||
if resource.CreatorID != userID {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
currentTs := time.Now().Unix()
|
||||
resourcePatch := &api.ResourcePatch{
|
||||
ID: resourceID,
|
||||
UpdatedTs: ¤tTs,
|
||||
}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(resourcePatch); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch resource request").SetInternal(err)
|
||||
}
|
||||
|
||||
resource, err := s.Store.PatchResource(ctx, resourcePatch)
|
||||
resource.ID = resourceID
|
||||
resource, err = s.Store.PatchResource(ctx, resourcePatch)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch resource").SetInternal(err)
|
||||
}
|
||||
@@ -224,8 +229,8 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find resource").SetInternal(err)
|
||||
}
|
||||
if resource == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "Not find resource").SetInternal(err)
|
||||
if resource.CreatorID != userID {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
resourceDelete := &api.ResourceDelete{
|
||||
@@ -262,7 +267,11 @@ func (s *Server) registerResourcePublicRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to fetch resource ID: %v", resourceID)).SetInternal(err)
|
||||
}
|
||||
|
||||
c.Response().Writer.Header().Set("Content-Type", resource.Type)
|
||||
if strings.HasPrefix(resource.Type, "text") || strings.HasPrefix(resource.Type, "application") {
|
||||
c.Response().Writer.Header().Set("Content-Type", echo.MIMETextPlain)
|
||||
} else {
|
||||
c.Response().Writer.Header().Set("Content-Type", resource.Type)
|
||||
}
|
||||
c.Response().Writer.WriteHeader(http.StatusOK)
|
||||
c.Response().Writer.Header().Set(echo.HeaderCacheControl, "max-age=31536000, immutable")
|
||||
c.Response().Writer.Header().Set(echo.HeaderContentSecurityPolicy, "default-src 'self'")
|
||||
@@ -272,3 +281,29 @@ func (s *Server) registerResourcePublicRoutes(g *echo.Group) {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) createResourceCreateActivity(c echo.Context, resource *api.Resource) error {
|
||||
ctx := c.Request().Context()
|
||||
payload := api.ActivityResourceCreatePayload{
|
||||
Filename: resource.Filename,
|
||||
Type: resource.Type,
|
||||
Size: resource.Size,
|
||||
}
|
||||
payloadStr, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to marshal activity payload")
|
||||
}
|
||||
activity, err := s.Store.CreateActivity(ctx, &api.ActivityCreate{
|
||||
CreatorID: resource.CreatorID,
|
||||
Type: api.ActivityResourceCreate,
|
||||
Level: api.ActivityInfo,
|
||||
Payload: string(payloadStr),
|
||||
})
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: string(activity.Type),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
110
server/server.go
110
server/server.go
@@ -1,13 +1,18 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/api"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
"github.com/usememos/memos/server/profile"
|
||||
"github.com/usememos/memos/store"
|
||||
"github.com/usememos/memos/store/db"
|
||||
|
||||
"github.com/gorilla/securecookie"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/labstack/echo-contrib/session"
|
||||
"github.com/labstack/echo/v4"
|
||||
@@ -17,46 +22,81 @@ import (
|
||||
type Server struct {
|
||||
e *echo.Echo
|
||||
|
||||
ID string
|
||||
Profile *profile.Profile
|
||||
Store *store.Store
|
||||
Collector *MetricCollector
|
||||
|
||||
Profile *profile.Profile
|
||||
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
func NewServer(profile *profile.Profile) *Server {
|
||||
func NewServer(ctx context.Context, profile *profile.Profile) (*Server, error) {
|
||||
e := echo.New()
|
||||
e.Debug = true
|
||||
e.HideBanner = true
|
||||
e.HidePort = true
|
||||
|
||||
s := &Server{
|
||||
e: e,
|
||||
Profile: profile,
|
||||
}
|
||||
|
||||
db := db.NewDB(profile)
|
||||
if err := db.Open(ctx); err != nil {
|
||||
return nil, errors.Wrap(err, "cannot open db")
|
||||
}
|
||||
|
||||
storeInstance := store.New(db.DBInstance, profile)
|
||||
s.Store = storeInstance
|
||||
|
||||
e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
|
||||
Format: `{"time":"${time_rfc3339}",` +
|
||||
`"method":"${method}","uri":"${uri}",` +
|
||||
`"status":${status},"error":"${error}"}` + "\n",
|
||||
}))
|
||||
|
||||
e.Use(middleware.Gzip())
|
||||
|
||||
e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
|
||||
Skipper: func(c echo.Context) bool {
|
||||
return s.DefaultAuthSkipper(c)
|
||||
},
|
||||
TokenLookup: "cookie:_csrf",
|
||||
}))
|
||||
|
||||
e.Use(middleware.CORS())
|
||||
|
||||
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
|
||||
Skipper: DefaultGetRequestSkipper,
|
||||
XSSProtection: "1; mode=block",
|
||||
ContentTypeNosniff: "nosniff",
|
||||
XFrameOptions: "SAMEORIGIN",
|
||||
HSTSPreloadEnabled: false,
|
||||
}))
|
||||
|
||||
e.Use(middleware.TimeoutWithConfig(middleware.TimeoutConfig{
|
||||
Skipper: middleware.DefaultSkipper,
|
||||
ErrorMessage: "Request timeout",
|
||||
Timeout: 30 * time.Second,
|
||||
}))
|
||||
|
||||
serverID, err := s.getSystemServerID(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.ID = serverID
|
||||
|
||||
secretSessionName := "usememos"
|
||||
if profile.Mode == "prod" {
|
||||
secretSessionName, err = s.getSystemSecretSessionName(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
e.Use(session.Middleware(sessions.NewCookieStore([]byte(secretSessionName))))
|
||||
|
||||
embedFrontend(e)
|
||||
|
||||
// In dev mode, set the const secret key to make signin session persistence.
|
||||
secret := []byte("usememos")
|
||||
if profile.Mode == "prod" {
|
||||
secret = securecookie.GenerateRandomKey(16)
|
||||
}
|
||||
e.Use(session.Middleware(sessions.NewCookieStore(secret)))
|
||||
|
||||
s := &Server{
|
||||
e: e,
|
||||
Profile: profile,
|
||||
}
|
||||
// Register MetricCollector to server.
|
||||
s.registerMetricCollector()
|
||||
|
||||
rootGroup := e.Group("")
|
||||
s.registerRSSRoutes(rootGroup)
|
||||
@@ -66,7 +106,7 @@ func NewServer(profile *profile.Profile) *Server {
|
||||
|
||||
publicGroup := e.Group("/o")
|
||||
s.registerResourcePublicRoutes(publicGroup)
|
||||
s.registerGetterPublicRoutes(publicGroup)
|
||||
registerGetterPublicRoutes(publicGroup)
|
||||
|
||||
apiGroup := e.Group("/api")
|
||||
apiGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
@@ -80,9 +120,37 @@ func NewServer(profile *profile.Profile) *Server {
|
||||
s.registerResourceRoutes(apiGroup)
|
||||
s.registerTagRoutes(apiGroup)
|
||||
|
||||
return s
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (server *Server) Run() error {
|
||||
return server.e.Start(fmt.Sprintf(":%d", server.Profile.Port))
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
if err := s.createServerStartActivity(ctx); err != nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
s.Collector.Identify(ctx)
|
||||
return s.e.Start(fmt.Sprintf(":%d", s.Profile.Port))
|
||||
}
|
||||
|
||||
func (s *Server) createServerStartActivity(ctx context.Context) error {
|
||||
payload := api.ActivityServerStartPayload{
|
||||
ServerID: s.ID,
|
||||
Profile: s.Profile,
|
||||
}
|
||||
payloadStr, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to marshal activity payload")
|
||||
}
|
||||
activity, err := s.Store.CreateActivity(ctx, &api.ActivityCreate{
|
||||
CreatorID: api.UnknownID,
|
||||
Type: api.ActivityServerStart,
|
||||
Level: api.ActivityInfo,
|
||||
Payload: string(payloadStr),
|
||||
})
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: string(activity.Type),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
@@ -21,20 +21,19 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
shortcutCreate := &api.ShortcutCreate{
|
||||
CreatorID: userID,
|
||||
}
|
||||
shortcutCreate := &api.ShortcutCreate{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(shortcutCreate); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post shortcut request").SetInternal(err)
|
||||
}
|
||||
|
||||
shortcutCreate.CreatorID = userID
|
||||
shortcut, err := s.Store.CreateShortcut(ctx, shortcutCreate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create shortcut").SetInternal(err)
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: "shortcut created",
|
||||
})
|
||||
if err := s.createShortcutCreateActivity(c, shortcut); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(shortcut)); err != nil {
|
||||
@@ -45,21 +44,36 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
|
||||
|
||||
g.PATCH("/shortcut/:shortcutId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
shortcutID, err := strconv.Atoi(c.Param("shortcutId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
shortcutFind := &api.ShortcutFind{
|
||||
ID: &shortcutID,
|
||||
}
|
||||
shortcut, err := s.Store.FindShortcut(ctx, shortcutFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find shortcut").SetInternal(err)
|
||||
}
|
||||
if shortcut.CreatorID != userID {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
currentTs := time.Now().Unix()
|
||||
shortcutPatch := &api.ShortcutPatch{
|
||||
ID: shortcutID,
|
||||
UpdatedTs: ¤tTs,
|
||||
}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(shortcutPatch); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch shortcut request").SetInternal(err)
|
||||
}
|
||||
|
||||
shortcut, err := s.Store.PatchShortcut(ctx, shortcutPatch)
|
||||
shortcutPatch.ID = shortcutID
|
||||
shortcut, err = s.Store.PatchShortcut(ctx, shortcutPatch)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch shortcut").SetInternal(err)
|
||||
}
|
||||
@@ -73,19 +87,14 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
|
||||
|
||||
g.GET("/shortcut", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
shortcutFind := &api.ShortcutFind{}
|
||||
|
||||
if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
|
||||
shortcutFind.CreatorID = &userID
|
||||
} else {
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find shortcut")
|
||||
}
|
||||
|
||||
shortcutFind.CreatorID = &userID
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find shortcut")
|
||||
}
|
||||
|
||||
shortcutFind := &api.ShortcutFind{
|
||||
CreatorID: &userID,
|
||||
}
|
||||
list, err := s.Store.FindShortcutList(ctx, shortcutFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch shortcut list").SetInternal(err)
|
||||
@@ -122,11 +131,26 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
|
||||
|
||||
g.DELETE("/shortcut/:shortcutId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
shortcutID, err := strconv.Atoi(c.Param("shortcutId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
shortcutFind := &api.ShortcutFind{
|
||||
ID: &shortcutID,
|
||||
}
|
||||
shortcut, err := s.Store.FindShortcut(ctx, shortcutFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find shortcut").SetInternal(err)
|
||||
}
|
||||
if shortcut.CreatorID != userID {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
shortcutDelete := &api.ShortcutDelete{
|
||||
ID: &shortcutID,
|
||||
}
|
||||
@@ -140,3 +164,25 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) createShortcutCreateActivity(c echo.Context, shortcut *api.Shortcut) error {
|
||||
ctx := c.Request().Context()
|
||||
payload := api.ActivityShortcutCreatePayload{
|
||||
Title: shortcut.Title,
|
||||
Payload: shortcut.Payload,
|
||||
}
|
||||
payloadStr, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to marshal activity payload")
|
||||
}
|
||||
activity, err := s.Store.CreateActivity(ctx, &api.ActivityCreate{
|
||||
CreatorID: shortcut.CreatorID,
|
||||
Type: api.ActivityShortcutCreate,
|
||||
Level: api.ActivityInfo,
|
||||
Payload: string(payloadStr),
|
||||
})
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
@@ -37,6 +38,7 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
|
||||
if hostUser != nil {
|
||||
// data desensitize
|
||||
hostUser.OpenID = ""
|
||||
hostUser.Email = ""
|
||||
}
|
||||
|
||||
systemStatus := api.SystemStatus{
|
||||
@@ -61,6 +63,10 @@ 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 {
|
||||
continue
|
||||
}
|
||||
|
||||
var value interface{}
|
||||
err := json.Unmarshal([]byte(systemSetting.Value), &value)
|
||||
if err != nil {
|
||||
@@ -75,13 +81,24 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
|
||||
systemStatus.AdditionalScript = value.(string)
|
||||
} else if systemSetting.Name == api.SystemSettingCustomizedProfileName {
|
||||
valueMap := value.(map[string]interface{})
|
||||
systemStatus.CustomizedProfile = api.CustomizedProfile{
|
||||
Name: valueMap["name"].(string),
|
||||
LogoURL: valueMap["logoUrl"].(string),
|
||||
Description: valueMap["description"].(string),
|
||||
Locale: valueMap["locale"].(string),
|
||||
Appearance: valueMap["appearance"].(string),
|
||||
ExternalURL: valueMap["externalUrl"].(string),
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,9 +141,7 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||
}
|
||||
if user == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "Current signin user not found")
|
||||
} else if user.Role != api.Host {
|
||||
if user == nil || user.Role != api.Host {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
@@ -142,10 +157,6 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert system setting").SetInternal(err)
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: "systemSetting updated",
|
||||
Labels: map[string]string{"field": string(systemSettingUpsert.Name)},
|
||||
})
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(systemSetting)); err != nil {
|
||||
@@ -190,3 +201,43 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) getSystemServerID(ctx context.Context) (string, error) {
|
||||
serverIDKey := api.SystemSettingServerID
|
||||
serverIDValue, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{
|
||||
Name: &serverIDKey,
|
||||
})
|
||||
if err != nil && common.ErrorCode(err) != common.NotFound {
|
||||
return "", err
|
||||
}
|
||||
if serverIDValue == nil || serverIDValue.Value == "" {
|
||||
serverIDValue, err = s.Store.UpsertSystemSetting(ctx, &api.SystemSettingUpsert{
|
||||
Name: serverIDKey,
|
||||
Value: uuid.NewString(),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return serverIDValue.Value, nil
|
||||
}
|
||||
|
||||
func (s *Server) getSystemSecretSessionName(ctx context.Context) (string, error) {
|
||||
secretSessionNameKey := api.SystemSettingSecretSessionName
|
||||
secretSessionNameValue, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{
|
||||
Name: &secretSessionNameKey,
|
||||
})
|
||||
if err != nil && common.ErrorCode(err) != common.NotFound {
|
||||
return "", err
|
||||
}
|
||||
if secretSessionNameValue == nil || secretSessionNameValue.Value == "" {
|
||||
secretSessionNameValue, err = s.Store.UpsertSystemSetting(ctx, &api.SystemSettingUpsert{
|
||||
Name: secretSessionNameKey,
|
||||
Value: uuid.NewString(),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return secretSessionNameValue.Value, nil
|
||||
}
|
||||
|
||||
@@ -6,11 +6,10 @@ import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
@@ -23,9 +22,7 @@ func (s *Server) registerTagRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
|
||||
tagUpsert := &api.TagUpsert{
|
||||
CreatorID: userID,
|
||||
}
|
||||
tagUpsert := &api.TagUpsert{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(tagUpsert); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post tag request").SetInternal(err)
|
||||
}
|
||||
@@ -33,13 +30,14 @@ func (s *Server) registerTagRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Tag name shouldn't be empty")
|
||||
}
|
||||
|
||||
tagUpsert.CreatorID = userID
|
||||
tag, err := s.Store.UpsertTag(ctx, tagUpsert)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert tag").SetInternal(err)
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: "tag created",
|
||||
})
|
||||
if err := s.createTagCreateActivity(c, tag); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(tag.Name)); err != nil {
|
||||
@@ -50,19 +48,14 @@ func (s *Server) registerTagRoutes(g *echo.Group) {
|
||||
|
||||
g.GET("/tag", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
tagFind := &api.TagFind{}
|
||||
if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
|
||||
tagFind.CreatorID = userID
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find tag")
|
||||
}
|
||||
|
||||
if tagFind.CreatorID == 0 {
|
||||
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find tag")
|
||||
}
|
||||
tagFind.CreatorID = currentUserID
|
||||
tagFind := &api.TagFind{
|
||||
CreatorID: userID,
|
||||
}
|
||||
|
||||
tagList, err := s.Store.FindTagList(ctx, tagFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find tag list").SetInternal(err)
|
||||
@@ -82,31 +75,18 @@ func (s *Server) registerTagRoutes(g *echo.Group) {
|
||||
|
||||
g.GET("/tag/suggestion", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Missing user session")
|
||||
}
|
||||
contentSearch := "#"
|
||||
normalRowStatus := api.Normal
|
||||
memoFind := api.MemoFind{
|
||||
CreatorID: &userID,
|
||||
ContentSearch: &contentSearch,
|
||||
RowStatus: &normalRowStatus,
|
||||
}
|
||||
|
||||
if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
|
||||
memoFind.CreatorID = &userID
|
||||
}
|
||||
|
||||
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
if memoFind.CreatorID == nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find memo")
|
||||
}
|
||||
memoFind.VisibilityList = []api.Visibility{api.Public}
|
||||
} else {
|
||||
if memoFind.CreatorID == nil {
|
||||
memoFind.CreatorID = ¤tUserID
|
||||
} else {
|
||||
memoFind.VisibilityList = []api.Visibility{api.Public, api.Protected}
|
||||
}
|
||||
}
|
||||
|
||||
memoList, err := s.Store.FindMemoList(ctx, &memoFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
|
||||
@@ -175,3 +155,24 @@ func findTagListFromMemoContent(memoContent string) []string {
|
||||
sort.Strings(tagList)
|
||||
return tagList
|
||||
}
|
||||
|
||||
func (s *Server) createTagCreateActivity(c echo.Context, tag *api.Tag) error {
|
||||
ctx := c.Request().Context()
|
||||
payload := api.ActivityTagCreatePayload{
|
||||
TagName: tag.Name,
|
||||
}
|
||||
payloadStr, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to marshal activity payload")
|
||||
}
|
||||
activity, err := s.Store.CreateActivity(ctx, &api.ActivityCreate{
|
||||
CreatorID: tag.CreatorID,
|
||||
Type: api.ActivityTagCreate,
|
||||
Level: api.ActivityInfo,
|
||||
Payload: string(payloadStr),
|
||||
})
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
@@ -29,18 +30,20 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by id").SetInternal(err)
|
||||
}
|
||||
if currentUser.Role != api.Host {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Only Host user can create member.")
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Only Host user can create member")
|
||||
}
|
||||
|
||||
userCreate := &api.UserCreate{
|
||||
OpenID: common.GenUUID(),
|
||||
}
|
||||
userCreate := &api.UserCreate{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(userCreate); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user request").SetInternal(err)
|
||||
}
|
||||
if userCreate.Role == api.Host {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "Could not create host user")
|
||||
}
|
||||
userCreate.OpenID = common.GenUUID()
|
||||
|
||||
if err := userCreate.Validate(); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format.").SetInternal(err)
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format").SetInternal(err)
|
||||
}
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(userCreate.Password), bcrypt.DefaultCost)
|
||||
@@ -53,9 +56,9 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: "user created",
|
||||
})
|
||||
if err := s.createUserCreateActivity(c, user); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
|
||||
@@ -74,6 +77,7 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
|
||||
for _, user := range userList {
|
||||
// data desensitize
|
||||
user.OpenID = ""
|
||||
user.Email = ""
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
@@ -159,6 +163,7 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
|
||||
if user != nil {
|
||||
// data desensitize
|
||||
user.OpenID = ""
|
||||
user.Email = ""
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
@@ -192,15 +197,14 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
|
||||
|
||||
currentTs := time.Now().Unix()
|
||||
userPatch := &api.UserPatch{
|
||||
ID: userID,
|
||||
UpdatedTs: ¤tTs,
|
||||
}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(userPatch); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch user request").SetInternal(err)
|
||||
}
|
||||
|
||||
if userPatch.Email != nil && *userPatch.Email != "" && !common.ValidateEmail(*userPatch.Email) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid email format")
|
||||
userPatch.ID = userID
|
||||
if err := userPatch.Validate(); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user patch format").SetInternal(err)
|
||||
}
|
||||
|
||||
if userPatch.Password != nil && *userPatch.Password != "" {
|
||||
@@ -274,3 +278,29 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) createUserCreateActivity(c echo.Context, user *api.User) error {
|
||||
ctx := c.Request().Context()
|
||||
payload := api.ActivityUserCreatePayload{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
}
|
||||
payloadStr, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to marshal activity payload")
|
||||
}
|
||||
activity, err := s.Store.CreateActivity(ctx, &api.ActivityCreate{
|
||||
CreatorID: user.ID,
|
||||
Type: api.ActivityUserCreate,
|
||||
Level: api.ActivityInfo,
|
||||
Payload: string(payloadStr),
|
||||
})
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: string(activity.Type),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
|
||||
// Version is the service current released version.
|
||||
// Semantic versioning: https://semver.org/
|
||||
var Version = "0.9.0"
|
||||
var Version = "0.10.0"
|
||||
|
||||
// DevVersion is the service current development version.
|
||||
var DevVersion = "0.9.0"
|
||||
var DevVersion = "0.10.0"
|
||||
|
||||
func GetCurrentVersion(mode string) string {
|
||||
if mode == "dev" {
|
||||
@@ -29,7 +29,6 @@ func GetMinorVersion(version string) string {
|
||||
|
||||
func GetSchemaVersion(version string) string {
|
||||
minorVersion := GetMinorVersion(version)
|
||||
|
||||
return minorVersion + ".0"
|
||||
}
|
||||
|
||||
|
||||
33
server/version/version_test.go
Normal file
33
server/version/version_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package version
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsVersionGreaterOrEqualThan(t *testing.T) {
|
||||
tests := []struct {
|
||||
version string
|
||||
target string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
version: "0.9.1",
|
||||
target: "0.9.1",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
version: "0.10.0",
|
||||
target: "0.9.1",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
version: "0.9.0",
|
||||
target: "0.9.1",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
result := IsVersionGreaterOrEqualThan(test.version, test.target)
|
||||
if result != test.want {
|
||||
t.Errorf("got result %v, want %v.", result, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
89
store/activity.go
Normal file
89
store/activity.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
)
|
||||
|
||||
// activityRaw is the store model for an Activity.
|
||||
// Fields have exactly the same meanings as Activity.
|
||||
type activityRaw struct {
|
||||
ID int
|
||||
|
||||
// Standard fields
|
||||
CreatorID int
|
||||
CreatedTs int64
|
||||
|
||||
// Domain specific fields
|
||||
Type api.ActivityType
|
||||
Level api.ActivityLevel
|
||||
Payload string
|
||||
}
|
||||
|
||||
// toActivity creates an instance of Activity based on the ActivityRaw.
|
||||
func (raw *activityRaw) toActivity() *api.Activity {
|
||||
return &api.Activity{
|
||||
ID: raw.ID,
|
||||
|
||||
CreatorID: raw.CreatorID,
|
||||
CreatedTs: raw.CreatedTs,
|
||||
|
||||
Type: raw.Type,
|
||||
Level: raw.Level,
|
||||
Payload: raw.Payload,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateActivity creates an instance of Activity.
|
||||
func (s *Store) CreateActivity(ctx context.Context, create *api.ActivityCreate) (*api.Activity, error) {
|
||||
if s.profile.Mode != "dev" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
activityRaw, err := createActivity(ctx, tx, create)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
activity := activityRaw.toActivity()
|
||||
return activity, nil
|
||||
}
|
||||
|
||||
// createActivity creates a new activity.
|
||||
func createActivity(ctx context.Context, tx *sql.Tx, create *api.ActivityCreate) (*activityRaw, error) {
|
||||
query := `
|
||||
INSERT INTO activity (
|
||||
creator_id,
|
||||
type,
|
||||
level,
|
||||
payload
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
RETURNING id, type, level, payload, creator_id, created_ts
|
||||
`
|
||||
var activityRaw activityRaw
|
||||
if err := tx.QueryRowContext(ctx, query, create.CreatorID, create.Type, create.Level, create.Payload).Scan(
|
||||
&activityRaw.ID,
|
||||
&activityRaw.Type,
|
||||
&activityRaw.Level,
|
||||
&activityRaw.Payload,
|
||||
&activityRaw.CreatedTs,
|
||||
&activityRaw.CreatedTs,
|
||||
); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
return &activityRaw, nil
|
||||
}
|
||||
@@ -24,8 +24,8 @@ var seedFS embed.FS
|
||||
|
||||
type DB struct {
|
||||
// sqlite db connection instance
|
||||
Db *sql.DB
|
||||
profile *profile.Profile
|
||||
DBInstance *sql.DB
|
||||
profile *profile.Profile
|
||||
}
|
||||
|
||||
// NewDB returns a new instance of DB associated with the given datasource name.
|
||||
@@ -47,7 +47,7 @@ func (db *DB) Open(ctx context.Context) (err error) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open db with dsn: %s, err: %w", db.profile.DSN, err)
|
||||
}
|
||||
db.Db = sqliteDB
|
||||
db.DBInstance = sqliteDB
|
||||
|
||||
if db.profile.Mode == "dev" {
|
||||
// In dev mode, we should migrate and seed the database.
|
||||
@@ -68,11 +68,11 @@ func (db *DB) Open(ctx context.Context) (err error) {
|
||||
}
|
||||
|
||||
currentVersion := version.GetCurrentVersion(db.profile.Mode)
|
||||
migrationHistory, err := db.FindMigrationHistory(ctx, &MigrationHistoryFind{})
|
||||
migrationHistoryList, err := db.FindMigrationHistoryList(ctx, &MigrationHistoryFind{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find migration history, err: %w", err)
|
||||
}
|
||||
if migrationHistory == nil {
|
||||
if len(migrationHistoryList) == 0 {
|
||||
if _, err = db.UpsertMigrationHistory(ctx, &MigrationHistoryUpsert{
|
||||
Version: currentVersion,
|
||||
}); err != nil {
|
||||
@@ -80,8 +80,14 @@ func (db *DB) Open(ctx context.Context) (err error) {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
migrationHistoryVersionList := []string{}
|
||||
for _, migrationHistory := range migrationHistoryList {
|
||||
migrationHistoryVersionList = append(migrationHistoryVersionList, migrationHistory.Version)
|
||||
}
|
||||
sort.Strings(migrationHistoryVersionList)
|
||||
latestMigrationHistoryVersion := migrationHistoryVersionList[0]
|
||||
|
||||
if version.IsVersionGreaterThan(version.GetSchemaVersion(currentVersion), migrationHistory.Version) {
|
||||
if version.IsVersionGreaterThan(version.GetSchemaVersion(currentVersion), latestMigrationHistoryVersion) {
|
||||
minorVersionList := getMinorVersionList()
|
||||
|
||||
// backup the raw database file before migration
|
||||
@@ -98,7 +104,7 @@ func (db *DB) Open(ctx context.Context) (err error) {
|
||||
println("start migrate")
|
||||
for _, minorVersion := range minorVersionList {
|
||||
normalizedVersion := minorVersion + ".0"
|
||||
if version.IsVersionGreaterThan(normalizedVersion, migrationHistory.Version) && version.IsVersionGreaterOrEqualThan(currentVersion, normalizedVersion) {
|
||||
if version.IsVersionGreaterThan(normalizedVersion, latestMigrationHistoryVersion) && version.IsVersionGreaterOrEqualThan(currentVersion, normalizedVersion) {
|
||||
println("applying migration for", normalizedVersion)
|
||||
if err := db.applyMigrationForMinorVersion(ctx, minorVersion); err != nil {
|
||||
return fmt.Errorf("failed to apply minor version migration: %w", err)
|
||||
@@ -156,7 +162,7 @@ func (db *DB) applyMigrationForMinorVersion(ctx context.Context, minorVersion st
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := db.Db.Begin()
|
||||
tx, err := db.DBInstance.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -197,7 +203,7 @@ func (db *DB) seed(ctx context.Context) error {
|
||||
|
||||
// execute runs a single SQL statement within a transaction.
|
||||
func (db *DB) execute(ctx context.Context, stmt string) error {
|
||||
tx, err := db.Db.Begin()
|
||||
tx, err := db.DBInstance.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -93,3 +93,13 @@ CREATE TABLE tag (
|
||||
creator_id INTEGER NOT NULL,
|
||||
UNIQUE(name, creator_id)
|
||||
);
|
||||
|
||||
-- activity
|
||||
CREATE TABLE activity (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
creator_id INTEGER NOT NULL,
|
||||
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
type TEXT NOT NULL DEFAULT '',
|
||||
level TEXT NOT NULL CHECK (level IN ('INFO', 'WARN', 'ERROR')) DEFAULT 'INFO',
|
||||
payload TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
9
store/db/migration/prod/0.10/00__activity.sql
Normal file
9
store/db/migration/prod/0.10/00__activity.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- activity
|
||||
CREATE TABLE activity (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
creator_id INTEGER NOT NULL,
|
||||
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
type TEXT NOT NULL DEFAULT '',
|
||||
level TEXT NOT NULL CHECK (level IN ('INFO', 'WARN', 'ERROR')) DEFAULT 'INFO',
|
||||
payload TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
@@ -93,3 +93,13 @@ CREATE TABLE tag (
|
||||
creator_id INTEGER NOT NULL,
|
||||
UNIQUE(name, creator_id)
|
||||
);
|
||||
|
||||
-- activity
|
||||
CREATE TABLE activity (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
creator_id INTEGER NOT NULL,
|
||||
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
type TEXT NOT NULL DEFAULT '',
|
||||
level TEXT NOT NULL CHECK (level IN ('INFO', 'WARN', 'ERROR')) DEFAULT 'INFO',
|
||||
payload TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
@@ -19,8 +19,8 @@ type MigrationHistoryFind struct {
|
||||
Version *string
|
||||
}
|
||||
|
||||
func (db *DB) FindMigrationHistory(ctx context.Context, find *MigrationHistoryFind) (*MigrationHistory, error) {
|
||||
tx, err := db.Db.BeginTx(ctx, nil)
|
||||
func (db *DB) FindMigrationHistoryList(ctx context.Context, find *MigrationHistoryFind) ([]*MigrationHistory, error) {
|
||||
tx, err := db.DBInstance.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -31,16 +31,11 @@ func (db *DB) FindMigrationHistory(ctx context.Context, find *MigrationHistoryFi
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(list) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
migrationHistory := list[0]
|
||||
return migrationHistory, nil
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (db *DB) UpsertMigrationHistory(ctx context.Context, upsert *MigrationHistoryUpsert) (*MigrationHistory, error) {
|
||||
tx, err := db.Db.BeginTx(ctx, nil)
|
||||
tx, err := db.DBInstance.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -217,13 +217,14 @@ func patchShortcut(ctx context.Context, tx *sql.Tx, patch *api.ShortcutPatch) (*
|
||||
UPDATE shortcut
|
||||
SET ` + strings.Join(set, ", ") + `
|
||||
WHERE id = ?
|
||||
RETURNING id, title, payload, created_ts, updated_ts, row_status
|
||||
RETURNING id, title, payload, creator_id, created_ts, updated_ts, row_status
|
||||
`
|
||||
var shortcutRaw shortcutRaw
|
||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(
|
||||
&shortcutRaw.ID,
|
||||
&shortcutRaw.Title,
|
||||
&shortcutRaw.Payload,
|
||||
&shortcutRaw.CreatorID,
|
||||
&shortcutRaw.CreatedTs,
|
||||
&shortcutRaw.UpdatedTs,
|
||||
&shortcutRaw.RowStatus,
|
||||
|
||||
@@ -48,7 +48,7 @@ const App = () => {
|
||||
const styleEl = document.createElement("style");
|
||||
styleEl.innerHTML = systemStatus.additionalStyle;
|
||||
styleEl.setAttribute("type", "text/css");
|
||||
document.head.appendChild(styleEl);
|
||||
document.body.insertAdjacentElement("beforeend", styleEl);
|
||||
}
|
||||
if (systemStatus.additionalScript) {
|
||||
const scriptEl = document.createElement("script");
|
||||
|
||||
@@ -11,6 +11,7 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const globalStore = useGlobalStore();
|
||||
const profile = globalStore.state.systemStatus.profile;
|
||||
const customizedProfile = globalStore.state.systemStatus.customizedProfile;
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
destroy();
|
||||
@@ -20,36 +21,36 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text flex items-center">
|
||||
<img className="w-7 h-auto mr-1" src="/logo.png" alt="" />
|
||||
{t("common.about")} memos
|
||||
{t("common.about")} {customizedProfile.name}
|
||||
</p>
|
||||
<button className="btn close-btn" onClick={handleCloseBtnClick}>
|
||||
<Icon.X />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container">
|
||||
<p>{t("slogan")}</p>
|
||||
<div className="border-t mt-1 pt-2 flex flex-row justify-start items-center">
|
||||
<span className=" text-gray-500 mr-2">Other projects:</span>
|
||||
<p className="text-sm">{customizedProfile.description || "No description"}</p>
|
||||
<div className="mt-4 flex flex-row text-sm justify-start items-center">
|
||||
<div className="flex flex-row justify-start items-center mr-2">
|
||||
Powered by
|
||||
<a href="https://usememos.com" className="flex flex-row justify-start items-center mr-1 hover:underline">
|
||||
<img className="w-6 h-auto" src="/logo.png" alt="" />
|
||||
memos
|
||||
</a>
|
||||
<span>v{profile.version}</span>
|
||||
</div>
|
||||
<GitHubBadge />
|
||||
</div>
|
||||
<div className="border-t mt-3 pt-2 text-sm flex flex-row justify-start items-center">
|
||||
<span className="text-gray-500 mr-2">Other projects:</span>
|
||||
<a href="https://github.com/boojack/sticky-notes" className="flex items-center underline text-blue-600 hover:opacity-80">
|
||||
<img
|
||||
className="w-5 h-auto mr-1"
|
||||
className="w-4 h-auto mr-1"
|
||||
src="https://raw.githubusercontent.com/boojack/sticky-notes/main/public/sticky-notes.ico"
|
||||
alt=""
|
||||
/>
|
||||
<span>Sticky notes</span>
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-row text-sm justify-start items-center">
|
||||
<GitHubBadge />
|
||||
<span className="ml-2">
|
||||
{t("common.version")}:
|
||||
<span className="font-mono">
|
||||
{profile.version}-{profile.mode}
|
||||
</span>
|
||||
🎉
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TextField } from "@mui/joy";
|
||||
import React, { useEffect, useState } from "react";
|
||||
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 toastHelper from "./Toast";
|
||||
@@ -10,7 +11,7 @@ import { generateDialog } from "./Dialog";
|
||||
type Props = DialogProps;
|
||||
|
||||
const validateTagName = (tagName: string): boolean => {
|
||||
const matchResult = Tag.matcher(`#${tagName}`);
|
||||
const matchResult = matcher(`#${tagName}`, Tag.regexp);
|
||||
if (!matchResult || matchResult[1] !== tagName) {
|
||||
return false;
|
||||
}
|
||||
@@ -22,6 +23,7 @@ const CreateTagDialog: React.FC<Props> = (props: Props) => {
|
||||
const tagStore = useTagStore();
|
||||
const [tagName, setTagName] = useState<string>("");
|
||||
const [suggestTagNameList, setSuggestTagNameList] = useState<string[]>([]);
|
||||
const [showTagSuggestions, setShowTagSuggestions] = useState<boolean>(false);
|
||||
const tagNameList = tagStore.state.tags;
|
||||
const shownSuggestTagNameList = suggestTagNameList.filter((tag) => !tagNameList.includes(tag));
|
||||
|
||||
@@ -42,10 +44,14 @@ const CreateTagDialog: React.FC<Props> = (props: Props) => {
|
||||
setTagName(tagName.trim());
|
||||
};
|
||||
|
||||
const handleUpsertSuggestTag = async (tagName: string) => {
|
||||
const handleUpsertTag = async (tagName: string) => {
|
||||
await tagStore.upsertTag(tagName);
|
||||
};
|
||||
|
||||
const handleToggleShowSuggestionTags = () => {
|
||||
setShowTagSuggestions((state) => !state);
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (!validateTagName(tagName)) {
|
||||
toastHelper.error("Invalid tag name");
|
||||
@@ -111,24 +117,30 @@ const CreateTagDialog: React.FC<Props> = (props: Props) => {
|
||||
|
||||
{shownSuggestTagNameList.length > 0 && (
|
||||
<>
|
||||
<p className="w-full mt-2 mb-1 text-sm text-gray-400">Tag suggestions</p>
|
||||
<div className="w-full flex flex-row justify-start items-start flex-wrap">
|
||||
{shownSuggestTagNameList.map((tag) => (
|
||||
<span
|
||||
className="max-w-[120px] text-sm mr-2 mt-1 font-mono cursor-pointer truncate dark:text-gray-300 hover:opacity-60"
|
||||
key={tag}
|
||||
onClick={() => handleUpsertSuggestTag(tag)}
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
<div className="mt-4 mb-1 text-sm w-full flex flex-row justify-start items-center">
|
||||
<span className="text-gray-400">Tag suggestions</span>
|
||||
<button className="btn-normal ml-2 px-2 py-0 leading-6 font-mono" onClick={handleToggleShowSuggestionTags}>
|
||||
{showTagSuggestions ? "hide" : "show"}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="mt-2 text-sm border px-2 leading-6 rounded cursor-pointer dark:border-gray-400 dark:text-gray-300 hover:opacity-80 hover:shadow"
|
||||
onClick={handleSaveSuggestTagList}
|
||||
>
|
||||
Save all
|
||||
</button>
|
||||
{showTagSuggestions && (
|
||||
<>
|
||||
<div className="w-full flex flex-row justify-start items-start flex-wrap">
|
||||
{shownSuggestTagNameList.map((tag) => (
|
||||
<span
|
||||
className="max-w-[120px] text-sm mr-2 mt-1 font-mono cursor-pointer truncate dark:text-gray-300 hover:opacity-60"
|
||||
key={tag}
|
||||
onClick={() => handleUpsertTag(tag)}
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn-normal mt-2 px-2 py-0 leading-6 font-mono" onClick={handleSaveSuggestTagList}>
|
||||
Save all
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
60
web/src/components/EmbedMemoDialog.tsx
Normal file
60
web/src/components/EmbedMemoDialog.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from "react";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import copy from "copy-to-clipboard";
|
||||
import toastHelper from "./Toast";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
memoId: MemoId;
|
||||
}
|
||||
|
||||
const EmbedMemoDialog: React.FC<Props> = (props: Props) => {
|
||||
const { memoId, destroy } = props;
|
||||
|
||||
const memoEmbeddedCode = () => {
|
||||
return `<iframe style="width:100%;height:auto;min-width:256px;" src="${window.location.origin}/m/${memoId}/embed" frameBorder="0"></iframe>`;
|
||||
};
|
||||
|
||||
const handleCopyCode = () => {
|
||||
copy(memoEmbeddedCode());
|
||||
toastHelper.success("Succeed to copy code to clipboard.");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">Embed Memo</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>
|
||||
<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="btn-primary" onClick={handleCopyCode}>
|
||||
Copy
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function showEmbedMemoDialog(memoId: MemoId) {
|
||||
generateDialog(
|
||||
{
|
||||
className: "embed-memo-dialog",
|
||||
dialogName: "embed-memo-dialog",
|
||||
},
|
||||
EmbedMemoDialog,
|
||||
{
|
||||
memoId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default showEmbedMemoDialog;
|
||||
@@ -30,6 +30,9 @@ const LocaleSelect: FC<Props> = (props: Props) => {
|
||||
<Option value="sv">Svenska</Option>
|
||||
<Option value="de">German</Option>
|
||||
<Option value="es">Español</Option>
|
||||
<Option value="uk">Українська</Option>
|
||||
<Option value="ru">Русский</Option>
|
||||
<Option value="it">Italiano</Option>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,12 +10,12 @@ import MemoContent from "./MemoContent";
|
||||
import MemoResources from "./MemoResources";
|
||||
import showShareMemo from "./ShareMemoDialog";
|
||||
import showPreviewImageDialog from "./PreviewImageDialog";
|
||||
import showEmbedMemoDialog from "./EmbedMemoDialog";
|
||||
import showChangeMemoCreatedTsDialog from "./ChangeMemoCreatedTsDialog";
|
||||
import "../less/memo.less";
|
||||
|
||||
interface Props {
|
||||
memo: Memo;
|
||||
highlightWord?: string;
|
||||
}
|
||||
|
||||
export const getFormatedMemoTimeStr = (time: number, locale = "en"): string => {
|
||||
@@ -27,7 +27,7 @@ export const getFormatedMemoTimeStr = (time: number, locale = "en"): string => {
|
||||
};
|
||||
|
||||
const Memo: React.FC<Props> = (props: Props) => {
|
||||
const { memo, highlightWord } = props;
|
||||
const { memo } = props;
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const editorStore = useEditorStore();
|
||||
@@ -55,6 +55,10 @@ const Memo: React.FC<Props> = (props: Props) => {
|
||||
navigate(`/m/${memo.id}`);
|
||||
};
|
||||
|
||||
const handleShowEmbedMemoDialog = () => {
|
||||
showEmbedMemoDialog(memo.id);
|
||||
};
|
||||
|
||||
const handleCopyContent = () => {
|
||||
copy(memo.content);
|
||||
toastHelper.success(t("message.succeed-copy-content"));
|
||||
@@ -215,6 +219,9 @@ const Memo: React.FC<Props> = (props: Props) => {
|
||||
<span className="btn" onClick={handleViewMemoDetailPage}>
|
||||
{t("memo.view-detail")}
|
||||
</span>
|
||||
<span className="btn" onClick={handleShowEmbedMemoDialog}>
|
||||
Embed memo
|
||||
</span>
|
||||
<span className="btn archive-btn" onClick={handleArchiveMemoClick}>
|
||||
{t("common.archive")}
|
||||
</span>
|
||||
@@ -225,7 +232,6 @@ const Memo: React.FC<Props> = (props: Props) => {
|
||||
</div>
|
||||
<MemoContent
|
||||
content={memo.content}
|
||||
highlightWord={highlightWord}
|
||||
onMemoContentClick={handleMemoContentClick}
|
||||
onMemoContentDoubleClick={handleMemoContentDoubleClick}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUserStore } from "../store/module";
|
||||
import { marked } from "../labs/marked";
|
||||
import { highlightWithWord } from "../labs/highlighter";
|
||||
import Icon from "./Icon";
|
||||
import "../less/memo-content.less";
|
||||
|
||||
@@ -12,7 +11,6 @@ export interface DisplayConfig {
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
highlightWord?: string;
|
||||
className?: string;
|
||||
displayConfig?: Partial<DisplayConfig>;
|
||||
onMemoContentClick?: (e: React.MouseEvent) => void;
|
||||
@@ -30,14 +28,14 @@ const defaultDisplayConfig: DisplayConfig = {
|
||||
};
|
||||
|
||||
const MemoContent: React.FC<Props> = (props: Props) => {
|
||||
const { className, content, highlightWord, onMemoContentClick, onMemoContentDoubleClick } = props;
|
||||
const { className, content, onMemoContentClick, onMemoContentDoubleClick } = props;
|
||||
const { t } = useTranslation();
|
||||
const userStore = useUserStore();
|
||||
const user = userStore.state.user;
|
||||
const foldedContent = useMemo(() => {
|
||||
const firstHorizontalRuleIndex = content.search(/^---$|^\*\*\*$|^___$/m);
|
||||
return firstHorizontalRuleIndex !== -1 ? content.slice(0, firstHorizontalRuleIndex) : content;
|
||||
}, [content]);
|
||||
const { t } = useTranslation();
|
||||
const userStore = useUserStore();
|
||||
const user = userStore.state.user;
|
||||
|
||||
const [state, setState] = useState<State>({
|
||||
expandButtonStatus: -1,
|
||||
@@ -97,10 +95,9 @@ const MemoContent: React.FC<Props> = (props: Props) => {
|
||||
className={`memo-content-text ${state.expandButtonStatus === 0 ? "expanded" : ""}`}
|
||||
onClick={handleMemoContentClick}
|
||||
onDoubleClick={handleMemoContentDoubleClick}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlightWithWord(marked(state.expandButtonStatus === 0 ? foldedContent : content), highlightWord),
|
||||
}}
|
||||
></div>
|
||||
>
|
||||
{marked(state.expandButtonStatus === 0 ? foldedContent : content)}
|
||||
</div>
|
||||
{state.expandButtonStatus !== -1 && (
|
||||
<div className="expand-btn-container">
|
||||
<span className={`btn ${state.expandButtonStatus === 0 ? "expand-btn" : "fold-btn"}`} onClick={handleExpandBtnClick}>
|
||||
|
||||
@@ -59,6 +59,7 @@ const MemoEditor = () => {
|
||||
const tagSelectorRef = useRef<HTMLDivElement>(null);
|
||||
const user = userStore.state.user as User;
|
||||
const setting = user.setting;
|
||||
const localSetting = user.localSetting;
|
||||
const tags = tagStore.state.tags;
|
||||
const memoVisibilityOptionSelectorItems = VISIBILITY_SELECTOR_ITEMS.map((item) => {
|
||||
return {
|
||||
@@ -215,25 +216,26 @@ const MemoEditor = () => {
|
||||
}
|
||||
}
|
||||
|
||||
for (const symbol of pairSymbols) {
|
||||
if (event.key === symbol[0]) {
|
||||
event.preventDefault();
|
||||
editorRef.current.insertText("", symbol[0], symbol[1]);
|
||||
if (localSetting.enablePowerfulEditor) {
|
||||
for (const symbol of pairSymbols) {
|
||||
if (event.key === symbol[0]) {
|
||||
event.preventDefault();
|
||||
editorRef.current.insertText("", symbol[0], symbol[1]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event.key === "Backspace") {
|
||||
const cursor = editorRef.current.getCursorPosition();
|
||||
const content = editorRef.current.getContent();
|
||||
const deleteChar = content?.slice(cursor - 1, cursor);
|
||||
const nextChar = content?.slice(cursor, cursor + 1);
|
||||
if (pairSymbols.includes(`${deleteChar}${nextChar}`)) {
|
||||
event.preventDefault();
|
||||
editorRef.current.removeText(cursor - 1, 2);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "Backspace") {
|
||||
const cursor = editorRef.current.getCursorPosition();
|
||||
const content = editorRef.current.getContent();
|
||||
const deleteChar = content?.slice(cursor - 1, cursor);
|
||||
const nextChar = content?.slice(cursor, cursor + 1);
|
||||
if (pairSymbols.includes(`${deleteChar}${nextChar}`)) {
|
||||
event.preventDefault();
|
||||
editorRef.current.removeText(cursor - 1, 2);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const uploadMultiFiles = async (files: FileList) => {
|
||||
@@ -494,8 +496,8 @@ const MemoEditor = () => {
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="tip-text" onClick={(e) => e.stopPropagation()}>
|
||||
{t("common.null")}
|
||||
<p className="tip-text italic" onClick={(e) => e.stopPropagation()}>
|
||||
No tags found
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,6 @@ const MemoList = () => {
|
||||
const memoDisplayTsOption = userStore.state.user?.setting.memoDisplayTsOption;
|
||||
const { memos, isFetching } = memoStore.state;
|
||||
const [isComplete, setIsComplete] = useState<boolean>(false);
|
||||
const [highlightWord, setHighlightWord] = useState<string | undefined>("");
|
||||
|
||||
const { tag: tagQuery, duration, type: memoType, text: textQuery, shortcutId, visibility } = query ?? {};
|
||||
const shortcut = shortcutId ? shortcutStore.getShortcutById(shortcutId) : null;
|
||||
@@ -107,7 +106,6 @@ const MemoList = () => {
|
||||
if (pageWrapper) {
|
||||
pageWrapper.scrollTo(0, 0);
|
||||
}
|
||||
setHighlightWord(query?.text);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -136,7 +134,7 @@ const MemoList = () => {
|
||||
return (
|
||||
<div className="memo-list-container">
|
||||
{sortedMemos.map((memo) => (
|
||||
<Memo key={`${memo.id}-${memo.displayTs}`} memo={memo} highlightWord={highlightWord} />
|
||||
<Memo key={`${memo.id}-${memo.displayTs}`} memo={memo} />
|
||||
))}
|
||||
{isFetching ? (
|
||||
<div className="status-text-container fetching-tip">
|
||||
|
||||
@@ -45,7 +45,11 @@ const PreferencesSection = () => {
|
||||
};
|
||||
|
||||
const handleIsFoldingEnabledChanged = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
userStore.upsertLocalSetting("enableFoldMemo", event.target.checked);
|
||||
userStore.upsertLocalSetting({ ...localSetting, enableFoldMemo: event.target.checked });
|
||||
};
|
||||
|
||||
const handlePowerfulEditorEnabledChanged = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
userStore.upsertLocalSetting({ ...localSetting, enablePowerfulEditor: event.target.checked });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -100,6 +104,10 @@ const PreferencesSection = () => {
|
||||
<span className="normal-text">{t("setting.preference-section.enable-folding-memo")}</span>
|
||||
<Switch className="ml-2" checked={localSetting.enableFoldMemo} onChange={handleIsFoldingEnabledChanged} />
|
||||
</label>
|
||||
<label className="form-label selector">
|
||||
<span className="normal-text">{t("setting.preference-section.enable-powerful-editor")}</span>
|
||||
<Switch className="ml-2" checked={localSetting.enablePowerfulEditor} onChange={handlePowerfulEditorEnabledChanged} />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -99,7 +99,7 @@ const ShareMemoDialog: React.FC<Props> = (props: Props) => {
|
||||
|
||||
const handleCopyLinkBtnClick = () => {
|
||||
copy(`${window.location.origin}/m/${memo.id}`);
|
||||
toastHelper.success(t("message.succeed-copy-content"));
|
||||
toastHelper.success("Succeed to copy memo link to clipboard.");
|
||||
};
|
||||
|
||||
const memoVisibilityOptionSelectorItems = VISIBILITY_SELECTOR_ITEMS.map((item) => {
|
||||
|
||||
@@ -35,11 +35,11 @@ const Sidebar = () => {
|
||||
<button className="btn action-btn" onClick={() => showDailyReviewDialog()}>
|
||||
<span className="icon">📅</span> {t("sidebar.daily-review")}
|
||||
</button>
|
||||
<Link to="/explore" className="btn action-btn">
|
||||
<span className="icon">🏂</span> {t("common.explore")}
|
||||
</Link>
|
||||
{!userStore.isVisitorMode() && (
|
||||
<>
|
||||
<Link to="/explore" className="btn action-btn">
|
||||
<span className="icon">🏂</span> {t("common.explore")}
|
||||
</Link>
|
||||
<button className="btn action-btn" onClick={handleSettingBtnClick}>
|
||||
<span className="icon">⚙️</span> {t("sidebar.setting")}
|
||||
</button>
|
||||
|
||||
@@ -103,23 +103,25 @@ const UpdateAccountDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container !w-64">
|
||||
<p className="title-text">Update information</p>
|
||||
<p className="title-text">{t("setting.account-section.update-information")}</p>
|
||||
<button className="btn close-btn" onClick={handleCloseBtnClick}>
|
||||
<Icon.X />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container">
|
||||
<p className="text-sm mb-1">
|
||||
Nickname<span className="text-sm text-gray-400 ml-1">(Display in the banner)</span>
|
||||
{t("common.nickname")}
|
||||
<span className="text-sm text-gray-400 ml-1">(Display in the banner)</span>
|
||||
</p>
|
||||
<input type="text" className="input-text" value={state.nickname} onChange={handleNicknameChanged} />
|
||||
<p className="text-sm mb-1 mt-2">
|
||||
Username
|
||||
{t("common.username")}
|
||||
<span className="text-sm text-gray-400 ml-1">(Using to sign in)</span>
|
||||
</p>
|
||||
<input type="text" className="input-text" value={state.username} onChange={handleUsernameChanged} />
|
||||
<p className="text-sm mb-1 mt-2">
|
||||
Email<span className="text-sm text-gray-400 ml-1">(Optional)</span>
|
||||
{t("common.email")}
|
||||
<span className="text-sm text-gray-400 ml-1">(Optional)</span>
|
||||
</p>
|
||||
<input type="text" className="input-text" value={state.email} onChange={handleEmailChanged} />
|
||||
<div className="mt-4 w-full flex flex-row justify-end items-center space-x-2">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGlobalStore } from "../store/module";
|
||||
import * as api from "../helpers/api";
|
||||
@@ -15,11 +15,7 @@ const UpdateCustomizedProfileDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
const globalStore = useGlobalStore();
|
||||
const [state, setState] = useState<CustomizedProfile>(globalStore.state.systemStatus.customizedProfile);
|
||||
|
||||
useEffect(() => {
|
||||
// do nth
|
||||
}, []);
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
const handleCloseButtonClick = () => {
|
||||
destroy();
|
||||
};
|
||||
|
||||
@@ -68,9 +64,20 @@ const UpdateCustomizedProfileDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (state.name === "" || state.logoUrl === "") {
|
||||
toastHelper.error(t("message.fill-all"));
|
||||
const handleRestoreButtonClick = () => {
|
||||
setState({
|
||||
name: "memos",
|
||||
logoUrl: "/logo.png",
|
||||
description: "",
|
||||
locale: "en",
|
||||
appearance: "system",
|
||||
externalUrl: "",
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveButtonClick = async () => {
|
||||
if (state.name === "") {
|
||||
toastHelper.error("Please fill server name");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -92,7 +99,7 @@ const UpdateCustomizedProfileDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">{t("setting.system-section.customize-server.title")}</p>
|
||||
<button className="btn close-btn" onClick={handleCloseBtnClick}>
|
||||
<button className="btn close-btn" onClick={handleCloseButtonClick}>
|
||||
<Icon.X />
|
||||
</button>
|
||||
</div>
|
||||
@@ -110,13 +117,20 @@ const UpdateCustomizedProfileDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
<LocaleSelect className="w-full" value={state.locale} onChange={handleLocaleSelectChange} />
|
||||
<p className="text-sm mb-1 mt-2">Server appearance</p>
|
||||
<AppearanceSelect className="w-full" value={state.appearance} onChange={handleAppearanceSelectChange} />
|
||||
<div className="mt-4 w-full flex flex-row justify-end items-center space-x-2">
|
||||
<span className="btn-text" onClick={handleCloseBtnClick}>
|
||||
{t("common.cancel")}
|
||||
</span>
|
||||
<span className="btn-primary" onClick={handleSaveBtnClick}>
|
||||
{t("common.save")}
|
||||
</span>
|
||||
<div className="mt-4 w-full flex flex-row justify-between items-center space-x-2">
|
||||
<div className="flex flex-row justify-start items-center">
|
||||
<button className="btn-normal" onClick={handleRestoreButtonClick}>
|
||||
{t("common.restore")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-row justify-end items-center">
|
||||
<button className="btn-text" onClick={handleCloseButtonClick}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button className="btn-primary" onClick={handleSaveButtonClick}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useLocationStore, useMemoStore, useTagStore, useUserStore } from "../store/module";
|
||||
import { getMemoStats } from "../helpers/api";
|
||||
import * as utils from "../helpers/utils";
|
||||
@@ -13,7 +12,6 @@ import "../less/user-banner.less";
|
||||
|
||||
const UserBanner = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const locationStore = useLocationStore();
|
||||
const userStore = useUserStore();
|
||||
const memoStore = useMemoStore();
|
||||
@@ -66,7 +64,8 @@ const UserBanner = () => {
|
||||
};
|
||||
|
||||
const handleSignOutBtnClick = async () => {
|
||||
navigate("/auth");
|
||||
await userStore.doSignOut();
|
||||
window.location.href = "/auth";
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
html,
|
||||
body {
|
||||
@apply text-base dark:bg-zinc-800;
|
||||
@apply text-base w-full h-full dark:bg-zinc-800;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Noto Sans", "Noto Sans CJK SC", "Microsoft YaHei UI", "Microsoft YaHei",
|
||||
"WenQuanYi Micro Hei", sans-serif, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||
"Noto Color Emoji";
|
||||
}
|
||||
|
||||
#root {
|
||||
@apply w-full h-full;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function signup(username: string, password: string, role: UserRole) {
|
||||
}
|
||||
|
||||
export function signout() {
|
||||
return axios.post("/api/auth/logout");
|
||||
return axios.post("/api/auth/signout");
|
||||
}
|
||||
|
||||
export function createUser(userCreate: UserCreate) {
|
||||
|
||||
@@ -8,6 +8,9 @@ import nlLocale from "./locales/nl.json";
|
||||
import svLocale from "./locales/sv.json";
|
||||
import deLocale from "./locales/de.json";
|
||||
import esLocale from "./locales/es.json";
|
||||
import ukLocale from "./locales/uk.json";
|
||||
import ruLocale from "./locales/ru.json";
|
||||
import itLocale from "./locales/it.json";
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
@@ -35,6 +38,15 @@ i18n.use(initReactI18next).init({
|
||||
es: {
|
||||
translation: esLocale,
|
||||
},
|
||||
uk: {
|
||||
translation: ukLocale,
|
||||
},
|
||||
ru: {
|
||||
translation: ruLocale,
|
||||
},
|
||||
it: {
|
||||
translation: itLocale,
|
||||
},
|
||||
},
|
||||
lng: "nl",
|
||||
fallbackLng: "en",
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { escape } from "lodash";
|
||||
|
||||
const walkthroughNodeWithKeyword = (node: HTMLElement, keyword: string) => {
|
||||
if (node.nodeType === 3) {
|
||||
const span = document.createElement("span");
|
||||
span.innerHTML = node.nodeValue?.replace(new RegExp(keyword, "g"), `<mark>${keyword}</mark>`) ?? "";
|
||||
node.parentNode?.insertBefore(span, node);
|
||||
node.parentNode?.removeChild(node);
|
||||
}
|
||||
for (const child of Array.from(node.childNodes)) {
|
||||
walkthroughNodeWithKeyword(<HTMLElement>child, keyword);
|
||||
}
|
||||
return node.innerHTML;
|
||||
};
|
||||
|
||||
export const highlightWithWord = (html: string, keyword?: string): string => {
|
||||
if (!keyword) {
|
||||
return html;
|
||||
}
|
||||
keyword = escape(keyword);
|
||||
const wrap = document.createElement("div");
|
||||
wrap.innerHTML = escape(html);
|
||||
return walkthroughNodeWithKeyword(wrap, keyword);
|
||||
};
|
||||
@@ -1,8 +1,13 @@
|
||||
import { matcher } from "./matcher";
|
||||
import { blockElementParserList, inlineElementParserList } from "./parser";
|
||||
|
||||
export const marked = (markdownStr: string, blockParsers = blockElementParserList, inlineParsers = inlineElementParserList): string => {
|
||||
export const marked = (
|
||||
markdownStr: string,
|
||||
blockParsers = blockElementParserList,
|
||||
inlineParsers = inlineElementParserList
|
||||
): string | JSX.Element => {
|
||||
for (const parser of blockParsers) {
|
||||
const matchResult = parser.matcher(markdownStr);
|
||||
const matchResult = matcher(markdownStr, parser.regexp);
|
||||
if (!matchResult) {
|
||||
continue;
|
||||
}
|
||||
@@ -10,12 +15,22 @@ export const marked = (markdownStr: string, blockParsers = blockElementParserLis
|
||||
const retainContent = markdownStr.slice(matchedStr.length);
|
||||
|
||||
if (parser.name === "br") {
|
||||
return parser.renderer(matchedStr) + marked(retainContent, blockParsers, inlineParsers);
|
||||
return (
|
||||
<>
|
||||
{parser.renderer(matchedStr)}
|
||||
{marked(retainContent, blockParsers, inlineParsers)}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
if (retainContent === "") {
|
||||
return parser.renderer(matchedStr);
|
||||
} else if (retainContent.startsWith("\n")) {
|
||||
return parser.renderer(matchedStr) + marked(retainContent.slice(1), blockParsers, inlineParsers);
|
||||
return (
|
||||
<>
|
||||
{parser.renderer(matchedStr)}
|
||||
{marked(retainContent.slice(1), blockParsers, inlineParsers)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +39,7 @@ export const marked = (markdownStr: string, blockParsers = blockElementParserLis
|
||||
let matchedIndex = -1;
|
||||
|
||||
for (const parser of inlineParsers) {
|
||||
const matchResult = parser.matcher(markdownStr);
|
||||
const matchResult = matcher(markdownStr, parser.regexp);
|
||||
if (!matchResult) {
|
||||
continue;
|
||||
}
|
||||
@@ -41,17 +56,23 @@ export const marked = (markdownStr: string, blockParsers = blockElementParserLis
|
||||
}
|
||||
|
||||
if (matchedInlineParser) {
|
||||
const matchResult = matchedInlineParser.matcher(markdownStr);
|
||||
const matchResult = matcher(markdownStr, matchedInlineParser.regexp);
|
||||
if (matchResult) {
|
||||
const matchedStr = matchResult[0];
|
||||
const matchedLength = matchedStr.length;
|
||||
const prefixStr = markdownStr.slice(0, matchedIndex);
|
||||
const suffixStr = markdownStr.slice(matchedIndex + matchedLength);
|
||||
return prefixStr + matchedInlineParser.renderer(matchedStr) + marked(suffixStr, [], inlineParsers);
|
||||
return (
|
||||
<>
|
||||
{marked(prefixStr, [], inlineParsers)}
|
||||
{matchedInlineParser.renderer(matchedStr)}
|
||||
{marked(suffixStr, [], inlineParsers)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return markdownStr;
|
||||
return <>{markdownStr}</>;
|
||||
};
|
||||
|
||||
interface MatchedNode {
|
||||
@@ -64,7 +85,7 @@ export const getMatchedNodes = (markdownStr: string): MatchedNode[] => {
|
||||
|
||||
const walkthough = (markdownStr: string, blockParsers = blockElementParserList, inlineParsers = inlineElementParserList): string => {
|
||||
for (const parser of blockParsers) {
|
||||
const matchResult = parser.matcher(markdownStr);
|
||||
const matchResult = matcher(markdownStr, parser.regexp);
|
||||
if (!matchResult) {
|
||||
continue;
|
||||
}
|
||||
@@ -79,6 +100,7 @@ export const getMatchedNodes = (markdownStr: string): MatchedNode[] => {
|
||||
return walkthough(retainContent, blockParsers, inlineParsers);
|
||||
} else {
|
||||
if (retainContent.startsWith("\n")) {
|
||||
walkthough(matchedStr, [], inlineParsers);
|
||||
return walkthough(retainContent.slice(1), blockParsers, inlineParsers);
|
||||
}
|
||||
}
|
||||
@@ -88,7 +110,7 @@ export const getMatchedNodes = (markdownStr: string): MatchedNode[] => {
|
||||
let matchedIndex = -1;
|
||||
|
||||
for (const parser of inlineParsers) {
|
||||
const matchResult = parser.matcher(markdownStr);
|
||||
const matchResult = matcher(markdownStr, parser.regexp);
|
||||
if (!matchResult) {
|
||||
continue;
|
||||
}
|
||||
@@ -105,7 +127,7 @@ export const getMatchedNodes = (markdownStr: string): MatchedNode[] => {
|
||||
}
|
||||
|
||||
if (matchedInlineParser) {
|
||||
const matchResult = matchedInlineParser.matcher(markdownStr);
|
||||
const matchResult = matcher(markdownStr, matchedInlineParser.regexp);
|
||||
if (matchResult) {
|
||||
const matchedStr = matchResult[0];
|
||||
const matchedLength = matchedStr.length;
|
||||
@@ -1,174 +0,0 @@
|
||||
/* eslint-disable no-irregular-whitespace */
|
||||
import { describe, expect, test } from "@jest/globals";
|
||||
import { unescape } from "lodash-es";
|
||||
import { marked } from ".";
|
||||
|
||||
describe("test marked parser", () => {
|
||||
test("horizontal rule", () => {
|
||||
const tests = [
|
||||
{
|
||||
markdown: `---
|
||||
This is some text after the horizontal rule.
|
||||
___
|
||||
This is some text after the horizontal rule.
|
||||
***
|
||||
This is some text after the horizontal rule.`,
|
||||
want: `<hr><p>This is some text after the horizontal rule.</p><hr><p>This is some text after the horizontal rule.</p><hr><p>This is some text after the horizontal rule.</p>`,
|
||||
},
|
||||
];
|
||||
for (const t of tests) {
|
||||
expect(unescape(marked(t.markdown))).toBe(t.want);
|
||||
}
|
||||
});
|
||||
test("parse code block", () => {
|
||||
const tests = [
|
||||
{
|
||||
markdown: `\`\`\`
|
||||
hello world!
|
||||
\`\`\``,
|
||||
want: `<pre><code class="language-plaintext">hello world!
|
||||
</code></pre>`,
|
||||
},
|
||||
{
|
||||
markdown: `test code block
|
||||
|
||||
\`\`\`js
|
||||
console.log("hello world!")
|
||||
\`\`\``,
|
||||
want: `<p>test code block</p><br><pre><code class="language-js"><span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(<span class="hljs-string">"hello world!"</span>)
|
||||
</code></pre>`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const t of tests) {
|
||||
expect(unescape(marked(t.markdown))).toBe(t.want);
|
||||
}
|
||||
});
|
||||
test("parse todo list block", () => {
|
||||
const tests = [
|
||||
{
|
||||
markdown: `My task:
|
||||
- [ ] finish my homework
|
||||
- [x] yahaha`,
|
||||
want: `<p>My task:</p><p class='li-container'><span class='todo-block todo' data-value='TODO'></span><span>finish my homework</span></p><p class='li-container'><span class='todo-block done' data-value='DONE'>✓</span><span>yahaha</span></p>`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const t of tests) {
|
||||
expect(unescape(marked(t.markdown))).toBe(t.want);
|
||||
}
|
||||
});
|
||||
test("parse list block", () => {
|
||||
const tests = [
|
||||
{
|
||||
markdown: `This is a list
|
||||
* list 123
|
||||
1. 123123`,
|
||||
want: `<p>This is a list</p><p class='li-container'><span class='ul-block'>•</span><span>list 123</span></p><p class='li-container'><span class='ol-block'>1.</span><span>123123</span></p>`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const t of tests) {
|
||||
expect(unescape(marked(t.markdown))).toBe(t.want);
|
||||
}
|
||||
});
|
||||
test("parse inline element", () => {
|
||||
const tests = [
|
||||
{
|
||||
markdown: `Link: [baidu](https://baidu.com#1231)`,
|
||||
want: `<p>Link: <a class='link' target='_blank' rel='noreferrer' href='https://baidu.com#1231'>baidu</a></p>`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const t of tests) {
|
||||
expect(unescape(marked(t.markdown))).toBe(t.want);
|
||||
}
|
||||
});
|
||||
test("parse inline code within inline element", () => {
|
||||
const tests = [
|
||||
{
|
||||
markdown: `Link: [\`baidu\`](https://baidu.com)`,
|
||||
want: `<p>Link: <a class='link' target='_blank' rel='noreferrer' href='https://baidu.com'><code>baidu</code></a></p>`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const t of tests) {
|
||||
expect(unescape(marked(t.markdown))).toBe(t.want);
|
||||
}
|
||||
});
|
||||
test("parse plain link", () => {
|
||||
const tests = [
|
||||
{
|
||||
markdown: `Link:https://baidu.com#1231`,
|
||||
want: `<p>Link:<a class='link' target='_blank' rel='noreferrer' href='https://baidu.com#1231'>https://baidu.com#1231</a></p>`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const t of tests) {
|
||||
expect(unescape(marked(t.markdown))).toBe(t.want);
|
||||
}
|
||||
});
|
||||
test("parse inline code", () => {
|
||||
const tests = [
|
||||
{
|
||||
markdown: `Code: \`console.log("Hello world!")\``,
|
||||
want: `<p>Code: <code>console.log("Hello world!")</code></p>`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const t of tests) {
|
||||
expect(unescape(marked(t.markdown))).toBe(t.want);
|
||||
}
|
||||
});
|
||||
test("parse bold and em text", () => {
|
||||
const tests = [
|
||||
{
|
||||
markdown: `Important: **Minecraft**`,
|
||||
want: `<p>Important: <strong>Minecraft</strong></p>`,
|
||||
},
|
||||
{
|
||||
markdown: `Em: *Minecraft*`,
|
||||
want: `<p>Em: <em>Minecraft</em></p>`,
|
||||
},
|
||||
{
|
||||
markdown: `Important: ***Minecraft/123***`,
|
||||
want: `<p>Important: <strong><em>Minecraft/123</em></strong></p>`,
|
||||
},
|
||||
{
|
||||
markdown: `Important: ***[baidu](https://baidu.com)***`,
|
||||
want: `<p>Important: <strong><em><a class='link' target='_blank' rel='noreferrer' href='https://baidu.com'>baidu</a></em></strong></p>`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const t of tests) {
|
||||
expect(unescape(marked(t.markdown))).toBe(t.want);
|
||||
}
|
||||
});
|
||||
test("parse full width space", () => {
|
||||
const tests = [
|
||||
{
|
||||
markdown: ` line1
|
||||
line2`,
|
||||
want: `<p> line1</p><p> line2</p>`,
|
||||
},
|
||||
];
|
||||
for (const t of tests) {
|
||||
expect(unescape(marked(t.markdown))).toBe(t.want);
|
||||
}
|
||||
});
|
||||
test("parse heading", () => {
|
||||
const tests = [
|
||||
{
|
||||
markdown: `# 123 `,
|
||||
want: `<h1>123 </h1>`,
|
||||
},
|
||||
{
|
||||
markdown: `## 123 `,
|
||||
want: `<h2>123 </h2>`,
|
||||
},
|
||||
];
|
||||
for (const t of tests) {
|
||||
expect(unescape(marked(t.markdown))).toBe(t.want);
|
||||
}
|
||||
});
|
||||
});
|
||||
4
web/src/labs/marked/matcher.ts
Normal file
4
web/src/labs/marked/matcher.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const matcher = (rawStr: string, regexp: RegExp) => {
|
||||
const matchResult = rawStr.match(regexp);
|
||||
return matchResult;
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import { escape } from "lodash";
|
||||
|
||||
export const BLOCKQUOTE_REG = /^> ([^\n]+)/;
|
||||
|
||||
const matcher = (rawStr: string) => {
|
||||
const matchResult = rawStr.match(BLOCKQUOTE_REG);
|
||||
return matchResult;
|
||||
};
|
||||
|
||||
const renderer = (rawStr: string): string => {
|
||||
const matchResult = matcher(rawStr);
|
||||
if (!matchResult) {
|
||||
return rawStr;
|
||||
}
|
||||
|
||||
return `<blockquote>${escape(matchResult[1])}</blockquote>`;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "blockquote",
|
||||
regex: BLOCKQUOTE_REG,
|
||||
matcher,
|
||||
renderer,
|
||||
};
|
||||
19
web/src/labs/marked/parser/Blockquote.tsx
Normal file
19
web/src/labs/marked/parser/Blockquote.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { escape } from "lodash";
|
||||
import { matcher } from "../matcher";
|
||||
|
||||
export const BLOCKQUOTE_REG = /^> ([^\n]+)/;
|
||||
|
||||
const renderer = (rawStr: string) => {
|
||||
const matchResult = matcher(rawStr, BLOCKQUOTE_REG);
|
||||
if (!matchResult) {
|
||||
return <>{rawStr}</>;
|
||||
}
|
||||
|
||||
return <blockquote>{escape(matchResult[1])}</blockquote>;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "blockquote",
|
||||
regexp: BLOCKQUOTE_REG,
|
||||
renderer,
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import { escape } from "lodash";
|
||||
import { marked } from "..";
|
||||
import Link from "./Link";
|
||||
|
||||
export const BOLD_REG = /\*\*(.+?)\*\*/;
|
||||
|
||||
const matcher = (rawStr: string) => {
|
||||
const matchResult = rawStr.match(BOLD_REG);
|
||||
return matchResult;
|
||||
};
|
||||
|
||||
const renderer = (rawStr: string): string => {
|
||||
const matchResult = matcher(rawStr);
|
||||
if (!matchResult) {
|
||||
return rawStr;
|
||||
}
|
||||
|
||||
const parsedContent = marked(escape(matchResult[1]), [], [Link]);
|
||||
return `<strong>${parsedContent}</strong>`;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "bold",
|
||||
regex: BOLD_REG,
|
||||
matcher,
|
||||
renderer,
|
||||
};
|
||||
22
web/src/labs/marked/parser/Bold.tsx
Normal file
22
web/src/labs/marked/parser/Bold.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { marked } from "..";
|
||||
import { matcher } from "../matcher";
|
||||
import Link from "./Link";
|
||||
import PlainText from "./PlainText";
|
||||
|
||||
export const BOLD_REG = /\*\*(.+?)\*\*/;
|
||||
|
||||
const renderer = (rawStr: string) => {
|
||||
const matchResult = matcher(rawStr, BOLD_REG);
|
||||
if (!matchResult) {
|
||||
return <>{rawStr}</>;
|
||||
}
|
||||
|
||||
const parsedContent = marked(matchResult[1], [], [Link, PlainText]);
|
||||
return <strong>{parsedContent}</strong>;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "bold",
|
||||
regexp: BOLD_REG,
|
||||
renderer,
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import { escape } from "lodash";
|
||||
import { marked } from "..";
|
||||
import Link from "./Link";
|
||||
|
||||
export const BOLD_EMPHASIS_REG = /\*\*\*(.+?)\*\*\*/;
|
||||
|
||||
const matcher = (rawStr: string) => {
|
||||
const matchResult = rawStr.match(BOLD_EMPHASIS_REG);
|
||||
return matchResult;
|
||||
};
|
||||
|
||||
const renderer = (rawStr: string): string => {
|
||||
const matchResult = matcher(rawStr);
|
||||
if (!matchResult) {
|
||||
return rawStr;
|
||||
}
|
||||
|
||||
const parsedContent = marked(escape(matchResult[1]), [], [Link]);
|
||||
return `<strong><em>${parsedContent}</em></strong>`;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "bold emphasis",
|
||||
regex: BOLD_EMPHASIS_REG,
|
||||
matcher,
|
||||
renderer,
|
||||
};
|
||||
26
web/src/labs/marked/parser/BoldEmphasis.tsx
Normal file
26
web/src/labs/marked/parser/BoldEmphasis.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { marked } from "..";
|
||||
import { matcher } from "../matcher";
|
||||
import Link from "./Link";
|
||||
import PlainText from "./PlainText";
|
||||
|
||||
export const BOLD_EMPHASIS_REG = /\*\*\*(.+?)\*\*\*/;
|
||||
|
||||
const renderer = (rawStr: string) => {
|
||||
const matchResult = matcher(rawStr, BOLD_EMPHASIS_REG);
|
||||
if (!matchResult) {
|
||||
return rawStr;
|
||||
}
|
||||
|
||||
const parsedContent = marked(matchResult[1], [], [Link, PlainText]);
|
||||
return (
|
||||
<strong>
|
||||
<em>${parsedContent}</em>
|
||||
</strong>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "bold emphasis",
|
||||
regexp: BOLD_EMPHASIS_REG,
|
||||
renderer,
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
export const BR_REG = /^(\n+)/;
|
||||
|
||||
const matcher = (rawStr: string) => {
|
||||
const matchResult = rawStr.match(BR_REG);
|
||||
return matchResult;
|
||||
};
|
||||
|
||||
const renderer = (rawStr: string): string => {
|
||||
return rawStr.replaceAll("\n", "<br>");
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "br",
|
||||
regex: BR_REG,
|
||||
matcher,
|
||||
renderer,
|
||||
};
|
||||
16
web/src/labs/marked/parser/Br.tsx
Normal file
16
web/src/labs/marked/parser/Br.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
export const BR_REG = /^(\n+)/;
|
||||
|
||||
const renderer = (rawStr: string) => {
|
||||
const length = rawStr.split("\n").length - 1;
|
||||
const brList = [];
|
||||
for (let i = 0; i < length; i++) {
|
||||
brList.push(<br />);
|
||||
}
|
||||
return <>{...brList}</>;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "br",
|
||||
regexp: BR_REG,
|
||||
renderer,
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
import { escape } from "lodash-es";
|
||||
import hljs from "highlight.js";
|
||||
|
||||
export const CODE_BLOCK_REG = /^```(\S*?)\s([\s\S]*?)```/;
|
||||
|
||||
const matcher = (rawStr: string) => {
|
||||
const matchResult = rawStr.match(CODE_BLOCK_REG);
|
||||
return matchResult;
|
||||
};
|
||||
|
||||
const renderer = (rawStr: string): string => {
|
||||
const matchResult = matcher(rawStr);
|
||||
if (!matchResult) {
|
||||
return rawStr;
|
||||
}
|
||||
|
||||
const language = escape(matchResult[1]) || "plaintext";
|
||||
let highlightedCode = hljs.highlightAuto(matchResult[2]).value;
|
||||
|
||||
try {
|
||||
const temp = hljs.highlight(matchResult[2], {
|
||||
language,
|
||||
}).value;
|
||||
highlightedCode = temp;
|
||||
} catch (error) {
|
||||
// do nth
|
||||
}
|
||||
|
||||
return `<pre><code class="language-${language}">${highlightedCode}</code></pre>`;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "code block",
|
||||
regex: CODE_BLOCK_REG,
|
||||
matcher,
|
||||
renderer,
|
||||
};
|
||||
51
web/src/labs/marked/parser/CodeBlock.tsx
Normal file
51
web/src/labs/marked/parser/CodeBlock.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { escape } from "lodash-es";
|
||||
import hljs from "highlight.js";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { matcher } from "../matcher";
|
||||
import toastHelper from "../../../components/Toast";
|
||||
|
||||
export const CODE_BLOCK_REG = /^```(\S*?)\s([\s\S]*?)```/;
|
||||
|
||||
const renderer = (rawStr: string) => {
|
||||
const { t } = useTranslation();
|
||||
const matchResult = matcher(rawStr, CODE_BLOCK_REG);
|
||||
if (!matchResult) {
|
||||
return <>{rawStr}</>;
|
||||
}
|
||||
|
||||
const language = escape(matchResult[1]) || "plaintext";
|
||||
let highlightedCode = hljs.highlightAuto(matchResult[2]).value;
|
||||
|
||||
try {
|
||||
const temp = hljs.highlight(matchResult[2], {
|
||||
language,
|
||||
}).value;
|
||||
highlightedCode = temp;
|
||||
} catch (error) {
|
||||
// do nth
|
||||
}
|
||||
|
||||
const handleCopyButtonClick = () => {
|
||||
copy(matchResult[2]);
|
||||
toastHelper.success(t("message.succeed-copy-code"));
|
||||
};
|
||||
|
||||
return (
|
||||
<pre>
|
||||
<button
|
||||
className="text-xs font-mono italic absolute top-0 right-0 px-2 leading-6 border btn-text rounded opacity-60"
|
||||
onClick={handleCopyButtonClick}
|
||||
>
|
||||
copy
|
||||
</button>
|
||||
<code className={`language-${language}`}>{highlightedCode}</code>
|
||||
</pre>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "code block",
|
||||
regexp: CODE_BLOCK_REG,
|
||||
renderer,
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
import { inlineElementParserList } from ".";
|
||||
import { marked } from "..";
|
||||
|
||||
export const DONE_LIST_REG = /^- \[[xX]\] ([^\n]+)/;
|
||||
|
||||
const matcher = (rawStr: string) => {
|
||||
const matchResult = rawStr.match(DONE_LIST_REG);
|
||||
return matchResult;
|
||||
};
|
||||
|
||||
const renderer = (rawStr: string): string => {
|
||||
const matchResult = matcher(rawStr);
|
||||
if (!matchResult) {
|
||||
return rawStr;
|
||||
}
|
||||
|
||||
const parsedContent = marked(matchResult[1], [], inlineElementParserList);
|
||||
return `<p class='li-container'><span class='todo-block done' data-value='DONE'>✓</span><span>${parsedContent}</span></p>`;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "done list",
|
||||
regex: DONE_LIST_REG,
|
||||
matcher,
|
||||
renderer,
|
||||
};
|
||||
28
web/src/labs/marked/parser/DoneList.tsx
Normal file
28
web/src/labs/marked/parser/DoneList.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { inlineElementParserList } from ".";
|
||||
import { marked } from "..";
|
||||
import { matcher } from "../matcher";
|
||||
|
||||
export const DONE_LIST_REG = /^- \[[xX]\] ([^\n]+)/;
|
||||
|
||||
const renderer = (rawStr: string) => {
|
||||
const matchResult = matcher(rawStr, DONE_LIST_REG);
|
||||
if (!matchResult) {
|
||||
return rawStr;
|
||||
}
|
||||
|
||||
const parsedContent = marked(matchResult[1], [], inlineElementParserList);
|
||||
return (
|
||||
<p className="li-container">
|
||||
<span className="todo-block done" data-value="DONE">
|
||||
✓
|
||||
</span>
|
||||
<span>{parsedContent}</span>
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "done list",
|
||||
regexp: DONE_LIST_REG,
|
||||
renderer,
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import { escape } from "lodash";
|
||||
import { marked } from "..";
|
||||
import Link from "./Link";
|
||||
|
||||
export const EMPHASIS_REG = /\*(.+?)\*/;
|
||||
|
||||
const matcher = (rawStr: string) => {
|
||||
const matchResult = rawStr.match(EMPHASIS_REG);
|
||||
return matchResult;
|
||||
};
|
||||
|
||||
const renderer = (rawStr: string): string => {
|
||||
const matchResult = matcher(rawStr);
|
||||
if (!matchResult) {
|
||||
return rawStr;
|
||||
}
|
||||
|
||||
const parsedContent = marked(escape(matchResult[1]), [], [Link]);
|
||||
return `<em>${parsedContent}</em>`;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "emphasis",
|
||||
regex: EMPHASIS_REG,
|
||||
matcher,
|
||||
renderer,
|
||||
};
|
||||
22
web/src/labs/marked/parser/Emphasis.tsx
Normal file
22
web/src/labs/marked/parser/Emphasis.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { marked } from "..";
|
||||
import { matcher } from "../matcher";
|
||||
import Link from "./Link";
|
||||
import PlainText from "./PlainText";
|
||||
|
||||
export const EMPHASIS_REG = /\*(.+?)\*/;
|
||||
|
||||
const renderer = (rawStr: string) => {
|
||||
const matchResult = matcher(rawStr, EMPHASIS_REG);
|
||||
if (!matchResult) {
|
||||
return rawStr;
|
||||
}
|
||||
|
||||
const parsedContent = marked(matchResult[1], [], [Link, PlainText]);
|
||||
return <em>{parsedContent}</em>;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "emphasis",
|
||||
regexp: EMPHASIS_REG,
|
||||
renderer,
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import { escape } from "lodash";
|
||||
|
||||
export const HEADING_REG = /^(#+) ([^\n]+)/;
|
||||
|
||||
const matcher = (rawStr: string) => {
|
||||
const matchResult = rawStr.match(HEADING_REG);
|
||||
return matchResult;
|
||||
};
|
||||
|
||||
const renderer = (rawStr: string): string => {
|
||||
const matchResult = matcher(rawStr);
|
||||
if (!matchResult) {
|
||||
return rawStr;
|
||||
}
|
||||
|
||||
const level = matchResult[1].length;
|
||||
return `<h${level}>${escape(matchResult[2])}</h${level}>`;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "heading",
|
||||
regex: HEADING_REG,
|
||||
matcher,
|
||||
renderer,
|
||||
};
|
||||
29
web/src/labs/marked/parser/Heading.tsx
Normal file
29
web/src/labs/marked/parser/Heading.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { escape } from "lodash";
|
||||
import { matcher } from "../matcher";
|
||||
|
||||
export const HEADING_REG = /^(#+) ([^\n]+)/;
|
||||
|
||||
const renderer = (rawStr: string) => {
|
||||
const matchResult = matcher(rawStr, HEADING_REG);
|
||||
if (!matchResult) {
|
||||
return rawStr;
|
||||
}
|
||||
|
||||
const level = matchResult[1].length;
|
||||
if (level === 1) {
|
||||
return <h1>{escape(matchResult[2])}</h1>;
|
||||
} else if (level === 2) {
|
||||
return <h2>{escape(matchResult[2])}</h2>;
|
||||
} else if (level === 3) {
|
||||
return <h3>{escape(matchResult[2])}</h3>;
|
||||
} else if (level === 4) {
|
||||
return <h4>{escape(matchResult[2])}</h4>;
|
||||
}
|
||||
return <h5>{escape(matchResult[2])}</h5>;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "heading",
|
||||
regexp: HEADING_REG,
|
||||
renderer,
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
export const HORIZONTAL_RULES_REG = /^_{3}|^-{3}|^\*{3}/;
|
||||
|
||||
const matcher = (rawStr: string) => {
|
||||
const matchResult = rawStr.match(HORIZONTAL_RULES_REG);
|
||||
return matchResult;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const renderer = (rawStr: string): string => {
|
||||
return `<hr>`;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "horizontal rules",
|
||||
regex: HORIZONTAL_RULES_REG,
|
||||
matcher,
|
||||
renderer,
|
||||
};
|
||||
12
web/src/labs/marked/parser/HorizontalRules.tsx
Normal file
12
web/src/labs/marked/parser/HorizontalRules.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
export const HORIZONTAL_RULES_REG = /^_{3}|^-{3}|^\*{3}/;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const renderer = (rawStr: string) => {
|
||||
return <hr />;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "horizontal rules",
|
||||
regexp: HORIZONTAL_RULES_REG,
|
||||
renderer,
|
||||
};
|
||||
@@ -1,26 +1,21 @@
|
||||
import { escape } from "lodash-es";
|
||||
import { absolutifyLink } from "../../../helpers/utils";
|
||||
import { matcher } from "../matcher";
|
||||
|
||||
export const IMAGE_REG = /!\[.*?\]\((.+?)\)/;
|
||||
|
||||
const matcher = (rawStr: string) => {
|
||||
const matchResult = rawStr.match(IMAGE_REG);
|
||||
return matchResult;
|
||||
};
|
||||
|
||||
const renderer = (rawStr: string): string => {
|
||||
const matchResult = matcher(rawStr);
|
||||
const renderer = (rawStr: string) => {
|
||||
const matchResult = matcher(rawStr, IMAGE_REG);
|
||||
if (!matchResult) {
|
||||
return rawStr;
|
||||
}
|
||||
|
||||
const imageUrl = absolutifyLink(escape(matchResult[1]));
|
||||
return `<img class='img' src='${imageUrl}' />`;
|
||||
return <img className="img" src={imageUrl} />;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "image",
|
||||
regex: IMAGE_REG,
|
||||
matcher,
|
||||
regexp: IMAGE_REG,
|
||||
renderer,
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import { escape } from "lodash-es";
|
||||
|
||||
export const INLINE_CODE_REG = /`(.+?)`/;
|
||||
|
||||
const matcher = (rawStr: string) => {
|
||||
const matchResult = rawStr.match(INLINE_CODE_REG);
|
||||
return matchResult;
|
||||
};
|
||||
|
||||
const renderer = (rawStr: string): string => {
|
||||
const matchResult = matcher(rawStr);
|
||||
if (!matchResult) {
|
||||
return rawStr;
|
||||
}
|
||||
|
||||
return `<code>${escape(matchResult[1])}</code>`;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "inline code",
|
||||
regex: INLINE_CODE_REG,
|
||||
matcher,
|
||||
renderer,
|
||||
};
|
||||
19
web/src/labs/marked/parser/InlineCode.tsx
Normal file
19
web/src/labs/marked/parser/InlineCode.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { escape } from "lodash-es";
|
||||
import { matcher } from "../matcher";
|
||||
|
||||
export const INLINE_CODE_REG = /`(.+?)`/;
|
||||
|
||||
const renderer = (rawStr: string) => {
|
||||
const matchResult = matcher(rawStr, INLINE_CODE_REG);
|
||||
if (!matchResult) {
|
||||
return rawStr;
|
||||
}
|
||||
|
||||
return <code>{escape(matchResult[1])}</code>;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "inline code",
|
||||
regexp: INLINE_CODE_REG,
|
||||
renderer,
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
import { escape } from "lodash-es";
|
||||
import Emphasis from "./Emphasis";
|
||||
import Bold from "./Bold";
|
||||
import { marked } from "..";
|
||||
import InlineCode from "./InlineCode";
|
||||
import BoldEmphasis from "./BoldEmphasis";
|
||||
|
||||
export const LINK_REG = /\[(.*?)\]\((.+?)\)+/;
|
||||
|
||||
const matcher = (rawStr: string) => {
|
||||
const matchResult = rawStr.match(LINK_REG);
|
||||
return matchResult;
|
||||
};
|
||||
|
||||
const renderer = (rawStr: string): string => {
|
||||
const matchResult = matcher(rawStr);
|
||||
if (!matchResult) {
|
||||
return rawStr;
|
||||
}
|
||||
const parsedContent = marked(escape(matchResult[1]), [], [InlineCode, BoldEmphasis, Emphasis, Bold]);
|
||||
return `<a class='link' target='_blank' rel='noreferrer' href='${escape(matchResult[2])}'>${parsedContent}</a>`;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "link",
|
||||
regex: LINK_REG,
|
||||
matcher,
|
||||
renderer,
|
||||
};
|
||||
29
web/src/labs/marked/parser/Link.tsx
Normal file
29
web/src/labs/marked/parser/Link.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { escape } from "lodash-es";
|
||||
import Emphasis from "./Emphasis";
|
||||
import Bold from "./Bold";
|
||||
import { marked } from "..";
|
||||
import InlineCode from "./InlineCode";
|
||||
import BoldEmphasis from "./BoldEmphasis";
|
||||
import PlainText from "./PlainText";
|
||||
import { matcher } from "../matcher";
|
||||
|
||||
export const LINK_REG = /\[(.*?)\]\((.+?)\)+/;
|
||||
|
||||
const renderer = (rawStr: string) => {
|
||||
const matchResult = matcher(rawStr, LINK_REG);
|
||||
if (!matchResult) {
|
||||
return rawStr;
|
||||
}
|
||||
const parsedContent = marked(matchResult[1], [], [InlineCode, BoldEmphasis, Emphasis, Bold, PlainText]);
|
||||
return (
|
||||
<a className="link" target="_blank" rel="noreferrer" href={escape(matchResult[2])}>
|
||||
{parsedContent}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "link",
|
||||
regexp: LINK_REG,
|
||||
renderer,
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
import { inlineElementParserList } from ".";
|
||||
import { marked } from "..";
|
||||
|
||||
export const ORDERED_LIST_REG = /^(\d+)\. (.+)/;
|
||||
|
||||
const matcher = (rawStr: string) => {
|
||||
const matchResult = rawStr.match(ORDERED_LIST_REG);
|
||||
return matchResult;
|
||||
};
|
||||
|
||||
const renderer = (rawStr: string): string => {
|
||||
const matchResult = matcher(rawStr);
|
||||
if (!matchResult) {
|
||||
return rawStr;
|
||||
}
|
||||
|
||||
const parsedContent = marked(matchResult[2], [], inlineElementParserList);
|
||||
return `<p class='li-container'><span class='ol-block'>${matchResult[1]}.</span><span>${parsedContent}</span></p>`;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "ordered list",
|
||||
regex: ORDERED_LIST_REG,
|
||||
matcher,
|
||||
renderer,
|
||||
};
|
||||
26
web/src/labs/marked/parser/OrderedList.tsx
Normal file
26
web/src/labs/marked/parser/OrderedList.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { inlineElementParserList } from ".";
|
||||
import { marked } from "..";
|
||||
import { matcher } from "../matcher";
|
||||
|
||||
export const ORDERED_LIST_REG = /^(\d+)\. (.+)/;
|
||||
|
||||
const renderer = (rawStr: string) => {
|
||||
const matchResult = matcher(rawStr, ORDERED_LIST_REG);
|
||||
if (!matchResult) {
|
||||
return rawStr;
|
||||
}
|
||||
|
||||
const parsedContent = marked(matchResult[2], [], inlineElementParserList);
|
||||
return (
|
||||
<p className="li-container">
|
||||
<span className="ol-block">{matchResult[1]}.</span>
|
||||
<span>{parsedContent}</span>
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "ordered list",
|
||||
regexp: ORDERED_LIST_REG,
|
||||
renderer,
|
||||
};
|
||||
@@ -3,19 +3,13 @@ import { marked } from "..";
|
||||
|
||||
export const PARAGRAPH_REG = /^([^\n]+)/;
|
||||
|
||||
const matcher = (rawStr: string) => {
|
||||
const matchResult = rawStr.match(PARAGRAPH_REG);
|
||||
return matchResult;
|
||||
};
|
||||
|
||||
const renderer = (rawStr: string): string => {
|
||||
const renderer = (rawStr: string) => {
|
||||
const parsedContent = marked(rawStr, [], inlineElementParserList);
|
||||
return `<p>${parsedContent}</p>`;
|
||||
return <p>{parsedContent}</p>;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "paragraph",
|
||||
regex: PARAGRAPH_REG,
|
||||
matcher,
|
||||
regexp: PARAGRAPH_REG,
|
||||
renderer,
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import { escape } from "lodash-es";
|
||||
|
||||
export const PLAIN_LINK_REG = /(https?:\/\/[^ ]+)/;
|
||||
|
||||
const matcher = (rawStr: string) => {
|
||||
const matchResult = rawStr.match(PLAIN_LINK_REG);
|
||||
return matchResult;
|
||||
};
|
||||
|
||||
const renderer = (rawStr: string): string => {
|
||||
const matchResult = matcher(rawStr);
|
||||
if (!matchResult) {
|
||||
return rawStr;
|
||||
}
|
||||
|
||||
return `<a class='link' target='_blank' rel='noreferrer' href='${escape(matchResult[1])}'>${escape(matchResult[1])}</a>`;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "plain link",
|
||||
regex: PLAIN_LINK_REG,
|
||||
matcher,
|
||||
renderer,
|
||||
};
|
||||
23
web/src/labs/marked/parser/PlainLink.tsx
Normal file
23
web/src/labs/marked/parser/PlainLink.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { escape } from "lodash-es";
|
||||
import { matcher } from "../matcher";
|
||||
|
||||
export const PLAIN_LINK_REG = /(https?:\/\/[^ ]+)/;
|
||||
|
||||
const renderer = (rawStr: string) => {
|
||||
const matchResult = matcher(rawStr, PLAIN_LINK_REG);
|
||||
if (!matchResult) {
|
||||
return rawStr;
|
||||
}
|
||||
|
||||
return (
|
||||
<a className="link" target="_blank" rel="noreferrer" href={escape(matchResult[1])}>
|
||||
{escape(matchResult[1])}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "plain link",
|
||||
regexp: PLAIN_LINK_REG,
|
||||
renderer,
|
||||
};
|
||||
@@ -1,14 +1,10 @@
|
||||
import { escape } from "lodash-es";
|
||||
import { matcher } from "../matcher";
|
||||
|
||||
export const PLAIN_TEXT_REG = /(.+)/;
|
||||
|
||||
const matcher = (rawStr: string) => {
|
||||
const matchResult = rawStr.match(PLAIN_TEXT_REG);
|
||||
return matchResult;
|
||||
};
|
||||
|
||||
const renderer = (rawStr: string): string => {
|
||||
const matchResult = matcher(rawStr);
|
||||
const matchResult = matcher(rawStr, PLAIN_TEXT_REG);
|
||||
if (!matchResult) {
|
||||
return rawStr;
|
||||
}
|
||||
@@ -18,7 +14,6 @@ const renderer = (rawStr: string): string => {
|
||||
|
||||
export default {
|
||||
name: "plain text",
|
||||
regex: PLAIN_TEXT_REG,
|
||||
matcher,
|
||||
regexp: PLAIN_TEXT_REG,
|
||||
renderer,
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import { marked } from "..";
|
||||
|
||||
export const STRIKETHROUGH_REG = /~~(.+?)~~/;
|
||||
|
||||
const matcher = (rawStr: string) => {
|
||||
const matchResult = rawStr.match(STRIKETHROUGH_REG);
|
||||
return matchResult;
|
||||
};
|
||||
|
||||
const renderer = (rawStr: string): string => {
|
||||
const matchResult = matcher(rawStr);
|
||||
if (!matchResult) {
|
||||
return rawStr;
|
||||
}
|
||||
|
||||
const parsedContent = marked(matchResult[1], [], []);
|
||||
return `<del>${parsedContent}</del>`;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "Strikethrough",
|
||||
regex: STRIKETHROUGH_REG,
|
||||
matcher,
|
||||
renderer,
|
||||
};
|
||||
19
web/src/labs/marked/parser/Strikethrough.tsx
Normal file
19
web/src/labs/marked/parser/Strikethrough.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { escape } from "lodash";
|
||||
import { matcher } from "../matcher";
|
||||
|
||||
export const STRIKETHROUGH_REG = /~~(.+?)~~/;
|
||||
|
||||
const renderer = (rawStr: string) => {
|
||||
const matchResult = matcher(rawStr, STRIKETHROUGH_REG);
|
||||
if (!matchResult) {
|
||||
return rawStr;
|
||||
}
|
||||
|
||||
return <del>{escape(matchResult[1])}</del>;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "Strikethrough",
|
||||
regexp: STRIKETHROUGH_REG,
|
||||
renderer,
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import { escape } from "lodash-es";
|
||||
|
||||
export const TAG_REG = /#([^\s#]+)/;
|
||||
|
||||
export const matcher = (rawStr: string) => {
|
||||
const matchResult = rawStr.match(TAG_REG);
|
||||
if (matchResult) {
|
||||
return matchResult;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderer = (rawStr: string): string => {
|
||||
const matchResult = matcher(rawStr);
|
||||
if (!matchResult) {
|
||||
return rawStr;
|
||||
}
|
||||
|
||||
return `<span class='tag-span'>#${escape(matchResult[1])}</span>`;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "tag",
|
||||
regex: TAG_REG,
|
||||
matcher,
|
||||
renderer,
|
||||
};
|
||||
19
web/src/labs/marked/parser/Tag.tsx
Normal file
19
web/src/labs/marked/parser/Tag.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { escape } from "lodash-es";
|
||||
import { matcher } from "../matcher";
|
||||
|
||||
export const TAG_REG = /#([^\s#]+)/;
|
||||
|
||||
const renderer = (rawStr: string) => {
|
||||
const matchResult = matcher(rawStr, TAG_REG);
|
||||
if (!matchResult) {
|
||||
return rawStr;
|
||||
}
|
||||
|
||||
return <span className="tag-span">#{escape(matchResult[1])}</span>;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "tag",
|
||||
regexp: TAG_REG,
|
||||
renderer,
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
import { inlineElementParserList } from ".";
|
||||
import { marked } from "..";
|
||||
|
||||
export const TODO_LIST_REG = /^- \[ \] ([^\n]+)/;
|
||||
|
||||
const matcher = (rawStr: string) => {
|
||||
const matchResult = rawStr.match(TODO_LIST_REG);
|
||||
return matchResult;
|
||||
};
|
||||
|
||||
const renderer = (rawStr: string): string => {
|
||||
const matchResult = matcher(rawStr);
|
||||
if (!matchResult) {
|
||||
return rawStr;
|
||||
}
|
||||
|
||||
const parsedContent = marked(matchResult[1], [], inlineElementParserList);
|
||||
return `<p class='li-container'><span class='todo-block todo' data-value='TODO'></span><span>${parsedContent}</span></p>`;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "todo list",
|
||||
regex: TODO_LIST_REG,
|
||||
matcher,
|
||||
renderer,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user