From 041be46732a491710b2057430c0b519c98146ba9 Mon Sep 17 00:00:00 2001 From: Athurg Gooth Date: Mon, 15 May 2023 22:42:12 +0800 Subject: [PATCH] Add support for image thumbnail (#1641) * Add a common function for resize image blob * Auto generate thumbnail for image resources * Auto thumbnail support for fetch image resources * Add support for image thumbnail in view * Fix missing error check * Fix es-lint check * Fix uncontrolled data used in path expression * Remove thumbnail while origin resource been deleted * Change the thumbnail's storage path --------- Co-authored-by: Athurg Feng --- common/image.go | 67 ++++++++++++++++++++++++++++ server/resource.go | 48 ++++++++++++++++++-- web/src/components/MemoResources.tsx | 2 +- 3 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 common/image.go diff --git a/common/image.go b/common/image.go new file mode 100644 index 00000000..6d9de484 --- /dev/null +++ b/common/image.go @@ -0,0 +1,67 @@ +package common + +import ( + "bytes" + "fmt" + "image" + + "image/jpeg" + "image/png" +) + +const ThumbnailPath = ".thumbnail_cache" + +func ResizeImageBlob(data []byte, maxSize int, mime string) ([]byte, error) { + var err error + var oldImage image.Image + + switch mime { + case "image/jpeg": + oldImage, err = jpeg.Decode(bytes.NewReader(data)) + case "image/png": + oldImage, err = png.Decode(bytes.NewReader(data)) + default: + return nil, fmt.Errorf("mime %s is not support", mime) + } + + if err != nil { + return nil, err + } + + bounds := oldImage.Bounds() + if bounds.Dx() <= maxSize && bounds.Dy() <= maxSize { + return data, nil + } + + oldBounds := oldImage.Bounds() + + dy := maxSize + r := float32(oldBounds.Dy()) / float32(maxSize) + dx := int(float32(oldBounds.Dx()) / r) + if oldBounds.Dx() > oldBounds.Dy() { + dx = maxSize + r = float32(oldBounds.Dx()) / float32(maxSize) + dy = int(float32(oldBounds.Dy()) / r) + } + + newBounds := image.Rect(0, 0, dx, dy) + newImage := image.NewRGBA(newBounds) + for x := 0; x < newBounds.Dx(); x++ { + for y := 0; y < newBounds.Dy(); y++ { + newImage.Set(x, y, oldImage.At(int(float32(x)*r), int(float32(y)*r))) + } + } + + var newBuffer bytes.Buffer + switch mime { + case "image/jpeg": + err = jpeg.Encode(&newBuffer, newImage, nil) + case "image/png": + err = png.Encode(&newBuffer, newImage) + } + if err != nil { + return nil, err + } + + return newBuffer.Bytes(), nil +} diff --git a/server/resource.go b/server/resource.go index f471bc1c..fe3f908e 100644 --- a/server/resource.go +++ b/server/resource.go @@ -116,6 +116,7 @@ func (s *Server) registerResourceRoutes(g *echo.Group) { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal storage service id").SetInternal(err) } } + publicID := common.GenUUID() if storageServiceID == api.DatabaseStorage { fileBytes, err := io.ReadAll(sourceFile) if err != nil { @@ -162,6 +163,32 @@ func (s *Server) registerResourceRoutes(g *echo.Group) { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to copy file").SetInternal(err) } + if filetype == "image/jpeg" || filetype == "image/png" { + _, err := sourceFile.Seek(0, io.SeekStart) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to seek file").SetInternal(err) + } + + fileBytes, err := io.ReadAll(sourceFile) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to load file").SetInternal(err) + } + + thumbnailBytes, err := common.ResizeImageBlob(fileBytes, 302, filetype) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate thumbnail").SetInternal(err) + } + + dir := filepath.Join(s.Profile.Data, common.ThumbnailPath) + if err = os.MkdirAll(dir, os.ModePerm); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create directory").SetInternal(err) + } + err = os.WriteFile(filepath.Join(dir, publicID), thumbnailBytes, 0666) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create thumbnail").SetInternal(err) + } + } + resourceCreate = &api.ResourceCreate{ CreatorID: userID, Filename: filename, @@ -212,7 +239,6 @@ func (s *Server) registerResourceRoutes(g *echo.Group) { } } - publicID := common.GenUUID() resourceCreate.PublicID = publicID resource, err := s.Store.CreateResource(ctx, resourceCreate) if err != nil { @@ -319,6 +345,12 @@ func (s *Server) registerResourceRoutes(g *echo.Group) { if err != nil { log.Warn(fmt.Sprintf("failed to delete local file with path %s", resource.InternalPath), zap.Error(err)) } + + thumbnailPath := filepath.Join(s.Profile.Data, common.ThumbnailPath, resource.PublicID) + err = os.Remove(thumbnailPath) + if err != nil { + log.Warn(fmt.Sprintf("failed to delete local thumbnail with path %s", thumbnailPath), zap.Error(err)) + } } resourceDelete := &api.ResourceDelete{ @@ -408,14 +440,22 @@ func (s *Server) registerResourcePublicRoutes(g *echo.Group) { blob := resource.Blob if resource.InternalPath != "" { - src, err := os.Open(resource.InternalPath) + resourcePath := resource.InternalPath + if c.QueryParam("thumbnail") == "1" && (resource.Type == "image/jpeg" || resource.Type == "image/png") { + thumbnailPath := filepath.Join(s.Profile.Data, common.ThumbnailPath, resource.PublicID) + if _, err := os.Stat(thumbnailPath); err == nil { + resourcePath = thumbnailPath + } + } + + src, err := os.Open(resourcePath) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to open the local resource: %s", resource.InternalPath)).SetInternal(err) + 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", resource.InternalPath)).SetInternal(err) + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to read the local resource: %s", resourcePath)).SetInternal(err) } } diff --git a/web/src/components/MemoResources.tsx b/web/src/components/MemoResources.tsx index 4bef8ec9..c6ffbe81 100644 --- a/web/src/components/MemoResources.tsx +++ b/web/src/components/MemoResources.tsx @@ -46,7 +46,7 @@ const MemoResources: React.FC = (props: Props) => { if (resource.type.startsWith("image")) { return ( - handleImageClick(url)} decoding="async" loading="lazy" /> + handleImageClick(url)} decoding="async" loading="lazy" /> ); } else if (resource.type.startsWith("video")) {