diff --git a/pkg/connector/capabilities_test.go b/pkg/connector/capabilities_test.go new file mode 100644 index 0000000..4620ad8 --- /dev/null +++ b/pkg/connector/capabilities_test.go @@ -0,0 +1,28 @@ +package connector + +import ( + "context" + "testing" + + "maunium.net/go/mautrix/event" +) + +func TestCapabilitiesAdvertiseFileSizeLimit(t *testing.T) { + const wantMaxFileSize int64 = 100 * 1024 * 1024 + caps := (&LineClient{}).GetCapabilities(context.Background(), nil) + for _, messageType := range []event.CapabilityMsgType{ + event.MsgImage, + event.MsgFile, + event.MsgVideo, + event.MsgAudio, + event.CapMsgVoice, + } { + features := caps.File[messageType] + if features == nil { + t.Fatalf("%s file features are missing", messageType) + } + if features.MaxSize != wantMaxFileSize { + t.Errorf("%s MaxSize = %d, want %d", messageType, features.MaxSize, wantMaxFileSize) + } + } +} diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 54cd1cf..cae3a39 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -50,7 +50,7 @@ func (lc *LineConnector) Start(ctx context.Context) error { } func (lc *LineConnector) GetBridgeInfoVersion() (info, capabilities int) { - return 1, 2 + return 1, 3 } func (lc *LineConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities { diff --git a/pkg/connector/handlers/audio.go b/pkg/connector/handlers/audio.go index 1e2236b..a0f7ec8 100644 --- a/pkg/connector/handlers/audio.go +++ b/pkg/connector/handlers/audio.go @@ -15,6 +15,10 @@ import ( // ConvertAudio converts a LINE audio message to a Matrix audio message. func (h *Handler) ConvertAudio(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data line.Message, decryptedBody string, relatesTo *event.RelatesTo) (*bridgev2.ConvertedMessage, error) { + if oversized := h.oversizedMediaNoticeFromMetadata(data.ContentMetadata, relatesTo); oversized != nil { + return oversized, nil + } + client := h.NewClient() oid := data.ContentMetadata["OID"] isPlainMedia := oid == "" @@ -95,6 +99,10 @@ func (h *Handler) ConvertAudio(ctx context.Context, portal *bridgev2.Portal, int } } + if oversized := h.oversizedMediaNotice(int64(len(audioData)), "downloaded", relatesTo); oversized != nil { + return oversized, nil + } + var duration int if durationStr := data.ContentMetadata["DURATION"]; durationStr != "" { if d, err := strconv.Atoi(durationStr); err == nil { diff --git a/pkg/connector/handlers/file.go b/pkg/connector/handlers/file.go index e2eac47..5ab1b16 100644 --- a/pkg/connector/handlers/file.go +++ b/pkg/connector/handlers/file.go @@ -14,6 +14,10 @@ import ( // ConvertFile converts a LINE file message to a Matrix file message. func (h *Handler) ConvertFile(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data line.Message, decryptedBody string, relatesTo *event.RelatesTo) (*bridgev2.ConvertedMessage, error) { + if oversized := h.oversizedMediaNoticeFromMetadata(data.ContentMetadata, relatesTo); oversized != nil { + return oversized, nil + } + client := h.NewClient() oid := data.ContentMetadata["OID"] isPlainMedia := oid == "" @@ -89,6 +93,10 @@ func (h *Handler) ConvertFile(ctx context.Context, portal *bridgev2.Portal, inte } } + if oversized := h.oversizedMediaNotice(int64(len(fileData)), "downloaded", relatesTo); oversized != nil { + return oversized, nil + } + if fileName == "" { fileName = data.ContentMetadata["FILE_NAME"] } diff --git a/pkg/connector/handlers/image.go b/pkg/connector/handlers/image.go index 83f315b..4c9792a 100644 --- a/pkg/connector/handlers/image.go +++ b/pkg/connector/handlers/image.go @@ -15,6 +15,10 @@ import ( // ConvertImage converts a LINE image message to a Matrix image message. func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data line.Message, decryptedBody string, relatesTo *event.RelatesTo) (*bridgev2.ConvertedMessage, error) { + if oversized := h.oversizedMediaNoticeFromMetadata(data.ContentMetadata, relatesTo); oversized != nil { + return oversized, nil + } + client := h.NewClient() oid := data.ContentMetadata["OID"] isPlainMedia := oid == "" @@ -95,6 +99,10 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int } } + if oversized := h.oversizedMediaNotice(int64(len(imgData)), "downloaded", relatesTo); oversized != nil { + return oversized, nil + } + // Upload to Matrix uploadStart := time.Now() mxc, file, err := intent.UploadMedia(ctx, portal.MXID, imgData, "image.jpg", "image/jpeg") diff --git a/pkg/connector/handlers/media_size.go b/pkg/connector/handlers/media_size.go new file mode 100644 index 0000000..3f9966e --- /dev/null +++ b/pkg/connector/handlers/media_size.go @@ -0,0 +1,48 @@ +package handlers + +import ( + "strconv" + "strings" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" +) + +const ( + BeeperMaxFileSize = 100 * 1024 * 1024 + encryptedMediaSizeOverhead = 32 + oversizedFileBody = "This file exceeds Beeper's 100MB file size limit. Open LINE to view it." +) + +func (h *Handler) oversizedMediaNoticeFromMetadata(metadata map[string]string, relatesTo *event.RelatesTo) *bridgev2.ConvertedMessage { + size, err := strconv.ParseInt(strings.TrimSpace(metadata["FILE_SIZE"]), 10, 64) + // E2EE media stored in OBS may include a 32-byte HMAC in FILE_SIZE. Keep + // borderline values on the authoritative post-decryption size-check path. + if err != nil || size <= BeeperMaxFileSize+encryptedMediaSizeOverhead { + return nil + } + return h.oversizedMediaNotice(size, "metadata", relatesTo) +} + +func (h *Handler) oversizedMediaNotice(size int64, sizeSource string, relatesTo *event.RelatesTo) *bridgev2.ConvertedMessage { + if size <= BeeperMaxFileSize { + return nil + } + h.Log.Warn(). + Int64("size_bytes", size). + Int("limit_bytes", BeeperMaxFileSize). + Str("size_source", sizeSource). + Msg("Skipping oversized LINE media upload") + return &bridgev2.ConvertedMessage{ + Parts: []*bridgev2.ConvertedMessagePart{ + { + Type: event.EventMessage, + Content: &event.MessageEventContent{ + MsgType: event.MsgNotice, + Body: oversizedFileBody, + RelatesTo: relatesTo, + }, + }, + }, + } +} diff --git a/pkg/connector/handlers/media_size_test.go b/pkg/connector/handlers/media_size_test.go new file mode 100644 index 0000000..7b50b38 --- /dev/null +++ b/pkg/connector/handlers/media_size_test.go @@ -0,0 +1,108 @@ +package handlers + +import ( + "context" + "testing" + + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" + + "github.com/highesttt/matrix-line-messenger/pkg/line" +) + +func TestOversizedMediaNotice(t *testing.T) { + h := &Handler{Log: zerolog.Nop()} + relatesTo := &event.RelatesTo{} + converted := h.oversizedMediaNotice(BeeperMaxFileSize+1, "downloaded", relatesTo) + if converted == nil || len(converted.Parts) != 1 { + t.Fatalf("converted = %#v, want one notice part", converted) + } + + part := converted.Parts[0] + if part.Type != event.EventMessage { + t.Fatalf("event type = %v, want %v", part.Type, event.EventMessage) + } + if part.Content == nil { + t.Fatal("notice content is nil") + } + if part.Content.MsgType != event.MsgNotice { + t.Fatalf("message type = %v, want %v", part.Content.MsgType, event.MsgNotice) + } + const expectedBody = "This file exceeds Beeper's 100MB file size limit. Open LINE to view it." + if part.Content.Body != expectedBody { + t.Fatalf("body = %q, want %q", part.Content.Body, expectedBody) + } + if part.Content.RelatesTo != relatesTo { + t.Fatalf("relates_to = %#v, want original pointer %#v", part.Content.RelatesTo, relatesTo) + } +} + +func TestOversizedMediaNoticeAllowsLimitAndBelow(t *testing.T) { + h := &Handler{Log: zerolog.Nop()} + for _, size := range []int64{0, BeeperMaxFileSize - 1, BeeperMaxFileSize} { + if converted := h.oversizedMediaNotice(size, "downloaded", nil); converted != nil { + t.Fatalf("size %d converted = %#v, want nil", size, converted) + } + } +} + +func TestOversizedMediaMetadataShortCircuitsAllMediaHandlers(t *testing.T) { + h := &Handler{ + Log: zerolog.Nop(), + NewClient: func() *line.Client { + t.Fatal("NewClient was called for media declared over the size limit") + return nil + }, + } + message := line.Message{ + ID: "message-id", + ContentMetadata: map[string]string{ + "FILE_SIZE": "104857633", + }, + } + relatesTo := &event.RelatesTo{} + + tests := map[string]func() (*bridgev2.ConvertedMessage, error){ + "image": func() (*bridgev2.ConvertedMessage, error) { + return h.ConvertImage(context.Background(), nil, nil, message, "", relatesTo) + }, + "video": func() (*bridgev2.ConvertedMessage, error) { + return h.ConvertVideo(context.Background(), nil, nil, message, "", relatesTo) + }, + "audio and voice": func() (*bridgev2.ConvertedMessage, error) { + return h.ConvertAudio(context.Background(), nil, nil, message, "", relatesTo) + }, + "file": func() (*bridgev2.ConvertedMessage, error) { + return h.ConvertFile(context.Background(), nil, nil, message, "", relatesTo) + }, + } + for name, convert := range tests { + t.Run(name, func(t *testing.T) { + converted, err := convert() + if err != nil { + t.Fatal(err) + } + if converted == nil || len(converted.Parts) != 1 || converted.Parts[0].Content.Body != oversizedFileBody { + t.Fatalf("converted = %#v, want oversized notice", converted) + } + }) + } +} + +func TestOversizedMediaMetadataFallsBackForMissingInvalidAndAllowedSizes(t *testing.T) { + h := &Handler{Log: zerolog.Nop()} + for name, metadata := range map[string]map[string]string{ + "missing": nil, + "invalid": {"FILE_SIZE": "not-a-number"}, + "negative": {"FILE_SIZE": "-1"}, + "at limit": {"FILE_SIZE": "104857600"}, + "possible encrypted overhead": {"FILE_SIZE": "104857632"}, + } { + t.Run(name, func(t *testing.T) { + if converted := h.oversizedMediaNoticeFromMetadata(metadata, nil); converted != nil { + t.Fatalf("converted = %#v, want normal download fallback", converted) + } + }) + } +} diff --git a/pkg/connector/handlers/video.go b/pkg/connector/handlers/video.go index 1aca568..a6f95f7 100644 --- a/pkg/connector/handlers/video.go +++ b/pkg/connector/handlers/video.go @@ -16,6 +16,10 @@ import ( // ConvertVideo converts a LINE video message to a Matrix video message. func (h *Handler) ConvertVideo(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data line.Message, decryptedBody string, relatesTo *event.RelatesTo) (*bridgev2.ConvertedMessage, error) { + if oversized := h.oversizedMediaNoticeFromMetadata(data.ContentMetadata, relatesTo); oversized != nil { + return oversized, nil + } + client := h.NewClient() oid := data.ContentMetadata["OID"] isPlainMedia := oid == "" @@ -109,6 +113,10 @@ func (h *Handler) ConvertVideo(ctx context.Context, portal *bridgev2.Portal, int } } + if oversized := h.oversizedMediaNotice(int64(len(videoData)), "downloaded", relatesTo); oversized != nil { + return oversized, nil + } + fileName := data.ContentMetadata["FILE_NAME"] if fileName == "" && decryptedBody != "" && strings.Contains(decryptedBody, "fileName") { diff --git a/pkg/connector/reaction_test.go b/pkg/connector/reaction_test.go index 5bb86ea..6a0eeed 100644 --- a/pkg/connector/reaction_test.go +++ b/pkg/connector/reaction_test.go @@ -20,8 +20,8 @@ import ( func TestCapabilitiesAdvertiseSupportedReactions(t *testing.T) { infoVersion, capabilityVersion := (&LineConnector{}).GetBridgeInfoVersion() - if infoVersion != 1 || capabilityVersion != 2 { - t.Fatalf("bridge info/capability versions = %d/%d, want 1/2", infoVersion, capabilityVersion) + if infoVersion != 1 || capabilityVersion != 3 { + t.Fatalf("bridge info/capability versions = %d/%d, want 1/3", infoVersion, capabilityVersion) } caps := (&LineClient{}).GetCapabilities(context.Background(), nil) diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go index 1d0f2b1..8ba7aeb 100644 --- a/pkg/connector/userinfo.go +++ b/pkg/connector/userinfo.go @@ -15,6 +15,7 @@ import ( "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" + "github.com/highesttt/matrix-line-messenger/pkg/connector/handlers" "github.com/highesttt/matrix-line-messenger/pkg/line" ) @@ -64,6 +65,7 @@ func (lc *LineClient) GetCapabilities(ctx context.Context, portal *bridgev2.Port File: event.FileFeatureMap{ event.MsgImage: { Caption: event.CapLevelRejected, + MaxSize: handlers.BeeperMaxFileSize, MimeTypes: map[string]event.CapabilitySupportLevel{ "image/jpeg": event.CapLevelFullySupported, "image/png": event.CapLevelFullySupported, @@ -80,6 +82,7 @@ func (lc *LineClient) GetCapabilities(ctx context.Context, portal *bridgev2.Port }, event.MsgFile: { Caption: event.CapLevelRejected, + MaxSize: handlers.BeeperMaxFileSize, MimeTypes: map[string]event.CapabilitySupportLevel{ "image/gif": event.CapLevelFullySupported, "*/*": event.CapLevelFullySupported, @@ -87,6 +90,7 @@ func (lc *LineClient) GetCapabilities(ctx context.Context, portal *bridgev2.Port }, event.MsgVideo: { Caption: event.CapLevelRejected, + MaxSize: handlers.BeeperMaxFileSize, MimeTypes: map[string]event.CapabilitySupportLevel{ "video/mp4": event.CapLevelFullySupported, "video/webm": event.CapLevelFullySupported, @@ -100,6 +104,7 @@ func (lc *LineClient) GetCapabilities(ctx context.Context, portal *bridgev2.Port }, event.MsgAudio: { Caption: event.CapLevelRejected, + MaxSize: handlers.BeeperMaxFileSize, MimeTypes: map[string]event.CapabilitySupportLevel{ "audio/mpeg": event.CapLevelFullySupported, "audio/ogg": event.CapLevelFullySupported, @@ -117,6 +122,7 @@ func (lc *LineClient) GetCapabilities(ctx context.Context, portal *bridgev2.Port }, event.CapMsgVoice: { Caption: event.CapLevelRejected, + MaxSize: handlers.BeeperMaxFileSize, MimeTypes: map[string]event.CapabilitySupportLevel{ "audio/ogg": event.CapLevelFullySupported, "audio/mp4": event.CapLevelFullySupported,