diff --git a/README.md b/README.md index 85af3f2..4765703 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,21 @@ with a private address can choose the address it is counted by through its own its own address is trusted too. Setting `trusted_proxies` to the proxy's own address closes this. +### Image Metadata + +pixa decodes and re-encodes every image it serves, and removes all metadata from +the output: EXIF (GPS position, camera make, model and serial number, capture +time, embedded thumbnail), XMP, IPTC and the ICC colour profile. This cannot be +turned off. + +- The `orig` format means the source's own format, not the source's bytes: an + `orig` image is re-encoded and stripped like any other. +- An image with an EXIF orientation is turned upright first, so it displays the + same without the tag; a requested size applies to the upright image. +- An image with an ICC profile is converted to sRGB first, since clients show an + image with no profile as sRGB. Colours outside sRGB, such as the most + saturated ones in a Display P3 photo, are clipped. + ### Source Hosts Source hosts may be allowlisted in the configuration. Non-allowlisted diff --git a/TODO.md b/TODO.md index 486c8eb..56d0745 100644 --- a/TODO.md +++ b/TODO.md @@ -30,6 +30,12 @@ exhaustion # Completed Steps +- 2026-09-28 strip metadata from processed images (closes #82): every output is + exported with govips' `StripMetadata`, so it carries no EXIF, XMP, IPTC or ICC + profile; the image is first turned upright with `AutoRotate` (before sizes are + worked out) and, when it has an ICC profile, converted to sRGB; the `orig` + format is re-encoded and stripped like any other, as pixa never serves the + source bytes; there is no setting to keep metadata; documented in `README.md`. - 2026-09-28 rate limit the login form (closes #66): `POST /` is limited to 5 attempts per minute per client address, and an attempt over the limit is refused with 429 and a `Retry-After` header; the address is the one @@ -251,7 +257,6 @@ exhaustion # Future Steps -- P1: strip EXIF and other metadata from processed images (privacy) - P2: security - referer blacklist - per-IP rate limiting on the image routes diff --git a/internal/imageprocessor/imageprocessor.go b/internal/imageprocessor/imageprocessor.go index 32e7b7a..dd77ac1 100644 --- a/internal/imageprocessor/imageprocessor.go +++ b/internal/imageprocessor/imageprocessor.go @@ -161,6 +161,13 @@ func (p *ImageProcessor) Process( } defer img.Close() + // Turn the image upright now: encode strips the EXIF orientation tag, + // and sizes below must be worked out on the upright image. + err = img.AutoRotate() + if err != nil { + return nil, fmt.Errorf("failed to auto-rotate: %w", err) + } + // Get original dimensions origWidth := img.Width() origHeight := img.Height() @@ -404,6 +411,21 @@ func (p *ImageProcessor) encode( return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format) } + // Stripping drops the ICC profile as well, and clients show an image + // with no profile as sRGB, so convert to sRGB first. "srgb" names + // libvips' built-in profile; govips' own sRGB path variable is set on + // first use but read without a lock, so concurrent requests race on it. + if img.HasICCProfile() { + err := img.TransformICCProfileWithFallback("srgb", "srgb") + if err != nil { + return nil, fmt.Errorf("failed to convert to sRGB: %w", err) + } + } + + // Drop EXIF, XMP, IPTC and the ICC profile. govips ignores this for + // GIF, which carries none of them. + params.StripMetadata = true + output, _, err := img.Export(¶ms) if err != nil { return nil, err