mirror of
https://github.com/usememos/memos.git
synced 2024-11-17 04:07:30 +08:00
943 lines
32 KiB
Go
943 lines
32 KiB
Go
package v1
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/lithammer/shortuuid/v4"
|
|
"github.com/pkg/errors"
|
|
|
|
"github.com/usememos/memos/internal/util"
|
|
"github.com/usememos/memos/plugin/webhook"
|
|
storepb "github.com/usememos/memos/proto/gen/store"
|
|
"github.com/usememos/memos/store"
|
|
)
|
|
|
|
// Visibility is the type of a visibility.
|
|
type Visibility string
|
|
|
|
const (
|
|
// Public is the PUBLIC visibility.
|
|
Public Visibility = "PUBLIC"
|
|
// Protected is the PROTECTED visibility.
|
|
Protected Visibility = "PROTECTED"
|
|
// Private is the PRIVATE visibility.
|
|
Private Visibility = "PRIVATE"
|
|
)
|
|
|
|
func (v Visibility) String() string {
|
|
switch v {
|
|
case Public:
|
|
return "PUBLIC"
|
|
case Protected:
|
|
return "PROTECTED"
|
|
case Private:
|
|
return "PRIVATE"
|
|
}
|
|
return "PRIVATE"
|
|
}
|
|
|
|
type Memo struct {
|
|
ID int32 `json:"id"`
|
|
Name string `json:"name"`
|
|
|
|
// Standard fields
|
|
RowStatus RowStatus `json:"rowStatus"`
|
|
CreatorID int32 `json:"creatorId"`
|
|
CreatedTs int64 `json:"createdTs"`
|
|
UpdatedTs int64 `json:"updatedTs"`
|
|
|
|
// Domain specific fields
|
|
DisplayTs int64 `json:"displayTs"`
|
|
Content string `json:"content"`
|
|
Visibility Visibility `json:"visibility"`
|
|
Pinned bool `json:"pinned"`
|
|
|
|
// Related fields
|
|
CreatorName string `json:"creatorName"`
|
|
CreatorUsername string `json:"creatorUsername"`
|
|
ResourceList []*Resource `json:"resourceList"`
|
|
RelationList []*MemoRelation `json:"relationList"`
|
|
}
|
|
|
|
type CreateMemoRequest struct {
|
|
// Standard fields
|
|
CreatorID int32 `json:"-"`
|
|
CreatedTs *int64 `json:"createdTs"`
|
|
|
|
// Domain specific fields
|
|
Visibility Visibility `json:"visibility"`
|
|
Content string `json:"content"`
|
|
|
|
// Related fields
|
|
ResourceIDList []int32 `json:"resourceIdList"`
|
|
RelationList []*UpsertMemoRelationRequest `json:"relationList"`
|
|
}
|
|
|
|
type PatchMemoRequest struct {
|
|
ID int32 `json:"-"`
|
|
|
|
// Standard fields
|
|
CreatedTs *int64 `json:"createdTs"`
|
|
UpdatedTs *int64
|
|
RowStatus *RowStatus `json:"rowStatus"`
|
|
|
|
// Domain specific fields
|
|
Content *string `json:"content"`
|
|
Visibility *Visibility `json:"visibility"`
|
|
|
|
// Related fields
|
|
ResourceIDList []int32 `json:"resourceIdList"`
|
|
RelationList []*UpsertMemoRelationRequest `json:"relationList"`
|
|
}
|
|
|
|
type FindMemoRequest struct {
|
|
ID *int32
|
|
|
|
// Standard fields
|
|
RowStatus *RowStatus
|
|
CreatorID *int32
|
|
|
|
// Domain specific fields
|
|
Pinned *bool
|
|
ContentSearch []string
|
|
VisibilityList []Visibility
|
|
|
|
// Pagination
|
|
Limit *int
|
|
Offset *int
|
|
}
|
|
|
|
// maxContentLength means the max memo content bytes is 1MB.
|
|
const maxContentLength = 1 << 30
|
|
|
|
func (s *APIV1Service) registerMemoRoutes(g *echo.Group) {
|
|
g.GET("/memo", s.GetMemoList)
|
|
g.POST("/memo", s.CreateMemo)
|
|
g.GET("/memo/all", s.GetAllMemos)
|
|
g.GET("/memo/stats", s.GetMemoStats)
|
|
g.GET("/memo/:memoId", s.GetMemo)
|
|
g.PATCH("/memo/:memoId", s.UpdateMemo)
|
|
g.DELETE("/memo/:memoId", s.DeleteMemo)
|
|
}
|
|
|
|
// GetMemoList godoc
|
|
//
|
|
// @Summary Get a list of memos matching optional filters
|
|
// @Tags memo
|
|
// @Produce json
|
|
// @Param creatorId query int false "Creator ID"
|
|
// @Param creatorUsername query string false "Creator username"
|
|
// @Param rowStatus query store.RowStatus false "Row status"
|
|
// @Param pinned query bool false "Pinned"
|
|
// @Param tag query string false "Search for tag. Do not append #"
|
|
// @Param content query string false "Search for content"
|
|
// @Param limit query int false "Limit"
|
|
// @Param offset query int false "Offset"
|
|
// @Success 200 {object} []store.Memo "Memo list"
|
|
// @Failure 400 {object} nil "Missing user to find memo"
|
|
// @Failure 500 {object} nil "Failed to get memo display with updated ts setting value | Failed to fetch memo list | Failed to compose memo response"
|
|
// @Router /api/v1/memo [GET]
|
|
func (s *APIV1Service) GetMemoList(c echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
find := &store.FindMemo{
|
|
OrderByPinned: true,
|
|
}
|
|
if userID, err := util.ConvertStringToInt32(c.QueryParam("creatorId")); err == nil {
|
|
find.CreatorID = &userID
|
|
}
|
|
|
|
if username := c.QueryParam("creatorUsername"); username != "" {
|
|
user, _ := s.Store.GetUser(ctx, &store.FindUser{Username: &username})
|
|
if user != nil {
|
|
find.CreatorID = &user.ID
|
|
}
|
|
}
|
|
|
|
currentUserID, ok := c.Get(userIDContextKey).(int32)
|
|
if !ok {
|
|
// Anonymous use should only fetch PUBLIC memos with specified user
|
|
if find.CreatorID == nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "Missing user to find memo")
|
|
}
|
|
find.VisibilityList = []store.Visibility{store.Public}
|
|
} else {
|
|
// Authorized user can fetch all PUBLIC/PROTECTED memo
|
|
visibilityList := []store.Visibility{store.Public, store.Protected}
|
|
|
|
// If Creator is authorized user (as default), PRIVATE memo is OK
|
|
if find.CreatorID == nil || *find.CreatorID == currentUserID {
|
|
find.CreatorID = ¤tUserID
|
|
visibilityList = append(visibilityList, store.Private)
|
|
}
|
|
find.VisibilityList = visibilityList
|
|
}
|
|
|
|
rowStatus := store.RowStatus(c.QueryParam("rowStatus"))
|
|
if rowStatus != "" {
|
|
find.RowStatus = &rowStatus
|
|
}
|
|
|
|
contentSearch := []string{}
|
|
tag := c.QueryParam("tag")
|
|
if tag != "" {
|
|
contentSearch = append(contentSearch, "#"+tag)
|
|
}
|
|
content := c.QueryParam("content")
|
|
if content != "" {
|
|
contentSearch = append(contentSearch, content)
|
|
}
|
|
find.ContentSearch = contentSearch
|
|
|
|
if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
|
|
find.Limit = &limit
|
|
}
|
|
if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil {
|
|
find.Offset = &offset
|
|
}
|
|
|
|
list, err := s.Store.ListMemos(ctx, find)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch memo list").SetInternal(err)
|
|
}
|
|
memoResponseList := []*Memo{}
|
|
for _, memo := range list {
|
|
memoResponse, err := s.convertMemoFromStore(ctx, memo)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err)
|
|
}
|
|
memoResponseList = append(memoResponseList, memoResponse)
|
|
}
|
|
return c.JSON(http.StatusOK, memoResponseList)
|
|
}
|
|
|
|
// CreateMemo godoc
|
|
//
|
|
// @Summary Create a memo
|
|
// @Description Visibility can be PUBLIC, PROTECTED or PRIVATE
|
|
// @Description *You should omit fields to use their default values
|
|
// @Tags memo
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body CreateMemoRequest true "Request object."
|
|
// @Success 200 {object} store.Memo "Stored memo"
|
|
// @Failure 400 {object} nil "Malformatted post memo request | Content size overflow, up to 1MB"
|
|
// @Failure 401 {object} nil "Missing user in session"
|
|
// @Failure 404 {object} nil "User not found | Memo not found: %d"
|
|
// @Failure 500 {object} nil "Failed to find user setting | Failed to unmarshal user setting value | Failed to find system setting | Failed to unmarshal system setting | Failed to find user | Failed to create memo | Failed to create activity | Failed to upsert memo resource | Failed to upsert memo relation | Failed to compose memo | Failed to compose memo response"
|
|
// @Router /api/v1/memo [POST]
|
|
//
|
|
// NOTES:
|
|
// - It's currently possible to create phantom resources and relations. Phantom relations will trigger backend 404's when fetching memo.
|
|
func (s *APIV1Service) CreateMemo(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")
|
|
}
|
|
|
|
createMemoRequest := &CreateMemoRequest{}
|
|
if err := json.NewDecoder(c.Request().Body).Decode(createMemoRequest); err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo request").SetInternal(err)
|
|
}
|
|
if len(createMemoRequest.Content) > maxContentLength {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "Content size overflow, up to 1MB")
|
|
}
|
|
|
|
if createMemoRequest.Visibility == "" {
|
|
userMemoVisibilitySetting, err := s.Store.GetUserSetting(ctx, &store.FindUserSetting{
|
|
UserID: &userID,
|
|
Key: storepb.UserSettingKey_USER_SETTING_MEMO_VISIBILITY,
|
|
})
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user setting").SetInternal(err)
|
|
}
|
|
if userMemoVisibilitySetting != nil {
|
|
createMemoRequest.Visibility = Visibility(userMemoVisibilitySetting.GetMemoVisibility())
|
|
} else {
|
|
// Private is the default memo visibility.
|
|
createMemoRequest.Visibility = Private
|
|
}
|
|
}
|
|
|
|
createMemoRequest.CreatorID = userID
|
|
memo, err := s.Store.CreateMemo(ctx, convertCreateMemoRequestToMemoMessage(createMemoRequest))
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create memo").SetInternal(err)
|
|
}
|
|
|
|
for _, resourceID := range createMemoRequest.ResourceIDList {
|
|
if _, err := s.Store.UpdateResource(ctx, &store.UpdateResource{
|
|
ID: resourceID,
|
|
MemoID: &memo.ID,
|
|
}); err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err)
|
|
}
|
|
}
|
|
|
|
for _, memoRelationUpsert := range createMemoRequest.RelationList {
|
|
if _, err := s.Store.UpsertMemoRelation(ctx, &store.MemoRelation{
|
|
MemoID: memo.ID,
|
|
RelatedMemoID: memoRelationUpsert.RelatedMemoID,
|
|
Type: store.MemoRelationType(memoRelationUpsert.Type),
|
|
}); err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo relation").SetInternal(err)
|
|
}
|
|
if memo.Visibility != store.Private && memoRelationUpsert.Type == MemoRelationComment {
|
|
relatedMemo, err := s.Store.GetMemo(ctx, &store.FindMemo{
|
|
ID: &memoRelationUpsert.RelatedMemoID,
|
|
})
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get related memo").SetInternal(err)
|
|
}
|
|
if relatedMemo.CreatorID != memo.CreatorID {
|
|
activity, err := s.Store.CreateActivity(ctx, &store.Activity{
|
|
CreatorID: memo.CreatorID,
|
|
Type: store.ActivityTypeMemoComment,
|
|
Level: store.ActivityLevelInfo,
|
|
Payload: &storepb.ActivityPayload{
|
|
MemoComment: &storepb.ActivityMemoCommentPayload{
|
|
MemoId: memo.ID,
|
|
RelatedMemoId: memoRelationUpsert.RelatedMemoID,
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
|
}
|
|
if _, err := s.Store.CreateInbox(ctx, &store.Inbox{
|
|
SenderID: memo.CreatorID,
|
|
ReceiverID: relatedMemo.CreatorID,
|
|
Status: store.UNREAD,
|
|
Message: &storepb.InboxMessage{
|
|
Type: storepb.InboxMessage_TYPE_MEMO_COMMENT,
|
|
ActivityId: &activity.ID,
|
|
},
|
|
}); err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create inbox").SetInternal(err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
composedMemo, err := s.Store.GetMemo(ctx, &store.FindMemo{
|
|
ID: &memo.ID,
|
|
})
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo").SetInternal(err)
|
|
}
|
|
if composedMemo == nil {
|
|
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memo.ID))
|
|
}
|
|
|
|
memoResponse, err := s.convertMemoFromStore(ctx, composedMemo)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err)
|
|
}
|
|
|
|
// Send notification to telegram if memo is not private.
|
|
if memoResponse.Visibility != Private {
|
|
// fetch all telegram UserID
|
|
userSettings, err := s.Store.ListUserSettings(ctx, &store.FindUserSetting{Key: storepb.UserSettingKey_USER_SETTING_TELEGRAM_USER_ID})
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to ListUserSettings").SetInternal(err)
|
|
}
|
|
for _, userSetting := range userSettings {
|
|
tgUserID, err := strconv.ParseInt(userSetting.GetTelegramUserId(), 10, 64)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// send notification to telegram
|
|
content := memoResponse.CreatorName + " Says:\n\n" + memoResponse.Content
|
|
_, err = s.telegramBot.SendMessage(ctx, tgUserID, content)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
// Try to dispatch webhook when memo is created.
|
|
if err := s.DispatchMemoCreatedWebhook(ctx, memoResponse); err != nil {
|
|
slog.Warn("Failed to dispatch memo created webhook", err)
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, memoResponse)
|
|
}
|
|
|
|
// GetAllMemos godoc
|
|
//
|
|
// @Summary Get a list of public memos matching optional filters
|
|
// @Description This should also list protected memos if the user is logged in
|
|
// @Description Authentication is optional
|
|
// @Tags memo
|
|
// @Produce json
|
|
// @Param limit query int false "Limit"
|
|
// @Param offset query int false "Offset"
|
|
// @Success 200 {object} []store.Memo "Memo list"
|
|
// @Failure 500 {object} nil "Failed to get memo display with updated ts setting value | Failed to fetch all memo list | Failed to compose memo response"
|
|
// @Router /api/v1/memo/all [GET]
|
|
//
|
|
// NOTES:
|
|
// - creatorUsername is listed at ./web/src/helpers/api.ts:82, but it's not present here
|
|
func (s *APIV1Service) GetAllMemos(c echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
memoFind := &store.FindMemo{}
|
|
_, ok := c.Get(userIDContextKey).(int32)
|
|
if !ok {
|
|
memoFind.VisibilityList = []store.Visibility{store.Public}
|
|
} else {
|
|
memoFind.VisibilityList = []store.Visibility{store.Public, store.Protected}
|
|
}
|
|
|
|
if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
|
|
memoFind.Limit = &limit
|
|
}
|
|
if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil {
|
|
memoFind.Offset = &offset
|
|
}
|
|
|
|
// Only fetch normal status memos.
|
|
normalStatus := store.Normal
|
|
memoFind.RowStatus = &normalStatus
|
|
|
|
list, err := s.Store.ListMemos(ctx, memoFind)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch all memo list").SetInternal(err)
|
|
}
|
|
memoResponseList := []*Memo{}
|
|
for _, memo := range list {
|
|
memoResponse, err := s.convertMemoFromStore(ctx, memo)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err)
|
|
}
|
|
memoResponseList = append(memoResponseList, memoResponse)
|
|
}
|
|
return c.JSON(http.StatusOK, memoResponseList)
|
|
}
|
|
|
|
// GetMemoStats godoc
|
|
//
|
|
// @Summary Get memo stats by creator ID or username
|
|
// @Description Used to generate the heatmap
|
|
// @Tags memo
|
|
// @Produce json
|
|
// @Param creatorId query int false "Creator ID"
|
|
// @Param creatorUsername query string false "Creator username"
|
|
// @Success 200 {object} []int "Memo createdTs list"
|
|
// @Failure 400 {object} nil "Missing user id to find memo"
|
|
// @Failure 500 {object} nil "Failed to get memo display with updated ts setting value | Failed to find memo list | Failed to compose memo response"
|
|
// @Router /api/v1/memo/stats [GET]
|
|
func (s *APIV1Service) GetMemoStats(c echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
normalStatus := store.Normal
|
|
findMemoMessage := &store.FindMemo{
|
|
RowStatus: &normalStatus,
|
|
ExcludeContent: true,
|
|
}
|
|
if creatorID, err := util.ConvertStringToInt32(c.QueryParam("creatorId")); err == nil {
|
|
findMemoMessage.CreatorID = &creatorID
|
|
}
|
|
|
|
if username := c.QueryParam("creatorUsername"); username != "" {
|
|
user, _ := s.Store.GetUser(ctx, &store.FindUser{Username: &username})
|
|
if user != nil {
|
|
findMemoMessage.CreatorID = &user.ID
|
|
}
|
|
}
|
|
|
|
if findMemoMessage.CreatorID == nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find memo")
|
|
}
|
|
|
|
currentUserID, ok := c.Get(userIDContextKey).(int32)
|
|
if !ok {
|
|
findMemoMessage.VisibilityList = []store.Visibility{store.Public}
|
|
} else {
|
|
if *findMemoMessage.CreatorID != currentUserID {
|
|
findMemoMessage.VisibilityList = []store.Visibility{store.Public, store.Protected}
|
|
} else {
|
|
findMemoMessage.VisibilityList = []store.Visibility{store.Public, store.Protected, store.Private}
|
|
}
|
|
}
|
|
|
|
list, err := s.Store.ListMemos(ctx, findMemoMessage)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
|
|
}
|
|
|
|
displayTsList := []int64{}
|
|
for _, memo := range list {
|
|
displayTsList = append(displayTsList, memo.CreatedTs)
|
|
}
|
|
return c.JSON(http.StatusOK, displayTsList)
|
|
}
|
|
|
|
// GetMemo godoc
|
|
//
|
|
// @Summary Get memo by ID
|
|
// @Tags memo
|
|
// @Produce json
|
|
// @Param memoId path int true "Memo ID"
|
|
// @Success 200 {object} []store.Memo "Memo list"
|
|
// @Failure 400 {object} nil "ID is not a number: %s"
|
|
// @Failure 401 {object} nil "Missing user in session"
|
|
// @Failure 403 {object} nil "this memo is private only | this memo is protected, missing user in session
|
|
// @Failure 404 {object} nil "Memo not found: %d"
|
|
// @Failure 500 {object} nil "Failed to find memo by ID: %v | Failed to compose memo response"
|
|
// @Router /api/v1/memo/{memoId} [GET]
|
|
func (s *APIV1Service) GetMemo(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)
|
|
}
|
|
|
|
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: %d", memoID))
|
|
}
|
|
|
|
userID, ok := c.Get(userIDContextKey).(int32)
|
|
if memo.Visibility == store.Private {
|
|
if !ok || memo.CreatorID != userID {
|
|
return echo.NewHTTPError(http.StatusForbidden, "this memo is private only")
|
|
}
|
|
} else if memo.Visibility == store.Protected {
|
|
if !ok {
|
|
return echo.NewHTTPError(http.StatusForbidden, "this memo is protected, missing user in session")
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
|
|
// DeleteMemo godoc
|
|
//
|
|
// @Summary Delete memo by ID
|
|
// @Tags memo
|
|
// @Produce json
|
|
// @Param memoId path int true "Memo ID to delete"
|
|
// @Success 200 {boolean} true "Memo deleted"
|
|
// @Failure 400 {object} nil "ID is not a number: %s"
|
|
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
|
|
// @Failure 404 {object} nil "Memo not found: %d"
|
|
// @Failure 500 {object} nil "Failed to find memo | Failed to delete memo ID: %v"
|
|
// @Router /api/v1/memo/{memoId} [DELETE]
|
|
func (s *APIV1Service) DeleteMemo(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")
|
|
}
|
|
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)
|
|
}
|
|
|
|
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: %d", memoID))
|
|
}
|
|
if memo.CreatorID != userID {
|
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
|
}
|
|
|
|
if memoMessage, err := s.convertMemoFromStore(ctx, memo); err == nil {
|
|
// Try to dispatch webhook when memo is deleted.
|
|
if err := s.DispatchMemoDeletedWebhook(ctx, memoMessage); err != nil {
|
|
slog.Warn("Failed to dispatch memo deleted webhook", err)
|
|
}
|
|
}
|
|
|
|
if err := s.Store.DeleteMemo(ctx, &store.DeleteMemo{
|
|
ID: memoID,
|
|
}); err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete memo ID: %v", memoID)).SetInternal(err)
|
|
}
|
|
return c.JSON(http.StatusOK, true)
|
|
}
|
|
|
|
// UpdateMemo godoc
|
|
//
|
|
// @Summary Update a memo
|
|
// @Description Visibility can be PUBLIC, PROTECTED or PRIVATE
|
|
// @Description *You should omit fields to use their default values
|
|
// @Tags memo
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param memoId path int true "ID of memo to update"
|
|
// @Param body body PatchMemoRequest true "Patched object."
|
|
// @Success 200 {object} store.Memo "Stored memo"
|
|
// @Failure 400 {object} nil "ID is not a number: %s | Malformatted patch memo request | Content size overflow, up to 1MB"
|
|
// @Failure 401 {object} nil "Missing user in session | Unauthorized"
|
|
// @Failure 404 {object} nil "Memo not found: %d"
|
|
// @Failure 500 {object} nil "Failed to find memo | Failed to patch memo | Failed to upsert memo resource | Failed to delete memo resource | Failed to compose memo response"
|
|
// @Router /api/v1/memo/{memoId} [PATCH]
|
|
//
|
|
// NOTES:
|
|
// - It's currently possible to create phantom resources and relations. Phantom relations will trigger backend 404's when fetching memo.
|
|
// - Passing 0 to createdTs and updatedTs will set them to 0 in the database, which is probably unwanted.
|
|
func (s *APIV1Service) UpdateMemo(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")
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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: %d", memoID))
|
|
}
|
|
if memo.CreatorID != userID {
|
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
|
}
|
|
|
|
currentTs := time.Now().Unix()
|
|
patchMemoRequest := &PatchMemoRequest{
|
|
ID: memoID,
|
|
UpdatedTs: ¤tTs,
|
|
}
|
|
if err := json.NewDecoder(c.Request().Body).Decode(patchMemoRequest); err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch memo request").SetInternal(err)
|
|
}
|
|
|
|
if patchMemoRequest.Content != nil && len(*patchMemoRequest.Content) > maxContentLength {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "Content size overflow, up to 1MB").SetInternal(err)
|
|
}
|
|
|
|
updateMemoMessage := &store.UpdateMemo{
|
|
ID: memoID,
|
|
CreatedTs: patchMemoRequest.CreatedTs,
|
|
UpdatedTs: patchMemoRequest.UpdatedTs,
|
|
Content: patchMemoRequest.Content,
|
|
}
|
|
if patchMemoRequest.RowStatus != nil {
|
|
rowStatus := store.RowStatus(patchMemoRequest.RowStatus.String())
|
|
updateMemoMessage.RowStatus = &rowStatus
|
|
}
|
|
|
|
err = s.Store.UpdateMemo(ctx, updateMemoMessage)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch memo").SetInternal(err)
|
|
}
|
|
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: %d", memoID))
|
|
}
|
|
|
|
memoMessage, err := s.convertMemoFromStore(ctx, memo)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo").SetInternal(err)
|
|
}
|
|
if patchMemoRequest.ResourceIDList != nil {
|
|
originResourceIDList := []int32{}
|
|
for _, resource := range memoMessage.ResourceList {
|
|
originResourceIDList = append(originResourceIDList, resource.ID)
|
|
}
|
|
addedResourceIDList, removedResourceIDList := getIDListDiff(originResourceIDList, patchMemoRequest.ResourceIDList)
|
|
for _, resourceID := range addedResourceIDList {
|
|
if _, err := s.Store.UpdateResource(ctx, &store.UpdateResource{
|
|
ID: resourceID,
|
|
MemoID: &memo.ID,
|
|
}); err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err)
|
|
}
|
|
}
|
|
for _, resourceID := range removedResourceIDList {
|
|
if err := s.Store.DeleteResource(ctx, &store.DeleteResource{
|
|
ID: resourceID,
|
|
}); err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete resource").SetInternal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if patchMemoRequest.RelationList != nil {
|
|
patchMemoRelationList := make([]*MemoRelation, 0)
|
|
for _, memoRelation := range patchMemoRequest.RelationList {
|
|
patchMemoRelationList = append(patchMemoRelationList, &MemoRelation{
|
|
MemoID: memo.ID,
|
|
RelatedMemoID: memoRelation.RelatedMemoID,
|
|
Type: memoRelation.Type,
|
|
})
|
|
}
|
|
addedMemoRelationList, removedMemoRelationList := getMemoRelationListDiff(memoMessage.RelationList, patchMemoRelationList)
|
|
for _, memoRelation := range addedMemoRelationList {
|
|
if _, err := s.Store.UpsertMemoRelation(ctx, memoRelation); err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo relation").SetInternal(err)
|
|
}
|
|
}
|
|
for _, memoRelation := range removedMemoRelationList {
|
|
if err := s.Store.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{
|
|
MemoID: &memo.ID,
|
|
RelatedMemoID: &memoRelation.RelatedMemoID,
|
|
Type: &memoRelation.Type,
|
|
}); err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete memo relation").SetInternal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
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: %d", memoID))
|
|
}
|
|
|
|
memoResponse, err := s.convertMemoFromStore(ctx, memo)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err)
|
|
}
|
|
// Try to dispatch webhook when memo is updated.
|
|
if err := s.DispatchMemoUpdatedWebhook(ctx, memoResponse); err != nil {
|
|
slog.Error("Failed to dispatch memo updated webhook", err)
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, memoResponse)
|
|
}
|
|
|
|
func (s *APIV1Service) convertMemoFromStore(ctx context.Context, memo *store.Memo) (*Memo, error) {
|
|
memoMessage := &Memo{
|
|
ID: memo.ID,
|
|
Name: memo.UID,
|
|
RowStatus: RowStatus(memo.RowStatus.String()),
|
|
CreatorID: memo.CreatorID,
|
|
CreatedTs: memo.CreatedTs,
|
|
UpdatedTs: memo.UpdatedTs,
|
|
Content: memo.Content,
|
|
Visibility: Visibility(memo.Visibility.String()),
|
|
Pinned: memo.Pinned,
|
|
}
|
|
|
|
// Compose creator name.
|
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
ID: &memoMessage.CreatorID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if user.Nickname != "" {
|
|
memoMessage.CreatorName = user.Nickname
|
|
} else {
|
|
memoMessage.CreatorName = user.Username
|
|
}
|
|
memoMessage.CreatorUsername = user.Username
|
|
|
|
// Compose display ts.
|
|
memoMessage.DisplayTs = memoMessage.CreatedTs
|
|
|
|
// Compose related resources.
|
|
resourceList, err := s.Store.ListResources(ctx, &store.FindResource{
|
|
MemoID: &memo.ID,
|
|
})
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "failed to list resources")
|
|
}
|
|
memoMessage.ResourceList = []*Resource{}
|
|
for _, resource := range resourceList {
|
|
memoMessage.ResourceList = append(memoMessage.ResourceList, convertResourceFromStore(resource))
|
|
}
|
|
|
|
// Compose related memo relations.
|
|
relationList := []*MemoRelation{}
|
|
tempList, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{
|
|
MemoID: &memo.ID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, relation := range tempList {
|
|
relationList = append(relationList, convertMemoRelationFromStore(relation))
|
|
}
|
|
tempList, err = s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{
|
|
RelatedMemoID: &memo.ID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, relation := range tempList {
|
|
relationList = append(relationList, convertMemoRelationFromStore(relation))
|
|
}
|
|
memoMessage.RelationList = relationList
|
|
return memoMessage, nil
|
|
}
|
|
|
|
func convertCreateMemoRequestToMemoMessage(memoCreate *CreateMemoRequest) *store.Memo {
|
|
createdTs := time.Now().Unix()
|
|
if memoCreate.CreatedTs != nil {
|
|
createdTs = *memoCreate.CreatedTs
|
|
}
|
|
return &store.Memo{
|
|
UID: shortuuid.New(),
|
|
CreatorID: memoCreate.CreatorID,
|
|
CreatedTs: createdTs,
|
|
Content: memoCreate.Content,
|
|
Visibility: store.Visibility(memoCreate.Visibility),
|
|
}
|
|
}
|
|
|
|
func getMemoRelationListDiff(oldList, newList []*MemoRelation) (addedList, removedList []*store.MemoRelation) {
|
|
oldMap := map[string]bool{}
|
|
for _, relation := range oldList {
|
|
oldMap[fmt.Sprintf("%d-%s", relation.RelatedMemoID, relation.Type)] = true
|
|
}
|
|
newMap := map[string]bool{}
|
|
for _, relation := range newList {
|
|
newMap[fmt.Sprintf("%d-%s", relation.RelatedMemoID, relation.Type)] = true
|
|
}
|
|
for _, relation := range oldList {
|
|
key := fmt.Sprintf("%d-%s", relation.RelatedMemoID, relation.Type)
|
|
if !newMap[key] {
|
|
removedList = append(removedList, &store.MemoRelation{
|
|
MemoID: relation.MemoID,
|
|
RelatedMemoID: relation.RelatedMemoID,
|
|
Type: store.MemoRelationType(relation.Type),
|
|
})
|
|
}
|
|
}
|
|
for _, relation := range newList {
|
|
key := fmt.Sprintf("%d-%s", relation.RelatedMemoID, relation.Type)
|
|
if !oldMap[key] {
|
|
addedList = append(addedList, &store.MemoRelation{
|
|
MemoID: relation.MemoID,
|
|
RelatedMemoID: relation.RelatedMemoID,
|
|
Type: store.MemoRelationType(relation.Type),
|
|
})
|
|
}
|
|
}
|
|
return addedList, removedList
|
|
}
|
|
|
|
func getIDListDiff(oldList, newList []int32) (addedList, removedList []int32) {
|
|
oldMap := map[int32]bool{}
|
|
for _, id := range oldList {
|
|
oldMap[id] = true
|
|
}
|
|
newMap := map[int32]bool{}
|
|
for _, id := range newList {
|
|
newMap[id] = true
|
|
}
|
|
for id := range oldMap {
|
|
if !newMap[id] {
|
|
removedList = append(removedList, id)
|
|
}
|
|
}
|
|
for id := range newMap {
|
|
if !oldMap[id] {
|
|
addedList = append(addedList, id)
|
|
}
|
|
}
|
|
return addedList, removedList
|
|
}
|
|
|
|
// DispatchMemoCreatedWebhook dispatches webhook when memo is created.
|
|
func (s *APIV1Service) DispatchMemoCreatedWebhook(ctx context.Context, memo *Memo) error {
|
|
return s.dispatchMemoRelatedWebhook(ctx, memo, "memos.memo.created")
|
|
}
|
|
|
|
// DispatchMemoUpdatedWebhook dispatches webhook when memo is updated.
|
|
func (s *APIV1Service) DispatchMemoUpdatedWebhook(ctx context.Context, memo *Memo) error {
|
|
return s.dispatchMemoRelatedWebhook(ctx, memo, "memos.memo.updated")
|
|
}
|
|
|
|
// DispatchMemoDeletedWebhook dispatches webhook when memo is deletedd.
|
|
func (s *APIV1Service) DispatchMemoDeletedWebhook(ctx context.Context, memo *Memo) error {
|
|
return s.dispatchMemoRelatedWebhook(ctx, memo, "memos.memo.deleted")
|
|
}
|
|
|
|
func (s *APIV1Service) dispatchMemoRelatedWebhook(ctx context.Context, memo *Memo, activityType string) error {
|
|
webhooks, err := s.Store.ListWebhooks(ctx, &store.FindWebhook{
|
|
CreatorID: &memo.CreatorID,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, hook := range webhooks {
|
|
payload := convertMemoToWebhookPayload(memo)
|
|
payload.ActivityType = activityType
|
|
payload.URL = hook.Url
|
|
err := webhook.Post(*payload)
|
|
if err != nil {
|
|
return errors.Wrap(err, "failed to post webhook")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func convertMemoToWebhookPayload(memo *Memo) *webhook.WebhookPayload {
|
|
return &webhook.WebhookPayload{
|
|
CreatorID: memo.CreatorID,
|
|
CreatedTs: time.Now().Unix(),
|
|
Memo: &webhook.Memo{
|
|
ID: memo.ID,
|
|
CreatorID: memo.CreatorID,
|
|
CreatedTs: memo.CreatedTs,
|
|
UpdatedTs: memo.UpdatedTs,
|
|
Content: memo.Content,
|
|
Visibility: memo.Visibility.String(),
|
|
Pinned: memo.Pinned,
|
|
ResourceList: func() []*webhook.Resource {
|
|
resources := []*webhook.Resource{}
|
|
for _, resource := range memo.ResourceList {
|
|
resources = append(resources, &webhook.Resource{
|
|
ID: resource.ID,
|
|
CreatorID: resource.CreatorID,
|
|
CreatedTs: resource.CreatedTs,
|
|
UpdatedTs: resource.UpdatedTs,
|
|
Filename: resource.Filename,
|
|
InternalPath: resource.InternalPath,
|
|
ExternalLink: resource.ExternalLink,
|
|
Type: resource.Type,
|
|
Size: resource.Size,
|
|
})
|
|
}
|
|
return resources
|
|
}(),
|
|
RelationList: func() []*webhook.MemoRelation {
|
|
relations := []*webhook.MemoRelation{}
|
|
for _, relation := range memo.RelationList {
|
|
relations = append(relations, &webhook.MemoRelation{
|
|
MemoID: relation.MemoID,
|
|
RelatedMemoID: relation.RelatedMemoID,
|
|
Type: relation.Type.String(),
|
|
})
|
|
}
|
|
return relations
|
|
}(),
|
|
},
|
|
}
|
|
}
|