diff --git a/api/resource.go b/api/resource.go index bf9688dc..08485ebd 100644 --- a/api/resource.go +++ b/api/resource.go @@ -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 -} diff --git a/api/v1/memo_resource.go b/api/v1/memo_resource.go new file mode 100644 index 00000000..551b36ce --- /dev/null +++ b/api/v1/memo_resource.go @@ -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 +} diff --git a/server/resource.go b/api/v1/resource.go similarity index 77% rename from server/resource.go rename to api/v1/resource.go index c05cb648..9450cb8e 100644 --- a/server/resource.go +++ b/api/v1/resource.go @@ -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: ¤tTs, - } - 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: ¤tTs, + } + 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, + } +} diff --git a/api/v1/v1.go b/api/v1/v1.go index 639ab1be..d4096efd 100644 --- a/api/v1/v1.go +++ b/api/v1/v1.go @@ -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) } diff --git a/server/memo.go b/server/memo.go index 7a729312..d39fec4f 100644 --- a/server/memo.go +++ b/server/memo.go @@ -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, + } +} diff --git a/server/memo_resource.go b/server/memo_resource.go index 8b562ab2..3a3e9060 100644 --- a/server/memo_resource.go +++ b/server/memo_resource.go @@ -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)) }) diff --git a/server/rss.go b/server/rss.go index 3323d013..31343306 100644 --- a/server/rss.go +++ b/server/rss.go @@ -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 { diff --git a/server/server.go b/server/server.go index 847932f9..3c3c24f7 100644 --- a/server/server.go +++ b/server/server.go @@ -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) diff --git a/server/telegram.go b/server/telegram.go index 7bd173ea..884ee3a7 100644 --- a/server/telegram.go +++ b/server/telegram.go @@ -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 diff --git a/store/memo_resource.go b/store/memo_resource.go index 92fe37de..804d5961 100644 --- a/store/memo_resource.go +++ b/store/memo_resource.go @@ -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 { diff --git a/store/resource.go b/store/resource.go index e7d42cdd..95375f0f 100644 --- a/store/resource.go +++ b/store/resource.go @@ -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, - } - dests = append(dests, []any{&resourceRaw.CreatedTs, &resourceRaw.UpdatedTs}...) - if err := tx.QueryRowContext(ctx, query, values...).Scan(dests...); err != nil { - return nil, FormatError(err) + 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 } - return &resourceRaw, nil + if err := tx.Commit(); err != nil { + return nil, err + } + + resource := create + return resource, nil } -func patchResourceImpl(ctx context.Context, tx *sql.Tx, patch *api.ResourcePatch) (*resourceRaw, error) { +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() + + resources, err := listResources(ctx, tx, find) + if err != nil { + return nil, err + } + + 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 + } + + return &resource, nil } -func findResourceListImpl(ctx context.Context, tx *sql.Tx, find *api.ResourceFind) ([]*resourceRaw, error) { +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 { diff --git a/store/store.go b/store/store.go index 5c5f97a0..661ed08e 100644 --- a/store/store.go +++ b/store/store.go @@ -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. diff --git a/test/store/resource_test.go b/test/store/resource_test.go index feb0ee6c..def65860 100644 --- a/test/store/resource_test.go +++ b/test/store/resource_test.go @@ -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) } diff --git a/web/src/helpers/api.ts b/web/src/helpers/api.ts index 34a9f1d0..939f6f11 100644 --- a/web/src/helpers/api.ts +++ b/web/src/helpers/api.ts @@ -161,7 +161,7 @@ export function deleteShortcutById(shortcutId: ShortcutId) { } export function getResourceList() { - return axios.get>("/api/resource"); + return axios.get("/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>(`/api/resource?${queryList.join("&")}`); + return axios.get(`/api/v1/resource?${queryList.join("&")}`); } export function createResource(resourceCreate: ResourceCreate) { - return axios.post>("/api/resource", resourceCreate); + return axios.post("/api/v1/resource", resourceCreate); } export function createResourceWithBlob(formData: FormData) { - return axios.post>("/api/resource/blob", formData); -} - -export function deleteResourceById(id: ResourceId) { - return axios.delete(`/api/resource/${id}`); + return axios.post("/api/v1/resource/blob", formData); } export function patchResource(resourcePatch: ResourcePatch) { - return axios.patch>(`/api/resource/${resourcePatch.id}`, resourcePatch); + return axios.patch(`/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>(`/api/memo/${memoId}/resource`, { + return axios.post(`/api/memo/${memoId}/resource`, { resourceId, }); } diff --git a/web/src/store/module/resource.ts b/web/src/store/module/resource.ts index f4a35f39..7423d79e 100644 --- a/web/src/store/module/resource.ts +++ b/web/src/store/module/resource.ts @@ -25,7 +25,7 @@ export const useResourceStore = () => { return store.getState().resource; }, async fetchResourceList(): Promise { - 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 { - 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 { - const { data } = (await api.patchResource(resourcePatch)).data; + const { data } = await api.patchResource(resourcePatch); const resource = convertResponseModelResource(data); store.dispatch(patchResource(resource)); return resource;