From 211f3a44f6f3f60a223faaee81635870dff89018 Mon Sep 17 00:00:00 2001 From: highesttt Date: Mon, 10 Aug 2026 22:18:49 -0400 Subject: [PATCH] fix: download LINE official account images --- pkg/connector/handlers/image.go | 69 ++++++++++++++++++---------- pkg/connector/handlers/image_test.go | 39 ++++++++++++++++ pkg/line/client.go | 39 ++++++++++++++++ pkg/line/obs_test.go | 67 +++++++++++++++++++++++++++ 4 files changed, 189 insertions(+), 25 deletions(-) create mode 100644 pkg/connector/handlers/image_test.go diff --git a/pkg/connector/handlers/image.go b/pkg/connector/handlers/image.go index 4c9792a..0a5becf 100644 --- a/pkg/connector/handlers/image.go +++ b/pkg/connector/handlers/image.go @@ -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) @@ -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 "" diff --git a/pkg/connector/handlers/image_test.go b/pkg/connector/handlers/image_test.go new file mode 100644 index 0000000..278647c --- /dev/null +++ b/pkg/connector/handlers/image_test.go @@ -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) + } +} diff --git a/pkg/line/client.go b/pkg/line/client.go index f9f980e..91bcfdc 100644 --- a/pkg/line/client.go +++ b/pkg/line/client.go @@ -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". diff --git a/pkg/line/obs_test.go b/pkg/line/obs_test.go index 01dcb74..5716da2 100644 --- a/pkg/line/obs_test.go +++ b/pkg/line/obs_test.go @@ -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)