https://github.com/pyaethu-aung/image-server
Image upload and transformation server in Go (multipart/URL uploads, on-the-fly resize/format/quality/fit transforms, cached derivatives). Under active development — core upload/transform/cache endpoints not yet implemented.
https://github.com/pyaethu-aung/image-server
chi-router docker go golang image-processing image-server libvips openapi postgresql redis rest-api
Last synced: 4 days ago
JSON representation
Image upload and transformation server in Go (multipart/URL uploads, on-the-fly resize/format/quality/fit transforms, cached derivatives). Under active development — core upload/transform/cache endpoints not yet implemented.
- Host: GitHub
- URL: https://github.com/pyaethu-aung/image-server
- Owner: pyaethu-aung
- License: mit
- Created: 2026-07-06T14:21:11.000Z (10 days ago)
- Default Branch: main
- Last Pushed: 2026-07-06T16:45:56.000Z (9 days ago)
- Last Synced: 2026-07-06T17:06:27.245Z (9 days ago)
- Topics: chi-router, docker, go, golang, image-processing, image-server, libvips, openapi, postgresql, redis, rest-api
- Language: Go
- Size: 54.7 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 4
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# image-server
[](docs/openapi/image-server.yaml)
[](https://github.com/pyaethu-aung/image-server/actions/workflows/ci.yml)
[](https://github.com/pyaethu-aung/image-server/actions/workflows/docker.yml)
[](https://github.com/pyaethu-aung/image-server/actions/workflows/security.yml)
An image upload and transformation service in Go. Upload images via multipart form or by URL, store originals on local disk behind a pluggable storage interface, and serve them back with on-the-fly transforms (resize, format conversion, quality, fit) via URL query params. Generated derivatives are cached so repeated transforms are served from cache, not recomputed.
> **Status: core HTTP service complete.** Uploads (`POST /v1/images`, `POST /v1/images/from-url`), reads (`GET /v1/images/{id}`, original or transformed, plus `/meta`), derivative caching, and `DELETE` are all live and tested. See [CLAUDE.md](CLAUDE.md) for the architecture decisions. Next on the roadmap: an MCP server interface, then an S3 storage backend.
## Features
- **Upload** via multipart file or by URL (server fetches it, with SSRF protection). Accepts JPEG, PNG, GIF, WebP, TIFF, and the HEIF family (HEIC/HEIF/AVIF, i.e. iPhone and modern Android photos)
- **Local filesystem storage** for originals, behind a `Storage` interface (S3 planned as a second backend)
- **On-the-fly transforms** on read: width, height, format (jpeg/png/webp), quality, fit mode
- **Derivative caching**: each unique transform is generated once, stored under a separate prefix, and tracked in Redis
- **Deduplication**: identical uploads (same sha256) return the existing record
- **Security built in**: magic-byte type validation, upload size limits, decompression-bomb guard, SSRF protection, path-traversal guard, API-key auth, per-key rate limiting
## Tech Stack
| Concern | Choice |
|---|---|
| Language / router | Go 1.26+, `chi` |
| Image processing | `h2non/bimg` (libvips) |
| Object storage | Local filesystem (S3 via AWS SDK v2 planned) |
| Metadata | PostgreSQL via `pgx` + `sqlc` |
| Cache / rate limiting | Redis |
| Local stack | `docker-compose` (Postgres, Redis) |
## Getting Started
### Prerequisites
- Docker and Docker Compose
- Go 1.26+ and libvips (only for running the server outside Docker: `brew install vips` on macOS)
### Run the stack
```sh
git clone https://github.com/pyaethu-aung/image-server.git
cd image-server
make setup # wire git hooks (one-time; see Quality gates below)
cp .env.example .env # set API_KEY and adjust as needed
make up
```
This builds the server image and boots it with Postgres and Redis, detached. Uploaded images are stored in a Docker volume mounted at `STORAGE_PATH`. Then apply migrations:
```sh
make migrate
```
### Makefile targets
```sh
make setup # one-time: activate the committed git hooks
make up # build and boot the full stack, detached
make down # stop the stack (keeps volumes)
make run # run the server locally (expects the compose services up)
make migrate # apply SQL migrations (DATABASE_URL from env or .env)
make sqlc-gen # regenerate database code from SQL queries
make openapi-gen # regenerate server interfaces from the OpenAPI spec
make lint # run golangci-lint
make test # run all unit tests
make coverage # print overall coverage
make test-api # validate every endpoint against the OpenAPI spec
make test-e2e # full-stack e2e tests against the real container (LOCAL-ONLY, not in CI)
```
### Quality gates (git hooks)
The repo ships hooks in `.githooks/`, activated by `make setup` (`git config core.hooksPath .githooks`):
- **pre-commit**: `go mod tidy` cleanliness and generated-code sync checks
- **commit-msg**: 50/72 subject-line rule
- **pre-push**: golangci-lint, unit-test coverage gate (90% per layer and overall, generated code excluded), API spec tests (`make test-api`), and a full-stack e2e gate against the real Docker container (`make test-e2e`)
CI re-runs three of these four gates on every PR (lint, coverage, generated-code drift, spec tests against real Postgres/Redis). **The e2e gate (`make test-e2e`) is local-only: CI does not re-run it.** `docker.yml` builds and pushes the server image but runs no tests against it, so a push made with `--no-verify`, or from a clone that skipped `make setup`, has no server-side backstop for container-level regressions. CI also runs `govulncheck` and validates PR titles as Conventional Commits. Dependabot keeps Go modules, Docker base images, and Actions current.
### Claude Code plugins (contributors)
This repo's Claude Code workflows (commit messages, PRs, Go feature scaffolding) come from two plugins tracked in `.claude/settings.json`. If you use Claude Code on this repo, add the marketplace and install them once:
```
/plugin marketplace add pyaethu-aung/skills
/plugin install git-workflow@pyaethu-aung-skills
/plugin install go-dev@pyaethu-aung-skills
```
Run `/reload-plugins` after installing.
### Configuration
All configuration is via environment variables. Never commit real credentials.
| Variable | Description | Default |
|---|---|---|
| `API_KEY` | Key clients must send in `X-API-Key` | (required) |
| `LISTEN_ADDR` | HTTP listen address | `:8080` |
| `DATABASE_URL` | Postgres connection string | (required) |
| `REDIS_ADDR` | Redis address | `localhost:6379` |
| `STORAGE_PATH` | Root directory for stored images | `./data/images` |
| `MAX_UPLOAD_BYTES` | Max upload size | `10485760` (10MB) |
| `MAX_PIXELS` | Max decoded pixel count (bomb guard) | `50000000` |
| `RATE_LIMIT_PER_MIN` | Requests per minute per API key | `120` |
| `CACHE_CONTROL_MAX_AGE` | `Cache-Control` max-age (seconds) on served image bytes | `31536000` (1 year) |
| `DERIVATIVE_CACHE_TTL` | TTL for Redis derivative-cache markers (Go duration) | `720h` (30 days) |
## API
The OpenAPI 3.0.3 spec at [`docs/openapi/image-server.yaml`](docs/openapi/image-server.yaml) is the source of truth for this API; server interfaces are generated from it with `oapi-codegen`. The examples below are illustrative.
All `/v1` routes require an `X-API-Key` header. Examples assume the server on `localhost:8080` and `$API_KEY` exported.
### Upload an image (multipart)
```sh
curl -X POST http://localhost:8080/v1/images \
-H "X-API-Key: $API_KEY" \
-F "file=@photo.jpg"
```
Response:
```json
{
"id": "1b4e28ba-2fa1-4d3b-9558-b7f6f18e3c2a",
"original_filename": "photo.jpg",
"content_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"mime_type": "image/jpeg",
"width": 4032,
"height": 3024,
"size_bytes": 2843190,
"created_at": "2026-07-06T12:00:00Z"
}
```
Uploading a byte-identical file again returns the existing record (dedup by sha256).
### Upload from a URL
```sh
curl -X POST http://localhost:8080/v1/images/from-url \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/photo.jpg"}'
```
Only public `http(s)` URLs are accepted. Requests resolving to private, loopback, link-local, or cloud-metadata addresses are rejected, including via redirects.
### Get an image (original or transformed)
```sh
# Original
curl -H "X-API-Key: $API_KEY" \
http://localhost:8080/v1/images/1b4e28ba-2fa1-4d3b-9558-b7f6f18e3c2a -o out.jpg
# Resized to 800px wide, converted to webp at quality 80
curl -H "X-API-Key: $API_KEY" \
"http://localhost:8080/v1/images/1b4e28ba-2fa1-4d3b-9558-b7f6f18e3c2a?w=800&fmt=webp&q=80" -o out.webp
# 400x400 thumbnail, cropped to fill
curl -H "X-API-Key: $API_KEY" \
"http://localhost:8080/v1/images/1b4e28ba-2fa1-4d3b-9558-b7f6f18e3c2a?w=400&h=400&fit=cover" -o thumb.jpg
# Strip metadata (EXIF/GPS, XMP, IPTC) from the served image
curl -H "X-API-Key: $API_KEY" \
"http://localhost:8080/v1/images/1b4e28ba-2fa1-4d3b-9558-b7f6f18e3c2a?strip=true" -o clean.jpg
```
Transform query params:
| Param | Values | Description |
|---|---|---|
| `w` | positive int | target width in pixels |
| `h` | positive int | target height in pixels |
| `fmt` | `jpeg`, `png`, `webp` | output format |
| `q` | 1 to 100 | output quality (lossy formats) |
| `fit` | `cover`, `contain` | `cover` crops to fill w x h; `contain` fits within w x h |
| `strip` | `true`, `false` | remove metadata (EXIF/GPS, XMP, IPTC, comments) from the served image |
Each unique combination is generated once and cached; responses include `Cache-Control` headers.
A HEIC, HEIF, AVIF, or TIFF original is served back unchanged when no transform
is requested. Transforming one requires an explicit `fmt` (`jpeg`, `png`, or
`webp`), since the server only encodes to those web formats; a transform without
`fmt` on such a source returns `400`.
`strip` never touches the stored original. On its own it is a lossless,
byte-preserving removal for JPEG and PNG; for other formats on their own it
returns `415` (add `fmt` to strip via a re-encode). Combined with a resize or
format change, the strip happens during that re-encode.
### Get image metadata
```sh
curl -H "X-API-Key: $API_KEY" \
http://localhost:8080/v1/images/1b4e28ba-2fa1-4d3b-9558-b7f6f18e3c2a/meta
```
### Delete an image
```sh
curl -X DELETE -H "X-API-Key: $API_KEY" \
http://localhost:8080/v1/images/1b4e28ba-2fa1-4d3b-9558-b7f6f18e3c2a
```
Deletes the original, cached derivatives, and metadata.
### Health check
```sh
curl http://localhost:8080/healthz
```
No auth required.
## Project Structure
```
cmd/server/ entrypoint
internal/api/ handlers, middleware, router
internal/storage/ Storage interface + local filesystem implementation
internal/imageproc/ bimg transforms + validation
internal/db/ sqlc-generated code + queries
internal/config/ env parsing
migrations/ SQL migrations
docker-compose.yml
Makefile
```
Storage is defined as an interface (`Put`, `Get`, `Delete`, `Exists`) with the local filesystem as the first implementation, so additional backends (S3, OneDrive, Dropbox) can be added without touching handler code.
## Testing
```sh
make test # unit tests
make test-api # spec-driven API tests (starts Postgres + Redis via compose)
make test-e2e # full-stack e2e tests against the real container (local-only)
```
Table-driven unit tests cover transform param parsing, the SSRF guard, and storage key sanitization; the pre-push hook enforces 90% coverage. API tests boot the router in-process and validate every request/response against the OpenAPI spec with `kin-openapi`. `make test-e2e` goes one step further: it boots the real `server` Docker image (not an in-process router) and runs the same kind of spec-validated checks against it over the network — the only test path that exercises the Dockerfile, the libvips runtime lib, and container env-var wiring. It is a local-only pre-push gate; CI does not run it.
## Roadmap
- [x] Core HTTP service (scaffold, storage, upload, transform, caching)
- [ ] MCP server interface exposing upload/transform as tools
- [ ] S3 storage backend (AWS SDK for Go v2, Garage or LocalStack for local testing)