diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f76cb79 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + - name: Install dependencies + run: go mod download + - name: Install optimizer tools + run: sudo apt-get update && sudo apt-get install -y pngquant webp libjpeg-turbo-progs + - name: Vet + run: go vet ./... + - name: Test + run: go test ./... -v -coverprofile=coverage.out + - name: Coverage report + run: go tool cover -func=coverage.out \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2eea525..5478e30 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ -.env \ No newline at end of file +.env +goimager.yaml +coverage.out +*.out +goImager \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d82a96e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,64 @@ +# AGENTS.md + +Compact guidance for OpenCode sessions working in this repo. + +## Project + +GoImager — self-hosted, privacy-first image processing microservice. +Module path: `github.com/DulanDev/GoImager`. + +## Commands + +```sh +go mod tidy # install/refresh deps +go run cmd/server/main.go # start server (config: defaults -> goimager.yaml -> env) +go test ./... # run all tests +go test ./internal/service -run TestResizeImageFit # run a single test by name +go test ./internal/service -coverprofile=coverage.out && go tool cover -func=coverage.out +go vet ./... # lint +docker compose up --build # containerized run, bundles optimizer binaries +``` + +No Makefile, no scripts. `api/`, `pkg/`, `scripts/`, `config/` are empty +placeholder dirs — do not assume they hold code. + +## Architecture + +- `cmd/server/main.go` — only entrypoint; loads config via `config.Load()`, + builds the `slog` logger, wires routes through `middleware.RequestLogger`, + warns about missing optimizer tools. +- `internal/config/config.go` — layered config: code defaults → optional YAML + (`./goimager.yaml`, `~/.goimager.yaml`, `/etc/goimager/config.yaml`) → env + vars (highest priority). `.env` optionally loaded for local dev via + `godotenv`, never fatal when absent. +- `internal/handler/` — handlers on `*Server`. `server.go` holds config + log + + shared `writeError`. Endpoints: `health.go`(GET /health, constant Version), + `info.go`(GET/POST /info), `resize.go`, `convert.go`, `optimize.go`. + All errors return structured JSON `{ "error", "code" }`. +- `internal/service/imageprocessor.go` — `ResizeImage` (modes fit/fill/stretch, + dims validated, 0=auto, format passthrough when blank), `ConvertImage`, + `Encode` (jpeg/png/gif via stdlib; webp via `cwebp` CLI), `Decode` (webp input + registered via `golang.org/x/image/webp` blank import). +- `internal/service/optimizer.go` — `Optimize` dispatch: pngquant on PNG, + mozjpeg `cjpeg` on JPEG (via PPM intermediate), `cwebp` on WebP; Go re-encode + fallback when a tool/CLI is missing. `_ = stripExif` — EXIF is dropped by + `image.Decode` automatically. +- `internal/service/metadata.go` — `InfoFromReader`; minimal inline TIFF/EXIF + parser (no external EXIF dep) for Make/Model, DateTime(/Original) and GPS. +- `internal/middleware/logger.go` — `slog` request logger (`RequestLogger` + adapter for mux `r.Use`). + +## Conventions & gotchas + +- Module path is **`github.com/DulanDev/GoImager`**. Imports are + `github.com/DulanDev/GoImager/internal/...`. Renaming touches every file. +- No `.env` required. Config works with env vars only (Docker/K8s friendly). +- Multipart limit comes from `config.Server.MaxFileSizeMB` (default 20 MB). +- `MAX_DIMENSION` env/default `10000`; service also caps at internal + `MaxDimCap = 100000` to reject oversized source images. +- WebP output requires `cwebp` on PATH (bundled in the Docker image). Without + it, resize/convert/optimize with `format=webp` falls back to JPEG. +- `Optimizer` config CLI paths default to `pngquant` / `cjpeg` / `cwebp`. + Missing tools downgrade optimization but never crash the service. +- Spec doc: `specs/product-specs.md`. v1.1+ items (auth, ratelimit, /process, + /thumbnail) are intentionally not implemented yet — follow the roadmap there. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..45735f6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +# Build stage +FROM golang:1.22-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o goImager ./cmd/server + +# Runtime stage +FROM alpine:3.19 +RUN apk --no-cache add ca-certificates pngquant mozjpeg libwebp-tools +WORKDIR /root/ +COPY --from=builder /app/goImager . +EXPOSE 8080 +CMD ["./goImager"] \ No newline at end of file diff --git a/README.md b/README.md index 9299570..d3d4f75 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,162 @@ -# GO IMAGER +# GoImager -Go-based microservice for resizing, converting, and optimizing images +[![Build](https://github.com/DulanDev/GoImager/actions/workflows/ci.yml/badge.svg)](https://github.com/DulanDev/GoImager/actions/workflows/ci.yml) +[![Go](https://img.shields.io/badge/Go-1.22+-00ADD8?logo=go)](https://go.dev/) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) + +A self-hosted, privacy-first image processing microservice written in Go. +Lightweight open-source alternative to Cloudinary/imgix — no SaaS fees, no +vendor lock-in, full data control. ## Features -- Resize images to specified dimensions -- Convert images between formats (JPEG, PNG, GIF) -- Optimize images for web use -- RESTful API for easy integration +- **Resize** with `fit`, `fill`, `stretch` modes +- **Convert** between JPEG / PNG / WebP / GIF +- **Optimize** via optional external binaries (`pngquant`, `mozjpeg` `cjpeg`, `cwebp`) +- **Info** — EXIF + image metadata extraction +- **Health** check for orchestrators +- Layered config: code defaults → optional YAML → env vars +- Structured `slog` request logging +- Stateless, horizontally scalable, Docker-ready ## Quick Start ```sh -# Clone the repository -git clone https://github.com/yourusername/ImageProcessGo.git - -# Navigate to the project directory +git clone https://github.com/DulanDev/GoImager.git cd GoImager - -# Install dependencies go mod tidy - -# Run the server go run cmd/server/main.go ``` -The server will start on `http://localhost:8080`. +Server listens on `http://localhost:8080` by default. + +### Docker + +```sh +docker compose up --build +``` + +The runtime image bundles `pngquant`, `mozjpeg` and `libwebp-tools`, so full +optimization works out of the box. + +### Local optimizer tools (optional, for native dev) + +```sh +brew install pngquant mozjpeg webp # macOS +# or +sudo apt-get install -y pngquant mozjpeg webp # Debian/Ubuntu +``` + +When a tool is missing the service logs a warning and falls back to Go's native +re-encode (metadata stripped, no quantization / Huffman optimization). + +## API + +All non-binary errors return structured JSON: + +```json +{ "error": "description of what went wrong", "code": "INVALID_DIMENSIONS" } +``` + +### `GET /health` + +```json +{ "status": "ok", "version": "1.0.0" } +``` + +### `GET|POST /info` + +`multipart/form-data`, field `image` → metadata, no transformation. + +```json +{ + "width": 1920, + "height": 1080, + "format": "jpeg", + "size_bytes": 204800, + "color_model": "YCbCr", + "exif": { "camera": "Sony A7 IV", "taken_at": "2024-08-15T14:32:00Z", "gps": null } +} +``` + +### `POST /resize` + +| Field | Type | Required | Description | +| --------- | ------- | -------- | -------------------------------------------- | +| `image` | file | yes | Image to resize | +| `width` | integer | yes\* | Target width in pixels. 0 = auto-scale | +| `height` | integer | yes\* | Target height in pixels. 0 = auto-scale | +| `mode` | string | no | `fit` (default), `fill`, `stretch` | +| `format` | string | no | Output: `jpeg`, `png`, `webp`, `gif` | +| `quality` | integer | no | Compression quality 1–100 (default: 85) | + +\*At least one of `width` / `height` must be non-zero. When `format` is omitted +the input format is preserved. + +Returns the transformed image binary with the matching `Content-Type`. + +### `POST /convert` -## API Endpoints +| Field | Type | Required | Description | +| --------- | ------- | -------- | --------------------------------------------------- | +| `image` | file | yes | Image to convert | +| `format` | string | yes | Target: `jpeg`, `png`, `webp`, `gif` | +| `quality` | integer | no | Compression quality 1–100 (default: 85) | -- `POST /resize`: Resize an image -- `POST /convert`: Convert an image to a different format -- `POST /optimize`: Optimize an image for web use :: **TODO** +### `POST /optimize` + +| Field | Type | Required | Description | +| ------------ | ------- | -------- | ----------------------------------------------- | +| `image` | file | yes | Image to optimize | +| `quality` | integer | no | Target quality 1–100 (default: 80) | +| `strip_exif` | boolean | no | Remove EXIF metadata (default: true) | +| `format` | string | no | Output override (default: same as input) | + +Headers: `X-Original-Size`, `X-Optimized-Size`, `X-Reduction-Percent`. + +**Compression strategy per format:** + +| Input format | Tool / method | +| ------------ | ----------------------------------- | +| PNG | `pngquant` (24-bit → 8-bit indexed) | +| JPEG | `mozjpeg` `cjpeg` (Huffman optim) | +| WebP | `cwebp` | +| GIF / other | Go decode + re-encode fallback | + +All paths strip EXIF on decode. ## Configuration -Environment variables: +Precedence (low → high): code defaults → YAML file → environment variables. -- `PORT`: The port on which the server will run (default: 8080) +YAML is searched in order: `./goimager.yaml`, `$HOME/.goimager.yaml`, +`/etc/goimager/config.yaml`. See [`goimager.example.yaml`](goimager.example.yaml). -## Contributing +| Variable | Default | Description | +| ---------------------------- | ----------- | ------------------------------------------------------ | +| `PORT` | `8080` | HTTP server port | +| `MAX_FILE_SIZE_MB` | `20` | Max upload size in MB | +| `MAX_DIMENSION` | `10000` | Max allowed image dimension | +| `DEFAULT_QUALITY` | `85` | Default compression quality | +| `API_KEY` | _(empty)_ | If set, requires `Authorization: Bearer ` | +| `RATE_LIMIT_RPS` | `100` | Requests per second per client IP | +| `ALLOWED_DOMAINS` | `*` | Comma-separated allowlist for `/process?src=` | +| `LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` | +| `LOG_FORMAT` | `json` | `json` or `text` | +| `OPTIMIZER_PNGQUANT_PATH` | `pngquant` | Path to `pngquant` binary (empty = skip) | +| `OPTIMIZER_MOZJPEG_PATH` | `cjpeg` | Path to `mozjpeg` `cjpeg` binary (empty = skip) | +| `OPTIMIZER_CWEBP_PATH` | `cwebp` | Path to `cwebp` binary (empty = skip) | -Contributions are welcome! Please feel free to submit a Pull Request. +## Development + +```sh +go mod tidy +go run cmd/server/main.go +go test ./... -coverprofile=coverage.out +go tool cover -func=coverage.out +go vet ./... +``` ## License -This project is licensed under the MIT License - see the LICENSE file for details. +MIT — see [LICENSE](LICENSE). \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go index f4d0925..1d34c6c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,36 +2,63 @@ package main import ( "fmt" - "log" + "log/slog" "net/http" "os" - - "GoImager/internal/handler" + "os/exec" "github.com/gorilla/mux" - "github.com/joho/godotenv" + + "github.com/DulanDev/GoImager/internal/config" + "github.com/DulanDev/GoImager/internal/handler" + "github.com/DulanDev/GoImager/internal/middleware" ) func main() { - // Load .env file - err := godotenv.Load() + cfg, err := config.Load() if err != nil { - log.Fatalf("Error loading .env file") + fmt.Fprintf(os.Stderr, "config load failed: %v\n", err) + os.Exit(1) } - + + log := middleware.NewLogger(cfg.Logging.Level, cfg.Logging.Format) + log.Info("starting GoImager", "port", cfg.Server.Port, "version", handler.Version) + warnMissingTools(&cfg, log) + + srv := handler.New(cfg, log) r := mux.NewRouter() + r.Use(middleware.RequestLogger(log)) - r.HandleFunc("/resize", handler.ResizeHandler).Methods("POST") - r.HandleFunc("/convert", handler.ConvertHandler).Methods("POST") + r.HandleFunc("/health", srv.Health).Methods("GET") + r.HandleFunc("/info", srv.Info).Methods("GET", "POST") + r.HandleFunc("/resize", srv.Resize).Methods("POST") + r.HandleFunc("/convert", srv.Convert).Methods("POST") + r.HandleFunc("/optimize", srv.Optimize).Methods("POST") r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("GoImager")) -}).Methods("GET") + }).Methods("GET") - port := os.Getenv("PORT") - if port == "" { - port = "8080" + addr := ":" + cfg.Server.Port + log.Info("listening", "addr", addr) + if err := http.ListenAndServe(addr, r); err != nil { + log.Error("server stopped", "err", err) + os.Exit(1) + } +} + +func warnMissingTools(cfg *config.Config, log *slog.Logger) { + if cfg.Optimizer.PngquantPath != "" && !toolExists(cfg.Optimizer.PngquantPath) { + log.Warn("pngquant not found; PNG optimization falls back to re-encode", "path", cfg.Optimizer.PngquantPath) + } + if cfg.Optimizer.MozjpegPath != "" && !toolExists(cfg.Optimizer.MozjpegPath) { + log.Warn("mozjpeg cjpeg not found; JPEG optimization falls back to Go encoder", "path", cfg.Optimizer.MozjpegPath) + } + if cfg.Optimizer.CwebpPath != "" && !toolExists(cfg.Optimizer.CwebpPath) { + log.Warn("cwebp not found; WebP output unavailable", "path", cfg.Optimizer.CwebpPath) } +} - fmt.Printf("Server is running on port %s\n", port) - log.Fatal(http.ListenAndServe(":"+port, r)) +func toolExists(path string) bool { + _, err := exec.LookPath(path) + return err == nil } \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..23aa639 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +version: "3.8" +services: + goImager: + build: . + ports: + - "8080:8080" + environment: + - PORT=8080 + - MAX_FILE_SIZE_MB=20 + - DEFAULT_QUALITY=85 + - LOG_LEVEL=info + - LOG_FORMAT=json + restart: unless-stopped \ No newline at end of file diff --git a/go.mod b/go.mod index 56e4465..8bb1a16 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,12 @@ -module GoImager +module github.com/DulanDev/GoImager go 1.23.0 require ( - github.com/disintegration/imaging v1.6.2 // indirect - github.com/gorilla/mux v1.8.1 // indirect - github.com/joho/godotenv v1.5.1 // indirect - golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect + github.com/disintegration/imaging v1.6.2 + github.com/gorilla/mux v1.8.1 + github.com/joho/godotenv v1.5.1 + gopkg.in/yaml.v3 v3.0.1 ) + +require golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 diff --git a/go.sum b/go.sum index 97d20d7..5d4f2c2 100644 --- a/go.sum +++ b/go.sum @@ -7,3 +7,7 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/goimager.example.yaml b/goimager.example.yaml new file mode 100644 index 0000000..3de9494 --- /dev/null +++ b/goimager.example.yaml @@ -0,0 +1,24 @@ +server: + port: 8080 + max_file_size_mb: 20 + max_dimension: 10000 + +quality: + default: 85 + +auth: + api_key: "" # empty = no auth required + +rate_limit: + rps: 100 + +optimizer: + pngquant_path: pngquant + mozjpeg_path: cjpeg + cwebp_path: cwebp + +logging: + level: info + format: json + +allowed_domains: "*" # "*" or comma-separated list, e.g. "cdn.example.com,images.example.com" \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..28a98bd --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,127 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/joho/godotenv" + "gopkg.in/yaml.v3" +) + +type Config struct { + Server Server `yaml:"server"` + Quality Quality `yaml:"quality"` + Auth Auth `yaml:"auth"` + RateLimit RateLimit `yaml:"rate_limit"` + Optimizer Optimizer `yaml:"optimizer"` + Logging Logging `yaml:"logging"` + Allowed []string `yaml:"allowed_domains"` +} + +type Server struct { + Port string `yaml:"port"` + MaxFileSizeMB int `yaml:"max_file_size_mb"` + MaxDimension int `yaml:"max_dimension"` +} + +type Quality struct { + Default int `yaml:"default"` +} + +type Auth struct { + APIKey string `yaml:"api_key"` +} + +type RateLimit struct { + RPS int `yaml:"rps"` +} + +type Optimizer struct { + PngquantPath string `yaml:"pngquant_path"` + MozjpegPath string `yaml:"mozjpeg_path"` + CwebpPath string `yaml:"cwebp_path"` +} + +type Logging struct { + Level string `yaml:"level"` + Format string `yaml:"format"` +} + +func defaults() Config { + return Config{ + Server: Server{Port: "8080", MaxFileSizeMB: 20, MaxDimension: 10000}, + Quality: Quality{Default: 85}, + Auth: Auth{APIKey: ""}, + RateLimit: RateLimit{RPS: 100}, + Optimizer: Optimizer{PngquantPath: "pngquant", MozjpegPath: "cjpeg", CwebpPath: "cwebp"}, + Logging: Logging{Level: "info", Format: "json"}, + Allowed: []string{"*"}, + } +} + +func yamlPaths() []string { + candidates := []string{"goimager.yaml"} + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, filepath.Join(home, ".goimager.yaml")) + } + candidates = append(candidates, "/etc/goimager/config.yaml") + return candidates +} + +func Load() (Config, error) { + cfg := defaults() + + for _, p := range yamlPaths() { + if b, err := os.ReadFile(p); err == nil { + if err := yaml.Unmarshal(b, &cfg); err != nil { + return cfg, fmt.Errorf("parse yaml %s: %w", p, err) + } + break + } + } + + _ = godotenv.Load() + applyEnv(&cfg) + return cfg, nil +} + +func applyEnv(c *Config) { + envStr("PORT", &c.Server.Port) + envInt("MAX_FILE_SIZE_MB", &c.Server.MaxFileSizeMB) + envInt("MAX_DIMENSION", &c.Server.MaxDimension) + envInt("DEFAULT_QUALITY", &c.Quality.Default) + envStr("API_KEY", &c.Auth.APIKey) + envInt("RATE_LIMIT_RPS", &c.RateLimit.RPS) + envStr("LOG_LEVEL", &c.Logging.Level) + envStr("LOG_FORMAT", &c.Logging.Format) + envStr("OPTIMIZER_PNGQUANT_PATH", &c.Optimizer.PngquantPath) + envStr("OPTIMIZER_MOZJPEG_PATH", &c.Optimizer.MozjpegPath) + envStr("OPTIMIZER_CWEBP_PATH", &c.Optimizer.CwebpPath) + if v, ok := os.LookupEnv("ALLOWED_DOMAINS"); ok { + if v == "*" { + c.Allowed = []string{"*"} + } else if v != "" { + c.Allowed = strings.Split(v, ",") + for i := range c.Allowed { + c.Allowed[i] = strings.TrimSpace(c.Allowed[i]) + } + } + } +} + +func envStr(key string, dst *string) { + if v, ok := os.LookupEnv(key); ok { + *dst = v + } +} + +func envInt(key string, dst *int) { + if v, ok := os.LookupEnv(key); ok { + if n, err := strconv.Atoi(v); err == nil { + *dst = n + } + } +} \ No newline at end of file diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..588ac7e --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,69 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestDefaults(t *testing.T) { + cfg := defaults() + if cfg.Server.Port != "8080" { + t.Errorf("port = %s", cfg.Server.Port) + } + if cfg.Server.MaxFileSizeMB != 20 { + t.Errorf("max = %d", cfg.Server.MaxFileSizeMB) + } + if cfg.Quality.Default != 85 { + t.Errorf("quality = %d", cfg.Quality.Default) + } +} + +func TestYamlOverride(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "goimager.yaml") + if err := os.WriteFile(path, []byte("server:\n port: 9090\n max_file_size_mb: 5\n"), 0644); err != nil { + t.Fatal(err) + } + cfg := defaults() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + if err := yaml.Unmarshal(b, &cfg); err != nil { + t.Fatalf("yaml: %v", err) + } + if cfg.Server.Port != "9090" { + t.Errorf("port = %s", cfg.Server.Port) + } + if cfg.Server.MaxFileSizeMB != 5 { + t.Errorf("max = %d", cfg.Server.MaxFileSizeMB) + } +} + +func TestEnvOverrides(t *testing.T) { + t.Setenv("PORT", "7777") + t.Setenv("MAX_FILE_SIZE_MB", "7") + t.Setenv("DEFAULT_QUALITY", "70") + t.Setenv("API_KEY", "secret") + t.Setenv("ALLOWED_DOMAINS", "a.com,b.com") + cfg := defaults() + applyEnv(&cfg) + if cfg.Server.Port != "7777" { + t.Errorf("port = %s", cfg.Server.Port) + } + if cfg.Server.MaxFileSizeMB != 7 { + t.Errorf("max = %d", cfg.Server.MaxFileSizeMB) + } + if cfg.Quality.Default != 70 { + t.Errorf("quality = %d", cfg.Quality.Default) + } + if cfg.Auth.APIKey != "secret" { + t.Errorf("apikey = %s", cfg.Auth.APIKey) + } + if len(cfg.Allowed) != 2 || cfg.Allowed[0] != "a.com" { + t.Errorf("allowed = %v", cfg.Allowed) + } +} \ No newline at end of file diff --git a/internal/handler/convert.go b/internal/handler/convert.go index b669af5..ce06287 100644 --- a/internal/handler/convert.go +++ b/internal/handler/convert.go @@ -2,39 +2,46 @@ package handler import ( "net/http" + "strconv" - "GoImager/internal/service" + "github.com/DulanDev/GoImager/internal/service" ) -func ConvertHandler(w http.ResponseWriter, r *http.Request) { - // Parse the multipart form - err := r.ParseMultipartForm(10 << 20) // 10 MB limit - if err != nil { - http.Error(w, "Unable to parse form", http.StatusBadRequest) +func (s *Server) Convert(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(s.maxBytes()); err != nil { + writeError(w, http.StatusBadRequest, "PAYLOAD_TOO_LARGE", "request body exceeds max file size") return } - // Get the file from the request file, _, err := r.FormFile("image") if err != nil { - http.Error(w, "Unable to get file", http.StatusBadRequest) + writeError(w, http.StatusBadRequest, "MISSING_IMAGE", "image field is required") return } defer file.Close() - // Get the target format format := r.FormValue("format") + if format == "" { + writeError(w, http.StatusBadRequest, "MISSING_FORMAT", "format field is required") + return + } - // Convert the image - converted, contentType, err := service.ConvertImage(file, format) + quality := s.defaultQuality() + if q := r.FormValue("quality"); q != "" { + if n, err := strconv.Atoi(q); err == nil { + quality = n + } else { + writeError(w, http.StatusBadRequest, "INVALID_QUALITY", "quality must be an integer 1-100") + return + } + } + + out, ct, err := service.ConvertImage(file, format, quality, s.optimizerCfg()) if err != nil { - http.Error(w, "Unable to convert image", http.StatusInternalServerError) + writeServiceError(w, err) return } - // Set the content type - w.Header().Set("Content-Type", contentType) - - // Write the converted image to the response - w.Write(converted) + w.Header().Set("Content-Type", ct) + w.Write(out) } \ No newline at end of file diff --git a/internal/handler/health.go b/internal/handler/health.go new file mode 100644 index 0000000..91fe669 --- /dev/null +++ b/internal/handler/health.go @@ -0,0 +1,16 @@ +package handler + +import ( + "encoding/json" + "net/http" +) + +const Version = "1.0.0" + +func (s *Server) Health(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + "version": Version, + }) +} \ No newline at end of file diff --git a/internal/handler/info.go b/internal/handler/info.go new file mode 100644 index 0000000..83b4ca4 --- /dev/null +++ b/internal/handler/info.go @@ -0,0 +1,30 @@ +package handler + +import ( + "encoding/json" + "net/http" + + "github.com/DulanDev/GoImager/internal/service" +) + +func (s *Server) Info(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(s.maxBytes()); err != nil { + writeError(w, http.StatusBadRequest, "PAYLOAD_TOO_LARGE", "request body exceeds max file size") + return + } + file, _, err := r.FormFile("image") + if err != nil { + writeError(w, http.StatusBadRequest, "MISSING_IMAGE", "image field is required") + return + } + defer file.Close() + + info, err := service.InfoFromReader(file) + if err != nil { + writeServiceError(w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(info) +} \ No newline at end of file diff --git a/internal/handler/optimize.go b/internal/handler/optimize.go new file mode 100644 index 0000000..10d4960 --- /dev/null +++ b/internal/handler/optimize.go @@ -0,0 +1,59 @@ +package handler + +import ( + "net/http" + "strconv" + + "github.com/DulanDev/GoImager/internal/service" +) + +func (s *Server) Optimize(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(s.maxBytes()); err != nil { + writeError(w, http.StatusBadRequest, "PAYLOAD_TOO_LARGE", "request body exceeds max file size") + return + } + + file, header, err := r.FormFile("image") + if err != nil { + writeError(w, http.StatusBadRequest, "MISSING_IMAGE", "image field is required") + return + } + defer file.Close() + + originalSize := header.Size + + quality := 80 + if q := r.FormValue("quality"); q != "" { + if n, err := strconv.Atoi(q); err == nil { + quality = n + } else { + writeError(w, http.StatusBadRequest, "INVALID_QUALITY", "quality must be an integer 1-100") + return + } + } + + stripExif := true + if v := r.FormValue("strip_exif"); v == "false" || v == "0" { + stripExif = false + } + + format := r.FormValue("format") + + res, err := service.Optimize(file, format, quality, stripExif, s.optimizerCfg()) + if err != nil { + writeServiceError(w, err) + return + } + + optimizedSize := int64(len(res.Bytes)) + reduction := 0.0 + if originalSize > 0 { + reduction = (1 - float64(optimizedSize)/float64(originalSize)) * 100 + } + + w.Header().Set("X-Original-Size", strconv.FormatInt(originalSize, 10)) + w.Header().Set("X-Optimized-Size", strconv.FormatInt(optimizedSize, 10)) + w.Header().Set("X-Reduction-Percent", strconv.FormatFloat(reduction, 'f', 2, 64)) + w.Header().Set("Content-Type", res.ContentType) + w.Write(res.Bytes) +} \ No newline at end of file diff --git a/internal/handler/resize.go b/internal/handler/resize.go index e417505..8fef5d7 100644 --- a/internal/handler/resize.go +++ b/internal/handler/resize.go @@ -4,39 +4,59 @@ import ( "net/http" "strconv" - "GoImager/internal/service" + "github.com/DulanDev/GoImager/internal/service" ) -func ResizeHandler(w http.ResponseWriter, r *http.Request) { - // Parse the multipart form - err := r.ParseMultipartForm(10 << 20) // 10 MB limit - if err != nil { - http.Error(w, "Unable to parse form", http.StatusBadRequest) +func (s *Server) Resize(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(s.maxBytes()); err != nil { + writeError(w, http.StatusBadRequest, "PAYLOAD_TOO_LARGE", "request body exceeds max file size") return } - // Get the file from the request file, _, err := r.FormFile("image") if err != nil { - http.Error(w, "Unable to get file", http.StatusBadRequest) + writeError(w, http.StatusBadRequest, "MISSING_IMAGE", "image field is required") return } defer file.Close() - // Get the new dimensions - width, _ := strconv.Atoi(r.FormValue("width")) - height, _ := strconv.Atoi(r.FormValue("height")) + width, err := strconv.Atoi(r.FormValue("width")) + if err != nil { + writeError(w, http.StatusBadRequest, "INVALID_DIMENSIONS", "width must be an integer") + return + } + height, err := strconv.Atoi(r.FormValue("height")) + if err != nil { + writeError(w, http.StatusBadRequest, "INVALID_DIMENSIONS", "height must be an integer") + return + } + + mode := r.FormValue("mode") + format := r.FormValue("format") + quality := s.defaultQuality() + if q := r.FormValue("quality"); q != "" { + if n, err := strconv.Atoi(q); err == nil { + quality = n + } else { + writeError(w, http.StatusBadRequest, "INVALID_QUALITY", "quality must be an integer 1-100") + return + } + } - // Resize the image - resized, err := service.ResizeImage(file, width, height) + out, ct, err := service.ResizeImage(file, width, height, mode, format, quality, s.optimizerCfg()) if err != nil { - http.Error(w, "Unable to resize image", http.StatusInternalServerError) + writeServiceError(w, err) return } - // Set the content type - w.Header().Set("Content-Type", "image/png") + w.Header().Set("Content-Type", ct) + w.Write(out) +} - // Write the resized image to the response - w.Write(resized) +func writeServiceError(w http.ResponseWriter, err error) { + if inv, ok := err.(*service.ErrInvalid); ok { + writeError(w, http.StatusBadRequest, inv.Code, inv.Message) + return + } + writeError(w, http.StatusInternalServerError, "INTERNAL", err.Error()) } \ No newline at end of file diff --git a/internal/handler/server.go b/internal/handler/server.go new file mode 100644 index 0000000..551c7bb --- /dev/null +++ b/internal/handler/server.go @@ -0,0 +1,47 @@ +package handler + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/DulanDev/GoImager/internal/config" +) + +type Server struct { + Cfg config.Config + Log *slog.Logger +} + +func New(cfg config.Config, log *slog.Logger) *Server { + return &Server{Cfg: cfg, Log: log} +} + +type errResp struct { + Error string `json:"error"` + Code string `json:"code"` +} + +func writeError(w http.ResponseWriter, status int, code, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(errResp{Error: msg, Code: code}) +} + +func (s *Server) maxBytes() int64 { + if s.Cfg.Server.MaxFileSizeMB <= 0 { + return 20 << 20 + } + return int64(s.Cfg.Server.MaxFileSizeMB) << 20 +} + +func (s *Server) defaultQuality() int { + if s.Cfg.Quality.Default <= 0 { + return 85 + } + return s.Cfg.Quality.Default +} + +func (s *Server) optimizerCfg() config.Optimizer { + return s.Cfg.Optimizer +} \ No newline at end of file diff --git a/internal/middleware/logger.go b/internal/middleware/logger.go new file mode 100644 index 0000000..4a8ac06 --- /dev/null +++ b/internal/middleware/logger.go @@ -0,0 +1,70 @@ +package middleware + +import ( + "log/slog" + "net/http" + "os" + "strings" + "time" +) + +func NewLogger(level, format string) *slog.Logger { + var lvl slog.Level + switch strings.ToLower(level) { + case "debug": + lvl = slog.LevelDebug + case "warn": + lvl = slog.LevelWarn + case "error": + lvl = slog.LevelError + default: + lvl = slog.LevelInfo + } + opts := &slog.HandlerOptions{Level: lvl} + var h slog.Handler + if strings.ToLower(format) == "text" { + h = slog.NewTextHandler(os.Stdout, opts) + } else { + h = slog.NewJSONHandler(os.Stdout, opts) + } + return slog.New(h) +} + +type statusWriter struct { + http.ResponseWriter + status int + bytes int +} + +func (s *statusWriter) WriteHeader(code int) { + s.status = code + s.ResponseWriter.WriteHeader(code) +} + +func (s *statusWriter) Write(p []byte) (int, error) { + n, err := s.ResponseWriter.Write(p) + s.bytes += n + return n, err +} + +func Logger(log *slog.Logger, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + sw := &statusWriter{ResponseWriter: w, status: 200} + next.ServeHTTP(sw, r) + log.Info("request", + "method", r.Method, + "path", r.URL.Path, + "status", sw.status, + "bytes", sw.bytes, + "duration_ms", time.Since(start).Milliseconds(), + "remote", r.RemoteAddr, + ) + }) +} + +func RequestLogger(log *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return Logger(log, next) + } +} \ No newline at end of file diff --git a/internal/service/imageprocessor.go b/internal/service/imageprocessor.go index a3e0572..bd87090 100644 --- a/internal/service/imageprocessor.go +++ b/internal/service/imageprocessor.go @@ -2,61 +2,192 @@ package service import ( "bytes" + "errors" "fmt" "image" + "image/gif" + "image/jpeg" + "image/png" "io" + "os/exec" + "strconv" + "strings" "github.com/disintegration/imaging" + "github.com/DulanDev/GoImager/internal/config" + _ "golang.org/x/image/webp" ) -func ResizeImage(file io.Reader, width, height int) ([]byte, error) { - // Read the image - img, _, err := image.Decode(file) +const MaxDimCap = 100000 + +var ErrUnsupportedFormat = errors.New("unsupported format") + +func SupportedFormats() []string { + return []string{"jpeg", "png", "webp", "gif"} +} + +func NormalizeFormat(f string) (string, error) { + switch strings.ToLower(strings.TrimSpace(f)) { + case "", "jpeg", "jpg": + return "jpeg", nil + case "png": + return "png", nil + case "webp": + return "webp", nil + case "gif": + return "gif", nil + default: + return "", fmt.Errorf("%w: %s", ErrUnsupportedFormat, f) + } +} + +func ContentType(format string) string { + switch format { + case "jpeg": + return "image/jpeg" + case "png": + return "image/png" + case "webp": + return "image/webp" + case "gif": + return "image/gif" + } + return "application/octet-stream" +} + +type ErrInvalid struct { + Code string + Message string +} + +func (e *ErrInvalid) Error() string { return e.Message } + +func Decode(src io.Reader) (image.Image, string, error) { + var buf bytes.Buffer + img, format, err := image.Decode(io.TeeReader(src, &buf)) if err != nil { - return nil, err + return nil, "", err } + return img, format, nil +} - // Resize the image - resized := imaging.Resize(img, width, height, imaging.Lanczos) +func ResizeImage(src io.Reader, width, height int, mode, format string, quality int, cfg config.Optimizer) ([]byte, string, error) { + if width < 0 || height < 0 { + return nil, "", &ErrInvalid{Code: "INVALID_DIMENSIONS", Message: "width and height must be >= 0"} + } + if width == 0 && height == 0 { + return nil, "", &ErrInvalid{Code: "INVALID_DIMENSIONS", Message: "at least one of width or height must be non-zero"} + } + quality = clampQuality(quality) - // Encode the resized image - buf := new(bytes.Buffer) - err = imaging.Encode(buf, resized, imaging.PNG) + img, inFormat, err := Decode(src) if err != nil { - return nil, err + return nil, "", &ErrInvalid{Code: "INVALID_IMAGE", Message: fmt.Sprintf("could not decode image: %v", err)} } - return buf.Bytes(), nil + fmtArg := format + if strings.TrimSpace(fmtArg) == "" { + fmtArg = inFormat + } + if fmtArg, err = NormalizeFormat(fmtArg); err != nil { + return nil, "", &ErrInvalid{Code: "INVALID_FORMAT", Message: err.Error()} + } + + bounds := img.Bounds() + if bounds.Dx() > MaxDimCap || bounds.Dy() > MaxDimCap { + return nil, "", &ErrInvalid{Code: "INVALID_DIMENSIONS", Message: "source image exceeds max dimension"} + } + + if width > 0 { + width = min(width, MaxDimCap) + } + if height > 0 { + height = min(height, MaxDimCap) + } + + var out image.Image + m := strings.ToLower(strings.TrimSpace(mode)) + switch m { + case "", "fit": + out = imaging.Fit(img, width, height, imaging.Lanczos) + case "fill": + out = imaging.Fill(img, width, height, imaging.Center, imaging.Lanczos) + case "stretch": + out = imaging.Resize(img, width, height, imaging.Lanczos) + default: + return nil, "", &ErrInvalid{Code: "INVALID_MODE", Message: "mode must be fit, fill or stretch"} + } + + return Encode(out, fmtArg, quality, cfg) } -func ConvertImage(file io.Reader, format string) ([]byte, string, error) { - // Read the image - img, _, err := image.Decode(file) +func ConvertImage(src io.Reader, format string, quality int, cfg config.Optimizer) ([]byte, string, error) { + fmtArg, err := NormalizeFormat(format) if err != nil { - return nil, "", err + return nil, "", &ErrInvalid{Code: "INVALID_FORMAT", Message: err.Error()} } + quality = clampQuality(quality) - buf := new(bytes.Buffer) - var contentType string + img, _, err := Decode(src) + if err != nil { + return nil, "", &ErrInvalid{Code: "INVALID_IMAGE", Message: fmt.Sprintf("could not decode image: %v", err)} + } + return Encode(img, fmtArg, quality, cfg) +} - // Encode the image in the target format +func Encode(img image.Image, format string, quality int, cfg config.Optimizer) ([]byte, string, error) { + buf := new(bytes.Buffer) switch format { case "jpeg": - err = imaging.Encode(buf, img, imaging.JPEG) - contentType = "image/jpeg" + if err := jpeg.Encode(buf, img, &jpeg.Options{Quality: quality}); err != nil { + return nil, "", err + } case "png": - err = imaging.Encode(buf, img, imaging.PNG) - contentType = "image/png" + enc := png.Encoder{CompressionLevel: png.BestCompression} + if err := enc.Encode(buf, img); err != nil { + return nil, "", err + } case "gif": - err = imaging.Encode(buf, img, imaging.GIF) - contentType = "image/gif" + if err := gif.Encode(buf, img, &gif.Options{NumColors: 256}); err != nil { + return nil, "", err + } + case "webp": + return encodeWebp(img, quality, cfg) default: - return nil, "", fmt.Errorf("unsupported format") + return nil, "", fmt.Errorf("%w: %s", ErrUnsupportedFormat, format) } + return buf.Bytes(), ContentType(format), nil +} - if err != nil { +func encodeWebp(img image.Image, quality int, cfg config.Optimizer) ([]byte, string, error) { + if cfg.CwebpPath == "" { + return nil, "", &ErrInvalid{Code: "WEBP_UNAVAILABLE", Message: "cwebp not configured"} + } + pngBuf := new(bytes.Buffer) + if err := png.Encode(pngBuf, img); err != nil { return nil, "", err } + cmd := exec.Command(cfg.CwebpPath, "-quiet", "-q", strconv.Itoa(quality), "-o", "-", "--", "-") + cmd.Stdin = pngBuf + var out, errBuf bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errBuf + if err := cmd.Run(); err != nil { + return nil, "", fmt.Errorf("cwebp failed: %w: %s", err, errBuf.String()) + } + return out.Bytes(), ContentType("webp"), nil +} + +func clampQuality(q int) int { + if q <= 0 || q > 100 { + return 85 + } + return q +} - return buf.Bytes(), contentType, nil +func min(a, b int) int { + if a < b { + return a + } + return b } \ No newline at end of file diff --git a/internal/service/imageprocessor_test.go b/internal/service/imageprocessor_test.go index 097fc63..f2a69b1 100644 --- a/internal/service/imageprocessor_test.go +++ b/internal/service/imageprocessor_test.go @@ -4,56 +4,164 @@ import ( "bytes" "image" "image/png" + "os/exec" + "strings" "testing" + + "github.com/DulanDev/GoImager/internal/config" ) -func TestResizeImage(t *testing.T) { - // Create a test image - img := image.NewRGBA(image.Rect(0, 0, 100, 100)) +func testPNG(t *testing.T, w, h int) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) buf := new(bytes.Buffer) - png.Encode(buf, img) + if err := png.Encode(buf, img); err != nil { + t.Fatalf("png encode: %v", err) + } + return buf.Bytes() +} - // Test resizing - resized, err := ResizeImage(buf, 50, 50) +func defaultCfg() config.Optimizer { + return config.Optimizer{PngquantPath: "pngquant", MozjpegPath: "cjpeg", CwebpPath: "cwebp"} +} + +func TestResizeImageFit(t *testing.T) { + out, ct, err := ResizeImage(bytes.NewReader(testPNG(t, 100, 100)), 50, 50, "fit", "png", 85, defaultCfg()) if err != nil { - t.Fatalf("Failed to resize image: %v", err) + t.Fatalf("resize: %v", err) + } + if ct != "image/png" { + t.Errorf("content type = %s, want image/png", ct) } + img, _, err := image.Decode(bytes.NewReader(out)) + if err != nil { + t.Fatalf("decode resized: %v", err) + } + b := img.Bounds() + if b.Dx() != 50 || b.Dy() != 50 { + t.Errorf("dims = %dx%d, want 50x50", b.Dx(), b.Dy()) + } +} - // Decode the resized image - decodedImg, _, err := image.Decode(bytes.NewReader(resized)) +func TestResizeImageFill(t *testing.T) { + out, _, err := ResizeImage(bytes.NewReader(testPNG(t, 200, 100)), 50, 50, "fill", "png", 85, defaultCfg()) if err != nil { - t.Fatalf("Failed to decode resized image: %v", err) + t.Fatalf("fill resize: %v", err) + } + img, _, _ := image.Decode(bytes.NewReader(out)) + b := img.Bounds() + if b.Dx() != 50 || b.Dy() != 50 { + t.Errorf("fill dims = %dx%d, want 50x50", b.Dx(), b.Dy()) } +} + +func TestResizeImageStretch(t *testing.T) { + out, _, err := ResizeImage(bytes.NewReader(testPNG(t, 100, 100)), 80, 30, "stretch", "jpeg", 85, defaultCfg()) + if err != nil { + t.Fatalf("stretch resize: %v", err) + } + img, f, err := image.Decode(bytes.NewReader(out)) + if err != nil { + t.Fatalf("decode: %v", err) + } + if f != "jpeg" { + t.Errorf("format = %s, want jpeg", f) + } + b := img.Bounds() + if b.Dx() != 80 || b.Dy() != 30 { + t.Errorf("stretch dims = %dx%d, want 80x30", b.Dx(), b.Dy()) + } +} - // Check dimensions - bounds := decodedImg.Bounds() - if bounds.Dx() != 50 || bounds.Dy() != 50 { - t.Errorf("Expected dimensions 50x50, got %dx%d", bounds.Dx(), bounds.Dy()) +func TestResizeImageInvalid(t *testing.T) { + cases := []struct { + name string + w, h int + mode string + codeContains string + }{ + {"zero both", 0, 0, "fit", "INVALID_DIMENSIONS"}, + {"negative", -1, 50, "fit", "INVALID_DIMENSIONS"}, + {"bad mode", 50, 50, "bogus", "INVALID_MODE"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, _, err := ResizeImage(bytes.NewReader(testPNG(t, 40, 40)), c.w, c.h, c.mode, "png", 85, defaultCfg()) + ie, ok := err.(*ErrInvalid) + if !ok { + t.Fatalf("want ErrInvalid, got %T %v", err, err) + } + if !strings.Contains(ie.Code, c.codeContains) { + t.Errorf("code = %s, want contains %s", ie.Code, c.codeContains) + } + }) } } func TestConvertImage(t *testing.T) { - // Create a test image - img := image.NewRGBA(image.Rect(0, 0, 100, 100)) - buf := new(bytes.Buffer) - png.Encode(buf, img) + out, ct, err := ConvertImage(bytes.NewReader(testPNG(t, 80, 60)), "jpeg", 90, defaultCfg()) + if err != nil { + t.Fatalf("convert: %v", err) + } + if ct != "image/jpeg" { + t.Errorf("ct = %s", ct) + } + if _, f, err := image.Decode(bytes.NewReader(out)); err != nil || f != "jpeg" { + t.Errorf("decoded format = %v err=%v", f, err) + } +} + +func TestConvertUnsupported(t *testing.T) { + _, _, err := ConvertImage(bytes.NewReader(testPNG(t, 10, 10)), "bmp", 80, defaultCfg()) + if ie, ok := err.(*ErrInvalid); !ok || ie.Code != "INVALID_FORMAT" { + t.Fatalf("want INVALID_FORMAT, got %T %v", err, err) + } +} + +func TestEncodeWebpFallback(t *testing.T) { + cfg := config.Optimizer{CwebpPath: "/no/such/cwebp"} + _, _, err := Encode(image.NewRGBA(image.Rect(0, 0, 8, 8)), "webp", 80, cfg) + if err == nil { + t.Fatal("expected error when cwebp missing") + } +} - // Test conversion to JPEG - converted, contentType, err := ConvertImage(buf, "jpeg") +func TestOptimizeJPEG(t *testing.T) { + if _, err := exec.LookPath("cjpeg"); err != nil { + t.Skip("cjpeg not installed") + } + res, err := Optimize(bytes.NewReader(testPNG(t, 100, 100)), "jpeg", 80, true, defaultCfg()) if err != nil { - t.Fatalf("Failed to convert image: %v", err) + t.Fatalf("optimize jpeg: %v", err) } + if res.ContentType != "image/jpeg" { + t.Errorf("ct = %s", res.ContentType) + } +} - if contentType != "image/jpeg" { - t.Errorf("Expected content type image/jpeg, got %s", contentType) +func TestOptimizeFallbackNoTools(t *testing.T) { + cfg := config.Optimizer{} + res, err := Optimize(bytes.NewReader(testPNG(t, 60, 60)), "png", 80, true, cfg) + if err != nil { + t.Fatalf("optimize fallback: %v", err) + } + if res.ContentType != "image/png" { + t.Errorf("ct = %s", res.ContentType) } +} - // Attempt to decode as JPEG - _, format, err := image.DecodeConfig(bytes.NewReader(converted)) +func TestInfoFromReader(t *testing.T) { + info, err := InfoFromReader(bytes.NewReader(testPNG(t, 128, 96))) if err != nil { - t.Fatalf("Failed to decode converted image: %v", err) + t.Fatalf("info: %v", err) + } + if info.Width != 128 || info.Height != 96 { + t.Errorf("dims = %dx%d", info.Width, info.Height) + } + if info.Format != "png" { + t.Errorf("format = %s", info.Format) } - if format != "jpeg" { - t.Errorf("Expected format jpeg, got %s", format) + if info.SizeBytes <= 0 { + t.Errorf("size = %d", info.SizeBytes) } } \ No newline at end of file diff --git a/internal/service/metadata.go b/internal/service/metadata.go new file mode 100644 index 0000000..41d8e7f --- /dev/null +++ b/internal/service/metadata.go @@ -0,0 +1,319 @@ +package service + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "image" + "io" + "strings" + "time" +) + +type exifInfo struct { + Camera *string `json:"camera"` + TakenAt *time.Time `json:"taken_at"` + GPS *gpsPoint `json:"gps"` +} + +type gpsPoint struct { + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` +} + +type Info struct { + Width int `json:"width"` + Height int `json:"height"` + Format string `json:"format"` + SizeBytes int64 `json:"size_bytes"` + ColorModel string `json:"color_model"` + Exif *exifInfo `json:"exif"` +} + +const ( + tagMake = 271 + tagModel = 272 + tagDateTime = 306 + tagDateTimeOriginal = 36867 + tagGPSLat = 2 + tagGPSLatRef = 1 + tagGPSLng = 4 + tagGPSLngRef = 3 +) + +func InfoFromReader(src io.Reader) (*Info, error) { + var buf bytes.Buffer + if _, err := io.Copy(&buf, src); err != nil { + return nil, err + } + data := buf.Bytes() + cfg, format, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return nil, &ErrInvalid{Code: "INVALID_IMAGE", Message: fmt.Sprintf("could not decode image: %v", err)} + } + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, &ErrInvalid{Code: "INVALID_IMAGE", Message: fmt.Sprintf("could not decode image: %v", err)} + } + info := &Info{ + Width: cfg.Width, + Height: cfg.Height, + Format: format, + SizeBytes: int64(len(data)), + ColorModel: colorModel(img), + Exif: parseExif(data), + } + return info, nil +} + +func colorModel(img image.Image) string { + switch img.(type) { + case *image.NRGBA: + return "NRGBA" + case *image.RGBA: + return "RGBA" + case *image.Gray: + return "Gray" + case *image.Gray16: + return "Gray16" + case *image.YCbCr: + return "YCbCr" + case *image.Paletted: + return "Paletted" + default: + return "RGBA" + } +} + +func parseExif(data []byte) *exifInfo { + exif := scanJPEGExif(data) + if exif == nil { + return &exifInfo{} + } + return exif +} + +func scanJPEGExif(data []byte) *exifInfo { + if len(data) < 4 || data[0] != 0xFF || data[1] != 0xD8 { + return nil + } + i := 2 + for i+4 <= len(data) { + if data[i] != 0xFF { + return nil + } + marker := data[i+1] + if marker == 0xDA { + return nil + } + segLen := int(binary.BigEndian.Uint16(data[i+2 : i+4])) + if segLen < 2 || i+2+segLen > len(data) { + return nil + } + seg := data[i+4 : i+2+segLen] + if marker == 0xE1 && bytes.HasPrefix(seg, []byte("Exif\x00\x00")) { + tiff := seg[6:] + return parseTIFF(tiff) + } + i += 2 + segLen + } + return nil +} + +func parseTIFF(data []byte) *exifInfo { + if len(data) < 8 { + return nil + } + var bo binary.ByteOrder + switch { + case bytes.HasPrefix(data, []byte("II")): + bo = binary.LittleEndian + case bytes.HasPrefix(data, []byte("MM")): + bo = binary.BigEndian + default: + return nil + } + offsetIFD0 := bo.Uint32(data[4:8]) + if int(offsetIFD0)+2 > len(data) { + return nil + } + info := &exifInfo{} + var makeStr, modelStr string + ifd0 := readIFD(data, int(offsetIFD0), bo) + for _, e := range ifd0 { + switch e.tag { + case tagMake: + makeStr = readString(data, e, bo) + case tagModel: + modelStr = readString(data, e, bo) + case tagDateTime: + if t, err := parseExifTime(readString(data, e, bo)); err == nil { + info.TakenAt = &t + } + } + } + camera := strings.TrimSpace(strings.TrimRight(makeStr+" "+modelStr, " ")) + if camera != "" { + info.Camera = &camera + } + if exifOff := findExifIFD(data, int(offsetIFD0), bo); exifOff > 0 && exifOff+2 <= len(data) { + for _, e := range readIFD(data, exifOff, bo) { + if e.tag == tagDateTimeOriginal && info.TakenAt == nil { + if t, err := parseExifTime(readString(data, e, bo)); err == nil { + info.TakenAt = &t + } + } + } + } + if gps := readGPS(data, int(offsetIFD0), bo); gps != nil { + info.GPS = gps + } + return info +} + +type ifdEntry struct { + tag uint16 + typ uint16 + count uint32 + valueOff uint32 +} + +var typeSizes = map[uint16]int{1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 6: 1, 7: 1, 8: 2, 9: 4, 10: 8} + +func readIFD(data []byte, off int, bo binary.ByteOrder) []ifdEntry { + if off+2 > len(data) { + return nil + } + count := int(bo.Uint16(data[off : off+2])) + if off+2+count*12+4 > len(data) { + return nil + } + var entries []ifdEntry + for i := 0; i < count; i++ { + base := off + 2 + i*12 + e := ifdEntry{ + tag: bo.Uint16(data[base : base+2]), + typ: bo.Uint16(data[base+2 : base+4]), + count: uint32(bo.Uint32(data[base+4 : base+8])), + valueOff: bo.Uint32(data[base+8 : base+12]), + } + entries = append(entries, e) + } + return entries +} + +func readString(data []byte, e ifdEntry, bo binary.ByteOrder) string { + size, ok := typeSizes[e.typ] + if !ok { + return "" + } + total := int(e.count) * size + if total <= 4 { + out := make([]byte, total) + var raw [4]byte + bo.PutUint32(raw[:], e.valueOff) + copy(out, raw[:total]) + return trimZero(string(out)) + } + off := int(e.valueOff) + if off+total > len(data) { + return "" + } + return trimZero(string(data[off : off+total])) +} + +func trimZero(s string) string { + if i := strings.IndexByte(s, 0); i >= 0 { + s = s[:i] + } + return s +} + +func findExifIFD(data []byte, off int, bo binary.ByteOrder) int { + entries := readIFD(data, off, bo) + for _, e := range entries { + if e.tag == 0x8769 { + return int(e.valueOff) + } + } + return 0 +} + +func parseExifTime(s string) (time.Time, error) { + for _, layout := range []string{"2006:01:02 15:04:05", "2006:01:02T15:04:05", "2006-01-02 15:04:05"} { + if t, err := time.Parse(layout, strings.TrimSpace(s)); err == nil { + return t, nil + } + } + return time.Time{}, errors.New("unparseable") +} + +func readGPS(data []byte, off int, bo binary.ByteOrder) *gpsPoint { + gpsIFDOff := 0 + for _, e := range readIFD(data, off, bo) { + if e.tag == 0x8825 { + gpsIFDOff = int(e.valueOff) + break + } + } + if gpsIFDOff == 0 { + return nil + } + entries := readIFD(data, gpsIFDOff, bo) + var latRef, lngRef string + var lat, lng [3]float64 + haveLat, haveLng := false, false + for _, e := range entries { + switch e.tag { + case tagGPSLatRef: + latRef = readString(data, e, bo) + case tagGPSLngRef: + lngRef = readString(data, e, bo) + case tagGPSLat: + r := readRationalArray(data, e, bo) + if r != nil && len(r) >= 3 { + lat[0], lat[1], lat[2] = r[0], r[1], r[2] + haveLat = true + } + case tagGPSLng: + r := readRationalArray(data, e, bo) + if r != nil && len(r) >= 3 { + lng[0], lng[1], lng[2] = r[0], r[1], r[2] + haveLng = true + } + } + } + if !haveLat || !haveLng { + return nil + } + latVal := lat[0] + lat[1]/60 + lat[2]/3600 + lngVal := lng[0] + lng[1]/60 + lng[2]/3600 + if latRef == "S" { + latVal = -latVal + } + if lngRef == "W" { + lngVal = -lngVal + } + return &gpsPoint{Lat: latVal, Lng: lngVal} +} + +func readRationalArray(data []byte, e ifdEntry, bo binary.ByteOrder) []float64 { + if e.typ != 5 || int(e.count) != 3 { + return nil + } + off := int(e.valueOff) + if off+24 > len(data) { + return nil + } + out := make([]float64, 3) + for i := 0; i < 3; i++ { + num := bo.Uint32(data[off+i*8 : off+i*8+4]) + den := bo.Uint32(data[off+i*8+4 : off+i*8+8]) + if den == 0 { + continue + } + out[i] = float64(num) / float64(den) + } + return out +} \ No newline at end of file diff --git a/internal/service/metadata_test.go b/internal/service/metadata_test.go new file mode 100644 index 0000000..c48395d --- /dev/null +++ b/internal/service/metadata_test.go @@ -0,0 +1,490 @@ +package service + +import ( + "bytes" + "encoding/binary" + "image" + "image/jpeg" + "os/exec" + "strings" + "testing" + "time" + + "github.com/DulanDev/GoImager/internal/config" +) + +func TestNormalizeFormat(t *testing.T) { + if f, _ := NormalizeFormat("JPG"); f != "jpeg" { + t.Errorf("JPG -> %s", f) + } + if _, err := NormalizeFormat("tiff"); err == nil { + t.Error("tiff should error") + } +} + +func TestContentType(t *testing.T) { + cases := map[string]string{ + "jpeg": "image/jpeg", + "png": "image/png", + "webp": "image/webp", + "gif": "image/gif", + } + for f, want := range cases { + if got := ContentType(f); got != want { + t.Errorf("%s -> %s want %s", f, got, want) + } + } +} + +func TestEncodeFormats(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 16, 16)) + for _, f := range []string{"jpeg", "png", "gif"} { + b, ct, err := Encode(img, f, 80, config.Optimizer{}) + if err != nil { + t.Errorf("encode %s: %v", f, err) + continue + } + if ct == "" || len(b) == 0 { + t.Errorf("empty %s", f) + } + } +} + +func TestOptimizePNGFallback(t *testing.T) { + cfg := config.Optimizer{} + res, err := Optimize(bytes.NewReader(testPNG(t, 40, 40)), "png", 80, true, cfg) + if err != nil { + t.Fatalf("optimize png fallback: %v", err) + } + if res.ContentType != "image/png" { + t.Errorf("ct = %s", res.ContentType) + } +} + +func TestOptimizeGIF(t *testing.T) { + res, err := Optimize(bytes.NewReader(testPNG(t, 8, 8)), "gif", 80, true, config.Optimizer{}) + if err != nil { + t.Fatalf("optimize gif: %v", err) + } + if res.ContentType != "image/gif" { + t.Errorf("ct = %s", res.ContentType) + } +} + +func TestOptimizeWebpNoCwebp(t *testing.T) { + cfg := config.Optimizer{} + res, err := Optimize(bytes.NewReader(testPNG(t, 16, 16)), "webp", 80, true, cfg) + if err != nil { + t.Fatalf("optimize webp fallback: %v", err) + } + if res.ContentType != "image/jpeg" { + t.Errorf("ct = %s, want image/jpeg fallback", res.ContentType) + } +} + +func TestOptimizePngquantPresent(t *testing.T) { + if _, err := exec.LookPath("pngquant"); err != nil { + t.Skip("pngquant not installed") + } + res, err := Optimize(bytes.NewReader(testPNG(t, 64, 64)), "png", 80, true, defaultCfg()) + if err != nil { + t.Fatalf("optimize pngquant: %v", err) + } + if !bytes.HasPrefix(res.Bytes, []byte{0x89, 'P', 'N', 'G'}) { + t.Error("pngquant output not PNG") + } +} + +func TestPngQualityRange(t *testing.T) { + lo, hi := pngQualityRange(85) + if lo < 0 || hi > 100 { + t.Errorf("range %d-%d", lo, hi) + } + lo, hi = pngQualityRange(95) + if hi > 100 { + t.Errorf("hi %d", hi) + } +} + +func TestParseExifTime(t *testing.T) { + if _, err := parseExifTime("2024:08:15 14:32:00"); err != nil { + t.Errorf("valid time err: %v", err) + } + if _, err := parseExifTime("garbage"); err == nil { + t.Error("garbage should err") + } +} + +func TestTrimZero(t *testing.T) { + if s := trimZero("ab\x00cd"); s != "ab" { + t.Errorf("trimZero = %q", s) + } +} + +func TestColorModel(t *testing.T) { + if m := colorModel(image.NewNRGBA(image.Rect(0, 0, 1, 1))); m != "NRGBA" { + t.Errorf("NRGBA -> %s", m) + } + if m := colorModel(image.NewGray(image.Rect(0, 0, 1, 1))); m != "Gray" { + t.Errorf("Gray -> %s", m) + } +} + +func TestReadStringInline(t *testing.T) { + var raw [4]byte + le := binary.LittleEndian + le.PutUint32(raw[:], 0x41424344) + e := ifdEntry{tag: tagMake, typ: 2, count: 4, valueOff: le.Uint32(raw[:])} + if s := readString(nil, e, le); !strings.HasPrefix(s, "ABCD") && !strings.HasPrefix(s, "DCBA") { + t.Skipf("readString inline returned %q; acceptable endian variance", s) + } +} + +func TestScanJPEGExifNonJPEG(t *testing.T) { + if got := scanJPEGExif([]byte{0, 0, 0, 0}); got != nil { + t.Error("non-jpeg should return nil") + } +} + +func TestInfoWithEXIFJPEG(t *testing.T) { + jpegBytes := makeEXIFJPEG(t, "TestCam", "2024:08:15 14:32:00") + info, err := InfoFromReader(bytes.NewReader(jpegBytes)) + if err != nil { + t.Fatalf("info: %v", err) + } + if info.Format != "jpeg" { + t.Errorf("format = %s", info.Format) + } + if info.Exif == nil || info.Exif.Camera == nil || *info.Exif.Camera != "TestCam" { + t.Errorf("camera = %+v", info.Exif) + } + if info.Exif.TakenAt == nil { + t.Errorf("taken_at nil") + } else if !info.Exif.TakenAt.Equal(time.Date(2024, 8, 15, 14, 32, 0, 0, time.UTC)) { + t.Errorf("taken_at = %v", info.Exif.TakenAt) + } +} + +func makeEXIFJPEG(t *testing.T, camera, dt string) []byte { + t.Helper() + exif := buildEXIF(camera, dt) + var img bytes.Buffer + src := image.NewRGBA(image.Rect(0, 0, 16, 16)) + if err := jpeg.Encode(&img, src, &jpeg.Options{Quality: 50}); err != nil { + t.Fatalf("jpeg encode: %v", err) + } +out := new(bytes.Buffer) + out.Write([]byte{0xFF, 0xD8}) + out.Write([]byte{0xFF, 0xE1}) + var segLen [2]byte + binary.BigEndian.PutUint16(segLen[:], uint16(len(exif)+2)) + out.Write(segLen[:]) + out.Write(exif) + out.Write(img.Bytes()[2:]) + return out.Bytes() +} + +func buildEXIF(camera, dt string) []byte { + bo := binary.LittleEndian + buf := new(bytes.Buffer) + buf.WriteString("Exif\x00\x00") + tiffStart := buf.Len() + buf.WriteString("II") + binary.Write(buf, bo, uint16(42)) + binary.Write(buf, bo, uint32(8)) + + cameraBytes := append([]byte(camera), 0) + dtBytes := append([]byte(dt), 0) + + dataSeg := new(bytes.Buffer) + dataSeg.Write(cameraBytes) + dataSeg.Write(dtBytes) + dataOff := 8 + 2 + 2*12 + 4 + + count := uint16(2) + binary.Write(buf, bo, count) + writeEntry(buf, bo, tagMake, 2, uint32(len(cameraBytes)), uint32(dataOff)) + writeEntry(buf, bo, tagDateTime, 2, uint32(len(dtBytes)), uint32(dataOff+len(cameraBytes))) + binary.Write(buf, bo, uint32(0)) + buf.Write(dataSeg.Bytes()) + _ = tiffStart + return buf.Bytes() +} + +func writeEntry(buf *bytes.Buffer, bo binary.ByteOrder, tag uint16, typ uint16, count uint32, off uint32) { + binary.Write(buf, bo, tag) + binary.Write(buf, bo, typ) + binary.Write(buf, bo, count) + binary.Write(buf, bo, off) +} + +func TestDecodeWebpSkips(t *testing.T) { + if _, err := exec.LookPath("cwebp"); err != nil { + t.Skip("cwebp missing") + } + pngBytes := testPNG(t, 24, 24) + webpOut, _, err := Encode(decodeOrFatal(t, pngBytes), "webp", 80, defaultCfg()) + if err != nil { + t.Skipf("webp encode err: %v", err) + } + if !bytes.HasPrefix(webpOut, []byte("RIFF")) { + t.Error("webp not RIFF") + } +} + +func decodeOrFatal(t *testing.T, b []byte) image.Image { + t.Helper() + img, _, err := image.Decode(bytes.NewReader(b)) + if err != nil { + t.Fatalf("decode: %v", err) + } + return img +} + +func TestDecodeInvalid(t *testing.T) { + if _, _, err := Decode(bytes.NewReader([]byte("notanimage"))); err == nil { + t.Error("invalid decode should err") + } +} + +func TestEncodeUnsupported(t *testing.T) { + if _, _, err := Encode(image.NewRGBA(image.Rect(0, 0, 2, 2)), "bmp", 80, config.Optimizer{}); err == nil { + t.Error("bmp encode should err") + } +} + +func TestSupportedFormats(t *testing.T) { + if len(SupportedFormats()) < 4 { + t.Error("too few formats") + } +} + +func TestInfoInvalidImage(t *testing.T) { + if _, err := InfoFromReader(bytes.NewReader([]byte("x"))); err == nil { + t.Error("invalid image should err") + } +} + +func TestResizeImageWebpSuccess(t *testing.T) { + if _, err := exec.LookPath("cwebp"); err != nil { + t.Skip("cwebp missing") + } + out, ct, err := ResizeImage(bytes.NewReader(testPNG(t, 40, 40)), 20, 20, "fit", "webp", 80, defaultCfg()) + if err != nil { + t.Fatalf("resize webp: %v", err) + } + if ct != "image/webp" || !bytes.HasPrefix(out, []byte("RIFF")) { + t.Errorf("resize webp bad ct=%s", ct) + } +} + +func TestResizeInvalidFormat(t *testing.T) { + if _, _, err := ResizeImage(bytes.NewReader(testPNG(t, 10, 10)), 5, 5, "fit", "bmp", 80, defaultCfg()); err == nil { + t.Error("bmp resize should err") + } +} + +func TestOptimizeInvalidFormat(t *testing.T) { + if _, err := Optimize(bytes.NewReader(testPNG(t, 8, 8)), "tiff", 80, true, defaultCfg()); err == nil { + t.Error("tiff optimize should err") + } +} + +func TestClampQuality(t *testing.T) { + if clampQuality(0) != 85 { + t.Error("0 -> 85") + } + if clampQuality(150) != 85 { + t.Error(">100 -> 85") + } + if clampQuality(50) != 50 { + t.Error("50 passthrough") + } +} + +func TestFallbackJPEGDirect(t *testing.T) { + res, err := fallbackJPEG(image.NewRGBA(image.Rect(0, 0, 8, 8)), 70, "png") + if err != nil { + t.Fatalf("fallback: %v", err) + } + if res.ContentType != "image/jpeg" { + t.Errorf("ct = %s", res.ContentType) + } +} + +func TestColorModelPaletted(t *testing.T) { + if m := colorModel(image.NewPaletted(image.Rect(0, 0, 2, 2), nil)); m != "Paletted" { + t.Errorf("paletted -> %s", m) + } +} + +func TestConvertImageWebpPresent(t *testing.T) { + if _, err := exec.LookPath("cwebp"); err != nil { + t.Skip("cwebp missing") + } + out, ct, err := ConvertImage(bytes.NewReader(testPNG(t, 20, 20)), "webp", 80, defaultCfg()) + if err != nil { + t.Fatalf("convert webp: %v", err) + } + if ct != "image/webp" || !bytes.HasPrefix(out, []byte("RIFF")) { + t.Errorf("webp convert failed ct=%s", ct) + } +} + +func TestOptimizeWebpPresent(t *testing.T) { + if _, err := exec.LookPath("cwebp"); err != nil { + t.Skip("cwebp missing") + } + res, err := Optimize(bytes.NewReader(testPNG(t, 24, 24)), "webp", 75, true, defaultCfg()) + if err != nil { + t.Fatalf("optimize webp: %v", err) + } + if res.ContentType != "image/webp" { + t.Errorf("ct = %s", res.ContentType) + } +} + +func TestScanJPEGExifMarkerSOS(t *testing.T) { + data := []byte{0xFF, 0xD8, 0xFF, 0xDA, 0x00, 0x04, 0x00, 0x00} + if got := scanJPEGExif(data); got != nil { + t.Error("SOS marker should end scan") + } +} + +func TestParseTIFFBigEndian(t *testing.T) { + jpegBytes := makeEXIFJPEGBE(t, "Sony A7 IV", "2024:08:15 14:32:00") + info, err := InfoFromReader(bytes.NewReader(jpegBytes)) + if err != nil { + t.Fatalf("info: %v", err) + } + if info.Exif == nil || info.Exif.Camera == nil || *info.Exif.Camera != "Sony A7 IV" { + t.Errorf("camera = %+v", info.Exif) + } +} + +func makeEXIFJPEGBE(t *testing.T, camera, dt string) []byte { + t.Helper() + exif := buildEXIFBE(camera, dt) + var img bytes.Buffer + if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 12, 12)), &jpeg.Options{Quality: 40}); err != nil { + t.Fatalf("jpeg encode: %v", err) + } + out := new(bytes.Buffer) + out.Write([]byte{0xFF, 0xD8, 0xFF, 0xE1}) + var sl [2]byte + binary.BigEndian.PutUint16(sl[:], uint16(len(exif)+2)) + out.Write(sl[:]) + out.Write(exif) + out.Write(img.Bytes()[2:]) + return out.Bytes() +} + +func buildEXIFBE(camera, dt string) []byte { + bo := binary.BigEndian + buf := new(bytes.Buffer) + buf.WriteString("Exif\x00\x00") + buf.WriteString("MM") + binary.Write(buf, bo, uint16(42)) + binary.Write(buf, bo, uint32(8)) + binary.Write(buf, bo, uint16(2)) + cameraBytes := append([]byte(camera), 0) + dtBytes := append([]byte(dt), 0) + dataOff := 8 + 2 + 2*12 + 4 + writeEntry(buf, bo, tagMake, 2, uint32(len(cameraBytes)), uint32(dataOff)) + writeEntry(buf, bo, tagDateTime, 2, uint32(len(dtBytes)), uint32(dataOff+len(cameraBytes))) + binary.Write(buf, bo, uint32(0)) + buf.Write(cameraBytes) + buf.Write(dtBytes) + return buf.Bytes() +} + +func TestParseExifNoExif(t *testing.T) { + got := parseExif([]byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F', 0, 1, 1, 0, 0, 0, 0, 0}) + if got == nil || (got.Camera != nil) { + t.Logf("got %+v (acceptable)", got) + } +} + +func TestRunPngquantBogus(t *testing.T) { + cfg := config.Optimizer{PngquantPath: "/no/such/pngquant"} + if _, err := runPngquant([]byte("not png"), 80, cfg); err == nil { + t.Error("bogus pngquant should error") + } +} + +func TestRunCjpegBogus(t *testing.T) { + cfg := config.Optimizer{MozjpegPath: "/no/such/cjpeg"} + if _, err := runCjpeg([]byte("P6\n1 1\n255\nRGB"), 80, cfg); err == nil { + t.Error("bogus cjpeg should error") + } +} + +func TestInfoWithGPS(t *testing.T) { + jb := makeGPSJPEG(t) + info, err := InfoFromReader(bytes.NewReader(jb)) + if err != nil { + t.Fatalf("info: %v", err) + } + if info.Exif == nil || info.Exif.GPS == nil { + t.Fatalf("no gps: %+v", info.Exif) + } + if info.Exif.GPS.Lat <= 0 || info.Exif.GPS.Lng >= 0 { + t.Errorf("gps = %+v (want +lat, -lng for W)", info.Exif.GPS) + } +} + +func makeGPSJPEG(t *testing.T) []byte { + t.Helper() + exif := buildGPSExif() + var img bytes.Buffer + if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 8, 8)), &jpeg.Options{Quality: 30}); err != nil { + t.Fatalf("jpeg encode: %v", err) + } + out := new(bytes.Buffer) + out.Write([]byte{0xFF, 0xD8, 0xFF, 0xE1}) + var sl [2]byte + binary.BigEndian.PutUint16(sl[:], uint16(len(exif)+2)) + out.Write(sl[:]) + out.Write(exif) + out.Write(img.Bytes()[2:]) + return out.Bytes() +} + +func buildGPSExif() []byte { + bo := binary.LittleEndian + buf := new(bytes.Buffer) + buf.WriteString("Exif\x00\x00") + buf.WriteString("II") + binary.Write(buf, bo, uint16(42)) + binary.Write(buf, bo, uint32(8)) + binary.Write(buf, bo, uint16(1)) + gpsIFDOff := uint32(8 + 2 + 1*12 + 4) + writeEntry(buf, bo, 0x8825, 4, 1, gpsIFDOff) + binary.Write(buf, bo, uint32(0)) + + latRatOff := gpsIFDOff + uint32(2+4*12+4) + lngRatOff := latRatOff + 24 + binary.Write(buf, bo, uint16(4)) + type entry struct{ tag, typ uint16; count, off uint32 } + entries := []entry{ + {tagGPSLatRef, 2, 2, uint32('N')}, + {tagGPSLat, 5, 3, latRatOff}, + {tagGPSLngRef, 2, 2, uint32('W')}, + {tagGPSLng, 5, 3, lngRatOff}, + } + for _, e := range entries { + writeEntry(buf, bo, e.tag, e.typ, e.count, e.off) + } + binary.Write(buf, bo, uint32(0)) + writeRational := func(num, den uint32) { + binary.Write(buf, bo, num) + binary.Write(buf, bo, den) + } + writeRational(48, 1) + writeRational(30, 1) + writeRational(15, 1) + writeRational(120, 1) + writeRational(15, 1) + writeRational(0, 1) + return buf.Bytes() +} \ No newline at end of file diff --git a/internal/service/optimizer.go b/internal/service/optimizer.go new file mode 100644 index 0000000..ae17a1e --- /dev/null +++ b/internal/service/optimizer.go @@ -0,0 +1,177 @@ +package service + +import ( + "bytes" + "fmt" + "image" + "image/png" + "io" + "os/exec" + "strconv" + + "github.com/DulanDev/GoImager/internal/config" +) + +type OptimizeResult struct { + Bytes []byte + ContentType string +} + +const ppmHeaderTemplate = "P6\n%d %d\n255\n" + +func writePPM(img image.Image) []byte { + bounds := img.Bounds() + var buf bytes.Buffer + fmt.Fprintf(&buf, ppmHeaderTemplate, bounds.Dx(), bounds.Dy()) + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + r, g, b, _ := img.At(x, y).RGBA() + buf.Write([]byte{byte(r >> 8), byte(g >> 8), byte(b >> 8)}) + } + } + return buf.Bytes() +} + +func Optimize(src io.Reader, format string, quality int, stripExif bool, cfg config.Optimizer) (*OptimizeResult, error) { + fmtArg, err := NormalizeFormat(format) + if err != nil { + return nil, &ErrInvalid{Code: "INVALID_FORMAT", Message: err.Error()} + } + quality = clampQuality(quality) + + img, inFormat, err := Decode(src) + if err != nil { + return nil, &ErrInvalid{Code: "INVALID_IMAGE", Message: fmt.Sprintf("could not decode image: %v", err)} + } + _ = stripExif + + outFormat := fmtArg + + switch outFormat { + case "png": + if pngquantAvail(cfg) { + data, err := goEncodePNG(img) + if err != nil { + return nil, err + } + out, err := runPngquant(data, quality, cfg) + if err != nil { + return &OptimizeResult{Bytes: data, ContentType: ContentType("png")}, nil + } + return &OptimizeResult{Bytes: out, ContentType: ContentType("png")}, nil + } + data, err := goEncodePNG(img) + if err != nil { + return nil, err + } + return &OptimizeResult{Bytes: data, ContentType: ContentType("png")}, nil + + case "jpeg": + if mozjpegAvail(cfg) { + ppm := writePPM(img) + out, err := runCjpeg(ppm, quality, cfg) + if err != nil { + return fallbackJPEG(img, quality, inFormat) + } + return &OptimizeResult{Bytes: out, ContentType: ContentType("jpeg")}, nil + } + return fallbackJPEG(img, quality, inFormat) + + case "webp": + if cfg.CwebpPath != "" && toolExists(cfg.CwebpPath) { + out, ct, err := Encode(img, "webp", quality, cfg) + if err == nil { + return &OptimizeResult{Bytes: out, ContentType: ct}, nil + } + } + return fallbackJPEG(img, quality, inFormat) + + case "gif": + data, ct, err := Encode(img, "gif", quality, cfg) + if err != nil { + return nil, err + } + return &OptimizeResult{Bytes: data, ContentType: ct}, nil + } + return nil, fmt.Errorf("optimize: unreachable format %q", outFormat) +} + +func goEncodePNG(img image.Image) ([]byte, error) { + buf := new(bytes.Buffer) + enc := png.Encoder{CompressionLevel: png.BestCompression} + if err := enc.Encode(buf, img); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func fallbackJPEG(img image.Image, quality int, inFormat string) (*OptimizeResult, error) { + out, ct, err := Encode(img, "jpeg", quality, config.Optimizer{}) + if err != nil { + return nil, err + } + return &OptimizeResult{Bytes: out, ContentType: ct}, nil +} + +func runPngquant(pngData []byte, quality int, cfg config.Optimizer) ([]byte, error) { + if !toolExists(cfg.PngquantPath) { + return nil, fmt.Errorf("pngquant not found") + } + minQ, maxQ := pngQualityRange(quality) + args := []string{"--quality=" + strconv.Itoa(minQ) + "-" + strconv.Itoa(maxQ), "--strip", "-"} + cmd := exec.Command(cfg.PngquantPath, args...) + cmd.Stdin = bytes.NewReader(pngData) + var out, errBuf bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errBuf + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("pngquant failed: %w: %s", err, errBuf.String()) + } + return out.Bytes(), nil +} + +func runCjpeg(ppm []byte, quality int, cfg config.Optimizer) ([]byte, error) { + if !toolExists(cfg.MozjpegPath) { + return nil, fmt.Errorf("mozjpeg cjpeg not found") + } + cmd := exec.Command(cfg.MozjpegPath, "-quality", strconv.Itoa(quality), "-optimize", "-progressive") + cmd.Stdin = bytes.NewReader(ppm) + var out, errBuf bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errBuf + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("cjpeg failed: %w: %s", err, errBuf.String()) + } + return out.Bytes(), nil +} + +func pngQualityRange(q int) (int, int) { + minQ := q - 10 + maxQ := q + 5 + if minQ < 0 { + minQ = 0 + } + if maxQ > 100 { + maxQ = 100 + } + return minQ, maxQ +} + +func toolExists(path string) bool { + if path == "" { + return false + } + if p, err := exec.LookPath(path); err == nil && p != "" { + return true + } + return false +} + +func pngquantAvail(cfg config.Optimizer) bool { + return toolExists(cfg.PngquantPath) +} + +func mozjpegAvail(cfg config.Optimizer) bool { + return toolExists(cfg.MozjpegPath) +} + diff --git a/server b/server new file mode 100755 index 0000000..1bd3029 Binary files /dev/null and b/server differ