Skip to content
Open
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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
.cache
./imageproxy
/imageproxy
/imageproxy.exe
*.exe
*.log
341 changes: 341 additions & 0 deletions DEPLOY.md

Large diffs are not rendered by default.

85 changes: 85 additions & 0 deletions FORK_NOTES.md
Original file line number Diff line number Diff line change
@@ -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 `<picture>` 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.
37 changes: 37 additions & 0 deletions build.ps1
Original file line number Diff line number Diff line change
@@ -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)." }
42 changes: 42 additions & 0 deletions build/README.md
Original file line number Diff line number Diff line change
@@ -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
<rule name="ImageProxy" stopProcessing="true">
<match url="^img/(.*)" />
<action type="Rewrite" url="http://localhost:8080/{R:1}" />
</rule>
```
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/<b64url-of-a-/media-file>"`
→ `200`, `Content-Type: image/avif`.

Linux deployment (systemd + nginx/Caddy) is in [../DEPLOY.md](../DEPLOY.md).
18 changes: 18 additions & 0 deletions build/config.example.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# imageproxy environment config (alternative to CLI flags).
# Every flag maps to IMAGEPROXY_<FLAG> (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/<file> resolves to
# <baseURL>/<file>, 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=<hex-from: openssl rand -hex 32>
119 changes: 119 additions & 0 deletions build/install-service.ps1
Original file line number Diff line number Diff line change
@@ -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/<file> 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 <name> <program> <args...>` - 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)."
15 changes: 15 additions & 0 deletions build/run.ps1
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions build/uninstall-service.ps1
Original file line number Diff line number Diff line change
@@ -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.)"
18 changes: 3 additions & 15 deletions cmd/imageproxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"flag"
"fmt"
"log"
"net"
"net/http"
"net/url"
"os"
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
Loading