The adapter fetches visual_url from an allowlisted host (https, image types, 10 MB cap, optional read token) and uploads it as multipart sendPhoto, so the forge can close without breaking post images. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 5457@bnt-lap001 Assistant-Session: 792bf84a-3783-4f5d-a811-cded61dd2012
101 lines
3.1 KiB
Go
101 lines
3.1 KiB
Go
package adapter
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// MaxPhotoBytes is Telegram's upload limit for sendPhoto. A larger image is
|
|
// refused, not resized: the composer chose it and the reviewer signed it off.
|
|
const MaxPhotoBytes = 10 << 20
|
|
|
|
// Photo is an image the adapter fetched itself, ready to upload. Uploading
|
|
// rather than handing Telegram a URL means the image's host does not have to
|
|
// be public (ORGREF-WP-0002, decision point 4).
|
|
type Photo struct {
|
|
Data []byte
|
|
Filename string
|
|
ContentType string
|
|
}
|
|
|
|
var photoTypes = map[string]bool{"image/jpeg": true, "image/png": true, "image/webp": true}
|
|
|
|
// VisualFetcher downloads a post's image from an allowlisted host, optionally
|
|
// with a read token. The token is sent only to allowlisted hosts and never
|
|
// appears in an error.
|
|
type VisualFetcher struct {
|
|
hosts map[string]bool
|
|
token string
|
|
hc *http.Client
|
|
}
|
|
|
|
// NewVisualFetcher allows fetches from the given hosts only. token may be
|
|
// empty while the host still serves the image anonymously.
|
|
func NewVisualFetcher(hosts []string, token string) *VisualFetcher {
|
|
f := &VisualFetcher{hosts: map[string]bool{}, token: token}
|
|
for _, h := range hosts {
|
|
if h = strings.TrimSpace(strings.ToLower(h)); h != "" {
|
|
f.hosts[h] = true
|
|
}
|
|
}
|
|
f.hc = &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
CheckRedirect: func(req *http.Request, _ []*http.Request) error {
|
|
if !f.allowed(req.URL) {
|
|
return errors.New("redirect to a host that is not allowlisted")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
return f
|
|
}
|
|
|
|
func (f *VisualFetcher) allowed(u *url.URL) bool {
|
|
return u.Scheme == "https" && f.hosts[strings.ToLower(u.Hostname())]
|
|
}
|
|
|
|
// Fetch downloads rawURL and checks that it is an image Telegram accepts.
|
|
func (f *VisualFetcher) Fetch(ctx context.Context, rawURL string) (Photo, error) {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil || !f.allowed(u) {
|
|
return Photo{}, fmt.Errorf("visual_url is not an https URL on an allowlisted host")
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
|
if err != nil {
|
|
return Photo{}, fmt.Errorf("visual_url: %w", err)
|
|
}
|
|
if f.token != "" {
|
|
req.Header.Set("Authorization", "token "+f.token)
|
|
}
|
|
resp, err := f.hc.Do(req)
|
|
if err != nil {
|
|
return Photo{}, errors.New("visual fetch: network failure")
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return Photo{}, fmt.Errorf("visual fetch: status %d", resp.StatusCode)
|
|
}
|
|
ct := strings.ToLower(strings.TrimSpace(strings.SplitN(resp.Header.Get("Content-Type"), ";", 2)[0]))
|
|
if !photoTypes[ct] {
|
|
return Photo{}, fmt.Errorf("visual fetch: content type %q is not jpeg, png or webp", ct)
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(resp.Body, MaxPhotoBytes+1))
|
|
if err != nil {
|
|
return Photo{}, errors.New("visual fetch: read failure")
|
|
}
|
|
if len(data) > MaxPhotoBytes {
|
|
return Photo{}, fmt.Errorf("visual fetch: image is over Telegram's %d byte photo limit", MaxPhotoBytes)
|
|
}
|
|
name := path.Base(u.Path)
|
|
if name == "" || name == "/" || name == "." {
|
|
name = "visual"
|
|
}
|
|
return Photo{Data: data, Filename: name, ContentType: ct}, nil
|
|
}
|