From 31997936d64b5e4ff261214dfca384c70c4c3caa Mon Sep 17 00:00:00 2001 From: Steven Date: Sun, 8 Oct 2023 19:40:30 +0800 Subject: [PATCH] chore: move resource public api --- api/resource/resource.go | 165 +++++++++++++++++++++++++++++++++++++++ api/v1/resource.go | 139 --------------------------------- api/v1/v1.go | 5 +- 3 files changed, 169 insertions(+), 140 deletions(-) create mode 100644 api/resource/resource.go diff --git a/api/resource/resource.go b/api/resource/resource.go new file mode 100644 index 00000000..4088d39c --- /dev/null +++ b/api/resource/resource.go @@ -0,0 +1,165 @@ +package resource + +import ( + "bytes" + "fmt" + "io" + "net/http" + "os" + "path" + "path/filepath" + "strings" + "sync/atomic" + "time" + + "github.com/disintegration/imaging" + "github.com/labstack/echo/v4" + "github.com/pkg/errors" + "go.uber.org/zap" + + "github.com/usememos/memos/common/log" + "github.com/usememos/memos/common/util" + "github.com/usememos/memos/server/profile" + "github.com/usememos/memos/store" +) + +const ( + // The key name used to store user id in the context + // user id is extracted from the jwt token subject field. + userIDContextKey = "user-id" + // thumbnailImagePath is the directory to store image thumbnails. + thumbnailImagePath = ".thumbnail_cache" +) + +type Service struct { + Profile *profile.Profile + Store *store.Store +} + +func NewService(profile *profile.Profile, store *store.Store) *Service { + return &Service{ + Profile: profile, + Store: store, + } +} + +func (s *Service) RegisterResourcePublicRoutes(g *echo.Group) { + g.GET("/r/:resourceId", s.streamResource) + g.GET("/r/:resourceId/*", s.streamResource) +} + +func (s *Service) streamResource(c echo.Context) error { + ctx := c.Request().Context() + resourceID, err := util.ConvertStringToInt32(c.Param("resourceId")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err) + } + + resource, err := s.Store.GetResource(ctx, &store.FindResource{ + ID: &resourceID, + GetBlob: true, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find resource by ID: %v", resourceID)).SetInternal(err) + } + if resource == nil { + return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource not found: %d", resourceID)) + } + // Check the related memo visibility. + if resource.MemoID != nil { + memo, err := s.Store.GetMemo(ctx, &store.FindMemo{ + ID: resource.MemoID, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find memo by ID: %v", resource.MemoID)).SetInternal(err) + } + if memo != nil && memo.Visibility != store.Public { + userID, ok := c.Get(userIDContextKey).(int32) + if !ok || (memo.Visibility == store.Private && userID != resource.CreatorID) { + return echo.NewHTTPError(http.StatusUnauthorized, "Resource visibility not match") + } + } + } + + blob := resource.Blob + if resource.InternalPath != "" { + resourcePath := resource.InternalPath + src, err := os.Open(resourcePath) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to open the local resource: %s", resourcePath)).SetInternal(err) + } + defer src.Close() + blob, err = io.ReadAll(src) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to read the local resource: %s", resourcePath)).SetInternal(err) + } + } + + if c.QueryParam("thumbnail") == "1" && util.HasPrefixes(resource.Type, "image/png", "image/jpeg") { + ext := filepath.Ext(resource.Filename) + thumbnailPath := filepath.Join(s.Profile.Data, thumbnailImagePath, fmt.Sprintf("%d%s", resource.ID, ext)) + thumbnailBlob, err := getOrGenerateThumbnailImage(blob, thumbnailPath) + if err != nil { + log.Warn(fmt.Sprintf("failed to get or generate local thumbnail with path %s", thumbnailPath), zap.Error(err)) + } else { + blob = thumbnailBlob + } + } + + c.Response().Writer.Header().Set(echo.HeaderCacheControl, "max-age=31536000, immutable") + c.Response().Writer.Header().Set(echo.HeaderContentSecurityPolicy, "default-src 'self'") + resourceType := strings.ToLower(resource.Type) + if strings.HasPrefix(resourceType, "text") { + resourceType = echo.MIMETextPlainCharsetUTF8 + } else if strings.HasPrefix(resourceType, "video") || strings.HasPrefix(resourceType, "audio") { + http.ServeContent(c.Response(), c.Request(), resource.Filename, time.Unix(resource.UpdatedTs, 0), bytes.NewReader(blob)) + return nil + } + c.Response().Writer.Header().Set("Content-Disposition", fmt.Sprintf(`filename="%s"`, resource.Filename)) + return c.Stream(http.StatusOK, resourceType, bytes.NewReader(blob)) +} + +var availableGeneratorAmount int32 = 32 + +func getOrGenerateThumbnailImage(srcBlob []byte, dstPath string) ([]byte, error) { + if _, err := os.Stat(dstPath); err != nil { + if !errors.Is(err, os.ErrNotExist) { + return nil, errors.Wrap(err, "failed to check thumbnail image stat") + } + + if atomic.LoadInt32(&availableGeneratorAmount) <= 0 { + return nil, errors.New("not enough available generator amount") + } + atomic.AddInt32(&availableGeneratorAmount, -1) + defer func() { + atomic.AddInt32(&availableGeneratorAmount, 1) + }() + + reader := bytes.NewReader(srcBlob) + src, err := imaging.Decode(reader, imaging.AutoOrientation(true)) + if err != nil { + return nil, errors.Wrap(err, "failed to decode thumbnail image") + } + thumbnailImage := imaging.Resize(src, 512, 0, imaging.Lanczos) + + dstDir := path.Dir(dstPath) + if err := os.MkdirAll(dstDir, os.ModePerm); err != nil { + return nil, errors.Wrap(err, "failed to create thumbnail dir") + } + + if err := imaging.Save(thumbnailImage, dstPath); err != nil { + return nil, errors.Wrap(err, "failed to resize thumbnail image") + } + } + + dstFile, err := os.Open(dstPath) + if err != nil { + return nil, errors.Wrap(err, "failed to open the local resource") + } + defer dstFile.Close() + dstBlob, err := io.ReadAll(dstFile) + if err != nil { + return nil, errors.Wrap(err, "failed to read the local resource") + } + return dstBlob, nil +} diff --git a/api/v1/resource.go b/api/v1/resource.go index b2f4f9a6..dd2b2b64 100644 --- a/api/v1/resource.go +++ b/api/v1/resource.go @@ -1,7 +1,6 @@ package v1 import ( - "bytes" "context" "encoding/json" "fmt" @@ -9,15 +8,12 @@ import ( "net/http" "net/url" "os" - "path" "path/filepath" "regexp" "strconv" "strings" - "sync/atomic" "time" - "github.com/disintegration/imaging" "github.com/labstack/echo/v4" "github.com/pkg/errors" "go.uber.org/zap" @@ -82,11 +78,6 @@ func (s *APIV1Service) registerResourceRoutes(g *echo.Group) { g.DELETE("/resource/:resourceId", s.DeleteResource) } -func (s *APIV1Service) registerResourcePublicRoutes(g *echo.Group) { - g.GET("/r/:resourceId", s.streamResource) - g.GET("/r/:resourceId/*", s.streamResource) -} - // GetResourceList godoc // // @Summary Get a list of resources @@ -362,91 +353,6 @@ func (s *APIV1Service) UpdateResource(c echo.Context) error { return c.JSON(http.StatusOK, convertResourceFromStore(resource)) } -// streamResource godoc -// -// @Summary Stream a resource -// @Description *Swagger UI may have problems displaying other file types than images -// @Tags resource -// @Produce octet-stream -// @Param resourceId path int true "Resource ID" -// @Param thumbnail query int false "Thumbnail" -// @Success 200 {object} nil "Requested resource" -// @Failure 400 {object} nil "ID is not a number: %s | Failed to get resource visibility" -// @Failure 401 {object} nil "Resource visibility not match" -// @Failure 404 {object} nil "Resource not found: %d" -// @Failure 500 {object} nil "Failed to find resource by ID: %v | Failed to open the local resource: %s | Failed to read the local resource: %s" -// @Router /o/r/{resourceId} [GET] -func (s *APIV1Service) streamResource(c echo.Context) error { - ctx := c.Request().Context() - resourceID, err := util.ConvertStringToInt32(c.Param("resourceId")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err) - } - - resource, err := s.Store.GetResource(ctx, &store.FindResource{ - ID: &resourceID, - GetBlob: true, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find resource by ID: %v", resourceID)).SetInternal(err) - } - if resource == nil { - return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource not found: %d", resourceID)) - } - // Check the related memo visibility. - if resource.MemoID != nil { - memo, err := s.Store.GetMemo(ctx, &store.FindMemo{ - ID: resource.MemoID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find memo by ID: %v", resource.MemoID)).SetInternal(err) - } - if memo != nil && memo.Visibility != store.Public { - userID, ok := c.Get(userIDContextKey).(int32) - if !ok || (memo.Visibility == store.Private && userID != resource.CreatorID) { - return echo.NewHTTPError(http.StatusUnauthorized, "Resource visibility not match") - } - } - } - - blob := resource.Blob - if resource.InternalPath != "" { - resourcePath := resource.InternalPath - src, err := os.Open(resourcePath) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to open the local resource: %s", resourcePath)).SetInternal(err) - } - defer src.Close() - blob, err = io.ReadAll(src) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to read the local resource: %s", resourcePath)).SetInternal(err) - } - } - - if c.QueryParam("thumbnail") == "1" && util.HasPrefixes(resource.Type, "image/png", "image/jpeg") { - ext := filepath.Ext(resource.Filename) - thumbnailPath := filepath.Join(s.Profile.Data, thumbnailImagePath, fmt.Sprintf("%d%s", resource.ID, ext)) - thumbnailBlob, err := getOrGenerateThumbnailImage(blob, thumbnailPath) - if err != nil { - log.Warn(fmt.Sprintf("failed to get or generate local thumbnail with path %s", thumbnailPath), zap.Error(err)) - } else { - blob = thumbnailBlob - } - } - - c.Response().Writer.Header().Set(echo.HeaderCacheControl, "max-age=31536000, immutable") - c.Response().Writer.Header().Set(echo.HeaderContentSecurityPolicy, "default-src 'self'") - resourceType := strings.ToLower(resource.Type) - if strings.HasPrefix(resourceType, "text") { - resourceType = echo.MIMETextPlainCharsetUTF8 - } else if strings.HasPrefix(resourceType, "video") || strings.HasPrefix(resourceType, "audio") { - http.ServeContent(c.Response(), c.Request(), resource.Filename, time.Unix(resource.UpdatedTs, 0), bytes.NewReader(blob)) - return nil - } - c.Response().Writer.Header().Set("Content-Disposition", fmt.Sprintf(`filename="%s"`, resource.Filename)) - return c.Stream(http.StatusOK, resourceType, bytes.NewReader(blob)) -} - func (s *APIV1Service) createResourceCreateActivity(ctx context.Context, resource *store.Resource) error { payload := ActivityResourceCreatePayload{ Filename: resource.Filename, @@ -495,51 +401,6 @@ func replacePathTemplate(path, filename string) string { return path } -var availableGeneratorAmount int32 = 32 - -func getOrGenerateThumbnailImage(srcBlob []byte, dstPath string) ([]byte, error) { - if _, err := os.Stat(dstPath); err != nil { - if !errors.Is(err, os.ErrNotExist) { - return nil, errors.Wrap(err, "failed to check thumbnail image stat") - } - - if atomic.LoadInt32(&availableGeneratorAmount) <= 0 { - return nil, errors.New("not enough available generator amount") - } - atomic.AddInt32(&availableGeneratorAmount, -1) - defer func() { - atomic.AddInt32(&availableGeneratorAmount, 1) - }() - - reader := bytes.NewReader(srcBlob) - src, err := imaging.Decode(reader, imaging.AutoOrientation(true)) - if err != nil { - return nil, errors.Wrap(err, "failed to decode thumbnail image") - } - thumbnailImage := imaging.Resize(src, 512, 0, imaging.Lanczos) - - dstDir := path.Dir(dstPath) - if err := os.MkdirAll(dstDir, os.ModePerm); err != nil { - return nil, errors.Wrap(err, "failed to create thumbnail dir") - } - - if err := imaging.Save(thumbnailImage, dstPath); err != nil { - return nil, errors.Wrap(err, "failed to resize thumbnail image") - } - } - - dstFile, err := os.Open(dstPath) - if err != nil { - return nil, errors.Wrap(err, "failed to open the local resource") - } - defer dstFile.Close() - dstBlob, err := io.ReadAll(dstFile) - if err != nil { - return nil, errors.Wrap(err, "failed to read the local resource") - } - return dstBlob, nil -} - func convertResourceFromStore(resource *store.Resource) *Resource { return &Resource{ ID: resource.ID, diff --git a/api/v1/v1.go b/api/v1/v1.go index 1bb5fd4b..76c87dfd 100644 --- a/api/v1/v1.go +++ b/api/v1/v1.go @@ -3,6 +3,7 @@ package v1 import ( "github.com/labstack/echo/v4" + "github.com/usememos/memos/api/resource" "github.com/usememos/memos/plugin/telegram" "github.com/usememos/memos/server/profile" "github.com/usememos/memos/store" @@ -66,7 +67,9 @@ func (s *APIV1Service) Register(rootGroup *echo.Group) { return JWTMiddleware(s, next, s.Secret) }) s.registerGetterPublicRoutes(publicGroup) - s.registerResourcePublicRoutes(publicGroup) + // Create and register resource public routes. + resourceService := resource.NewService(s.Profile, s.Store) + resourceService.RegisterResourcePublicRoutes(publicGroup) // programmatically set API version same as the server version SwaggerInfo.Version = s.Profile.Version