From 29c67c704c5834d052476cbaf561cb7461674515 Mon Sep 17 00:00:00 2001 From: Adrien Vermersch Date: Wed, 17 Jun 2026 14:07:50 +0200 Subject: [PATCH 1/8] fix: add Matrix metadata for LINE images --- .gitignore | 3 - pkg/connector/handlers/image.go | 86 ++++++++++++++++-- pkg/connector/handlers/image_test.go | 128 +++++++++++++++++++++++++++ 3 files changed, 205 insertions(+), 12 deletions(-) create mode 100644 pkg/connector/handlers/image_test.go diff --git a/.gitignore b/.gitignore index 14a1822..f894503 100644 --- a/.gitignore +++ b/.gitignore @@ -24,8 +24,5 @@ # Executables *.exe -# Internal Test files -*_test.go - # internal tools /tools diff --git a/pkg/connector/handlers/image.go b/pkg/connector/handlers/image.go index 173041d..8000699 100644 --- a/pkg/connector/handlers/image.go +++ b/pkg/connector/handlers/image.go @@ -1,14 +1,22 @@ package handlers import ( + "bytes" "context" "encoding/json" "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "net/http" "strings" "time" + _ "golang.org/x/image/webp" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" "github.com/highesttt/matrix-line-messenger/pkg/line" ) @@ -105,8 +113,9 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int } // Upload to Matrix + fileName, mimeType, imageInfo := lineImageMediaInfo(imgData) uploadStart := time.Now() - mxc, file, err := intent.UploadMedia(ctx, portal.MXID, imgData, "image.jpg", "image/jpeg") + mxc, file, err := intent.UploadMedia(ctx, portal.MXID, imgData, fileName, mimeType) uploadDuration := time.Since(uploadStart) if err != nil { h.Log.Error(). @@ -135,19 +144,78 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int return &bridgev2.ConvertedMessage{ Parts: []*bridgev2.ConvertedMessagePart{ { - Type: event.EventMessage, - Content: &event.MessageEventContent{ - MsgType: event.MsgImage, - Body: "image.jpg", - URL: mxc, - File: file, - RelatesTo: relatesTo, - }, + Type: event.EventMessage, + Content: lineImageEventContent(mxc, file, fileName, imageInfo, relatesTo), }, }, }, nil } +func lineImageEventContent(mxc id.ContentURIString, file *event.EncryptedFileInfo, fileName string, info *event.FileInfo, relatesTo *event.RelatesTo) *event.MessageEventContent { + return &event.MessageEventContent{ + MsgType: event.MsgImage, + Body: fileName, + URL: mxc, + File: file, + Info: info, + RelatesTo: relatesTo, + } +} + +func lineImageMediaInfo(data []byte) (string, string, *event.FileInfo) { + mimeType := detectLineImageMimeType(data) + info := &event.FileInfo{ + MimeType: mimeType, + Size: len(data), + } + + if config, _, err := image.DecodeConfig(bytes.NewReader(data)); err == nil { + info.Width = config.Width + info.Height = config.Height + } + + return "image." + imageExtensionForMIME(mimeType), mimeType, info +} + +func detectLineImageMimeType(data []byte) string { + mimeType := http.DetectContentType(data) + if strings.HasPrefix(mimeType, "image/") { + return mimeType + } + + if len(data) >= 12 && string(data[4:8]) == "ftyp" { + switch string(data[8:12]) { + case "heic", "heix", "hevc", "hevx": + return "image/heic" + case "heim", "heis", "mif1", "msf1": + return "image/heif" + case "avif", "avis": + return "image/avif" + } + } + + return "image/jpeg" +} + +func imageExtensionForMIME(mimeType string) string { + switch mimeType { + case "image/png": + return "png" + case "image/gif": + return "gif" + case "image/webp": + return "webp" + case "image/heic": + return "heic" + case "image/heif": + return "heif" + case "image/avif": + return "avif" + default: + return "jpg" + } +} + func lineMediaCategory(metadata map[string]string) string { if metadata == nil || metadata["MEDIA_CONTENT_INFO"] == "" { return "" diff --git a/pkg/connector/handlers/image_test.go b/pkg/connector/handlers/image_test.go new file mode 100644 index 0000000..fa77d4e --- /dev/null +++ b/pkg/connector/handlers/image_test.go @@ -0,0 +1,128 @@ +package handlers + +import ( + "bytes" + "image" + "image/color" + "image/jpeg" + "image/png" + "testing" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +func TestLineImageMediaInfo(t *testing.T) { + tests := []struct { + name string + data []byte + fileName string + mimeType string + width int + height int + }{ + { + name: "jpeg", + data: testJPEG(t, 23, 17), + fileName: "image.jpg", + mimeType: "image/jpeg", + width: 23, + height: 17, + }, + { + name: "png", + data: testPNG(t, 19, 11), + fileName: "image.png", + mimeType: "image/png", + width: 19, + height: 11, + }, + { + name: "heic", + data: []byte("\x00\x00\x00\x18ftypheic\x00\x00\x00\x00"), + fileName: "image.heic", + mimeType: "image/heic", + }, + { + name: "fallback", + data: []byte("not an image"), + fileName: "image.jpg", + mimeType: "image/jpeg", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fileName, mimeType, info := lineImageMediaInfo(tt.data) + + if fileName != tt.fileName { + t.Fatalf("unexpected file name: got %q, want %q", fileName, tt.fileName) + } + if mimeType != tt.mimeType { + t.Fatalf("unexpected mime type: got %q, want %q", mimeType, tt.mimeType) + } + if info == nil { + t.Fatal("expected file info") + } + if info.MimeType != tt.mimeType { + t.Fatalf("unexpected info mime type: got %q, want %q", info.MimeType, tt.mimeType) + } + if info.Size != len(tt.data) { + t.Fatalf("unexpected info size: got %d, want %d", info.Size, len(tt.data)) + } + if info.Width != tt.width || info.Height != tt.height { + t.Fatalf("unexpected dimensions: got %dx%d, want %dx%d", info.Width, info.Height, tt.width, tt.height) + } + }) + } +} + +func TestLineImageEventContentIncludesFileInfo(t *testing.T) { + relatesTo := &event.RelatesTo{} + info := &event.FileInfo{MimeType: "image/jpeg", Size: 123, Width: 10, Height: 12} + + content := lineImageEventContent(id.ContentURIString("mxc://example/image"), nil, "image.jpg", info, relatesTo) + + if content.MsgType != event.MsgImage { + t.Fatalf("unexpected msgtype: got %q, want %q", content.MsgType, event.MsgImage) + } + if content.Body != "image.jpg" { + t.Fatalf("unexpected body: got %q", content.Body) + } + if content.Info != info { + t.Fatal("expected image file info to be attached to content") + } + if content.RelatesTo != relatesTo { + t.Fatal("expected relates_to to be preserved") + } +} + +func testJPEG(t *testing.T, width, height int) []byte { + t.Helper() + + var buf bytes.Buffer + if err := jpeg.Encode(&buf, testImage(width, height), nil); err != nil { + t.Fatalf("encode jpeg: %v", err) + } + return buf.Bytes() +} + +func testPNG(t *testing.T, width, height int) []byte { + t.Helper() + + var buf bytes.Buffer + if err := png.Encode(&buf, testImage(width, height)); err != nil { + t.Fatalf("encode png: %v", err) + } + return buf.Bytes() +} + +func testImage(width, height int) image.Image { + img := image.NewRGBA(image.Rect(0, 0, width, height)) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 0x80, A: 0xff}) + } + } + return img +} From 4d06d2bb0e28f366fa5e928ac19f2bcdab052b03 Mon Sep 17 00:00:00 2001 From: Adrien Vermersch Date: Wed, 17 Jun 2026 14:52:56 +0200 Subject: [PATCH 2/8] fix: tighten LINE image metadata detection --- pkg/connector/handlers/image.go | 87 +++++++++++++++++++++------- pkg/connector/handlers/image_test.go | 80 +++++++++++++++++++++---- 2 files changed, 135 insertions(+), 32 deletions(-) diff --git a/pkg/connector/handlers/image.go b/pkg/connector/handlers/image.go index 8000699..60ef54e 100644 --- a/pkg/connector/handlers/image.go +++ b/pkg/connector/handlers/image.go @@ -9,6 +9,7 @@ import ( _ "image/gif" _ "image/jpeg" _ "image/png" + "mime" "net/http" "strings" "time" @@ -113,9 +114,22 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int } // Upload to Matrix - fileName, mimeType, imageInfo := lineImageMediaInfo(imgData) + imageMedia := lineImageMediaInfo(imgData) + if imageMedia.usedMimeFallback { + h.Log.Debug(). + Int("size_bytes", len(imgData)). + Msg("Falling back to JPEG MIME type for LINE image") + } + if imageMedia.decodeErr != nil { + h.Log.Debug(). + Err(imageMedia.decodeErr). + Str("mime_type", imageMedia.mimeType). + Int("size_bytes", len(imgData)). + Msg("Could not decode LINE image dimensions") + } + uploadStart := time.Now() - mxc, file, err := intent.UploadMedia(ctx, portal.MXID, imgData, fileName, mimeType) + mxc, file, err := intent.UploadMedia(ctx, portal.MXID, imgData, imageMedia.fileName, imageMedia.mimeType) uploadDuration := time.Since(uploadStart) if err != nil { h.Log.Error(). @@ -145,7 +159,7 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int Parts: []*bridgev2.ConvertedMessagePart{ { Type: event.EventMessage, - Content: lineImageEventContent(mxc, file, fileName, imageInfo, relatesTo), + Content: lineImageEventContent(mxc, file, imageMedia.fileName, imageMedia.info, relatesTo), }, }, }, nil @@ -162,43 +176,66 @@ func lineImageEventContent(mxc id.ContentURIString, file *event.EncryptedFileInf } } -func lineImageMediaInfo(data []byte) (string, string, *event.FileInfo) { - mimeType := detectLineImageMimeType(data) +type lineImageMedia struct { + fileName string + mimeType string + info *event.FileInfo + usedMimeFallback bool + decodeErr error +} + +func lineImageMediaInfo(data []byte) lineImageMedia { + mimeType, usedFallback := detectLineImageMimeType(data) info := &event.FileInfo{ MimeType: mimeType, Size: len(data), } + var decodeErr error if config, _, err := image.DecodeConfig(bytes.NewReader(data)); err == nil { info.Width = config.Width info.Height = config.Height + } else { + decodeErr = err } - return "image." + imageExtensionForMIME(mimeType), mimeType, info + return lineImageMedia{ + fileName: "image." + imageExtensionForMIME(mimeType), + mimeType: mimeType, + info: info, + usedMimeFallback: usedFallback, + decodeErr: decodeErr, + } } -func detectLineImageMimeType(data []byte) string { +func detectLineImageMimeType(data []byte) (string, bool) { mimeType := http.DetectContentType(data) if strings.HasPrefix(mimeType, "image/") { - return mimeType + return mimeType, false } - if len(data) >= 12 && string(data[4:8]) == "ftyp" { - switch string(data[8:12]) { - case "heic", "heix", "hevc", "hevx": - return "image/heic" - case "heim", "heis", "mif1", "msf1": - return "image/heif" - case "avif", "avis": - return "image/avif" + if len(data) >= 12 && bytes.Equal(data[4:8], []byte("ftyp")) { + brand := data[8:12] + switch { + case bytes.Equal(brand, []byte("heic")) || bytes.Equal(brand, []byte("heix")) || bytes.Equal(brand, []byte("hevc")) || bytes.Equal(brand, []byte("hevx")): + return "image/heic", false + case bytes.Equal(brand, []byte("heim")) || bytes.Equal(brand, []byte("heis")) || bytes.Equal(brand, []byte("mif1")) || bytes.Equal(brand, []byte("msf1")): + return "image/heif", false + case bytes.Equal(brand, []byte("avif")) || bytes.Equal(brand, []byte("avis")): + return "image/avif", false } } - return "image/jpeg" + return "image/jpeg", true } func imageExtensionForMIME(mimeType string) string { - switch mimeType { + normalizedMIMEType, _, _ := strings.Cut(mimeType, ";") + normalizedMIMEType = strings.ToLower(strings.TrimSpace(normalizedMIMEType)) + + switch normalizedMIMEType { + case "image/jpeg": + return "jpg" case "image/png": return "png" case "image/gif": @@ -211,9 +248,19 @@ func imageExtensionForMIME(mimeType string) string { return "heif" case "image/avif": return "avif" - default: - return "jpg" + case "image/bmp": + return "bmp" + case "image/tiff": + return "tiff" + case "image/x-icon", "image/vnd.microsoft.icon": + return "ico" } + + if extensions, err := mime.ExtensionsByType(normalizedMIMEType); err == nil && len(extensions) > 0 { + return strings.TrimPrefix(extensions[0], ".") + } + + return "jpg" } func lineMediaCategory(metadata map[string]string) string { diff --git a/pkg/connector/handlers/image_test.go b/pkg/connector/handlers/image_test.go index fa77d4e..dd40194 100644 --- a/pkg/connector/handlers/image_test.go +++ b/pkg/connector/handlers/image_test.go @@ -43,6 +43,24 @@ func TestLineImageMediaInfo(t *testing.T) { fileName: "image.heic", mimeType: "image/heic", }, + { + name: "heif", + data: []byte("\x00\x00\x00\x18ftypmif1\x00\x00\x00\x00"), + fileName: "image.heif", + mimeType: "image/heif", + }, + { + name: "avif", + data: []byte("\x00\x00\x00\x18ftypavif\x00\x00\x00\x00"), + fileName: "image.avif", + mimeType: "image/avif", + }, + { + name: "bmp", + data: []byte("BM line image data"), + fileName: "image.bmp", + mimeType: "image/bmp", + }, { name: "fallback", data: []byte("not an image"), @@ -53,25 +71,63 @@ func TestLineImageMediaInfo(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - fileName, mimeType, info := lineImageMediaInfo(tt.data) + media := lineImageMediaInfo(tt.data) - if fileName != tt.fileName { - t.Fatalf("unexpected file name: got %q, want %q", fileName, tt.fileName) + if media.fileName != tt.fileName { + t.Fatalf("unexpected file name: got %q, want %q", media.fileName, tt.fileName) } - if mimeType != tt.mimeType { - t.Fatalf("unexpected mime type: got %q, want %q", mimeType, tt.mimeType) + if media.mimeType != tt.mimeType { + t.Fatalf("unexpected mime type: got %q, want %q", media.mimeType, tt.mimeType) } - if info == nil { + if media.info == nil { t.Fatal("expected file info") } - if info.MimeType != tt.mimeType { - t.Fatalf("unexpected info mime type: got %q, want %q", info.MimeType, tt.mimeType) + if media.info.MimeType != tt.mimeType { + t.Fatalf("unexpected info mime type: got %q, want %q", media.info.MimeType, tt.mimeType) + } + if media.info.Size != len(tt.data) { + t.Fatalf("unexpected info size: got %d, want %d", media.info.Size, len(tt.data)) } - if info.Size != len(tt.data) { - t.Fatalf("unexpected info size: got %d, want %d", info.Size, len(tt.data)) + if media.info.Width != tt.width || media.info.Height != tt.height { + t.Fatalf("unexpected dimensions: got %dx%d, want %dx%d", media.info.Width, media.info.Height, tt.width, tt.height) + } + }) + } +} + +func TestLineImageMediaInfoFallbackState(t *testing.T) { + tests := []struct { + name string + data []byte + usedMimeFallback bool + hasDecodeErr bool + }{ + { + name: "jpeg", + data: testJPEG(t, 2, 2), + }, + { + name: "unknown", + data: []byte("not an image"), + usedMimeFallback: true, + hasDecodeErr: true, + }, + { + name: "heif without decoder", + data: []byte("\x00\x00\x00\x18ftypmif1\x00\x00\x00\x00"), + hasDecodeErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + media := lineImageMediaInfo(tt.data) + + if media.usedMimeFallback != tt.usedMimeFallback { + t.Fatalf("unexpected fallback state: got %t, want %t", media.usedMimeFallback, tt.usedMimeFallback) } - if info.Width != tt.width || info.Height != tt.height { - t.Fatalf("unexpected dimensions: got %dx%d, want %dx%d", info.Width, info.Height, tt.width, tt.height) + if (media.decodeErr != nil) != tt.hasDecodeErr { + t.Fatalf("unexpected decode error state: got %v, want error %t", media.decodeErr, tt.hasDecodeErr) } }) } From 59501441a10544cc3620489171deda44fa113868 Mon Sep 17 00:00:00 2001 From: Adrien Vermersch Date: Wed, 17 Jun 2026 15:09:40 +0200 Subject: [PATCH 3/8] fix: refine LINE image metadata detection --- pkg/connector/handlers/image.go | 27 +++++++++++++------ pkg/connector/handlers/image_test.go | 39 +++++++++++++++++++++++++--- 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/pkg/connector/handlers/image.go b/pkg/connector/handlers/image.go index 60ef54e..e6c0059 100644 --- a/pkg/connector/handlers/image.go +++ b/pkg/connector/handlers/image.go @@ -195,7 +195,7 @@ func lineImageMediaInfo(data []byte) lineImageMedia { if config, _, err := image.DecodeConfig(bytes.NewReader(data)); err == nil { info.Width = config.Width info.Height = config.Height - } else { + } else if !usedFallback && shouldLogLineImageDecodeError(mimeType) { decodeErr = err } @@ -215,13 +215,12 @@ func detectLineImageMimeType(data []byte) (string, bool) { } if len(data) >= 12 && bytes.Equal(data[4:8], []byte("ftyp")) { - brand := data[8:12] - switch { - case bytes.Equal(brand, []byte("heic")) || bytes.Equal(brand, []byte("heix")) || bytes.Equal(brand, []byte("hevc")) || bytes.Equal(brand, []byte("hevx")): + switch [4]byte{data[8], data[9], data[10], data[11]} { + case [4]byte{'h', 'e', 'i', 'c'}, [4]byte{'h', 'e', 'i', 'x'}, [4]byte{'h', 'e', 'v', 'c'}, [4]byte{'h', 'e', 'v', 'x'}: return "image/heic", false - case bytes.Equal(brand, []byte("heim")) || bytes.Equal(brand, []byte("heis")) || bytes.Equal(brand, []byte("mif1")) || bytes.Equal(brand, []byte("msf1")): + case [4]byte{'h', 'e', 'i', 'm'}, [4]byte{'h', 'e', 'i', 's'}, [4]byte{'m', 'i', 'f', '1'}, [4]byte{'m', 's', 'f', '1'}: return "image/heif", false - case bytes.Equal(brand, []byte("avif")) || bytes.Equal(brand, []byte("avis")): + case [4]byte{'a', 'v', 'i', 'f'}, [4]byte{'a', 'v', 'i', 's'}: return "image/avif", false } } @@ -230,8 +229,7 @@ func detectLineImageMimeType(data []byte) (string, bool) { } func imageExtensionForMIME(mimeType string) string { - normalizedMIMEType, _, _ := strings.Cut(mimeType, ";") - normalizedMIMEType = strings.ToLower(strings.TrimSpace(normalizedMIMEType)) + normalizedMIMEType := normalizedImageMIMEType(mimeType) switch normalizedMIMEType { case "image/jpeg": @@ -263,6 +261,19 @@ func imageExtensionForMIME(mimeType string) string { return "jpg" } +func shouldLogLineImageDecodeError(mimeType string) bool { + switch normalizedImageMIMEType(mimeType) { + case "image/jpeg", "image/png", "image/gif", "image/webp": + return true + } + return false +} + +func normalizedImageMIMEType(mimeType string) string { + normalizedMIMEType, _, _ := strings.Cut(mimeType, ";") + return strings.ToLower(strings.TrimSpace(normalizedMIMEType)) +} + func lineMediaCategory(metadata map[string]string) string { if metadata == nil || metadata["MEDIA_CONTENT_INFO"] == "" { return "" diff --git a/pkg/connector/handlers/image_test.go b/pkg/connector/handlers/image_test.go index dd40194..af0b39c 100644 --- a/pkg/connector/handlers/image_test.go +++ b/pkg/connector/handlers/image_test.go @@ -110,11 +110,14 @@ func TestLineImageMediaInfoFallbackState(t *testing.T) { name: "unknown", data: []byte("not an image"), usedMimeFallback: true, - hasDecodeErr: true, }, { - name: "heif without decoder", - data: []byte("\x00\x00\x00\x18ftypmif1\x00\x00\x00\x00"), + name: "heif without decoder", + data: []byte("\x00\x00\x00\x18ftypmif1\x00\x00\x00\x00"), + }, + { + name: "truncated jpeg", + data: []byte{0xff, 0xd8, 0xff}, hasDecodeErr: true, }, } @@ -133,6 +136,36 @@ func TestLineImageMediaInfoFallbackState(t *testing.T) { } } +func TestImageExtensionForMIME(t *testing.T) { + tests := []struct { + mimeType string + extension string + }{ + {mimeType: "image/jpeg", extension: "jpg"}, + {mimeType: "image/png", extension: "png"}, + {mimeType: "image/gif", extension: "gif"}, + {mimeType: "image/webp", extension: "webp"}, + {mimeType: "image/heic", extension: "heic"}, + {mimeType: "image/heif", extension: "heif"}, + {mimeType: "image/avif", extension: "avif"}, + {mimeType: "image/bmp", extension: "bmp"}, + {mimeType: "image/tiff", extension: "tiff"}, + {mimeType: "image/x-icon", extension: "ico"}, + {mimeType: "image/vnd.microsoft.icon", extension: "ico"}, + {mimeType: "image/png; charset=binary", extension: "png"}, + {mimeType: "image/svg+xml", extension: "svg"}, + {mimeType: "unknown/unknown", extension: "jpg"}, + } + + for _, tt := range tests { + t.Run(tt.mimeType, func(t *testing.T) { + if extension := imageExtensionForMIME(tt.mimeType); extension != tt.extension { + t.Fatalf("unexpected extension: got %q, want %q", extension, tt.extension) + } + }) + } +} + func TestLineImageEventContentIncludesFileInfo(t *testing.T) { relatesTo := &event.RelatesTo{} info := &event.FileInfo{MimeType: "image/jpeg", Size: 123, Width: 10, Height: 12} From 7677aafcd5c98a32c6ae636cf5f394d766fd87bb Mon Sep 17 00:00:00 2001 From: Adrien Vermersch Date: Wed, 17 Jun 2026 16:44:42 +0200 Subject: [PATCH 4/8] fix: make SVG image extension deterministic --- pkg/connector/handlers/image.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pkg/connector/handlers/image.go b/pkg/connector/handlers/image.go index e6c0059..6f2d361 100644 --- a/pkg/connector/handlers/image.go +++ b/pkg/connector/handlers/image.go @@ -230,7 +230,6 @@ func detectLineImageMimeType(data []byte) (string, bool) { func imageExtensionForMIME(mimeType string) string { normalizedMIMEType := normalizedImageMIMEType(mimeType) - switch normalizedMIMEType { case "image/jpeg": return "jpg" @@ -250,14 +249,14 @@ func imageExtensionForMIME(mimeType string) string { return "bmp" case "image/tiff": return "tiff" + case "image/svg+xml": + return "svg" case "image/x-icon", "image/vnd.microsoft.icon": return "ico" } - if extensions, err := mime.ExtensionsByType(normalizedMIMEType); err == nil && len(extensions) > 0 { return strings.TrimPrefix(extensions[0], ".") } - return "jpg" } From 4601bb55e11513aa47e6b02c0a06e14f57ef6655 Mon Sep 17 00:00:00 2001 From: Adrien Vermersch Date: Wed, 17 Jun 2026 17:08:52 +0200 Subject: [PATCH 5/8] fix: scan LINE image ftyp compatible brands --- pkg/connector/handlers/image.go | 55 +++++++++++++--------------- pkg/connector/handlers/image_test.go | 31 ++++++++++++++-- 2 files changed, 53 insertions(+), 33 deletions(-) diff --git a/pkg/connector/handlers/image.go b/pkg/connector/handlers/image.go index 6f2d361..5f9df10 100644 --- a/pkg/connector/handlers/image.go +++ b/pkg/connector/handlers/image.go @@ -9,7 +9,6 @@ import ( _ "image/gif" _ "image/jpeg" _ "image/png" - "mime" "net/http" "strings" "time" @@ -215,13 +214,29 @@ func detectLineImageMimeType(data []byte) (string, bool) { } if len(data) >= 12 && bytes.Equal(data[4:8], []byte("ftyp")) { - switch [4]byte{data[8], data[9], data[10], data[11]} { - case [4]byte{'h', 'e', 'i', 'c'}, [4]byte{'h', 'e', 'i', 'x'}, [4]byte{'h', 'e', 'v', 'c'}, [4]byte{'h', 'e', 'v', 'x'}: - return "image/heic", false - case [4]byte{'h', 'e', 'i', 'm'}, [4]byte{'h', 'e', 'i', 's'}, [4]byte{'m', 'i', 'f', '1'}, [4]byte{'m', 's', 'f', '1'}: - return "image/heif", false - case [4]byte{'a', 'v', 'i', 'f'}, [4]byte{'a', 'v', 'i', 's'}: - return "image/avif", false + boxSize := int(data[0])<<24 | int(data[1])<<16 | int(data[2])<<8 | int(data[3]) + end := len(data) + if boxSize >= 16 && boxSize < end { + end = boxSize + } + detected := "" + for offset := 8; offset+4 <= end; { + switch [4]byte{data[offset], data[offset+1], data[offset+2], data[offset+3]} { + case [4]byte{'a', 'v', 'i', 'f'}, [4]byte{'a', 'v', 'i', 's'}: + return "image/avif", false + case [4]byte{'h', 'e', 'i', 'c'}, [4]byte{'h', 'e', 'i', 'x'}, [4]byte{'h', 'e', 'v', 'c'}, [4]byte{'h', 'e', 'v', 'x'}: + return "image/heic", false + case [4]byte{'h', 'e', 'i', 'm'}, [4]byte{'h', 'e', 'i', 's'}, [4]byte{'m', 'i', 'f', '1'}, [4]byte{'m', 's', 'f', '1'}: + detected = "image/heif" + } + if offset == 8 { + offset = 16 + } else { + offset += 4 + } + } + if detected != "" { + return detected, false } } @@ -229,33 +244,15 @@ func detectLineImageMimeType(data []byte) (string, bool) { } func imageExtensionForMIME(mimeType string) string { - normalizedMIMEType := normalizedImageMIMEType(mimeType) - switch normalizedMIMEType { + switch normalizedMIMEType := normalizedImageMIMEType(mimeType); normalizedMIMEType { case "image/jpeg": return "jpg" - case "image/png": - return "png" - case "image/gif": - return "gif" - case "image/webp": - return "webp" - case "image/heic": - return "heic" - case "image/heif": - return "heif" - case "image/avif": - return "avif" - case "image/bmp": - return "bmp" - case "image/tiff": - return "tiff" case "image/svg+xml": return "svg" case "image/x-icon", "image/vnd.microsoft.icon": return "ico" - } - if extensions, err := mime.ExtensionsByType(normalizedMIMEType); err == nil && len(extensions) > 0 { - return strings.TrimPrefix(extensions[0], ".") + case "image/png", "image/gif", "image/webp", "image/heic", "image/heif", "image/avif", "image/bmp", "image/tiff": + return strings.TrimPrefix(normalizedMIMEType, "image/") } return "jpg" } diff --git a/pkg/connector/handlers/image_test.go b/pkg/connector/handlers/image_test.go index af0b39c..a5abbac 100644 --- a/pkg/connector/handlers/image_test.go +++ b/pkg/connector/handlers/image_test.go @@ -39,19 +39,31 @@ func TestLineImageMediaInfo(t *testing.T) { }, { name: "heic", - data: []byte("\x00\x00\x00\x18ftypheic\x00\x00\x00\x00"), + data: testFTYP("heic"), fileName: "image.heic", mimeType: "image/heic", }, { name: "heif", - data: []byte("\x00\x00\x00\x18ftypmif1\x00\x00\x00\x00"), + data: testFTYP("mif1"), fileName: "image.heif", mimeType: "image/heif", }, + { + name: "heic compatible brand", + data: testFTYP("mif1", "heic"), + fileName: "image.heic", + mimeType: "image/heic", + }, { name: "avif", - data: []byte("\x00\x00\x00\x18ftypavif\x00\x00\x00\x00"), + data: testFTYP("avif"), + fileName: "image.avif", + mimeType: "image/avif", + }, + { + name: "avif compatible brand", + data: testFTYP("mif1", "avif"), fileName: "image.avif", mimeType: "image/avif", }, @@ -113,7 +125,7 @@ func TestLineImageMediaInfoFallbackState(t *testing.T) { }, { name: "heif without decoder", - data: []byte("\x00\x00\x00\x18ftypmif1\x00\x00\x00\x00"), + data: testFTYP("mif1"), }, { name: "truncated jpeg", @@ -206,6 +218,17 @@ func testPNG(t *testing.T, width, height int) []byte { return buf.Bytes() } +func testFTYP(majorBrand string, compatibleBrands ...string) []byte { + data := make([]byte, 16+4*len(compatibleBrands)) + data[3] = byte(len(data)) + copy(data[4:8], "ftyp") + copy(data[8:12], majorBrand) + for index, brand := range compatibleBrands { + copy(data[16+4*index:], brand) + } + return data +} + func testImage(width, height int) image.Image { img := image.NewRGBA(image.Rect(0, 0, width, height)) for y := 0; y < height; y++ { From 1e8a2793896d58d5c4c4b351ea4e944c1189c305 Mon Sep 17 00:00:00 2001 From: Adrien Vermersch Date: Wed, 17 Jun 2026 17:18:24 +0200 Subject: [PATCH 6/8] fix: harden LINE image ftyp parsing --- pkg/connector/handlers/image.go | 11 ++++++----- pkg/connector/handlers/image_test.go | 3 ++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/pkg/connector/handlers/image.go b/pkg/connector/handlers/image.go index 5f9df10..75446b8 100644 --- a/pkg/connector/handlers/image.go +++ b/pkg/connector/handlers/image.go @@ -3,6 +3,7 @@ package handlers import ( "bytes" "context" + "encoding/binary" "encoding/json" "fmt" "image" @@ -208,16 +209,16 @@ func lineImageMediaInfo(data []byte) lineImageMedia { } func detectLineImageMimeType(data []byte) (string, bool) { - mimeType := http.DetectContentType(data) + mimeType := normalizedImageMIMEType(http.DetectContentType(data)) if strings.HasPrefix(mimeType, "image/") { return mimeType, false } - if len(data) >= 12 && bytes.Equal(data[4:8], []byte("ftyp")) { - boxSize := int(data[0])<<24 | int(data[1])<<16 | int(data[2])<<8 | int(data[3]) + if len(data) >= 12 && string(data[4:8]) == "ftyp" { + boxSize := binary.BigEndian.Uint32(data[:4]) end := len(data) - if boxSize >= 16 && boxSize < end { - end = boxSize + if boxSize >= 16 && uint64(boxSize) < uint64(end) { + end = int(boxSize) } detected := "" for offset := 8; offset+4 <= end; { diff --git a/pkg/connector/handlers/image_test.go b/pkg/connector/handlers/image_test.go index a5abbac..44b3db9 100644 --- a/pkg/connector/handlers/image_test.go +++ b/pkg/connector/handlers/image_test.go @@ -2,6 +2,7 @@ package handlers import ( "bytes" + "encoding/binary" "image" "image/color" "image/jpeg" @@ -220,7 +221,7 @@ func testPNG(t *testing.T, width, height int) []byte { func testFTYP(majorBrand string, compatibleBrands ...string) []byte { data := make([]byte, 16+4*len(compatibleBrands)) - data[3] = byte(len(data)) + binary.BigEndian.PutUint32(data[:4], uint32(len(data))) copy(data[4:8], "ftyp") copy(data[8:12], majorBrand) for index, brand := range compatibleBrands { From 3d2edb958f9d66054459b799b7933b08edcf9757 Mon Sep 17 00:00:00 2001 From: Adrien Vermersch Date: Wed, 17 Jun 2026 17:27:12 +0200 Subject: [PATCH 7/8] fix: harden LINE image brand detection --- pkg/connector/handlers/image.go | 38 +++++++++++++--------------- pkg/connector/handlers/image_test.go | 24 ++++++++++++++++++ 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/pkg/connector/handlers/image.go b/pkg/connector/handlers/image.go index 75446b8..04b7ff2 100644 --- a/pkg/connector/handlers/image.go +++ b/pkg/connector/handlers/image.go @@ -216,29 +216,27 @@ func detectLineImageMimeType(data []byte) (string, bool) { if len(data) >= 12 && string(data[4:8]) == "ftyp" { boxSize := binary.BigEndian.Uint32(data[:4]) - end := len(data) - if boxSize >= 16 && uint64(boxSize) < uint64(end) { - end = int(boxSize) - } - detected := "" - for offset := 8; offset+4 <= end; { - switch [4]byte{data[offset], data[offset+1], data[offset+2], data[offset+3]} { - case [4]byte{'a', 'v', 'i', 'f'}, [4]byte{'a', 'v', 'i', 's'}: - return "image/avif", false - case [4]byte{'h', 'e', 'i', 'c'}, [4]byte{'h', 'e', 'i', 'x'}, [4]byte{'h', 'e', 'v', 'c'}, [4]byte{'h', 'e', 'v', 'x'}: - return "image/heic", false - case [4]byte{'h', 'e', 'i', 'm'}, [4]byte{'h', 'e', 'i', 's'}, [4]byte{'m', 'i', 'f', '1'}, [4]byte{'m', 's', 'f', '1'}: - detected = "image/heif" + if boxSize >= 16 && uint64(boxSize) <= uint64(len(data)) { + detected := "" + for offset, end := 8, int(boxSize); offset+4 <= end; { + switch [4]byte{data[offset], data[offset+1], data[offset+2], data[offset+3]} { + case [4]byte{'a', 'v', 'i', 'f'}, [4]byte{'a', 'v', 'i', 's'}: + return "image/avif", false + case [4]byte{'h', 'e', 'i', 'c'}, [4]byte{'h', 'e', 'i', 'x'}, [4]byte{'h', 'e', 'v', 'c'}, [4]byte{'h', 'e', 'v', 'x'}: + return "image/heic", false + case [4]byte{'h', 'e', 'i', 'f'}, [4]byte{'h', 'e', 'i', 'm'}, [4]byte{'h', 'e', 'i', 's'}, [4]byte{'m', 'i', 'f', '1'}, [4]byte{'m', 's', 'f', '1'}: + detected = "image/heif" + } + if offset == 8 { + offset = 16 + } else { + offset += 4 + } } - if offset == 8 { - offset = 16 - } else { - offset += 4 + if detected != "" { + return detected, false } } - if detected != "" { - return detected, false - } } return "image/jpeg", true diff --git a/pkg/connector/handlers/image_test.go b/pkg/connector/handlers/image_test.go index 44b3db9..2ba8b1d 100644 --- a/pkg/connector/handlers/image_test.go +++ b/pkg/connector/handlers/image_test.go @@ -50,6 +50,12 @@ func TestLineImageMediaInfo(t *testing.T) { fileName: "image.heif", mimeType: "image/heif", }, + { + name: "heif brand", + data: testFTYP("heif"), + fileName: "image.heif", + mimeType: "image/heif", + }, { name: "heic compatible brand", data: testFTYP("mif1", "heic"), @@ -80,6 +86,18 @@ func TestLineImageMediaInfo(t *testing.T) { fileName: "image.jpg", mimeType: "image/jpeg", }, + { + name: "invalid ftyp box size", + data: testFTYPWithSize(12, "mif1", "heic"), + fileName: "image.jpg", + mimeType: "image/jpeg", + }, + { + name: "oversized ftyp box size", + data: testFTYPWithSize(1024, "mif1", "heic"), + fileName: "image.jpg", + mimeType: "image/jpeg", + }, } for _, tt := range tests { @@ -230,6 +248,12 @@ func testFTYP(majorBrand string, compatibleBrands ...string) []byte { return data } +func testFTYPWithSize(boxSize uint32, majorBrand string, compatibleBrands ...string) []byte { + data := testFTYP(majorBrand, compatibleBrands...) + binary.BigEndian.PutUint32(data[:4], boxSize) + return data +} + func testImage(width, height int) image.Image { img := image.NewRGBA(image.Rect(0, 0, width, height)) for y := 0; y < height; y++ { From eb5f321df63f3eaf9c60f925363df0f0631f0f07 Mon Sep 17 00:00:00 2001 From: Adrien Vermersch Date: Wed, 17 Jun 2026 17:44:19 +0200 Subject: [PATCH 8/8] fix: avoid LINE image brand allocation --- pkg/connector/handlers/image.go | 8 ++++++-- pkg/connector/handlers/image_test.go | 28 ++++++++++++++++++++-------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/pkg/connector/handlers/image.go b/pkg/connector/handlers/image.go index 04b7ff2..9b0a177 100644 --- a/pkg/connector/handlers/image.go +++ b/pkg/connector/handlers/image.go @@ -214,12 +214,12 @@ func detectLineImageMimeType(data []byte) (string, bool) { return mimeType, false } - if len(data) >= 12 && string(data[4:8]) == "ftyp" { + if len(data) >= 12 && lineImageBrandAt(data, 4) == [4]byte{'f', 't', 'y', 'p'} { boxSize := binary.BigEndian.Uint32(data[:4]) if boxSize >= 16 && uint64(boxSize) <= uint64(len(data)) { detected := "" for offset, end := 8, int(boxSize); offset+4 <= end; { - switch [4]byte{data[offset], data[offset+1], data[offset+2], data[offset+3]} { + switch lineImageBrandAt(data, offset) { case [4]byte{'a', 'v', 'i', 'f'}, [4]byte{'a', 'v', 'i', 's'}: return "image/avif", false case [4]byte{'h', 'e', 'i', 'c'}, [4]byte{'h', 'e', 'i', 'x'}, [4]byte{'h', 'e', 'v', 'c'}, [4]byte{'h', 'e', 'v', 'x'}: @@ -242,6 +242,10 @@ func detectLineImageMimeType(data []byte) (string, bool) { return "image/jpeg", true } +func lineImageBrandAt(data []byte, offset int) [4]byte { + return [4]byte{data[offset], data[offset+1], data[offset+2], data[offset+3]} +} + func imageExtensionForMIME(mimeType string) string { switch normalizedMIMEType := normalizedImageMIMEType(mimeType); normalizedMIMEType { case "image/jpeg": diff --git a/pkg/connector/handlers/image_test.go b/pkg/connector/handlers/image_test.go index 2ba8b1d..87afb33 100644 --- a/pkg/connector/handlers/image_test.go +++ b/pkg/connector/handlers/image_test.go @@ -17,6 +17,7 @@ func TestLineImageMediaInfo(t *testing.T) { tests := []struct { name string data []byte + makeData func(t *testing.T) []byte fileName string mimeType string width int @@ -24,7 +25,7 @@ func TestLineImageMediaInfo(t *testing.T) { }{ { name: "jpeg", - data: testJPEG(t, 23, 17), + makeData: func(t *testing.T) []byte { return testJPEG(t, 23, 17) }, fileName: "image.jpg", mimeType: "image/jpeg", width: 23, @@ -32,7 +33,7 @@ func TestLineImageMediaInfo(t *testing.T) { }, { name: "png", - data: testPNG(t, 19, 11), + makeData: func(t *testing.T) []byte { return testPNG(t, 19, 11) }, fileName: "image.png", mimeType: "image/png", width: 19, @@ -102,7 +103,12 @@ func TestLineImageMediaInfo(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - media := lineImageMediaInfo(tt.data) + data := tt.data + if tt.makeData != nil { + data = tt.makeData(t) + } + + media := lineImageMediaInfo(data) if media.fileName != tt.fileName { t.Fatalf("unexpected file name: got %q, want %q", media.fileName, tt.fileName) @@ -116,8 +122,8 @@ func TestLineImageMediaInfo(t *testing.T) { if media.info.MimeType != tt.mimeType { t.Fatalf("unexpected info mime type: got %q, want %q", media.info.MimeType, tt.mimeType) } - if media.info.Size != len(tt.data) { - t.Fatalf("unexpected info size: got %d, want %d", media.info.Size, len(tt.data)) + if media.info.Size != len(data) { + t.Fatalf("unexpected info size: got %d, want %d", media.info.Size, len(data)) } if media.info.Width != tt.width || media.info.Height != tt.height { t.Fatalf("unexpected dimensions: got %dx%d, want %dx%d", media.info.Width, media.info.Height, tt.width, tt.height) @@ -130,12 +136,13 @@ func TestLineImageMediaInfoFallbackState(t *testing.T) { tests := []struct { name string data []byte + makeData func(t *testing.T) []byte usedMimeFallback bool hasDecodeErr bool }{ { - name: "jpeg", - data: testJPEG(t, 2, 2), + name: "jpeg", + makeData: func(t *testing.T) []byte { return testJPEG(t, 2, 2) }, }, { name: "unknown", @@ -155,7 +162,12 @@ func TestLineImageMediaInfoFallbackState(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - media := lineImageMediaInfo(tt.data) + data := tt.data + if tt.makeData != nil { + data = tt.makeData(t) + } + + media := lineImageMediaInfo(data) if media.usedMimeFallback != tt.usedMimeFallback { t.Fatalf("unexpected fallback state: got %t, want %t", media.usedMimeFallback, tt.usedMimeFallback)