Commit bbd206e8 authored by Steven's avatar Steven

chore: retire legacy api

parent cf4db170
......@@ -2,7 +2,6 @@ package jobs
import (
"context"
"encoding/json"
"log/slog"
"strings"
"time"
......@@ -10,7 +9,9 @@ import (
"github.com/pkg/errors"
"github.com/usememos/memos/plugin/storage/s3"
apiv1 "github.com/usememos/memos/server/route/api/v1"
apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
storepb "github.com/usememos/memos/proto/gen/store"
apiv2 "github.com/usememos/memos/server/route/api/v2"
"github.com/usememos/memos/store"
)
......@@ -95,44 +96,35 @@ func signExternalLinks(ctx context.Context, dataStore *store.Store) error {
// Returns error only in case of internal problems (ie: database or configuration issues).
// May return nil client and nil error.
func findObjectStorage(ctx context.Context, dataStore *store.Store) (*s3.Client, error) {
systemSettingStorageServiceID, err := dataStore.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{Name: apiv1.SystemSettingStorageServiceIDName.String()})
workspaceStorageSetting, err := dataStore.GetWorkspaceStorageSetting(ctx)
if err != nil {
return nil, errors.Wrap(err, "Failed to find SystemSettingStorageServiceIDName")
return nil, errors.Wrap(err, "Failed to find workspaceStorageSetting")
}
storageServiceID := apiv1.DefaultStorage
if systemSettingStorageServiceID != nil {
err = json.Unmarshal([]byte(systemSettingStorageServiceID.Value), &storageServiceID)
if err != nil {
return nil, errors.Wrap(err, "Failed to unmarshal storage service id")
}
if workspaceStorageSetting.StorageType != storepb.WorkspaceStorageSetting_STORAGE_TYPE_EXTERNAL || workspaceStorageSetting.ActivedExternalStorageId == nil {
return nil, nil
}
storage, err := dataStore.GetStorage(ctx, &store.FindStorage{ID: &storageServiceID})
storage, err := dataStore.GetStorageV1(ctx, &store.FindStorage{ID: workspaceStorageSetting.ActivedExternalStorageId})
if err != nil {
return nil, errors.Wrap(err, "Failed to find StorageServiceID")
return nil, errors.Wrap(err, "Failed to find storage")
}
if storage == nil {
return nil, nil // storage not configured - not an error, just return empty ref
return nil, nil
}
storageMessage, err := apiv1.ConvertStorageFromStore(storage)
if err != nil {
return nil, errors.Wrap(err, "Failed to ConvertStorageFromStore")
}
if storageMessage.Type != apiv1.StorageS3 {
storageMessage := apiv2.ConvertStorageFromStore(storage)
if storageMessage.Type != apiv2pb.Storage_S3 {
return nil, nil
}
s3Config := storageMessage.Config.S3Config
s3Config := storageMessage.Config.GetS3Config()
return s3.NewClient(ctx, &s3.Config{
AccessKey: s3Config.AccessKey,
SecretKey: s3Config.SecretKey,
EndPoint: s3Config.EndPoint,
Region: s3Config.Region,
Bucket: s3Config.Bucket,
URLPrefix: s3Config.URLPrefix,
URLSuffix: s3Config.URLSuffix,
URLPrefix: s3Config.UrlPrefix,
URLSuffix: s3Config.UrlSuffix,
PreSign: s3Config.PreSign,
})
}
package integration
import (
"bytes"
"context"
"fmt"
"path/filepath"
......@@ -19,7 +18,6 @@ import (
"github.com/usememos/memos/plugin/telegram"
"github.com/usememos/memos/plugin/webhook"
storepb "github.com/usememos/memos/proto/gen/store"
apiv1 "github.com/usememos/memos/server/route/api/v1"
apiv2 "github.com/usememos/memos/server/route/api/v2"
"github.com/usememos/memos/store"
)
......@@ -126,9 +124,10 @@ func (t *TelegramHandler) MessageHandle(ctx context.Context, bot *telegram.Bot,
Type: attachment.GetMimeType(),
Size: attachment.FileSize,
MemoID: &memoMessage.ID,
Blob: attachment.Data,
}
err := apiv1.SaveResourceBlob(ctx, t.store, &create, bytes.NewReader(attachment.Data))
err := apiv2.SaveResourceBlob(ctx, t.store, &create)
if err != nil {
_, err := bot.EditMessage(ctx, message.Chat.ID, reply.MessageID, fmt.Sprintf("Failed to SaveResourceBlob: %s", err), nil)
return err
......
package v1
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
"github.com/usememos/memos/internal/util"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/server/route/api/auth"
"github.com/usememos/memos/store"
)
type SignIn struct {
Username string `json:"username"`
Password string `json:"password"`
Remember bool `json:"remember"`
}
type SSOSignIn struct {
IdentityProviderID int32 `json:"identityProviderId"`
Code string `json:"code"`
RedirectURI string `json:"redirectUri"`
}
type SignUp struct {
Username string `json:"username"`
Password string `json:"password"`
}
func (s *APIV1Service) registerAuthRoutes(g *echo.Group) {
g.POST("/auth/signin", s.SignIn)
g.POST("/auth/signout", s.SignOut)
g.POST("/auth/signup", s.SignUp)
}
// SignIn godoc
//
// @Summary Sign-in to memos.
// @Tags auth
// @Accept json
// @Produce json
// @Param body body SignIn true "Sign-in object"
// @Success 200 {object} store.User "User information"
// @Failure 400 {object} nil "Malformatted signin request"
// @Failure 401 {object} nil "Password login is deactivated | Incorrect login credentials, please try again"
// @Failure 403 {object} nil "User has been archived with username %s"
// @Failure 500 {object} nil "Failed to find system setting | Failed to unmarshal system setting | Incorrect login credentials, please try again | Failed to generate tokens | Failed to create activity"
// @Router /api/v1/auth/signin [POST]
func (s *APIV1Service) SignIn(c echo.Context) error {
ctx := c.Request().Context()
workspaceGeneralSetting, err := s.Store.GetWorkspaceGeneralSetting(ctx)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting").SetInternal(err)
}
if workspaceGeneralSetting.DisallowPasswordLogin {
return echo.NewHTTPError(http.StatusUnauthorized, "password login is deactivated").SetInternal(err)
}
signin := &SignIn{}
if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signin request").SetInternal(err)
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
Username: &signin.Username,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Incorrect login credentials, please try again")
}
if user == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Incorrect login credentials, please try again")
} else if user.RowStatus == store.Archived {
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with username %s", signin.Username))
}
// Compare the stored hashed password, with the hashed version of the password that was received.
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(signin.Password)); err != nil {
// If the two passwords don't match, return a 401 status.
return echo.NewHTTPError(http.StatusUnauthorized, "Incorrect login credentials, please try again")
}
var expireAt time.Time
// Set cookie expiration to 100 years to make it persistent.
cookieExp := time.Now().AddDate(100, 0, 0)
if !signin.Remember {
expireAt = time.Now().Add(auth.AccessTokenDuration)
cookieExp = time.Now().Add(auth.CookieExpDuration)
}
accessToken, err := auth.GenerateAccessToken(user.Username, user.ID, expireAt, []byte(s.Secret))
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
}
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert access token, err: %s", err)).SetInternal(err)
}
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
userMessage := convertUserFromStore(user)
return c.JSON(http.StatusOK, userMessage)
}
// SignOut godoc
//
// @Summary Sign-out from memos.
// @Tags auth
// @Produce json
// @Success 200 {boolean} true "Sign-out success"
// @Router /api/v1/auth/signout [POST]
func (s *APIV1Service) SignOut(c echo.Context) error {
accessToken := findAccessToken(c)
userID, _ := getUserIDFromAccessToken(accessToken, s.Secret)
err := removeAccessTokenAndCookies(c, s.Store, userID, accessToken)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to remove access token, err: %s", err)).SetInternal(err)
}
return c.JSON(http.StatusOK, true)
}
// SignUp godoc
//
// @Summary Sign-up to memos.
// @Tags auth
// @Accept json
// @Produce json
// @Param body body SignUp true "Sign-up object"
// @Success 200 {object} store.User "User information"
// @Failure 400 {object} nil "Malformatted signup request | Failed to find users"
// @Failure 401 {object} nil "signup is disabled"
// @Failure 403 {object} nil "Forbidden"
// @Failure 404 {object} nil "Not found"
// @Failure 500 {object} nil "Failed to find system setting | Failed to unmarshal system setting allow signup | Failed to generate password hash | Failed to create user | Failed to generate tokens | Failed to create activity"
// @Router /api/v1/auth/signup [POST]
func (s *APIV1Service) SignUp(c echo.Context) error {
ctx := c.Request().Context()
signup := &SignUp{}
if err := json.NewDecoder(c.Request().Body).Decode(signup); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signup request").SetInternal(err)
}
hostUserType := store.RoleHost
existedHostUsers, err := s.Store.ListUsers(ctx, &store.FindUser{
Role: &hostUserType,
})
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Failed to find users").SetInternal(err)
}
if !util.UIDMatcher.MatchString(strings.ToLower(signup.Username)) {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid username %s", signup.Username)).SetInternal(err)
}
userCreate := &store.User{
Username: signup.Username,
// The new signup user should be normal user by default.
Role: store.RoleUser,
Nickname: signup.Username,
}
if len(existedHostUsers) == 0 {
// Change the default role to host if there is no host user.
userCreate.Role = store.RoleHost
} else {
workspaceGeneralSetting, err := s.Store.GetWorkspaceGeneralSetting(ctx)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting").SetInternal(err)
}
if workspaceGeneralSetting.DisallowSignup {
return echo.NewHTTPError(http.StatusUnauthorized, "signup is disabled").SetInternal(err)
}
if workspaceGeneralSetting.DisallowPasswordLogin {
return echo.NewHTTPError(http.StatusUnauthorized, "password login is deactivated").SetInternal(err)
}
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(signup.Password), bcrypt.DefaultCost)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
}
userCreate.PasswordHash = string(passwordHash)
user, err := s.Store.CreateUser(ctx, userCreate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
}
accessToken, err := auth.GenerateAccessToken(user.Username, user.ID, time.Now().Add(auth.AccessTokenDuration), []byte(s.Secret))
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
}
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert access token, err: %s", err)).SetInternal(err)
}
cookieExp := time.Now().Add(auth.CookieExpDuration)
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
userMessage := convertUserFromStore(user)
return c.JSON(http.StatusOK, userMessage)
}
func (s *APIV1Service) UpsertAccessTokenToStore(ctx context.Context, user *store.User, accessToken string) error {
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID)
if err != nil {
return errors.Wrap(err, "failed to get user access tokens")
}
userAccessToken := storepb.AccessTokensUserSetting_AccessToken{
AccessToken: accessToken,
Description: "Account sign in",
}
userAccessTokens = append(userAccessTokens, &userAccessToken)
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
UserId: user.ID,
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
Value: &storepb.UserSetting_AccessTokens{
AccessTokens: &storepb.AccessTokensUserSetting{
AccessTokens: userAccessTokens,
},
},
}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert user setting, err: %s", err)).SetInternal(err)
}
return nil
}
// removeAccessTokenAndCookies removes the jwt token from the cookies.
func removeAccessTokenAndCookies(c echo.Context, s *store.Store, userID int32, token string) error {
err := s.RemoveUserAccessToken(c.Request().Context(), userID, token)
if err != nil {
return err
}
cookieExp := time.Now().Add(-1 * time.Hour)
setTokenCookie(c, auth.AccessTokenCookieName, "", cookieExp)
return nil
}
// setTokenCookie sets the token to the cookie.
func setTokenCookie(c echo.Context, name, token string, expiration time.Time) {
cookie := new(http.Cookie)
cookie.Name = name
cookie.Value = token
cookie.Expires = expiration
cookie.Path = "/"
// Http-only helps mitigate the risk of client side script accessing the protected cookie.
cookie.HttpOnly = true
cookie.SameSite = http.SameSiteStrictMode
c.SetCookie(cookie)
}
package v1
// RowStatus is the status for a row.
type RowStatus string
const (
// Normal is the status for a normal row.
Normal RowStatus = "NORMAL"
// Archived is the status for an archived row.
Archived RowStatus = "ARCHIVED"
)
func (r RowStatus) String() string {
return string(r)
}
This source diff could not be displayed because it is too large. You can view the blob instead.
package v1
import (
"fmt"
"net/http"
"net/url"
"github.com/labstack/echo/v4"
getter "github.com/usememos/memos/plugin/http-getter"
)
func (*APIV1Service) registerGetterPublicRoutes(g *echo.Group) {
// GET /get/image?url={url} - Get image.
g.GET("/get/image", GetImage)
}
// GetImage godoc
//
// @Summary Get GetImage from URL
// @Tags image-url
// @Produce GetImage/*
// @Param url query string true "Image url"
// @Success 200 {object} nil "Image"
// @Failure 400 {object} nil "Missing GetImage url | Wrong url | Failed to get GetImage url: %s"
// @Failure 500 {object} nil "Failed to write GetImage blob"
// @Router /o/get/GetImage [GET]
func GetImage(c echo.Context) error {
urlStr := c.QueryParam("url")
if urlStr == "" {
return echo.NewHTTPError(http.StatusBadRequest, "Missing image url")
}
if _, err := url.Parse(urlStr); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Wrong url").SetInternal(err)
}
image, err := getter.GetImage(urlStr)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to get image url: %s", urlStr)).SetInternal(err)
}
c.Response().Writer.WriteHeader(http.StatusOK)
c.Response().Writer.Header().Set("Content-Type", image.Mediatype)
c.Response().Writer.Header().Set(echo.HeaderCacheControl, "max-age=31536000, immutable")
if _, err := c.Response().Writer.Write(image.Blob); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to write image blob").SetInternal(err)
}
return nil
}
package v1
import (
"fmt"
"log/slog"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"github.com/usememos/memos/internal/util"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/server/route/api/auth"
"github.com/usememos/memos/store"
)
const (
// The key name used to store user id in the context
// user id is extracted from the jwt token subject field.
userIDContextKey = "user-id"
)
func extractTokenFromHeader(c echo.Context) (string, error) {
authHeader := c.Request().Header.Get("Authorization")
if authHeader == "" {
return "", nil
}
authHeaderParts := strings.Fields(authHeader)
if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" {
return "", errors.New("Authorization header format must be Bearer {token}")
}
return authHeaderParts[1], nil
}
func findAccessToken(c echo.Context) string {
// Check the HTTP request header first.
accessToken, _ := extractTokenFromHeader(c)
if accessToken == "" {
// Check the cookie.
cookie, _ := c.Cookie(auth.AccessTokenCookieName)
if cookie != nil {
accessToken = cookie.Value
}
}
return accessToken
}
// JWTMiddleware validates the access token.
func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Request().Context()
path := c.Request().URL.Path
method := c.Request().Method
if server.defaultAuthSkipper(c) {
return next(c)
}
// Skip validation for server status endpoints.
if util.HasPrefixes(path, "/api/v1/ping", "/api/v1/status") && method == http.MethodGet {
return next(c)
}
accessToken := findAccessToken(c)
if accessToken == "" {
// Allow the user to access the public endpoints.
if util.HasPrefixes(path, "/o") {
return next(c)
}
// When the request is not authenticated, we allow the user to access the memo endpoints for those public memos.
if util.HasPrefixes(path, "/api/v1/idp", "/api/v1/memo", "/api/v1/user") && path != "/api/v1/user" && method == http.MethodGet {
return next(c)
}
return echo.NewHTTPError(http.StatusUnauthorized, "Missing access token")
}
userID, err := getUserIDFromAccessToken(accessToken, secret)
if err != nil {
err = removeAccessTokenAndCookies(c, server.Store, userID, accessToken)
if err != nil {
slog.Warn("fail to remove AccessToken and Cookies", err)
}
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid or expired access token")
}
accessTokens, err := server.Store.GetUserAccessTokens(ctx, userID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get user access tokens.").WithInternal(err)
}
if !validateAccessToken(accessToken, accessTokens) {
err = removeAccessTokenAndCookies(c, server.Store, userID, accessToken)
if err != nil {
slog.Warn("fail to remove AccessToken and Cookies", err)
}
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid access token.")
}
// Even if there is no error, we still need to make sure the user still exists.
user, err := server.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to find user ID: %d", userID)).SetInternal(err)
}
if user == nil {
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Failed to find user ID: %d", userID))
}
// Stores userID into context.
c.Set(userIDContextKey, userID)
return next(c)
}
}
func getUserIDFromAccessToken(accessToken, secret string) (int32, error) {
claims := &auth.ClaimsMessage{}
_, err := jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (any, error) {
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
}
if kid, ok := t.Header["kid"].(string); ok {
if kid == "v1" {
return []byte(secret), nil
}
}
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
})
if err != nil {
return 0, errors.Wrap(err, "Invalid or expired access token")
}
// We either have a valid access token or we will attempt to generate new access token.
userID, err := util.ConvertStringToInt32(claims.Subject)
if err != nil {
return 0, errors.Wrap(err, "Malformed ID in the token")
}
return userID, nil
}
func (*APIV1Service) defaultAuthSkipper(c echo.Context) bool {
path := c.Path()
return util.HasPrefixes(path, "/api/v1/auth")
}
func validateAccessToken(accessTokenString string, userAccessTokens []*storepb.AccessTokensUserSetting_AccessToken) bool {
for _, userAccessToken := range userAccessTokens {
if accessTokenString == userAccessToken.AccessToken {
return true
}
}
return false
}
This diff is collapsed.
package v1
import (
"encoding/json"
"fmt"
"net/http"
"github.com/labstack/echo/v4"
"github.com/usememos/memos/internal/util"
"github.com/usememos/memos/store"
)
type MemoOrganizer struct {
MemoID int32 `json:"memoId"`
UserID int32 `json:"userId"`
Pinned bool `json:"pinned"`
}
type UpsertMemoOrganizerRequest struct {
Pinned bool `json:"pinned"`
}
func (s *APIV1Service) registerMemoOrganizerRoutes(g *echo.Group) {
g.POST("/memo/:memoId/organizer", s.CreateMemoOrganizer)
}
// CreateMemoOrganizer godoc
//
// @Summary Organize memo (pin/unpin)
// @Tags memo-organizer
// @Accept json
// @Produce json
// @Param memoId path int true "ID of memo to organize"
// @Param body body UpsertMemoOrganizerRequest true "Memo organizer object"
// @Success 200 {object} store.Memo "Memo information"
// @Failure 400 {object} nil "ID is not a number: %s | Malformatted post memo organizer request"
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
// @Failure 404 {object} nil "Memo not found: %v"
// @Failure 500 {object} nil "Failed to find memo | Failed to upsert memo organizer | Failed to find memo by ID: %v | Failed to compose memo response"
// @Router /api/v1/memo/{memoId}/organizer [POST]
func (s *APIV1Service) CreateMemoOrganizer(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := util.ConvertStringToInt32(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
ID: &memoID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err)
}
if memo == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %v", memoID))
}
if memo.CreatorID != userID {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
request := &UpsertMemoOrganizerRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo organizer request").SetInternal(err)
}
upsert := &store.MemoOrganizer{
MemoID: memoID,
UserID: userID,
Pinned: request.Pinned,
}
_, err = s.Store.UpsertMemoOrganizer(ctx, upsert)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo organizer").SetInternal(err)
}
memo, err = s.Store.GetMemo(ctx, &store.FindMemo{
ID: &memoID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find memo by ID: %v", memoID)).SetInternal(err)
}
if memo == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %v", memoID))
}
memoResponse, err := s.convertMemoFromStore(ctx, memo)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err)
}
return c.JSON(http.StatusOK, memoResponse)
}
package v1
import (
"encoding/json"
"fmt"
"net/http"
"github.com/labstack/echo/v4"
"github.com/usememos/memos/internal/util"
"github.com/usememos/memos/store"
)
type MemoRelationType string
const (
MemoRelationReference MemoRelationType = "REFERENCE"
MemoRelationComment MemoRelationType = "COMMENT"
)
func (t MemoRelationType) String() string {
return string(t)
}
type MemoRelation struct {
MemoID int32 `json:"memoId"`
RelatedMemoID int32 `json:"relatedMemoId"`
Type MemoRelationType `json:"type"`
}
type UpsertMemoRelationRequest struct {
RelatedMemoID int32 `json:"relatedMemoId"`
Type MemoRelationType `json:"type"`
}
func (s *APIV1Service) registerMemoRelationRoutes(g *echo.Group) {
g.GET("/memo/:memoId/relation", s.GetMemoRelationList)
g.POST("/memo/:memoId/relation", s.CreateMemoRelation)
g.DELETE("/memo/:memoId/relation/:relatedMemoId/type/:relationType", s.DeleteMemoRelation)
}
// GetMemoRelationList godoc
//
// @Summary Get a list of Memo Relations
// @Tags memo-relation
// @Accept json
// @Produce json
// @Param memoId path int true "ID of memo to find relations"
// @Success 200 {object} []store.MemoRelation "Memo relation information list"
// @Failure 400 {object} nil "ID is not a number: %s"
// @Failure 500 {object} nil "Failed to list memo relations"
// @Router /api/v1/memo/{memoId}/relation [GET]
func (s *APIV1Service) GetMemoRelationList(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := util.ConvertStringToInt32(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
memoRelationList, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{
MemoID: &memoID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to list memo relations").SetInternal(err)
}
return c.JSON(http.StatusOK, memoRelationList)
}
// CreateMemoRelation godoc
//
// @Summary Create Memo Relation
// @Description Create a relation between two memos
// @Tags memo-relation
// @Accept json
// @Produce json
// @Param memoId path int true "ID of memo to relate"
// @Param body body UpsertMemoRelationRequest true "Memo relation object"
// @Success 200 {object} store.MemoRelation "Memo relation information"
// @Failure 400 {object} nil "ID is not a number: %s | Malformatted post memo relation request"
// @Failure 500 {object} nil "Failed to upsert memo relation"
// @Router /api/v1/memo/{memoId}/relation [POST]
//
// NOTES:
// - Currently not secured
// - It's possible to create relations to memos that doesn't exist, which will trigger 404 errors when the frontend tries to load them.
// - It's possible to create multiple relations, though the interface only shows first.
func (s *APIV1Service) CreateMemoRelation(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := util.ConvertStringToInt32(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
request := &UpsertMemoRelationRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo relation request").SetInternal(err)
}
memoRelation, err := s.Store.UpsertMemoRelation(ctx, &store.MemoRelation{
MemoID: memoID,
RelatedMemoID: request.RelatedMemoID,
Type: store.MemoRelationType(request.Type),
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo relation").SetInternal(err)
}
return c.JSON(http.StatusOK, memoRelation)
}
// DeleteMemoRelation godoc
//
// @Summary Delete a Memo Relation
// @Description Removes a relation between two memos
// @Tags memo-relation
// @Accept json
// @Produce json
// @Param memoId path int true "ID of memo to find relations"
// @Param relatedMemoId path int true "ID of memo to remove relation to"
// @Param relationType path MemoRelationType true "Type of relation to remove"
// @Success 200 {boolean} true "Memo relation deleted"
// @Failure 400 {object} nil "Memo ID is not a number: %s | Related memo ID is not a number: %s"
// @Failure 500 {object} nil "Failed to delete memo relation"
// @Router /api/v1/memo/{memoId}/relation/{relatedMemoId}/type/{relationType} [DELETE]
//
// NOTES:
// - Currently not secured.
// - Will always return true, even if the relation doesn't exist.
func (s *APIV1Service) DeleteMemoRelation(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := util.ConvertStringToInt32(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Memo ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
relatedMemoID, err := util.ConvertStringToInt32(c.Param("relatedMemoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Related memo ID is not a number: %s", c.Param("relatedMemoId"))).SetInternal(err)
}
relationType := store.MemoRelationType(c.Param("relationType"))
if err := s.Store.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{
MemoID: &memoID,
RelatedMemoID: &relatedMemoID,
Type: &relationType,
}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete memo relation").SetInternal(err)
}
return c.JSON(http.StatusOK, true)
}
func convertMemoRelationFromStore(memoRelation *store.MemoRelation) *MemoRelation {
return &MemoRelation{
MemoID: memoRelation.MemoID,
RelatedMemoID: memoRelation.RelatedMemoID,
Type: MemoRelationType(memoRelation.Type),
}
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
package v1
import (
"encoding/json"
"net/http"
"github.com/labstack/echo/v4"
"github.com/usememos/memos/server/profile"
"github.com/usememos/memos/store"
)
type SystemStatus struct {
Host *User `json:"host"`
Profile profile.Profile `json:"profile"`
DBSize int64 `json:"dbSize"`
// System settings
// Disable password login.
DisablePasswordLogin bool `json:"disablePasswordLogin"`
// Disable public memos.
DisablePublicMemos bool `json:"disablePublicMemos"`
// Max upload size.
MaxUploadSizeMiB int `json:"maxUploadSizeMiB"`
// Customized server profile, including server name and external url.
CustomizedProfile CustomizedProfile `json:"customizedProfile"`
// Storage service ID.
StorageServiceID int32 `json:"storageServiceId"`
// Local storage path.
LocalStoragePath string `json:"localStoragePath"`
// Memo display with updated timestamp.
MemoDisplayWithUpdatedTs bool `json:"memoDisplayWithUpdatedTs"`
}
func (s *APIV1Service) registerSystemRoutes(g *echo.Group) {
g.GET("/ping", s.PingSystem)
g.GET("/status", s.GetSystemStatus)
g.POST("/system/vacuum", s.ExecVacuum)
}
// PingSystem godoc
//
// @Summary Ping the system
// @Tags system
// @Produce json
// @Success 200 {boolean} true "If succeed to ping the system"
// @Router /api/v1/ping [GET]
func (*APIV1Service) PingSystem(c echo.Context) error {
return c.JSON(http.StatusOK, true)
}
// GetSystemStatus godoc
//
// @Summary Get system GetSystemStatus
// @Tags system
// @Produce json
// @Success 200 {object} SystemStatus "System GetSystemStatus"
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
// @Failure 500 {object} nil "Failed to find host user | Failed to find system setting list | Failed to unmarshal system setting customized profile value"
// @Router /api/v1/status [GET]
func (s *APIV1Service) GetSystemStatus(c echo.Context) error {
ctx := c.Request().Context()
systemStatus := SystemStatus{
Profile: profile.Profile{
Mode: s.Profile.Mode,
Version: s.Profile.Version,
},
MaxUploadSizeMiB: 32,
CustomizedProfile: CustomizedProfile{
Name: "Memos",
Locale: "en",
Appearance: "system",
},
StorageServiceID: DefaultStorage,
LocalStoragePath: "assets/{timestamp}_{filename}",
}
hostUserType := store.RoleHost
hostUser, err := s.Store.GetUser(ctx, &store.FindUser{
Role: &hostUserType,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find host user").SetInternal(err)
}
if hostUser != nil {
systemStatus.Host = &User{ID: hostUser.ID}
}
workspaceGeneralSetting, err := s.Store.GetWorkspaceGeneralSetting(ctx)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find workspace general setting").SetInternal(err)
}
systemStatus.DisablePasswordLogin = workspaceGeneralSetting.DisallowPasswordLogin
systemSettingList, err := s.Store.ListWorkspaceSettings(ctx, &store.FindWorkspaceSetting{})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting list").SetInternal(err)
}
for _, systemSetting := range systemSettingList {
var baseValue any
err := json.Unmarshal([]byte(systemSetting.Value), &baseValue)
if err != nil {
// Skip invalid value.
continue
}
switch systemSetting.Name {
case SystemSettingMaxUploadSizeMiBName.String():
systemStatus.MaxUploadSizeMiB = int(baseValue.(float64))
case SystemSettingCustomizedProfileName.String():
customizedProfile := CustomizedProfile{}
if err := json.Unmarshal([]byte(systemSetting.Value), &customizedProfile); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting customized profile value").SetInternal(err)
}
systemStatus.CustomizedProfile = customizedProfile
case SystemSettingStorageServiceIDName.String():
systemStatus.StorageServiceID = int32(baseValue.(float64))
default:
// Skip unknown system setting.
}
}
return c.JSON(http.StatusOK, systemStatus)
}
// ExecVacuum godoc
//
// @Summary Vacuum the database
// @Tags system
// @Produce json
// @Success 200 {boolean} true "Database vacuumed"
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
// @Failure 500 {object} nil "Failed to find user | Failed to ExecVacuum database"
// @Router /api/v1/system/vacuum [POST]
func (s *APIV1Service) ExecVacuum(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if user == nil || user.Role != store.RoleHost {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
if err := s.Store.Vacuum(ctx); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to vacuum database").SetInternal(err)
}
return c.JSON(http.StatusOK, true)
}
package v1
import (
"encoding/json"
"net/http"
"path/filepath"
"strings"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"github.com/usememos/memos/store"
)
type SystemSettingName string
const (
// SystemSettingMaxUploadSizeMiBName is the name of max upload size setting.
SystemSettingMaxUploadSizeMiBName SystemSettingName = "max-upload-size-mib"
// SystemSettingCustomizedProfileName is the name of customized server profile.
SystemSettingCustomizedProfileName SystemSettingName = "customized-profile"
// SystemSettingStorageServiceIDName is the name of storage service ID.
SystemSettingStorageServiceIDName SystemSettingName = "storage-service-id"
// SystemSettingLocalStoragePathName is the name of local storage path.
SystemSettingLocalStoragePathName SystemSettingName = "local-storage-path"
)
const systemSettingUnmarshalError = `failed to unmarshal value from system setting "%v"`
// CustomizedProfile is the struct definition for SystemSettingCustomizedProfileName system setting item.
type CustomizedProfile struct {
// Name is the server name, default is `memos`
Name string `json:"name"`
// LogoURL is the url of logo image.
LogoURL string `json:"logoUrl"`
// Description is the server description.
Description string `json:"description"`
// Locale is the server default locale.
Locale string `json:"locale"`
// Appearance is the server default appearance.
Appearance string `json:"appearance"`
}
func (key SystemSettingName) String() string {
return string(key)
}
type SystemSetting struct {
Name SystemSettingName `json:"name"`
// Value is a JSON string with basic value.
Value string `json:"value"`
Description string `json:"description"`
}
type UpsertSystemSettingRequest struct {
Name SystemSettingName `json:"name"`
Value string `json:"value"`
Description string `json:"description"`
}
func (s *APIV1Service) registerSystemSettingRoutes(g *echo.Group) {
g.GET("/system/setting", s.GetSystemSettingList)
g.POST("/system/setting", s.CreateSystemSetting)
}
// GetSystemSettingList godoc
//
// @Summary Get a list of system settings
// @Tags system-setting
// @Produce json
// @Success 200 {object} []SystemSetting "System setting list"
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
// @Failure 500 {object} nil "Failed to find user | Failed to find system setting list"
// @Router /api/v1/system/setting [GET]
func (s *APIV1Service) GetSystemSettingList(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if user == nil || user.Role != store.RoleHost {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
list, err := s.Store.ListWorkspaceSettings(ctx, &store.FindWorkspaceSetting{})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting list").SetInternal(err)
}
systemSettingList := make([]*SystemSetting, 0, len(list))
for _, systemSetting := range list {
systemSettingList = append(systemSettingList, convertSystemSettingFromStore(systemSetting))
}
return c.JSON(http.StatusOK, systemSettingList)
}
// CreateSystemSetting godoc
//
// @Summary Create system setting
// @Tags system-setting
// @Accept json
// @Produce json
// @Param body body UpsertSystemSettingRequest true "Request object."
// @Failure 400 {object} nil "Malformatted post system setting request | invalid system setting"
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
// @Failure 403 {object} nil "Cannot disable passwords if no SSO identity provider is configured."
// @Failure 500 {object} nil "Failed to find user | Failed to upsert system setting"
// @Router /api/v1/system/setting [POST]
func (s *APIV1Service) CreateSystemSetting(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if user == nil || user.Role != store.RoleHost {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
systemSettingUpsert := &UpsertSystemSettingRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(systemSettingUpsert); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post system setting request").SetInternal(err)
}
if err := systemSettingUpsert.Validate(); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid system setting").SetInternal(err)
}
systemSetting, err := s.Store.UpsertWorkspaceSetting(ctx, &store.WorkspaceSetting{
Name: systemSettingUpsert.Name.String(),
Value: systemSettingUpsert.Value,
Description: systemSettingUpsert.Description,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert system setting").SetInternal(err)
}
return c.JSON(http.StatusOK, convertSystemSettingFromStore(systemSetting))
}
func (upsert UpsertSystemSettingRequest) Validate() error {
switch settingName := upsert.Name; settingName {
case SystemSettingMaxUploadSizeMiBName:
var value int
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return errors.Errorf(systemSettingUnmarshalError, settingName)
}
case SystemSettingCustomizedProfileName:
customizedProfile := CustomizedProfile{
Name: "Memos",
LogoURL: "",
Description: "",
Locale: "en",
Appearance: "system",
}
if err := json.Unmarshal([]byte(upsert.Value), &customizedProfile); err != nil {
return errors.Errorf(systemSettingUnmarshalError, settingName)
}
case SystemSettingStorageServiceIDName:
// Note: 0 is the default value(database) for storage service ID.
value := 0
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return errors.Errorf(systemSettingUnmarshalError, settingName)
}
return nil
case SystemSettingLocalStoragePathName:
value := ""
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return errors.Errorf(systemSettingUnmarshalError, settingName)
}
trimmedValue := strings.TrimSpace(value)
switch {
case trimmedValue != value:
return errors.New("local storage path must not contain leading or trailing whitespace")
case trimmedValue == "":
return errors.New("local storage path can't be empty")
case strings.Contains(trimmedValue, "\\"):
return errors.New("local storage path must use forward slashes `/`")
case strings.Contains(trimmedValue, "../"):
return errors.New("local storage path is not allowed to contain `../`")
case strings.HasPrefix(trimmedValue, "./"):
return errors.New("local storage path is not allowed to start with `./`")
case filepath.IsAbs(trimmedValue) || trimmedValue[0] == '/':
return errors.New("local storage path must be a relative path")
case !strings.Contains(trimmedValue, "{filename}"):
return errors.New("local storage path must contain `{filename}`")
}
default:
return errors.New("invalid system setting name")
}
return nil
}
func convertSystemSettingFromStore(systemSetting *store.WorkspaceSetting) *SystemSetting {
return &SystemSetting{
Name: SystemSettingName(systemSetting.Name),
Value: systemSetting.Value,
Description: systemSetting.Description,
}
}
package v1
import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"slices"
"sort"
"github.com/labstack/echo/v4"
"github.com/usememos/memos/store"
)
type Tag struct {
Name string
CreatorID int32
}
type UpsertTagRequest struct {
Name string `json:"name"`
}
type DeleteTagRequest struct {
Name string `json:"name"`
}
func (s *APIV1Service) registerTagRoutes(g *echo.Group) {
g.GET("/tag", s.GetTagList)
g.POST("/tag", s.CreateTag)
g.GET("/tag/suggestion", s.GetTagSuggestion)
g.POST("/tag/delete", s.DeleteTag)
}
// GetTagList godoc
//
// @Summary Get a list of tags
// @Tags tag
// @Produce json
// @Success 200 {object} []string "Tag list"
// @Failure 400 {object} nil "Missing user id to find tag"
// @Failure 500 {object} nil "Failed to find tag list"
// @Router /api/v1/tag [GET]
func (s *APIV1Service) GetTagList(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find tag")
}
list, err := s.Store.ListTags(ctx, &store.FindTag{
CreatorID: userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find tag list").SetInternal(err)
}
tagNameList := []string{}
for _, tag := range list {
tagNameList = append(tagNameList, tag.Name)
}
return c.JSON(http.StatusOK, tagNameList)
}
// CreateTag godoc
//
// @Summary Create a tag
// @Tags tag
// @Accept json
// @Produce json
// @Param body body UpsertTagRequest true "Request object."
// @Success 200 {object} string "Created tag name"
// @Failure 400 {object} nil "Malformatted post tag request | Tag name shouldn't be empty"
// @Failure 401 {object} nil "Missing user in session"
// @Failure 500 {object} nil "Failed to upsert tag | Failed to create activity"
// @Router /api/v1/tag [POST]
func (s *APIV1Service) CreateTag(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
tagUpsert := &UpsertTagRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(tagUpsert); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post tag request").SetInternal(err)
}
if tagUpsert.Name == "" {
return echo.NewHTTPError(http.StatusBadRequest, "Tag name shouldn't be empty")
}
tag, err := s.Store.UpsertTag(ctx, &store.Tag{
Name: tagUpsert.Name,
CreatorID: userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert tag").SetInternal(err)
}
tagMessage := convertTagFromStore(tag)
return c.JSON(http.StatusOK, tagMessage.Name)
}
// DeleteTag godoc
//
// @Summary Delete a tag
// @Tags tag
// @Accept json
// @Produce json
// @Param body body DeleteTagRequest true "Request object."
// @Success 200 {boolean} true "Tag deleted"
// @Failure 400 {object} nil "Malformatted post tag request | Tag name shouldn't be empty"
// @Failure 401 {object} nil "Missing user in session"
// @Failure 500 {object} nil "Failed to delete tag name: %v"
// @Router /api/v1/tag/delete [POST]
func (s *APIV1Service) DeleteTag(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
tagDelete := &DeleteTagRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(tagDelete); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post tag request").SetInternal(err)
}
if tagDelete.Name == "" {
return echo.NewHTTPError(http.StatusBadRequest, "Tag name shouldn't be empty")
}
err := s.Store.DeleteTag(ctx, &store.DeleteTag{
Name: tagDelete.Name,
CreatorID: userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete tag name: %v", tagDelete.Name)).SetInternal(err)
}
return c.JSON(http.StatusOK, true)
}
// GetTagSuggestion godoc
//
// @Summary Get a list of tags suggested from other memos contents
// @Tags tag
// @Produce json
// @Success 200 {object} []string "Tag list"
// @Failure 400 {object} nil "Missing user session"
// @Failure 500 {object} nil "Failed to find memo list | Failed to find tag list"
// @Router /api/v1/tag/suggestion [GET]
func (s *APIV1Service) GetTagSuggestion(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user session")
}
normalRowStatus := store.Normal
memoFind := &store.FindMemo{
CreatorID: &userID,
ContentSearch: []string{"#"},
RowStatus: &normalRowStatus,
}
memoMessageList, err := s.Store.ListMemos(ctx, memoFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
}
list, err := s.Store.ListTags(ctx, &store.FindTag{
CreatorID: userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find tag list").SetInternal(err)
}
tagNameList := []string{}
for _, tag := range list {
tagNameList = append(tagNameList, tag.Name)
}
tagMapSet := make(map[string]bool)
for _, memo := range memoMessageList {
for _, tag := range findTagListFromMemoContent(memo.Content) {
if !slices.Contains(tagNameList, tag) {
tagMapSet[tag] = true
}
}
}
tagList := []string{}
for tag := range tagMapSet {
tagList = append(tagList, tag)
}
sort.Strings(tagList)
return c.JSON(http.StatusOK, tagList)
}
func convertTagFromStore(tag *store.Tag) *Tag {
return &Tag{
Name: tag.Name,
CreatorID: tag.CreatorID,
}
}
var tagRegexp = regexp.MustCompile(`#([^\s#,]+)`)
func findTagListFromMemoContent(memoContent string) []string {
tagMapSet := make(map[string]bool)
matches := tagRegexp.FindAllStringSubmatch(memoContent, -1)
for _, v := range matches {
tagName := v[1]
tagMapSet[tagName] = true
}
tagList := []string{}
for tag := range tagMapSet {
tagList = append(tagList, tag)
}
sort.Strings(tagList)
return tagList
}
package v1
import (
"testing"
)
func TestFindTagListFromMemoContent(t *testing.T) {
tests := []struct {
memoContent string
want []string
}{
{
memoContent: "#tag1 ",
want: []string{"tag1"},
},
{
memoContent: "#tag1 #tag2 ",
want: []string{"tag1", "tag2"},
},
{
memoContent: "#tag1 #tag2 \n#tag3 ",
want: []string{"tag1", "tag2", "tag3"},
},
{
memoContent: "#tag1 #tag2 \n#tag3 #tag4 ",
want: []string{"tag1", "tag2", "tag3", "tag4"},
},
{
memoContent: "#tag1 #tag2 \n#tag3 #tag4 ",
want: []string{"tag1", "tag2", "tag3", "tag4"},
},
{
memoContent: "#tag1 123123#tag2 \n#tag3 #tag4 ",
want: []string{"tag1", "tag2", "tag3", "tag4"},
},
{
memoContent: "#tag1 http://123123.com?123123#tag2 \n#tag3 #tag4 http://123123.com?123123#tag2) ",
want: []string{"tag1", "tag2", "tag2)", "tag3", "tag4"},
},
}
for _, test := range tests {
result := findTagListFromMemoContent(test.memoContent)
if len(result) != len(test.want) {
t.Errorf("Find tag list %s: got result %v, want %v.", test.memoContent, result, test.want)
}
}
}
This diff is collapsed.
This diff is collapsed.
......@@ -25,7 +25,7 @@ func (s *APIV2Service) CreateStorage(ctx context.Context, request *apiv2pb.Creat
return nil, status.Errorf(codes.Internal, "failed to create storage, error: %+v", err)
}
return &apiv2pb.CreateStorageResponse{
Storage: convertStorageFromStore(storage),
Storage: ConvertStorageFromStore(storage),
}, nil
}
......@@ -39,7 +39,7 @@ func (s *APIV2Service) ListStorages(ctx context.Context, _ *apiv2pb.ListStorages
Storages: []*apiv2pb.Storage{},
}
for _, storage := range storages {
response.Storages = append(response.Storages, convertStorageFromStore(storage))
response.Storages = append(response.Storages, ConvertStorageFromStore(storage))
}
return response, nil
}
......@@ -55,7 +55,7 @@ func (s *APIV2Service) GetStorage(ctx context.Context, request *apiv2pb.GetStora
return nil, status.Errorf(codes.NotFound, "storage not found")
}
return &apiv2pb.GetStorageResponse{
Storage: convertStorageFromStore(storage),
Storage: ConvertStorageFromStore(storage),
}, nil
}
......@@ -82,7 +82,7 @@ func (s *APIV2Service) UpdateStorage(ctx context.Context, request *apiv2pb.Updat
return nil, status.Errorf(codes.Internal, "failed to update storage, error: %+v", err)
}
return &apiv2pb.UpdateStorageResponse{
Storage: convertStorageFromStore(storage),
Storage: ConvertStorageFromStore(storage),
}, nil
}
......@@ -96,7 +96,7 @@ func (s *APIV2Service) DeleteStorage(ctx context.Context, request *apiv2pb.Delet
return &apiv2pb.DeleteStorageResponse{}, nil
}
func convertStorageFromStore(storage *storepb.Storage) *apiv2pb.Storage {
func ConvertStorageFromStore(storage *storepb.Storage) *apiv2pb.Storage {
temp := &apiv2pb.Storage{
Id: storage.Id,
Title: storage.Name,
......
......@@ -15,7 +15,6 @@ import (
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/server/integration"
"github.com/usememos/memos/server/profile"
apiv1 "github.com/usememos/memos/server/route/api/v1"
apiv2 "github.com/usememos/memos/server/route/api/v2"
"github.com/usememos/memos/server/route/frontend"
versionchecker "github.com/usememos/memos/server/service/version_checker"
......@@ -75,11 +74,6 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store
frontendService.Serve(ctx, e)
}
// Register API v1 endpoints.
rootGroup := e.Group("")
apiV1Service := apiv1.NewAPIV1Service(s.Secret, profile, store, s.telegramBot)
apiV1Service.Register(rootGroup)
apiV2Service := apiv2.NewAPIV2Service(s.Secret, profile, store, s.Profile.Port+1)
// Register gRPC gateway as api v2.
if err := apiV2Service.RegisterGateway(ctx, e); err != nil {
......
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