Compare commits

...

38 Commits

Author SHA1 Message Date
Stephen Zhou
936927f5bc fix: missing creator id in shortcut cache (#915)
fix: missing creatot id in shortcut cache
2023-01-07 12:46:16 +08:00
boojack
e2e8130f4c fix: sort version (#914) 2023-01-07 11:49:58 +08:00
boojack
46c13a4b7f chore: add skipper for secure (#913) 2023-01-07 10:51:34 +08:00
boojack
96798e10b4 feat: support embed memo with iframe (#912) 2023-01-07 01:56:02 +08:00
boojack
0f8ce3dd16 refactor: return jsx element instead of string in marked (#910)
* refactor: return jsx element instead of string in marked

* chore: update
2023-01-07 00:13:49 +08:00
boojack
491859bbf6 chore: update activity metrics (#908) 2023-01-05 20:56:50 +08:00
boojack
f16123a624 chore: update memo create activity (#903) 2023-01-03 23:49:11 +08:00
boojack
d50ad9433f feat: persistent session name (#902)
* feat: persistent session name

* chore: update
2023-01-03 23:05:42 +08:00
Zeng1998
92a8a4ac0c feat: support code copy (#901)
* feat: support code copy

* update
2023-01-03 23:05:00 +08:00
helaxious
62f53888ba feat: update docker command description (#893) (#900)
* Add a line on README to help prevent a mistake (#893)

* Update README.md

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

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-01-03 22:40:53 +08:00
boojack
79180928d4 chore: update server activity (#898) 2023-01-03 20:05:37 +08:00
boojack
e5550828a0 chore: update activity payload (#891) 2023-01-02 23:18:12 +08:00
Vincenzo Cardone
2e95f6824f feat: add Italian Translation (#890) 2023-01-02 09:41:39 +08:00
boojack
5195012217 feat: add activity table (#888)
feat: introduce activity
2023-01-01 23:55:02 +08:00
boojack
a797280e3f chore: update middleware skipper (#887)
* chore: update middleware skipper

* chore: update
2023-01-01 23:26:21 +08:00
boojack
293f88e40c chore: update GitHub action name (#886)
* chore: update github actions name

* chore: update
2023-01-01 22:01:16 +08:00
boojack
861eeb7b0f chore: add skipper in CSRF (#885) 2023-01-01 21:32:17 +08:00
boojack
24b21aa9d7 chore: update version to 0.9.1 (#882) 2022-12-31 15:40:53 +08:00
boojack
51eac649c5 chore: update create tag dialog (#881) 2022-12-31 15:13:25 +08:00
boojack
7670c95360 chore: fix XSS in renderer (#880) 2022-12-31 11:52:57 +08:00
Ivan
65e9fdead1 feat: add russian locale (#879) 2022-12-31 09:02:14 +08:00
Zeng1998
2b2792de73 fix: logic of email validation (#877)
* fix: fix logic of email validation

* update
2022-12-30 13:10:52 +08:00
boojack
c9bb2b785d chore: fix CSRF (#876) 2022-12-30 00:17:19 +08:00
boojack
64e5c343c5 chore: fix XSS in renderer (#875)
chore: fix xss in renderer
2022-12-29 23:27:56 +08:00
boojack
9169b3f2cd chore: update tip text for empty tag list (#872) 2022-12-29 09:14:24 +08:00
boojack
b6f7a85a2a fix: reload page when sign out (#871) 2022-12-28 20:58:59 +08:00
boojack
3556ae4e65 fix: access control (#870) 2022-12-28 20:22:52 +08:00
boojack
f888c62840 chore: update userinfo validator (#868)
* chore: update userinfo validator

* chore: update actions

* chore: update
2022-12-27 21:51:43 +08:00
Taras
c160bed403 fead: add ukrainian locale (#864) 2022-12-26 19:10:47 +08:00
boojack
afc9709484 chore: update dev config (#857) 2022-12-25 10:39:45 +08:00
boojack
05b41804e3 chore: hide host user email (#856) 2022-12-25 10:28:51 +08:00
Zeng1998
2e2657b39d feat: add setting for power editor (#851) 2022-12-24 16:18:13 +08:00
Zeng1998
60ee602639 feat: enable word break (#849)
* feat: enable word break

* Update web/src/less/editor.less

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

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

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

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-12-24 14:50:27 +08:00
Zeng1998
cac04e4406 feat: enable word break (#849)
* feat: enable word break

* Update web/src/less/editor.less

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

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

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

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-12-24 14:50:10 +08:00
M. Gschwandtner
278b4d21b4 fix: prioritize user css by moving it to the body end (#847)
Co-authored-by: M. Gschwandtner <84477901+OnlyPain-ctrl@users.noreply.github.com>
2022-12-24 09:35:30 +08:00
boojack
27fd1e2880 chore: remove secure flag (#848) 2022-12-24 08:31:37 +08:00
boojack
fae9b3db46 chore: revert add linux/arm/v7 to platforms (#843)
Revert "chore: add `linux/arm/v7` to platforms (#842)"

This reverts commit 49c7f49820.
2022-12-23 22:39:04 +08:00
boojack
49c7f49820 chore: add linux/arm/v7 to platforms (#842) 2022-12-23 22:25:24 +08:00
124 changed files with 2769 additions and 1336 deletions

41
.github/workflows/backend-tests.yml vendored Normal file
View 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"}'

View File

@@ -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"}'

View File

@@ -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
View 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"`
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ type MemoResource struct {
}
type MemoResourceUpsert struct {
MemoID int
MemoID int `json:"-"`
ResourceID int
UpdatedTs *int64
}

View File

@@ -20,7 +20,7 @@ type Resource struct {
type ResourceCreate struct {
// Standard fields
CreatorID int
CreatorID int `json:"-"`
// Domain specific fields
Filename string `json:"filename"`

View File

@@ -16,7 +16,7 @@ type Shortcut struct {
type ShortcutCreate struct {
// Standard fields
CreatorID int
CreatorID int `json:"-"`
// Domain specific fields
Title string `json:"title"`

View File

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

View File

@@ -7,7 +7,7 @@ type Tag struct {
type TagUpsert struct {
Name string
CreatorID int
CreatorID int `json:"-"`
}
type TagFind struct {

View File

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

View File

@@ -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"`
}

View File

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

View File

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

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

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

View File

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

View File

@@ -1,7 +0,0 @@
package metric
// Metric is the API message for metric.
type Metric struct {
Name string
Labels map[string]string
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = &currentTs
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: &currentTs,
}
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
}

View File

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

View File

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

View File

@@ -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: &currentTs,
}
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
}

View File

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

View File

@@ -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: &currentTs,
}
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
}

View File

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

View File

@@ -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 = &currentUserID
} 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
}

View File

@@ -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: &currentTs,
}
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
}

View File

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

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

View File

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

View File

@@ -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 '{}'
);

View 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 '{}'
);

View File

@@ -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 '{}'
);

View File

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

View File

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

View File

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

View File

@@ -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>
</>
);

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
export const matcher = (rawStr: string, regexp: RegExp) => {
const matchResult = rawStr.match(regexp);
return matchResult;
};

View File

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

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

View File

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

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

View File

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

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

View File

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

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

View File

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

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

View File

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

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

View File

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

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

View File

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

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

View File

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

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

View File

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

View File

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

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

View File

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

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

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

View File

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