Unverified Commit 475f258f authored by XQ's avatar XQ Committed by GitHub

Merge pull request #26 from justmemos/refactor/go

🔥🔥🔥Big refactoring🔥🔥🔥
parents 3d8997a4 925773db
......@@ -28,4 +28,4 @@ jobs:
context: ./
file: ./Dockerfile
push: true
tags: ${{ secrets.DOCKER_NEOSMEMO_USERNAME }}/memos:latest
tags: ${{ secrets.DOCKER_NEOSMEMO_USERNAME }}/memos:next
......@@ -17,18 +17,13 @@ COPY . .
RUN go build \
-o memos \
./server/main.go
./bin/server/main.go
# Make workspace with above generated files.
FROM alpine:3.14.3 AS monolithic
WORKDIR /usr/local/memos
RUN apk add --no-cache tzdata
ENV TZ="Asia/Shanghai"
COPY --from=backend /backend-build/memos /usr/local/memos/
# Copy default resources, like db file.
COPY --from=backend /backend-build/resources /usr/local/memos/resources
COPY --from=frontend /frontend-build/dist /usr/local/memos/web/dist
CMD ["./memos"]
......
......@@ -2,8 +2,7 @@
<p align="center">
<a href="https://memos.onrender.com/">Live Demo</a>
<a href="https://github.com/justmemos/memos/discussions">Discussions</a>
<a href="https://t.me/+M-AqruZmJBhkYWQ1">Telegram</a>
<a href="https://github.com/justmemos/memos/discussions">Discussions</a>
</p>
<p align="center">
......@@ -13,23 +12,30 @@
<img alt="GitHub license" src="https://img.shields.io/github/license/justmemos/memos" />
</p>
Memos 是一款开源的 [flomo](https://flomoapp.com/) 替代工具,为了快速方便的部署属于自己的碎片化知识管理工具。
Memos is an open source, self-hosted alternative to [flomo](https://flomoapp.com/). Built with `Golang` and `React`.
## 🎯 产品意图
Making sure that you are in charge of your data and more customizations.
- 📅 用于记录:每日/周计划、💡 突发奇想、📕 读后感...
- 🏗️ 代替了微信“文件传输助手”;
- 📒 打造一个属于自己的轻量化“卡片”笔记簿;
## 🎯 Intentions
## ✨ 特色亮点
- ✍️ For noting 📅 daily/weekly plans, 💡 fantastic ideas, 📕 reading thoughts...
- 📒 Write down the lightweight card memos easily;
- 🏗️ Build your own fragmented knowledge management tools;
- 🦄 开源项目;
- 😋 精美且细节的视觉样式;
- 📑 体验优良的交互逻辑;
- ⚡️ 快速地私有化部署;
## ✨ Features
## 📕 文档
- 🦄 Open source project;
- 😋 Beautiful and detailed visual styles;
- 📑 Experience excellent interaction logic;
- ⚡️ Quick privatization deployment;
- [使用 Docker 部署](https://github.com/justmemos/memos/tree/main/docs/deploy)
<!--
WIP
## 📕 Docs
Enjoy it and welcome your contributions
- [Guide to self host with Docker](https://github.com/justmemos/memos/tree/main/docs/deploy)
-->
---
Just enjoy it.
package api
import (
"encoding/json"
"memos/api/e"
"memos/store"
"net/http"
"github.com/gorilla/mux"
)
func handleUserSignUp(w http.ResponseWriter, r *http.Request) {
type UserSignUpDataBody struct {
Username string `json:"username"`
Password string `json:"password"`
}
userSignup := UserSignUpDataBody{}
err := json.NewDecoder(r.Body).Decode(&userSignup)
if err != nil {
e.ErrorHandler(w, "REQUEST_BODY_ERROR", "Bad request")
return
}
usernameUsable, _ := store.CheckUsernameUsable(userSignup.Username)
if !usernameUsable {
json.NewEncoder(w).Encode(Response{
Succeed: false,
Message: "Username is existed",
Data: nil,
})
return
}
user, err := store.CreateNewUser(userSignup.Username, userSignup.Password)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
session, _ := SessionStore.Get(r, "session")
session.Values["user_id"] = user.Id
session.Save(r, w)
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: user,
})
type Login struct {
Name string `json:"name"`
Password string `json:"password"`
}
func handleUserSignIn(w http.ResponseWriter, r *http.Request) {
type UserSigninDataBody struct {
Username string `json:"username"`
Password string `json:"password"`
}
userSignin := UserSigninDataBody{}
err := json.NewDecoder(r.Body).Decode(&userSignin)
if err != nil {
e.ErrorHandler(w, "REQUEST_BODY_ERROR", "Bad request")
return
}
user, err := store.GetUserByUsernameAndPassword(userSignin.Username, userSignin.Password)
if err != nil {
json.NewEncoder(w).Encode(Response{
Succeed: false,
Message: "Username and password not allowed",
Data: nil,
})
return
}
session, _ := SessionStore.Get(r, "session")
session.Values["user_id"] = user.Id
session.Save(r, w)
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: user,
})
}
func handleUserSignOut(w http.ResponseWriter, r *http.Request) {
session, _ := SessionStore.Get(r, "session")
session.Values["user_id"] = ""
session.Save(r, w)
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: nil,
})
}
func RegisterAuthRoutes(r *mux.Router) {
authRouter := r.PathPrefix("/api/auth").Subrouter()
authRouter.Use(JSONResponseMiddleWare)
authRouter.HandleFunc("/signup", handleUserSignUp).Methods("POST")
authRouter.HandleFunc("/signin", handleUserSignIn).Methods("POST")
authRouter.HandleFunc("/signout", handleUserSignOut).Methods("POST")
type Signup struct {
Name string `json:"name"`
Password string `json:"password"`
}
package e
var Codes = map[string]int{
"NOT_AUTH": 20001,
"REQUEST_BODY_ERROR": 40001,
"UPLOAD_FILE_ERROR": 40002,
"OVERLOAD_MAX_SIZE": 40003,
"NOT_FOUND": 40400,
"USER_NOT_FOUND": 40401,
"RESOURCE_NOT_FOUND": 40402,
"DATABASE_ERROR": 50001,
}
package e
import (
"encoding/json"
"net/http"
)
type ServerError struct {
Code int
Message string
}
type ErrorResponse struct {
Succeed bool `json:"succeed"`
Message string `json:"message"`
StatusCode int `json:"statusCode"`
Data interface{} `json:"data"`
}
func getServerError(err string) ServerError {
code, exists := Codes[err]
println(err)
if !exists {
err = "BAD_REQUEST"
code = 40000
}
return ServerError{
Code: code,
Message: err,
}
}
func ErrorHandler(w http.ResponseWriter, err string, message string) {
serverError := getServerError(err)
res := ErrorResponse{
Succeed: false,
Message: message,
StatusCode: serverError.Code,
Data: nil,
}
statusCode := int(serverError.Code / 100)
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(res)
}
package api
import (
"encoding/json"
"memos/api/e"
"memos/store"
"net/http"
"github.com/gorilla/mux"
)
func handleGetMyMemos(w http.ResponseWriter, r *http.Request) {
userId, _ := GetUserIdInSession(r)
urlParams := r.URL.Query()
deleted := urlParams.Get("deleted")
onlyDeletedFlag := deleted == "true"
memos, err := store.GetMemosByUserId(userId, onlyDeletedFlag)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: memos,
})
type Memo struct {
Id int `json:"id"`
CreatedTs int64 `json:"createdTs"`
UpdatedTs int64 `json:"updatedTs"`
RowStatus string `json:"rowStatus"`
Content string `json:"content"`
CreatorId int `json:"creatorId"`
}
func handleCreateMemo(w http.ResponseWriter, r *http.Request) {
userId, _ := GetUserIdInSession(r)
type CreateMemoDataBody struct {
Content string `json:"content"`
}
createMemo := CreateMemoDataBody{}
err := json.NewDecoder(r.Body).Decode(&createMemo)
if err != nil {
e.ErrorHandler(w, "REQUEST_BODY_ERROR", "Bad request")
return
}
memo, err := store.CreateNewMemo(createMemo.Content, userId)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: memo,
})
type MemoCreate struct {
Content string `json:"content"`
CreatorId int
}
func handleUpdateMemo(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
memoId := vars["id"]
memoPatch := store.MemoPatch{}
err := json.NewDecoder(r.Body).Decode(&memoPatch)
if err != nil {
e.ErrorHandler(w, "REQUEST_BODY_ERROR", "Bad request")
return
}
memo, err := store.UpdateMemo(memoId, &memoPatch)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
type MemoPatch struct {
Id int
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: memo,
})
Content *string `json:"content"`
RowStatus *string `json:"rowStatus"`
}
func handleDeleteMemo(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
memoId := vars["id"]
err := store.DeleteMemo(memoId)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: nil,
})
type MemoFind struct {
Id *int `json:"id"`
CreatorId *int `json:"creatorId"`
RowStatus *string `json:"rowStatus"`
}
func RegisterMemoRoutes(r *mux.Router) {
memoRouter := r.PathPrefix("/api/memo").Subrouter()
memoRouter.Use(JSONResponseMiddleWare)
memoRouter.Use(AuthCheckerMiddleWare)
type MemoDelete struct {
Id *int `json:"id"`
CreatorId *int
}
memoRouter.HandleFunc("/all", handleGetMyMemos).Methods("GET")
memoRouter.HandleFunc("/", handleCreateMemo).Methods("PUT")
memoRouter.HandleFunc("/{id}", handleUpdateMemo).Methods("PATCH")
memoRouter.HandleFunc("/{id}", handleDeleteMemo).Methods("DELETE")
type MemoService interface {
CreateMemo(create *MemoCreate) (*Memo, error)
PatchMemo(patch *MemoPatch) (*Memo, error)
FindMemoList(find *MemoFind) ([]*Memo, error)
FindMemo(find *MemoFind) (*Memo, error)
DeleteMemo(delete *MemoDelete) error
}
package api
import (
"memos/api/e"
"net/http"
)
func AuthCheckerMiddleWare(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, _ := SessionStore.Get(r, "session")
if userId, ok := session.Values["user_id"].(string); !ok || userId == "" {
e.ErrorHandler(w, "NOT_AUTH", "Need authorize")
return
}
next.ServeHTTP(w, r)
})
}
func JSONResponseMiddleWare(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
next.ServeHTTP(w, r)
})
}
func CorsMiddleWare(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
next.ServeHTTP(w, r)
})
}
package api
import (
"encoding/json"
"memos/api/e"
"memos/store"
"net/http"
"github.com/gorilla/mux"
)
func handleGetMyQueries(w http.ResponseWriter, r *http.Request) {
userId, _ := GetUserIdInSession(r)
queries, err := store.GetQueriesByUserId(userId)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: queries,
})
}
func handleCreateQuery(w http.ResponseWriter, r *http.Request) {
userId, _ := GetUserIdInSession(r)
type CreateQueryDataBody struct {
Title string `json:"title"`
Querystring string `json:"querystring"`
}
queryData := CreateQueryDataBody{}
err := json.NewDecoder(r.Body).Decode(&queryData)
if err != nil {
e.ErrorHandler(w, "REQUEST_BODY_ERROR", "Bad request")
return
}
query, err := store.CreateNewQuery(queryData.Title, queryData.Querystring, userId)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: query,
})
}
func handleUpdateQuery(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
queryId := vars["id"]
queryPatch := store.QueryPatch{}
err := json.NewDecoder(r.Body).Decode(&queryPatch)
if err != nil {
e.ErrorHandler(w, "REQUEST_BODY_ERROR", "Bad request")
return
}
query, err := store.UpdateQuery(queryId, &queryPatch)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: query,
})
}
func handleDeleteQuery(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
queryId := vars["id"]
err := store.DeleteQuery(queryId)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: nil,
})
}
func RegisterQueryRoutes(r *mux.Router) {
queryRouter := r.PathPrefix("/api/query").Subrouter()
queryRouter.Use(JSONResponseMiddleWare)
queryRouter.Use(AuthCheckerMiddleWare)
queryRouter.HandleFunc("/all", handleGetMyQueries).Methods("GET")
queryRouter.HandleFunc("/", handleCreateQuery).Methods("PUT")
queryRouter.HandleFunc("/{id}", handleUpdateQuery).Methods("PATCH")
queryRouter.HandleFunc("/{id}", handleDeleteQuery).Methods("DELETE")
}
package api
import (
"encoding/json"
"io/ioutil"
"memos/api/e"
"memos/store"
"net/http"
"strings"
type Resource struct {
Id int `json:"id"`
CreatedTs int64 `json:"createdTs"`
UpdatedTs int64 `json:"updatedTs"`
"github.com/gorilla/mux"
)
Filename string `json:"filename"`
Blob []byte `json:"blob"`
Type string `json:"type"`
Size int64 `json:"size"`
func handleGetMyResources(w http.ResponseWriter, r *http.Request) {
userId, _ := GetUserIdInSession(r)
resources, err := store.GetResourcesByUserId(userId)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: resources,
})
CreatorId int `json:"creatorId"`
}
func handleUploadResource(w http.ResponseWriter, r *http.Request) {
userId, _ := GetUserIdInSession(r)
err := r.ParseMultipartForm(5 << 20)
if err != nil {
e.ErrorHandler(w, "OVERLOAD_MAX_SIZE", "The max size of resource is 5Mb.")
return
}
file, handler, err := r.FormFile("file")
if err != nil {
e.ErrorHandler(w, "REQUEST_BODY_ERROR", "Bad request")
return
}
defer file.Close()
filename := handler.Filename
filetype := handler.Header.Get("Content-Type")
size := handler.Size
fileBytes, err := ioutil.ReadAll(file)
if err != nil {
e.ErrorHandler(w, "UPLOAD_FILE_ERROR", "Read file error")
return
}
resource, err := store.CreateResource(userId, filename, fileBytes, filetype, size)
type ResourceCreate struct {
Filename string `json:"filename"`
Blob []byte `json:"blob"`
Type string `json:"type"`
Size int64 `json:"size"`
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "Upload file succeed",
Data: resource,
})
CreatorId int
}
func handleDeleteResource(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
resourceId := vars["id"]
err := store.DeleteResourceById(resourceId)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: nil,
})
type ResourceFind struct {
Id *int `json:"id"`
CreatorId *int `json:"creatorId"`
Filename *string `json:"filename"`
}
func handleGetResource(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
resourceId := vars["id"]
filename := vars["filename"]
etag := `"` + resourceId + "/" + filename + `"`
w.Header().Set("Etag", etag)
w.Header().Set("Cache-Control", "max-age=2592000")
if match := r.Header.Get("If-None-Match"); match != "" {
if strings.Contains(match, etag) {
w.WriteHeader(http.StatusNotModified)
return
}
}
resource, err := store.GetResourceByIdAndFilename(resourceId, filename)
if err != nil {
e.ErrorHandler(w, "RESOURCE_NOT_FOUND", err.Error())
return
}
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/octet-stream")
w.Write(resource.Blob)
type ResourceDelete struct {
Id int
}
func RegisterResourceRoutes(r *mux.Router) {
resourceRouter := r.PathPrefix("/").Subrouter()
resourceRouter.Use(AuthCheckerMiddleWare)
resourceRouter.HandleFunc("/api/resource/all", handleGetMyResources).Methods("GET")
resourceRouter.HandleFunc("/api/resource/", handleUploadResource).Methods("PUT")
resourceRouter.HandleFunc("/api/resource/{id}", handleDeleteResource).Methods("DELETE")
resourceRouter.HandleFunc("/r/{id}/{filename}", handleGetResource).Methods("GET")
type ResourceService interface {
CreateResource(create *ResourceCreate) (*Resource, error)
FindResourceList(find *ResourceFind) ([]*Resource, error)
FindResource(find *ResourceFind) (*Resource, error)
DeleteResource(delete *ResourceDelete) error
}
package api
import (
"memos/utils"
"github.com/gorilla/sessions"
)
var SessionStore = sessions.NewCookieStore([]byte(utils.GenUUID()))
package api
type Shortcut struct {
Id int `json:"id"`
CreatedTs int64 `json:"createdTs"`
UpdatedTs int64 `json:"updatedTs"`
Title string `json:"title"`
Payload string `json:"payload"`
RowStatus string `json:"rowStatus"`
CreatorId int
}
type ShortcutCreate struct {
// Standard fields
CreatorId int
// Domain specific fields
Title string `json:"title"`
Payload string `json:"payload"`
}
type ShortcutPatch struct {
Id int
Title *string `json:"title"`
Payload *string `json:"payload"`
RowStatus *string `json:"rowStatus"`
}
type ShortcutFind struct {
Id *int
// Standard fields
CreatorId *int
// Domain specific fields
Title *string `json:"title"`
}
type ShortcutDelete struct {
Id int
}
type ShortcutService interface {
CreateShortcut(create *ShortcutCreate) (*Shortcut, error)
PatchShortcut(patch *ShortcutPatch) (*Shortcut, error)
FindShortcutList(find *ShortcutFind) ([]*Shortcut, error)
FindShortcut(find *ShortcutFind) (*Shortcut, error)
DeleteShortcut(delete *ShortcutDelete) error
}
package api
import (
"net/http"
"os"
"path/filepath"
)
type SPAHandler struct {
StaticPath string
IndexPath string
}
func (h SPAHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path, err := filepath.Abs(r.URL.Path)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
path = filepath.Join(h.StaticPath, path)
_, err = os.Stat(path)
if os.IsNotExist(err) {
// file does not exist, serve index.html
http.ServeFile(w, r, filepath.Join(h.StaticPath, h.IndexPath))
return
} else if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.FileServer(http.Dir(h.StaticPath)).ServeHTTP(w, r)
}
package api
import (
"encoding/json"
"memos/api/e"
"memos/store"
"net/http"
"github.com/gorilla/mux"
)
func handleGetMyUserInfo(w http.ResponseWriter, r *http.Request) {
userId, _ := GetUserIdInSession(r)
user, err := store.GetUserById(userId)
if err != nil {
e.ErrorHandler(w, "USER_NOT_FOUND", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: user,
})
type User struct {
Id int `json:"id"`
CreatedTs int64 `json:"createdTs"`
UpdatedTs int64 `json:"updatedTs"`
OpenId string `json:"openId"`
Name string `json:"name"`
Password string `json:"-"`
}
func handleUpdateMyUserInfo(w http.ResponseWriter, r *http.Request) {
userId, _ := GetUserIdInSession(r)
updateUserPatch := store.UpdateUserPatch{}
err := json.NewDecoder(r.Body).Decode(&updateUserPatch)
if err != nil {
e.ErrorHandler(w, "REQUEST_BODY_ERROR", "Bad request")
return
}
if updateUserPatch.Username != nil {
usernameUsable, _ := store.CheckUsernameUsable(*updateUserPatch.Username)
if !usernameUsable {
json.NewEncoder(w).Encode(Response{
Succeed: false,
Message: "Username is existed",
Data: nil,
})
return
}
}
user, err := store.UpdateUser(userId, &updateUserPatch)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: user,
})
type UserCreate struct {
OpenId string `json:"openId"`
Name string `json:"name"`
Password string `json:"password"`
}
func handleResetUserOpenId(w http.ResponseWriter, r *http.Request) {
userId, _ := GetUserIdInSession(r)
openId, err := store.ResetUserOpenId(userId)
type UserPatch struct {
Id int
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
OpenId *string
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: openId,
})
Name *string `json:"name"`
Password *string `json:"password"`
ResetOpenId *bool `json:"resetOpenId"`
}
func handleCheckUsername(w http.ResponseWriter, r *http.Request) {
type CheckUsernameDataBody struct {
Username string
}
type UserFind struct {
Id *int `json:"id"`
checkUsername := CheckUsernameDataBody{}
err := json.NewDecoder(r.Body).Decode(&checkUsername)
if err != nil {
e.ErrorHandler(w, "REQUEST_BODY_ERROR", "Bad request")
return
}
usable, err := store.CheckUsernameUsable(checkUsername.Username)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: usable,
})
Name *string `json:"name"`
Password *string
OpenId *string
}
func handleValidPassword(w http.ResponseWriter, r *http.Request) {
type ValidPasswordDataBody struct {
Password string
}
userId, _ := GetUserIdInSession(r)
validPassword := ValidPasswordDataBody{}
err := json.NewDecoder(r.Body).Decode(&validPassword)
if err != nil {
e.ErrorHandler(w, "REQUEST_BODY_ERROR", "Bad request")
return
}
valid, err := store.CheckPasswordValid(userId, validPassword.Password)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: valid,
})
type UserRenameCheck struct {
Name string `json:"name"`
}
func RegisterUserRoutes(r *mux.Router) {
userRouter := r.PathPrefix("/api/user").Subrouter()
userRouter.Use(JSONResponseMiddleWare)
userRouter.Use(AuthCheckerMiddleWare)
type UserPasswordCheck struct {
Password string `json:"password"`
}
userRouter.HandleFunc("/me", handleGetMyUserInfo).Methods("GET")
userRouter.HandleFunc("/me", handleUpdateMyUserInfo).Methods("PATCH")
userRouter.HandleFunc("/open_id/new", handleResetUserOpenId).Methods("POST")
userRouter.HandleFunc("/checkusername", handleCheckUsername).Methods("POST")
userRouter.HandleFunc("/validpassword", handleValidPassword).Methods("POST")
type UserService interface {
CreateUser(create *UserCreate) (*User, error)
PatchUser(patch *UserPatch) (*User, error)
FindUser(find *UserFind) (*User, error)
}
package api
import (
"net/http"
)
type Response struct {
Succeed bool `json:"succeed"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
func GetUserIdInSession(r *http.Request) (string, error) {
session, _ := SessionStore.Get(r, "session")
userId, ok := session.Values["user_id"].(string)
if !ok {
return "", http.ErrNoCookie
}
return userId, nil
}
package api
import (
"encoding/json"
"memos/api/e"
"memos/store"
"net/http"
"github.com/gorilla/mux"
)
func handleCreateMemoByWH(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
openId := vars["openId"]
type CreateMemoDataBody struct {
Content string `json:"content"`
}
createMemo := CreateMemoDataBody{}
err := json.NewDecoder(r.Body).Decode(&createMemo)
if err != nil {
e.ErrorHandler(w, "REQUEST_BODY_ERROR", "Bad request")
return
}
user, err := store.GetUserByOpenId(openId)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
memo, err := store.CreateNewMemo(createMemo.Content, user.Id)
if err != nil {
e.ErrorHandler(w, "DATABASE_ERROR", err.Error())
return
}
json.NewEncoder(w).Encode(Response{
Succeed: true,
Message: "",
Data: memo,
})
}
func RegisterWebHooksRoutes(r *mux.Router) {
memoRouter := r.PathPrefix("/api/whs").Subrouter()
memoRouter.Use(JSONResponseMiddleWare)
memoRouter.HandleFunc("/memo/{openId}", handleCreateMemoByWH).Methods("POST")
}
//go:build !release
// +build !release
package cmd
import (
"fmt"
)
// GetDevProfile will return a profile for dev.
func GetDevProfile(dataDir string) Profile {
return Profile{
mode: "8080",
port: 8080,
dsn: fmt.Sprintf("file:%s/memos_dev.db", dataDir),
}
}
package cmd
import (
"fmt"
"os"
"path/filepath"
"strings"
"memos/server"
"memos/store"
)
var (
dataDir string
)
type Profile struct {
// mode can be "release" or "dev"
mode string
// port is the binding port for server.
port int
// dsn points to where Memos stores its own data
dsn string
}
func checkDataDir() error {
// Convert to absolute path if relative path is supplied.
if !filepath.IsAbs(dataDir) {
absDir, err := filepath.Abs(filepath.Dir(os.Args[0]) + "/" + dataDir)
if err != nil {
return err
}
dataDir = absDir
}
// Trim trailing / in case user supplies
dataDir = strings.TrimRight(dataDir, "/")
if _, err := os.Stat(dataDir); err != nil {
error := fmt.Errorf("unable to access --data %s, %w", dataDir, err)
return error
}
return nil
}
type Main struct {
profile *Profile
server *server.Server
db *store.DB
}
func Execute() {
err := checkDataDir()
if err != nil {
fmt.Printf("%+v\n", err)
os.Exit(1)
}
m := Main{}
profile := GetDevProfile(dataDir)
m.profile = &profile
err = m.Run()
if err != nil {
fmt.Printf("%+v\n", err)
os.Exit(1)
}
}
func (m *Main) Run() error {
db := store.NewDB(m.profile.dsn)
if err := db.Open(); err != nil {
return fmt.Errorf("cannot open db: %w", err)
}
m.db = db
s := server.NewServer(m.profile.port)
s.ShortcutService = store.NewShortcutService(db)
s.MemoService = store.NewMemoService(db)
s.UserService = store.NewUserService(db)
s.ShortcutService = store.NewShortcutService(db)
s.ResourceService = store.NewResourceService(db)
m.server = s
if err := s.Run(); err != nil {
return err
}
return nil
}
package main
import "memos/bin/server/cmd"
func main() {
cmd.Execute()
}
package common
import (
"errors"
)
// Code is the error code.
type Code int
// Application error codes.
const (
// 0 ~ 99 general error
Ok Code = 0
Internal Code = 1
NotAuthorized Code = 2
Invalid Code = 3
NotFound Code = 4
Conflict Code = 5
NotImplemented Code = 6
// 101 ~ 199 db error
DbConnectionFailure Code = 101
DbStatementSyntaxError Code = 102
DbExecutionError Code = 103
// 201 db migration error
// Db migration is a core feature, so we separate it from the db error
MigrationSchemaMissing Code = 201
MigrationAlreadyApplied Code = 202
MigrationOutOfOrder Code = 203
MigrationBaselineMissing Code = 204
// 301 task error
TaskTimingNotAllowed Code = 301
// 10001 advisor error code
CompatibilityDropDatabase Code = 10001
CompatibilityRenameTable Code = 10002
CompatibilityDropTable Code = 10003
CompatibilityRenameColumn Code = 10004
CompatibilityDropColumn Code = 10005
CompatibilityAddPrimaryKey Code = 10006
CompatibilityAddUniqueKey Code = 10007
CompatibilityAddForeignKey Code = 10008
CompatibilityAddCheck Code = 10009
CompatibilityAlterCheck Code = 10010
CompatibilityAlterColumn Code = 10011
)
// Error represents an application-specific error. Application errors can be
// unwrapped by the caller to extract out the code & message.
//
// Any non-application error (such as a disk error) should be reported as an
// Internal error and the human user should only see "Internal error" as the
// message. These low-level internal error details should only be logged and
// reported to the operator of the application (not the end user).
type Error struct {
// Machine-readable error code.
Code Code
// Embedded error.
Err error
}
// Error implements the error interface. Not used by the application otherwise.
func (e *Error) Error() string {
return e.Err.Error()
}
// ErrorCode unwraps an application error and returns its code.
// Non-application errors always return EINTERNAL.
func ErrorCode(err error) Code {
var e *Error
if err == nil {
return Ok
} else if errors.As(err, &e) {
return e.Code
}
return Internal
}
// ErrorMessage unwraps an application error and returns its message.
// Non-application errors always return "Internal error".
func ErrorMessage(err error) string {
var e *Error
if err == nil {
return ""
} else if errors.As(err, &e) {
return e.Err.Error()
}
return "Internal error."
}
// Errorf is a helper function to return an Error with a given code and error.
func Errorf(code Code, err error) *Error {
return &Error{
Code: code,
Err: err,
}
}
package common
import (
"strings"
"github.com/google/uuid"
)
// HasPrefixes returns true if the string s has any of the given prefixes.
func HasPrefixes(src string, prefixes ...string) bool {
for _, prefix := range prefixes {
if strings.HasPrefix(src, prefix) {
return true
}
}
return false
}
func GenUUID() string {
return uuid.New().String()
}
......@@ -2,13 +2,31 @@ module memos
go 1.17
require github.com/gorilla/mux v1.8.0
require github.com/mattn/go-sqlite3 v1.14.9
require github.com/google/uuid v1.3.0
require (
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/mattn/go-colorable v0.1.11 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.1 // indirect
golang.org/x/crypto v0.0.0-20210920023735-84f357641f63 // indirect
golang.org/x/net v0.0.0-20210917221730-978cfadd31cf // indirect
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 // indirect
)
require (
github.com/gorilla/context v1.1.1 // indirect
github.com/labstack/echo/v4 v4.6.3
github.com/labstack/gommon v0.3.1 // indirect
)
require (
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/gorilla/sessions v1.2.1
github.com/labstack/echo-contrib v0.12.0
)
This diff is collapsed.
DROP TABLE IF EXISTS `memos`;
DROP TABLE IF EXISTS `queries`;
DROP TABLE IF EXISTS `resources`;
DROP TABLE IF EXISTS `users`;
CREATE TABLE `users` (
`id` TEXT NOT NULL PRIMARY KEY,
`username` TEXT NOT NULL,
`password` TEXT NOT NULL,
`open_id` TEXT NOT NULL DEFAULT '',
`created_at` TEXT NOT NULL DEFAULT (DATETIME('now', 'localtime')),
`updated_at` TEXT NOT NULL DEFAULT (DATETIME('now', 'localtime')),
UNIQUE(`username`, `open_id`)
);
CREATE TABLE `queries` (
`id` TEXT NOT NULL PRIMARY KEY,
`user_id` TEXT NOT NULL,
`title` TEXT NOT NULL,
`querystring` TEXT NOT NULL,
`created_at` TEXT NOT NULL DEFAULT (DATETIME('now', 'localtime')),
`updated_at` TEXT NOT NULL DEFAULT (DATETIME('now', 'localtime')),
`pinned_at` TEXT NOT NULL DEFAULT '',
FOREIGN KEY(`user_id`) REFERENCES `users`(`id`)
);
CREATE TABLE `memos` (
`id` TEXT NOT NULL PRIMARY KEY,
`content` TEXT NOT NULL,
`user_id` TEXT NOT NULL,
`created_at` TEXT NOT NULL DEFAULT (DATETIME('now', 'localtime')),
`updated_at` TEXT NOT NULL DEFAULT (DATETIME('now', 'localtime')),
`deleted_at` TEXT NOT NULL DEFAULT '',
FOREIGN KEY(`user_id`) REFERENCES `users`(`id`)
);
CREATE TABLE `resources` (
`id` TEXT NOT NULL PRIMARY KEY,
`user_id` TEXT NOT NULL,
`filename` TEXT NOT NULL,
`blob` BLOB NOT NULL,
`type` TEXT NOT NULL,
`size` INTEGER NOT NULL DEFAULT 0,
`created_at` TEXT NOT NULL DEFAULT (DATETIME('now', 'localtime')),
FOREIGN KEY(`user_id`) REFERENCES `users`(`id`)
);
INSERT INTO `users`
(`id`, `username`, `password`, `open_id`)
VALUES
('1', 'guest', '123456', 'guest_open_id'),
('2', 'mine', '123456', 'mine_open_id');
INSERT INTO `memos`
(`id`, `content`, `user_id`)
VALUES
('1', '👋 Welcome to memos', '1'),
('2', '👋 Welcome to memos', '2');
......@@ -3,7 +3,7 @@ tmp_dir = ".air"
[build]
bin = "./.air/memos"
cmd = "go build -o ./.air/memos ./server/main.go"
cmd = "go build -o ./.air/memos ./bin/server/main.go"
delay = 1000
exclude_dir = [".air", "web"]
exclude_file = []
......
package server
import (
"encoding/json"
"fmt"
"memos/api"
"memos/common"
"net/http"
"github.com/labstack/echo/v4"
)
func (s *Server) registerAuthRoutes(g *echo.Group) {
g.POST("/auth/login", func(c echo.Context) error {
login := &api.Login{}
if err := json.NewDecoder(c.Request().Body).Decode(login); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted login request").SetInternal(err)
}
userFind := &api.UserFind{
Name: &login.Name,
}
user, err := s.UserService.FindUser(userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to authenticate user").SetInternal(err)
}
if user == nil {
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("User not found: %s", login.Name))
}
// Compare the stored password
if login.Password != user.Password {
// If the two passwords don't match, return a 401 status.
return echo.NewHTTPError(http.StatusUnauthorized, "Incorrect password").SetInternal(err)
}
err = setUserSession(c, user)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set login session").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user response").SetInternal(err)
}
return nil
})
g.POST("/auth/logout", func(c echo.Context) error {
err := removeUserSession(c)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set logout session").SetInternal(err)
}
c.Response().WriteHeader(http.StatusOK)
return nil
})
g.POST("/auth/signup", func(c echo.Context) error {
signup := &api.Signup{}
if err := json.NewDecoder(c.Request().Body).Decode(signup); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signup request").SetInternal(err)
}
userFind := &api.UserFind{
Name: &signup.Name,
}
user, err := s.UserService.FindUser(userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to authenticate user").SetInternal(err)
}
if user != nil {
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Existed user found: %s", signup.Name))
}
userCreate := &api.UserCreate{
Name: signup.Name,
Password: signup.Password,
OpenId: common.GenUUID(),
}
user, err = s.UserService.CreateUser(userCreate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
}
err = setUserSession(c, user)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set signup session").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode created user response").SetInternal(err)
}
return nil
})
}
package server
import (
"fmt"
"memos/api"
"memos/common"
"net/http"
"strconv"
"github.com/gorilla/sessions"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
)
var (
userIdContextKey = "user-id"
)
func getUserIdContextKey() string {
return userIdContextKey
}
// Purpose of this cookie is to store the user's id.
func setUserSession(c echo.Context, user *api.User) error {
sess, err := session.Get("session", c)
if err != nil {
return fmt.Errorf("failed to get session, err: %w", err)
}
sess.Options = &sessions.Options{
Path: "/",
MaxAge: 1000 * 3600 * 24 * 30,
HttpOnly: true,
}
sess.Values[userIdContextKey] = user.Id
err = sess.Save(c.Request(), c.Response())
if err != nil {
return fmt.Errorf("failed to set session, err: %w", err)
}
return nil
}
func removeUserSession(c echo.Context) error {
sess, err := session.Get("session", c)
if err != nil {
return fmt.Errorf("failed to get session, err: %w", err)
}
sess.Options = &sessions.Options{
Path: "/",
MaxAge: 0,
HttpOnly: true,
}
sess.Values[userIdContextKey] = nil
err = sess.Save(c.Request(), c.Response())
if err != nil {
return fmt.Errorf("failed to set session, err: %w", err)
}
return nil
}
// Use session in the initial version
func BasicAuthMiddleware(us api.UserService, next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Skips auth
if common.HasPrefixes(c.Path(), "/api/auth") {
return next(c)
}
sess, err := session.Get("session", c)
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing session").SetInternal(err)
}
userIdValue := sess.Values[userIdContextKey]
if userIdValue == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing userId in session")
}
userId, err := strconv.Atoi(fmt.Sprintf("%v", userIdValue))
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Failed to malformatted user id in the session.")
}
// Even if there is no error, we still need to make sure the user still exists.
principalFind := &api.UserFind{
Id: &userId,
}
user, err := us.FindUser(principalFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user by ID: %d", userId)).SetInternal(err)
}
if user == nil {
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Not found user ID: %d", userId))
}
// Stores userId into context.
c.Set(getUserIdContextKey(), userId)
return next(c)
}
}
package server
func composeResponse(data interface{}) interface{} {
type R struct {
Data interface{} `json:"data"`
}
return R{
Data: data,
}
}
package main
import (
"memos/api"
"memos/store"
"net/http"
"github.com/gorilla/mux"
)
func main() {
store.InitDBConn()
r := mux.NewRouter().StrictSlash(true)
api.RegisterAuthRoutes(r)
api.RegisterUserRoutes(r)
api.RegisterMemoRoutes(r)
api.RegisterQueryRoutes(r)
api.RegisterResourceRoutes(r)
api.RegisterWebHooksRoutes(r)
webServe := api.SPAHandler{
StaticPath: "./web/dist",
IndexPath: "index.html",
}
r.PathPrefix("/").Handler(webServe)
http.ListenAndServe(":8080", r)
}
package server
import (
"encoding/json"
"fmt"
"memos/api"
"memos/common"
"net/http"
"strconv"
"github.com/labstack/echo/v4"
)
func (s *Server) registerMemoRoutes(g *echo.Group) {
g.POST("/memo", func(c echo.Context) error {
userId := c.Get(getUserIdContextKey()).(int)
memoCreate := &api.MemoCreate{
CreatorId: userId,
}
if err := json.NewDecoder(c.Request().Body).Decode(memoCreate); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo request").SetInternal(err)
}
memo, err := s.MemoService.CreateMemo(memoCreate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create memo").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.PATCH("/memo/:memoId", func(c echo.Context) error {
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)
}
memoPatch := &api.MemoPatch{
Id: memoId,
}
if err := json.NewDecoder(c.Request().Body).Decode(memoPatch); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch memo request").SetInternal(err)
}
memo, err := s.MemoService.PatchMemo(memoPatch)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch memo").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.GET("/memo", func(c echo.Context) error {
userId := c.Get(getUserIdContextKey()).(int)
memoFind := &api.MemoFind{
CreatorId: &userId,
}
showHiddenMemo, err := strconv.ParseBool(c.QueryParam("hidden"))
if err != nil {
showHiddenMemo = false
}
rowStatus := "NORMAL"
if showHiddenMemo {
rowStatus = "HIDDEN"
}
memoFind.RowStatus = &rowStatus
list, err := s.MemoService.FindMemoList(memoFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch memo list").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(list)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo list response").SetInternal(err)
}
return nil
})
g.GET("/memo/:memoId", func(c echo.Context) error {
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.MemoService.FindMemo(memoFind)
if err != nil {
if common.ErrorCode(err) == common.NotFound {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo ID not found: %d", memoId))
}
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete memo 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.DELETE("/memo/:memoId", func(c echo.Context) error {
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)
}
memoDelete := &api.MemoDelete{
Id: &memoId,
}
err = s.MemoService.DeleteMemo(memoDelete)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete memo ID: %v", memoId)).SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
c.Response().WriteHeader(http.StatusOK)
return nil
})
}
package server
import (
"encoding/json"
"fmt"
"io/ioutil"
"memos/api"
"net/http"
"strconv"
"github.com/labstack/echo/v4"
)
func (s *Server) registerResourceRoutes(g *echo.Group) {
g.POST("/resource", func(c echo.Context) error {
userId := c.Get(getUserIdContextKey()).(int)
err := c.Request().ParseMultipartForm(5 << 20)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Upload file overload max size").SetInternal(err)
}
file, err := c.FormFile("file")
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Upload file not found").SetInternal(err)
}
filename := file.Filename
filetype := file.Header.Get("Content-Type")
size := file.Size
src, err := file.Open()
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to open file").SetInternal(err)
}
defer src.Close()
fileBytes, err := ioutil.ReadAll(src)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to read file").SetInternal(err)
}
resourceCreate := &api.ResourceCreate{
Filename: filename,
Type: filetype,
Size: size,
Blob: fileBytes,
CreatorId: userId,
}
resource, err := s.ResourceService.CreateResource(resourceCreate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create 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 shortcut response").SetInternal(err)
}
return nil
})
g.GET("/resource", func(c echo.Context) error {
userId := c.Get(getUserIdContextKey()).(int)
resourceFind := &api.ResourceFind{
CreatorId: &userId,
}
list, err := s.ResourceService.FindResourceList(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(list)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode resource list response").SetInternal(err)
}
return nil
})
g.DELETE("/resource/:resourceId", func(c echo.Context) error {
resourceId, err := strconv.Atoi(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
resourceDelete := &api.ResourceDelete{
Id: resourceId,
}
if err := s.ResourceService.DeleteResource(resourceDelete); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete resource").SetInternal(err)
}
return nil
})
}
package server
import (
"fmt"
"memos/api"
"time"
"github.com/gorilla/sessions"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
type Server struct {
e *echo.Echo
UserService api.UserService
MemoService api.MemoService
ShortcutService api.ShortcutService
ResourceService api.ResourceService
port int
}
func NewServer(port int) *Server {
e := echo.New()
e.Debug = true
e.HideBanner = true
e.HidePort = false
e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
Format: "${method} ${uri} ${status}\n",
}))
e.Use(middleware.TimeoutWithConfig(middleware.TimeoutConfig{
Skipper: middleware.DefaultSkipper,
ErrorMessage: "Request timeout",
Timeout: 30 * time.Second,
}))
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
Skipper: middleware.DefaultSkipper,
Root: "web/dist",
Browse: false,
HTML5: true,
}))
e.Use(session.Middleware(sessions.NewCookieStore([]byte("just_memos"))))
s := &Server{
e: e,
port: port,
}
webhookGroup := e.Group("/h")
s.registerWebhookRoutes(webhookGroup)
apiGroup := e.Group("/api")
apiGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return BasicAuthMiddleware(s.UserService, next)
})
s.registerAuthRoutes(apiGroup)
s.registerUserRoutes(apiGroup)
s.registerMemoRoutes(apiGroup)
s.registerShortcutRoutes(apiGroup)
s.registerResourceRoutes(apiGroup)
return s
}
func (server *Server) Run() error {
return server.e.Start(fmt.Sprintf(":%d", server.port))
}
package server
import (
"encoding/json"
"fmt"
"memos/api"
"net/http"
"strconv"
"github.com/labstack/echo/v4"
)
func (s *Server) registerShortcutRoutes(g *echo.Group) {
g.POST("/shortcut", func(c echo.Context) error {
userId := c.Get(getUserIdContextKey()).(int)
shortcutCreate := &api.ShortcutCreate{
CreatorId: userId,
}
if err := json.NewDecoder(c.Request().Body).Decode(shortcutCreate); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post shortcut request").SetInternal(err)
}
shortcut, err := s.ShortcutService.CreateShortcut(shortcutCreate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create shortcut").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(shortcut)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode shortcut response").SetInternal(err)
}
return nil
})
g.PATCH("/shortcut/:shortcutId", func(c echo.Context) error {
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)
}
shortcutPatch := &api.ShortcutPatch{
Id: shortcutId,
}
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.ShortcutService.PatchShortcut(shortcutPatch)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch shortcut").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(shortcut)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode shortcut response").SetInternal(err)
}
return nil
})
g.GET("/shortcut", func(c echo.Context) error {
userId := c.Get(getUserIdContextKey()).(int)
shortcutFind := &api.ShortcutFind{
CreatorId: &userId,
}
list, err := s.ShortcutService.FindShortcutList(shortcutFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch shortcut list").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(list)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode shortcut list response").SetInternal(err)
}
return nil
})
g.GET("/shortcut/:shortcutId", func(c echo.Context) error {
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.ShortcutService.FindShortcut(shortcutFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch shortcut").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(shortcut)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode shortcut response").SetInternal(err)
}
return nil
})
g.DELETE("/shortcut/:shortcutId", func(c echo.Context) error {
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)
}
shortcutDelete := &api.ShortcutDelete{
Id: shortcutId,
}
if err := s.ShortcutService.DeleteShortcut(shortcutDelete); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete shortcut").SetInternal(err)
}
return nil
})
}
package server
import (
"encoding/json"
"memos/api"
"memos/common"
"net/http"
"github.com/labstack/echo/v4"
)
func (s *Server) registerUserRoutes(g *echo.Group) {
g.GET("/user/me", func(c echo.Context) error {
// /api/user/me is used to check if the user is logged in,
userSessionId := c.Get(getUserIdContextKey())
if userSessionId == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing session")
}
userId := userSessionId.(int)
userFind := &api.UserFind{
Id: &userId,
}
user, err := s.UserService.FindUser(userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch user").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user response").SetInternal(err)
}
return nil
})
g.POST("/user/rename_check", func(c echo.Context) error {
userRenameCheck := &api.UserRenameCheck{}
if err := json.NewDecoder(c.Request().Body).Decode(userRenameCheck); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user rename check request").SetInternal(err)
}
if userRenameCheck.Name == "" {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user rename check request")
}
userFind := &api.UserFind{
Name: &userRenameCheck.Name,
}
user, err := s.UserService.FindUser(userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
isUsable := true
if user != nil {
isUsable = false
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(isUsable)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode rename check response").SetInternal(err)
}
return nil
})
g.POST("/user/password_check", func(c echo.Context) error {
userId := c.Get(getUserIdContextKey()).(int)
userPasswordCheck := &api.UserPasswordCheck{}
if err := json.NewDecoder(c.Request().Body).Decode(userPasswordCheck); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user password check request").SetInternal(err)
}
if userPasswordCheck.Password == "" {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user password check request")
}
userFind := &api.UserFind{
Id: &userId,
Password: &userPasswordCheck.Password,
}
user, err := s.UserService.FindUser(userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
isValid := false
if user != nil {
isValid = true
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(isValid)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode password check response").SetInternal(err)
}
return nil
})
g.PATCH("/user/me", func(c echo.Context) error {
userId := c.Get(getUserIdContextKey()).(int)
userPatch := &api.UserPatch{
Id: userId,
}
if err := json.NewDecoder(c.Request().Body).Decode(userPatch); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch user request").SetInternal(err)
}
if userPatch.ResetOpenId != nil && *userPatch.ResetOpenId {
openId := common.GenUUID()
userPatch.OpenId = &openId
}
user, err := s.UserService.PatchUser(userPatch)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch user").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user response").SetInternal(err)
}
return nil
})
}
package server
import (
"encoding/json"
"fmt"
"memos/api"
"net/http"
"strconv"
"github.com/labstack/echo/v4"
)
func (s *Server) registerWebhookRoutes(g *echo.Group) {
g.GET("/test", func(c echo.Context) error {
return c.HTML(http.StatusOK, "<strong>Hello, World!</strong>")
})
g.POST("/:openId/memo", func(c echo.Context) error {
openId := c.Param("openId")
userFind := &api.UserFind{
OpenId: &openId,
}
user, err := s.UserService.FindUser(userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by open_id").SetInternal(err)
}
if user == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("User openId not found: %s", openId))
}
memoCreate := &api.MemoCreate{
CreatorId: user.Id,
}
if err := json.NewDecoder(c.Request().Body).Decode(memoCreate); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo request by open api").SetInternal(err)
}
memo, err := s.MemoService.CreateMemo(memoCreate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create memo").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.GET("/r/:resourceId/:filename", func(c echo.Context) error {
resourceId, err := strconv.Atoi(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
filename := c.Param("filename")
resourceFind := &api.ResourceFind{
Id: &resourceId,
Filename: &filename,
}
resource, err := s.ResourceService.FindResource(resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to fetch resource ID: %v", resourceId)).SetInternal(err)
}
c.Response().Writer.WriteHeader(http.StatusOK)
c.Response().Writer.Header().Set("Content-Type", "application/octet-stream")
c.Response().Writer.Write(resource.Blob)
return nil
})
}
package store
import (
"database/sql"
"errors"
"io/ioutil"
"os"
"path/filepath"
_ "github.com/mattn/go-sqlite3"
)
/*
* Use a global variable to save the db connection: Quick and easy to setup.
* Reference: https://techinscribed.com/different-approaches-to-pass-database-connection-into-controllers-in-golang/
*/
var DB *sql.DB
func InitDBConn() {
// mounting point in docker is "/usr/local/memos/data"
dbFilePath := "./data/memos.db"
if _, err := os.Stat(dbFilePath); err != nil {
dbFilePath = "./resources/memos.db"
println("use the default database")
} else {
println("use the custom database")
}
db, err := sql.Open("sqlite3", dbFilePath)
if err != nil {
panic("db connect failed")
} else {
DB = db
println("connect to sqlite succeed")
}
if dbFilePath == "./resources/memos.db" {
resetDataInDefaultDatabase()
}
}
func FormatDBError(err error) error {
if err == nil {
return nil
}
switch err {
case sql.ErrNoRows:
return errors.New("data not found")
default:
return err
}
}
func resetDataInDefaultDatabase() {
initialSQLFilePath := filepath.Join("resources", "initial_db.sql")
c, err := ioutil.ReadFile(initialSQLFilePath)
if err != nil {
// do nth
return
}
sql := string(c)
DB.Exec(sql)
println("Initial data succeed")
}
package store
import (
"memos/utils"
"fmt"
"memos/api"
"memos/common"
"strings"
)
type Memo struct {
Id string `json:"id"`
Content string `json:"content"`
UserId string `json:"userId"`
DeletedAt string `json:"deletedAt"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
type MemoService struct {
db *DB
}
func CreateNewMemo(content string, userId string) (Memo, error) {
nowDateTimeStr := utils.GetNowDateTimeStr()
newMemo := Memo{
Id: utils.GenUUID(),
Content: content,
UserId: userId,
DeletedAt: "",
CreatedAt: nowDateTimeStr,
UpdatedAt: nowDateTimeStr,
}
func NewMemoService(db *DB) *MemoService {
return &MemoService{db: db}
}
query := `INSERT INTO memos (id, content, user_id, deleted_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`
_, err := DB.Exec(query, newMemo.Id, newMemo.Content, newMemo.UserId, newMemo.DeletedAt, newMemo.CreatedAt, newMemo.UpdatedAt)
func (s *MemoService) CreateMemo(create *api.MemoCreate) (*api.Memo, error) {
memo, err := createMemo(s.db, create)
if err != nil {
return nil, err
}
return newMemo, FormatDBError(err)
return memo, nil
}
type MemoPatch struct {
Content *string
DeletedAt *string
func (s *MemoService) PatchMemo(patch *api.MemoPatch) (*api.Memo, error) {
memo, err := patchMemo(s.db, patch)
if err != nil {
return nil, err
}
return memo, nil
}
func UpdateMemo(id string, memoPatch *MemoPatch) (Memo, error) {
memo, _ := GetMemoById(id)
set, args := []string{}, []interface{}{}
func (s *MemoService) FindMemoList(find *api.MemoFind) ([]*api.Memo, error) {
list, err := findMemoList(s.db, find)
if err != nil {
return nil, err
}
return list, nil
}
if v := memoPatch.Content; v != nil {
memo.Content = *v
set, args = append(set, "content=?"), append(args, *v)
func (s *MemoService) FindMemo(find *api.MemoFind) (*api.Memo, error) {
list, err := findMemoList(s.db, find)
if err != nil {
return nil, err
}
if v := memoPatch.DeletedAt; v != nil {
memo.DeletedAt = *v
set, args = append(set, "deleted_at=?"), append(args, *v)
if len(list) == 0 {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
set, args = append(set, "updated_at=?"), append(args, utils.GetNowDateTimeStr())
args = append(args, id)
sqlQuery := `UPDATE memos SET ` + strings.Join(set, ",") + ` WHERE id=?`
_, err := DB.Exec(sqlQuery, args...)
return list[0], nil
}
return memo, FormatDBError(err)
func (s *MemoService) DeleteMemo(delete *api.MemoDelete) error {
err := deleteMemo(s.db, delete)
if err != nil {
return FormatError(err)
}
return nil
}
func DeleteMemo(memoId string) error {
query := `DELETE FROM memos WHERE id=?`
_, err := DB.Exec(query, memoId)
return FormatDBError(err)
func createMemo(db *DB, create *api.MemoCreate) (*api.Memo, error) {
row, err := db.Db.Query(`
INSERT INTO memo (
creator_id,
content
)
VALUES (?, ?)
RETURNING id, creator_id, created_ts, updated_ts, content, row_status
`,
create.CreatorId,
create.Content,
)
if err != nil {
return nil, FormatError(err)
}
defer row.Close()
if !row.Next() {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
var memo api.Memo
if err := row.Scan(
&memo.Id,
&memo.CreatorId,
&memo.CreatedTs,
&memo.UpdatedTs,
&memo.Content,
&memo.RowStatus,
); err != nil {
return nil, FormatError(err)
}
return &memo, nil
}
func GetMemoById(id string) (Memo, error) {
query := `SELECT id, content, deleted_at, created_at, updated_at FROM memos WHERE id=?`
memo := Memo{}
err := DB.QueryRow(query, id).Scan(&memo.Id, &memo.Content, &memo.DeletedAt, &memo.CreatedAt, &memo.UpdatedAt)
return memo, FormatDBError(err)
func patchMemo(db *DB, patch *api.MemoPatch) (*api.Memo, error) {
set, args := []string{}, []interface{}{}
if v := patch.Content; v != nil {
set, args = append(set, "content = ?"), append(args, *v)
}
if v := patch.RowStatus; v != nil {
set, args = append(set, "row_status = ?"), append(args, *v)
}
args = append(args, patch.Id)
row, err := db.Db.Query(`
UPDATE memo
SET `+strings.Join(set, ", ")+`
WHERE id = ?
RETURNING id, created_ts, updated_ts, content, row_status
`, args...)
if err != nil {
return nil, FormatError(err)
}
defer row.Close()
if !row.Next() {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
var memo api.Memo
if err := row.Scan(
&memo.Id,
&memo.CreatedTs,
&memo.UpdatedTs,
&memo.Content,
&memo.RowStatus,
); err != nil {
return nil, FormatError(err)
}
return &memo, nil
}
func GetMemosByUserId(userId string, onlyDeleted bool) ([]Memo, error) {
sqlQuery := `SELECT id, content, deleted_at, created_at, updated_at FROM memos WHERE user_id=?`
func findMemoList(db *DB, find *api.MemoFind) ([]*api.Memo, error) {
where, args := []string{"1 = 1"}, []interface{}{}
if onlyDeleted {
sqlQuery = sqlQuery + ` AND deleted_at!=""`
} else {
sqlQuery = sqlQuery + ` AND deleted_at=""`
if v := find.Id; v != nil {
where, args = append(where, "id = ?"), append(args, *v)
}
if v := find.CreatorId; v != nil {
where, args = append(where, "creator_id = ?"), append(args, *v)
}
if v := find.RowStatus; v != nil {
where, args = append(where, "row_status = ?"), append(args, *v)
}
rows, _ := DB.Query(sqlQuery, userId)
rows, err := db.Db.Query(`
SELECT
id,
creator_id,
created_ts,
updated_ts,
content,
row_status
FROM memo
WHERE `+strings.Join(where, " AND "),
args...,
)
if err != nil {
return nil, FormatError(err)
}
defer rows.Close()
memos := []Memo{}
list := make([]*api.Memo, 0)
for rows.Next() {
memo := Memo{}
rows.Scan(&memo.Id, &memo.Content, &memo.DeletedAt, &memo.CreatedAt, &memo.UpdatedAt)
memos = append(memos, memo)
var memo api.Memo
if err := rows.Scan(
&memo.Id,
&memo.CreatorId,
&memo.CreatedTs,
&memo.UpdatedTs,
&memo.Content,
&memo.RowStatus,
); err != nil {
return nil, FormatError(err)
}
list = append(list, &memo)
}
if err := rows.Err(); err != nil {
return nil, FormatDBError(err)
return nil, FormatError(err)
}
return list, nil
}
func deleteMemo(db *DB, delete *api.MemoDelete) error {
result, err := db.Db.Exec(`DELETE FROM memo WHERE id = ?`, delete.Id)
if err != nil {
return FormatError(err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return &common.Error{Code: common.NotFound, Err: fmt.Errorf("memo ID not found: %d", delete.Id)}
}
return memos, nil
return nil
}
package store
import (
"memos/utils"
"strings"
)
type Query struct {
Id string `json:"id"`
UserId string `json:"userId"`
Title string `json:"title"`
Querystring string `json:"querystring"`
PinnedAt string `json:"pinnedAt"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
func CreateNewQuery(title string, querystring string, userId string) (Query, error) {
nowDateTimeStr := utils.GetNowDateTimeStr()
newQuery := Query{
Id: utils.GenUUID(),
Title: title,
Querystring: querystring,
UserId: userId,
PinnedAt: "",
CreatedAt: nowDateTimeStr,
UpdatedAt: nowDateTimeStr,
}
sqlQuery := `INSERT INTO queries (id, title, querystring, user_id, pinned_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`
_, err := DB.Exec(sqlQuery, newQuery.Id, newQuery.Title, newQuery.Querystring, newQuery.UserId, newQuery.PinnedAt, newQuery.CreatedAt, newQuery.UpdatedAt)
return newQuery, FormatDBError(err)
}
type QueryPatch struct {
Title *string
Querystring *string
PinnedAt *string
}
func UpdateQuery(id string, queryPatch *QueryPatch) (Query, error) {
query, _ := GetQueryById(id)
set, args := []string{}, []interface{}{}
if v := queryPatch.Title; v != nil {
query.Title = *v
set, args = append(set, "title=?"), append(args, *v)
}
if v := queryPatch.Querystring; v != nil {
query.Querystring = *v
set, args = append(set, "querystring=?"), append(args, *v)
}
if v := queryPatch.PinnedAt; v != nil {
query.PinnedAt = *v
set, args = append(set, "pinned_at=?"), append(args, *v)
}
set, args = append(set, "updated_at=?"), append(args, utils.GetNowDateTimeStr())
args = append(args, id)
sqlQuery := `UPDATE queries SET ` + strings.Join(set, ",") + ` WHERE id=?`
_, err := DB.Exec(sqlQuery, args...)
return query, FormatDBError(err)
}
func DeleteQuery(queryId string) error {
query := `DELETE FROM queries WHERE id=?`
_, err := DB.Exec(query, queryId)
return FormatDBError(err)
}
func GetQueryById(queryId string) (Query, error) {
sqlQuery := `SELECT id, title, querystring, pinned_at, created_at, updated_at FROM queries WHERE id=?`
query := Query{}
err := DB.QueryRow(sqlQuery, queryId).Scan(&query.Id, &query.Title, &query.Querystring, &query.PinnedAt, &query.CreatedAt, &query.UpdatedAt)
return query, FormatDBError(err)
}
func GetQueriesByUserId(userId string) ([]Query, error) {
query := `SELECT id, title, querystring, pinned_at, created_at, updated_at FROM queries WHERE user_id=?`
rows, _ := DB.Query(query, userId)
defer rows.Close()
queries := []Query{}
for rows.Next() {
query := Query{}
rows.Scan(&query.Id, &query.Title, &query.Querystring, &query.PinnedAt, &query.CreatedAt, &query.UpdatedAt)
queries = append(queries, query)
}
if err := rows.Err(); err != nil {
return nil, FormatDBError(err)
}
return queries, nil
}
package store
import "memos/utils"
import (
"fmt"
"memos/api"
"memos/common"
"strings"
)
type Resource struct {
Id string `json:"id"`
UserId string `json:"userId"`
Filename string `json:"filename"`
Blob []byte `json:"blob"`
Type string `json:"type"`
Size int64 `json:"size"`
CreatedAt string `json:"createdAt"`
type ResourceService struct {
db *DB
}
func CreateResource(userId string, filename string, blob []byte, filetype string, size int64) (Resource, error) {
newResource := Resource{
Id: utils.GenUUID(),
UserId: userId,
Filename: filename,
Blob: blob,
Type: filetype,
Size: size,
CreatedAt: utils.GetNowDateTimeStr(),
func NewResourceService(db *DB) *ResourceService {
return &ResourceService{db: db}
}
func (s *ResourceService) CreateResource(create *api.ResourceCreate) (*api.Resource, error) {
resource, err := createResource(s.db, create)
if err != nil {
return nil, err
}
query := `INSERT INTO resources (id, user_id, filename, blob, type, size, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`
_, err := DB.Exec(query, newResource.Id, newResource.UserId, newResource.Filename, newResource.Blob, newResource.Type, newResource.Size, newResource.CreatedAt)
return resource, nil
}
func (s *ResourceService) FindResourceList(find *api.ResourceFind) ([]*api.Resource, error) {
list, err := findResourceList(s.db, find)
if err != nil {
return nil, err
}
return newResource, FormatDBError(err)
return list, nil
}
func GetResourcesByUserId(userId string) ([]Resource, error) {
query := `SELECT id, filename, type, size, created_at FROM resources WHERE user_id=?`
rows, _ := DB.Query(query, userId)
defer rows.Close()
func (s *ResourceService) FindResource(find *api.ResourceFind) (*api.Resource, error) {
list, err := findResourceList(s.db, find)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
return list[0], nil
}
func (s *ResourceService) DeleteResource(delete *api.ResourceDelete) error {
err := deleteResource(s.db, delete)
if err != nil {
return err
}
return nil
}
func createResource(db *DB, create *api.ResourceCreate) (*api.Resource, error) {
row, err := db.Db.Query(`
INSERT INTO resource (
filename,
blob,
type,
size,
creator_id
)
VALUES (?, ?, ?, ?, ?)
RETURNING id, filename, blob, type, size, created_ts, updated_ts
`,
create.Filename,
create.Blob,
create.Type,
create.Size,
create.CreatorId,
)
if err != nil {
return nil, FormatError(err)
}
defer row.Close()
resources := []Resource{}
if !row.Next() {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
var resource api.Resource
if err := row.Scan(
&resource.Id,
&resource.Filename,
&resource.Blob,
&resource.Type,
&resource.Size,
&resource.CreatedTs,
&resource.UpdatedTs,
); err != nil {
return nil, FormatError(err)
}
return &resource, nil
}
func findResourceList(db *DB, find *api.ResourceFind) ([]*api.Resource, error) {
where, args := []string{"1 = 1"}, []interface{}{}
if v := find.Id; v != nil {
where, args = append(where, "id = ?"), append(args, *v)
}
if v := find.CreatorId; v != nil {
where, args = append(where, "creator_id = ?"), append(args, *v)
}
if v := find.Filename; v != nil {
where, args = append(where, "filename = ?"), append(args, *v)
}
rows, err := db.Db.Query(`
SELECT
id,
filename,
blob,
type,
size,
created_ts,
updated_ts
FROM resource
WHERE `+strings.Join(where, " AND "),
args...,
)
if err != nil {
return nil, FormatError(err)
}
defer rows.Close()
list := make([]*api.Resource, 0)
for rows.Next() {
resource := Resource{}
rows.Scan(&resource.Id, &resource.Filename, &resource.Type, &resource.Size, &resource.CreatedAt)
resources = append(resources, resource)
var resource api.Resource
if err := rows.Scan(
&resource.Id,
&resource.Filename,
&resource.Blob,
&resource.Type,
&resource.Size,
&resource.CreatedTs,
&resource.UpdatedTs,
); err != nil {
return nil, FormatError(err)
}
list = append(list, &resource)
}
if err := rows.Err(); err != nil {
return nil, FormatDBError(err)
return nil, FormatError(err)
}
return resources, nil
return list, nil
}
func GetResourceByIdAndFilename(id string, filename string) (Resource, error) {
query := `SELECT id, filename, blob, type, size FROM resources WHERE id=? AND filename=?`
resource := Resource{}
err := DB.QueryRow(query, id, filename).Scan(&resource.Id, &resource.Filename, &resource.Blob, &resource.Type, &resource.Size)
return resource, FormatDBError(err)
}
func deleteResource(db *DB, delete *api.ResourceDelete) error {
result, err := db.Db.Exec(`DELETE FROM resource WHERE id = ?`, delete.Id)
if err != nil {
return FormatError(err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return &common.Error{Code: common.NotFound, Err: fmt.Errorf("resource ID not found: %d", delete.Id)}
}
func DeleteResourceById(id string) error {
query := `DELETE FROM resources WHERE id=?`
_, err := DB.Exec(query, id)
return FormatDBError(err)
return nil
}
DROP TABLE IF EXISTS `memo`;
DROP TABLE IF EXISTS `shortcut`;
DROP TABLE IF EXISTS `resource`;
DROP TABLE IF EXISTS `user`;
-- user
CREATE TABLE user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
password TEXT NOT NULL,
open_id TEXT NOT NULL,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
UNIQUE(`name`, `open_id`)
);
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('user', 100);
CREATE TRIGGER IF NOT EXISTS `trigger_update_user_modification_time`
AFTER
UPDATE
ON `user` FOR EACH ROW BEGIN
UPDATE
`user`
SET
updated_ts = (strftime('%s', 'now'))
WHERE
rowid = old.rowid;
END;
-- memo
CREATE TABLE memo (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
-- allowed row status are 'NORMAL', 'HIDDEN'.
row_status TEXT NOT NULL DEFAULT 'NORMAL',
content TEXT NOT NULL DEFAULT '',
creator_id INTEGER NOT NULL,
FOREIGN KEY(creator_id) REFERENCES users(id)
);
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('memo', 100);
CREATE TRIGGER IF NOT EXISTS `trigger_update_memo_modification_time`
AFTER
UPDATE
ON `memo` FOR EACH ROW BEGIN
UPDATE
`memo`
SET
updated_ts = (strftime('%s', 'now'))
WHERE
rowid = old.rowid;
END;
-- shortcut
CREATE TABLE shortcut (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
title TEXT NOT NULL DEFAULT '',
payload TEXT NOT NULL DEFAULT '',
creator_id INTEGER NOT NULL,
-- allowed row status are 'NORMAL', 'ARCHIVED'.
row_status TEXT NOT NULL DEFAULT 'NORMAL',
FOREIGN KEY(creator_id) REFERENCES users(id)
);
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('shortcut', 100);
CREATE TRIGGER IF NOT EXISTS `trigger_update_shortcut_modification_time`
AFTER
UPDATE
ON `shortcut` FOR EACH ROW BEGIN
UPDATE
`shortcut`
SET
updated_ts = (strftime('%s', 'now'))
WHERE
rowid = old.rowid;
END;
-- resource
CREATE TABLE resource (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL DEFAULT '',
blob BLOB NOT NULL,
type TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
creator_id INTEGER NOT NULL,
FOREIGN KEY(creator_id) REFERENCES users(id)
);
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('resource', 100);
CREATE TRIGGER IF NOT EXISTS `trigger_update_resource_modification_time`
AFTER
UPDATE
ON `resource` FOR EACH ROW BEGIN
UPDATE
`resource`
SET
updated_ts = (strftime('%s', 'now'))
WHERE
rowid = old.rowid;
END;
INSERT INTO user
(`id`, `name`, `password`, `open_id`)
VALUES
(1, 'guest', '123456', 'guest_open_id'),
INSERT INTO memo
(`content`, `creator_id`)
VALUES
('👋 Welcome to memos', 1),
package store
import (
"fmt"
"memos/api"
"memos/common"
"strings"
)
type ShortcutService struct {
db *DB
}
func NewShortcutService(db *DB) *ShortcutService {
return &ShortcutService{db: db}
}
func (s *ShortcutService) CreateShortcut(create *api.ShortcutCreate) (*api.Shortcut, error) {
shortcut, err := createShortcut(s.db, create)
if err != nil {
return nil, err
}
return shortcut, nil
}
func (s *ShortcutService) PatchShortcut(patch *api.ShortcutPatch) (*api.Shortcut, error) {
shortcut, err := patchShortcut(s.db, patch)
if err != nil {
return nil, err
}
return shortcut, nil
}
func (s *ShortcutService) FindShortcutList(find *api.ShortcutFind) ([]*api.Shortcut, error) {
list, err := findShortcutList(s.db, find)
if err != nil {
return nil, err
}
return list, nil
}
func (s *ShortcutService) FindShortcut(find *api.ShortcutFind) (*api.Shortcut, error) {
list, err := findShortcutList(s.db, find)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
return list[0], nil
}
func (s *ShortcutService) DeleteShortcut(delete *api.ShortcutDelete) error {
err := deleteShortcut(s.db, delete)
if err != nil {
return FormatError(err)
}
return nil
}
func createShortcut(db *DB, create *api.ShortcutCreate) (*api.Shortcut, error) {
row, err := db.Db.Query(`
INSERT INTO shortcut (
title,
payload,
creator_id
)
VALUES (?, ?, ?)
RETURNING id, title, payload, creator_id, created_ts, updated_ts, row_status
`,
create.Title,
create.Payload,
create.CreatorId,
)
if err != nil {
return nil, FormatError(err)
}
defer row.Close()
row.Next()
var shortcut api.Shortcut
if err := row.Scan(
&shortcut.Id,
&shortcut.Title,
&shortcut.Payload,
&shortcut.CreatorId,
&shortcut.CreatedTs,
&shortcut.UpdatedTs,
&shortcut.RowStatus,
); err != nil {
return nil, FormatError(err)
}
return &shortcut, nil
}
func patchShortcut(db *DB, patch *api.ShortcutPatch) (*api.Shortcut, error) {
set, args := []string{}, []interface{}{}
if v := patch.Title; v != nil {
set, args = append(set, "title = ?"), append(args, *v)
}
if v := patch.Payload; v != nil {
set, args = append(set, "payload = ?"), append(args, *v)
}
if v := patch.RowStatus; v != nil {
set, args = append(set, "row_status = ?"), append(args, *v)
}
args = append(args, patch.Id)
row, err := db.Db.Query(`
UPDATE shortcut
SET `+strings.Join(set, ", ")+`
WHERE id = ?
RETURNING id, title, payload, created_ts, updated_ts, row_status
`, args...)
if err != nil {
return nil, FormatError(err)
}
defer row.Close()
if !row.Next() {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
var shortcut api.Shortcut
if err := row.Scan(
&shortcut.Id,
&shortcut.Title,
&shortcut.Payload,
&shortcut.CreatedTs,
&shortcut.UpdatedTs,
&shortcut.RowStatus,
); err != nil {
return nil, FormatError(err)
}
return &shortcut, nil
}
func findShortcutList(db *DB, find *api.ShortcutFind) ([]*api.Shortcut, error) {
where, args := []string{"1 = 1"}, []interface{}{}
if v := find.Id; v != nil {
where, args = append(where, "id = ?"), append(args, *v)
}
if v := find.CreatorId; v != nil {
where, args = append(where, "creator_id = ?"), append(args, *v)
}
if v := find.Title; v != nil {
where, args = append(where, "title = ?"), append(args, *v)
}
rows, err := db.Db.Query(`
SELECT
id,
title,
payload,
creator_id,
created_ts,
updated_ts,
row_status
FROM shortcut
WHERE `+strings.Join(where, " AND "),
args...,
)
if err != nil {
return nil, FormatError(err)
}
defer rows.Close()
list := make([]*api.Shortcut, 0)
for rows.Next() {
var shortcut api.Shortcut
if err := rows.Scan(
&shortcut.Id,
&shortcut.Title,
&shortcut.Payload,
&shortcut.CreatorId,
&shortcut.CreatedTs,
&shortcut.UpdatedTs,
&shortcut.RowStatus,
); err != nil {
return nil, FormatError(err)
}
list = append(list, &shortcut)
}
if err := rows.Err(); err != nil {
return nil, FormatError(err)
}
return list, nil
}
func deleteShortcut(db *DB, delete *api.ShortcutDelete) error {
result, err := db.Db.Exec(`DELETE FROM saved_query WHERE id = ?`, delete.Id)
if err != nil {
return FormatError(err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return &common.Error{Code: common.NotFound, Err: fmt.Errorf("memo ID not found: %d", delete.Id)}
}
return nil
}
package store
import (
"database/sql"
"embed"
"errors"
"fmt"
"io/fs"
"sort"
_ "github.com/mattn/go-sqlite3"
)
//go:embed seed
var seedFS embed.FS
type DB struct {
Db *sql.DB
// Datasource name.
DSN string
}
// NewDB returns a new instance of DB associated with the given datasource name.
func NewDB(dsn string) *DB {
db := &DB{
DSN: dsn,
}
return db
}
func (db *DB) Open() (err error) {
// Ensure a DSN is set before attempting to open the database.
if db.DSN == "" {
return fmt.Errorf("dsn required")
}
// Connect to the database.
if db.Db, err = sql.Open("sqlite3", db.DSN); err != nil {
return err
}
if err := db.seed(); err != nil {
return fmt.Errorf("failed to seed: %w", err)
}
return err
}
func (db *DB) seed() error {
filenames, err := fs.Glob(seedFS, fmt.Sprintf("%s/*.sql", "seed"))
if err != nil {
return err
}
sort.Strings(filenames)
// Loop over all seed files and execute them in order.
for _, filename := range filenames {
if err := db.seedFile(filename); err != nil {
return fmt.Errorf("seed error: name=%q err=%w", filename, err)
}
}
return nil
}
// seedFile runs a single seed file within a transaction.
func (db *DB) seedFile(name string) error {
tx, err := db.Db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Read and execute migration file.
if buf, err := fs.ReadFile(seedFS, name); err != nil {
return err
} else if _, err := tx.Exec(string(buf)); err != nil {
return err
}
return tx.Commit()
}
func FormatError(err error) error {
if err == nil {
return nil
}
switch err {
case sql.ErrNoRows:
return errors.New("data not found")
default:
return err
}
}
package store
import (
"database/sql"
"fmt"
"memos/utils"
"memos/api"
"memos/common"
"strings"
)
type User struct {
Id string `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
OpenId string `json:"openId"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
type UserService struct {
db *DB
}
func CreateNewUser(username string, password string) (User, error) {
nowDateTimeStr := utils.GetNowDateTimeStr()
newUser := User{
Id: utils.GenUUID(),
Username: username,
Password: password,
OpenId: utils.GenUUID(),
CreatedAt: nowDateTimeStr,
UpdatedAt: nowDateTimeStr,
}
query := `INSERT INTO users (id, username, password, open_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`
_, err := DB.Exec(query, newUser.Id, newUser.Username, newUser.Password, newUser.OpenId, newUser.CreatedAt, newUser.UpdatedAt)
return newUser, FormatDBError(err)
func NewUserService(db *DB) *UserService {
return &UserService{db: db}
}
type UpdateUserPatch struct {
Username *string
Password *string
}
func (s *UserService) CreateUser(create *api.UserCreate) (*api.User, error) {
user, err := createUser(s.db, create)
if err != nil {
return nil, err
}
func UpdateUser(id string, updateUserPatch *UpdateUserPatch) (User, error) {
user := User{}
user, err := GetUserById(id)
return user, nil
}
func (s *UserService) PatchUser(patch *api.UserPatch) (*api.User, error) {
user, err := patchUser(s.db, patch)
if err != nil {
return user, FormatDBError(err)
return nil, err
}
set, args := []string{}, []interface{}{}
return user, nil
}
if v := updateUserPatch.Username; v != nil {
user.Username = *v
set, args = append(set, "username=?"), append(args, *v)
}
if v := updateUserPatch.Password; v != nil {
user.Password = *v
set, args = append(set, "password=?"), append(args, *v)
func (s *UserService) FindUser(find *api.UserFind) (*api.User, error) {
list, err := findUserList(s.db, find)
if err != nil {
return nil, err
}
set, args = append(set, "updated_at=?"), append(args, utils.GetNowDateTimeStr())
args = append(args, id)
sqlQuery := `UPDATE users SET ` + strings.Join(set, ",") + ` WHERE id=?`
_, err = DB.Exec(sqlQuery, args...)
if len(list) == 0 {
return nil, nil
} else if len(list) > 1 {
return nil, &common.Error{Code: common.Conflict, Err: fmt.Errorf("found %d users with filter %+v, expect 1. ", len(list), find)}
}
return user, FormatDBError(err)
return list[0], nil
}
func ResetUserOpenId(userId string) (string, error) {
openId := utils.GenUUID()
query := `UPDATE users SET open_id=? WHERE id=?`
_, err := DB.Exec(query, openId, userId)
return openId, FormatDBError(err)
}
func createUser(db *DB, create *api.UserCreate) (*api.User, error) {
row, err := db.Db.Query(`
INSERT INTO user (
name,
password,
open_id
)
VALUES (?, ?, ?)
RETURNING id, name, password, open_id, created_ts, updated_ts
`,
create.Name,
create.Password,
create.OpenId,
)
if err != nil {
return nil, FormatError(err)
}
defer row.Close()
row.Next()
var user api.User
if err := row.Scan(
&user.Id,
&user.Name,
&user.Password,
&user.OpenId,
&user.CreatedTs,
&user.UpdatedTs,
); err != nil {
return nil, FormatError(err)
}
func GetUserById(id string) (User, error) {
query := `SELECT id, username, password, open_id, created_at, updated_at FROM users WHERE id=?`
user := User{}
err := DB.QueryRow(query, id).Scan(&user.Id, &user.Username, &user.Password, &user.OpenId, &user.CreatedAt, &user.UpdatedAt)
return user, FormatDBError(err)
return &user, nil
}
func GetUserByOpenId(openId string) (User, error) {
query := `SELECT id, username, password, open_id, created_at, updated_at FROM users WHERE open_id=?`
user := User{}
err := DB.QueryRow(query, openId).Scan(&user.Id, &user.Username, &user.Password, &user.OpenId, &user.CreatedAt, &user.UpdatedAt)
return user, FormatDBError(err)
}
func patchUser(db *DB, patch *api.UserPatch) (*api.User, error) {
set, args := []string{}, []interface{}{}
func GetUserByUsernameAndPassword(username string, password string) (User, error) {
query := `SELECT id, username, password, open_id, created_at, updated_at FROM users WHERE username=? AND password=?`
user := User{}
err := DB.QueryRow(query, username, password).Scan(&user.Id, &user.Username, &user.Password, &user.OpenId, &user.CreatedAt, &user.UpdatedAt)
return user, FormatDBError(err)
}
if v := patch.Name; v != nil {
set, args = append(set, "name = ?"), append(args, v)
}
if v := patch.Password; v != nil {
set, args = append(set, "password = ?"), append(args, v)
}
if v := patch.OpenId; v != nil {
set, args = append(set, "open_id = ?"), append(args, v)
}
func CheckUsernameUsable(username string) (bool, error) {
query := `SELECT * FROM users WHERE username=?`
query = fmt.Sprintf("SELECT COUNT(*) FROM (%s)", query)
args = append(args, patch.Id)
var count uint
err := DB.QueryRow(query, username).Scan(&count)
if err != nil && err != sql.ErrNoRows {
return false, FormatDBError(err)
row, err := db.Db.Query(`
UPDATE user
SET `+strings.Join(set, ", ")+`
WHERE id = ?
RETURNING id, name, password, open_id, created_ts, updated_ts
`, args...)
if err != nil {
return nil, FormatError(err)
}
usable := true
if count > 0 {
usable = false
defer row.Close()
if row.Next() {
var user api.User
if err := row.Scan(
&user.Id,
&user.Name,
&user.Password,
&user.OpenId,
&user.CreatedTs,
&user.UpdatedTs,
); err != nil {
return nil, FormatError(err)
}
return &user, nil
}
return usable, nil
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("user ID not found: %d", patch.Id)}
}
func CheckPasswordValid(id string, password string) (bool, error) {
query := `SELECT * FROM users WHERE id=? AND password=?`
query = fmt.Sprintf("SELECT COUNT(*) FROM (%s)", query)
func findUserList(db *DB, find *api.UserFind) ([]*api.User, error) {
where, args := []string{"1 = 1"}, []interface{}{}
var count uint
err := DB.QueryRow(query, id, password).Scan(&count)
if err != nil && err != sql.ErrNoRows {
return false, FormatDBError(err)
if v := find.Id; v != nil {
where, args = append(where, "id = ?"), append(args, *v)
}
if v := find.Name; v != nil {
where, args = append(where, "name = ?"), append(args, *v)
}
if v := find.OpenId; v != nil {
where, args = append(where, "open_id = ?"), append(args, *v)
}
if count > 0 {
return true, nil
} else {
return false, nil
rows, err := db.Db.Query(`
SELECT
id,
name,
password,
open_id,
created_ts,
updated_ts
FROM user
WHERE `+strings.Join(where, " AND "),
args...,
)
if err != nil {
return nil, FormatError(err)
}
defer rows.Close()
list := make([]*api.User, 0)
for rows.Next() {
var user api.User
if err := rows.Scan(
&user.Id,
&user.Name,
&user.Password,
&user.OpenId,
&user.CreatedTs,
&user.UpdatedTs,
); err != nil {
fmt.Println(err)
return nil, FormatError(err)
}
list = append(list, &user)
}
if err := rows.Err(); err != nil {
return nil, FormatError(err)
}
return list, nil
}
package utils
import (
"time"
"github.com/google/uuid"
)
func GenUUID() string {
return uuid.New().String()
}
func GetNowDateTimeStr() string {
return time.Now().Local().Format("2006/01/02 15:04:05")
}
......@@ -13,7 +13,7 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
<>
<div className="dialog-header-container">
<p className="title-text">
<span className="icon-text">🤠</span>关于 <b>Memos</b>
<span className="icon-text">🤠</span>About <b>Memos</b>
</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<img className="icon-img" src="/icons/close.svg" />
......@@ -21,9 +21,9 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
</div>
<div className="dialog-content-container">
<p>
把玩 <a href="https://flomoapp.com">flomo</a> 后有感而作的开源项目
Memos is an open source, self-hosted alternative to <a href="https://flomoapp.com">flomo</a>.
</p>
<p>特点:精美且细节的视觉样式、体验优良的交互逻辑</p>
<p>Built with `Golang` and `React`.</p>
<br />
<p>
🏗 This project is working in progress, <br /> and very pleasure to welcome your{" "}
......
......@@ -44,19 +44,19 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
const handleSaveBtnClick = async () => {
if (oldPassword === "" || newPassword === "" || newPasswordAgain === "") {
toastHelper.error("密码不能为空");
toastHelper.error("Please fill in all fields.");
return;
}
if (newPassword !== newPasswordAgain) {
toastHelper.error("新密码两次输入不一致");
toastHelper.error("New passwords do not match.");
setNewPasswordAgain("");
return;
}
const passwordValidResult = validate(newPassword, validateConfig);
if (!passwordValidResult.result) {
toastHelper.error("密码 " + passwordValidResult.reason);
toastHelper.error("Password " + passwordValidResult.reason);
return;
}
......@@ -64,13 +64,13 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
const isValid = await userService.checkPasswordValid(oldPassword);
if (!isValid) {
toastHelper.error("旧密码不匹配");
toastHelper.error("Old password is invalid.");
setOldPassword("");
return;
}
await userService.updatePassword(newPassword);
toastHelper.info("密码修改成功!");
toastHelper.info("Password changed.");
handleCloseBtnClick();
} catch (error: any) {
toastHelper.error(error);
......@@ -80,30 +80,30 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
return (
<>
<div className="dialog-header-container">
<p className="title-text">修改密码</p>
<p className="title-text">Change Password</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<img className="icon-img" src="/icons/close.svg" />
</button>
</div>
<div className="dialog-content-container">
<label className="form-label input-form-label">
<span className={"normal-text " + (oldPassword === "" ? "" : "not-null")}>旧密码</span>
<span className={"normal-text " + (oldPassword === "" ? "" : "not-null")}>Old password</span>
<input type="password" value={oldPassword} onChange={handleOldPasswordChanged} />
</label>
<label className="form-label input-form-label">
<span className={"normal-text " + (newPassword === "" ? "" : "not-null")}>新密码</span>
<span className={"normal-text " + (newPassword === "" ? "" : "not-null")}>New passworld</span>
<input type="password" value={newPassword} onChange={handleNewPasswordChanged} />
</label>
<label className="form-label input-form-label">
<span className={"normal-text " + (newPasswordAgain === "" ? "" : "not-null")}>再次输入新密码</span>
<span className={"normal-text " + (newPasswordAgain === "" ? "" : "not-null")}>New password again</span>
<input type="password" value={newPasswordAgain} onChange={handleNewPasswordAgainChanged} />
</label>
<div className="btns-container">
<span className="btn cancel-btn" onClick={handleCloseBtnClick}>
取消
Cancel
</span>
<span className="btn confirm-btn" onClick={handleSaveBtnClick}>
保存
Save
</span>
</div>
</div>
......
......@@ -27,29 +27,31 @@ const ConfirmResetOpenIdDialog: React.FC<Props> = ({ destroy }: Props) => {
try {
await userService.resetOpenId();
} catch (error) {
toastHelper.error("请求重置 Open API 失败");
toastHelper.error("Request reset open API failed.");
return;
}
toastHelper.success("重置成功!");
toastHelper.success("Reset open API succeeded.");
handleCloseBtnClick();
};
return (
<>
<div className="dialog-header-container">
<p className="title-text">重置 Open API</p>
<p className="title-text">Reset Open API</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<img className="icon-img" src="/icons/close.svg" />
</button>
</div>
<div className="dialog-content-container">
<p className="warn-text">⚠️ 现有 API 将失效,并生成新的 API,确定要重置吗?</p>
<p className="warn-text">
⚠️ The existing API will be invalidated and a new one will be generated, are you sure you want to reset?
</p>
<div className="btns-container">
<span className="btn cancel-btn" onClick={handleCloseBtnClick}>
取消
Cancel
</span>
<span className={`btn confirm-btn ${resetBtnClickLoadingState.isLoading ? "loading" : ""}`} onClick={handleConfirmBtnClick}>
确定重置!
Reset!
</span>
</div>
</div>
......
import { memo, useCallback, useEffect, useState } from "react";
import { memoService, queryService } from "../services";
import { memoService, shortcutService } from "../services";
import { checkShouldShowMemoWithFilters, filterConsts, getDefaultFilter, relationConsts } from "../helpers/filter";
import useLoading from "../hooks/useLoading";
import { showDialog } from "./Dialog";
import toastHelper from "./Toast";
import Selector from "./common/Selector";
import "../less/create-query-dialog.less";
import "../less/create-shortcut-dialog.less";
interface Props extends DialogProps {
queryId?: string;
shortcutId?: string;
}
const CreateQueryDialog: React.FC<Props> = (props: Props) => {
const { destroy, queryId } = props;
const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
const { destroy, shortcutId } = props;
const [title, setTitle] = useState<string>("");
const [filters, setFilters] = useState<Filter[]>([]);
......@@ -23,15 +23,15 @@ const CreateQueryDialog: React.FC<Props> = (props: Props) => {
}).length;
useEffect(() => {
const queryTemp = queryService.getQueryById(queryId ?? "");
if (queryTemp) {
setTitle(queryTemp.title);
const temp = JSON.parse(queryTemp.querystring);
const shortcutTemp = shortcutService.getShortcutById(shortcutId ?? "");
if (shortcutTemp) {
setTitle(shortcutTemp.title);
const temp = JSON.parse(shortcutTemp.payload);
if (Array.isArray(temp)) {
setFilters(temp);
}
}
}, [queryId]);
}, [shortcutId]);
const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
......@@ -40,17 +40,17 @@ const CreateQueryDialog: React.FC<Props> = (props: Props) => {
const handleSaveBtnClick = async () => {
if (!title) {
toastHelper.error("标题不能为空!");
toastHelper.error("Title is required");
return;
}
try {
if (queryId) {
const editedQuery = await queryService.updateQuery(queryId, title, JSON.stringify(filters));
queryService.editQuery(editedQuery);
if (shortcutId) {
const editedShortcut = await shortcutService.updateShortcut(shortcutId, title, JSON.stringify(filters));
shortcutService.editShortcut(shortcutService.convertResponseModelShortcut(editedShortcut));
} else {
const query = await queryService.createQuery(title, JSON.stringify(filters));
queryService.pushQuery(query);
const shortcut = await shortcutService.createShortcut(title, JSON.stringify(filters));
shortcutService.pushShortcut(shortcutService.convertResponseModelShortcut(shortcut));
}
} catch (error: any) {
toastHelper.error(error.message);
......@@ -62,7 +62,7 @@ const CreateQueryDialog: React.FC<Props> = (props: Props) => {
if (filters.length > 0) {
const lastFilter = filters[filters.length - 1];
if (lastFilter.value.value === "") {
toastHelper.info("先完善上一个过滤器吧");
toastHelper.info("Please fill in previous filter value");
return;
}
}
......@@ -90,7 +90,7 @@ const CreateQueryDialog: React.FC<Props> = (props: Props) => {
<div className="dialog-header-container">
<p className="title-text">
<span className="icon-text">🔖</span>
{queryId ? "编辑检索" : "创建检索"}
{shortcutId ? "Edit Shortcut" : "Create Shortcut"}
</p>
<button className="btn close-btn" onClick={destroy}>
<img className="icon-img" src="/icons/close.svg" />
......@@ -98,11 +98,11 @@ const CreateQueryDialog: React.FC<Props> = (props: Props) => {
</div>
<div className="dialog-content-container">
<div className="form-item-container input-form-container">
<span className="normal-text">标题</span>
<span className="normal-text">Title</span>
<input className="title-input" type="text" value={title} onChange={handleTitleInputChange} />
</div>
<div className="form-item-container filter-form-container">
<span className="normal-text">过滤器</span>
<span className="normal-text">Filter</span>
<div className="filters-wrapper">
{filters.map((f, index) => {
return (
......@@ -116,7 +116,7 @@ const CreateQueryDialog: React.FC<Props> = (props: Props) => {
);
})}
<div className="create-filter-btn" onClick={handleAddFilterBenClick}>
添加筛选条件
New Filter
</div>
</div>
</div>
......@@ -125,10 +125,10 @@ const CreateQueryDialog: React.FC<Props> = (props: Props) => {
<div></div>
<div className="btns-container">
<span className={`tip-text ${filters.length === 0 && "hidden"}`}>
符合条件的 Memo 有 <strong>{shownMemoLength}</strong>
<strong>{shownMemoLength}</strong> eligible memo
</span>
<button className={`btn save-btn ${requestState.isLoading ? "requesting" : ""}`} onClick={handleSaveBtnClick}>
保存
Save
</button>
</div>
</div>
......@@ -298,12 +298,12 @@ const FilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterInpute
const MemoFilterInputer: React.FC<MemoFilterInputerProps> = memo(FilterInputer);
export default function showCreateQueryDialog(queryId?: string): void {
export default function showCreateShortcutDialog(shortcutId?: string): void {
showDialog(
{
className: "create-query-dialog",
className: "create-shortcut-dialog",
},
CreateQueryDialog,
{ queryId }
CreateShortcutDialog,
{ shortcutId }
);
}
......@@ -106,11 +106,11 @@ const DailyMemoDiaryDialog: React.FC<Props> = (props: Props) => {
/>
{loadingState.isLoading ? (
<div className="tip-container">
<p className="tip-text">努力加载中...</p>
<p className="tip-text">Loading...</p>
</div>
) : memos.length === 0 ? (
<div className="tip-container">
<p className="tip-text">空空如也</p>
<p className="tip-text">Oops, there is nothing.</p>
</div>
) : (
<div className="dailymemos-wrapper">
......
......@@ -18,7 +18,7 @@ const DeletedMemo: React.FC<Props> = (props: Props) => {
const memo: FormattedMemo = {
...propsMemo,
createdAtStr: utils.getDateTimeString(propsMemo.createdAt),
deletedAtStr: utils.getDateTimeString(propsMemo.deletedAt ?? Date.now()),
deletedAtStr: utils.getDateTimeString(propsMemo.updatedAt ?? Date.now()),
};
const [showConfirmDeleteBtn, toggleConfirmDeleteBtn] = useToggle(false);
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []);
......@@ -40,7 +40,7 @@ const DeletedMemo: React.FC<Props> = (props: Props) => {
try {
await memoService.restoreMemoById(memo.id);
handleDeletedMemoAction(memo.id);
toastHelper.info("恢复成功");
toastHelper.info("Restored successfully");
} catch (error: any) {
toastHelper.error(error.message);
}
......@@ -55,7 +55,7 @@ const DeletedMemo: React.FC<Props> = (props: Props) => {
return (
<div className={`memo-wrapper ${"memos-" + memo.id}`} onMouseLeave={handleMouseLeaveMemoWrapper}>
<div className="memo-top-wrapper">
<span className="time-text">删除于 {memo.deletedAtStr}</span>
<span className="time-text">Deleted at {memo.deletedAtStr}</span>
<div className="btns-container">
<span className="btn more-action-btn">
<img className="icon-img" src="/icons/more.svg" />
......@@ -63,10 +63,10 @@ const DeletedMemo: React.FC<Props> = (props: Props) => {
<div className="more-action-btns-wrapper">
<div className="more-action-btns-container">
<span className="btn restore-btn" onClick={handleRestoreMemoClick}>
恢复
Restore
</span>
<span className={`btn delete-btn ${showConfirmDeleteBtn ? "final-confirm" : ""}`} onClick={handleDeleteMemoClick}>
{showConfirmDeleteBtn ? "确定删除!" : "完全删除"}
{showConfirmDeleteBtn ? "Delete!" : "Delete"}
</span>
</div>
</div>
......
......@@ -176,12 +176,12 @@ const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef<EditorRef
<div className="btns-container">
<Only when={showCancelBtn}>
<button className="action-btn cancel-btn" onClick={handleCommonCancelBtnClick}>
撤销修改
Cancel editting
</button>
</Only>
<Only when={showConfirmBtn}>
<button className="action-btn confirm-btn" disabled={!editorRef.current?.value} onClick={handleCommonConfirmBtnClick}>
记下<span className="icon-text">✍️</span>
Save <span className="icon-text">✍️</span>
</button>
</Only>
</div>
......
......@@ -93,19 +93,19 @@ const Memo: React.FC<Props> = (props: Props) => {
<div className="more-action-btns-wrapper">
<div className="more-action-btns-container">
<span className="btn" onClick={handleShowMemoStoryDialog}>
查看详情
View Story
</span>
<span className="btn" onClick={handleMarkMemoClick}>
Mark
</span>
<span className="btn" onClick={handleGenMemoImageBtnClick}>
分享
Share
</span>
<span className="btn" onClick={handleEditMemoClick}>
编辑
Edit
</span>
<span className={`btn delete-btn ${showConfirmDeleteBtn ? "final-confirm" : ""}`} onClick={handleDeleteMemoClick}>
{showConfirmDeleteBtn ? "确定删除!" : "删除"}
{showConfirmDeleteBtn ? "Delete!" : "Delete"}
</span>
</div>
</div>
......@@ -151,7 +151,7 @@ export function formatMemoContent(content: string) {
.replace(LINK_REG, "<a class='link' target='_blank' rel='noreferrer' href='$1'>$1</a>")
.replace(MEMO_LINK_REG, "<span class='memo-link-text' data-value='$2'>$1</span>");
// 中英文之间加空格
// Add space in english and chinese
if (shouldSplitMemoWord) {
content = content
.replace(/([\u4e00-\u9fa5])([A-Za-z0-9?.,;[\]]+)/g, "$1 $2")
......
......@@ -148,7 +148,7 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
</div>
{linkMemos.length > 0 ? (
<div className="linked-memos-wrapper">
<p className="normal-text">关联了 {linkMemos.length} MEMO</p>
<p className="normal-text">{linkMemos.length} related MEMO</p>
{linkMemos.map((m) => {
const rawtext = parseHtmlToRawText(formatMemoContent(m.content)).replaceAll("\n", " ");
return (
......@@ -162,7 +162,7 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
) : null}
{linkedMemos.length > 0 ? (
<div className="linked-memos-wrapper">
<p className="normal-text">{linkedMemos.length} 个链接至此的 MEMO</p>
<p className="normal-text">{linkedMemos.length} linked MEMO</p>
{linkedMemos.map((m) => {
const rawtext = parseHtmlToRawText(formatMemoContent(m.content)).replaceAll("\n", " ");
return (
......
......@@ -130,7 +130,7 @@ const MemoEditor: React.FC<Props> = () => {
try {
const image = await resourceService.upload(file);
const url = `/r/${image.id}/${image.filename}`;
const url = `/h/r/${image.id}/${image.filename}`;
return url;
} catch (error: any) {
......@@ -140,7 +140,7 @@ const MemoEditor: React.FC<Props> = () => {
const handleSaveBtnClick = useCallback(async (content: string) => {
if (content === "") {
toastHelper.error("内容不能为空呀");
toastHelper.error("Content can't be empty");
return;
}
......@@ -270,7 +270,7 @@ const MemoEditor: React.FC<Props> = () => {
() => ({
className: "memo-editor",
initialContent: getEditorContentCache(),
placeholder: "现在的想法是...",
placeholder: "Any thoughts...",
showConfirmBtn: true,
showCancelBtn: showEditStatus,
onConfirmBtnClick: handleSaveBtnClick,
......@@ -282,7 +282,7 @@ const MemoEditor: React.FC<Props> = () => {
return (
<div className={"memo-editor-wrapper " + (showEditStatus ? "edit-ing" : "")}>
<p className={"tip-text " + (showEditStatus ? "" : "hidden")}>正在修改中...</p>
<p className={"tip-text " + (showEditStatus ? "" : "hidden")}>Editting...</p>
<Editor
ref={editorRef}
{...editorConfig}
......
import { useContext } from "react";
import appContext from "../stores/appContext";
import { locationService, queryService } from "../services";
import { locationService, shortcutService } from "../services";
import utils from "../helpers/utils";
import { getTextWithMemoType } from "../helpers/filter";
import "../less/memo-filter.less";
......@@ -12,17 +12,17 @@ const MemoFilter: React.FC<FilterProps> = () => {
locationState: { query },
} = useContext(appContext);
const { tag: tagQuery, duration, type: memoType, text: textQuery, filter } = query;
const queryFilter = queryService.getQueryById(filter);
const { tag: tagQuery, duration, type: memoType, text: textQuery, shortcutId } = query;
const queryFilter = shortcutService.getShortcutById(shortcutId);
const showFilter = Boolean(tagQuery || (duration && duration.from < duration.to) || memoType || textQuery || queryFilter);
return (
<div className={`filter-query-container ${showFilter ? "" : "hidden"}`}>
<span className="tip-text">筛选:</span>
<span className="tip-text">Filter:</span>
<div
className={"filter-item-container " + (queryFilter ? "" : "hidden")}
onClick={() => {
locationService.setMemoFilter("");
locationService.setMemoShortcut("");
}}
>
<span className="icon-text">🔖</span> {queryFilter?.title}
......
import { useCallback, useContext, useEffect, useRef, useState } from "react";
import appContext from "../stores/appContext";
import { locationService, memoService, queryService } from "../services";
import { locationService, memoService, shortcutService } from "../services";
import { IMAGE_URL_REG, LINK_REG, MEMO_LINK_REG, TAG_REG } from "../helpers/consts";
import utils from "../helpers/utils";
import { checkShouldShowMemoWithFilters } from "../helpers/filter";
......@@ -18,8 +18,8 @@ const MemoList: React.FC<Props> = () => {
const [isFetching, setFetchStatus] = useState(true);
const wrapperElement = useRef<HTMLDivElement>(null);
const { tag: tagQuery, duration, type: memoType, text: textQuery, filter: queryId } = query;
const queryFilter = queryService.getQueryById(queryId);
const { tag: tagQuery, duration, type: memoType, text: textQuery, shortcutId } = query;
const queryFilter = shortcutService.getShortcutById(shortcutId);
const showMemoFilter = Boolean(tagQuery || (duration && duration.from < duration.to) || memoType || textQuery || queryFilter);
const shownMemos =
......@@ -28,7 +28,7 @@ const MemoList: React.FC<Props> = () => {
let shouldShow = true;
if (queryFilter) {
const filters = JSON.parse(queryFilter.querystring) as Filter[];
const filters = JSON.parse(queryFilter.payload) as Filter[];
if (Array.isArray(filters)) {
shouldShow = checkShouldShowMemoWithFilters(memo, filters);
}
......@@ -83,7 +83,7 @@ const MemoList: React.FC<Props> = () => {
setFetchStatus(false);
})
.catch(() => {
toastHelper.error("😭 请求数据失败了");
toastHelper.error("😭 Refresh failed, please try again later.");
});
}, []);
......@@ -111,7 +111,13 @@ const MemoList: React.FC<Props> = () => {
))}
<div className="status-text-container">
<p className="status-text">
{isFetching ? "努力请求数据中..." : shownMemos.length === 0 ? "空空如也" : showMemoFilter ? "" : "所有数据加载完啦 🎉"}
{isFetching
? "Fetching data..."
: shownMemos.length === 0
? "Oops, there is nothing"
: showMemoFilter
? ""
: "Fetching completed 🎉"}
</p>
</div>
</div>
......
import { useCallback, useContext, useEffect, useState } from "react";
import appContext from "../stores/appContext";
import SearchBar from "./SearchBar";
import { globalStateService, memoService, queryService } from "../services";
import { globalStateService, memoService, shortcutService } from "../services";
import Only from "./common/OnlyWhen";
import "../less/memos-header.less";
......@@ -12,22 +12,22 @@ interface Props {}
const MemosHeader: React.FC<Props> = () => {
const {
locationState: {
query: { filter },
query: { shortcutId },
},
globalState: { isMobileView },
queryState: { queries },
shortcutState: { shortcuts },
} = useContext(appContext);
const [titleText, setTitleText] = useState("MEMOS");
useEffect(() => {
const query = queryService.getQueryById(filter);
const query = shortcutService.getShortcutById(shortcutId);
if (query) {
setTitleText(query.title);
} else {
setTitleText("MEMOS");
}
}, [filter, queries]);
}, [shortcutId, shortcuts]);
const handleMemoTextClick = useCallback(() => {
const now = Date.now();
......
......@@ -49,16 +49,16 @@ const MenuBtnsPopup: React.FC<Props> = (props: Props) => {
return (
<div className={`menu-btns-popup ${shownStatus ? "" : "hidden"}`} ref={popupElRef}>
<button className="btn action-btn" onClick={handleMyAccountBtnClick}>
<span className="icon">👤</span> 账号与设置
<span className="icon">👤</span> Settings
</button>
<button className="btn action-btn" onClick={handleMemosTrashBtnClick}>
<span className="icon">🗑️</span> 回收站
<span className="icon">🗑️</span> Recycle Bin
</button>
<button className="btn action-btn" onClick={handleAboutBtnClick}>
<span className="icon">🤠</span> 关于
<span className="icon">🤠</span> About
</button>
<button className="btn action-btn" onClick={handleSignOutBtnClick}>
<span className="icon">👋</span> 退出
<span className="icon">👋</span> Sign out
</button>
</div>
);
......
......@@ -20,8 +20,8 @@ interface Props {}
const MyAccountSection: React.FC<Props> = () => {
const { userState } = useContext(appContext);
const user = userState.user as Model.User;
const [username, setUsername] = useState<string>(user.username);
const openAPIRoute = `${window.location.origin}/api/whs/memo/${user.openId}`;
const [username, setUsername] = useState<string>(user.name);
const openAPIRoute = `${window.location.origin}/h/${user.openId}/memo`;
const handleUsernameChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const nextUsername = e.target.value as string;
......@@ -29,18 +29,18 @@ const MyAccountSection: React.FC<Props> = () => {
};
const handleConfirmEditUsernameBtnClick = async () => {
if (user.username === "guest") {
toastHelper.info("🈲 不要修改我的用户名");
if (user.name === "guest") {
toastHelper.info("Do not change my username");
return;
}
if (username === user.username) {
if (username === user.name) {
return;
}
const usernameValidResult = validate(username, validateConfig);
if (!usernameValidResult.result) {
toastHelper.error("用户名 " + usernameValidResult.reason);
toastHelper.error("Username " + usernameValidResult.reason);
return;
}
......@@ -48,21 +48,21 @@ const MyAccountSection: React.FC<Props> = () => {
const isUsable = await userService.checkUsernameUsable(username);
if (!isUsable) {
toastHelper.error("用户名无法使用");
toastHelper.error("Username is not available");
return;
}
await userService.updateUsername(username);
await userService.doSignIn();
toastHelper.info("修改成功~");
toastHelper.info("Username changed");
} catch (error: any) {
toastHelper.error(error.message);
}
};
const handleChangePasswordBtnClick = () => {
if (user.username === "guest") {
toastHelper.info("🈲 不要修改我的密码");
if (user.name === "guest") {
toastHelper.info("Do not change my password");
return;
}
......@@ -81,47 +81,47 @@ const MyAccountSection: React.FC<Props> = () => {
return (
<>
<div className="section-container account-section-container">
<p className="title-text">基本信息</p>
<p className="title-text">Account Information</p>
<label className="form-label input-form-label">
<span className="normal-text">ID</span>
<span className="normal-text">ID:</span>
<span className="normal-text">{user.id}</span>
</label>
<label className="form-label input-form-label">
<span className="normal-text">创建时间:</span>
<span className="normal-text">Created at:</span>
<span className="normal-text">{utils.getDateString(user.createdAt)}</span>
</label>
<label className="form-label input-form-label username-label">
<span className="normal-text">账号:</span>
<span className="normal-text">Username:</span>
<input type="text" value={username} onChange={handleUsernameChanged} />
<div className={`btns-container ${username === user.username ? "hidden" : ""}`} onClick={handlePreventDefault}>
<div className={`btns-container ${username === user.name ? "hidden" : ""}`} onClick={handlePreventDefault}>
<span className="btn confirm-btn" onClick={handleConfirmEditUsernameBtnClick}>
保存
Save
</span>
<span
className="btn cancel-btn"
onClick={() => {
setUsername(user.username);
setUsername(user.name);
}}
>
撤销
Cancel
</span>
</div>
</label>
<label className="form-label password-label">
<span className="normal-text">密码:</span>
<span className="normal-text">Password:</span>
<span className="btn" onClick={handleChangePasswordBtnClick}>
修改密码
Change It
</span>
</label>
</div>
<div className="section-container openapi-section-container">
<p className="title-text">Open API(实验性功能)</p>
<p className="title-text">Open API (Experimental feature)</p>
<p className="value-text">{openAPIRoute}</p>
<span className="reset-btn" onClick={handleResetOpenIdBtnClick}>
重置 API
Reset API
</span>
<div className="usage-guide-container">
<p className="title-text">使用方法:</p>
<p className="title-text">Usage guide:</p>
<pre>{`POST ${openAPIRoute}\nContent-type: application/json\n{\n "content": "Hello, #memos ${window.location.origin}"\n}`}</pre>
</div>
</div>
......
......@@ -11,7 +11,7 @@ const PreferencesSection: React.FC<Props> = () => {
const { globalState } = useContext(appContext);
const { useTinyUndoHistoryCache, shouldHideImageUrl, shouldSplitMemoWord, shouldUseMarkdownParser } = globalState;
const demoMemoContent = "👋 你好呀~欢迎使用memos!\n* ✨ **开源项目**;\n* 😋 精美且细节的视觉样式;\n* 📑 体验优良的交互逻辑;";
const demoMemoContent = "👋 Hiya, welcome to memos!\n* ✨ **Open source project**;\n* 😋 What do you think;\n* 📑 Tell me something plz;";
const handleOpenTinyUndoChanged = () => {
globalStateService.setAppSetting({
......@@ -65,29 +65,29 @@ const PreferencesSection: React.FC<Props> = () => {
return (
<>
<div className="section-container preferences-section-container">
<p className="title-text">Memo 显示相关</p>
<p className="title-text">Memo Display</p>
<div
className="demo-content-container memo-content-text"
dangerouslySetInnerHTML={{ __html: formatMemoContent(demoMemoContent) }}
></div>
<label className="form-label checkbox-form-label" onClick={handleSplitWordsValueChanged}>
<span className="normal-text">中英文内容自动间隔</span>
<label className="form-label checkbox-form-label hidden" onClick={handleSplitWordsValueChanged}>
<span className="normal-text">Auto-space in English and Chinese</span>
<img className="icon-img" src={shouldSplitMemoWord ? "/icons/checkbox-active.svg" : "/icons/checkbox.svg"} />
</label>
<label className="form-label checkbox-form-label" onClick={handleUseMarkdownParserChanged}>
<span className="normal-text">部分 markdown 格式解析</span>
<span className="normal-text">Partial markdown format parsing</span>
<img className="icon-img" src={shouldUseMarkdownParser ? "/icons/checkbox-active.svg" : "/icons/checkbox.svg"} />
</label>
<label className="form-label checkbox-form-label" onClick={handleHideImageUrlValueChanged}>
<span className="normal-text">隐藏图片链接地址</span>
<span className="normal-text">Hide image url</span>
<img className="icon-img" src={shouldHideImageUrl ? "/icons/checkbox-active.svg" : "/icons/checkbox.svg"} />
</label>
</div>
<div className="section-container preferences-section-container">
<p className="title-text">编辑器</p>
<p className="title-text">Editor Extensions</p>
<label className="form-label checkbox-form-label" onClick={handleOpenTinyUndoChanged}>
<span className="normal-text">
启用{" "}
Use{" "}
<a target="_blank" href="https://github.com/boojack/tiny-undo" onClick={(e) => e.stopPropagation()} rel="noreferrer">
tiny-undo
</a>
......@@ -96,13 +96,13 @@ const PreferencesSection: React.FC<Props> = () => {
</label>
</div>
<div className="section-container">
<p className="title-text">其他</p>
<p className="title-text">Others</p>
<div className="w-full flex flex-row justify-start items-center">
<button className="px-2 py-1 border rounded text-base hover:opacity-80" onClick={handleExportBtnClick}>
导出数据(JSON)
Export data as JSON
</button>
<button className="btn format-btn hidden" onClick={handleFormatMemosBtnClick}>
格式化数据
Format Data
</button>
</div>
</div>
......
......@@ -43,8 +43,8 @@ const PreviewImageDialog: React.FC<Props> = ({ destroy, imgUrl }: Props) => {
<div className="img-container">
<img className={imgWidth <= 0 ? "hidden" : ""} ref={imgRef} width={imgWidth + "%"} src={imgUrl} />
<span className={"loading-text " + (imgWidth === -1 ? "" : "hidden")}>图片加载中...</span>
<span className={"loading-text " + (imgWidth === 0 ? "" : "hidden")}>😟 图片加载失败,可能是无效的链接</span>
<span className={"loading-text " + (imgWidth === -1 ? "" : "hidden")}>Loading image...</span>
<span className={"loading-text " + (imgWidth === 0 ? "" : "hidden")}>😟 Failed to load image</span>
</div>
<div className="action-btns-container">
......
......@@ -36,7 +36,7 @@ const SearchBar: React.FC<Props> = () => {
<div className="quickly-action-container">
<p className="title-text">QUICKLY FILTER</p>
<div className="section-container types-container">
<span className="section-text">类型:</span>
<span className="section-text">Type:</span>
<div className="values-container">
{memoSpecialTypes.map((t, idx) => {
return (
......
This diff is collapsed.
......@@ -3,7 +3,7 @@ import appContext from "../stores/appContext";
import { SHOW_SIDERBAR_MOBILE_CLASSNAME } from "../helpers/consts";
import { globalStateService } from "../services";
import UserBanner from "./UserBanner";
import QueryList from "./QueryList";
import ShortcutList from "./ShortcutList";
import TagList from "./TagList";
import UsageHeatMap from "./UsageHeatMap";
import "../less/siderbar.less";
......@@ -66,7 +66,7 @@ const Sidebar: React.FC<Props> = () => {
<aside className="sidebar-wrapper" ref={wrapperElRef}>
<UserBanner />
<UsageHeatMap />
<QueryList />
<ShortcutList />
<TagList />
</aside>
);
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment