diff --git a/internal/imageutil/imageutil.go b/internal/imageutil/imageutil.go new file mode 100644 index 0000000..7582973 --- /dev/null +++ b/internal/imageutil/imageutil.go @@ -0,0 +1,118 @@ +// 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 +} diff --git a/internal/imageutil/imageutil_test.go b/internal/imageutil/imageutil_test.go new file mode 100644 index 0000000..4051d58 --- /dev/null +++ b/internal/imageutil/imageutil_test.go @@ -0,0 +1,52 @@ +package imageutil + +import ( + "encoding/base64" + "os" + "path/filepath" + "testing" +) + +func TestDecode(t *testing.T) { + raw := []byte{0x89, 0x50, 0x4e, 0x47} // PNG magic prefix + b64 := base64.StdEncoding.EncodeToString(raw) + + t.Run("data uri", func(t *testing.T) { + data, mt, err := Decode("data:image/png;base64," + b64) + if err != nil || mt != "image/png" || string(data) != string(raw) { + t.Fatalf("data=%v mt=%q err=%v", data, mt, err) + } + }) + t.Run("bare base64 sniffs png", func(t *testing.T) { + data, mt, err := Decode(b64) + if err != nil || mt != "image/png" || string(data) != string(raw) { + t.Fatalf("data=%v mt=%q err=%v", data, mt, err) + } + }) + t.Run("file path", func(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "x.png") + if err := os.WriteFile(p, raw, 0o600); err != nil { + t.Fatal(err) + } + data, mt, err := Decode(p) + if err != nil || mt != "image/png" || string(data) != string(raw) { + t.Fatalf("data=%v mt=%q err=%v", data, mt, err) + } + }) +} + +func TestGridCentroids(t *testing.T) { + pts := GridCentroids(300, 300, 3, 3) + if len(pts) != 9 { + t.Fatalf("want 9 centroids, got %d", len(pts)) + } + // index 0 = top-left tile centre at (50,50) + if pts[0].X != 50 || pts[0].Y != 50 { + t.Fatalf("centroid[0]=%+v", pts[0]) + } + // index 4 = centre tile at (150,150) + if pts[4].X != 150 || pts[4].Y != 150 { + t.Fatalf("centroid[4]=%+v", pts[4]) + } +}