Skip to content

Commit 211f3a4

Browse files
committed
fix: download LINE official account images
1 parent 0fc10ea commit 211f3a4

4 files changed

Lines changed: 189 additions & 25 deletions

File tree

pkg/connector/handlers/image.go

Lines changed: 44 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -20,57 +20,56 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int
2020
}
2121

2222
client := h.NewClient()
23-
oid := data.ContentMetadata["OID"]
24-
isPlainMedia := oid == ""
25-
26-
// For plain media, the image is stored at r/talk/m/{messageID}
27-
if isPlainMedia {
28-
oid = data.ID
29-
}
30-
31-
if oid == "" {
23+
downloadSource := lineImageDownloadSource(data)
24+
if downloadSource.publicPath == "" && downloadSource.oid == "" {
3225
return nil, nil
3326
}
3427

3528
mediaCategory := lineMediaCategory(data.ContentMetadata)
36-
downloadOptions := lineOBSDownloadOptions(data.ContentMetadata, isPlainMedia)
37-
talkMetaMessageID := obsTalkMetaMessageID(data.ID, isPlainMedia)
29+
downloadOptions := lineOBSDownloadOptions(data.ContentMetadata, downloadSource.isPlainMedia)
30+
talkMetaMessageID := obsTalkMetaMessageID(data.ID, downloadSource.isPlainMedia)
3831

3932
var imgData []byte
4033
var err error
4134
dlStart := time.Now()
4235
h.Log.Debug().
43-
Str("oid", oid).
36+
Str("oid", downloadSource.oid).
4437
Str("msg_id", data.ID).
4538
Str("tid", downloadOptions.TID).
4639
Str("media_category", mediaCategory).
4740
Bool("has_obs_pop", downloadOptions.OBSPop != "").
48-
Bool("plain_media", isPlainMedia).
41+
Bool("plain_media", downloadSource.isPlainMedia).
42+
Bool("public_resource", downloadSource.publicPath != "").
4943
Msg("Downloading image from LINE OBS")
50-
if isPlainMedia {
51-
imgData, err = client.DownloadOBSWithSIDOptions(ctx, oid, talkMetaMessageID, "m", downloadOptions)
44+
if downloadSource.publicPath != "" {
45+
imgData, err = client.DownloadOBSPublicResource(ctx, downloadSource.publicPath)
46+
} else if downloadSource.isPlainMedia {
47+
imgData, err = client.DownloadOBSWithSIDOptions(ctx, downloadSource.oid, talkMetaMessageID, "m", downloadOptions)
5248
} else {
53-
imgData, err = client.DownloadOBSWithOptions(ctx, oid, talkMetaMessageID, downloadOptions)
49+
imgData, err = client.DownloadOBSWithOptions(ctx, downloadSource.oid, talkMetaMessageID, downloadOptions)
5450
}
5551

5652
// Refresh token if we get a 401
57-
if newClient, ok := h.tryRecoverClient(ctx, client, err); ok {
58-
client = newClient
59-
if isPlainMedia {
60-
imgData, err = client.DownloadOBSWithSIDOptions(ctx, oid, talkMetaMessageID, "m", downloadOptions)
61-
} else {
62-
imgData, err = client.DownloadOBSWithOptions(ctx, oid, talkMetaMessageID, downloadOptions)
53+
if downloadSource.publicPath == "" {
54+
if newClient, ok := h.tryRecoverClient(ctx, client, err); ok {
55+
client = newClient
56+
if downloadSource.isPlainMedia {
57+
imgData, err = client.DownloadOBSWithSIDOptions(ctx, downloadSource.oid, talkMetaMessageID, "m", downloadOptions)
58+
} else {
59+
imgData, err = client.DownloadOBSWithOptions(ctx, downloadSource.oid, talkMetaMessageID, downloadOptions)
60+
}
6361
}
62+
h.handleFinalAuthError(ctx, client, err)
6463
}
65-
h.handleFinalAuthError(ctx, client, err)
6664
downloadDuration := time.Since(dlStart)
6765

6866
if err != nil {
6967
h.Log.Warn().
7068
Err(err).
71-
Str("oid", oid).
69+
Str("oid", downloadSource.oid).
7270
Str("msg_id", data.ID).
73-
Bool("plain_media", isPlainMedia).
71+
Bool("plain_media", downloadSource.isPlainMedia).
72+
Bool("public_resource", downloadSource.publicPath != "").
7473
Dur("download_duration", downloadDuration).
7574
Msg("Failed to download image from OBS")
7675
return mediaDownloadFailure("Image", err, relatesTo)
@@ -147,6 +146,26 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int
147146
}, nil
148147
}
149148

149+
type imageDownloadSource struct {
150+
publicPath string
151+
oid string
152+
isPlainMedia bool
153+
}
154+
155+
func lineImageDownloadSource(data line.Message) imageDownloadSource {
156+
if publicPath := data.ContentMetadata["DOWNLOAD_URL"]; publicPath != "" {
157+
return imageDownloadSource{publicPath: publicPath}
158+
}
159+
160+
oid := data.ContentMetadata["OID"]
161+
if oid != "" {
162+
return imageDownloadSource{oid: oid}
163+
}
164+
165+
// For plain media, the image is stored at r/talk/m/{messageID}.
166+
return imageDownloadSource{oid: data.ID, isPlainMedia: true}
167+
}
168+
150169
func lineMediaCategory(metadata map[string]string) string {
151170
if metadata == nil || metadata["MEDIA_CONTENT_INFO"] == "" {
152171
return ""
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package handlers
2+
3+
import (
4+
"testing"
5+
6+
"github.com/highesttt/matrix-line-messenger/pkg/line"
7+
)
8+
9+
func TestLineImageDownloadSourcePrefersPublicResource(t *testing.T) {
10+
source := lineImageDownloadSource(line.Message{
11+
ID: "message-id",
12+
ContentMetadata: map[string]string{
13+
"DOWNLOAD_URL": "/r/official/business-image",
14+
"OID": "ignored-private-oid",
15+
},
16+
})
17+
18+
if source.publicPath != "/r/official/business-image" {
19+
t.Fatalf("public path = %q", source.publicPath)
20+
}
21+
if source.oid != "" || source.isPlainMedia {
22+
t.Fatalf("source = %#v, want public resource only", source)
23+
}
24+
}
25+
26+
func TestLineImageDownloadSourcePrivateAndPlainFallbacks(t *testing.T) {
27+
privateSource := lineImageDownloadSource(line.Message{
28+
ID: "message-id",
29+
ContentMetadata: map[string]string{"OID": "private-oid"},
30+
})
31+
if privateSource.publicPath != "" || privateSource.oid != "private-oid" || privateSource.isPlainMedia {
32+
t.Fatalf("private source = %#v", privateSource)
33+
}
34+
35+
plainSource := lineImageDownloadSource(line.Message{ID: "message-id"})
36+
if plainSource.publicPath != "" || plainSource.oid != "message-id" || !plainSource.isPlainMedia {
37+
t.Fatalf("plain source = %#v", plainSource)
38+
}
39+
}

pkg/line/client.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,6 +716,45 @@ func (c *Client) DownloadOBSWithSIDOptions(ctx context.Context, oid string, mess
716716
return c.downloadOBSWithServiceAndSIDOptions(ctx, "talk", sid, oid, messageID, opts)
717717
}
718718

719+
// DownloadOBSPublicResource downloads a public OBS resource referenced by
720+
// message content metadata. LINE Chrome uses DOWNLOAD_URL directly and skips
721+
// object_info.obs and the private OBS authorization header mapper.
722+
func (c *Client) DownloadOBSPublicResource(ctx context.Context, resourcePath string) ([]byte, error) {
723+
parsedPath, err := url.Parse(resourcePath)
724+
if err != nil {
725+
return nil, fmt.Errorf("failed to parse public OBS resource path: %w", err)
726+
}
727+
if parsedPath.IsAbs() || parsedPath.Host != "" || !strings.HasPrefix(parsedPath.Path, "/") {
728+
return nil, errors.New("public OBS resource must be an absolute path")
729+
}
730+
731+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, OBSBaseURL+resourcePath, nil)
732+
if err != nil {
733+
return nil, fmt.Errorf("failed to create public OBS resource request: %w", err)
734+
}
735+
req.Header.Set("User-Agent", UserAgent)
736+
737+
resp, err := c.obsHTTPClient().Do(req)
738+
if err != nil {
739+
return nil, fmt.Errorf("public OBS resource request failed: %w", err)
740+
}
741+
body, readErr := io.ReadAll(resp.Body)
742+
resp.Body.Close()
743+
if readErr != nil {
744+
return nil, fmt.Errorf("failed to read public OBS resource response: %w", readErr)
745+
}
746+
switch resp.StatusCode {
747+
case http.StatusOK:
748+
return body, nil
749+
case http.StatusAccepted:
750+
return nil, ErrOBSEncodingIncomplete
751+
case http.StatusNotFound:
752+
return nil, ErrOBSObjectNotFound
753+
default:
754+
return nil, fmt.Errorf("public OBS resource download failed (%d): %s", resp.StatusCode, string(body))
755+
}
756+
}
757+
719758
// DownloadOBSResource retrieves a non-talk resource using the service, SID,
720759
// and OID supplied by LINE metadata. Album post previews use service "album"
721760
// and SID "a".

pkg/line/obs_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,73 @@ func TestDownloadOBSPlainMatchesChromeRequestFlow(t *testing.T) {
8888
}
8989
}
9090

91+
func TestDownloadOBSPublicResourceMatchesChromeRequestFlow(t *testing.T) {
92+
var request *http.Request
93+
client := NewClient("line-token-that-must-not-be-used")
94+
client.OBSClient = &http.Client{
95+
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
96+
request = req
97+
return obsResponse(http.StatusOK, "business-image"), nil
98+
}),
99+
}
100+
101+
data, err := client.DownloadOBSPublicResource(
102+
context.Background(),
103+
"/r/official/image-id?public=resource",
104+
)
105+
if err != nil {
106+
t.Fatal(err)
107+
}
108+
if string(data) != "business-image" {
109+
t.Fatalf("data = %q, want business-image", data)
110+
}
111+
if request == nil {
112+
t.Fatal("public resource request was not made")
113+
}
114+
if request.URL.Scheme != "https" || request.URL.Host != "obs.line-apps.com" {
115+
t.Fatalf("request URL origin = %s://%s", request.URL.Scheme, request.URL.Host)
116+
}
117+
if request.URL.Path != "/r/official/image-id" || request.URL.RawQuery != "public=resource" {
118+
t.Fatalf("request URL = %s", request.URL.String())
119+
}
120+
if request.Header.Get("X-Line-Access") != "" {
121+
t.Fatal("public resource request unexpectedly included X-Line-Access")
122+
}
123+
if request.Header.Get("X-Line-Application") != "" {
124+
t.Fatal("public resource request unexpectedly included X-Line-Application")
125+
}
126+
if request.Header.Get("X-Talk-Meta") != "" {
127+
t.Fatal("public resource request unexpectedly included X-Talk-Meta")
128+
}
129+
}
130+
131+
func TestDownloadOBSPublicResourceRejectsExternalURL(t *testing.T) {
132+
client := NewClient("line-token")
133+
for _, resourcePath := range []string{
134+
"https://example.com/image",
135+
"//example.com/image",
136+
"relative/image",
137+
} {
138+
if _, err := client.DownloadOBSPublicResource(context.Background(), resourcePath); err == nil {
139+
t.Fatalf("resource path %q was accepted", resourcePath)
140+
}
141+
}
142+
}
143+
144+
func TestDownloadOBSPublicResourceClassifiesMissingObject(t *testing.T) {
145+
client := NewClient("line-token")
146+
client.OBSClient = &http.Client{
147+
Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
148+
return obsResponse(http.StatusNotFound, "not found"), nil
149+
}),
150+
}
151+
152+
_, err := client.DownloadOBSPublicResource(context.Background(), "/r/official/missing")
153+
if !errors.Is(err, ErrOBSObjectNotFound) {
154+
t.Fatalf("err = %v, want ErrOBSObjectNotFound", err)
155+
}
156+
}
157+
91158
func TestDownloadOBSResourceUsesReceiveServiceAndSID(t *testing.T) {
92159
installCachedOBSToken(t)
93160

0 commit comments

Comments
 (0)