fix(fileserver): preserve HDR image metadata in thumbnails

pull/5917/head
memoclaw 4 weeks ago
parent 267f90a3ff
commit c7242324a1

@ -38,6 +38,10 @@ const (
// thumbnailMaxSize is the maximum dimension (width or height) for thumbnails.
thumbnailMaxSize = 600
// thumbnailMetadataProbeSize is the maximum number of original image bytes inspected
// before thumbnail generation to detect metadata that the JPEG thumbnail pipeline cannot preserve.
thumbnailMetadataProbeSize = 1 << 20
// maxConcurrentThumbnails limits concurrent thumbnail generation to prevent memory exhaustion.
maxConcurrentThumbnails = 3
@ -61,6 +65,7 @@ var xssUnsafeTypes = map[string]bool{
var thumbnailSupportedTypes = map[string]bool{
"image/png": true,
"image/jpeg": true,
"image/jpg": true,
"image/heic": true,
"image/heif": true,
"image/webp": true,
@ -81,11 +86,14 @@ var avatarAllowedTypes = map[string]bool{
var SupportedThumbnailMimeTypes = []string{
"image/png",
"image/jpeg",
"image/jpg",
"image/heic",
"image/heif",
"image/webp",
}
var errUseOriginalForThumbnail = errors.New("serve original image instead of metadata-stripping thumbnail")
// dataURIRegex parses data URI format: data:image/png;base64,iVBORw0KGgo...
var dataURIRegex = regexp.MustCompile(`^data:(?P<type>[^;]+);base64,(?P<base64>.+)`)
@ -226,7 +234,9 @@ func (s *FileServerService) serveStaticFile(c *echo.Context, attachment *store.A
// Generate thumbnail for supported image types.
if wantThumbnail && thumbnailSupportedTypes[attachment.Type] {
if thumbnailBlob, err := s.getOrGenerateThumbnail(c.Request().Context(), attachment); err != nil {
slog.Warn("failed to get thumbnail", "error", err)
if !errors.Is(err, errUseOriginalForThumbnail) {
slog.Warn("failed to get thumbnail", "error", err)
}
} else {
setSecurityHeaders(c)
setMediaHeaders(c, "image/jpeg", attachment.Type)
@ -413,6 +423,14 @@ func (s *FileServerService) getOrGenerateThumbnail(ctx context.Context, attachme
return blob, nil
}
useOriginal, err := s.shouldUseOriginalForThumbnail(ctx, attachment)
if err != nil {
return nil, err
}
if useOriginal {
return nil, errUseOriginalForThumbnail
}
// Acquire semaphore to limit concurrent generation.
if err := s.thumbnailSemaphore.Acquire(ctx, 1); err != nil {
return nil, errors.Wrap(err, "failed to acquire semaphore")
@ -433,10 +451,76 @@ func (s *FileServerService) getThumbnailPath(attachment *store.Attachment) (stri
if err := os.MkdirAll(cacheFolder, os.ModePerm); err != nil {
return "", errors.Wrap(err, "failed to create thumbnail cache folder")
}
filename := fmt.Sprintf("%s.jpeg", attachment.UID)
filename := fmt.Sprintf("%s.v2.jpeg", attachment.UID)
return filepath.Join(cacheFolder, filename), nil
}
func (s *FileServerService) shouldUseOriginalForThumbnail(ctx context.Context, attachment *store.Attachment) (bool, error) {
if attachment.Type == "image/heic" || attachment.Type == "image/heif" {
return true, nil
}
if attachment.Type != "image/jpeg" && attachment.Type != "image/jpg" && attachment.Type != "image/png" && attachment.Type != "image/webp" {
return false, nil
}
reader, err := s.getAttachmentReader(ctx, attachment)
if err != nil {
return false, errors.Wrap(err, "failed to open image for metadata probe")
}
defer reader.Close()
probe, err := io.ReadAll(io.LimitReader(reader, thumbnailMetadataProbeSize))
if err != nil {
return false, errors.Wrap(err, "failed to read image metadata probe")
}
return hasThumbnailSensitiveMetadata(attachment.Type, probe), nil
}
func hasThumbnailSensitiveMetadata(mimeType string, data []byte) bool {
if mimeType == "image/heic" || mimeType == "image/heif" {
return true
}
for _, marker := range [][]byte{
[]byte("ICC_PROFILE"),
[]byte("iCCP"),
[]byte("ICCP"),
[]byte("cICP"),
[]byte("mDCv"),
[]byte("cLLi"),
} {
if bytes.Contains(data, marker) {
return true
}
}
lowerData := strings.ToLower(string(data))
for _, marker := range []string{
"hdrgm:",
"hdr gain map",
"hdrgainmap",
"gainmap",
"ultrahdr",
"adobe:hdrgainmap",
"aux:hdr",
"auxiliaryimagetype",
"display p3",
"display-p3",
"rec.2020",
"bt.2020",
"arib-std-b67",
"smpte st 2084",
} {
if strings.Contains(lowerData, marker) {
return true
}
}
return false
}
// readCachedThumbnail reads a thumbnail from the cache directory.
func (*FileServerService) readCachedThumbnail(path string) ([]byte, error) {
file, err := os.Open(path)

@ -1,8 +1,14 @@
package fileserver
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"hash/crc32"
"image"
"image/color"
"image/png"
"net/http"
"net/http/httptest"
"strings"
@ -280,6 +286,126 @@ func TestServeAttachmentFile_SVGThumbnailServedAsImageWithSecurityHeaders(t *tes
require.Equal(t, svgContent, rec.Body.Bytes())
}
func TestServeAttachmentFile_ThumbnailWithSensitiveMetadataServesOriginal(t *testing.T) {
ctx := context.Background()
svc, fs, _, cleanup := newShareAttachmentTestServices(ctx, t)
defer cleanup()
creator, err := svc.Store.CreateUser(ctx, &store.User{
Username: "hdr-owner",
Role: store.RoleUser,
Email: "hdr-owner@example.com",
})
require.NoError(t, err)
creatorCtx := context.WithValue(ctx, auth.UserIDContextKey, creator.ID)
imageContent := testPNGWithChunk(t, "cICP", []byte{9, 16, 9, 1})
attachment, err := svc.CreateAttachment(creatorCtx, &apiv1.CreateAttachmentRequest{
Attachment: &apiv1.Attachment{
Filename: "hdr.png",
Type: "image/png",
Content: imageContent,
},
})
require.NoError(t, err)
_, err = svc.CreateMemo(creatorCtx, &apiv1.CreateMemoRequest{
Memo: &apiv1.Memo{
Content: "hdr memo",
Visibility: apiv1.Visibility_PUBLIC,
Attachments: []*apiv1.Attachment{
{Name: attachment.Name},
},
},
})
require.NoError(t, err)
e := echo.New()
fs.RegisterRoutes(e)
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/file/%s/%s?thumbnail=true", attachment.Name, attachment.Filename), nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "image/png", rec.Header().Get("Content-Type"))
require.Equal(t, imageContent, rec.Body.Bytes())
}
func TestHasThumbnailSensitiveMetadata(t *testing.T) {
tests := []struct {
name string
mimeType string
data []byte
want bool
}{
{
name: "jpeg hdr gain map",
mimeType: "image/jpeg",
data: []byte("xmp hdrgm:Version=\"1.0\""),
want: true,
},
{
name: "jpeg icc profile",
mimeType: "image/jpeg",
data: []byte("ICC_PROFILE"),
want: true,
},
{
name: "png cicp chunk",
mimeType: "image/png",
data: []byte("cICP"),
want: true,
},
{
name: "heic",
mimeType: "image/heic",
data: nil,
want: true,
},
{
name: "plain jpeg",
mimeType: "image/jpeg",
data: []byte("plain image data"),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, hasThumbnailSensitiveMetadata(tt.mimeType, tt.data))
})
}
}
func testPNGWithChunk(t *testing.T, chunkType string, chunkData []byte) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
img.Set(0, 0, color.RGBA{R: 255, A: 255})
var encoded bytes.Buffer
require.NoError(t, png.Encode(&encoded, img))
pngData := encoded.Bytes()
iendIndex := bytes.LastIndex(pngData, []byte("IEND"))
require.GreaterOrEqual(t, iendIndex, 4)
chunkStart := iendIndex - 4
var chunk bytes.Buffer
require.NoError(t, binary.Write(&chunk, binary.BigEndian, uint32(len(chunkData))))
chunk.WriteString(chunkType)
chunk.Write(chunkData)
checksum := crc32.ChecksumIEEE(append([]byte(chunkType), chunkData...))
require.NoError(t, binary.Write(&chunk, binary.BigEndian, checksum))
result := make([]byte, 0, len(pngData)+chunk.Len())
result = append(result, pngData[:chunkStart]...)
result = append(result, chunk.Bytes()...)
result = append(result, pngData[chunkStart:]...)
return result
}
func newShareAttachmentTestServices(ctx context.Context, t *testing.T) (*apiv1service.APIV1Service, *FileServerService, *store.Store, func()) {
t.Helper()

Loading…
Cancel
Save