Skip to content

Commit

Permalink
feat: reintroducing thumbnails (#3821)
Browse files Browse the repository at this point in the history
* Reintroducing thumbnails

* Aligned with linting rules

* making recomended code review change
- changed method names to start with lower case as they are not used outside of their package
- made receiver types for struct funcs to be pointers to not need to create copies

Trying to cover all linting issues
- converted slog warning to use attributes when logging warnings
- seperated imports to have package files in their own section

* Update go.mod

---------

Co-authored-by: boojack <[email protected]>
  • Loading branch information
RoccoSmit and boojack authored Aug 29, 2024
1 parent 615aa94 commit 9b1adfb
Show file tree
Hide file tree
Showing 11 changed files with 336 additions and 84 deletions.
5 changes: 5 additions & 0 deletions docs/apidocs.swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1799,6 +1799,11 @@ paths:
in: path
required: true
type: string
- name: thumbnail
description: A flag indicating if the thumbnail version of the resource should be returned
in: query
required: false
type: boolean
tags:
- ResourceService
definitions:
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ require (
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240723171418-e6d459c13d2a // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect
Expand Down Expand Up @@ -88,6 +89,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect
github.com/aws/smithy-go v1.20.3 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/disintegration/imaging v1.6.2
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/inconshreveable/mousetrap v1.1.0 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8Yc
github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f h1:U5y3Y5UE0w7amNe7Z5G/twsBW0KEalRQXZzf8ufSh9I=
github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f/go.mod h1:xH/i4TFMt8koVQZ6WFms69WAsDWr2XsYL3Hkl7jkoLE=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
Expand Down Expand Up @@ -483,6 +485,8 @@ golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8 h1:ESSUROHIBHg7USnszlcdmjBEw
golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
Expand Down
3 changes: 3 additions & 0 deletions proto/api/v1/resource_service.proto
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ message GetResourceBinaryRequest {

// The filename of the resource. Mainly used for downloading.
string filename = 2;

// A flag indicating if the thumbnail version of the resource should be returned
bool thumbnail = 3;
}

message UpdateResourceRequest {
Expand Down
174 changes: 92 additions & 82 deletions proto/gen/api/v1/resource_service.pb.go

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions proto/gen/api/v1/resource_service.pb.gw.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions server/router/api/v1/memo_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,11 @@ 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
Expand Down
59 changes: 59 additions & 0 deletions server/router/api/v1/resource_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/binary"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"regexp"
Expand Down Expand Up @@ -33,6 +34,9 @@ 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"
)

func (s *APIV1Service) CreateResource(ctx context.Context, request *v1pb.CreateResourceRequest) (*v1pb.Resource, error) {
Expand Down Expand Up @@ -171,6 +175,27 @@ 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 err != nil {
// thumbnail failures are logged as warnings and not cosidered critical failures as
// 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{
ContentType: resource.Type,
Data: thumbnailBlob,
}

return httpBody, nil
}
}

blob := resource.Blob
if resource.StorageType == storepb.ResourceStorageType_LOCAL {
resourcePath := filepath.FromSlash(resource.Reference)
Expand All @@ -192,6 +217,34 @@ func (s *APIV1Service) GetResourceBinary(ctx context.Context, request *v1pb.GetR
}
}

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
}
}

contentType := resource.Type
if strings.HasPrefix(contentType, "text/") {
contentType += "; charset=utf-8"
Expand Down Expand Up @@ -266,6 +319,12 @@ 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
}

Expand Down
146 changes: 146 additions & 0 deletions server/router/api/v1/thumbnail.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
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
}
2 changes: 1 addition & 1 deletion web/src/components/MemoResourceListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const MemoResourceListView = ({ resources = [] }: { resources: Resource[] }) =>
return (
<img
className="cursor-pointer min-h-full w-auto object-cover"
src={url}
src={url + "?thumbnail=true"}
onClick={() => handleImageClick(url)}
decoding="async"
loading="lazy"
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/ResourceIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const ResourceIcon = (props: Props) => {
<SquareDiv className={clsx(className, "flex items-center justify-center overflow-clip")}>
<img
className="min-w-full min-h-full object-cover"
src={resource.externalLink ? resourceUrl : resourceUrl + "?thumbnail=1"}
src={resource.externalLink ? resourceUrl : resourceUrl + "?thumbnail=true"}
onClick={() => showPreviewImageDialog(resourceUrl)}
decoding="async"
loading="lazy"
Expand Down

0 comments on commit 9b1adfb

Please sign in to comment.