fluid-telegram/internal/adapter/visual.go
tegwick 86cbb43965 FT-WP-0001-T14: upload post images instead of linking them
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
2026-10-08 20:55:32 +02:00

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
}