Files
mcp-captcharesolver/internal/imageutil/imageutil.go
T

119 lines
3.2 KiB
Go

// Package imageutil decodes image/audio inputs (base64, data-URI, file path,
// http(s) URL) and computes grid geometry for tile-based captcha solving.
package imageutil
import (
"context"
"encoding/base64"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strings"
"time"
)
var dataURIRe = regexp.MustCompile(`^data:([^;,]+)(;base64)?,(.*)$`)
// Point is a pixel coordinate.
type Point struct {
X int
Y int
}
// Decode resolves input (data-URI | bare base64 | file path | http(s) URL) to
// raw bytes plus a best-effort media type sniffed from the content.
func Decode(input string) ([]byte, string, error) {
input = strings.TrimSpace(input)
switch {
case strings.HasPrefix(input, "data:"):
m := dataURIRe.FindStringSubmatch(input)
if m == nil {
return nil, "", fmt.Errorf("malformed data URI")
}
var data []byte
var err error
if m[2] == ";base64" {
data, err = base64.StdEncoding.DecodeString(m[3])
} else {
data = []byte(m[3])
}
if err != nil {
return nil, "", err
}
return data, m[1], nil
case strings.HasPrefix(input, "http://"), strings.HasPrefix(input, "https://"):
return fetchURL(input)
default:
// File path if it exists; otherwise treat as bare base64.
if data, err := os.ReadFile(input); err == nil { //nolint:gosec // path is caller-provided automation input
return data, sniff(data), nil
}
data, err := base64.StdEncoding.DecodeString(input)
if err != nil {
return nil, "", fmt.Errorf("input is not a data-URI, URL, readable file, or base64: %w", err)
}
return data, sniff(data), nil
}
}
func fetchURL(url string) ([]byte, string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 300 {
return nil, "", fmt.Errorf("fetch %s: HTTP %d", url, resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err != nil {
return nil, "", err
}
mt := resp.Header.Get("Content-Type")
if mt == "" {
mt = sniff(data)
}
return data, mt, nil
}
// sniff returns a media type from magic bytes, defaulting to image/png.
func sniff(b []byte) string {
switch {
case len(b) >= 4 && b[0] == 0x89 && b[1] == 0x50 && b[2] == 0x4e && b[3] == 0x47:
return "image/png"
case len(b) >= 3 && b[0] == 0xff && b[1] == 0xd8 && b[2] == 0xff:
return "image/jpeg"
case len(b) >= 4 && b[0] == 'R' && b[1] == 'I' && b[2] == 'F' && b[3] == 'F':
return "audio/wav"
case len(b) >= 6 && string(b[:3]) == "ID3":
return "audio/mpeg"
default:
return "image/png"
}
}
// GridCentroids returns the pixel centre of each tile for a rows x cols grid
// laid over a width x height image, in row-major order (index 0 = top-left).
func GridCentroids(width, height, rows, cols int) []Point {
pts := make([]Point, 0, rows*cols)
cw := float64(width) / float64(cols)
ch := float64(height) / float64(rows)
for r := 0; r < rows; r++ {
for c := 0; c < cols; c++ {
pts = append(pts, Point{
X: int(cw*float64(c) + cw/2),
Y: int(ch*float64(r) + ch/2),
})
}
}
return pts
}