Skip to content
Open
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ Based on the [mautrix-twilio](https://github.com/mautrix/twilio) bridge
> need to log out of the bridge first as the bridge will try to automatically
> log back in and invalidate the LINE Chrome session again.

For direct-chat identifiers, synchronization triggers, outbound-message diagnostics,
and QR login troubleshooting, see [the debugging guide](readme/DEBUGGING.md).

## Features

- [x] Messages
Expand Down
1 change: 1 addition & 0 deletions pkg/connector/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,7 @@ func (lc *LineClient) Connect(ctx context.Context) {
// deliver messages. Otherwise an existing LINE group whose Matrix room
// doesn't exist yet may be created by the first message, which makes the
// sender's existing membership look like a fresh join.
lc.UserLogin.Bridge.Log.Info().Str("sync_trigger", "connect").Msg("Starting LINE conversation synchronization")
lc.syncChatsNow(ctx)
if ctx.Err() != nil {
return
Expand Down
11 changes: 6 additions & 5 deletions pkg/connector/handle_message.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,12 @@ const (

func (lc *LineClient) newMessageHandler() *handlers.Handler {
handler := &handlers.Handler{
Log: lc.UserLogin.Bridge.Log,
HTTPClient: lc.HTTPClient,
RecoverClient: lc.recoverClientAfterAuthError,
NewClient: func() *line.Client { return lc.newClient() },
DecryptMedia: lc.decryptImageData,
Log: lc.UserLogin.Bridge.Log,
HTTPClient: lc.HTTPClient,
RecoverClient: lc.recoverClientAfterAuthError,
NewClient: func() *line.Client { return lc.newClient() },
DecryptMedia: lc.decryptImageData,
DecryptVideoMedia: lc.decryptVideoData,
}
connector, ok := lc.UserLogin.Bridge.Network.(*LineConnector)
if ok && connector.directMedia.Load() {
Expand Down
10 changes: 8 additions & 2 deletions pkg/connector/handlers/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ type Handler struct {

// DecryptMedia decrypts E2EE encrypted media data using the given key material.
DecryptMedia func(data []byte, keyMaterial string) ([]byte, error)
// DecryptVideoMedia verifies the video-specific chunk-hash HMAC before decryption.
DecryptVideoMedia func(data []byte, keyMaterial string) ([]byte, error)
}

func (h *Handler) decryptDownloadedMedia(data []byte, decryptedBody string, metadata map[string]string, kind string) ([]byte, error) {
Expand Down Expand Up @@ -90,13 +92,17 @@ func (h *Handler) decryptMediaWithKeys(data []byte, keys []string, encrypted boo
if len(keys) == 0 {
return nil, fmt.Errorf("%w: encrypted %s has no usable media key", bridgev2.ErrIgnoringRemoteEvent, strings.ToLower(kind))
}
if h.DecryptMedia == nil {
decrypt := h.DecryptMedia
if kind == "video" {
decrypt = h.DecryptVideoMedia
}
if decrypt == nil {
return nil, fmt.Errorf("%w: encrypted %s has no media decryptor", bridgev2.ErrIgnoringRemoteEvent, strings.ToLower(kind))
}

decryptErrors := make([]error, 0, len(keys))
for _, key := range keys {
decrypted, err := h.DecryptMedia(data, key)
decrypted, err := decrypt(data, key)
if err == nil {
return decrypted, nil
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/connector/handlers/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ func TestDecryptDownloadedMediaFailsClosed(t *testing.T) {
ciphertext := []byte("ciphertext")
decryptErr := errors.New("invalid media key")
var calls int
h := &Handler{DecryptMedia: func([]byte, string) ([]byte, error) {
h := &Handler{DecryptVideoMedia: func([]byte, string) ([]byte, error) {
calls++
return nil, decryptErr
}}
Expand Down
12 changes: 10 additions & 2 deletions pkg/connector/handlers/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,15 @@ 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).Msg("Could not decode LINE image dimensions")
}
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 Down Expand Up @@ -145,7 +152,8 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgImage,
Body: "image.jpg",
Body: imageMedia.fileName,
Info: imageMedia.info,
URL: mxc,
File: file,
RelatesTo: relatesTo,
Expand Down
98 changes: 98 additions & 0 deletions pkg/connector/handlers/image_media.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package handlers

import (
"bytes"
"encoding/binary"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"net/http"
"strings"

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

// Adapted from beeper/line#189; OBS retry policy remains independent.
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 {
switch mimeType {
case "image/jpeg", "image/png", "image/gif", "image/webp":
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 normalizedImageMIMEType(mimeType string) string {
normalizedMIMEType, _, _ := strings.Cut(mimeType, ";")
return strings.ToLower(strings.TrimSpace(normalizedMIMEType))
}
70 changes: 70 additions & 0 deletions pkg/connector/handlers/image_media_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package handlers

import (
"bytes"
"encoding/binary"
"image"
"image/jpeg"
"image/png"
"testing"
)

func TestLineImageMediaInfo(t *testing.T) {
for _, format := range []string{"jpeg", "png"} {
t.Run(format, func(t *testing.T) {
var buf bytes.Buffer
img := image.NewRGBA(image.Rect(0, 0, 23, 17))
var err error
if format == "jpeg" {
err = jpeg.Encode(&buf, img, nil)
} else {
err = png.Encode(&buf, img)
}
if err != nil {
t.Fatal(err)
}
got := lineImageMediaInfo(buf.Bytes())
if got.mimeType != "image/"+format || got.info.Width != 23 || got.info.Height != 17 || got.info.Size != buf.Len() || got.info.MimeType != got.mimeType || got.usedMimeFallback || got.decodeErr != nil {
t.Fatalf("incorrect image metadata: %+v, info=%+v", got, got.info)
}
})
}
}

func TestLineImageBrands(t *testing.T) {
for _, tt := range []struct{ major, compatible, want string }{
{"heic", "", "image/heic"}, {"heif", "", "image/heif"},
{"mif1", "heic", "image/heic"}, {"mif1", "avif", "image/avif"},
{"avif", "", "image/avif"},
} {
t.Run(tt.major+tt.compatible, func(t *testing.T) {
data := make([]byte, 20)
binary.BigEndian.PutUint32(data, 20)
copy(data[4:], "ftyp")
copy(data[8:], tt.major)
copy(data[16:], tt.compatible)
got := lineImageMediaInfo(data)
if got.mimeType != tt.want || got.usedMimeFallback || got.decodeErr != nil {
t.Fatalf("unexpected metadata: %+v", got)
}
for _, size := range []uint32{0, 12, 1024, 0xffffffff} {
binary.BigEndian.PutUint32(data, size)
if got := lineImageMediaInfo(data); !got.usedMimeFallback {
t.Fatalf("accepted invalid ftyp size %d", size)
}
}
})
}
}

func TestLineImageExtensions(t *testing.T) {
for mimeType, want := range map[string]string{
"image/jpeg": "jpg", "image/png; charset=binary": "png", "image/gif": "gif",
"image/webp": "webp", "image/heic": "heic", "image/heif": "heif", "image/avif": "avif",
"image/bmp": "bmp", "image/tiff": "tiff", "image/svg+xml": "svg", "image/x-icon": "ico", "unknown": "jpg",
} {
if got := imageExtensionForMIME(mimeType); got != want {
t.Errorf("%s: got %s, want %s", mimeType, got, want)
}
}
}
48 changes: 38 additions & 10 deletions pkg/connector/login_qr.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package connector
import (
"context"
"fmt"
"sync"
"time"

"maunium.net/go/mautrix/bridgev2"
Expand All @@ -11,18 +12,23 @@ import (
)

type LineQRLogin struct {
login *LineEmailLogin
client *line.Client
session string
qr *line.QRCodeResponse
poll chan error
pin bool
operationMu sync.Mutex
login *LineEmailLogin
client *line.Client
session string
qr *line.QRCodeResponse
poll chan error
pin bool
}

var _ bridgev2.LoginProcessDisplayAndWait = (*LineQRLogin)(nil)
var _ bridgev2.LoginProcessWithOverride = (*LineQRLogin)(nil)

func (lq *LineQRLogin) StartWithOverride(ctx context.Context, override *bridgev2.UserLogin) (*bridgev2.LoginStep, error) {
if !lq.operationMu.TryLock() {
return nil, fmt.Errorf("QR login operation already in progress")
}
defer lq.operationMu.Unlock()
meta, ok := override.Metadata.(*UserLoginMetadata)
if !ok {
return nil, fmt.Errorf("existing LINE login metadata has unexpected type %T", override.Metadata)
Expand All @@ -32,10 +38,21 @@ func (lq *LineQRLogin) StartWithOverride(ctx context.Context, override *bridgev2
if meta.ForceFullE2EELogin || len(meta.ExportedKeyMap) == 0 {
lq.login.Certificate = ""
}
return lq.Start(ctx)
return lq.start(ctx)
}

func (lq *LineQRLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) {
if !lq.operationMu.TryLock() {
return nil, fmt.Errorf("QR login operation already in progress")
}
defer lq.operationMu.Unlock()
return lq.start(ctx)
}

func (lq *LineQRLogin) start(ctx context.Context) (*bridgev2.LoginStep, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
ll := lq.login
ll.mu.Lock()
if ll.canceled || ll.attemptCtx != nil {
Expand All @@ -54,10 +71,15 @@ func (lq *LineQRLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) {
canceled := ll.canceled || ctx.Err() != nil || processCtx.Err() != nil
ll.mu.Unlock()
if err != nil || canceled {
lq.Cancel()
if canceled {
return nil, context.Canceled
// Preserve expiry before Cancel changes the attempt's context error.
if ctx.Err() != nil {
err = ctx.Err()
} else if processCtx.Err() != nil {
err = processCtx.Err()
} else if canceled {
err = context.Canceled
}
lq.Cancel()
return nil, err
}
lq.client, lq.session, lq.qr = client, session, qr
Expand Down Expand Up @@ -100,6 +122,12 @@ func (lq *LineQRLogin) startPoll(check func(context.Context, string) error, retr
}

func (lq *LineQRLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) {
// Overlapping provisioning calls must not consume each other's polling
// results or advance the PIN/token exchange twice. Cancel doesn't take this lock.
if !lq.operationMu.TryLock() {
return nil, fmt.Errorf("QR login operation already in progress")
}
defer lq.operationMu.Unlock()
if lq.poll == nil {
return nil, fmt.Errorf("QR login has not started")
}
Expand Down
Loading