Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ go = "1.27.2"

[tasks.fmt]
description = "Check Go formatting"
run = "test -z \"$(gofmt -l cmd)\""
run = "test -z \"$(gofmt -l cmd internal)\""

[tasks.vet]
description = "Run static analysis"
Expand Down
17 changes: 14 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,25 @@ Run the unit tests with:
make test
```

They need neither ExifTool nor network access. To also run the tests against
the pinned ExifTool release:
They need neither ExifTool nor network access: a fake stripper covers raw and
multipart uploads, request limits, error responses, deadlines and cancellation,
bounded uploads and concurrency, temporary file cleanup, and shutdown. To also
run the tests against the pinned ExifTool release:

```sh
make test-integration
```

This downloads and verifies ExifTool if it is missing. The integration suite
fails rather than silently skipping when ExifTool is unavailable.
tags JPEG, PNG, TIFF, GIF, WebP, AVIF, and HEIC files with a location, camera,
and capture time, strips them, and checks that every private tag is gone while
the orientation is kept. A JPEG also checks that its ICC profile is kept byte
for byte. The non-JPEG and non-PNG fixtures in `internal/exiftool/testdata`
are 32x24 gradients made with ImageMagick 7.1
(`magick -size 32x24 gradient:red-blue photo.<format>`). It also covers files ExifTool cannot rewrite, recovery after a timeout
or an exited process, concurrent requests, and that a failed startup leaves no
processes behind. It fails rather than silently skipping when ExifTool is
unavailable.

## Upgrading ExifTool

Expand All @@ -48,4 +58,5 @@ checksums in the author's

```text
cmd/exiftool-server/ HTTP server, lifecycle, and telemetry
internal/exiftool/ ExifTool process pool and metadata removal
```
66 changes: 61 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@
writing image, video, and document metadata. The service checks for ExifTool
at startup and refuses to start without it.

It removes metadata such as location, camera, and capture time from images
while keeping the ICC color profile and orientation, so a stripped photo looks
the same as the original. Pixel data is never re-encoded. JPEG, PNG, TIFF, GIF,
WebP, AVIF, and HEIC are tested. A fixed pool of ExifTool processes runs in
`-stay_open` mode, so each request skips Perl's startup and memory does not
grow with the number of callers. Uploads are written straight to a temporary
file and the response is sent from it, so memory does not grow with file size
either.

## Requirements

- Go 1.27 or newer
Expand Down Expand Up @@ -32,8 +41,17 @@ The server listens on `:8080`. These environment variables are available:
| --- | --- | --- |
| `ADDR` | `:8080` | HTTP listen address |
| `EXIFTOOL_PATH` | `exiftool` | ExifTool executable, either a path or a name looked up on `PATH` |
| `MAX_CONCURRENT_STRIPS` | available CPUs | Number of ExifTool processes, and so of files stripped concurrently |
| `STRIP_TIMEOUT` | `30s` | Maximum lifetime of a strip request, including upload and queue waits |
| `MAX_REQUEST_SIZE` | `25MiB` | Maximum `/strip` request body, including multipart overhead. Uploads are kept on disk, so this caps temporary files, not memory |
| `SHUTDOWN_TIMEOUT` | `30s` | Grace period for active requests during shutdown |

Each ExifTool process uses about 50 MiB, so memory depends on
`MAX_CONCURRENT_STRIPS`, not on how many clients call the service or how
large their files are. With eight processes and 16 concurrent 18 MB uploads,
the HTTP server itself peaked at 14 MiB, and 340 requests for 320 KB JPEGs, 34
at a time, completed at about 385 per second with a p95 of about 110 ms.

## Docker

The image downloads ExifTool from CPAN, verifies its checksum during the build,
Expand Down Expand Up @@ -76,7 +94,40 @@ curl -sS http://localhost:8080/readyz

`/livez` reports that the process is running. `/readyz` reports whether the
service accepts requests and returns 503 as soon as graceful shutdown starts.
`/healthz` is an alias for `/readyz`.
`/healthz` is an alias for `/readyz`. The service only starts listening after
every ExifTool process has started.

Send a file to strip as the raw request body:

```sh
curl -sS -X POST http://localhost:8080/strip \
-H 'Content-Type: application/octet-stream' \
--data-binary '@photo.jpg' -o stripped.jpg
```

Or as a multipart `file` field:

```sh
curl -sS -X POST http://localhost:8080/strip \
-F 'file=@photo.jpg' -o stripped.jpg
```

A successful response is `200` with the stripped file as
`application/octet-stream`. The format is detected from the content, so a
renamed file is still stripped. Errors are JSON with an `error` message:

| Status | Meaning |
| --- | --- |
| `400` | Missing or invalid body |
| `413` | Body is larger than `MAX_REQUEST_SIZE` |
| `422` | ExifTool cannot rewrite the file: the format is not writable or the file is corrupt |
| `499` | The client cancelled the request |
| `500` | ExifTool failed; its process is replaced before the next request uses it |
| `503` | The service is shutting down; retry after `Retry-After` |
| `504` | The request took longer than `STRIP_TIMEOUT` |

Callers that must not lose an upload should keep the original file on any
response other than `200`.

## Telemetry and shutdown

Expand All @@ -86,13 +137,18 @@ letters, digits, `_`, or `-` and is at most 64 characters. Logs never include
request bodies, query strings, or request headers.

Prometheus metrics are exposed at `/metrics`. They cover HTTP request rates and
latency, Go runtime and process statistics, and build information including the
ExifTool version. Keep this endpoint private at the ingress layer.
latency, strip stage latency and outcomes, active ExifTool commands,
cancellations, Go runtime and process statistics, and build information
including the ExifTool version. Keep this endpoint private at the ingress layer.

Every strip request has a deadline. A request that times out or is cancelled
stops its ExifTool process, which is started again the next time it is needed,
so one stuck file never blocks other requests.

On SIGINT or SIGTERM, readiness becomes false immediately, and active requests
have `SHUTDOWN_TIMEOUT` to finish. After that, request contexts are cancelled
and connections are closed. Set the orchestrator's termination grace period
longer than `SHUTDOWN_TIMEOUT`.
and connections are closed, and the ExifTool processes are stopped. Set the
orchestrator's termination grace period longer than `SHUTDOWN_TIMEOUT`.

## Contributing

Expand Down
106 changes: 106 additions & 0 deletions cmd/exiftool-server/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,20 @@
package main

import (
"bytes"
"context"
"encoding/json"
"image"
"image/color"
"image/jpeg"
"net/http"
"os"
"os/exec"
"path/filepath"
"testing"
"time"

"exiftool/internal/exiftool"
)

func TestDetectExiftoolVersionWithRealExiftool(t *testing.T) {
Expand All @@ -21,3 +32,98 @@ func TestDetectExiftoolVersionWithRealExiftool(t *testing.T) {
t.Fatalf("version = %q, want %q", got, want)
}
}

func TestStripWithRealExiftool(t *testing.T) {
path := os.Getenv("EXIFTOOL_PATH")
if path == "" {
t.Fatal("EXIFTOOL_PATH must point to an ExifTool executable")
}
pool, err := exiftool.New(context.Background(), exiftool.Options{Binary: path, Size: 2, StartupTimeout: time.Minute})
if err != nil {
t.Fatal(err)
}
t.Cleanup(pool.Close)
app := newStripApplication(t, pool, 2)
original := taggedJPEG(t, path)

for _, tc := range []struct {
name string
req *http.Request
}{
{"raw body", stripRequest(bytes.NewReader(original))},
{"multipart file", multipartRequest(t, "file", original)},
} {
t.Run(tc.name, func(t *testing.T) {
rec := serveRequest(app, tc.req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
}
stripped := rec.Body.Bytes()
if len(stripped) >= len(original) {
t.Fatalf("stripped size = %d, want less than %d", len(stripped), len(original))
}
tags := readTags(t, path, stripped)
for _, tag := range []string{"GPSLatitude", "Make"} {
if value, ok := tags[tag]; ok {
t.Errorf("%s = %v was not removed", tag, value)
}
}
if got := tags["Orientation"]; got != float64(6) {
t.Errorf("Orientation = %v, want 6", got)
}
})
}

t.Run("unsupported file", func(t *testing.T) {
rec := serveRequest(app, stripRequest(bytes.NewReader([]byte("plain text, not an image"))))
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusUnprocessableEntity, rec.Body.String())
}
})
}

// taggedJPEG returns a JPEG carrying a location, camera, and orientation.
func taggedJPEG(t *testing.T, exiftoolPath string) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 16, 12))
for x := range 16 {
for y := range 12 {
img.Set(x, y, color.RGBA{R: uint8(x * 15), G: uint8(y * 20), B: 120, A: 255})
}
}
var buffer bytes.Buffer
if err := jpeg.Encode(&buffer, img, nil); err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "photo.jpg")
if err := os.WriteFile(path, buffer.Bytes(), 0o600); err != nil {
t.Fatal(err)
}
output, err := exec.Command(exiftoolPath, "-GPSLatitude=37.7749", "-GPSLatitudeRef=N",
"-Make=Canon", "-Orientation#=6", "-overwrite_original", path).CombinedOutput()
if err != nil {
t.Fatalf("tag fixture: %v: %s", err, output)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return data
}

func readTags(t *testing.T, exiftoolPath string, data []byte) map[string]any {
t.Helper()
path := filepath.Join(t.TempDir(), "result")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
output, err := exec.Command(exiftoolPath, "-j", "-n", path).Output()
if err != nil {
t.Fatalf("read tags: %v", err)
}
var tags []map[string]any
if err := json.Unmarshal(output, &tags); err != nil || len(tags) != 1 {
t.Fatalf("decode tags: %v", err)
}
return tags[0]
}
Loading
Loading