diff --git a/.gitignore b/.gitignore index 85ca5df2b..5000abcfe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ .cache -./imageproxy +/imageproxy +/imageproxy.exe +*.exe +*.log diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 000000000..a0770047f --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,341 @@ +# Deploying imageproxy (trungnsbkvn fork) — Windows & Linux + +Careful, end-to-end deployment guide for the WebP/AVIF-enabled fork that powers the +`/img` on-the-fly resizer for **luatsumienbac.vn**. The fork is a **single pure-Go +binary** (no cgo, no libvips, no Docker) that builds and runs natively on **Windows** +and **Linux**. For what the fork adds over upstream, see [FORK_NOTES.md](FORK_NOTES.md). + +> Site-side wiring (the Astro `IMAGE_RESIZER` env, the `` output, the media +> migration) lives in the site repo: `docs/MEDIA_RESIZER.md` and +> `docs/RESIZER_PROVIDERS.md`. This file is only about running the engine. + +--- + +## 1. How it fits together + +``` +Browser ──/img/{opts}/{b64url(mediaURL)}──▶ reverse proxy (IIS / nginx / Caddy) + strips /img, forwards to 127.0.0.1:8080 + │ + ▼ + imageproxy (this binary) + • fetches the ORIGINAL over HTTP from + https://luatsumienbac.vn/media/ + • resizes + encodes AVIF/WebP/JPEG + • caches the RESULT to disk (encode once) + │ + ▼ + Cloudflare caches at the edge (immutable) +``` + +- imageproxy **fetches originals over HTTP** from the public `/media` URL, so it needs + **no access to `D:\media`** — it only needs outbound HTTP to your own origin. +- It listens on **loopback only** (`127.0.0.1:8080`); the public reverse proxy is the + only thing that can reach it. +- The `-cache` disk store holds **transformed** variants (each width×format encoded + once), so repeat hits are served straight from disk; Cloudflare caches downstream. + +--- + +## 2. Build the binary + +Requires **Go 1.25.8+** (built & tested with Go 1.26). No C toolchain needed. + +```bash +git clone https://github.com/trungnsbkvn/imageproxy.git +cd imageproxy +go mod tidy # first time only; pulls gen2brain webp/avif + wazero +``` + +**Linux (native):** +```bash +CGO_ENABLED=0 go build -ldflags "-s -w" -o imageproxy ./cmd/imageproxy +``` + +**Windows (native, PowerShell):** +```powershell +$env:CGO_ENABLED=0 +go build -ldflags "-s -w" -o imageproxy.exe .\cmd\imageproxy +``` + +**Cross-compile** (build once, ship anywhere — pure Go makes this trivial): +```bash +CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -o imageproxy.exe ./cmd/imageproxy +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o imageproxy ./cmd/imageproxy +``` + +Output is a single ~48 MB static binary (it embeds the libwebp + libaom WASM blobs). +`-ldflags "-s -w"` strips debug info to shrink it. Verify: +```bash +./imageproxy -addr 127.0.0.1:8080 & # or .\imageproxy.exe on Windows +curl http://127.0.0.1:8080/health-check # -> OK +``` + +--- + +## 3. Configuration reference + +Every flag can also be set as an environment variable prefixed **`IMAGEPROXY_`** with +the flag name upper-cased (via `envy`). Env vars are convenient for services. + +| Flag | `IMAGEPROXY_` env | Recommended value | Purpose | +|------|-------------------|-------------------|---------| +| `-addr` | `IMAGEPROXY_ADDR` | `127.0.0.1:8080` | listen address (loopback → only the proxy reaches it). `unix:/path` also supported. | +| `-allowHosts` | `IMAGEPROXY_ALLOWHOSTS` | `luatsumienbac.vn` | **lock the source origin** — prevents open-proxy abuse. Comma-separated. | +| `-cache` | `IMAGEPROXY_CACHE` | a disk path (see §6) | cache transformed variants. Omit → no cache (re-encodes every hit). | +| `-signatureKey` | `IMAGEPROXY_SIGNATUREKEY` | *(optional)* | HMAC key for signed URLs (see §7). `@/path` reads the key from a file. | +| `-contentTypes` | `IMAGEPROXY_CONTENTTYPES` | `image/*` (default) | allowed source content types. Leave default. | +| `-timeout` | `IMAGEPROXY_TIMEOUT` | `20s` | per-request limit. `0` = none. | +| `-verbose` | `IMAGEPROXY_VERBOSE` | `false` in prod | debug logging (useful during first bring-up). | +| `-scaleUp` | `IMAGEPROXY_SCALEUP` | `false` | never upscale past the original. Keep false. | +| `-service` | — | *(control)* | fork addition: `install`/`uninstall`/`start`/`stop`/`restart` the OS service, then exit. | +| `-logFile` | `IMAGEPROXY_LOGFILE` | a file path | fork addition: append logs to a file (a service's stdout is discarded — set this). | + +Endpoints: `GET /health-check` → `OK`; `GET /metrics` → Prometheus. + +> **Server write timeout:** the HTTP server uses a 30 s write timeout. AVIF encoding of +> very large originals can be slow — but the CMS already downsizes uploads to ~1600 px, +> so encodes stay a few seconds, well under the limit. Keep originals reasonably sized. + +--- + +## 4. Run as a service + +### 4a. Windows — as a service (native by default, or nssm) +Two methods, both giving a real service in `services.msc`. **Native** is the default and +needs no third-party tool; **nssm** is available if you prefer it. The bundled +`build\install-service.ps1` does either from a CONFIG block (`-Method nssm` for the +latter) — prefer it over the raw commands below. + +**Native** — the binary registers **itself** with the Service Control Manager. Put it +somewhere stable, e.g. `C:\svc\imageproxy\`, and from an **elevated** PowerShell: + +```powershell +$exe = "C:\svc\imageproxy\imageproxy.exe" + +# Install: everything after `-service install` becomes the service's command line. +& $exe -service install ` + -addr 127.0.0.1:8080 ` + -allowHosts luatsumienbac.vn ` + -cache D:/media/luatsumienbac/_imgcache ` + -timeout 20s ` + -logFile C:\svc\imageproxy\imageproxy.log + +# Native crash recovery + auto-start, via built-in sc.exe: +sc.exe failure imageproxy reset= 86400 actions= restart/5000/restart/5000/restart/5000 +sc.exe config imageproxy start= auto + +& $exe -service start +sc.exe query imageproxy # or services.msc +``` +Manage it with `imageproxy.exe -service stop|start|restart|uninstall` (or the usual +`sc.exe` / `services.msc`). + +**NSSM** (alternative). + +*Get nssm onto the server* — it's a single standalone binary, nothing to install, no +runtime: +- **Chocolatey:** `choco install nssm` · **Scoop:** `scoop install nssm` (both add it to PATH) +- **Manual:** download `nssm-2.24.zip` from (or a mirror), unzip, + take **`win64\nssm.exe`**. Then either add its folder to PATH, or skip PATH entirely and + point the installer at it: + ```powershell + .\install-service.ps1 -Method nssm -NssmPath 'C:\tools\nssm\nssm.exe' + ``` + Verify with `nssm version`. + +*Configure by hand* (what `install-service.ps1 -Method nssm` does for you): +```powershell +$exe = "C:\svc\imageproxy\imageproxy.exe" +# Pass the flags right after the program — nssm stores them as AppParameters, quoting +# spaced paths itself (more robust than `set AppParameters "...escaped quotes..."`): +nssm install imageproxy $exe -addr 127.0.0.1:8080 -allowHosts luatsumienbac.vn -cache "D:/media/luatsumienbac/_imgcache" -timeout 20s -logFile "C:\svc\imageproxy\imageproxy.log" +nssm set imageproxy AppDirectory "C:\svc\imageproxy" +nssm set imageproxy AppExit Default Restart # restart if the process exits +nssm set imageproxy Start SERVICE_AUTO_START # start at boot +nssm set imageproxy AppStderr "C:\svc\imageproxy\err.log" # crash backstop (normal logs -> -logFile) +nssm start imageproxy +``` +`nssm edit imageproxy` opens a GUI for these; `nssm restart imageproxy` after changes. On +stop, nssm sends the process Ctrl+C first, so imageproxy shuts down gracefully. Runs as +LocalSystem unless you set `nssm set imageproxy ObjectName `. + +Either method: remove with `build\uninstall-service.ps1` (or `sc.exe delete imageproxy`, +which works for both). The two methods are interchangeable — don't run both at once. + +> **Logs:** a service's stdout is discarded by Windows, so pass `-logFile` (both methods) +> to capture startup + errors. Start/stop/failure are also in the Windows Event Log. + +> **Cache path on Windows:** both `D:/media/.../_imgcache` and `D:\media\...\_imgcache` +> are accepted (verified). Forward slashes are slightly safer (the value passes through +> Go's `url.Parse`). The cache directory is created on first use. + +> **Firewall:** binding to `127.0.0.1` already blocks external access. No inbound rule +> is needed — only IIS on the same box connects to it. The service runs as LocalSystem +> by default (can read `D:\media` and write the cache). + +### 4b. Linux — via systemd +> The binary can also self-install on Linux: `sudo imageproxy -service install -addr … -cache …` +> writes a systemd unit automatically (same `-service` flag as Windows). The hand-written +> unit below is preferred in production because it adds a dedicated user + hardening. + +Create a dedicated unprivileged user and a unit. + +```bash +sudo useradd --system --no-create-home --shell /usr/sbin/nologin imageproxy +sudo install -Dm755 imageproxy /usr/local/bin/imageproxy +sudo install -d -o imageproxy -g imageproxy /var/cache/imageproxy +``` + +`/etc/imageproxy.env`: +```ini +IMAGEPROXY_ADDR=127.0.0.1:8080 +IMAGEPROXY_ALLOWHOSTS=luatsumienbac.vn +IMAGEPROXY_CACHE=/var/cache/imageproxy +IMAGEPROXY_TIMEOUT=20s +# IMAGEPROXY_SIGNATUREKEY=... # optional, see §7 +``` + +`/etc/systemd/system/imageproxy.service`: +```ini +[Unit] +Description=imageproxy (WebP/AVIF resizer) +After=network-online.target +Wants=network-online.target + +[Service] +User=imageproxy +Group=imageproxy +EnvironmentFile=/etc/imageproxy.env +ExecStart=/usr/local/bin/imageproxy +Restart=on-failure +RestartSec=2 +# hardening +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +ReadWritePaths=/var/cache/imageproxy + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now imageproxy +systemctl status imageproxy +curl http://127.0.0.1:8080/health-check # -> OK +``` + +--- + +## 5. Reverse proxy (route `/img` → imageproxy) + +The site emits `/img/{opts}/{b64url}`. imageproxy has **no `/img` prefix**, so the proxy +must **strip `/img`** before forwarding. + +### 5a. Windows IIS (URL Rewrite + ARR) +Requires the **URL Rewrite** and **Application Request Routing (ARR)** modules, and ARR +proxy enabled (IIS Manager → server node → *Application Request Routing Cache* → *Server +Proxy Settings* → **Enable proxy**). Then in the site's `web.config`, inside +``: + +```xml + + + + +``` +`{R:1}` is everything after `img/`, so `http://localhost:8080/{opts}/{b64url}` — the +`/img` prefix is dropped, which is exactly what imageproxy expects. Put this rule +**before** any catch-all rule and after the `/media` static rule. + +### 5b. Linux nginx +```nginx +location /img/ { + proxy_pass http://127.0.0.1:8080/; # trailing slash strips the /img/ prefix + proxy_set_header Host $host; + proxy_read_timeout 30s; + # imageproxy already sets immutable Cache-Control; let it pass through + proxy_pass_header Cache-Control; +} +``` + +### 5c. Linux Caddy +```caddy +handle_path /img/* { + reverse_proxy 127.0.0.1:8080 # handle_path strips the /img prefix +} +``` + +Smoke-test through the public edge once wired: +```bash +b64=$(printf 'https://luatsumienbac.vn/media/.jpg' | base64 | tr '+/' '-_' | tr -d '=') +curl -sI "https://luatsumienbac.vn/img/800x,avif,q55/$b64" # 200, Content-Type: image/avif +curl -sI "https://luatsumienbac.vn/img/800x,webp/$b64" # 200, Content-Type: image/webp +``` + +--- + +## 6. Caching + +- **imageproxy disk cache** (`-cache `): stores each transformed variant once, + keyed on the full request URL (options + source). **Set this** — without it every hit + re-fetches and re-encodes. Point it at fast local disk with room to grow + (thousands of small AVIF/WebP files). Other backends: `memory:`, `s3://…`, + `gcs://…`, `azure://…`, `redis://…`; multiple `-cache` values create a tiered cache. +- **Cloudflare**: responses carry a long immutable `Cache-Control`, so the edge caches + them. Because each format has a **distinct URL** (from the `` element), edge + caching is correct with no `Vary` gymnastics. + +Cache invalidation: variant URLs are derived from the source filename + options. If an +editor **replaces** an image under the same filename, purge that path at Cloudflare (or +clear the imageproxy cache dir). New filenames need no purge. + +--- + +## 7. Signed URLs (optional, defense-in-depth) + +`-allowHosts` already prevents open-proxy abuse, so signing is optional. To enable it: + +1. Generate a key: `openssl rand -hex 32` +2. **Server:** set `IMAGEPROXY_SIGNATUREKEY=` (or `-signatureKey `, or + `-signatureKey @/etc/imageproxy.key`). +3. **Astro build:** set **`IMAGEPROXY_SIGNATURE_KEY=`** — note the build-side + var name has an underscore (`SIGNATURE_KEY`) while the server-side one does not + (`SIGNATUREKEY`); the **value must match**. `src/utils/imageResizer.ts` then appends + an `s` option to every URL; without the build var it emits unsigned URLs (fine + under `-allowHosts`). + +--- + +## 8. Updating the fork + +```bash +git pull # get new commits +go build -ldflags "-s -w" -o imageproxy ./cmd/imageproxy # rebuild (Windows: build.ps1) +# restart: Windows: imageproxy.exe -service restart Linux: sudo systemctl restart imageproxy +``` +To rebase the fork on a newer upstream, re-apply the small delta in +[FORK_NOTES.md](FORK_NOTES.md). + +--- + +## 9. Troubleshooting + +| Symptom | Cause & fix | +|---------|-------------| +| `403 requested URL is not allowed` | Source host not in `-allowHosts`, or a signature was expected but missing/wrong. Confirm `-allowHosts luatsumienbac.vn` and that the URL's origin matches. | +| AVIF returns `application/octet-stream` / 403 | You're on an **unpatched** imageproxy. This fork fixes it by setting an explicit `Content-Type` (Go's sniffer has no AVIF signature). Rebuild from this fork. | +| Blank/again `Cannot GET` at `/img/...` | Reverse proxy isn't stripping `/img`. IIS `{R:1}` / nginx trailing-slash `proxy_pass` / Caddy `handle_path` all strip it — verify the rule. | +| Slow first hit, fast after | Expected: the first request for a variant encodes then caches. Ensure `-cache` is set so it's paid once. AVIF is the heaviest; `q55`/speed keep it reasonable on ~1600 px originals. | +| 504 / cut-off on huge images | 30 s write timeout hit by a very large AVIF encode. Keep originals ≤ ~1600 px (the CMS already downsizes uploads). | +| Won't start: `listen failed` | Port already in use. Change `-addr` or free `:8080`. | +| `-service install` fails / access denied | Must run in an **elevated** (Administrator) PowerShell. | +| `install-service.ps1` aborts with `Can't open service!` / `NativeCommandError` | Old copy of the script on PowerShell 7.4+ (a harmless first-run "stop non-existent service" was treated as fatal). Pull the current script (it sets `$PSNativeCommandUseErrorActionPreference = $false`). | +| Service path has spaces / `&` (e.g. `…\Youth & Partners\…`) | Handled, but a space-free path like `C:\svc\imageproxy` is safest. The scripts pass args as an array so quoting is correct either way. | +| Service installed but keeps stopping | Check the `-logFile` and the Windows Event Log. Common causes: cache dir parent missing or not writable by LocalSystem, or `listen failed` (port in use). | + +Enable `-verbose` during bring-up to log every fetch/transform and the served-from-cache +flag. When running as a service, combine it with `-logFile` to capture that output. diff --git a/FORK_NOTES.md b/FORK_NOTES.md new file mode 100644 index 000000000..b2038510c --- /dev/null +++ b/FORK_NOTES.md @@ -0,0 +1,85 @@ +# Fork notes — trungnsbkvn/imageproxy + +This fork of [willnorris/imageproxy](https://github.com/willnorris/imageproxy) adds +**WebP and AVIF output encoding**, which upstream does not have (upstream decodes +WebP but only *encodes* JPEG/PNG/GIF/TIFF/BMP — see upstream issue #114). + +It powers the on-the-fly `/img` resizer for **luatsumienbac.vn** (self-hosted media +served from `D:\media`, out of git). See the site repo's +`docs/RESIZER_PROVIDERS.md` and `docs/MEDIA_RESIZER.md`. + +## Why fork instead of use upstream +We need modern-format output (WebP/AVIF ≈ 25–50 % smaller than JPEG) from a **single +pure-Go binary** that builds natively on Windows (the IIS host has no Docker and is +resource-tight). Upstream is the right base — pure Go, single binary, mature caching / +signing / host-allowlist — it just can't emit WebP/AVIF. This fork closes that one gap +without touching the fetch/cache/sign/allowlist machinery. + +### Why not the maintainer's own WebP PR (#393)? +willnorris opened [PR #393](https://github.com/willnorris/imageproxy/pull/393) (May +2024) adding WebP output — but it's **still open/unmerged**, is **WebP-only (no AVIF)**, +and encodes via **`go-libwebp`, which needs cgo + the C libwebp library**. That +reintroduces exactly what we forked to avoid: a C toolchain to build, libwebp present at +runtime, and hard cross-compilation — i.e. no "one `.exe`, no dependencies" on the +Windows box. The PR also has acknowledged quality/size bugs (inverted `q`, "lossless" +~4× larger) that kept it from merging. + +Our approach instead uses `gen2brain/webp` + `gen2brain/avif`, which compile libwebp and +libaom **to WASM** (run by wazero) — so it stays `CGO_ENABLED=0`, ships one static +binary, adds **AVIF** too, and is built + runtime-verified. Trade-off: WASM encode is +slower than native cgo libwebp, mitigated by the disk cache (encode once) and +pre-downsized originals. For a Docker-less, resource-tight Windows host that also wants +AVIF, pure-Go wins; native libwebp/libvips (or imgproxy) would only pull ahead on a big +Linux box optimizing purely for encode throughput. + +## The delta (all pure-Go, `CGO_ENABLED=0`) + +| File | Change | +|------|--------| +| `go.mod` | + `github.com/gen2brain/webp`, `github.com/gen2brain/avif` (libwebp/libaom via WASM/wazero — **no cgo**); + `github.com/kardianos/service` (pure-Go OS-service support) | +| `data.go` | `webp`/`avif` registered as parseable `format` options (`optFormatWEBP`, `optFormatAVIF`) | +| `transform.go` | `webp` + `avif` cases in the encode switch; `contentTypeForFormat()` helper; default qualities (WebP 80, AVIF 55, AVIF speed 8) | +| `imageproxy.go` | replay path sets an explicit `Content-Type` for known output formats — **required for AVIF**, because Go's `http.DetectContentType` has no AVIF signature and would mislabel it `application/octet-stream` (→ 403 under the default `image/*` content-type filter) | +| `cmd/imageproxy/service.go` *(new)* | native OS-service support: `-service install\|uninstall\|start\|stop\|restart` (Windows SCM / systemd / launchd — **no wrapper needed**; nssm still works as an external wrapper too) + `-logFile`. Foreground behaviour unchanged. | +| `cmd/imageproxy/main.go` | tail refactor: build the `http.Server`, then `runWithService(server)` instead of `server.Serve` directly (so it runs under a service manager or foreground). | + +Nothing else changes: URL scheme, HMAC signing (`s` option), `allowHosts`, caching, +metrics, and all existing formats behave exactly as upstream. + +## Deploying +Full Windows + Linux deployment guide (build, service via NSSM/systemd, IIS/nginx/Caddy +reverse proxy, caching, signing, troubleshooting): **[DEPLOY.md](DEPLOY.md)**. + +## Build (single binary, any OS) +```bash +go mod tidy +CGO_ENABLED=0 go build -ldflags "-s -w" -o imageproxy . # Linux +# Windows: +# $env:CGO_ENABLED=0; go build -ldflags "-s -w" -o imageproxy.exe ./cmd/imageproxy +``` +The binary is ~48 MB (embeds the libwebp + libaom WASM blobs). No runtime deps. + +## Verified +Built with `CGO_ENABLED=0` and smoke-tested end-to-end (codercat.jpg @300px): + +| option | Content-Type | bytes | +|--------|--------------|-------| +| `300,fit,avif` | `image/avif` | 3 860 | +| `300,fit,webp` | `image/webp` | 5 634 | +| `300,fit` (default) | `image/jpeg` | 18 362 | +| *(original)* | — | 28 863 | + +## Usage (new options) +``` +/{width},fit,webp/{base64url-or-plain remote URL} → WebP +/{width},fit,avif,q55/{...} → AVIF at quality 55 +``` +Distinct URL per format → CDN-cache-correct; the site emits a `` so the +browser picks AVIF → WebP → JPEG. (imageproxy does **not** negotiate format from the +`Accept` header — that's intentional; its result cache is keyed on the URL only.) + +## Keeping in sync with upstream +The delta is small and localized. To rebase on a newer upstream: re-apply the four +files above (the encode switch, the two option constants + parse case, the +`contentTypeForFormat` header fix, and the two go.mod requires). Watch for changes to +`transform.go`'s format switch and `imageproxy.go`'s response-replay block. diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 000000000..a520557f1 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,37 @@ +<# + build.ps1 - build the Windows imageproxy binary into build\imageproxy.exe. + Pure Go, no cgo. Requires Go 1.25.8+ on PATH. Run from the repo root. +#> +$ErrorActionPreference = 'Stop' +# Let explicit $LASTEXITCODE checks govern native (go) failures instead of PS 7.4+ +# auto-throwing on any non-zero exit. Harmless on PS 5.1. +$PSNativeCommandUseErrorActionPreference = $false +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location $here + +if (-not (Get-Command go -ErrorAction SilentlyContinue)) { + throw "Go toolchain not found on PATH. Install Go 1.25.8+ (https://go.dev/dl/)." +} + +New-Item -ItemType Directory -Force (Join-Path $here 'build') | Out-Null + +go mod download +$env:CGO_ENABLED = '0' +$env:GOOS = 'windows' +$env:GOARCH = 'amd64' +go build -ldflags "-s -w" -o build/imageproxy.exe ./cmd/imageproxy +if ($LASTEXITCODE -ne 0) { throw "go build failed ($LASTEXITCODE)." } + +Write-Host "Built build\imageproxy.exe" + +# Smoke test: start the fresh binary, poll /health-check, then stop that exact process. +$exe = Join-Path $here 'build\imageproxy.exe' +$proc = Start-Process -FilePath $exe -ArgumentList '-addr', '127.0.0.1:8099' -PassThru -WindowStyle Hidden +$ok = $null +foreach ($i in 1..20) { + Start-Sleep -Milliseconds 300 + try { $ok = (Invoke-WebRequest -UseBasicParsing -TimeoutSec 2 'http://127.0.0.1:8099/health-check').Content; break } catch { } +} +if ($proc -and -not $proc.HasExited) { $proc | Stop-Process -Force } +if ($ok -eq 'OK') { Write-Host "Smoke test: health-check -> OK" } +else { Write-Warning "Smoke test did not confirm health-check (binary built OK regardless)." } diff --git a/build/README.md b/build/README.md new file mode 100644 index 000000000..f1d578563 --- /dev/null +++ b/build/README.md @@ -0,0 +1,42 @@ +# imageproxy — Windows deploy bundle + +Copy this folder to the server (e.g. `C:\svc\imageproxy\`) and follow the checklist. +`imageproxy.exe` itself is **not** committed to git (built artifact) — produce it with +`..\build.ps1`. Full reference: [../DEPLOY.md](../DEPLOY.md). + +## Contents +| File | Purpose | +|------|---------| +| `imageproxy.exe` | the resizer binary (built by `..\build.ps1`; not in git) | +| `install-service.ps1` | install as a Windows service — **native** (default) or **`-Method nssm`**; edit CONFIG first | +| `uninstall-service.ps1` | stop + remove the service (works for either method) | +| `run.ps1` | run in the foreground for testing | +| `config.example.env` | env-var alternative to CLI flags | + +> **Two install methods, one script.** Default is **native** — the binary self-registers +> with the Windows SCM (no third-party tool). If you have `nssm.exe`, run +> `install-service.ps1 -Method nssm` instead. Both produce a real service in +> `services.msc`; `uninstall-service.ps1` removes either. + +## Deploy checklist (Windows) +1. **Build** the exe (on a machine with Go): from the repo root run `.\build.ps1` + → produces `build\imageproxy.exe`. +2. **Copy** this `build\` folder to the server, e.g. `C:\svc\imageproxy\`. +3. **Edit** `install-service.ps1` → the CONFIG block (cache dir, allowHosts, port, optional key). +4. **Install** (elevated PowerShell) — pick one: + - native (default): `powershell -ExecutionPolicy Bypass -File .\install-service.ps1` + - nssm: `powershell -ExecutionPolicy Bypass -File .\install-service.ps1 -Method nssm` + → registers + starts the service with auto-start + crash-restart. +5. **Smoke test:** `curl.exe http://127.0.0.1:8080/health-check` → `OK` (logs: `imageproxy.log`) +6. **IIS:** add the reverse-proxy rule (needs URL Rewrite + ARR, proxy enabled): + ```xml + + + + + ``` +7. **Build env:** set `IMAGE_RESIZER=imageproxy` so the site emits `/img/...` URLs. +8. **Verify live:** `curl.exe -I "https://luatsumienbac.vn/img/800x,avif,q55/"` + → `200`, `Content-Type: image/avif`. + +Linux deployment (systemd + nginx/Caddy) is in [../DEPLOY.md](../DEPLOY.md). diff --git a/build/config.example.env b/build/config.example.env new file mode 100644 index 000000000..5bcb9b924 --- /dev/null +++ b/build/config.example.env @@ -0,0 +1,18 @@ +# imageproxy environment config (alternative to CLI flags). +# Every flag maps to IMAGEPROXY_ (upper-cased). Use these with NSSM +# (nssm set imageproxy AppEnvironmentExtra "IMAGEPROXY_CACHE=...") or a Linux +# systemd EnvironmentFile. CLI flags in install-service.ps1 do the same thing - +# pick one approach, not both. + +IMAGEPROXY_ADDR=127.0.0.1:8080 +IMAGEPROXY_ALLOWHOSTS=luatsumienbac.vn +# Readable, SEO-friendly URLs: with a baseURL set, /img/880x,avif/ resolves to +# /, so Astro emits the bare filename instead of a base64 URL. +IMAGEPROXY_BASEURL=https://luatsumienbac.vn/media/ +IMAGEPROXY_CACHE=D:/imgcache/luatsumienbac +IMAGEPROXY_TIMEOUT=20s + +# Optional HMAC signing. If you set this, also set IMAGEPROXY_SIGNATURE_KEY +# (note the underscore) to the SAME value in the Astro BUILD env so emitted URLs +# are signed. Leave unset to run unsigned (allowHosts still prevents abuse). +# IMAGEPROXY_SIGNATUREKEY= diff --git a/build/install-service.ps1 b/build/install-service.ps1 new file mode 100644 index 000000000..e67495bee --- /dev/null +++ b/build/install-service.ps1 @@ -0,0 +1,119 @@ +<# + install-service.ps1 - install imageproxy as a Windows service. Two methods: + + .\install-service.ps1 # NATIVE (default): the binary self-registers + # with the SCM via `-service install` (no deps) + .\install-service.ps1 -Method nssm # via NSSM (nssm.exe on PATH, or pass -NssmPath) + .\install-service.ps1 -Method nssm -NssmPath 'C:\tools\nssm\nssm.exe' + + Run from an ELEVATED PowerShell in this folder, after editing the CONFIG block. + Compatible with Windows PowerShell 5.1 and PowerShell 7+. + Tip: a service path WITHOUT spaces (e.g. C:\svc\imageproxy) avoids all quoting edge cases. +#> +param( + [ValidateSet('native', 'nssm')] + [string]$Method = 'native', + # Full path to nssm.exe (only for -Method nssm). If omitted, nssm must be on PATH. + [string]$NssmPath = '' +) +$ErrorActionPreference = 'Stop' +# In PowerShell 7.4+ a native command's non-zero exit throws under 'Stop'. Several calls +# below are expected to "fail" harmlessly (e.g. stopping a not-yet-installed service), so +# opt out and check $LASTEXITCODE explicitly where it matters. Harmless on PS 5.1. +$PSNativeCommandUseErrorActionPreference = $false + +# -- CONFIG (edit these) ----------------------------------------------------- +$ServiceName = 'imageproxy' +$Addr = '127.0.0.1:8080' # loopback: only IIS reaches it +$AllowHosts = 'luatsumienbac.vn' # lock the source origin +$BaseURL = 'https://luatsumienbac.vn/media/' # readable URLs: /img/880x,avif/ resolves here +$CacheDir = 'D:/imgcache/luatsumienbac' # NO spaces: nssm AppParameters stores args unquoted, so a spaced path truncates -cache and drops every flag after it (incl. -baseURL). Cache is scratch; any writable no-space path works. +$Timeout = '20s' +$SignatureKey = '' # '' = unsigned (allowHosts still protects) +# ---------------------------------------------------------------------------- + +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$exe = Join-Path $here 'imageproxy.exe' +if (-not (Test-Path $exe)) { + throw "imageproxy.exe not found next to this script. Build it first: ..\build.ps1 (or see ..\DEPLOY.md)." +} + +$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() + ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +if (-not $isAdmin) { throw "Run this in an ELEVATED PowerShell (Administrator)." } + +$logPath = Join-Path $here 'imageproxy.log' +# Runtime flags as an ARRAY. NOTE: nssm stores AppParameters UNQUOTED, so any value +# containing a space truncates that flag and Go's flag parser drops EVERY flag after +# it (this silently killed -baseURL once). Keep all values space-free; asserted below. +$svcArgs = @('-addr', $Addr, '-allowHosts', $AllowHosts, '-cache', $CacheDir, '-timeout', $Timeout, '-logFile', $logPath) +if ($BaseURL -ne '') { $svcArgs += @('-baseURL', $BaseURL) } +if ($SignatureKey -ne '') { $svcArgs += @('-signatureKey', $SignatureKey) } + +if ($Method -eq 'nssm') { + $spaced = $svcArgs | Where-Object { $_ -match '\s' } + if ($spaced) { + throw ("nssm cannot store arguments containing spaces (they get truncated, dropping " + + "every flag after them). Use space-free values (e.g. `$CacheDir = 'D:/imgcache/luatsumienbac' " + + "and install this script under a path without spaces). Offending: " + ($spaced -join ' | ')) + } +} + +# Remove any existing service first (works no matter how it was installed). +if (Get-Service $ServiceName -ErrorAction SilentlyContinue) { + Write-Host "Removing existing '$ServiceName' service ..." + & sc.exe stop $ServiceName 2>$null | Out-Null + Start-Sleep -Milliseconds 600 + & sc.exe delete $ServiceName 2>$null | Out-Null + Start-Sleep -Milliseconds 600 +} + +if ($Method -eq 'nssm') { + if ($NssmPath -ne '') { + if (-not (Test-Path $NssmPath)) { throw "NssmPath not found: $NssmPath" } + $nssm = (Resolve-Path $NssmPath).Path + } + else { + $nssmCmd = Get-Command nssm -ErrorAction SilentlyContinue + if (-not $nssmCmd) { + throw "nssm not found on PATH. Pass -NssmPath 'C:\tools\nssm\nssm.exe', install it (choco/scoop), or omit -Method for the native install." + } + $nssm = $nssmCmd.Source + } + + Write-Host "Installing service '$ServiceName' via NSSM ..." + # `install ` - PS quotes each array element; nssm stores them + # as AppParameters with correct quoting (handles the spaced/& path). + & $nssm install $ServiceName $exe @svcArgs + if ($LASTEXITCODE -ne 0) { throw "nssm install failed ($LASTEXITCODE)." } + & $nssm set $ServiceName AppDirectory $here | Out-Null + & $nssm set $ServiceName AppStderr (Join-Path $here 'err.log') | Out-Null # crash backstop; normal logs -> -logFile + & $nssm set $ServiceName AppExit Default Restart | Out-Null + & $nssm set $ServiceName Start SERVICE_AUTO_START | Out-Null + & $nssm start $ServiceName | Out-Null +} +else { + Write-Host "Installing service '$ServiceName' (native SCM, no nssm) ..." + & $exe -service install @svcArgs + if ($LASTEXITCODE -ne 0) { throw "install failed ($LASTEXITCODE)." } + # Native crash recovery + auto-start via built-in sc.exe. + & sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/5000/restart/5000 | Out-Null + & sc.exe config $ServiceName start= auto | Out-Null + & $exe -service start +} + +# Confirm it came up. +Start-Sleep -Seconds 1 +$svc = Get-Service $ServiceName -ErrorAction SilentlyContinue +if (-not $svc) { throw "Service '$ServiceName' was not created - see output above." } +if ($svc.Status -ne 'Running') { + Write-Warning "Service '$ServiceName' is '$($svc.Status)', not Running. Check logs: $logPath (and Event Viewer)." +} + +Write-Host "" +Write-Host "Installed '$ServiceName' via '$Method' - a real Windows service (services.msc). Status: $($svc.Status)" +Write-Host " Query : sc.exe query $ServiceName" +Write-Host " Logs : $logPath" +Write-Host " Test : curl.exe http://$Addr/health-check # -> OK" +Write-Host "Uninstall (either method): .\uninstall-service.ps1" +Write-Host "Next: add the IIS /img rule + set IMAGE_RESIZER=imageproxy (see ..\DEPLOY.md)." diff --git a/build/run.ps1 b/build/run.ps1 new file mode 100644 index 000000000..5ad7e8c13 --- /dev/null +++ b/build/run.ps1 @@ -0,0 +1,15 @@ +<# + run.ps1 - run imageproxy in the FOREGROUND for testing (Ctrl+C to stop). + Same config as the service; edit to taste. For production use install-service.ps1. +#> +$ErrorActionPreference = 'Stop' +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$exe = Join-Path $here 'imageproxy.exe' +if (-not (Test-Path $exe)) { throw "imageproxy.exe not found. Build it first (..\build.ps1 or ..\DEPLOY.md)." } + +& $exe -addr 127.0.0.1:8080 ` + -allowHosts luatsumienbac.vn ` + -baseURL https://luatsumienbac.vn/media/ ` + -cache D:/imgcache/luatsumienbac ` + -timeout 20s ` + -verbose diff --git a/build/uninstall-service.ps1 b/build/uninstall-service.ps1 new file mode 100644 index 000000000..0749ed269 --- /dev/null +++ b/build/uninstall-service.ps1 @@ -0,0 +1,27 @@ +<# + uninstall-service.ps1 - stop and remove the imageproxy Windows service. + Works for BOTH install methods (native self-register and nssm): sc.exe removes a + service by name regardless of how it was created, and deleting the service key also + clears any nssm parameters under it. Run from an ELEVATED PowerShell. +#> +$ErrorActionPreference = 'Stop' +# PS 7.4+ throws on a native non-zero exit under 'Stop'; sc.exe stop on an already-stopped +# service exits non-zero, which is fine here. Opt out (harmless on PS 5.1). +$PSNativeCommandUseErrorActionPreference = $false +$ServiceName = 'imageproxy' + +$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() + ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +if (-not $isAdmin) { throw "Run this in an ELEVATED PowerShell (Administrator)." } + +if (-not (Get-Service $ServiceName -ErrorAction SilentlyContinue)) { + Write-Host "Service '$ServiceName' not found - nothing to do." + return +} + +& sc.exe stop $ServiceName 2>$null | Out-Null +Start-Sleep -Milliseconds 500 +& sc.exe delete $ServiceName + +Write-Host "Removed service '$ServiceName' (works for native and nssm installs)." +Write-Host "(Cache dir and logs are left in place; nssm.exe, if used, remains on disk.)" diff --git a/cmd/imageproxy/main.go b/cmd/imageproxy/main.go index a04296ff7..0c9c06440 100644 --- a/cmd/imageproxy/main.go +++ b/cmd/imageproxy/main.go @@ -8,7 +8,6 @@ import ( "flag" "fmt" "log" - "net" "net/http" "net/url" "os" @@ -100,18 +99,6 @@ func main() { p.MinimumCacheDuration = *minCacheDuration p.ForceCache = *forceCache - var ln net.Listener - var err error - - if path, ok := strings.CutPrefix(*addr, "unix:"); ok { - ln, err = net.Listen("unix", path) - } else { - ln, err = net.Listen("tcp", *addr) - } - if err != nil { - log.Fatalf("listen failed: %v", err) - } - server := &http.Server{ Addr: *addr, Handler: p, @@ -121,8 +108,9 @@ func main() { IdleTimeout: 120 * time.Second, } - fmt.Printf("imageproxy listening on %s\n", *addr) - log.Fatal(server.Serve(ln)) + // Run as a native OS service (Windows SCM / systemd) when managed by the service + // manager, or in the foreground when run interactively. See service.go. + runWithService(server) } type signatureKeyList [][]byte diff --git a/cmd/imageproxy/service.go b/cmd/imageproxy/service.go new file mode 100644 index 000000000..f4c61c592 --- /dev/null +++ b/cmd/imageproxy/service.go @@ -0,0 +1,119 @@ +// Copyright 2013 The imageproxy authors. +// SPDX-License-Identifier: Apache-2.0 + +// Native OS-service support (fork addition). Lets the single imageproxy binary +// install/run itself as a Windows service (via the SCM — no nssm or other wrapper), +// a Linux systemd unit, or a macOS launchd job, and run in the foreground otherwise. +package main + +import ( + "context" + "flag" + "fmt" + "log" + "net" + "net/http" + "os" + "strings" + "time" + + "github.com/kardianos/service" +) + +var svcAction = flag.String("service", "", "control the service and exit: install | uninstall | start | stop | restart") +var logFile = flag.String("logFile", "", "append logs to this file (recommended when running as a service, whose stdout is discarded)") + +// program adapts the HTTP server to the service.Interface lifecycle. +type program struct { + server *http.Server + ln net.Listener +} + +// Start is non-blocking (required by service.Interface): it binds the listener and +// serves in a goroutine. Binding here (not at install time) means `-service install` +// never needs the port free. +func (p *program) Start(s service.Service) error { + var err error + if path, ok := strings.CutPrefix(p.server.Addr, "unix:"); ok { + p.ln, err = net.Listen("unix", path) + } else { + p.ln, err = net.Listen("tcp", p.server.Addr) + } + if err != nil { + return err + } + log.Printf("imageproxy listening on %s", p.server.Addr) + go func() { + if err := p.server.Serve(p.ln); err != nil && err != http.ErrServerClosed { + log.Printf("serve error: %v", err) + } + }() + return nil +} + +// Stop gracefully drains in-flight requests. +func (p *program) Stop(s service.Service) error { + if p.ln == nil { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return p.server.Shutdown(ctx) +} + +// serviceArgs returns the process args with the -service control flag (and its value) +// removed, so the installed service's registered command line carries the same runtime +// flags minus the one-shot control action. +func serviceArgs() []string { + var out []string + args := os.Args[1:] + for i := 0; i < len(args); i++ { + a := args[i] + if a == "-service" || a == "--service" { + i++ // also skip its value + continue + } + if strings.HasPrefix(a, "-service=") || strings.HasPrefix(a, "--service=") { + continue + } + out = append(out, a) + } + return out +} + +// runWithService runs the server as a managed OS service when launched by the service +// manager, or in the foreground when run interactively. With -service it +// performs the control action (install/uninstall/start/stop/restart) and exits. +func runWithService(server *http.Server) { + if *logFile != "" { + f, err := os.OpenFile(*logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + log.Fatalf("cannot open -logFile %q: %v", *logFile, err) + } + log.SetOutput(f) + } + + prg := &program{server: server} + cfg := &service.Config{ + Name: "imageproxy", + DisplayName: "imageproxy (WebP/AVIF image resizer)", + Description: "On-the-fly image resizer for self-hosted /media (WebP/AVIF/JPEG).", + Arguments: serviceArgs(), + } + s, err := service.New(prg, cfg) + if err != nil { + log.Fatalf("service init: %v", err) + } + + if *svcAction != "" { + if err := service.Control(s, *svcAction); err != nil { + log.Fatalf("service %q failed: %v (valid actions: %v)", *svcAction, err, service.ControlAction) + } + fmt.Printf("service %s: ok\n", *svcAction) + return + } + + if err := s.Run(); err != nil { + log.Fatal(err) + } +} diff --git a/data.go b/data.go index 8b5765cc7..3fb7618b6 100644 --- a/data.go +++ b/data.go @@ -23,6 +23,8 @@ const ( optFormatJPEG = "jpeg" optFormatPNG = "png" optFormatTIFF = "tiff" + optFormatWEBP = "webp" + optFormatAVIF = "avif" optRotatePrefix = "r" optQualityPrefix = "q" optSignaturePrefix = "s" @@ -74,7 +76,8 @@ type Options struct { // will always be overwritten by the value of Proxy.ScaleUp. ScaleUp bool - // Desired image format. Valid values are "jpeg", "png", "tiff". + // Desired image format. Valid values are "jpeg", "png", "tiff", "webp", + // "avif". webp and avif are encoded via pure-Go WASM encoders (no cgo). Format string // Crop rectangle params @@ -262,6 +265,8 @@ func (o Options) transform() bool { // 100,fv,fh - 100 pixels square, flipped horizontal and vertical // 200x,q60 - 200 pixels wide, proportional height, 60% quality // 200x,png - 200 pixels wide, converted to PNG format +// 800,fit,webp - scale to fit 800px, encoded as WebP +// 800,fit,avif,q55 - scale to fit 800px, encoded as AVIF at quality 55 // cw100,ch100 - crop image to 100px square, starting at (0,0) // cx10,cy20,cw100,ch200 - crop image starting at (10,20) is 100px wide and 200px tall func ParseOptions(str string) Options { @@ -278,7 +283,7 @@ func ParseOptions(str string) Options { options.FlipHorizontal = true case opt == optScaleUp: // this option is intentionally not documented above options.ScaleUp = true - case opt == optFormatJPEG, opt == optFormatPNG, opt == optFormatTIFF: + case opt == optFormatJPEG, opt == optFormatPNG, opt == optFormatTIFF, opt == optFormatWEBP, opt == optFormatAVIF: options.Format = opt case opt == optSmartCrop: options.SmartCrop = true diff --git a/go.mod b/go.mod index 51fb8610b..76b002737 100644 --- a/go.mod +++ b/go.mod @@ -9,9 +9,12 @@ require ( github.com/die-net/lrucache v0.0.0-20220628165024-20a71bc65bf1 github.com/disintegration/imaging v1.6.2 github.com/fcjr/aia-transport-go v1.2.2 + github.com/gen2brain/avif v0.5.2 + github.com/gen2brain/webp v0.6.3 github.com/gomodule/redigo v1.9.2 github.com/google/uuid v1.6.0 github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 + github.com/kardianos/service v1.2.4 github.com/muesli/smartcrop v0.3.0 github.com/peterbourgon/diskv v0.0.0-20171120014656-2973218375c3 github.com/prometheus/client_golang v1.22.0 @@ -43,6 +46,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect github.com/dnaeon/go-vcr v1.2.0 // indirect + github.com/ebitengine/purego v0.10.1 // indirect github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -63,6 +67,7 @@ require ( github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect + github.com/tetratelabs/wazero v1.12.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect diff --git a/go.sum b/go.sum index ebec3230e..b47e0c663 100644 --- a/go.sum +++ b/go.sum @@ -68,6 +68,8 @@ github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1 github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= @@ -80,6 +82,10 @@ github.com/fcjr/aia-transport-go v1.2.2 h1:sIZqXcM+YhTd2BDtkV2OJaqbcIVcPv1oKru3V github.com/fcjr/aia-transport-go v1.2.2/go.mod h1:onSqSq3tGkM14WusDx7q9FTheS9R1KBtD+QBWI6zG/w= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/gen2brain/avif v0.5.2 h1:QCCtJK4bJaxn1yScts9QP4iwLemRl533iJ43V99DtGo= +github.com/gen2brain/avif v0.5.2/go.mod h1:QgrYqdVE9y40PCfArK9VakcMIpYeDYpZmCSLkW6C1n8= +github.com/gen2brain/webp v0.6.3 h1:DbXXCkiHN6zq2qIuTsPSZQhVi2VcQ0UPzKbKqKENfsQ= +github.com/gen2brain/webp v0.6.3/go.mod h1:iGWMaCSw7t3I/Cv9llzEKmpnR36S8lS8VL/ZVjxU0JE= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -117,6 +123,8 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2OgBk= +github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -162,6 +170,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= diff --git a/imageproxy.go b/imageproxy.go index 18df0b325..e9b77fdbf 100644 --- a/imageproxy.go +++ b/imageproxy.go @@ -649,6 +649,12 @@ func (t *TransformingTransport) RoundTrip(req *http.Request) (*http.Response, er }); err != nil { t.log("error copying headers: %v", err) } + // When the output format is known explicitly, set an accurate Content-Type so + // we don't fall back to Go's http.DetectContentType — which has no AVIF + // signature and would mislabel encoded AVIF as application/octet-stream. + if ct := contentTypeForFormat(opt.Format); ct != "" { + fmt.Fprintf(buf, "Content-Type: %s\n", ct) + } fmt.Fprintf(buf, "Content-Length: %d\n\n", len(img)) buf.Write(img) diff --git a/transform.go b/transform.go index a5e87d4b0..e0333caef 100644 --- a/transform.go +++ b/transform.go @@ -16,19 +16,32 @@ import ( "math" "github.com/disintegration/imaging" + "github.com/gen2brain/avif" // pure-Go AVIF encode (libaom via WASM, no cgo) + "github.com/gen2brain/webp" // pure-Go WebP encode (libwebp via WASM, no cgo) "github.com/muesli/smartcrop" "github.com/muesli/smartcrop/nfnt" "github.com/prometheus/client_golang/prometheus" "github.com/rwcarlsen/goexif/exif" "golang.org/x/image/bmp" // register bmp format "golang.org/x/image/tiff" // register tiff format - _ "golang.org/x/image/webp" // register webp format + _ "golang.org/x/image/webp" // register webp decode (lightweight; gen2brain handles encode) "willnorris.com/go/gifresize" ) // default compression quality of resized jpegs const defaultQuality = 95 +// default quality for the modern formats when the request omits q. WebP is on +// the same 0–100 scale as JPEG; AVIF's scale is more aggressive, so a lower +// number yields comparable perceived quality at a much smaller size. +const ( + defaultWebPQuality = 80 + defaultAVIFQuality = 55 + // AVIF encode speed 0(slow)–10(fast). 8 keeps CPU sane on modest servers; + // every variant is encoded once and then served from cache. + avifEncodeSpeed = 8 +) + // maximum distance into image to look for EXIF tags const maxExifSize = 1 << 20 @@ -121,6 +134,26 @@ func Transform(img []byte, opt Options) ([]byte, error) { if err != nil { return nil, err } + case "webp": + quality := opt.Quality + if quality == 0 { + quality = defaultWebPQuality + } + m = transformImage(m, opt) + err = webp.Encode(buf, m, webp.Options{Quality: quality, Method: 4}) + if err != nil { + return nil, err + } + case "avif": + quality := opt.Quality + if quality == 0 { + quality = defaultAVIFQuality + } + m = transformImage(m, opt) + err = avif.Encode(buf, m, avif.Options{Quality: quality, Speed: avifEncodeSpeed}) + if err != nil { + return nil, err + } default: return nil, fmt.Errorf("unsupported format: %v", format) } @@ -128,6 +161,27 @@ func Transform(img []byte, opt Options) ([]byte, error) { return buf.Bytes(), nil } +// contentTypeForFormat returns the MIME type for a known output format option, +// or "" when the format is empty/unknown (in which case the source +// Content-Type, or Go's content sniffer, is used). This is required for AVIF: +// Go's http.DetectContentType has no AVIF signature and would otherwise +// mislabel encoded AVIF as application/octet-stream. +func contentTypeForFormat(format string) string { + switch format { + case optFormatJPEG: + return "image/jpeg" + case optFormatPNG: + return "image/png" + case optFormatTIFF: + return "image/tiff" + case optFormatWEBP: + return "image/webp" + case optFormatAVIF: + return "image/avif" + } + return "" +} + // evaluateFloat interprets the option value f. If f is between 0 and 1, it is // interpreted as a percentage of max, otherwise it is treated as an absolute // value. If f is less than 0, 0 is returned.