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 }