From 808587ec88418086de2af2cc8545d5b06a991bf5 Mon Sep 17 00:00:00 2001 From: Florian Dewald Date: Wed, 22 Oct 2025 22:29:16 +0100 Subject: [PATCH] feat: generate thumbnails for images stored in S3 and generate thumbnails with a maximum size --- plugin/storage/s3/s3.go | 14 +++++ server/router/api/v1/attachment_service.go | 59 +++++++++++++++++-- web/src/components/AttachmentIcon.tsx | 4 +- web/src/components/MemoAttachmentListView.tsx | 5 +- web/src/utils/attachment.ts | 4 ++ 5 files changed, 77 insertions(+), 9 deletions(-) diff --git a/plugin/storage/s3/s3.go b/plugin/storage/s3/s3.go index 34b55c107..6d326c7e2 100644 --- a/plugin/storage/s3/s3.go +++ b/plugin/storage/s3/s3.go @@ -79,6 +79,20 @@ func (c *Client) PresignGetObject(ctx context.Context, key string) (string, erro return presignResult.URL, nil } +// GetObject retrieves an object from S3. +func (c *Client) GetObject(ctx context.Context, key string) ([]byte, error) { + downloader := manager.NewDownloader(c.Client) + buffer := manager.NewWriteAtBuffer([]byte{}) + _, err := downloader.Download(ctx, buffer, &s3.GetObjectInput{ + Bucket: c.Bucket, + Key: aws.String(key), + }) + if err != nil { + return nil, errors.Wrap(err, "failed to download object") + } + return buffer.Bytes(), nil +} + // DeleteObject deletes an object in S3. func (c *Client) DeleteObject(ctx context.Context, key string) error { _, err := c.Client.DeleteObject(ctx, &s3.DeleteObjectInput{ diff --git a/server/router/api/v1/attachment_service.go b/server/router/api/v1/attachment_service.go index 40362cf45..1efbf4a82 100644 --- a/server/router/api/v1/attachment_service.go +++ b/server/router/api/v1/attachment_service.go @@ -492,13 +492,40 @@ func (s *APIV1Service) GetAttachmentBlob(attachment *store.Attachment) ([]byte, } return blob, nil } + // For S3 storage, download the file from S3. + if attachment.StorageType == storepb.AttachmentStorageType_S3 { + if attachment.Payload == nil { + return nil, errors.New("attachment payload is missing") + } + s3Object := attachment.Payload.GetS3Object() + if s3Object == nil { + return nil, errors.New("S3 object payload is missing") + } + if s3Object.S3Config == nil { + return nil, errors.New("S3 config is missing") + } + if s3Object.Key == "" { + return nil, errors.New("S3 object key is missing") + } + + s3Client, err := s3.NewClient(context.Background(), s3Object.S3Config) + if err != nil { + return nil, errors.Wrap(err, "failed to create S3 client") + } + + blob, err := s3Client.GetObject(context.Background(), s3Object.Key) + if err != nil { + return nil, errors.Wrap(err, "failed to get object from S3") + } + return blob, nil + } // For database storage, return the blob from the database. return attachment.Blob, nil } const ( - // thumbnailRatio is the ratio of the thumbnail image. - thumbnailRatio = 0.8 + // thumbnailMaxSize is the maximum size in pixels for the largest dimension of the thumbnail image. + thumbnailMaxSize = 600 ) // getOrGenerateThumbnail returns the thumbnail image of the attachment. @@ -523,9 +550,31 @@ func (s *APIV1Service) getOrGenerateThumbnail(attachment *store.Attachment) ([]b return nil, errors.Wrap(err, "failed to decode thumbnail image") } - thumbnailWidth := int(float64(img.Bounds().Dx()) * thumbnailRatio) - // Resize the image to the thumbnailWidth. - thumbnailImage := imaging.Resize(img, thumbnailWidth, 0, imaging.Lanczos) + // The largest dimension is set to thumbnailMaxSize and the smaller dimension is scaled proportionally. + // Small images are not enlarged. + width := img.Bounds().Dx() + height := img.Bounds().Dy() + var thumbnailWidth, thumbnailHeight int + + // Only resize if the image is larger than thumbnailMaxSize + if max(width, height) > thumbnailMaxSize { + if width > height { + // Landscape or square - constrain width, maintain aspect ratio for height + thumbnailWidth = thumbnailMaxSize + thumbnailHeight = 0 + } else { + // Portrait - constrain height, maintain aspect ratio for width + thumbnailWidth = 0 + thumbnailHeight = thumbnailMaxSize + } + } else { + // Keep original dimensions for small images + thumbnailWidth = width + thumbnailHeight = height + } + + // Resize the image to the calculated dimensions. + thumbnailImage := imaging.Resize(img, thumbnailWidth, thumbnailHeight, imaging.Lanczos) if err := imaging.Save(thumbnailImage, filePath); err != nil { return nil, errors.Wrap(err, "failed to save thumbnail file") } diff --git a/web/src/components/AttachmentIcon.tsx b/web/src/components/AttachmentIcon.tsx index a28259e14..c817dad55 100644 --- a/web/src/components/AttachmentIcon.tsx +++ b/web/src/components/AttachmentIcon.tsx @@ -12,7 +12,7 @@ import { import React, { useState } from "react"; import { cn } from "@/lib/utils"; import { Attachment } from "@/types/proto/api/v1/attachment_service"; -import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment"; +import { getAttachmentThumbnailUrl, getAttachmentType, getAttachmentUrl } from "@/utils/attachment"; import PreviewImageDialog from "./PreviewImageDialog"; import SquareDiv from "./kit/SquareDiv"; @@ -48,7 +48,7 @@ const AttachmentIcon = (props: Props) => { { // Fallback to original image if thumbnail fails diff --git a/web/src/components/MemoAttachmentListView.tsx b/web/src/components/MemoAttachmentListView.tsx index bfa58b47a..08ddfb98f 100644 --- a/web/src/components/MemoAttachmentListView.tsx +++ b/web/src/components/MemoAttachmentListView.tsx @@ -1,7 +1,7 @@ import { memo, useState } from "react"; import { cn } from "@/lib/utils"; import { Attachment } from "@/types/proto/api/v1/attachment_service"; -import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment"; +import { getAttachmentType, getAttachmentUrl, getAttachmentThumbnailUrl } from "@/utils/attachment"; import MemoAttachment from "./MemoAttachment"; import PreviewImageDialog from "./PreviewImageDialog"; @@ -35,12 +35,13 @@ const MemoAttachmentListView = ({ attachments = [] }: { attachments: Attachment[ const MediaCard = ({ attachment, className }: { attachment: Attachment; className?: string }) => { const type = getAttachmentType(attachment); const attachmentUrl = getAttachmentUrl(attachment); + const attachmentThumbnailUrl = getAttachmentThumbnailUrl(attachment); if (type === "image/*") { return ( { // Fallback to original image if thumbnail fails const target = e.target as HTMLImageElement; diff --git a/web/src/utils/attachment.ts b/web/src/utils/attachment.ts index cf6ab9f4f..a2cfe1dda 100644 --- a/web/src/utils/attachment.ts +++ b/web/src/utils/attachment.ts @@ -8,6 +8,10 @@ export const getAttachmentUrl = (attachment: Attachment) => { return `${window.location.origin}/file/${attachment.name}/${attachment.filename}`; }; +export const getAttachmentThumbnailUrl = (attachment: Attachment) => { + return `${window.location.origin}/file/${attachment.name}/${attachment.filename}?thumbnail=true`; +}; + export const getAttachmentType = (attachment: Attachment) => { if (isImage(attachment.type)) { return "image/*";