From 20570fc7712a8c89a8aec3d389d2c383b595c999 Mon Sep 17 00:00:00 2001 From: Steven Date: Fri, 30 Aug 2024 08:09:07 +0800 Subject: [PATCH] refactor: resource thumbnail --- server/router/api/v1/memo_service.go | 5 - server/router/api/v1/resource_service.go | 162 +++++++++++--------- server/router/api/v1/thumbnail.go | 146 ------------------ server/router/api/v1/workspace_service.go | 1 - store/db/mysql/memo.go | 7 +- web/src/components/MemoResourceListView.tsx | 8 +- 6 files changed, 101 insertions(+), 228 deletions(-) delete mode 100644 server/router/api/v1/thumbnail.go diff --git a/server/router/api/v1/memo_service.go b/server/router/api/v1/memo_service.go index 60ae77be..58f36c23 100644 --- a/server/router/api/v1/memo_service.go +++ b/server/router/api/v1/memo_service.go @@ -365,11 +365,6 @@ func (s *APIV1Service) DeleteMemo(ctx context.Context, request *v1pb.DeleteMemoR if err := s.Store.DeleteResource(ctx, &store.DeleteResource{ID: resource.ID}); err != nil { return nil, status.Errorf(codes.Internal, "failed to delete resource") } - - thumb := thumbnail{resource} - if err := thumb.deleteFile(s.Profile.Data); err != nil { - slog.Warn("failed to delete resource thumbnail") - } } // Delete memo comments diff --git a/server/router/api/v1/resource_service.go b/server/router/api/v1/resource_service.go index 152246af..67bfce58 100644 --- a/server/router/api/v1/resource_service.go +++ b/server/router/api/v1/resource_service.go @@ -11,8 +11,10 @@ import ( "path/filepath" "regexp" "strings" + "sync/atomic" "time" + "github.com/disintegration/imaging" "github.com/lithammer/shortuuid/v4" "github.com/pkg/errors" "google.golang.org/genproto/googleapis/api/httpbody" @@ -34,11 +36,15 @@ const ( // This is unrelated to maximum upload size limit, which is now set through system setting. MaxUploadBufferSizeBytes = 32 << 20 MebiByte = 1024 * 1024 - - // thumbnailImagePath is the directory to store image thumbnails. - thumbnailImagePath = ".thumbnail_cache" + // ThumbnailCacheFolder is the folder name where the thumbnail images are stored. + ThumbnailCacheFolder = ".thumbnail_cache" ) +var SupportedThumbnailMimeTypes = []string{ + "image/png", + "image/jpeg", +} + func (s *APIV1Service) CreateResource(ctx context.Context, request *v1pb.CreateResourceRequest) (*v1pb.Resource, error) { user, err := s.GetCurrentUser(ctx) if err != nil { @@ -175,74 +181,23 @@ func (s *APIV1Service) GetResourceBinary(ctx context.Context, request *v1pb.GetR } } - thumb := thumbnail{resource} - returnThumbnail := false - - if request.Thumbnail && util.HasPrefixes(resource.Type, thumb.supportedMimeTypes()...) { - returnThumbnail = true - - thumbnailBlob, err := thumb.getFile(s.Profile.Data) + if request.Thumbnail && util.HasPrefixes(resource.Type, SupportedThumbnailMimeTypes...) { + thumbnailBlob, err := s.getOrGenerateThumbnail(ctx, resource) if err != nil { // thumbnail failures are logged as warnings and not cosidered critical failures as - // a resource image can be used in its place + // a resource image can be used in its place. slog.Warn("failed to get resource thumbnail image", slog.Any("error", err)) } else { - httpBody := &httpbody.HttpBody{ + return &httpbody.HttpBody{ ContentType: resource.Type, Data: thumbnailBlob, - } - - return httpBody, nil + }, nil } } - blob := resource.Blob - if resource.StorageType == storepb.ResourceStorageType_LOCAL { - resourcePath := filepath.FromSlash(resource.Reference) - if !filepath.IsAbs(resourcePath) { - resourcePath = filepath.Join(s.Profile.Data, resourcePath) - } - - file, err := os.Open(resourcePath) - if err != nil { - if os.IsNotExist(err) { - return nil, status.Errorf(codes.NotFound, "file not found for resource: %s", request.Name) - } - return nil, status.Errorf(codes.Internal, "failed to open the file: %v", err) - } - defer file.Close() - blob, err = io.ReadAll(file) - if err != nil { - return nil, status.Errorf(codes.Internal, "failed to read the file: %v", err) - } - } - - if returnThumbnail { - // wrapping generation logic in a func to exit failed non critical flow using return - generateThumbnailBlob := func() ([]byte, error) { - thumbnailImage, err := thumb.generateImage(blob) - if err != nil { - return nil, errors.Wrap(err, "failed to generate resource thumbnail") - } - - if err := thumb.saveAsFile(s.Profile.Data, thumbnailImage); err != nil { - return nil, errors.Wrap(err, "failed to save generated resource thumbnail") - } - - thumbnailBlob, err := thumb.imageToBlob(thumbnailImage) - if err != nil { - return nil, errors.Wrap(err, "failed to convert generate resource thumbnail to bytes") - } - - return thumbnailBlob, nil - } - - thumbnailBlob, err := generateThumbnailBlob() - if err != nil { - slog.Warn("failed to generate a thumbnail blob for the resource", slog.Any("error", err)) - } else { - blob = thumbnailBlob - } + blob, err := s.GetResourceBlob(ctx, resource) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get resource blob: %v", err) } contentType := resource.Type @@ -250,11 +205,10 @@ func (s *APIV1Service) GetResourceBinary(ctx context.Context, request *v1pb.GetR contentType += "; charset=utf-8" } - httpBody := &httpbody.HttpBody{ + return &httpbody.HttpBody{ ContentType: contentType, Data: blob, - } - return httpBody, nil + }, nil } func (s *APIV1Service) UpdateResource(ctx context.Context, request *v1pb.UpdateResourceRequest) (*v1pb.Resource, error) { @@ -319,12 +273,6 @@ func (s *APIV1Service) DeleteResource(ctx context.Context, request *v1pb.DeleteR }); err != nil { return nil, status.Errorf(codes.Internal, "failed to delete resource: %v", err) } - - thumb := thumbnail{resource} - if err := thumb.deleteFile(s.Profile.Data); err != nil { - slog.Warn("failed to delete resource thumbnail") - } - return &emptypb.Empty{}, nil } @@ -436,6 +384,78 @@ func SaveResourceBlob(ctx context.Context, s *store.Store, create *store.Resourc return nil } +func (s *APIV1Service) GetResourceBlob(ctx context.Context, resource *store.Resource) ([]byte, error) { + blob := resource.Blob + if resource.StorageType == storepb.ResourceStorageType_LOCAL { + resourcePath := filepath.FromSlash(resource.Reference) + if !filepath.IsAbs(resourcePath) { + resourcePath = filepath.Join(s.Profile.Data, resourcePath) + } + + file, err := os.Open(resourcePath) + if err != nil { + if os.IsNotExist(err) { + return nil, errors.Wrap(err, "file not found") + } + return nil, errors.Wrap(err, "failed to open the file") + } + defer file.Close() + blob, err = io.ReadAll(file) + if err != nil { + return nil, errors.Wrap(err, "failed to read the file") + } + } + return blob, nil +} + +// getOrGenerateThumbnail returns the thumbnail image of the resource. +func (s *APIV1Service) getOrGenerateThumbnail(ctx context.Context, resource *store.Resource) ([]byte, error) { + thumbnailCacheFolder := filepath.Join(s.Profile.Data, ThumbnailCacheFolder) + if err := os.MkdirAll(thumbnailCacheFolder, os.ModePerm); err != nil { + return nil, errors.Wrap(err, "failed to create thumbnail cache folder") + } + filePath := filepath.Join(thumbnailCacheFolder, fmt.Sprintf("%d%s", resource.ID, filepath.Ext(resource.Filename))) + if _, err := os.Stat(filePath); err != nil { + if !os.IsNotExist(err) { + return nil, errors.Wrap(err, "failed to check thumbnail image stat") + } + + var availableGeneratorAmount int32 = 32 + if atomic.LoadInt32(&availableGeneratorAmount) <= 0 { + return nil, errors.New("not enough available generator amount") + } + atomic.AddInt32(&availableGeneratorAmount, -1) + defer func() { + atomic.AddInt32(&availableGeneratorAmount, 1) + }() + + // Otherwise, generate and save the thumbnail image. + blob, err := s.GetResourceBlob(ctx, resource) + if err != nil { + return nil, errors.Wrap(err, "failed to get resource blob") + } + image, err := imaging.Decode(bytes.NewReader(blob), imaging.AutoOrientation(true)) + if err != nil { + return nil, errors.Wrap(err, "failed to decode thumbnail image") + } + thumbnailImage := imaging.Resize(image, 512, 0, imaging.Lanczos) + if err := imaging.Save(thumbnailImage, filePath); err != nil { + return nil, errors.Wrap(err, "failed to save thumbnail file") + } + } + + dstFile, err := os.Open(filePath) + if err != nil { + return nil, errors.Wrap(err, "failed to open thumbnail file") + } + defer dstFile.Close() + dstBlob, err := io.ReadAll(dstFile) + if err != nil { + return nil, errors.Wrap(err, "failed to read thumbnail file") + } + return dstBlob, nil +} + var fileKeyPattern = regexp.MustCompile(`\{[a-z]{1,9}\}`) func replaceFilenameWithPathTemplate(path, filename string) string { diff --git a/server/router/api/v1/thumbnail.go b/server/router/api/v1/thumbnail.go deleted file mode 100644 index d1d97a4f..00000000 --- a/server/router/api/v1/thumbnail.go +++ /dev/null @@ -1,146 +0,0 @@ -package v1 - -import ( - "bytes" - "fmt" - "image" - "io" - "os" - "path/filepath" - "sync/atomic" - - "github.com/disintegration/imaging" - "github.com/pkg/errors" - - "github.com/usememos/memos/store" -) - -// thumbnail provides functionality to manage thumbnail images -// for resources. -type thumbnail struct { - // The resource the thumbnail is for - resource *store.Resource -} - -func (thumbnail) supportedMimeTypes() []string { - return []string{ - "image/png", - "image/jpeg", - } -} - -func (t *thumbnail) getFilePath(assetsFolderPath string) (string, error) { - if assetsFolderPath == "" { - return "", errors.New("aapplication path is not set") - } - - ext := filepath.Ext(t.resource.Filename) - path := filepath.Join(assetsFolderPath, thumbnailImagePath, fmt.Sprintf("%d%s", t.resource.ID, ext)) - - return path, nil -} - -func (t *thumbnail) getFile(assetsFolderPath string) ([]byte, error) { - path, err := t.getFilePath(assetsFolderPath) - - if err != nil { - return nil, errors.Wrap(err, "failed to get thumbnail file path") - } - - if _, err := os.Stat(path); err != nil { - if !errors.Is(err, os.ErrNotExist) { - return nil, errors.Wrap(err, "failed to check thumbnail image stat") - } - } - - dstFile, err := os.Open(path) - if err != nil { - return nil, errors.Wrap(err, "failed to open thumbnail file") - } - defer dstFile.Close() - - dstBlob, err := io.ReadAll(dstFile) - if err != nil { - return nil, errors.Wrap(err, "failed to read thumbnail file") - } - - return dstBlob, nil -} - -func (thumbnail) generateImage(sourceBlob []byte) (image.Image, error) { - var availableGeneratorAmount int32 = 32 - - 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(sourceBlob) - 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) - return thumbnailImage, nil -} - -func (t *thumbnail) saveAsFile(assetsFolderPath string, thumbnailImage image.Image) error { - path, err := t.getFilePath(assetsFolderPath) - if err != nil { - return errors.Wrap(err, "failed to get thumbnail file path") - } - - dstDir := filepath.Dir(path) - if err := os.MkdirAll(dstDir, os.ModePerm); err != nil { - return errors.Wrap(err, "failed to create thumbnail directory") - } - - if err := imaging.Save(thumbnailImage, path); err != nil { - return errors.Wrap(err, "failed to save thumbnail file") - } - - return nil -} - -func (t *thumbnail) imageToBlob(thumbnailImage image.Image) ([]byte, error) { - mimeTypeMap := map[string]imaging.Format{ - "image/png": imaging.JPEG, - "image/jpeg": imaging.PNG, - } - - imgFormat, ok := mimeTypeMap[t.resource.Type] - if !ok { - return nil, errors.New("failed to map resource type to an image encoder format") - } - - buf := new(bytes.Buffer) - if err := imaging.Encode(buf, thumbnailImage, imgFormat); err != nil { - return nil, errors.Wrap(err, "failed to convert thumbnail image to bytes") - } - - return buf.Bytes(), nil -} - -func (t *thumbnail) deleteFile(assetsFolderPath string) error { - path, err := t.getFilePath(assetsFolderPath) - if err != nil { - return errors.Wrap(err, "failed to get thumbnail file path") - } - - if _, err := os.Stat(path); err != nil { - if !errors.Is(err, os.ErrNotExist) { - return errors.Wrap(err, "failed to check thumbnail image stat") - } - } - - if err := os.Remove(path); err != nil { - return errors.Wrap(err, "failed to delete thumbnail file") - } - - return nil -} diff --git a/server/router/api/v1/workspace_service.go b/server/router/api/v1/workspace_service.go index c2485f9e..46f32707 100644 --- a/server/router/api/v1/workspace_service.go +++ b/server/router/api/v1/workspace_service.go @@ -17,7 +17,6 @@ func (s *APIV1Service) GetWorkspaceProfile(ctx context.Context, _ *v1pb.GetWorks Mode: s.Profile.Mode, InstanceUrl: s.Profile.InstanceURL, } - println("workspaceProfile: ", workspaceProfile.Mode) owner, err := s.GetInstanceOwner(ctx) if err != nil { return nil, status.Errorf(codes.Internal, "failed to get instance owner: %v", err) diff --git a/store/db/mysql/memo.go b/store/db/mysql/memo.go index bddfe7c6..346523f7 100644 --- a/store/db/mysql/memo.go +++ b/store/db/mysql/memo.go @@ -146,7 +146,12 @@ func (d *DB) ListMemos(ctx context.Context, find *store.FindMemo) ([]*store.Memo fields = append(fields, "`memo`.`content` AS `content`") } - query := "SELECT " + strings.Join(fields, ", ") + " FROM `memo` LEFT JOIN `memo_organizer` ON `memo`.`id` = `memo_organizer`.`memo_id` AND `memo`.`creator_id` = `memo_organizer`.`user_id` LEFT JOIN `memo_relation` ON `memo`.`id` = `memo_relation`.`memo_id` AND `memo_relation`.`type` = \"COMMENT\" WHERE " + strings.Join(where, " AND ") + " HAVING " + strings.Join(having, " AND ") + " ORDER BY " + strings.Join(orders, ", ") + query := "SELECT " + strings.Join(fields, ", ") + " FROM `memo`" + " " + + "LEFT JOIN `memo_organizer` ON `memo`.`id` = `memo_organizer`.`memo_id` AND `memo`.`creator_id` = `memo_organizer`.`user_id`" + " " + + "LEFT JOIN `memo_relation` ON `memo`.`id` = `memo_relation`.`memo_id` AND `memo_relation`.`type` = 'COMMENT'" + " " + + "WHERE " + strings.Join(where, " AND ") + " " + + "HAVING " + strings.Join(having, " AND ") + " " + + "ORDER BY " + strings.Join(orders, ", ") if find.Limit != nil { query = fmt.Sprintf("%s LIMIT %d", query, *find.Limit) if find.Offset != nil { diff --git a/web/src/components/MemoResourceListView.tsx b/web/src/components/MemoResourceListView.tsx index d0f5c6c7..32956f5a 100644 --- a/web/src/components/MemoResourceListView.tsx +++ b/web/src/components/MemoResourceListView.tsx @@ -29,14 +29,14 @@ const MemoResourceListView = ({ resources = [] }: { resources: Resource[] }) => const MediaCard = ({ resource }: { resource: Resource }) => { const type = getResourceType(resource); - const url = getResourceUrl(resource); + const resourceUrl = getResourceUrl(resource); if (type === "image/*") { return ( handleImageClick(url)} + src={resource.externalLink ? resourceUrl : resourceUrl + "?thumbnail=true"} + onClick={() => handleImageClick(resourceUrl)} decoding="async" loading="lazy" /> @@ -47,7 +47,7 @@ const MemoResourceListView = ({ resources = [] }: { resources: Resource[] }) => className="cursor-pointer w-full h-full object-contain bg-zinc-100 dark:bg-zinc-800" preload="metadata" crossOrigin="anonymous" - src={url} + src={resourceUrl} controls /> );