refactor: migrate resource to apiv1 (#1901)

This commit is contained in:
boojack
2023-07-06 00:01:40 +08:00
committed by GitHub
parent 5ea561af3d
commit 1fa9f162a5
15 changed files with 462 additions and 382 deletions

View File

@ -20,51 +20,3 @@ type Resource struct {
// Related fields
LinkedMemoAmount int `json:"linkedMemoAmount"`
}
type ResourceCreate struct {
// Standard fields
CreatorID int `json:"-"`
// Domain specific fields
Filename string `json:"filename"`
Blob []byte `json:"-"`
InternalPath string `json:"internalPath"`
ExternalLink string `json:"externalLink"`
Type string `json:"type"`
Size int64 `json:"-"`
PublicID string `json:"publicId"`
DownloadToLocal bool `json:"downloadToLocal"`
}
type ResourceFind struct {
ID *int `json:"id"`
// Standard fields
CreatorID *int `json:"creatorId"`
// Domain specific fields
Filename *string `json:"filename"`
MemoID *int
PublicID *string `json:"publicId"`
GetBlob bool
// Pagination
Limit *int
Offset *int
}
type ResourcePatch struct {
ID int `json:"-"`
// Standard fields
UpdatedTs *int64
// Domain specific fields
Filename *string `json:"filename"`
ResetPublicID *bool `json:"resetPublicId"`
PublicID *string `json:"-"`
}
type ResourceDelete struct {
ID int
}

24
api/v1/memo_resource.go Normal file
View File

@ -0,0 +1,24 @@
package v1
type MemoResource struct {
MemoID int
ResourceID int
CreatedTs int64
UpdatedTs int64
}
type MemoResourceUpsert struct {
MemoID int `json:"-"`
ResourceID int
UpdatedTs *int64
}
type MemoResourceFind struct {
MemoID *int
ResourceID *int
}
type MemoResourceDelete struct {
MemoID *int
ResourceID *int
}

View File

@ -1,4 +1,4 @@
package server
package v1
import (
"bytes"
@ -21,8 +21,6 @@ import (
"github.com/disintegration/imaging"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"github.com/usememos/memos/api"
apiv1 "github.com/usememos/memos/api/v1"
"github.com/usememos/memos/common"
"github.com/usememos/memos/common/log"
"github.com/usememos/memos/plugin/storage/s3"
@ -30,6 +28,48 @@ import (
"go.uber.org/zap"
)
type Resource struct {
ID int `json:"id"`
// Standard fields
CreatorID int `json:"creatorId"`
CreatedTs int64 `json:"createdTs"`
UpdatedTs int64 `json:"updatedTs"`
// Domain specific fields
Filename string `json:"filename"`
Blob []byte `json:"-"`
InternalPath string `json:"-"`
ExternalLink string `json:"externalLink"`
Type string `json:"type"`
Size int64 `json:"size"`
PublicID string `json:"publicId"`
// Related fields
LinkedMemoAmount int `json:"linkedMemoAmount"`
}
type CreateResourceRequest struct {
Filename string `json:"filename"`
InternalPath string `json:"internalPath"`
ExternalLink string `json:"externalLink"`
Type string `json:"type"`
PublicID string `json:"publicId"`
DownloadToLocal bool `json:"downloadToLocal"`
}
type FindResourceRequest struct {
ID *int `json:"id"`
CreatorID *int `json:"creatorId"`
Filename *string `json:"filename"`
PublicID *string `json:"publicId"`
}
type UpdateResourceRequest struct {
Filename *string `json:"filename"`
ResetPublicID *bool `json:"resetPublicId"`
}
const (
// The upload memory buffer is 32 MiB.
// It should be kept low, so RAM usage doesn't get out of control.
@ -43,7 +83,7 @@ const (
var fileKeyPattern = regexp.MustCompile(`\{[a-z]{1,9}\}`)
func (s *Server) registerResourceRoutes(g *echo.Group) {
func (s *APIV1Service) registerResourceRoutes(g *echo.Group) {
g.POST("/resource", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
@ -51,15 +91,21 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
resourceCreate := &api.ResourceCreate{}
if err := json.NewDecoder(c.Request().Body).Decode(resourceCreate); err != nil {
request := &CreateResourceRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post resource request").SetInternal(err)
}
resourceCreate.CreatorID = userID
if resourceCreate.ExternalLink != "" {
create := &store.Resource{
CreatorID: userID,
Filename: request.Filename,
ExternalLink: request.ExternalLink,
Type: request.Type,
PublicID: common.GenUUID(),
}
if request.ExternalLink != "" {
// Only allow those external links scheme with http/https
linkURL, err := url.Parse(resourceCreate.ExternalLink)
linkURL, err := url.Parse(request.ExternalLink)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid external link").SetInternal(err)
}
@ -67,24 +113,24 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid external link scheme")
}
if resourceCreate.DownloadToLocal {
if request.DownloadToLocal {
resp, err := http.Get(linkURL.String())
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Failed to request "+resourceCreate.ExternalLink)
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to request %s", request.ExternalLink))
}
defer resp.Body.Close()
blob, err := io.ReadAll(resp.Body)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Failed to read "+resourceCreate.ExternalLink)
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to read %s", request.ExternalLink))
}
resourceCreate.Blob = blob
create.Blob = blob
mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Failed to read mime from "+resourceCreate.ExternalLink)
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to read mime from %s", request.ExternalLink))
}
resourceCreate.Type = mediaType
create.Type = mediaType
filename := path.Base(linkURL.Path)
if path.Ext(filename) == "" {
@ -93,20 +139,19 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
filename += extensions[0]
}
}
resourceCreate.Filename = filename
resourceCreate.PublicID = common.GenUUID()
resourceCreate.ExternalLink = ""
create.Filename = filename
create.ExternalLink = ""
}
}
resource, err := s.Store.CreateResource(ctx, resourceCreate)
resource, err := s.Store.CreateResourceV1(ctx, create)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err)
}
if err := s.createResourceCreateActivity(ctx, resource); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
}
return c.JSON(http.StatusOK, composeResponse(resource))
return c.JSON(http.StatusOK, convertResourceFromStore(resource))
})
g.POST("/resource/blob", func(c echo.Context) error {
@ -117,7 +162,7 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
}
// This is the backend default max upload size limit.
maxUploadSetting := s.Store.GetSystemSettingValueWithDefault(&ctx, apiv1.SystemSettingMaxUploadSizeMiBName.String(), "32")
maxUploadSetting := s.Store.GetSystemSettingValueWithDefault(&ctx, SystemSettingMaxUploadSizeMiBName.String(), "32")
var settingMaxUploadSizeBytes int
if settingMaxUploadSizeMiB, err := strconv.Atoi(maxUploadSetting); err == nil {
settingMaxUploadSizeBytes = settingMaxUploadSizeMiB * MebiByte
@ -150,12 +195,12 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
}
defer sourceFile.Close()
var resourceCreate *api.ResourceCreate
systemSettingStorageServiceID, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{Name: apiv1.SystemSettingStorageServiceIDName.String()})
create := &store.Resource{}
systemSettingStorageServiceID, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{Name: SystemSettingStorageServiceIDName.String()})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find storage").SetInternal(err)
}
storageServiceID := apiv1.DatabaseStorage
storageServiceID := DatabaseStorage
if systemSettingStorageServiceID != nil {
err = json.Unmarshal([]byte(systemSettingStorageServiceID.Value), &storageServiceID)
if err != nil {
@ -164,23 +209,23 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
}
publicID := common.GenUUID()
if storageServiceID == apiv1.DatabaseStorage {
if storageServiceID == DatabaseStorage {
fileBytes, err := io.ReadAll(sourceFile)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to read file").SetInternal(err)
}
resourceCreate = &api.ResourceCreate{
create = &store.Resource{
CreatorID: userID,
Filename: file.Filename,
Type: filetype,
Size: size,
Blob: fileBytes,
}
} else if storageServiceID == apiv1.LocalStorage {
} else if storageServiceID == LocalStorage {
// filepath.Join() should be used for local file paths,
// as it handles the os-specific path separator automatically.
// path.Join() always uses '/' as path separator.
systemSettingLocalStoragePath, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{Name: apiv1.SystemSettingLocalStoragePathName.String()})
systemSettingLocalStoragePath, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{Name: SystemSettingLocalStoragePathName.String()})
if err != nil && common.ErrorCode(err) != common.NotFound {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find local storage path setting").SetInternal(err)
}
@ -211,7 +256,7 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to copy file").SetInternal(err)
}
resourceCreate = &api.ResourceCreate{
create = &store.Resource{
CreatorID: userID,
Filename: file.Filename,
Type: filetype,
@ -223,12 +268,12 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find storage").SetInternal(err)
}
storageMessage, err := apiv1.ConvertStorageFromStore(storage)
storageMessage, err := ConvertStorageFromStore(storage)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to convert storage").SetInternal(err)
}
if storageMessage.Type == apiv1.StorageS3 {
if storageMessage.Type == StorageS3 {
s3Config := storageMessage.Config.S3Config
s3Client, err := s3.NewClient(ctx, &s3.Config{
AccessKey: s3Config.AccessKey,
@ -253,7 +298,7 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upload via s3 client").SetInternal(err)
}
resourceCreate = &api.ResourceCreate{
create = &store.Resource{
CreatorID: userID,
Filename: filename,
Type: filetype,
@ -265,15 +310,15 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
}
}
resourceCreate.PublicID = publicID
resource, err := s.Store.CreateResource(ctx, resourceCreate)
create.PublicID = publicID
resource, err := s.Store.CreateResourceV1(ctx, create)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err)
}
if err := s.createResourceCreateActivity(ctx, resource); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
}
return c.JSON(http.StatusOK, composeResponse(resource))
return c.JSON(http.StatusOK, convertResourceFromStore(resource))
})
g.GET("/resource", func(c echo.Context) error {
@ -282,21 +327,25 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
resourceFind := &api.ResourceFind{
find := &store.FindResource{
CreatorID: &userID,
}
if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
resourceFind.Limit = &limit
find.Limit = &limit
}
if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil {
resourceFind.Offset = &offset
find.Offset = &offset
}
list, err := s.Store.FindResourceList(ctx, resourceFind)
list, err := s.Store.ListResources(ctx, find)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
}
return c.JSON(http.StatusOK, composeResponse(list))
resourceMessageList := []*Resource{}
for _, resource := range list {
resourceMessageList = append(resourceMessageList, convertResourceFromStore(resource))
}
return c.JSON(http.StatusOK, resourceMessageList)
})
g.PATCH("/resource/:resourceId", func(c echo.Context) error {
@ -311,10 +360,9 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
resourceFind := &api.ResourceFind{
resource, err := s.Store.GetResource(ctx, &store.FindResource{
ID: &resourceID,
}
resource, err := s.Store.FindResource(ctx, resourceFind)
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find resource").SetInternal(err)
}
@ -322,25 +370,29 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
currentTs := time.Now().Unix()
resourcePatch := &api.ResourcePatch{
UpdatedTs: &currentTs,
}
if err := json.NewDecoder(c.Request().Body).Decode(resourcePatch); err != nil {
request := &UpdateResourceRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch resource request").SetInternal(err)
}
if resourcePatch.ResetPublicID != nil && *resourcePatch.ResetPublicID {
currentTs := time.Now().Unix()
update := &store.UpdateResource{
ID: resourceID,
UpdatedTs: &currentTs,
}
if request.Filename != nil && *request.Filename != "" {
update.Filename = request.Filename
}
if request.ResetPublicID != nil && *request.ResetPublicID {
publicID := common.GenUUID()
resourcePatch.PublicID = &publicID
update.PublicID = &publicID
}
resourcePatch.ID = resourceID
resource, err = s.Store.PatchResource(ctx, resourcePatch)
resource, err = s.Store.UpdateResource(ctx, update)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch resource").SetInternal(err)
}
return c.JSON(http.StatusOK, composeResponse(resource))
return c.JSON(http.StatusOK, convertResourceFromStore(resource))
})
g.DELETE("/resource/:resourceId", func(c echo.Context) error {
@ -355,15 +407,15 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
resource, err := s.Store.FindResource(ctx, &api.ResourceFind{
resource, err := s.Store.GetResource(ctx, &store.FindResource{
ID: &resourceID,
CreatorID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find resource").SetInternal(err)
}
if resource.CreatorID != userID {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
if resource == nil {
return echo.NewHTTPError(http.StatusNotFound, "Resource not found")
}
if resource.InternalPath != "" {
@ -378,20 +430,16 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
log.Warn(fmt.Sprintf("failed to delete local thumbnail with path %s", thumbnailPath), zap.Error(err))
}
resourceDelete := &api.ResourceDelete{
if err := s.Store.DeleteResourceV1(ctx, &store.DeleteResource{
ID: resourceID,
}
if err := s.Store.DeleteResource(ctx, resourceDelete); err != nil {
if common.ErrorCode(err) == common.NotFound {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource ID not found: %d", resourceID))
}
}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete resource").SetInternal(err)
}
return c.JSON(http.StatusOK, true)
})
}
func (s *Server) registerResourcePublicRoutes(g *echo.Group) {
func (s *APIV1Service) registerResourcePublicRoutes(g *echo.Group) {
f := func(c echo.Context) error {
ctx := c.Request().Context()
resourceID, err := strconv.Atoi(c.Param("resourceId"))
@ -399,7 +447,7 @@ func (s *Server) registerResourcePublicRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
resourceVisibility, err := CheckResourceVisibility(ctx, s.Store, resourceID)
resourceVisibility, err := checkResourceVisibility(ctx, s.Store, resourceID)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Failed to get resource visibility").SetInternal(err)
}
@ -410,11 +458,10 @@ func (s *Server) registerResourcePublicRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusUnauthorized, "Resource visibility not match").SetInternal(err)
}
resourceFind := &api.ResourceFind{
resource, err := s.Store.GetResource(ctx, &store.FindResource{
ID: &resourceID,
GetBlob: true,
}
resource, err := s.Store.FindResource(ctx, resourceFind)
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find resource by ID: %v", resourceID)).SetInternal(err)
}
@ -465,8 +512,8 @@ func (s *Server) registerResourcePublicRoutes(g *echo.Group) {
g.GET("/r/:resourceId/*", f)
}
func (s *Server) createResourceCreateActivity(ctx context.Context, resource *api.Resource) error {
payload := apiv1.ActivityResourceCreatePayload{
func (s *APIV1Service) createResourceCreateActivity(ctx context.Context, resource *store.Resource) error {
payload := ActivityResourceCreatePayload{
Filename: resource.Filename,
Type: resource.Type,
Size: resource.Size,
@ -477,8 +524,8 @@ func (s *Server) createResourceCreateActivity(ctx context.Context, resource *api
}
activity, err := s.Store.CreateActivity(ctx, &store.ActivityMessage{
CreatorID: resource.CreatorID,
Type: apiv1.ActivityResourceCreate.String(),
Level: apiv1.ActivityInfo.String(),
Type: ActivityResourceCreate.String(),
Level: ActivityInfo.String(),
Payload: string(payloadBytes),
})
if err != nil || activity == nil {
@ -560,12 +607,10 @@ func getOrGenerateThumbnailImage(srcBlob []byte, dstPath string) ([]byte, error)
return dstBlob, nil
}
func CheckResourceVisibility(ctx context.Context, s *store.Store, resourceID int) (store.Visibility, error) {
memoResourceFind := &api.MemoResourceFind{
func checkResourceVisibility(ctx context.Context, s *store.Store, resourceID int) (store.Visibility, error) {
memoResources, err := s.ListMemoResources(ctx, &store.FindMemoResource{
ResourceID: &resourceID,
}
memoResources, err := s.FindMemoResourceList(ctx, memoResourceFind)
})
if err != nil {
return store.Private, err
}
@ -604,3 +649,20 @@ func CheckResourceVisibility(ctx context.Context, s *store.Store, resourceID int
// If all memo is PRIVATE, the resource do
return store.Private, nil
}
func convertResourceFromStore(resource *store.Resource) *Resource {
return &Resource{
ID: resource.ID,
CreatorID: resource.CreatorID,
CreatedTs: resource.CreatedTs,
UpdatedTs: resource.UpdatedTs,
Filename: resource.Filename,
Blob: resource.Blob,
InternalPath: resource.InternalPath,
ExternalLink: resource.ExternalLink,
Type: resource.Type,
Size: resource.Size,
PublicID: resource.PublicID,
LinkedMemoAmount: resource.LinkedMemoAmount,
}
}

View File

@ -34,4 +34,11 @@ func (s *APIV1Service) Register(rootGroup *echo.Group) {
s.registerTagRoutes(apiV1Group)
s.registerShortcutRoutes(apiV1Group)
s.registerStorageRoutes(apiV1Group)
s.registerResourceRoutes(apiV1Group)
publicGroup := rootGroup.Group("/o")
publicGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return JWTMiddleware(s, next, s.Secret)
})
s.registerResourcePublicRoutes(publicGroup)
}

View File

@ -685,13 +685,15 @@ func (s *Server) composeMemoMessageToMemoResponse(ctx context.Context, memoMessa
resourceList := []*api.Resource{}
for _, resourceID := range memoMessage.ResourceIDList {
resource, err := s.Store.FindResource(ctx, &api.ResourceFind{
resource, err := s.Store.GetResource(ctx, &store.FindResource{
ID: &resourceID,
})
if err != nil {
return nil, err
}
resourceList = append(resourceList, resource)
if resource != nil {
resourceList = append(resourceList, convertResourceFromStore(resource))
}
}
memoResponse.ResourceList = resourceList
@ -714,3 +716,20 @@ func (s *Server) getMemoDisplayWithUpdatedTsSettingValue(ctx context.Context) (b
}
return memoDisplayWithUpdatedTs, nil
}
func convertResourceFromStore(resource *store.Resource) *api.Resource {
return &api.Resource{
ID: resource.ID,
CreatorID: resource.CreatorID,
CreatedTs: resource.CreatedTs,
UpdatedTs: resource.UpdatedTs,
Filename: resource.Filename,
Blob: resource.Blob,
InternalPath: resource.InternalPath,
ExternalLink: resource.ExternalLink,
Type: resource.Type,
Size: resource.Size,
PublicID: resource.PublicID,
LinkedMemoAmount: resource.LinkedMemoAmount,
}
}

View File

@ -29,10 +29,9 @@ func (s *Server) registerMemoResourceRoutes(g *echo.Group) {
if err := json.NewDecoder(c.Request().Body).Decode(memoResourceUpsert); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo resource request").SetInternal(err)
}
resourceFind := &api.ResourceFind{
resource, err := s.Store.GetResource(ctx, &store.FindResource{
ID: &memoResourceUpsert.ResourceID,
}
resource, err := s.Store.FindResource(ctx, resourceFind)
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource").SetInternal(err)
}
@ -48,7 +47,7 @@ func (s *Server) registerMemoResourceRoutes(g *echo.Group) {
if _, err := s.Store.UpsertMemoResource(ctx, memoResourceUpsert); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err)
}
return c.JSON(http.StatusOK, composeResponse(resource))
return c.JSON(http.StatusOK, true)
})
g.GET("/memo/:memoId/resource", func(c echo.Context) error {
@ -58,13 +57,16 @@ func (s *Server) registerMemoResourceRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
resourceFind := &api.ResourceFind{
list, err := s.Store.ListResources(ctx, &store.FindResource{
MemoID: &memoID,
}
resourceList, err := s.Store.FindResourceList(ctx, resourceFind)
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
}
resourceList := []*api.Resource{}
for _, resource := range list {
resourceList = append(resourceList, convertResourceFromStore(resource))
}
return c.JSON(http.StatusOK, composeResponse(resourceList))
})

View File

@ -11,7 +11,6 @@ import (
"github.com/gorilla/feeds"
"github.com/labstack/echo/v4"
"github.com/usememos/memos/api"
apiv1 "github.com/usememos/memos/api/v1"
"github.com/usememos/memos/common"
"github.com/usememos/memos/store"
@ -102,7 +101,7 @@ func (s *Server) generateRSSFromMemoList(ctx context.Context, memoList []*store.
}
if len(memo.ResourceIDList) > 0 {
resourceID := memo.ResourceIDList[0]
resource, err := s.Store.FindResource(ctx, &api.ResourceFind{
resource, err := s.Store.GetResource(ctx, &store.FindResource{
ID: &resourceID,
})
if err != nil {

View File

@ -92,7 +92,6 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store
return JWTMiddleware(s, next, s.Secret)
})
registerGetterPublicRoutes(publicGroup)
s.registerResourcePublicRoutes(publicGroup)
apiGroup := e.Group("/api")
apiGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
@ -100,7 +99,6 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store
})
s.registerMemoRoutes(apiGroup)
s.registerMemoResourceRoutes(apiGroup)
s.registerResourceRoutes(apiGroup)
s.registerMemoRelationRoutes(apiGroup)
apiV1Service := apiv1.NewAPIV1Service(s.Secret, profile, store)

View File

@ -90,15 +90,14 @@ func (t *telegramHandler) MessageHandle(ctx context.Context, bot *telegram.Bot,
case ".png":
mime = "image/png"
}
resourceCreate := api.ResourceCreate{
resource, err := t.store.CreateResourceV1(ctx, &store.Resource{
CreatorID: creatorID,
Filename: filename,
Type: mime,
Size: int64(len(blob)),
Blob: blob,
PublicID: common.GenUUID(),
}
resource, err := t.store.CreateResource(ctx, &resourceCreate)
})
if err != nil {
_, err := bot.EditMessage(ctx, message.Chat.ID, reply.MessageID, fmt.Sprintf("failed to CreateResource: %s", err), nil)
return err

View File

@ -10,6 +10,85 @@ import (
"github.com/usememos/memos/common"
)
type MemoResource struct {
MemoID int
ResourceID int
CreatedTs int64
UpdatedTs int64
}
type FindMemoResource struct {
MemoID *int
ResourceID *int
}
func (s *Store) ListMemoResources(ctx context.Context, find *FindMemoResource) ([]*MemoResource, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
list, err := listMemoResources(ctx, tx, find)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return list, nil
}
func listMemoResources(ctx context.Context, tx *sql.Tx, find *FindMemoResource) ([]*MemoResource, error) {
where, args := []string{"1 = 1"}, []any{}
if v := find.MemoID; v != nil {
where, args = append(where, "memo_id = ?"), append(args, *v)
}
if v := find.ResourceID; v != nil {
where, args = append(where, "resource_id = ?"), append(args, *v)
}
query := `
SELECT
memo_id,
resource_id,
created_ts,
updated_ts
FROM memo_resource
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY updated_ts DESC
`
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, FormatError(err)
}
defer rows.Close()
list := make([]*MemoResource, 0)
for rows.Next() {
var memoResource MemoResource
if err := rows.Scan(
&memoResource.MemoID,
&memoResource.ResourceID,
&memoResource.CreatedTs,
&memoResource.UpdatedTs,
); err != nil {
return nil, FormatError(err)
}
list = append(list, &memoResource)
}
if err := rows.Err(); err != nil {
return nil, err
}
return list, nil
}
// memoResourceRaw is the store model for an MemoResource.
// Fields have exactly the same meanings as MemoResource.
type memoResourceRaw struct {

View File

@ -5,14 +5,9 @@ import (
"database/sql"
"fmt"
"strings"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
)
// resourceRaw is the store model for an Resource.
// Fields have exactly the same meanings as Resource.
type resourceRaw struct {
type Resource struct {
ID int
// Standard fields
@ -31,217 +26,177 @@ type resourceRaw struct {
LinkedMemoAmount int
}
func (raw *resourceRaw) toResource() *api.Resource {
return &api.Resource{
ID: raw.ID,
// Standard fields
CreatorID: raw.CreatorID,
CreatedTs: raw.CreatedTs,
UpdatedTs: raw.UpdatedTs,
// Domain specific fields
Filename: raw.Filename,
Blob: raw.Blob,
InternalPath: raw.InternalPath,
ExternalLink: raw.ExternalLink,
Type: raw.Type,
Size: raw.Size,
PublicID: raw.PublicID,
LinkedMemoAmount: raw.LinkedMemoAmount,
}
type FindResource struct {
GetBlob bool
ID *int
CreatorID *int
Filename *string
MemoID *int
PublicID *string
Limit *int
Offset *int
}
func (s *Store) CreateResource(ctx context.Context, create *api.ResourceCreate) (*api.Resource, error) {
type UpdateResource struct {
ID int
UpdatedTs *int64
Filename *string
PublicID *string
}
type DeleteResource struct {
ID int
}
func (s *Store) CreateResourceV1(ctx context.Context, create *Resource) (*Resource, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
resourceRaw, err := createResourceImpl(ctx, tx, create)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, FormatError(err)
}
resource := resourceRaw.toResource()
return resource, nil
}
func (s *Store) PatchResource(ctx context.Context, patch *api.ResourcePatch) (*api.Resource, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
resourceRaw, err := patchResourceImpl(ctx, tx, patch)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, FormatError(err)
}
s.resourceCache.Store(resourceRaw.ID, resourceRaw)
resource := resourceRaw.toResource()
return resource, nil
}
func (s *Store) FindResourceList(ctx context.Context, find *api.ResourceFind) ([]*api.Resource, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
resourceRawList, err := findResourceListImpl(ctx, tx, find)
if err != nil {
return nil, err
}
resourceList := []*api.Resource{}
for _, raw := range resourceRawList {
if !find.GetBlob {
s.resourceCache.Store(raw.ID, raw)
}
resourceList = append(resourceList, raw.toResource())
}
return resourceList, nil
}
func (s *Store) FindResource(ctx context.Context, find *api.ResourceFind) (*api.Resource, error) {
if !find.GetBlob && find.ID != nil {
if raw, ok := s.resourceCache.Load(find.ID); ok {
return raw.(*resourceRaw).toResource(), nil
}
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
list, err := findResourceListImpl(ctx, tx, find)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
resourceRaw := list[0]
if !find.GetBlob {
s.resourceCache.Store(resourceRaw.ID, resourceRaw)
}
resource := resourceRaw.toResource()
return resource, nil
}
func (s *Store) DeleteResource(ctx context.Context, delete *api.ResourceDelete) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return FormatError(err)
}
defer tx.Rollback()
if err := deleteResource(ctx, tx, delete); err != nil {
return err
}
if err := s.vacuumImpl(ctx, tx); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return FormatError(err)
}
s.resourceCache.Delete(delete.ID)
return nil
}
func createResourceImpl(ctx context.Context, tx *sql.Tx, create *api.ResourceCreate) (*resourceRaw, error) {
fields := []string{"filename", "blob", "external_link", "type", "size", "creator_id", "internal_path", "public_id"}
values := []any{create.Filename, create.Blob, create.ExternalLink, create.Type, create.Size, create.CreatorID, create.InternalPath, create.PublicID}
placeholders := []string{"?", "?", "?", "?", "?", "?", "?", "?"}
query := `
if err := tx.QueryRowContext(ctx, `
INSERT INTO resource (
` + strings.Join(fields, ",") + `
filename,
blob,
external_link,
type,
size,
creator_id,
internal_path,
public_id
)
VALUES (` + strings.Join(placeholders, ",") + `)
RETURNING id, ` + strings.Join(fields, ",") + `, created_ts, updated_ts
`
var resourceRaw resourceRaw
dests := []any{
&resourceRaw.ID,
&resourceRaw.Filename,
&resourceRaw.Blob,
&resourceRaw.ExternalLink,
&resourceRaw.Type,
&resourceRaw.Size,
&resourceRaw.CreatorID,
&resourceRaw.InternalPath,
&resourceRaw.PublicID,
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id, created_ts, updated_ts
`,
create.Filename, create.Blob, create.ExternalLink, create.Type, create.Size, create.CreatorID, create.InternalPath, create.PublicID,
).Scan(&create.ID, &create.CreatedTs, &create.UpdatedTs); err != nil {
return nil, err
}
dests = append(dests, []any{&resourceRaw.CreatedTs, &resourceRaw.UpdatedTs}...)
if err := tx.QueryRowContext(ctx, query, values...).Scan(dests...); err != nil {
if err := tx.Commit(); err != nil {
return nil, err
}
resource := create
return resource, nil
}
func (s *Store) ListResources(ctx context.Context, find *FindResource) ([]*Resource, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
return &resourceRaw, nil
resources, err := listResources(ctx, tx, find)
if err != nil {
return nil, err
}
func patchResourceImpl(ctx context.Context, tx *sql.Tx, patch *api.ResourcePatch) (*resourceRaw, error) {
if err := tx.Commit(); err != nil {
return nil, err
}
return resources, nil
}
func (s *Store) GetResource(ctx context.Context, find *FindResource) (*Resource, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
resources, err := listResources(ctx, tx, find)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
if len(resources) == 0 {
return nil, nil
}
return resources[0], nil
}
func (s *Store) UpdateResource(ctx context.Context, update *UpdateResource) (*Resource, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
set, args := []string{}, []any{}
if v := patch.UpdatedTs; v != nil {
if v := update.UpdatedTs; v != nil {
set, args = append(set, "updated_ts = ?"), append(args, *v)
}
if v := patch.Filename; v != nil {
if v := update.Filename; v != nil {
set, args = append(set, "filename = ?"), append(args, *v)
}
if v := patch.PublicID; v != nil {
if v := update.PublicID; v != nil {
set, args = append(set, "public_id = ?"), append(args, *v)
}
args = append(args, patch.ID)
args = append(args, update.ID)
fields := []string{"id", "filename", "external_link", "type", "size", "creator_id", "created_ts", "updated_ts", "internal_path", "public_id"}
query := `
UPDATE resource
SET ` + strings.Join(set, ", ") + `
WHERE id = ?
RETURNING ` + strings.Join(fields, ", ")
var resourceRaw resourceRaw
resource := Resource{}
dests := []any{
&resourceRaw.ID,
&resourceRaw.Filename,
&resourceRaw.ExternalLink,
&resourceRaw.Type,
&resourceRaw.Size,
&resourceRaw.CreatorID,
&resourceRaw.CreatedTs,
&resourceRaw.UpdatedTs,
&resourceRaw.InternalPath,
&resourceRaw.PublicID,
&resource.ID,
&resource.Filename,
&resource.ExternalLink,
&resource.Type,
&resource.Size,
&resource.CreatorID,
&resource.CreatedTs,
&resource.UpdatedTs,
&resource.InternalPath,
&resource.PublicID,
}
if err := tx.QueryRowContext(ctx, query, args...).Scan(dests...); err != nil {
return nil, FormatError(err)
}
return &resourceRaw, nil
if err := tx.Commit(); err != nil {
return nil, err
}
func findResourceListImpl(ctx context.Context, tx *sql.Tx, find *api.ResourceFind) ([]*resourceRaw, error) {
return &resource, nil
}
func (s *Store) DeleteResourceV1(ctx context.Context, delete *DeleteResource) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return FormatError(err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `
DELETE FROM resource
WHERE id = ?
`, delete.ID); err != nil {
return err
}
if err := tx.Commit(); err != nil {
// Prevent linter warning.
return err
}
return nil
}
func listResources(ctx context.Context, tx *sql.Tx, find *FindResource) ([]*Resource, error) {
where, args := []string{"1 = 1"}, []any{}
if v := find.ID; v != nil {
@ -288,53 +243,36 @@ func findResourceListImpl(ctx context.Context, tx *sql.Tx, find *api.ResourceFin
}
defer rows.Close()
resourceRawList := make([]*resourceRaw, 0)
list := make([]*Resource, 0)
for rows.Next() {
var resourceRaw resourceRaw
resource := Resource{}
dests := []any{
&resourceRaw.LinkedMemoAmount,
&resourceRaw.ID,
&resourceRaw.Filename,
&resourceRaw.ExternalLink,
&resourceRaw.Type,
&resourceRaw.Size,
&resourceRaw.CreatorID,
&resourceRaw.CreatedTs,
&resourceRaw.UpdatedTs,
&resourceRaw.InternalPath,
&resourceRaw.PublicID,
&resource.LinkedMemoAmount,
&resource.ID,
&resource.Filename,
&resource.ExternalLink,
&resource.Type,
&resource.Size,
&resource.CreatorID,
&resource.CreatedTs,
&resource.UpdatedTs,
&resource.InternalPath,
&resource.PublicID,
}
if find.GetBlob {
dests = append(dests, &resourceRaw.Blob)
dests = append(dests, &resource.Blob)
}
if err := rows.Scan(dests...); err != nil {
return nil, FormatError(err)
}
resourceRawList = append(resourceRawList, &resourceRaw)
list = append(list, &resource)
}
if err := rows.Err(); err != nil {
return nil, FormatError(err)
}
return resourceRawList, nil
}
func deleteResource(ctx context.Context, tx *sql.Tx, delete *api.ResourceDelete) error {
where, args := []string{"id = ?"}, []any{delete.ID}
stmt := `DELETE FROM resource WHERE ` + strings.Join(where, " AND ")
result, err := tx.ExecContext(ctx, stmt, args...)
if err != nil {
return FormatError(err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return &common.Error{Code: common.NotFound, Err: fmt.Errorf("resource not found")}
}
return nil
return list, nil
}
func vacuumResource(ctx context.Context, tx *sql.Tx) error {

View File

@ -17,7 +17,6 @@ type Store struct {
userSettingCache sync.Map // map[string]*UserSetting
shortcutCache sync.Map // map[int]*shortcutRaw
idpCache sync.Map // map[int]*IdentityProvider
resourceCache sync.Map // map[int]*resourceRaw
}
// New creates a new instance of Store.

View File

@ -5,13 +5,13 @@ import (
"testing"
"github.com/stretchr/testify/require"
"github.com/usememos/memos/api"
"github.com/usememos/memos/store"
)
func TestResourceStore(t *testing.T) {
ctx := context.Background()
store := NewTestingStore(ctx, t)
_, err := store.CreateResource(ctx, &api.ResourceCreate{
ts := NewTestingStore(ctx, t)
_, err := ts.CreateResourceV1(ctx, &store.Resource{
CreatorID: 101,
Filename: "test.epub",
Blob: []byte("test"),
@ -25,34 +25,36 @@ func TestResourceStore(t *testing.T) {
correctFilename := "test.epub"
incorrectFilename := "test.png"
res, err := store.FindResource(ctx, &api.ResourceFind{
res, err := ts.GetResource(ctx, &store.FindResource{
Filename: &correctFilename,
})
require.NoError(t, err)
require.Equal(t, correctFilename, res.Filename)
require.Equal(t, 1, res.ID)
_, err = store.FindResource(ctx, &api.ResourceFind{
notFoundResource, err := ts.GetResource(ctx, &store.FindResource{
Filename: &incorrectFilename,
})
require.Error(t, err)
require.NoError(t, err)
require.Nil(t, notFoundResource)
correctCreatorID := 101
incorrectCreatorID := 102
_, err = store.FindResource(ctx, &api.ResourceFind{
_, err = ts.GetResource(ctx, &store.FindResource{
CreatorID: &correctCreatorID,
})
require.NoError(t, err)
_, err = store.FindResource(ctx, &api.ResourceFind{
notFoundResource, err = ts.GetResource(ctx, &store.FindResource{
CreatorID: &incorrectCreatorID,
})
require.Error(t, err)
require.NoError(t, err)
require.Nil(t, notFoundResource)
err = store.DeleteResource(ctx, &api.ResourceDelete{
err = ts.DeleteResourceV1(ctx, &store.DeleteResource{
ID: 1,
})
require.NoError(t, err)
err = store.DeleteResource(ctx, &api.ResourceDelete{
err = ts.DeleteResourceV1(ctx, &store.DeleteResource{
ID: 2,
})
require.Error(t, err)
require.NoError(t, err)
}

View File

@ -161,7 +161,7 @@ export function deleteShortcutById(shortcutId: ShortcutId) {
}
export function getResourceList() {
return axios.get<ResponseObject<Resource[]>>("/api/resource");
return axios.get<Resource[]>("/api/v1/resource");
}
export function getResourceListWithLimit(resourceFind?: ResourceFind) {
@ -172,23 +172,23 @@ export function getResourceListWithLimit(resourceFind?: ResourceFind) {
if (resourceFind?.limit) {
queryList.push(`limit=${resourceFind.limit}`);
}
return axios.get<ResponseObject<Resource[]>>(`/api/resource?${queryList.join("&")}`);
return axios.get<Resource[]>(`/api/v1/resource?${queryList.join("&")}`);
}
export function createResource(resourceCreate: ResourceCreate) {
return axios.post<ResponseObject<Resource>>("/api/resource", resourceCreate);
return axios.post<Resource>("/api/v1/resource", resourceCreate);
}
export function createResourceWithBlob(formData: FormData) {
return axios.post<ResponseObject<Resource>>("/api/resource/blob", formData);
}
export function deleteResourceById(id: ResourceId) {
return axios.delete(`/api/resource/${id}`);
return axios.post<Resource>("/api/v1/resource/blob", formData);
}
export function patchResource(resourcePatch: ResourcePatch) {
return axios.patch<ResponseObject<Resource>>(`/api/resource/${resourcePatch.id}`, resourcePatch);
return axios.patch<Resource>(`/api/v1/resource/${resourcePatch.id}`, resourcePatch);
}
export function deleteResourceById(id: ResourceId) {
return axios.delete(`/api/v1/resource/${id}`);
}
export function getMemoResourceList(memoId: MemoId) {
@ -196,7 +196,7 @@ export function getMemoResourceList(memoId: MemoId) {
}
export function upsertMemoResource(memoId: MemoId, resourceId: ResourceId) {
return axios.post<ResponseObject<Resource>>(`/api/memo/${memoId}/resource`, {
return axios.post(`/api/memo/${memoId}/resource`, {
resourceId,
});
}

View File

@ -25,7 +25,7 @@ export const useResourceStore = () => {
return store.getState().resource;
},
async fetchResourceList(): Promise<Resource[]> {
const { data } = (await api.getResourceList()).data;
const { data } = await api.getResourceList();
const resourceList = data.map((m) => convertResponseModelResource(m));
store.dispatch(setResources(resourceList));
return resourceList;
@ -35,13 +35,13 @@ export const useResourceStore = () => {
limit,
offset,
};
const { data } = (await api.getResourceListWithLimit(resourceFind)).data;
const { data } = await api.getResourceListWithLimit(resourceFind);
const resourceList = data.map((m) => convertResponseModelResource(m));
store.dispatch(upsertResources(resourceList));
return resourceList;
},
async createResource(resourceCreate: ResourceCreate): Promise<Resource> {
const { data } = (await api.createResource(resourceCreate)).data;
const { data } = await api.createResource(resourceCreate);
const resource = convertResponseModelResource(data);
const resourceList = state.resources;
store.dispatch(setResources([resource, ...resourceList]));
@ -55,7 +55,7 @@ export const useResourceStore = () => {
const formData = new FormData();
formData.append("file", file, filename);
const { data } = (await api.createResourceWithBlob(formData)).data;
const { data } = await api.createResourceWithBlob(formData);
const resource = convertResponseModelResource(data);
const resourceList = state.resources;
store.dispatch(setResources([resource, ...resourceList]));
@ -71,7 +71,7 @@ export const useResourceStore = () => {
const formData = new FormData();
formData.append("file", file, filename);
const { data } = (await api.createResourceWithBlob(formData)).data;
const { data } = await api.createResourceWithBlob(formData);
const resource = convertResponseModelResource(data);
newResourceList = [resource, ...newResourceList];
}
@ -84,7 +84,7 @@ export const useResourceStore = () => {
store.dispatch(deleteResource(id));
},
async patchResource(resourcePatch: ResourcePatch): Promise<Resource> {
const { data } = (await api.patchResource(resourcePatch)).data;
const { data } = await api.patchResource(resourcePatch);
const resource = convertResponseModelResource(data);
store.dispatch(patchResource(resource));
return resource;