Skip to content
59 changes: 25 additions & 34 deletions pkg/connector/handlers/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@ import (
"context"
"encoding/json"
"fmt"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"strings"
"time"

_ "golang.org/x/image/webp"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/event"

Expand Down Expand Up @@ -105,8 +109,22 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int
}

// Upload to Matrix
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")
}
Comment thread
Adri11334 marked this conversation as resolved.

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, imageMedia.fileName, imageMedia.mimeType)
uploadDuration := time.Since(uploadStart)
if err != nil {
h.Log.Error().
Expand All @@ -126,7 +144,11 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int

h.Log.Info().
Str("matrix_media_url", matrixMediaURL).
Str("file_name", imageMedia.fileName).
Str("mime_type", imageMedia.mimeType).
Int("size", len(imgData)).
Int("width", imageMedia.info.Width).
Int("height", imageMedia.info.Height).
Dur("download_duration", downloadDuration).
Dur("decrypt_duration", decryptDuration).
Dur("upload_duration", uploadDuration).
Expand All @@ -135,40 +157,9 @@ 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, imageMedia.fileName, imageMedia.info, relatesTo),
},
},
}, nil
}

func lineMediaCategory(metadata map[string]string) string {
if metadata == nil || metadata["MEDIA_CONTENT_INFO"] == "" {
return ""
}

var info struct {
Category string `json:"category"`
}
if err := json.Unmarshal([]byte(metadata["MEDIA_CONTENT_INFO"]), &info); err != nil {
return ""
}

return info.Category
}

func lineOBSDownloadOptions(metadata map[string]string, isPlainMedia bool) line.OBSDownloadOptions {
opts := line.OBSDownloadOptions{
OBSPop: metadata["OBS_POP"],
}
if isPlainMedia && lineMediaCategory(metadata) == "original" {
opts.TID = "original"
}
return opts
}
159 changes: 159 additions & 0 deletions pkg/connector/handlers/image_media.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package handlers

import (
"bytes"
"encoding/binary"
"encoding/json"
"image"
"net/http"
"strings"

"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"

"github.com/highesttt/matrix-line-messenger/pkg/line"
)

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

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 if !usedFallback && shouldLogLineImageDecodeError(mimeType) {
decodeErr = err
}

return lineImageMedia{
fileName: "image." + imageExtensionForMIME(mimeType),
mimeType: mimeType,
info: info,
usedMimeFallback: usedFallback,
decodeErr: decodeErr,
}
}

func detectLineImageMimeType(data []byte) (string, bool) {
mimeType := normalizedImageMIMEType(http.DetectContentType(data))
if strings.HasPrefix(mimeType, "image/") {
return mimeType, false
}

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 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'}:
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 detected != "" {
return detected, false
}
}
}

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":
return "jpg"
case "image/svg+xml":
return "svg"
case "image/x-icon", "image/vnd.microsoft.icon":
return "ico"
case "image/png", "image/gif", "image/webp", "image/heic", "image/heif", "image/avif", "image/bmp", "image/tiff":
return strings.TrimPrefix(normalizedMIMEType, "image/")
}
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))
}

type lineMediaContentInfo struct {
Category string `json:"category"`
}

func parseLineMediaContentInfo(metadata map[string]string) (lineMediaContentInfo, bool) {
if metadata == nil || metadata["MEDIA_CONTENT_INFO"] == "" {
return lineMediaContentInfo{}, false
}

var info lineMediaContentInfo
if err := json.Unmarshal([]byte(metadata["MEDIA_CONTENT_INFO"]), &info); err != nil {
return lineMediaContentInfo{}, false
}

return info, true
}

func lineMediaCategory(metadata map[string]string) string {
info, ok := parseLineMediaContentInfo(metadata)
if !ok {
return ""
}
return info.Category
}

func lineOBSDownloadOptions(metadata map[string]string, isPlainMedia bool) line.OBSDownloadOptions {
opts := line.OBSDownloadOptions{
OBSPop: metadata["OBS_POP"],
}
if isPlainMedia && lineMediaCategory(metadata) == "original" {
opts.TID = "original"
opts.MaxProcessingRetries = line.OBSOriginalMediaMaxRetries
}
return opts
}
30 changes: 30 additions & 0 deletions pkg/connector/handlers/image_media_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package handlers

import (
"testing"

"github.com/highesttt/matrix-line-messenger/pkg/line"
)

func TestLineOBSDownloadOptionsUsesLongRetryWindowForOriginalMedia(t *testing.T) {
metadata := map[string]string{
"MEDIA_CONTENT_INFO": `{"category":"original","extension":"jpeg"}`,
"OBS_POP": "pop-token",
}

plainOptions := lineOBSDownloadOptions(metadata, true)
if plainOptions.TID != "original" {
t.Fatalf("unexpected TID for original plain media: got %q", plainOptions.TID)
}
if plainOptions.OBSPop != "pop-token" {
t.Fatalf("unexpected OBS_POP: got %q", plainOptions.OBSPop)
}
if plainOptions.MaxProcessingRetries != line.OBSOriginalMediaMaxRetries {
t.Fatalf("unexpected retry limit: got %d, want %d", plainOptions.MaxProcessingRetries, line.OBSOriginalMediaMaxRetries)
}

encryptedOptions := lineOBSDownloadOptions(metadata, false)
if encryptedOptions.MaxProcessingRetries != 0 {
t.Fatalf("unexpected retry override for encrypted media: got %d", encryptedOptions.MaxProcessingRetries)
}
}
Loading
Loading