Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
.env
.env
goimager.yaml
coverage.out
*.out
goImager
64 changes: 64 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
162 changes: 138 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 <key>` |
| `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).
59 changes: 43 additions & 16 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading
Loading