From 0d859d9df6dda5f4829e971b6b6437c2a6862a64 Mon Sep 17 00:00:00 2001 From: trungnsbkvn Date: Wed, 1 Jul 2026 19:26:15 +0700 Subject: [PATCH 01/13] feat: add WebP + AVIF output encoding (pure-Go, no cgo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream imageproxy decodes WebP but only encodes JPEG/PNG/GIF/TIFF/BMP (issue #114). This fork adds WebP + AVIF encoders via gen2brain/webp and gen2brain/avif (libwebp/libaom compiled to WASM, run by wazero) so the single static binary — built with CGO_ENABLED=0, native Windows — can emit modern formats for luatsumienbac.vn's self-hosted /media resizer. - data.go: register webp/avif as parseable format options - transform.go: webp/avif encode cases + contentTypeForFormat() + defaults - imageproxy.go: set explicit Content-Type for known formats (Go's http.DetectContentType has no AVIF signature -> would 403 as octet-stream) - go.mod: + gen2brain/webp, gen2brain/avif (pure-Go WASM, no cgo) Verified: CGO_ENABLED=0 build + runtime smoke test emits valid image/avif (ftypavif) and image/webp (RIFF/WEBP). Co-Authored-By: Claude Opus 4.8 --- .gitignore | 4 +++- FORK_NOTES.md | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++ data.go | 9 ++++++-- go.mod | 4 ++++ go.sum | 8 +++++++ imageproxy.go | 6 +++++ transform.go | 56 +++++++++++++++++++++++++++++++++++++++++++++- 7 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 FORK_NOTES.md diff --git a/.gitignore b/.gitignore index 85ca5df2b..e3a988730 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .cache -./imageproxy +/imageproxy +/imageproxy.exe +*.exe diff --git a/FORK_NOTES.md b/FORK_NOTES.md new file mode 100644 index 000000000..ffaef09fc --- /dev/null +++ b/FORK_NOTES.md @@ -0,0 +1,62 @@ +# 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. + +## 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**) | +| `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) | + +Nothing else changes: URL scheme, HMAC signing (`s` option), `allowHosts`, caching, +metrics, and all existing formats behave exactly as upstream. + +## 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/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..045932a01 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,8 @@ 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 @@ -43,6 +45,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 +66,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..d4eaa74f0 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= @@ -162,6 +168,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. From aff1a16b0ec4158d031b8500a7e329f80192ec30 Mon Sep 17 00:00:00 2001 From: trungnsbkvn Date: Wed, 1 Jul 2026 20:32:12 +0700 Subject: [PATCH 02/13] docs: add careful Windows + Linux deployment guide (DEPLOY.md) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build (native + cross-compile), run as a service (NSSM on Windows, systemd on Linux with hardening), reverse proxy (IIS URL Rewrite+ARR, nginx, Caddy — all stripping /img), disk + Cloudflare caching, optional HMAC signing, and a troubleshooting table. Flags/behaviour verified against cmd/imageproxy. Co-Authored-By: Claude Opus 4.8 --- DEPLOY.md | 288 ++++++++++++++++++++++++++++++++++++++++++++++++++ FORK_NOTES.md | 4 + 2 files changed, 292 insertions(+) create mode 100644 DEPLOY.md diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 000000000..c84ddf97b --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,288 @@ +# 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. | + +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 — via [NSSM](https://nssm.cc/) +A console app isn't a native Windows service, so wrap it with NSSM (Non-Sucking +Service Manager). Put the binary somewhere stable, e.g. `C:\svc\imageproxy\`. + +```powershell +# Install +nssm install imageproxy "C:\svc\imageproxy\imageproxy.exe" +nssm set imageproxy AppDirectory "C:\svc\imageproxy" + +# Arguments (or use AppEnvironmentExtra with IMAGEPROXY_* instead) +nssm set imageproxy AppParameters "-addr 127.0.0.1:8080 -allowHosts luatsumienbac.vn -cache C:/media/luatsumienbac/_imgcache -timeout 20s" + +# Recommended: restart on crash, log to files +nssm set imageproxy AppStdout "C:\svc\imageproxy\out.log" +nssm set imageproxy AppStderr "C:\svc\imageproxy\err.log" +nssm set imageproxy AppExit Default Restart +nssm set imageproxy Start SERVICE_AUTO_START + +nssm start imageproxy +nssm status imageproxy +``` +Update later with `nssm restart imageproxy`; remove with `nssm remove imageproxy confirm`. + +> **Cache path on Windows:** both `C:/media/.../_imgcache` and `C:\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. + +### 4b. Linux — via systemd +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: nssm restart imageproxy 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`. | + +Enable `-verbose` during bring-up to log every fetch/transform and the served-from-cache +flag. diff --git a/FORK_NOTES.md b/FORK_NOTES.md index ffaef09fc..a5c5d1278 100644 --- a/FORK_NOTES.md +++ b/FORK_NOTES.md @@ -27,6 +27,10 @@ without touching the fetch/cache/sign/allowlist machinery. 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 From d393f4d2127845f0dc0b27f21c228922cab015d2 Mon Sep 17 00:00:00 2001 From: trungnsbkvn Date: Thu, 2 Jul 2026 10:59:34 +0700 Subject: [PATCH 03/13] build: Windows deploy bundle (build.ps1 + build/ service scripts) - build.ps1: build build/imageproxy.exe (CGO_ENABLED=0, windows/amd64) + smoke test - build/install-service.ps1, uninstall-service.ps1: NSSM service (PS 5.1+ compatible) - build/run.ps1: foreground run for testing - build/config.example.env: IMAGEPROXY_* env alternative to flags - build/README.md: copy-to-server deploy checklist -> DEPLOY.md - .gitignore: ignore built *.exe and *.log (scripts are tracked) Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + build.ps1 | 27 +++++++++++++++++++ build/README.md | 36 +++++++++++++++++++++++++ build/config.example.env | 15 +++++++++++ build/install-service.ps1 | 52 +++++++++++++++++++++++++++++++++++++ build/run.ps1 | 14 ++++++++++ build/uninstall-service.ps1 | 14 ++++++++++ 7 files changed, 159 insertions(+) create mode 100644 build.ps1 create mode 100644 build/README.md create mode 100644 build/config.example.env create mode 100644 build/install-service.ps1 create mode 100644 build/run.ps1 create mode 100644 build/uninstall-service.ps1 diff --git a/.gitignore b/.gitignore index e3a988730..5000abcfe 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /imageproxy /imageproxy.exe *.exe +*.log diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 000000000..32fd5455e --- /dev/null +++ b/build.ps1 @@ -0,0 +1,27 @@ +<# + 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' +$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" +& (Join-Path $here 'build\imageproxy.exe') -addr 127.0.0.1:8099 & +Start-Sleep -Milliseconds 800 +try { $ok = (Invoke-WebRequest -UseBasicParsing http://127.0.0.1:8099/health-check).Content } catch { $ok = "(smoke test skipped)" } +Get-Process imageproxy -ErrorAction SilentlyContinue | Stop-Process -Force +Write-Host "Health check: $ok" diff --git a/build/README.md b/build/README.md new file mode 100644 index 000000000..e02373089 --- /dev/null +++ b/build/README.md @@ -0,0 +1,36 @@ +# 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 via NSSM (edit CONFIG block first) | +| `uninstall-service.ps1` | stop + remove the service | +| `run.ps1` | run in the foreground for testing | +| `config.example.env` | env-var alternative to CLI flags | + +## 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. **Install NSSM** if needed: `choco install nssm` (or `scoop install nssm`, or nssm.cc). +4. **Edit** `install-service.ps1` → the CONFIG block (cache dir, allowHosts, port, optional key). +5. **Install** (elevated PowerShell): + `powershell -ExecutionPolicy Bypass -File .\install-service.ps1` +6. **Smoke test:** `curl.exe http://127.0.0.1:8080/health-check` → `OK` +7. **IIS:** add the reverse-proxy rule (needs URL Rewrite + ARR, proxy enabled): + ```xml + + + + + ``` +8. **Build env:** set `IMAGE_RESIZER=imageproxy` so the site emits `/img/...` URLs. +9. **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..a03c1035a --- /dev/null +++ b/build/config.example.env @@ -0,0 +1,15 @@ +# 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 +IMAGEPROXY_CACHE=D:/media/luatsumienbac/_imgcache +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..7284c9651 --- /dev/null +++ b/build/install-service.ps1 @@ -0,0 +1,52 @@ +<# + install-service.ps1 — install imageproxy as a Windows service via NSSM. + + Run from an ELEVATED PowerShell in this folder, after editing the CONFIG block. + Requires nssm on PATH (https://nssm.cc/ , or: choco install nssm / scoop install nssm). + Compatible with Windows PowerShell 5.1 and PowerShell 7+. +#> +$ErrorActionPreference = 'Stop' + +# ── CONFIG (edit these) ───────────────────────────────────────────────────── +$ServiceName = 'imageproxy' +$Addr = '127.0.0.1:8080' # loopback: only IIS reaches it +$AllowHosts = 'luatsumienbac.vn' # lock the source origin +$CacheDir = 'D:/media/luatsumienbac/_imgcache' # forward slashes are safest +$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)." +} + +$nssmCmd = Get-Command nssm -ErrorAction SilentlyContinue +if (-not $nssmCmd) { + throw "nssm not found on PATH. Install it (https://nssm.cc/ , choco install nssm, or scoop install nssm)." +} +$nssm = $nssmCmd.Source + +$params = "-addr $Addr -allowHosts $AllowHosts -cache $CacheDir -timeout $Timeout" +if ($SignatureKey -ne '') { $params = "$params -signatureKey $SignatureKey" } + +Write-Host "Installing service '$ServiceName' -> $exe" +& $nssm stop $ServiceName 2>$null | Out-Null +& $nssm remove $ServiceName confirm 2>$null | Out-Null +& $nssm install $ServiceName $exe +& $nssm set $ServiceName AppDirectory $here +& $nssm set $ServiceName AppParameters $params +& $nssm set $ServiceName AppStdout (Join-Path $here 'out.log') +& $nssm set $ServiceName AppStderr (Join-Path $here 'err.log') +& $nssm set $ServiceName AppRotateFiles 1 +& $nssm set $ServiceName AppRotateBytes 10485760 +& $nssm set $ServiceName AppExit Default Restart +& $nssm set $ServiceName Start SERVICE_AUTO_START +& $nssm start $ServiceName +& $nssm status $ServiceName + +Write-Host "" +Write-Host "Done. Params: $params" +Write-Host "Smoke test: curl.exe http://$Addr/health-check # -> OK" +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..23b250472 --- /dev/null +++ b/build/run.ps1 @@ -0,0 +1,14 @@ +<# + 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 ` + -cache D:/media/luatsumienbac/_imgcache ` + -timeout 20s ` + -verbose diff --git a/build/uninstall-service.ps1 b/build/uninstall-service.ps1 new file mode 100644 index 000000000..f9e525d67 --- /dev/null +++ b/build/uninstall-service.ps1 @@ -0,0 +1,14 @@ +<# + uninstall-service.ps1 — stop and remove the imageproxy Windows service (NSSM). + Run from an ELEVATED PowerShell. +#> +$ErrorActionPreference = 'Stop' +$ServiceName = 'imageproxy' + +$nssmCmd = Get-Command nssm -ErrorAction SilentlyContinue +if (-not $nssmCmd) { throw "nssm not found on PATH." } +$nssm = $nssmCmd.Source + +& $nssm stop $ServiceName 2>$null | Out-Null +& $nssm remove $ServiceName confirm +Write-Host "Removed service '$ServiceName'. (Cache dir and logs are left in place.)" From 4d533d3114fa6de9b98cbfd16e22bec7e6249a35 Mon Sep 17 00:00:00 2001 From: trungnsbkvn Date: Thu, 2 Jul 2026 11:03:16 +0700 Subject: [PATCH 04/13] docs: record why we don't use upstream WebP PR #393 (cgo/libwebp, WebP-only) #393 is unmerged, WebP-only, and cgo-bound to C libwebp (breaks the single-binary / native-Windows / no-deps goal). Our gen2brain WASM approach stays CGO_ENABLED=0 and adds AVIF. Trade-off: slower encode, mitigated by cache. Co-Authored-By: Claude Opus 4.8 --- FORK_NOTES.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/FORK_NOTES.md b/FORK_NOTES.md index a5c5d1278..25b7bed76 100644 --- a/FORK_NOTES.md +++ b/FORK_NOTES.md @@ -15,6 +15,23 @@ resource-tight). Upstream is the right base — pure Go, single binary, mature c 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 | From 47959ed923988232db9a42201d3136ebdddca436 Mon Sep 17 00:00:00 2001 From: trungnsbkvn Date: Thu, 2 Jul 2026 12:49:09 +0700 Subject: [PATCH 05/13] feat: native Windows service (no nssm) via kardianos/service nssm is an external dependency (and its site can be down). Make the single binary register ITSELF with the OS service manager instead: - cmd/imageproxy/service.go (new): -service install|uninstall|start|stop|restart (Windows SCM / systemd / launchd) + -logFile (service stdout is discarded). Foreground behaviour unchanged; graceful shutdown on Stop. - cmd/imageproxy/main.go: build the http.Server then runWithService(server). - go.mod: + github.com/kardianos/service (pure Go, keeps CGO_ENABLED=0). - build/install-service.ps1 + uninstall-service.ps1: rewritten to use the binary's -service (no nssm); install adds sc.exe crash-restart + auto-start. - DEPLOY.md / build/README.md / FORK_NOTES.md: nssm -> native service. Verified: CGO_ENABLED=0 windows build; foreground health-check OK; -service control wired (lists valid actions); -logFile captures startup + errors. Co-Authored-By: Claude Opus 4.8 --- DEPLOY.md | 64 +++++++++++-------- FORK_NOTES.md | 4 +- build/README.md | 20 +++--- build/install-service.ps1 | 57 ++++++++--------- build/uninstall-service.ps1 | 18 +++--- cmd/imageproxy/main.go | 18 +----- cmd/imageproxy/service.go | 119 ++++++++++++++++++++++++++++++++++++ go.mod | 1 + go.sum | 2 + 9 files changed, 217 insertions(+), 86 deletions(-) create mode 100644 cmd/imageproxy/service.go diff --git a/DEPLOY.md b/DEPLOY.md index c84ddf97b..89cb318be 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -88,6 +88,8 @@ the flag name upper-cased (via `envy`). Env vars are convenient for services. | `-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. @@ -99,37 +101,49 @@ Endpoints: `GET /health-check` → `OK`; `GET /metrics` → Prometheus. ## 4. Run as a service -### 4a. Windows — via [NSSM](https://nssm.cc/) -A console app isn't a native Windows service, so wrap it with NSSM (Non-Sucking -Service Manager). Put the binary somewhere stable, e.g. `C:\svc\imageproxy\`. +### 4a. Windows — native service (no nssm) +The binary registers **itself** with the Windows Service Control Manager — no NSSM or +other wrapper. Put it somewhere stable, e.g. `C:\svc\imageproxy\`, and from an +**elevated** PowerShell: ```powershell -# Install -nssm install imageproxy "C:\svc\imageproxy\imageproxy.exe" -nssm set imageproxy AppDirectory "C:\svc\imageproxy" - -# Arguments (or use AppEnvironmentExtra with IMAGEPROXY_* instead) -nssm set imageproxy AppParameters "-addr 127.0.0.1:8080 -allowHosts luatsumienbac.vn -cache C:/media/luatsumienbac/_imgcache -timeout 20s" - -# Recommended: restart on crash, log to files -nssm set imageproxy AppStdout "C:\svc\imageproxy\out.log" -nssm set imageproxy AppStderr "C:\svc\imageproxy\err.log" -nssm set imageproxy AppExit Default Restart -nssm set imageproxy Start SERVICE_AUTO_START - -nssm start imageproxy -nssm status imageproxy +$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 ``` -Update later with `nssm restart imageproxy`; remove with `nssm remove imageproxy confirm`. +Manage it with `imageproxy.exe -service stop|start|restart|uninstall` (or the usual +`sc.exe` / `services.msc`). The bundled **`build\install-service.ps1`** does all of the +above from a CONFIG block — prefer it. -> **Cache path on Windows:** both `C:/media/.../_imgcache` and `C:\media\...\_imgcache` +> **Logs:** a service's stdout is discarded by Windows, so pass `-logFile` (as above) to +> capture startup + errors. Start/stop/failure are also written to 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. +> 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 @@ -265,8 +279,8 @@ clear the imageproxy cache dir). New filenames need no purge. ```bash git pull # get new commits -go build -ldflags "-s -w" -o imageproxy ./cmd/imageproxy # rebuild -# Windows: nssm restart imageproxy Linux: sudo systemctl restart imageproxy +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). @@ -283,6 +297,8 @@ To rebase the fork on a newer upstream, re-apply the small delta in | 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. | +| 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. +flag. When running as a service, combine it with `-logFile` to capture that output. diff --git a/FORK_NOTES.md b/FORK_NOTES.md index 25b7bed76..74cb10b66 100644 --- a/FORK_NOTES.md +++ b/FORK_NOTES.md @@ -36,10 +36,12 @@ Linux box optimizing purely for encode throughput. | File | Change | |------|--------| -| `go.mod` | + `github.com/gen2brain/webp`, `github.com/gen2brain/avif` (libwebp/libaom via WASM/wazero — **no cgo**) | +| `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 nssm**) + `-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. diff --git a/build/README.md b/build/README.md index e02373089..385e4638f 100644 --- a/build/README.md +++ b/build/README.md @@ -8,29 +8,33 @@ Copy this folder to the server (e.g. `C:\svc\imageproxy\`) and follow the checkl | File | Purpose | |------|---------| | `imageproxy.exe` | the resizer binary (built by `..\build.ps1`; not in git) | -| `install-service.ps1` | install as a Windows service via NSSM (edit CONFIG block first) | +| `install-service.ps1` | install as a **native** Windows service (**no nssm**; edit CONFIG first) | | `uninstall-service.ps1` | stop + remove the service | | `run.ps1` | run in the foreground for testing | | `config.example.env` | env-var alternative to CLI flags | +> **No nssm required.** The binary registers itself with the Windows Service Control +> Manager via `imageproxy.exe -service install`, so it's a real service in `services.msc` +> with no third-party wrapper. + ## 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. **Install NSSM** if needed: `choco install nssm` (or `scoop install nssm`, or nssm.cc). -4. **Edit** `install-service.ps1` → the CONFIG block (cache dir, allowHosts, port, optional key). -5. **Install** (elevated PowerShell): +3. **Edit** `install-service.ps1` → the CONFIG block (cache dir, allowHosts, port, optional key). +4. **Install** (elevated PowerShell): `powershell -ExecutionPolicy Bypass -File .\install-service.ps1` -6. **Smoke test:** `curl.exe http://127.0.0.1:8080/health-check` → `OK` -7. **IIS:** add the reverse-proxy rule (needs URL Rewrite + ARR, proxy enabled): + → registers + starts the service, sets auto-start + crash-restart via `sc.exe`. +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 ``` -8. **Build env:** set `IMAGE_RESIZER=imageproxy` so the site emits `/img/...` URLs. -9. **Verify live:** `curl.exe -I "https://luatsumienbac.vn/img/800x,avif,q55/"` +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/install-service.ps1 b/build/install-service.ps1 index 7284c9651..ee1f2546e 100644 --- a/build/install-service.ps1 +++ b/build/install-service.ps1 @@ -1,14 +1,15 @@ <# - install-service.ps1 — install imageproxy as a Windows service via NSSM. + install-service.ps1 — install imageproxy as a NATIVE Windows service (no nssm). + The binary registers ITSELF with the Windows Service Control Manager via + `imageproxy.exe -service install`, so no third-party wrapper is needed. Run from an ELEVATED PowerShell in this folder, after editing the CONFIG block. - Requires nssm on PATH (https://nssm.cc/ , or: choco install nssm / scoop install nssm). Compatible with Windows PowerShell 5.1 and PowerShell 7+. #> $ErrorActionPreference = 'Stop' # ── CONFIG (edit these) ───────────────────────────────────────────────────── -$ServiceName = 'imageproxy' +$ServiceName = 'imageproxy' # the name the binary registers $Addr = '127.0.0.1:8080' # loopback: only IIS reaches it $AllowHosts = 'luatsumienbac.vn' # lock the source origin $CacheDir = 'D:/media/luatsumienbac/_imgcache' # forward slashes are safest @@ -22,31 +23,31 @@ if (-not (Test-Path $exe)) { throw "imageproxy.exe not found next to this script. Build it first: ..\build.ps1 (or see ..\DEPLOY.md)." } -$nssmCmd = Get-Command nssm -ErrorAction SilentlyContinue -if (-not $nssmCmd) { - throw "nssm not found on PATH. Install it (https://nssm.cc/ , choco install nssm, or scoop install nssm)." -} -$nssm = $nssmCmd.Source - -$params = "-addr $Addr -allowHosts $AllowHosts -cache $CacheDir -timeout $Timeout" -if ($SignatureKey -ne '') { $params = "$params -signatureKey $SignatureKey" } - -Write-Host "Installing service '$ServiceName' -> $exe" -& $nssm stop $ServiceName 2>$null | Out-Null -& $nssm remove $ServiceName confirm 2>$null | Out-Null -& $nssm install $ServiceName $exe -& $nssm set $ServiceName AppDirectory $here -& $nssm set $ServiceName AppParameters $params -& $nssm set $ServiceName AppStdout (Join-Path $here 'out.log') -& $nssm set $ServiceName AppStderr (Join-Path $here 'err.log') -& $nssm set $ServiceName AppRotateFiles 1 -& $nssm set $ServiceName AppRotateBytes 10485760 -& $nssm set $ServiceName AppExit Default Restart -& $nssm set $ServiceName Start SERVICE_AUTO_START -& $nssm start $ServiceName -& $nssm status $ServiceName +# Installing a service requires elevation. +$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' +$svcArgs = @('-addr', $Addr, '-allowHosts', $AllowHosts, '-cache', $CacheDir, '-timeout', $Timeout, '-logFile', $logPath) +if ($SignatureKey -ne '') { $svcArgs += @('-signatureKey', $SignatureKey) } + +# Remove any previous install (ignore errors if absent). +& $exe -service stop 2>$null | Out-Null +& $exe -service uninstall 2>$null | Out-Null + +Write-Host "Installing service '$ServiceName' (native SCM, no nssm) ..." +& $exe -service install @svcArgs +if ($LASTEXITCODE -ne 0) { throw "install failed ($LASTEXITCODE)." } + +# Native crash recovery via built-in sc.exe: restart after 5s, reset the fail count daily. +& sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/5000/restart/5000 | Out-Null +& sc.exe config $ServiceName start= auto | Out-Null # start at boot +& $exe -service start Write-Host "" -Write-Host "Done. Params: $params" -Write-Host "Smoke test: curl.exe http://$Addr/health-check # -> OK" +Write-Host "Installed + started '$ServiceName' — a real Windows service (services.msc)." +Write-Host " Status : sc.exe query $ServiceName" +Write-Host " Logs : $logPath" +Write-Host " Test : curl.exe http://$Addr/health-check # -> OK" Write-Host "Next: add the IIS /img rule + set IMAGE_RESIZER=imageproxy (see ..\DEPLOY.md)." diff --git a/build/uninstall-service.ps1 b/build/uninstall-service.ps1 index f9e525d67..77689bc61 100644 --- a/build/uninstall-service.ps1 +++ b/build/uninstall-service.ps1 @@ -1,14 +1,12 @@ <# - uninstall-service.ps1 — stop and remove the imageproxy Windows service (NSSM). - Run from an ELEVATED PowerShell. + uninstall-service.ps1 — stop and remove the imageproxy Windows service. + Uses the binary's own SCM integration (no nssm). Run from an ELEVATED PowerShell. #> $ErrorActionPreference = 'Stop' -$ServiceName = 'imageproxy' +$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." } -$nssmCmd = Get-Command nssm -ErrorAction SilentlyContinue -if (-not $nssmCmd) { throw "nssm not found on PATH." } -$nssm = $nssmCmd.Source - -& $nssm stop $ServiceName 2>$null | Out-Null -& $nssm remove $ServiceName confirm -Write-Host "Removed service '$ServiceName'. (Cache dir and logs are left in place.)" +& $exe -service stop 2>$null | Out-Null +& $exe -service uninstall +Write-Host "Removed service 'imageproxy'. (Cache dir and logs are left in place.)" 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/go.mod b/go.mod index 045932a01..76b002737 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( 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 diff --git a/go.sum b/go.sum index d4eaa74f0..b47e0c663 100644 --- a/go.sum +++ b/go.sum @@ -123,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= From 89abe41a30c14bad28a49bf5ab354b5067b02f50 Mon Sep 17 00:00:00 2001 From: trungnsbkvn Date: Thu, 2 Jul 2026 13:08:35 +0700 Subject: [PATCH 06/13] build: support BOTH native and nssm service install (-Method switch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nssm is available again, so keep it as an option alongside the native self-install (native stays the default — no dependency). - install-service.ps1: -Method native|nssm (default native). nssm branch quotes paths, sets AppExit Restart + auto-start; native branch unchanged. - uninstall-service.ps1: generic sc.exe stop+delete — removes either kind. - DEPLOY.md / build/README.md: document both methods; FORK_NOTES clarifies nssm still works as an external wrapper. Co-Authored-By: Claude Opus 4.8 --- DEPLOY.md | 32 +++++++++++++----- FORK_NOTES.md | 2 +- build/README.md | 18 +++++----- build/install-service.ps1 | 65 ++++++++++++++++++++++++++++--------- build/uninstall-service.ps1 | 26 +++++++++++---- 5 files changed, 103 insertions(+), 40 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index 89cb318be..a182ec207 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -101,10 +101,14 @@ Endpoints: `GET /health-check` → `OK`; `GET /metrics` → Prometheus. ## 4. Run as a service -### 4a. Windows — native service (no nssm) -The binary registers **itself** with the Windows Service Control Manager — no NSSM or -other wrapper. Put it somewhere stable, e.g. `C:\svc\imageproxy\`, and from an -**elevated** PowerShell: +### 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" @@ -125,11 +129,23 @@ sc.exe config imageproxy start= auto sc.exe query imageproxy # or services.msc ``` Manage it with `imageproxy.exe -service stop|start|restart|uninstall` (or the usual -`sc.exe` / `services.msc`). The bundled **`build\install-service.ps1`** does all of the -above from a CONFIG block — prefer it. +`sc.exe` / `services.msc`). + +**NSSM** (alternative) — if you have `nssm.exe` on PATH: +```powershell +$exe = "C:\svc\imageproxy\imageproxy.exe" +nssm install imageproxy $exe +nssm set imageproxy AppDirectory "C:\svc\imageproxy" +nssm set imageproxy AppParameters "-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 AppExit Default Restart +nssm set imageproxy Start SERVICE_AUTO_START +nssm start imageproxy +``` +Either way, remove it 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` (as above) to -> capture startup + errors. Start/stop/failure are also written to the Windows Event Log. +> **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 diff --git a/FORK_NOTES.md b/FORK_NOTES.md index 74cb10b66..b2038510c 100644 --- a/FORK_NOTES.md +++ b/FORK_NOTES.md @@ -40,7 +40,7 @@ Linux box optimizing purely for encode throughput. | `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 nssm**) + `-logFile`. Foreground behaviour unchanged. | +| `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, diff --git a/build/README.md b/build/README.md index 385e4638f..f1d578563 100644 --- a/build/README.md +++ b/build/README.md @@ -8,23 +8,25 @@ Copy this folder to the server (e.g. `C:\svc\imageproxy\`) and follow the checkl | File | Purpose | |------|---------| | `imageproxy.exe` | the resizer binary (built by `..\build.ps1`; not in git) | -| `install-service.ps1` | install as a **native** Windows service (**no nssm**; edit CONFIG first) | -| `uninstall-service.ps1` | stop + remove the service | +| `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 | -> **No nssm required.** The binary registers itself with the Windows Service Control -> Manager via `imageproxy.exe -service install`, so it's a real service in `services.msc` -> with no third-party wrapper. +> **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): - `powershell -ExecutionPolicy Bypass -File .\install-service.ps1` - → registers + starts the service, sets auto-start + crash-restart via `sc.exe`. +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 diff --git a/build/install-service.ps1 b/build/install-service.ps1 index ee1f2546e..b7a5b7ad6 100644 --- a/build/install-service.ps1 +++ b/build/install-service.ps1 @@ -1,15 +1,21 @@ <# - install-service.ps1 — install imageproxy as a NATIVE Windows service (no nssm). + 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 (requires nssm.exe on PATH) - The binary registers ITSELF with the Windows Service Control Manager via - `imageproxy.exe -service install`, so no third-party wrapper is needed. Run from an ELEVATED PowerShell in this folder, after editing the CONFIG block. Compatible with Windows PowerShell 5.1 and PowerShell 7+. #> +param( + [ValidateSet('native', 'nssm')] + [string]$Method = 'native' +) $ErrorActionPreference = 'Stop' # ── CONFIG (edit these) ───────────────────────────────────────────────────── -$ServiceName = 'imageproxy' # the name the binary registers +$ServiceName = 'imageproxy' $Addr = '127.0.0.1:8080' # loopback: only IIS reaches it $AllowHosts = 'luatsumienbac.vn' # lock the source origin $CacheDir = 'D:/media/luatsumienbac/_imgcache' # forward slashes are safest @@ -23,31 +29,58 @@ if (-not (Test-Path $exe)) { throw "imageproxy.exe not found next to this script. Build it first: ..\build.ps1 (or see ..\DEPLOY.md)." } -# Installing a service requires elevation. $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 shared by both methods (as an array — handles spaces in paths). $svcArgs = @('-addr', $Addr, '-allowHosts', $AllowHosts, '-cache', $CacheDir, '-timeout', $Timeout, '-logFile', $logPath) if ($SignatureKey -ne '') { $svcArgs += @('-signatureKey', $SignatureKey) } -# Remove any previous install (ignore errors if absent). -& $exe -service stop 2>$null | Out-Null -& $exe -service uninstall 2>$null | Out-Null +if ($Method -eq 'nssm') { + $nssmCmd = Get-Command nssm -ErrorAction SilentlyContinue + if (-not $nssmCmd) { + throw "nssm not found on PATH. Install it (nssm.cc / choco install nssm / scoop install nssm), or omit -Method to use the native method." + } + $nssm = $nssmCmd.Source + + # Quote paths so spaces in $CacheDir/$logPath survive as a single AppParameters string. + $nssmParams = "-addr $Addr -allowHosts $AllowHosts -cache `"$CacheDir`" -timeout $Timeout -logFile `"$logPath`"" + if ($SignatureKey -ne '') { $nssmParams += " -signatureKey $SignatureKey" } + + & $nssm stop $ServiceName 2>$null | Out-Null + & $nssm remove $ServiceName confirm 2>$null | Out-Null -Write-Host "Installing service '$ServiceName' (native SCM, no nssm) ..." -& $exe -service install @svcArgs -if ($LASTEXITCODE -ne 0) { throw "install failed ($LASTEXITCODE)." } + Write-Host "Installing service '$ServiceName' via NSSM ..." + & $nssm install $ServiceName $exe + & $nssm set $ServiceName AppDirectory $here + & $nssm set $ServiceName AppParameters $nssmParams + & $nssm set $ServiceName AppStderr (Join-Path $here 'err.log') # crash backstop; normal logs go to imageproxy.log + & $nssm set $ServiceName AppExit Default Restart + & $nssm set $ServiceName Start SERVICE_AUTO_START + & $nssm start $ServiceName + & $nssm status $ServiceName +} +else { + # NATIVE: the binary registers itself with the Windows SCM (no nssm). + & $exe -service stop 2>$null | Out-Null + & $exe -service uninstall 2>$null | Out-Null + + Write-Host "Installing service '$ServiceName' (native SCM, no nssm) ..." + & $exe -service install @svcArgs + if ($LASTEXITCODE -ne 0) { throw "install failed ($LASTEXITCODE)." } -# Native crash recovery via built-in sc.exe: restart after 5s, reset the fail count daily. -& sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/5000/restart/5000 | Out-Null -& sc.exe config $ServiceName start= auto | Out-Null # start at boot + # 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 +} -& $exe -service start Write-Host "" -Write-Host "Installed + started '$ServiceName' — a real Windows service (services.msc)." +Write-Host "Installed + started '$ServiceName' via '$Method' — a real Windows service (services.msc)." Write-Host " Status : 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/uninstall-service.ps1 b/build/uninstall-service.ps1 index 77689bc61..b09ac23d4 100644 --- a/build/uninstall-service.ps1 +++ b/build/uninstall-service.ps1 @@ -1,12 +1,24 @@ <# uninstall-service.ps1 — stop and remove the imageproxy Windows service. - Uses the binary's own SCM integration (no nssm). Run from an ELEVATED PowerShell. + 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' -$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." } +$ServiceName = 'imageproxy' -& $exe -service stop 2>$null | Out-Null -& $exe -service uninstall -Write-Host "Removed service 'imageproxy'. (Cache dir and logs are left in place.)" +$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.)" From 4b8c328aeb74e8b721df5e64d683c2a232efc056 Mon Sep 17 00:00:00 2001 From: trungnsbkvn Date: Thu, 2 Jul 2026 13:12:46 +0700 Subject: [PATCH 07/13] build: make build.ps1 smoke test reliable (Start-Process -PassThru + poll) The old trailing-& background job raced the health check ('(smoke test skipped)') and could leak the process. Now starts hidden with -PassThru, polls /health-check up to 6s, and stops that exact process. Co-Authored-By: Claude Opus 4.8 --- build.ps1 | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/build.ps1 b/build.ps1 index 32fd5455e..7dd82bad8 100644 --- a/build.ps1 +++ b/build.ps1 @@ -20,8 +20,15 @@ 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" -& (Join-Path $here 'build\imageproxy.exe') -addr 127.0.0.1:8099 & -Start-Sleep -Milliseconds 800 -try { $ok = (Invoke-WebRequest -UseBasicParsing http://127.0.0.1:8099/health-check).Content } catch { $ok = "(smoke test skipped)" } -Get-Process imageproxy -ErrorAction SilentlyContinue | Stop-Process -Force -Write-Host "Health check: $ok" + +# 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)." } From 20b64caa95b9f8817a96ccfcc8d10877d4955aa0 Mon Sep 17 00:00:00 2001 From: trungnsbkvn Date: Thu, 2 Jul 2026 13:24:51 +0700 Subject: [PATCH 08/13] docs: how to get nssm on the server + -NssmPath option - install-service.ps1: add optional -NssmPath so nssm need not be on PATH. - DEPLOY.md: acquisition (choco/scoop/manual zip -> win64\nssm.exe), by-hand configuration + what each nssm setting means, graceful-stop and run-as-account notes. Co-Authored-By: Claude Opus 4.8 --- DEPLOY.md | 26 ++++++++++++++++++++++---- build/install-service.ps1 | 18 +++++++++++++----- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index a182ec207..cc130bd59 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -131,17 +131,35 @@ 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) — if you have `nssm.exe` on PATH: +**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" nssm install imageproxy $exe nssm set imageproxy AppDirectory "C:\svc\imageproxy" nssm set imageproxy AppParameters "-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 AppExit Default Restart -nssm set imageproxy Start SERVICE_AUTO_START +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 ``` -Either way, remove it with `build\uninstall-service.ps1` (or `sc.exe delete 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) diff --git a/build/install-service.ps1 b/build/install-service.ps1 index b7a5b7ad6..11f044d88 100644 --- a/build/install-service.ps1 +++ b/build/install-service.ps1 @@ -10,7 +10,9 @@ #> param( [ValidateSet('native', 'nssm')] - [string]$Method = 'native' + [string]$Method = 'native', + # Full path to nssm.exe (only for -Method nssm). If omitted, nssm must be on PATH. + [string]$NssmPath = '' ) $ErrorActionPreference = 'Stop' @@ -39,11 +41,17 @@ $svcArgs = @('-addr', $Addr, '-allowHosts', $AllowHosts, '-cache', $CacheDir, '- if ($SignatureKey -ne '') { $svcArgs += @('-signatureKey', $SignatureKey) } if ($Method -eq 'nssm') { - $nssmCmd = Get-Command nssm -ErrorAction SilentlyContinue - if (-not $nssmCmd) { - throw "nssm not found on PATH. Install it (nssm.cc / choco install nssm / scoop install nssm), or omit -Method to use the native method." + 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 } - $nssm = $nssmCmd.Source # Quote paths so spaces in $CacheDir/$logPath survive as a single AppParameters string. $nssmParams = "-addr $Addr -allowHosts $AllowHosts -cache `"$CacheDir`" -timeout $Timeout -logFile `"$logPath`"" From b7afb69c0621b08202dd31b653bfce47a1dbc4c7 Mon Sep 17 00:00:00 2001 From: trungnsbkvn Date: Thu, 2 Jul 2026 13:56:55 +0700 Subject: [PATCH 09/13] fix(install): don't abort on expected native non-zero exits; robust nssm args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: 'nssm.exe : Can't open service!' aborted install-service.ps1 on a fresh install. Root cause: PowerShell 7.4+ throws on a native command's non-zero exit under ErrorActionPreference='Stop', so the pre-cleanup 'nssm stop' of a not-yet-installed service was fatal. - install/uninstall/build .ps1: set $PSNativeCommandUseErrorActionPreference = $false (harmless on PS 5.1); keep explicit $LASTEXITCODE checks. - install-service.ps1: pass runtime flags to nssm as an ARRAY () instead of a hand-quoted AppParameters string — fixes spaced/& paths (e.g. 'D:\Webs\2. Youth & Partners\...'). Cleanup now Get-Service-guarded + sc.exe; verify Running at the end. - DEPLOY.md: by-hand nssm uses the array form; troubleshooting rows for the NativeCommandError and spaced paths (space-free path recommended). Verified: reproduced the throw with sc.exe on a nonexistent service and confirmed the flag prevents it; all scripts parse clean. Co-Authored-By: Claude Opus 4.8 --- DEPLOY.md | 7 +++-- build.ps1 | 3 ++ build/install-service.ps1 | 60 ++++++++++++++++++++++--------------- build/uninstall-service.ps1 | 3 ++ 4 files changed, 47 insertions(+), 26 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index cc130bd59..a0770047f 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -147,9 +147,10 @@ runtime: *Configure by hand* (what `install-service.ps1 -Method nssm` does for you): ```powershell $exe = "C:\svc\imageproxy\imageproxy.exe" -nssm install 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 AppParameters "-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 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) @@ -332,6 +333,8 @@ To rebase the fork on a newer upstream, re-apply the small delta in | 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 diff --git a/build.ps1 b/build.ps1 index 7dd82bad8..299bbcc06 100644 --- a/build.ps1 +++ b/build.ps1 @@ -3,6 +3,9 @@ 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 diff --git a/build/install-service.ps1 b/build/install-service.ps1 index 11f044d88..09e9f7393 100644 --- a/build/install-service.ps1 +++ b/build/install-service.ps1 @@ -3,10 +3,12 @@ .\install-service.ps1 # NATIVE (default): the binary self-registers # with the SCM via `-service install` (no deps) - .\install-service.ps1 -Method nssm # via NSSM (requires nssm.exe on PATH) + .\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')] @@ -15,6 +17,10 @@ param( [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' @@ -36,10 +42,19 @@ $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIden if (-not $isAdmin) { throw "Run this in an ELEVATED PowerShell (Administrator)." } $logPath = Join-Path $here 'imageproxy.log' -# Runtime flags shared by both methods (as an array — handles spaces in paths). +# Runtime flags as an ARRAY — PowerShell quotes each element, so spaces/& in paths are safe. $svcArgs = @('-addr', $Addr, '-allowHosts', $AllowHosts, '-cache', $CacheDir, '-timeout', $Timeout, '-logFile', $logPath) if ($SignatureKey -ne '') { $svcArgs += @('-signatureKey', $SignatureKey) } +# 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" } @@ -53,41 +68,38 @@ if ($Method -eq 'nssm') { $nssm = $nssmCmd.Source } - # Quote paths so spaces in $CacheDir/$logPath survive as a single AppParameters string. - $nssmParams = "-addr $Addr -allowHosts $AllowHosts -cache `"$CacheDir`" -timeout $Timeout -logFile `"$logPath`"" - if ($SignatureKey -ne '') { $nssmParams += " -signatureKey $SignatureKey" } - - & $nssm stop $ServiceName 2>$null | Out-Null - & $nssm remove $ServiceName confirm 2>$null | Out-Null - Write-Host "Installing service '$ServiceName' via NSSM ..." - & $nssm install $ServiceName $exe - & $nssm set $ServiceName AppDirectory $here - & $nssm set $ServiceName AppParameters $nssmParams - & $nssm set $ServiceName AppStderr (Join-Path $here 'err.log') # crash backstop; normal logs go to imageproxy.log - & $nssm set $ServiceName AppExit Default Restart - & $nssm set $ServiceName Start SERVICE_AUTO_START - & $nssm start $ServiceName - & $nssm status $ServiceName + # `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 { - # NATIVE: the binary registers itself with the Windows SCM (no nssm). - & $exe -service stop 2>$null | Out-Null - & $exe -service uninstall 2>$null | Out-Null - 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 + started '$ServiceName' via '$Method' — a real Windows service (services.msc)." -Write-Host " Status : sc.exe query $ServiceName" +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" diff --git a/build/uninstall-service.ps1 b/build/uninstall-service.ps1 index b09ac23d4..5714aa624 100644 --- a/build/uninstall-service.ps1 +++ b/build/uninstall-service.ps1 @@ -5,6 +5,9 @@ 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() From 4b73e7ac135b89b1c00606f33c80a946b2c574fd Mon Sep 17 00:00:00 2001 From: trungnsbkvn Date: Thu, 2 Jul 2026 19:21:20 +0700 Subject: [PATCH 10/13] build: real media cache path + enable -baseURL for readable URLs - install-service.ps1 / run.ps1 / config.example.env: cache dir -> "D:/Webs/2. Youth & Partners/media/luatsumienbac/_imgcache" (real path; spaces + & handled by the svcArgs array-splat / quoting) - add -baseURL https://luatsumienbac.vn/media/ so /img// resolves to a readable, SEO-friendly, CDN-cacheable path (no base64) Co-Authored-By: Claude Opus 4.8 --- build/config.example.env | 5 ++++- build/install-service.ps1 | 4 +++- build/run.ps1 | 3 ++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/build/config.example.env b/build/config.example.env index a03c1035a..87422c66a 100644 --- a/build/config.example.env +++ b/build/config.example.env @@ -6,7 +6,10 @@ IMAGEPROXY_ADDR=127.0.0.1:8080 IMAGEPROXY_ALLOWHOSTS=luatsumienbac.vn -IMAGEPROXY_CACHE=D:/media/luatsumienbac/_imgcache +# 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:/Webs/2. Youth & Partners/media/luatsumienbac/_imgcache IMAGEPROXY_TIMEOUT=20s # Optional HMAC signing. If you set this, also set IMAGEPROXY_SIGNATURE_KEY diff --git a/build/install-service.ps1 b/build/install-service.ps1 index 09e9f7393..9e6272070 100644 --- a/build/install-service.ps1 +++ b/build/install-service.ps1 @@ -26,7 +26,8 @@ $PSNativeCommandUseErrorActionPreference = $false $ServiceName = 'imageproxy' $Addr = '127.0.0.1:8080' # loopback: only IIS reaches it $AllowHosts = 'luatsumienbac.vn' # lock the source origin -$CacheDir = 'D:/media/luatsumienbac/_imgcache' # forward slashes are safest +$BaseURL = 'https://luatsumienbac.vn/media/' # readable URLs: /img/880x,avif/ resolves here +$CacheDir = 'D:/Webs/2. Youth & Partners/media/luatsumienbac/_imgcache' # real media root (spaces + & — svcArgs array quotes it) $Timeout = '20s' $SignatureKey = '' # '' = unsigned (allowHosts still protects) # ──────────────────────────────────────────────────────────────────────────── @@ -44,6 +45,7 @@ if (-not $isAdmin) { throw "Run this in an ELEVATED PowerShell (Administrator)." $logPath = Join-Path $here 'imageproxy.log' # Runtime flags as an ARRAY — PowerShell quotes each element, so spaces/& in paths are safe. $svcArgs = @('-addr', $Addr, '-allowHosts', $AllowHosts, '-cache', $CacheDir, '-timeout', $Timeout, '-logFile', $logPath) +if ($BaseURL -ne '') { $svcArgs += @('-baseURL', $BaseURL) } if ($SignatureKey -ne '') { $svcArgs += @('-signatureKey', $SignatureKey) } # Remove any existing service first (works no matter how it was installed). diff --git a/build/run.ps1 b/build/run.ps1 index 23b250472..cfb6b0494 100644 --- a/build/run.ps1 +++ b/build/run.ps1 @@ -9,6 +9,7 @@ if (-not (Test-Path $exe)) { throw "imageproxy.exe not found. Build it first (.. & $exe -addr 127.0.0.1:8080 ` -allowHosts luatsumienbac.vn ` - -cache D:/media/luatsumienbac/_imgcache ` + -baseURL https://luatsumienbac.vn/media/ ` + -cache 'D:/Webs/2. Youth & Partners/media/luatsumienbac/_imgcache' ` -timeout 20s ` -verbose From 71ec815b985306f6ad01b36b487c746ee543dcb5 Mon Sep 17 00:00:00 2001 From: trungnsbkvn Date: Thu, 2 Jul 2026 19:26:53 +0700 Subject: [PATCH 11/13] fix(build): make service scripts pure ASCII (fix PS 5.1 parse errors) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows PowerShell 5.1 reads UTF-8 files without a BOM as CP1252, mangling em-dashes (—) and box-drawing (─) into `â€"` and breaking string/brace parsing (e.g. install-service.ps1 line 97 threw "Unexpected token"). Replace all non-ASCII with ASCII equivalents so the scripts parse under PS 5.1 and 7+. Co-Authored-By: Claude Opus 4.8 --- build.ps1 | 2 +- build/install-service.ps1 | 16 ++++++++-------- build/run.ps1 | 2 +- build/uninstall-service.ps1 | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/build.ps1 b/build.ps1 index 299bbcc06..a520557f1 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,5 +1,5 @@ <# - build.ps1 — build the Windows imageproxy binary into build\imageproxy.exe. + 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' diff --git a/build/install-service.ps1 b/build/install-service.ps1 index 9e6272070..b16c5a863 100644 --- a/build/install-service.ps1 +++ b/build/install-service.ps1 @@ -1,5 +1,5 @@ <# - install-service.ps1 — install imageproxy as a Windows service. Two methods: + 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) @@ -22,15 +22,15 @@ $ErrorActionPreference = 'Stop' # opt out and check $LASTEXITCODE explicitly where it matters. Harmless on PS 5.1. $PSNativeCommandUseErrorActionPreference = $false -# ── CONFIG (edit these) ───────────────────────────────────────────────────── +# -- 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:/Webs/2. Youth & Partners/media/luatsumienbac/_imgcache' # real media root (spaces + & — svcArgs array quotes it) +$CacheDir = 'D:/Webs/2. Youth & Partners/media/luatsumienbac/_imgcache' # real media root (spaces + & - svcArgs array quotes it) $Timeout = '20s' $SignatureKey = '' # '' = unsigned (allowHosts still protects) -# ──────────────────────────────────────────────────────────────────────────── +# ---------------------------------------------------------------------------- $here = Split-Path -Parent $MyInvocation.MyCommand.Path $exe = Join-Path $here 'imageproxy.exe' @@ -43,7 +43,7 @@ $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIden if (-not $isAdmin) { throw "Run this in an ELEVATED PowerShell (Administrator)." } $logPath = Join-Path $here 'imageproxy.log' -# Runtime flags as an ARRAY — PowerShell quotes each element, so spaces/& in paths are safe. +# Runtime flags as an ARRAY - PowerShell quotes each element, so spaces/& in paths are safe. $svcArgs = @('-addr', $Addr, '-allowHosts', $AllowHosts, '-cache', $CacheDir, '-timeout', $Timeout, '-logFile', $logPath) if ($BaseURL -ne '') { $svcArgs += @('-baseURL', $BaseURL) } if ($SignatureKey -ne '') { $svcArgs += @('-signatureKey', $SignatureKey) } @@ -71,7 +71,7 @@ if ($Method -eq 'nssm') { } Write-Host "Installing service '$ServiceName' via NSSM ..." - # `install ` — PS quotes each array element; nssm stores them + # `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)." } @@ -94,13 +94,13 @@ else { # 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 (-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 "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" diff --git a/build/run.ps1 b/build/run.ps1 index cfb6b0494..320f1d1b9 100644 --- a/build/run.ps1 +++ b/build/run.ps1 @@ -1,5 +1,5 @@ <# - run.ps1 — run imageproxy in the FOREGROUND for testing (Ctrl+C to stop). + 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' diff --git a/build/uninstall-service.ps1 b/build/uninstall-service.ps1 index 5714aa624..0749ed269 100644 --- a/build/uninstall-service.ps1 +++ b/build/uninstall-service.ps1 @@ -1,5 +1,5 @@ <# - uninstall-service.ps1 — stop and remove the imageproxy Windows service. + 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. @@ -15,7 +15,7 @@ $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIden 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." + Write-Host "Service '$ServiceName' not found - nothing to do." return } From ac286d4e970e6d988a6c37dc039f69a623ad5877 Mon Sep 17 00:00:00 2001 From: trungnsbkvn Date: Thu, 2 Jul 2026 19:51:31 +0700 Subject: [PATCH 12/13] fix(build): no-space cache path + guard against spaced nssm args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nssm stores AppParameters unquoted, so the spaced/& media cache path (D:/Webs/2. Youth & Partners/...) truncated -cache at the first space and Go's flag parser dropped every flag after it — silently killing -baseURL, so readable URLs 400'd ("must provide absolute remote URL"). - $CacheDir -> D:/imgcache/luatsumienbac (no spaces; cache is scratch, regenerates) - install-service.ps1: assert no svcArg contains a space on the nssm path (fail loudly instead of installing a silently-broken service) Co-Authored-By: Claude Opus 4.8 --- build/config.example.env | 2 +- build/install-service.ps1 | 15 +++++++++++++-- build/run.ps1 | 2 +- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/build/config.example.env b/build/config.example.env index 87422c66a..f3672375b 100644 --- a/build/config.example.env +++ b/build/config.example.env @@ -9,7 +9,7 @@ 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:/Webs/2. Youth & Partners/media/luatsumienbac/_imgcache +IMAGEPROXY_CACHE=D:/imgcache/luatsumienbac IMAGEPROXY_TIMEOUT=20s # Optional HMAC signing. If you set this, also set IMAGEPROXY_SIGNATURE_KEY diff --git a/build/install-service.ps1 b/build/install-service.ps1 index b16c5a863..e67495bee 100644 --- a/build/install-service.ps1 +++ b/build/install-service.ps1 @@ -27,7 +27,7 @@ $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:/Webs/2. Youth & Partners/media/luatsumienbac/_imgcache' # real media root (spaces + & - svcArgs array quotes it) +$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) # ---------------------------------------------------------------------------- @@ -43,11 +43,22 @@ $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIden if (-not $isAdmin) { throw "Run this in an ELEVATED PowerShell (Administrator)." } $logPath = Join-Path $here 'imageproxy.log' -# Runtime flags as an ARRAY - PowerShell quotes each element, so spaces/& in paths are safe. +# 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 ..." diff --git a/build/run.ps1 b/build/run.ps1 index 320f1d1b9..5ad7e8c13 100644 --- a/build/run.ps1 +++ b/build/run.ps1 @@ -10,6 +10,6 @@ if (-not (Test-Path $exe)) { throw "imageproxy.exe not found. Build it first (.. & $exe -addr 127.0.0.1:8080 ` -allowHosts luatsumienbac.vn ` -baseURL https://luatsumienbac.vn/media/ ` - -cache 'D:/Webs/2. Youth & Partners/media/luatsumienbac/_imgcache' ` + -cache D:/imgcache/luatsumienbac ` -timeout 20s ` -verbose From a6d92632cd5940c5f595f757e5821d7b84fee8e9 Mon Sep 17 00:00:00 2001 From: trungnsbkvn Date: Thu, 2 Jul 2026 19:52:09 +0700 Subject: [PATCH 13/13] chore(build): drop last non-ASCII em-dash in config.example.env Co-Authored-By: Claude Opus 4.8 --- build/config.example.env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/config.example.env b/build/config.example.env index f3672375b..5bcb9b924 100644 --- a/build/config.example.env +++ b/build/config.example.env @@ -1,7 +1,7 @@ # 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 — +# systemd EnvironmentFile. CLI flags in install-service.ps1 do the same thing - # pick one approach, not both. IMAGEPROXY_ADDR=127.0.0.1:8080