Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 44 additions & 25 deletions pkg/connector/handlers/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,57 +20,56 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int
}

client := h.NewClient()
oid := data.ContentMetadata["OID"]
isPlainMedia := oid == ""

// For plain media, the image is stored at r/talk/m/{messageID}
if isPlainMedia {
oid = data.ID
}

if oid == "" {
downloadSource := lineImageDownloadSource(data)
if downloadSource.publicPath == "" && downloadSource.oid == "" {
return nil, nil
}

mediaCategory := lineMediaCategory(data.ContentMetadata)
downloadOptions := lineOBSDownloadOptions(data.ContentMetadata, isPlainMedia)
talkMetaMessageID := obsTalkMetaMessageID(data.ID, isPlainMedia)
downloadOptions := lineOBSDownloadOptions(data.ContentMetadata, downloadSource.isPlainMedia)
talkMetaMessageID := obsTalkMetaMessageID(data.ID, downloadSource.isPlainMedia)

var imgData []byte
var err error
dlStart := time.Now()
h.Log.Debug().
Str("oid", oid).
Str("oid", downloadSource.oid).
Str("msg_id", data.ID).
Str("tid", downloadOptions.TID).
Str("media_category", mediaCategory).
Bool("has_obs_pop", downloadOptions.OBSPop != "").
Bool("plain_media", isPlainMedia).
Bool("plain_media", downloadSource.isPlainMedia).
Bool("public_resource", downloadSource.publicPath != "").
Msg("Downloading image from LINE OBS")
if isPlainMedia {
imgData, err = client.DownloadOBSWithSIDOptions(ctx, oid, talkMetaMessageID, "m", downloadOptions)
if downloadSource.publicPath != "" {
imgData, err = client.DownloadOBSPublicResource(ctx, downloadSource.publicPath)
} else if downloadSource.isPlainMedia {
imgData, err = client.DownloadOBSWithSIDOptions(ctx, downloadSource.oid, talkMetaMessageID, "m", downloadOptions)
} else {
imgData, err = client.DownloadOBSWithOptions(ctx, oid, talkMetaMessageID, downloadOptions)
imgData, err = client.DownloadOBSWithOptions(ctx, downloadSource.oid, talkMetaMessageID, downloadOptions)
}

// Refresh token if we get a 401
if newClient, ok := h.tryRecoverClient(ctx, client, err); ok {
client = newClient
if isPlainMedia {
imgData, err = client.DownloadOBSWithSIDOptions(ctx, oid, talkMetaMessageID, "m", downloadOptions)
} else {
imgData, err = client.DownloadOBSWithOptions(ctx, oid, talkMetaMessageID, downloadOptions)
if downloadSource.publicPath == "" {
if newClient, ok := h.tryRecoverClient(ctx, client, err); ok {
client = newClient
if downloadSource.isPlainMedia {
imgData, err = client.DownloadOBSWithSIDOptions(ctx, downloadSource.oid, talkMetaMessageID, "m", downloadOptions)
} else {
imgData, err = client.DownloadOBSWithOptions(ctx, downloadSource.oid, talkMetaMessageID, downloadOptions)
}
}
h.handleFinalAuthError(ctx, client, err)
}
h.handleFinalAuthError(ctx, client, err)
downloadDuration := time.Since(dlStart)

if err != nil {
h.Log.Warn().
Err(err).
Str("oid", oid).
Str("oid", downloadSource.oid).
Str("msg_id", data.ID).
Bool("plain_media", isPlainMedia).
Bool("plain_media", downloadSource.isPlainMedia).
Bool("public_resource", downloadSource.publicPath != "").
Dur("download_duration", downloadDuration).
Msg("Failed to download image from OBS")
return mediaDownloadFailure("Image", err, relatesTo)
Expand Down Expand Up @@ -147,6 +146,26 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int
}, nil
}

type imageDownloadSource struct {
publicPath string
oid string
isPlainMedia bool
}

func lineImageDownloadSource(data line.Message) imageDownloadSource {
if publicPath := data.ContentMetadata["DOWNLOAD_URL"]; publicPath != "" {
return imageDownloadSource{publicPath: publicPath}
}

oid := data.ContentMetadata["OID"]
if oid != "" {
return imageDownloadSource{oid: oid}
}

// For plain media, the image is stored at r/talk/m/{messageID}.
return imageDownloadSource{oid: data.ID, isPlainMedia: true}
}

func lineMediaCategory(metadata map[string]string) string {
if metadata == nil || metadata["MEDIA_CONTENT_INFO"] == "" {
return ""
Expand Down
39 changes: 39 additions & 0 deletions pkg/connector/handlers/image_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package handlers

import (
"testing"

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

func TestLineImageDownloadSourcePrefersPublicResource(t *testing.T) {
source := lineImageDownloadSource(line.Message{
ID: "message-id",
ContentMetadata: map[string]string{
"DOWNLOAD_URL": "/r/official/business-image",
"OID": "ignored-private-oid",
},
})

if source.publicPath != "/r/official/business-image" {
t.Fatalf("public path = %q", source.publicPath)
}
if source.oid != "" || source.isPlainMedia {
t.Fatalf("source = %#v, want public resource only", source)
}
}

func TestLineImageDownloadSourcePrivateAndPlainFallbacks(t *testing.T) {
privateSource := lineImageDownloadSource(line.Message{
ID: "message-id",
ContentMetadata: map[string]string{"OID": "private-oid"},
})
if privateSource.publicPath != "" || privateSource.oid != "private-oid" || privateSource.isPlainMedia {
t.Fatalf("private source = %#v", privateSource)
}

plainSource := lineImageDownloadSource(line.Message{ID: "message-id"})
if plainSource.publicPath != "" || plainSource.oid != "message-id" || !plainSource.isPlainMedia {
t.Fatalf("plain source = %#v", plainSource)
}
}
39 changes: 39 additions & 0 deletions pkg/line/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,45 @@ func (c *Client) DownloadOBSWithSIDOptions(ctx context.Context, oid string, mess
return c.downloadOBSWithServiceAndSIDOptions(ctx, "talk", sid, oid, messageID, opts)
}

// DownloadOBSPublicResource downloads a public OBS resource referenced by
// message content metadata. LINE Chrome uses DOWNLOAD_URL directly and skips
// object_info.obs and the private OBS authorization header mapper.
func (c *Client) DownloadOBSPublicResource(ctx context.Context, resourcePath string) ([]byte, error) {
parsedPath, err := url.Parse(resourcePath)
if err != nil {
return nil, fmt.Errorf("failed to parse public OBS resource path: %w", err)
}
if parsedPath.IsAbs() || parsedPath.Host != "" || !strings.HasPrefix(parsedPath.Path, "/") {
return nil, errors.New("public OBS resource must be an absolute path")
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, OBSBaseURL+resourcePath, nil)
if err != nil {
return nil, fmt.Errorf("failed to create public OBS resource request: %w", err)
}
req.Header.Set("User-Agent", UserAgent)

resp, err := c.obsHTTPClient().Do(req)
if err != nil {
return nil, fmt.Errorf("public OBS resource request failed: %w", err)
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, fmt.Errorf("failed to read public OBS resource response: %w", readErr)
}
switch resp.StatusCode {
case http.StatusOK:
return body, nil
case http.StatusAccepted:
return nil, ErrOBSEncodingIncomplete
case http.StatusNotFound:
return nil, ErrOBSObjectNotFound
default:
return nil, fmt.Errorf("public OBS resource download failed (%d): %s", resp.StatusCode, string(body))
}
}

// DownloadOBSResource retrieves a non-talk resource using the service, SID,
// and OID supplied by LINE metadata. Album post previews use service "album"
// and SID "a".
Expand Down
67 changes: 67 additions & 0 deletions pkg/line/obs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,73 @@ func TestDownloadOBSPlainMatchesChromeRequestFlow(t *testing.T) {
}
}

func TestDownloadOBSPublicResourceMatchesChromeRequestFlow(t *testing.T) {
var request *http.Request
client := NewClient("line-token-that-must-not-be-used")
client.OBSClient = &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
request = req
return obsResponse(http.StatusOK, "business-image"), nil
}),
}

data, err := client.DownloadOBSPublicResource(
context.Background(),
"/r/official/image-id?public=resource",
)
if err != nil {
t.Fatal(err)
}
if string(data) != "business-image" {
t.Fatalf("data = %q, want business-image", data)
}
if request == nil {
t.Fatal("public resource request was not made")
}
if request.URL.Scheme != "https" || request.URL.Host != "obs.line-apps.com" {
t.Fatalf("request URL origin = %s://%s", request.URL.Scheme, request.URL.Host)
}
if request.URL.Path != "/r/official/image-id" || request.URL.RawQuery != "public=resource" {
t.Fatalf("request URL = %s", request.URL.String())
}
if request.Header.Get("X-Line-Access") != "" {
t.Fatal("public resource request unexpectedly included X-Line-Access")
}
if request.Header.Get("X-Line-Application") != "" {
t.Fatal("public resource request unexpectedly included X-Line-Application")
}
if request.Header.Get("X-Talk-Meta") != "" {
t.Fatal("public resource request unexpectedly included X-Talk-Meta")
}
}

func TestDownloadOBSPublicResourceRejectsExternalURL(t *testing.T) {
client := NewClient("line-token")
for _, resourcePath := range []string{
"https://example.com/image",
"//example.com/image",
"relative/image",
} {
if _, err := client.DownloadOBSPublicResource(context.Background(), resourcePath); err == nil {
t.Fatalf("resource path %q was accepted", resourcePath)
}
}
}

func TestDownloadOBSPublicResourceClassifiesMissingObject(t *testing.T) {
client := NewClient("line-token")
client.OBSClient = &http.Client{
Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return obsResponse(http.StatusNotFound, "not found"), nil
}),
}

_, err := client.DownloadOBSPublicResource(context.Background(), "/r/official/missing")
if !errors.Is(err, ErrOBSObjectNotFound) {
t.Fatalf("err = %v, want ErrOBSObjectNotFound", err)
}
}

func TestDownloadOBSResourceUsesReceiveServiceAndSID(t *testing.T) {
installCachedOBSToken(t)

Expand Down
Loading