123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065 |
- package v1
- import (
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "strconv"
- "time"
- "github.com/labstack/echo/v4"
- "github.com/lithammer/shortuuid/v4"
- "github.com/pkg/errors"
- "go.uber.org/zap"
- "github.com/usememos/memos/internal/log"
- "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
- }
- memoDisplayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx)
- if err != nil {
- return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get memo display with updated ts setting value").SetInternal(err)
- }
- if memoDisplayWithUpdatedTs {
- find.OrderByUpdatedTs = true
- }
- 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
- }
- }
- // Find disable public memos system setting.
- disablePublicMemosSystemSetting, err := s.Store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{
- Name: SystemSettingDisablePublicMemosName.String(),
- })
- if err != nil {
- return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting").SetInternal(err)
- }
- if disablePublicMemosSystemSetting != nil {
- disablePublicMemos := false
- err = json.Unmarshal([]byte(disablePublicMemosSystemSetting.Value), &disablePublicMemos)
- if err != nil {
- return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting").SetInternal(err)
- }
- if disablePublicMemos {
- 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 {
- return echo.NewHTTPError(http.StatusNotFound, "User not found")
- }
- // Enforce normal user to create private memo if public memos are disabled.
- if user.Role == store.RoleUser {
- 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 {
- log.Error("failed to parse Telegram UserID", zap.Error(err))
- continue
- }
- // send notification to telegram
- content := memoResponse.CreatorName + " Says:\n\n" + memoResponse.Content
- _, err = s.telegramBot.SendMessage(ctx, tgUserID, content)
- if err != nil {
- log.Error("Failed to send Telegram notification", zap.Error(err))
- continue
- }
- }
- }
- // Try to dispatch webhook when memo is created.
- if err := s.DispatchMemoCreatedWebhook(ctx, memoResponse); err != nil {
- log.Warn("Failed to dispatch memo created webhook", zap.Error(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
- memoDisplayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx)
- if err != nil {
- return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get memo display with updated ts setting value").SetInternal(err)
- }
- if memoDisplayWithUpdatedTs {
- memoFind.OrderByUpdatedTs = true
- }
- 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}
- }
- }
- memoDisplayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx)
- if err != nil {
- return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get memo display with updated ts setting value").SetInternal(err)
- }
- if memoDisplayWithUpdatedTs {
- findMemoMessage.OrderByUpdatedTs = true
- }
- list, err := s.Store.ListMemos(ctx, findMemoMessage)
- if err != nil {
- return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
- }
- displayTsList := []int64{}
- if memoDisplayWithUpdatedTs {
- for _, memo := range list {
- displayTsList = append(displayTsList, memo.UpdatedTs)
- }
- } else {
- 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 {
- log.Warn("Failed to dispatch memo deleted webhook", zap.Error(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
- }
- if patchMemoRequest.Visibility != nil {
- visibility := store.Visibility(patchMemoRequest.Visibility.String())
- updateMemoMessage.Visibility = &visibility
- // Find disable public memos system setting.
- disablePublicMemosSystemSetting, err := s.Store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{
- Name: SystemSettingDisablePublicMemosName.String(),
- })
- if err != nil {
- return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting").SetInternal(err)
- }
- if disablePublicMemosSystemSetting != nil {
- disablePublicMemos := false
- err = json.Unmarshal([]byte(disablePublicMemosSystemSetting.Value), &disablePublicMemos)
- if err != nil {
- return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting").SetInternal(err)
- }
- if disablePublicMemos {
- 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 {
- return echo.NewHTTPError(http.StatusNotFound, "User not found")
- }
- // Enforce normal user to save as private memo if public memos are disabled.
- if user.Role == store.RoleUser {
- visibility = store.Visibility("PRIVATE")
- updateMemoMessage.Visibility = &visibility
- }
- }
- }
- }
- 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 {
- log.Warn("Failed to dispatch memo updated webhook", zap.Error(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.ResourceName,
- 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
- // Find memo display with updated ts setting.
- memoDisplayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx)
- if err != nil {
- return nil, err
- }
- if memoDisplayWithUpdatedTs {
- memoMessage.DisplayTs = memoMessage.UpdatedTs
- }
- // 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 (s *APIV1Service) getMemoDisplayWithUpdatedTsSettingValue(ctx context.Context) (bool, error) {
- memoDisplayWithUpdatedTsSetting, err := s.Store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{
- Name: SystemSettingMemoDisplayWithUpdatedTsName.String(),
- })
- if err != nil {
- return false, errors.Wrap(err, "failed to find system setting")
- }
- memoDisplayWithUpdatedTs := false
- if memoDisplayWithUpdatedTsSetting != nil {
- err = json.Unmarshal([]byte(memoDisplayWithUpdatedTsSetting.Value), &memoDisplayWithUpdatedTs)
- if err != nil {
- return false, errors.Wrap(err, "failed to unmarshal system setting value")
- }
- }
- return memoDisplayWithUpdatedTs, nil
- }
- func convertCreateMemoRequestToMemoMessage(memoCreate *CreateMemoRequest) *store.Memo {
- createdTs := time.Now().Unix()
- if memoCreate.CreatedTs != nil {
- createdTs = *memoCreate.CreatedTs
- }
- return &store.Memo{
- ResourceName: 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
- }(),
- },
- }
- }
|