diff --git a/.air.toml b/.air.toml index bc332ebe..00ef64c4 100644 --- a/.air.toml +++ b/.air.toml @@ -3,61 +3,61 @@ testdata_dir = "testdata" tmp_dir = "tmp" [build] - args_bin = [] - bin = "./tmp/main" - cmd = "go build -o ./tmp/main ." - delay = 1000 - exclude_dir = ["assets", "tmp", "vendor", "testdata"] - exclude_file = [] - exclude_regex = ["_test.go"] - exclude_unchanged = false - follow_symlink = false - full_bin = "" - include_dir = [] - include_ext = ["go", "tpl", "tmpl", "html"] - include_file = [ - "vips/vips.c", - "vips/vips.h", - "vips/source.c", - "vips/source.h", - "vips/icoload.c", - "vips/icosave.c", - "vips/ico.h", - "vips/bmpload.c", - "vips/bmpload.h", - "vips/bmp.h" - ] - kill_delay = "4s" - log = "build-errors.log" - poll = false - poll_interval = 0 - post_cmd = [] - pre_cmd = [] - rerun = false - rerun_delay = 500 - send_interrupt = true - stop_on_error = true +args_bin = [] +bin = "./tmp/main" +cmd = "make build -- -o ./tmp/main" +delay = 1000 +exclude_dir = ["assets", "tmp", "vendor", "testdata"] +exclude_file = [] +exclude_regex = ["_test.go"] +exclude_unchanged = false +follow_symlink = false +full_bin = "" +include_dir = [] +include_ext = ["go", "tpl", "tmpl", "html"] +include_file = [ + "vips/vips.c", + "vips/vips.h", + "vips/source.c", + "vips/source.h", + "vips/icoload.c", + "vips/icosave.c", + "vips/ico.h", + "vips/bmpload.c", + "vips/bmpload.h", + "vips/bmp.h", +] +kill_delay = "4s" +log = "build-errors.log" +poll = false +poll_interval = 0 +post_cmd = [] +pre_cmd = [] +rerun = false +rerun_delay = 500 +send_interrupt = true +stop_on_error = true [color] - app = "" - build = "yellow" - main = "magenta" - runner = "green" - watcher = "cyan" +app = "" +build = "yellow" +main = "magenta" +runner = "green" +watcher = "cyan" [log] - main_only = false - silent = false - time = false +main_only = false +silent = false +time = false [misc] - clean_on_exit = false +clean_on_exit = false [proxy] - app_port = 0 - enabled = false - proxy_port = 0 +app_port = 0 +enabled = false +proxy_port = 0 [screen] - clear_on_rebuild = false - keep_scroll = true +clear_on_rebuild = false +keep_scroll = true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23c8f82f..6bc5bdbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: - name: Mark git workspace as safe run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - name: Test - run: go test -tags integration ./... + run: go test ./... lint: runs-on: ubuntu-latest diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..30608185 --- /dev/null +++ b/Makefile @@ -0,0 +1,66 @@ +# imgproxy Makefile + +BINARY := imgproxy + +GOCMD := go +GOBUILD := $(GOCMD) build +GOCLEAN := $(GOCMD) clean +GOTEST := $(GOCMD) test +GOFMT := gofmt +GOLINT := golangci-lint +GOTESTSUM := gotestsum + +SRCDIR := ./cli + +# Default target +.PHONY: all +all: build + +# Build the binary. If -o is not provided, it defaults to $(BINARY). +# +# Usage: +# make build -- -o output_name +.PHONY: build +build: + @args="$(filter-out $@,$(MAKECMDGOALS))"; \ + if echo "$$args" | grep -q "\-o"; then \ + $(GOBUILD) $$args $(SRCDIR); \ + else \ + $(GOBUILD) -o $(BINARY) $$args $(SRCDIR); \ + fi + +# Clean +.PHONY: clean +clean: + $(GOCLEAN) + rm -f $(BINARY) + +# Run tests +# +# Usage: +# make test -- -run FooTest +.PHONY: test +test: + @$(GOTEST) ./... $(filter-out $@,$(MAKECMDGOALS)) + +# Run gotestsum +# +# Usage: +# make testsum -- -run FooTest +testsum: + @$(GOTESTSUM) -- $(filter-out $@,$(MAKECMDGOALS)) + +# Format code +.PHONY: fmt +fmt: + $(GOFMT) -s -w . + +# Lint code (requires golangci-lint installed) +.PHONY: lint +lint: + $(GOLINT) run ./... + +# Make any unknown target do nothing to avoid "up to date" messages +.PHONY: FORCE +%: FORCE + @: diff --git a/auximageprovider/static_provider.go b/auximageprovider/static_provider.go index c169ea29..687579a4 100644 --- a/auximageprovider/static_provider.go +++ b/auximageprovider/static_provider.go @@ -21,7 +21,12 @@ func (s *staticProvider) Get(_ context.Context, po *options.ProcessingOptions) ( } // NewStaticFromTriple creates a new ImageProvider from either a base64 string, file path, or URL -func NewStaticProvider(ctx context.Context, c *StaticConfig, desc string, idf *imagedata.Factory) (Provider, error) { +func NewStaticProvider( + ctx context.Context, + c *StaticConfig, + desc string, + idf *imagedata.Factory, +) (Provider, error) { var ( data imagedata.ImageData headers = make(http.Header) diff --git a/healthcheck.go b/cli/healthcheck.go similarity index 75% rename from healthcheck.go rename to cli/healthcheck.go index 9843795e..269ce26e 100644 --- a/healthcheck.go +++ b/cli/healthcheck.go @@ -10,9 +10,11 @@ import ( "github.com/imgproxy/imgproxy/v3/config" "github.com/imgproxy/imgproxy/v3/config/configurators" + "github.com/urfave/cli/v3" ) -func healthcheck() int { +// healthcheck performs a healthcheck on a running imgproxy instance +func healthcheck(ctx context.Context, c *cli.Command) error { network := config.Network bind := config.Bind pathprefix := config.PathPrefix @@ -32,7 +34,7 @@ func healthcheck() int { res, err := httpc.Get(fmt.Sprintf("http://imgproxy%s/health", pathprefix)) if err != nil { fmt.Fprintln(os.Stderr, err.Error()) - return 1 + return cli.Exit(err, 1) } defer res.Body.Close() @@ -40,8 +42,9 @@ func healthcheck() int { fmt.Fprintln(os.Stderr, string(msg)) if res.StatusCode != 200 { - return 1 + err := fmt.Errorf("healthcheck failed: %s", msg) + return cli.Exit(err, 1) } - return 0 + return nil } diff --git a/cli/main.go b/cli/main.go new file mode 100644 index 00000000..3c44fc3c --- /dev/null +++ b/cli/main.go @@ -0,0 +1,86 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/imgproxy/imgproxy/v3" + "github.com/imgproxy/imgproxy/v3/version" + "github.com/urfave/cli/v3" +) + +// ver prints the imgproxy version and runs the main application +func ver(ctx context.Context, c *cli.Command) error { + fmt.Println(version.Version) + return nil +} + +// run starts the imgproxy server +func run(ctx context.Context, cmd *cli.Command) error { + // NOTE: for now, these flags are loaded in config.go package + + // keypath := cmd.String("keypath") + // saltpath := cmd.String("saltpath") + // presets := cmd.String("presets") + + if err := imgproxy.Init(); err != nil { + return err + } + defer imgproxy.Shutdown() + + cfg, err := imgproxy.LoadConfigFromEnv(nil) + if err != nil { + return err + } + + instance, err := imgproxy.New(ctx, cfg) + if err != nil { + return err + } + + if err := instance.StartServer(ctx); err != nil { + return err + } + + return nil +} + +func main() { + cmd := &cli.Command{ + Name: "imgproxy", + Usage: "Fast and secure standalone server for resizing and converting remote images", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "keypath", + Usage: "path of the file with hex-encoded key", + }, + &cli.StringFlag{ + Name: "saltpath", + Usage: "path of the file with hex-encoded salt", + }, + &cli.StringFlag{ + Name: "presets", + Usage: "path of the file with presets", + }, + }, + Action: run, + Commands: []*cli.Command{ + { + Name: "version", + Usage: "print the version", + Action: ver, + }, + { + Name: "health", + Usage: "perform a healthcheck on a running imgproxy instance", + Action: healthcheck, + }, + }, + } + + if err := cmd.Run(context.Background(), os.Args); err != nil { + log.Fatal(err) + } +} diff --git a/config.go b/config.go new file mode 100644 index 00000000..96b2160e --- /dev/null +++ b/config.go @@ -0,0 +1,86 @@ +package imgproxy + +import ( + "github.com/imgproxy/imgproxy/v3/auximageprovider" + "github.com/imgproxy/imgproxy/v3/ensure" + "github.com/imgproxy/imgproxy/v3/fetcher" + processinghandler "github.com/imgproxy/imgproxy/v3/handlers/processing" + "github.com/imgproxy/imgproxy/v3/handlers/stream" + "github.com/imgproxy/imgproxy/v3/headerwriter" + "github.com/imgproxy/imgproxy/v3/semaphores" + "github.com/imgproxy/imgproxy/v3/server" + "github.com/imgproxy/imgproxy/v3/transport" +) + +// Config represents an instance configuration +type Config struct { + HeaderWriter headerwriter.Config + Semaphores semaphores.Config + FallbackImage auximageprovider.StaticConfig + WatermarkImage auximageprovider.StaticConfig + Transport transport.Config + Fetcher fetcher.Config + ProcessingHandler processinghandler.Config + StreamHandler stream.Config + Server server.Config +} + +// NewDefaultConfig creates a new default configuration +func NewDefaultConfig() Config { + return Config{ + HeaderWriter: headerwriter.NewDefaultConfig(), + Semaphores: semaphores.NewDefaultConfig(), + FallbackImage: auximageprovider.NewDefaultStaticConfig(), + WatermarkImage: auximageprovider.NewDefaultStaticConfig(), + Transport: transport.NewDefaultConfig(), + Fetcher: fetcher.NewDefaultConfig(), + ProcessingHandler: processinghandler.NewDefaultConfig(), + StreamHandler: stream.NewDefaultConfig(), + Server: server.NewDefaultConfig(), + } +} + +// LoadConfigFromEnv loads configuration from environment variables +func LoadConfigFromEnv(c *Config) (*Config, error) { + c = ensure.Ensure(c, NewDefaultConfig) + + var err error + + if _, err = server.LoadConfigFromEnv(&c.Server); err != nil { + return nil, err + } + + if _, err = auximageprovider.LoadFallbackStaticConfigFromEnv(&c.FallbackImage); err != nil { + return nil, err + } + + if _, err = auximageprovider.LoadWatermarkStaticConfigFromEnv(&c.WatermarkImage); err != nil { + return nil, err + } + + if _, err = headerwriter.LoadConfigFromEnv(&c.HeaderWriter); err != nil { + return nil, err + } + + if _, err = semaphores.LoadConfigFromEnv(&c.Semaphores); err != nil { + return nil, err + } + + if _, err = transport.LoadConfigFromEnv(&c.Transport); err != nil { + return nil, err + } + + if _, err = fetcher.LoadConfigFromEnv(&c.Fetcher); err != nil { + return nil, err + } + + if _, err = processinghandler.LoadConfigFromEnv(&c.ProcessingHandler); err != nil { + return nil, err + } + + if _, err = stream.LoadConfigFromEnv(&c.StreamHandler); err != nil { + return nil, err + } + + return c, nil +} diff --git a/config/config.go b/config/config.go index 0f94c046..bf0071d9 100644 --- a/config/config.go +++ b/config/config.go @@ -17,6 +17,14 @@ import ( "github.com/imgproxy/imgproxy/v3/version" ) +func init() { + // We need to reset config to defaults once on app start. + // Tests could perform config.Reset() to ensure a clean state where required. + // NOTE: this is temporary workaround until we finally move + // to Config objects everywhere + Reset() +} + type URLReplacement = configurators.URLReplacement var ( @@ -425,8 +433,6 @@ func Reset() { } func Configure() error { - Reset() - if port := os.Getenv("PORT"); len(port) > 0 { Bind = fmt.Sprintf(":%s", port) } diff --git a/docker/Dockerfile b/docker/Dockerfile index eb605d59..d573839c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -5,7 +5,7 @@ FROM ghcr.io/imgproxy/imgproxy-base:${BASE_IMAGE_VERSION} AS build ENV CGO_ENABLED=1 COPY . . -RUN bash -c 'go build -v -ldflags "-s -w" -o /opt/imgproxy/bin/imgproxy' +RUN bash -c 'go build -v -ldflags "-s -w" -o /opt/imgproxy/bin/imgproxy ./cli' # Remove unnecessary files RUN rm -rf /opt/imgproxy/lib/pkgconfig /opt/imgproxy/lib/cmake diff --git a/fetcher/config.go b/fetcher/config.go index d3ddf6e1..f39acd71 100644 --- a/fetcher/config.go +++ b/fetcher/config.go @@ -13,8 +13,10 @@ import ( type Config struct { // UserAgent is the User-Agent header to use when fetching images. UserAgent string + // DownloadTimeout is the timeout for downloading an image, in seconds. DownloadTimeout time.Duration + // MaxRedirects is the maximum number of redirects to follow when fetching an image. MaxRedirects int } diff --git a/go.mod b/go.mod index 95fb419d..440e42ec 100644 --- a/go.mod +++ b/go.mod @@ -119,6 +119,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575 // indirect github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/eapache/queue/v2 v2.0.0-20230407133247-75960ed334e4 // indirect @@ -174,6 +175,7 @@ require ( github.com/prometheus/common v0.65.0 // indirect github.com/prometheus/procfs v0.17.0 // indirect github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect github.com/secure-systems-lab/go-securesystemslib v0.9.0 // indirect github.com/shabbyrobe/gocovmerge v0.0.0-20190829150210-3e036491d500 // indirect @@ -182,6 +184,7 @@ require ( github.com/tinylib/msgp v1.3.0 // indirect github.com/tklauser/go-sysconf v0.3.15 // indirect github.com/tklauser/numcpus v0.10.0 // indirect + github.com/urfave/cli/v3 v3.4.1 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/zeebo/errs v1.4.0 // indirect diff --git a/go.sum b/go.sum index 29f605ff..4104fbd9 100644 --- a/go.sum +++ b/go.sum @@ -44,6 +44,7 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mo github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DarthSim/godotenv v1.3.1 h1:NMWdswlRx2M9uPY4Ux8p/Q/rDs7A97OG89fECiQ/Tz0= github.com/DarthSim/godotenv v1.3.1/go.mod h1:B3ySe1HYTUFFR6+TPyHyxPWjUdh48il0Blebg9p1cCc= github.com/DataDog/appsec-internal-go v1.13.0 h1:aO6DmHYsAU8BNFuvYJByhMKGgcQT3WAbj9J/sgAJxtA= @@ -172,6 +173,8 @@ github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9MkoYwI= github.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -394,6 +397,8 @@ github.com/richardartoul/molecule v1.0.1-0.20240531184615-7ca0df43c0b3 h1:4+LEVO github.com/richardartoul/molecule v1.0.1-0.20240531184615-7ca0df43c0b3/go.mod h1:vl5+MqJ1nBINuSsUI2mGgH79UweUT/B5Fy8857PqyyI= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8= github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= github.com/secure-systems-lab/go-securesystemslib v0.9.0 h1:rf1HIbL64nUpEIZnjLZ3mcNEL9NBPB0iuVjyxvq3LZc= @@ -441,6 +446,10 @@ github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfj github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= github.com/trimmer-io/go-xmp v1.0.0 h1:zY8bolSga5kOjBAaHS6hrdxLgEoYuT875xTy0QDwZWs= github.com/trimmer-io/go-xmp v1.0.0/go.mod h1:Aaptr9sp1lLv7UnCAdQ+gSHZyY2miYaKmcNVj7HRBwA= +github.com/urfave/cli v1.22.17 h1:SYzXoiPfQjHBbkYxbew5prZHS1TOLT3ierW8SYLqtVQ= +github.com/urfave/cli v1.22.17/go.mod h1:b0ht0aqgH/6pBYzzxURyrM4xXNgsoT/n2ZzwQiEhNVo= +github.com/urfave/cli/v3 v3.4.1 h1:1M9UOCy5bLmGnuu1yn3t3CB4rG79Rtoxuv1sPhnm6qM= +github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= github.com/vmihailenco/msgpack/v4 v4.3.13 h1:A2wsiTbvp63ilDaWmsk2wjx6xZdxQOvpiNlKBGKKXKI= github.com/vmihailenco/msgpack/v4 v4.3.13/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4= github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vbd1qPqc= diff --git a/handlers/processing/handler.go b/handlers/processing/handler.go index 28d3bf74..130db1a2 100644 --- a/handlers/processing/handler.go +++ b/handlers/processing/handler.go @@ -26,7 +26,7 @@ type Handler struct { semaphores *semaphores.Semaphores fallbackImage auximageprovider.Provider watermarkImage auximageprovider.Provider - imageData *imagedata.Factory + idf *imagedata.Factory } // New creates new handler object @@ -50,7 +50,7 @@ func New( semaphores: semaphores, fallbackImage: fi, watermarkImage: wi, - imageData: idf, + idf: idf, }, nil } @@ -88,7 +88,7 @@ func (h *Handler) Execute( monitoringMeta: mm, semaphores: h.semaphores, hwr: h.hw.NewRequest(), - idf: h.imageData, + idf: h.idf, } return req.execute(ctx) diff --git a/handlers/processing/handler_test.go b/handlers/processing/handler_test.go new file mode 100644 index 00000000..ce50a100 --- /dev/null +++ b/handlers/processing/handler_test.go @@ -0,0 +1 @@ +package processing diff --git a/handlers/processing/request_methods.go b/handlers/processing/request_methods.go index 599a5778..b692e981 100644 --- a/handlers/processing/request_methods.go +++ b/handlers/processing/request_methods.go @@ -154,7 +154,7 @@ func (r *request) getFallbackImage( // processImage calls actual image processing func (r *request) processImage(ctx context.Context, originData imagedata.ImageData) (*processing.Result, error) { defer monitoring.StartProcessingSegment(ctx, r.monitoringMeta.Filter(monitoring.MetaProcessingOptions))() - return processing.ProcessImage(ctx, originData, r.po, r.handler.watermarkImage, r.handler.imageData) + return processing.ProcessImage(ctx, originData, r.po, r.handler.watermarkImage, r.handler.idf) } // writeDebugHeaders writes debug headers (X-Origin-*, X-Result-*) to the response diff --git a/imgproxy.go b/imgproxy.go new file mode 100644 index 00000000..ce864d96 --- /dev/null +++ b/imgproxy.go @@ -0,0 +1,170 @@ +package imgproxy + +import ( + "context" + "os/signal" + "syscall" + "time" + + "github.com/imgproxy/imgproxy/v3/auximageprovider" + "github.com/imgproxy/imgproxy/v3/fetcher" + "github.com/imgproxy/imgproxy/v3/handlers" + processinghandler "github.com/imgproxy/imgproxy/v3/handlers/processing" + "github.com/imgproxy/imgproxy/v3/handlers/stream" + "github.com/imgproxy/imgproxy/v3/headerwriter" + "github.com/imgproxy/imgproxy/v3/imagedata" + "github.com/imgproxy/imgproxy/v3/memory" + "github.com/imgproxy/imgproxy/v3/monitoring/prometheus" + "github.com/imgproxy/imgproxy/v3/semaphores" + "github.com/imgproxy/imgproxy/v3/server" + "github.com/imgproxy/imgproxy/v3/transport" +) + +const ( + faviconPath = "/favicon.ico" + healthPath = "/health" +) + +// Imgproxy holds all the components needed for imgproxy to function +type Imgproxy struct { + HeaderWriter *headerwriter.Writer + Semaphores *semaphores.Semaphores + FallbackImage auximageprovider.Provider + WatermarkImage auximageprovider.Provider + Fetcher *fetcher.Fetcher + ProcessingHandler *processinghandler.Handler + StreamHandler *stream.Handler + ImageDataFactory *imagedata.Factory + Config *Config +} + +// New creates a new imgproxy instance +func New(ctx context.Context, config *Config) (*Imgproxy, error) { + headerWriter, err := headerwriter.New(&config.HeaderWriter) + if err != nil { + return nil, err + } + + ts, err := transport.New(&config.Transport) + if err != nil { + return nil, err + } + + fetcher, err := fetcher.New(ts, &config.Fetcher) + if err != nil { + return nil, err + } + + idf := imagedata.NewFactory(fetcher) + + fallbackImage, err := auximageprovider.NewStaticProvider(ctx, &config.FallbackImage, "fallback", idf) + if err != nil { + return nil, err + } + + watermarkImage, err := auximageprovider.NewStaticProvider(ctx, &config.WatermarkImage, "watermark", idf) + if err != nil { + return nil, err + } + + semaphores, err := semaphores.New(&config.Semaphores) + if err != nil { + return nil, err + } + + streamHandler, err := stream.New(&config.StreamHandler, headerWriter, fetcher) + if err != nil { + return nil, err + } + + ph, err := processinghandler.New( + streamHandler, headerWriter, semaphores, fallbackImage, watermarkImage, idf, &config.ProcessingHandler, + ) + if err != nil { + return nil, err + } + + return &Imgproxy{ + HeaderWriter: headerWriter, + Semaphores: semaphores, + FallbackImage: fallbackImage, + WatermarkImage: watermarkImage, + Fetcher: fetcher, + StreamHandler: streamHandler, + ProcessingHandler: ph, + ImageDataFactory: idf, + Config: config, + }, nil +} + +// BuildRouter sets up the HTTP routes and middleware +func (i *Imgproxy) BuildRouter() (*server.Router, error) { + r, err := server.NewRouter(&i.Config.Server) + if err != nil { + return nil, err + } + + r.GET("/", handlers.LandingHandler) + r.GET("", handlers.LandingHandler) + + r.GET(faviconPath, r.NotFoundHandler).Silent() + r.GET(healthPath, handlers.HealthHandler).Silent() + if i.Config.Server.HealthCheckPath != "" { + r.GET(i.Config.Server.HealthCheckPath, handlers.HealthHandler).Silent() + } + + r.GET( + "/*", i.ProcessingHandler.Execute, + r.WithSecret, r.WithCORS, r.WithPanic, r.WithReportError, r.WithMonitoring, + ) + + r.HEAD("/*", r.OkHandler, r.WithCORS) + r.OPTIONS("/*", r.OkHandler, r.WithCORS) + + return r, nil +} + +// Start runs the imgproxy server. This function blocks until the context is cancelled. +func (i *Imgproxy) StartServer(ctx context.Context) error { + go i.startMemoryTicker(ctx) + + ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) + + if err := prometheus.StartServer(cancel); err != nil { + return err + } + + router, err := i.BuildRouter() + if err != nil { + return err + } + + s, err := server.Start(cancel, router) + if err != nil { + return err + } + defer s.Shutdown(context.Background()) + + <-ctx.Done() + + return nil +} + +// startMemoryTicker starts a ticker that periodically frees memory and optionally logs memory stats +func (i *Imgproxy) startMemoryTicker(ctx context.Context) { + ticker := time.NewTicker(i.Config.Server.FreeMemoryInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + memory.Free() + + if i.Config.Server.LogMemStats { + memory.LogStats() + } + } + } +} diff --git a/init.go b/init.go new file mode 100644 index 00000000..c5687782 --- /dev/null +++ b/init.go @@ -0,0 +1,74 @@ +// init_once.go contains global initialization/teardown functions that should be called exactly once +// per process. +package imgproxy + +import ( + "github.com/DataDog/datadog-agent/pkg/trace/log" + "github.com/imgproxy/imgproxy/v3/config" + "github.com/imgproxy/imgproxy/v3/config/loadenv" + "github.com/imgproxy/imgproxy/v3/errorreport" + "github.com/imgproxy/imgproxy/v3/gliblog" + "github.com/imgproxy/imgproxy/v3/logger" + "github.com/imgproxy/imgproxy/v3/monitoring" + "github.com/imgproxy/imgproxy/v3/options" + "github.com/imgproxy/imgproxy/v3/processing" + "github.com/imgproxy/imgproxy/v3/vips" + "go.uber.org/automaxprocs/maxprocs" +) + +// Init performs the global resources initialization. This should be done once per process. +func Init() error { + if err := loadenv.Load(); err != nil { + return err + } + + if err := logger.Init(); err != nil { + return err + } + + // NOTE: This is temporary workaround. We have to load env vars in config.go before + // actually configuring ImgProxy instance because for now we use it as a source of truth. + // Will be removed once we move env var loading to imgproxy.go + if err := config.Configure(); err != nil { + return err + } + // NOTE: End of temporary workaround. + + gliblog.Init() + + maxprocs.Set(maxprocs.Logger(log.Debugf)) + + if err := monitoring.Init(); err != nil { + return err + } + + if err := vips.Init(); err != nil { + return err + } + + errorreport.Init() + + if err := processing.ValidatePreferredFormats(); err != nil { + vips.Shutdown() + return err + } + + if err := options.ParsePresets(config.Presets); err != nil { + vips.Shutdown() + return err + } + + if err := options.ValidatePresets(); err != nil { + vips.Shutdown() + return err + } + + return nil +} + +// Shutdown performs global cleanup +func Shutdown() { + monitoring.Stop() + errorreport.Close() + vips.Shutdown() +} diff --git a/integration/load_test.go b/integration/load_test.go index a0fb3bc7..71f40389 100644 --- a/integration/load_test.go +++ b/integration/load_test.go @@ -1,12 +1,12 @@ -//go:build integration -// +build integration - package integration import ( "bytes" + "context" "fmt" "image/png" + "io" + "net/http" "os" "path" "path/filepath" @@ -14,28 +14,50 @@ import ( "testing" "github.com/corona10/goimagehash" + "github.com/imgproxy/imgproxy/v3" + "github.com/imgproxy/imgproxy/v3/config" "github.com/imgproxy/imgproxy/v3/imagetype" + "github.com/imgproxy/imgproxy/v3/testutil" "github.com/imgproxy/imgproxy/v3/vips" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" ) const ( similarityThreshold = 5 // Distance between images to be considered similar ) +type LoadTestSuite struct { + suite.Suite + ctx context.Context + cancel context.CancelFunc + testData *testutil.TestDataProvider + testImagesPath string +} + +// SetupSuite starts imgproxy instance server +func (s *LoadTestSuite) SetupSuite() { + s.testData = testutil.NewTestDataProvider(s.T()) + s.testImagesPath = s.testData.Path("test-images") + s.ctx, s.cancel = context.WithCancel(s.T().Context()) + + s.startImgproxy(s.ctx) +} + +// TearDownSuite stops imgproxy instance server +func (s *LoadTestSuite) TearDownSuite() { + s.cancel() +} + // testLoadFolder fetches images iterates over images in the specified folder, // runs imgproxy on each image, and compares the result with the reference image // which is expected to be in the `integration` folder with the same name // but with `.png` extension. -func testLoadFolder(t *testing.T, cs, sourcePath, folder string) { - t.Logf("Testing folder: %s", folder) - - walkPath := path.Join(sourcePath, folder) +func (s *LoadTestSuite) testLoadFolder(folder string) { + walkPath := path.Join(s.testImagesPath, folder) // Iterate over the files in the source folder err := filepath.Walk(walkPath, func(path string, info os.FileInfo, err error) error { - require.NoError(t, err) + s.Require().NoError(err) // Skip directories if info.IsDir() { @@ -49,39 +71,79 @@ func testLoadFolder(t *testing.T, cs, sourcePath, folder string) { referencePath := strings.TrimSuffix(basePath, filepath.Ext(basePath)) + ".png" // Construct the full path to the reference image (integration/ folder) - referencePath = filepath.Join(sourcePath, "integration", folder, referencePath) + referencePath = filepath.Join(s.testImagesPath, "integration", folder, referencePath) // Construct the source URL for imgproxy (no processing) sourceUrl := fmt.Sprintf("insecure/plain/local:///%s/%s@png", folder, basePath) - imgproxyImageBytes := fetchImage(t, cs, sourceUrl) + imgproxyImageBytes := s.fetchImage(sourceUrl) imgproxyImage, err := png.Decode(bytes.NewReader(imgproxyImageBytes)) - require.NoError(t, err, "Failed to decode PNG image from imgproxy for %s", basePath) + s.Require().NoError(err, "Failed to decode PNG image from imgproxy for %s", basePath) referenceFile, err := os.Open(referencePath) - require.NoError(t, err) + s.Require().NoError(err) defer referenceFile.Close() referenceImage, err := png.Decode(referenceFile) - require.NoError(t, err, "Failed to decode PNG reference image for %s", referencePath) + s.Require().NoError(err, "Failed to decode PNG reference image for %s", referencePath) hash1, err := goimagehash.DifferenceHash(imgproxyImage) - require.NoError(t, err) + s.Require().NoError(err) hash2, err := goimagehash.DifferenceHash(referenceImage) - require.NoError(t, err) + s.Require().NoError(err) distance, err := hash1.Distance(hash2) - require.NoError(t, err) + s.Require().NoError(err) - assert.LessOrEqual(t, distance, similarityThreshold, + s.Require().LessOrEqual(distance, similarityThreshold, "Image %s differs from reference image %s by %d, which is greater than the allowed threshold of %d", basePath, referencePath, distance, similarityThreshold) return nil }) - require.NoError(t, err) + s.Require().NoError(err) +} + +// fetchImage fetches an image from the imgproxy server +func (s *LoadTestSuite) fetchImage(path string) []byte { + url := fmt.Sprintf("http://%s:%d/%s", bindHost, bindPort, path) + + resp, err := http.Get(url) + s.Require().NoError(err, "Failed to fetch image from %s", url) + defer resp.Body.Close() + + s.Require().Equal(http.StatusOK, resp.StatusCode, "Expected status code 200 OK, got %d, url: %s", resp.StatusCode, url) + + bytes, err := io.ReadAll(resp.Body) + s.Require().NoError(err, "Failed to read response body from %s", url) + + return bytes +} + +func (s *LoadTestSuite) startImgproxy(ctx context.Context) *imgproxy.Imgproxy { + c, err := imgproxy.LoadConfigFromEnv(nil) + s.Require().NoError(err) + + c.Server.Bind = ":" + fmt.Sprintf("%d", bindPort) + c.Transport.Local.Root = s.testImagesPath + c.Server.LogMemStats = true + + config.MaxAnimationFrames = 999 + config.DevelopmentErrorsMode = true + + i, err := imgproxy.New(ctx, c) + s.Require().NoError(err) + + go func() { + err = i.StartServer(ctx) + if err != nil { + s.T().Errorf("Imgproxy server exited with error: %v", err) + } + }() + + return i } // TestLoadSaveToPng ensures that our load pipeline works, @@ -89,53 +151,34 @@ func testLoadFolder(t *testing.T, cs, sourcePath, folder string) { // in the folder, it does the passthrough request through imgproxy: // no processing, just convert format of the source file to png. // Then, it compares the result with the reference image. -func TestLoadSaveToPng(t *testing.T) { - ctx := t.Context() - - // TODO: Will be moved to test suite (like in processing_test.go) - // Since we use SupportsLoad, we need to initialize vips - defer vips.Shutdown() // either way it needs to be deinitialized - err := vips.Init() - require.NoError(t, err, "Failed to initialize vips") - - path, err := testImagesPath(t) - require.NoError(t, err) - - cs := startImgproxy(t, ctx, path) - - if vips.SupportsLoad(imagetype.GIF) { - testLoadFolder(t, cs, path, "gif") +func (s *LoadTestSuite) TestLoadSaveToPng() { + testCases := []struct { + name string + imageType imagetype.Type + folderName string + }{ + {"GIF", imagetype.GIF, "gif"}, + {"JPEG", imagetype.JPEG, "jpg"}, + {"HEIC", imagetype.HEIC, "heif"}, + {"JXL", imagetype.JXL, "jxl"}, + {"SVG", imagetype.SVG, "svg"}, + {"TIFF", imagetype.TIFF, "tiff"}, + {"WEBP", imagetype.WEBP, "webp"}, + {"BMP", imagetype.BMP, "bmp"}, + {"ICO", imagetype.ICO, "ico"}, } - if vips.SupportsLoad(imagetype.JPEG) { - testLoadFolder(t, cs, path, "jpg") - } - - if vips.SupportsLoad(imagetype.HEIC) { - testLoadFolder(t, cs, path, "heif") - } - - if vips.SupportsLoad(imagetype.JXL) { - testLoadFolder(t, cs, path, "jxl") - } - - if vips.SupportsLoad(imagetype.SVG) { - testLoadFolder(t, cs, path, "svg") - } - - if vips.SupportsLoad(imagetype.TIFF) { - testLoadFolder(t, cs, path, "tiff") - } - - if vips.SupportsLoad(imagetype.WEBP) { - testLoadFolder(t, cs, path, "webp") - } - - if vips.SupportsLoad(imagetype.BMP) { - testLoadFolder(t, cs, path, "bmp") - } - - if vips.SupportsLoad(imagetype.ICO) { - testLoadFolder(t, cs, path, "ico") + for _, tc := range testCases { + s.T().Run(tc.name, func(t *testing.T) { + if vips.SupportsLoad(tc.imageType) { + s.testLoadFolder(tc.folderName) + } else { + t.Skipf("%s format not supported by VIPS", tc.name) + } + }) } } + +func TestIntegration(t *testing.T) { + suite.Run(t, new(LoadTestSuite)) +} diff --git a/integration/main_test.go b/integration/main_test.go new file mode 100644 index 00000000..c8b3da6f --- /dev/null +++ b/integration/main_test.go @@ -0,0 +1,20 @@ +package integration + +import ( + "os" + "testing" + + "github.com/imgproxy/imgproxy/v3" +) + +const ( + bindPort = 9090 // Port to bind imgproxy to + bindHost = "localhost" +) + +// TestMain performs global setup/teardown for the integration tests. +func TestMain(m *testing.M) { + imgproxy.Init() + os.Exit(m.Run()) + imgproxy.Shutdown() +} diff --git a/processing_handler_test.go b/integration/processing_handler_test.go similarity index 52% rename from processing_handler_test.go rename to integration/processing_handler_test.go index b83e1338..a191bae3 100644 --- a/processing_handler_test.go +++ b/integration/processing_handler_test.go @@ -1,9 +1,4 @@ -package main - -// NOTE: this test is the integration test for the processing handler. We can't extract and -// move it to handlers package yet because it depends on the global routes, methods and -// initialization functions. Once those would we wrapped into structures, we'll be able to move this test -// to where it belongs. +package integration import ( "fmt" @@ -11,70 +6,84 @@ import ( "net/http" "net/http/httptest" "os" - "path/filepath" "regexp" "testing" "time" - "github.com/sirupsen/logrus" - "github.com/stretchr/testify/suite" - + "github.com/imgproxy/imgproxy/v3" "github.com/imgproxy/imgproxy/v3/config" "github.com/imgproxy/imgproxy/v3/config/configurators" - "github.com/imgproxy/imgproxy/v3/fetcher" "github.com/imgproxy/imgproxy/v3/httpheaders" "github.com/imgproxy/imgproxy/v3/imagedata" "github.com/imgproxy/imgproxy/v3/imagetype" "github.com/imgproxy/imgproxy/v3/server" "github.com/imgproxy/imgproxy/v3/svg" "github.com/imgproxy/imgproxy/v3/testutil" - "github.com/imgproxy/imgproxy/v3/transport" "github.com/imgproxy/imgproxy/v3/vips" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/suite" ) +// ProcessingHandlerTestSuite is a test suite for testing image processing handler type ProcessingHandlerTestSuite struct { suite.Suite - router *server.Router + testData *testutil.TestDataProvider + config testutil.LazyObj[*imgproxy.Config] + router testutil.LazyObj[*server.Router] + imgproxy testutil.LazyObj[*imgproxy.Imgproxy] } func (s *ProcessingHandlerTestSuite) SetupSuite() { - config.Reset() - - wd, err := os.Getwd() - s.Require().NoError(err) - - s.T().Setenv("IMGPROXY_LOCAL_FILESYSTEM_ROOT", filepath.Join(wd, "/testdata")) - s.T().Setenv("IMGPROXY_CLIENT_KEEP_ALIVE_TIMEOUT", "0") - - err = initialize() - s.Require().NoError(err) - + // Silence all the logs logrus.SetOutput(io.Discard) - cfg := server.NewDefaultConfig() - r, err := server.NewRouter(&cfg) - s.Require().NoError(err) - - s.router = buildRouter(r) + // Initialize test data provider (local test files) + s.testData = testutil.NewTestDataProvider(s.T()) } func (s *ProcessingHandlerTestSuite) TeardownSuite() { - shutdown() logrus.SetOutput(os.Stdout) } -func (s *ProcessingHandlerTestSuite) SetupTest() { - wd, err := os.Getwd() - s.Require().NoError(err) +// setupObjs initializes lazy objects +func (s *ProcessingHandlerTestSuite) setupObjs() { + s.config = testutil.NewLazyObj(s.T(), func() (*imgproxy.Config, error) { + c, err := imgproxy.LoadConfigFromEnv(nil) + s.Require().NoError(err) - config.Reset() - config.AllowLoopbackSourceAddresses = true - config.LocalFileSystemRoot = filepath.Join(wd, "/testdata") - config.ClientKeepAliveTimeout = 0 + c.Transport.Local.Root = s.testData.Root() + c.Transport.HTTP.ClientKeepAliveTimeout = 0 + + return c, nil + }) + + s.imgproxy = testutil.NewLazyObj(s.T(), func() (*imgproxy.Imgproxy, error) { + return imgproxy.New(s.T().Context(), s.config()) + }) + + s.router = testutil.NewLazyObj(s.T(), func() (*server.Router, error) { + return s.imgproxy().BuildRouter() + }) } -func (s *ProcessingHandlerTestSuite) send(path string, header ...http.Header) *httptest.ResponseRecorder { +func (s *ProcessingHandlerTestSuite) SetupTest() { + config.Reset() // We reset config only at the start of each test + + // NOTE: This must be moved to security config + config.AllowLoopbackSourceAddresses = true + // NOTE: end note + + s.setupObjs() +} + +func (s *ProcessingHandlerTestSuite) SetupSubTest() { + // We use t.Run() a lot, so we need to reset lazy objects at the beginning of each subtest + s.setupObjs() +} + +// GET performs a GET request to the given path and returns the response recorder +func (s *ProcessingHandlerTestSuite) GET(path string, header ...http.Header) *http.Response { req := httptest.NewRequest(http.MethodGet, path, nil) rw := httptest.NewRecorder() @@ -82,84 +91,43 @@ func (s *ProcessingHandlerTestSuite) send(path string, header ...http.Header) *h req.Header = header[0] } - s.router.ServeHTTP(rw, req) + s.router().ServeHTTP(rw, req) - return rw -} - -func (s *ProcessingHandlerTestSuite) readTestFile(name string) []byte { - wd, err := os.Getwd() - s.Require().NoError(err) - - data, err := os.ReadFile(filepath.Join(wd, "testdata", name)) - s.Require().NoError(err) - - return data -} - -func (s *ProcessingHandlerTestSuite) readTestImageData(name string) imagedata.ImageData { - wd, err := os.Getwd() - s.Require().NoError(err) - - data, err := os.ReadFile(filepath.Join(wd, "testdata", name)) - s.Require().NoError(err) - - // NOTE: Temporary workaround. We create imagedata.Factory here - // because currently configuration is changed via env vars - // or config. We need to pick up those config changes. - // This will be addressed in the next PR - trc, err := transport.LoadConfigFromEnv(nil) - s.Require().NoError(err) - - tr, err := transport.New(trc) - s.Require().NoError(err) - - fc, err := fetcher.LoadConfigFromEnv(nil) - s.Require().NoError(err) - - f, err := fetcher.New(tr, fc) - s.Require().NoError(err) - - idf := imagedata.NewFactory(f) - // end of workaround - - imgdata, err := idf.NewFromBytes(data) - s.Require().NoError(err) - - return imgdata -} - -func (s *ProcessingHandlerTestSuite) TestRequest() { - rw := s.send("/unsafe/rs:fill:4:4/plain/local:///test1.png") - res := rw.Result() - - s.Require().Equal(200, res.StatusCode) - s.Require().Equal("image/png", res.Header.Get("Content-Type")) - - format, err := imagetype.Detect(res.Body) - - s.Require().NoError(err) - s.Require().Equal(imagetype.PNG, format) + return rw.Result() } func (s *ProcessingHandlerTestSuite) TestSignatureValidationFailure() { config.Keys = [][]byte{[]byte("test-key")} config.Salts = [][]byte{[]byte("test-salt")} - rw := s.send("/unsafe/rs:fill:4:4/plain/local:///test1.png") - res := rw.Result() + tt := []struct { + name string + url string + statusCode int + }{ + { + name: "NoSignature", + url: "/unsafe/rs:fill:4:4/plain/local:///test1.png", + statusCode: http.StatusForbidden, + }, + { + name: "BadSignature", + url: "/bad-signature/rs:fill:4:4/plain/local:///test1.png", + statusCode: http.StatusForbidden, + }, + { + name: "ValidSignature", + url: "/My9d3xq_PYpVHsPrCyww0Kh1w5KZeZhIlWhsa4az1TI/rs:fill:4:4/plain/local:///test1.png", + statusCode: http.StatusOK, + }, + } - s.Require().Equal(403, res.StatusCode) -} - -func (s *ProcessingHandlerTestSuite) TestSignatureValidationSuccess() { - config.Keys = [][]byte{[]byte("test-key")} - config.Salts = [][]byte{[]byte("test-salt")} - - rw := s.send("/My9d3xq_PYpVHsPrCyww0Kh1w5KZeZhIlWhsa4az1TI/rs:fill:4:4/plain/local:///test1.png") - res := rw.Result() - - s.Require().Equal(200, res.StatusCode) + for _, tc := range tt { + s.Run(tc.name, func() { + res := s.GET(tc.url) + s.Require().Equal(tc.statusCode, res.StatusCode) + }) + } } func (s *ProcessingHandlerTestSuite) TestSourceValidation() { @@ -176,19 +144,16 @@ func (s *ProcessingHandlerTestSuite) TestSourceValidation() { name: "match http URL without wildcard", allowedSources: []string{"local://", "http://images.dev/"}, requestPath: "/unsafe/plain/http://images.dev/lorem/ipsum.jpg", - expectedError: false, }, { name: "match http URL with wildcard in hostname single level", allowedSources: []string{"local://", "http://*.mycdn.dev/"}, requestPath: "/unsafe/plain/http://a-1.mycdn.dev/lorem/ipsum.jpg", - expectedError: false, }, { name: "match http URL with wildcard in hostname multiple levels", allowedSources: []string{"local://", "http://*.mycdn.dev/"}, requestPath: "/unsafe/plain/http://a-1.b-2.mycdn.dev/lorem/ipsum.jpg", - expectedError: false, }, { name: "no match s3 URL with allowed local and http URLs", @@ -206,26 +171,24 @@ func (s *ProcessingHandlerTestSuite) TestSourceValidation() { for _, tc := range tt { s.Run(tc.name, func() { - exps := make([]*regexp.Regexp, len(tc.allowedSources)) + config.AllowedSources = make([]*regexp.Regexp, len(tc.allowedSources)) for i, pattern := range tc.allowedSources { - exps[i] = configurators.RegexpFromPattern(pattern) + config.AllowedSources[i] = configurators.RegexpFromPattern(pattern) } - config.AllowedSources = exps - rw := s.send(tc.requestPath) - res := rw.Result() + res := s.GET(tc.requestPath) if tc.expectedError { - s.Require().Equal(404, res.StatusCode) + s.Require().Equal(http.StatusNotFound, res.StatusCode) } else { - s.Require().Equal(200, res.StatusCode) + s.Require().Equal(http.StatusOK, res.StatusCode) } }) } } func (s *ProcessingHandlerTestSuite) TestSourceNetworkValidation() { - data := s.readTestFile("test1.png") + data := s.testData.Read("test1.png") server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(200) @@ -233,180 +196,158 @@ func (s *ProcessingHandlerTestSuite) TestSourceNetworkValidation() { })) defer server.Close() - var rw *httptest.ResponseRecorder + url := fmt.Sprintf("/unsafe/rs:fill:4:4/plain/%s/test1.png", server.URL) - u := fmt.Sprintf("/unsafe/rs:fill:4:4/plain/%s/test1.png", server.URL) + // We wrap this in a subtest to reset s.router() + s.Run("AllowLoopbackSourceAddressesTrue", func() { + config.AllowLoopbackSourceAddresses = true + res := s.GET(url) + s.Require().Equal(http.StatusOK, res.StatusCode) + }) - rw = s.send(u) - s.Require().Equal(200, rw.Result().StatusCode) - - config.AllowLoopbackSourceAddresses = false - rw = s.send(u) - s.Require().Equal(404, rw.Result().StatusCode) + s.Run("AllowLoopbackSourceAddressesFalse", func() { + config.AllowLoopbackSourceAddresses = false + res := s.GET(url) + s.Require().Equal(http.StatusNotFound, res.StatusCode) + }) } func (s *ProcessingHandlerTestSuite) TestSourceFormatNotSupported() { vips.DisableLoadSupport(imagetype.PNG) defer vips.ResetLoadSupport() - rw := s.send("/unsafe/rs:fill:4:4/plain/local:///test1.png") - res := rw.Result() - - s.Require().Equal(422, res.StatusCode) + res := s.GET("/unsafe/rs:fill:4:4/plain/local:///test1.png") + s.Require().Equal(http.StatusUnprocessableEntity, res.StatusCode) } func (s *ProcessingHandlerTestSuite) TestResultingFormatNotSupported() { vips.DisableSaveSupport(imagetype.PNG) defer vips.ResetSaveSupport() - rw := s.send("/unsafe/rs:fill:4:4/plain/local:///test1.png@png") - res := rw.Result() - - s.Require().Equal(422, res.StatusCode) + res := s.GET("/unsafe/rs:fill:4:4/plain/local:///test1.png@png") + s.Require().Equal(http.StatusUnprocessableEntity, res.StatusCode) } func (s *ProcessingHandlerTestSuite) TestSkipProcessingConfig() { config.SkipProcessingFormats = []imagetype.Type{imagetype.PNG} - rw := s.send("/unsafe/rs:fill:4:4/plain/local:///test1.png") - res := rw.Result() + res := s.GET("/unsafe/rs:fill:4:4/plain/local:///test1.png") - s.Require().Equal(200, res.StatusCode) - - expected := s.readTestImageData("test1.png") - - s.Require().True(testutil.ReadersEqual(s.T(), expected.Reader(), res.Body)) + s.Require().Equal(http.StatusOK, res.StatusCode) + s.Require().True(s.testData.FileEqualsToReader("test1.png", res.Body)) } func (s *ProcessingHandlerTestSuite) TestSkipProcessingPO() { - rw := s.send("/unsafe/rs:fill:4:4/skp:png/plain/local:///test1.png") - res := rw.Result() + res := s.GET("/unsafe/rs:fill:4:4/skp:png/plain/local:///test1.png") - s.Require().Equal(200, res.StatusCode) - - expected := s.readTestImageData("test1.png") - - s.Require().True(testutil.ReadersEqual(s.T(), expected.Reader(), res.Body)) + s.Require().Equal(http.StatusOK, res.StatusCode) + s.Require().True(s.testData.FileEqualsToReader("test1.png", res.Body)) } func (s *ProcessingHandlerTestSuite) TestSkipProcessingSameFormat() { config.SkipProcessingFormats = []imagetype.Type{imagetype.PNG} - rw := s.send("/unsafe/rs:fill:4:4/plain/local:///test1.png@png") - res := rw.Result() + res := s.GET("/unsafe/rs:fill:4:4/plain/local:///test1.png@png") - s.Require().Equal(200, res.StatusCode) - - expected := s.readTestImageData("test1.png") - - s.Require().True(testutil.ReadersEqual(s.T(), expected.Reader(), res.Body)) + s.Require().Equal(http.StatusOK, res.StatusCode) + s.Require().True(s.testData.FileEqualsToReader("test1.png", res.Body)) } func (s *ProcessingHandlerTestSuite) TestSkipProcessingDifferentFormat() { config.SkipProcessingFormats = []imagetype.Type{imagetype.PNG} - rw := s.send("/unsafe/rs:fill:4:4/plain/local:///test1.png@jpg") - res := rw.Result() + res := s.GET("/unsafe/rs:fill:4:4/plain/local:///test1.png@jpg") - s.Require().Equal(200, res.StatusCode) - - expected := s.readTestImageData("test1.png") - - s.Require().False(testutil.ReadersEqual(s.T(), expected.Reader(), res.Body)) + s.Require().Equal(http.StatusOK, res.StatusCode) + s.Require().False(s.testData.FileEqualsToReader("test1.png", res.Body)) } func (s *ProcessingHandlerTestSuite) TestSkipProcessingSVG() { - rw := s.send("/unsafe/rs:fill:4:4/plain/local:///test1.svg") - res := rw.Result() + res := s.GET("/unsafe/rs:fill:4:4/plain/local:///test1.svg") - s.Require().Equal(200, res.StatusCode) + s.Require().Equal(http.StatusOK, res.StatusCode) - expected, err := svg.Sanitize(s.readTestImageData("test1.svg")) + data, err := s.imgproxy().ImageDataFactory.NewFromBytes(s.testData.Read("test1.svg")) + s.Require().NoError(err) + + expected, err := svg.Sanitize(data) s.Require().NoError(err) s.Require().True(testutil.ReadersEqual(s.T(), expected.Reader(), res.Body)) } func (s *ProcessingHandlerTestSuite) TestNotSkipProcessingSVGToJPG() { - rw := s.send("/unsafe/rs:fill:4:4/plain/local:///test1.svg@jpg") - res := rw.Result() + res := s.GET("/unsafe/rs:fill:4:4/plain/local:///test1.svg@jpg") - s.Require().Equal(200, res.StatusCode) - - expected := s.readTestImageData("test1.svg") - - s.Require().False(testutil.ReadersEqual(s.T(), expected.Reader(), res.Body)) + s.Require().Equal(http.StatusOK, res.StatusCode) + s.Require().False(s.testData.FileEqualsToReader("test1.svg", res.Body)) } func (s *ProcessingHandlerTestSuite) TestErrorSavingToSVG() { - rw := s.send("/unsafe/rs:fill:4:4/plain/local:///test1.png@svg") - res := rw.Result() + res := s.GET("/unsafe/rs:fill:4:4/plain/local:///test1.png@svg") - s.Require().Equal(422, res.StatusCode) + s.Require().Equal(http.StatusUnprocessableEntity, res.StatusCode) } func (s *ProcessingHandlerTestSuite) TestCacheControlPassthroughCacheControl() { - config.CacheControlPassthrough = true + s.config().HeaderWriter.CacheControlPassthrough = true ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - rw.Header().Set("Cache-Control", "max-age=1234, public") - rw.Header().Set("Expires", time.Now().Add(time.Hour).UTC().Format(http.TimeFormat)) + rw.Header().Set(httpheaders.CacheControl, "max-age=1234, public") + rw.Header().Set(httpheaders.Expires, time.Now().Add(time.Hour).UTC().Format(http.TimeFormat)) rw.WriteHeader(200) - rw.Write(s.readTestFile("test1.png")) + rw.Write(s.testData.Read("test1.png")) })) defer ts.Close() - rw := s.send("/unsafe/rs:fill:4:4/plain/" + ts.URL) - res := rw.Result() + res := s.GET("/unsafe/rs:fill:4:4/plain/" + ts.URL) - s.Require().Equal("max-age=1234, public", res.Header.Get("Cache-Control")) - s.Require().Empty(res.Header.Get("Expires")) + s.Require().Equal(http.StatusOK, res.StatusCode) + s.Require().Equal("max-age=1234, public", res.Header.Get(httpheaders.CacheControl)) + s.Require().Empty(res.Header.Get(httpheaders.Expires)) } func (s *ProcessingHandlerTestSuite) TestCacheControlPassthroughExpires() { config.CacheControlPassthrough = true ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - rw.Header().Set("Expires", time.Now().Add(1239*time.Second).UTC().Format(http.TimeFormat)) + rw.Header().Set(httpheaders.Expires, time.Now().Add(1239*time.Second).UTC().Format(http.TimeFormat)) rw.WriteHeader(200) - rw.Write(s.readTestFile("test1.png")) + rw.Write(s.testData.Read("test1.png")) })) defer ts.Close() - rw := s.send("/unsafe/rs:fill:4:4/plain/" + ts.URL) - res := rw.Result() + res := s.GET("/unsafe/rs:fill:4:4/plain/" + ts.URL) // Use regex to allow some delay - s.Require().Regexp("max-age=123[0-9], public", res.Header.Get("Cache-Control")) - s.Require().Empty(res.Header.Get("Expires")) + s.Require().Regexp("max-age=123[0-9], public", res.Header.Get(httpheaders.CacheControl)) + s.Require().Empty(res.Header.Get(httpheaders.Expires)) } func (s *ProcessingHandlerTestSuite) TestCacheControlPassthroughDisabled() { config.CacheControlPassthrough = false ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - rw.Header().Set("Cache-Control", "max-age=1234, public") - rw.Header().Set("Expires", time.Now().Add(time.Hour).UTC().Format(http.TimeFormat)) + rw.Header().Set(httpheaders.CacheControl, "max-age=1234, public") + rw.Header().Set(httpheaders.Expires, time.Now().Add(time.Hour).UTC().Format(http.TimeFormat)) rw.WriteHeader(200) - rw.Write(s.readTestFile("test1.png")) + rw.Write(s.testData.Read("test1.png")) })) defer ts.Close() - rw := s.send("/unsafe/rs:fill:4:4/plain/" + ts.URL) - res := rw.Result() + res := s.GET("/unsafe/rs:fill:4:4/plain/" + ts.URL) - s.Require().NotEqual("max-age=1234, public", res.Header.Get("Cache-Control")) - s.Require().Empty(res.Header.Get("Expires")) + s.Require().NotEqual("max-age=1234, public", res.Header.Get(httpheaders.CacheControl)) + s.Require().Empty(res.Header.Get(httpheaders.Expires)) } func (s *ProcessingHandlerTestSuite) TestETagDisabled() { config.ETagEnabled = false - rw := s.send("/unsafe/rs:fill:4:4/plain/local:///test1.png") - res := rw.Result() + res := s.GET("/unsafe/rs:fill:4:4/plain/local:///test1.png") s.Require().Equal(200, res.StatusCode) - s.Require().Empty(res.Header.Get("ETag")) + s.Require().Empty(res.Header.Get(httpheaders.Etag)) } func (s *ProcessingHandlerTestSuite) TestETagDataMatch() { @@ -425,8 +366,7 @@ func (s *ProcessingHandlerTestSuite) TestETagDataMatch() { header := make(http.Header) header.Set(httpheaders.IfNoneMatch, etag) - rw := s.send(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header) - res := rw.Result() + res := s.GET(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header) s.Require().Equal(304, res.StatusCode) s.Require().Equal(etag, res.Header.Get(httpheaders.Etag)) @@ -435,39 +375,37 @@ func (s *ProcessingHandlerTestSuite) TestETagDataMatch() { func (s *ProcessingHandlerTestSuite) TestLastModifiedEnabled() { config.LastModifiedEnabled = true ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - rw.Header().Set("Last-Modified", "Wed, 21 Oct 2015 07:28:00 GMT") + rw.Header().Set(httpheaders.LastModified, "Wed, 21 Oct 2015 07:28:00 GMT") rw.WriteHeader(200) - rw.Write(s.readTestFile("test1.png")) + rw.Write(s.testData.Read("test1.png")) })) defer ts.Close() - rw := s.send("/unsafe/rs:fill:4:4/plain/" + ts.URL) - res := rw.Result() + res := s.GET("/unsafe/rs:fill:4:4/plain/" + ts.URL) - s.Require().Equal("Wed, 21 Oct 2015 07:28:00 GMT", res.Header.Get("Last-Modified")) + s.Require().Equal("Wed, 21 Oct 2015 07:28:00 GMT", res.Header.Get(httpheaders.LastModified)) } func (s *ProcessingHandlerTestSuite) TestLastModifiedDisabled() { config.LastModifiedEnabled = false ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - rw.Header().Set("Last-Modified", "Wed, 21 Oct 2015 07:28:00 GMT") + rw.Header().Set(httpheaders.LastModified, "Wed, 21 Oct 2015 07:28:00 GMT") rw.WriteHeader(200) - rw.Write(s.readTestFile("test1.png")) + rw.Write(s.testData.Read("test1.png")) })) defer ts.Close() - rw := s.send("/unsafe/rs:fill:4:4/plain/" + ts.URL) - res := rw.Result() + res := s.GET("/unsafe/rs:fill:4:4/plain/" + ts.URL) - s.Require().Empty(res.Header.Get("Last-Modified")) + s.Require().Empty(res.Header.Get(httpheaders.LastModified)) } func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqExactMatchLastModifiedDisabled() { config.LastModifiedEnabled = false - data := s.readTestFile("test1.png") + data := s.testData.Read("test1.png") lastModified := "Wed, 21 Oct 2015 07:28:00 GMT" ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - modifiedSince := r.Header.Get("If-Modified-Since") + modifiedSince := r.Header.Get(httpheaders.IfModifiedSince) s.Empty(modifiedSince) rw.WriteHeader(200) rw.Write(data) @@ -475,9 +413,8 @@ func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqExactMatchLastModifiedD defer ts.Close() header := make(http.Header) - header.Set("If-Modified-Since", lastModified) - rw := s.send(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header) - res := rw.Result() + header.Set(httpheaders.IfModifiedSince, lastModified) + res := s.GET(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header) s.Require().Equal(200, res.StatusCode) } @@ -486,25 +423,24 @@ func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqExactMatchLastModifiedE config.LastModifiedEnabled = true lastModified := "Wed, 21 Oct 2015 07:28:00 GMT" ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - modifiedSince := r.Header.Get("If-Modified-Since") + modifiedSince := r.Header.Get(httpheaders.IfModifiedSince) s.Equal(lastModified, modifiedSince) rw.WriteHeader(304) })) defer ts.Close() header := make(http.Header) - header.Set("If-Modified-Since", lastModified) - rw := s.send(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header) - res := rw.Result() + header.Set(httpheaders.IfModifiedSince, lastModified) + res := s.GET(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header) s.Require().Equal(304, res.StatusCode) } func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqCompareMoreRecentLastModifiedDisabled() { - data := s.readTestFile("test1.png") + data := s.testData.Read("test1.png") config.LastModifiedEnabled = false ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - modifiedSince := r.Header.Get("If-Modified-Since") + modifiedSince := r.Header.Get(httpheaders.IfModifiedSince) s.Empty(modifiedSince) rw.WriteHeader(200) rw.Write(data) @@ -514,10 +450,9 @@ func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqCompareMoreRecentLastMo recentTimestamp := "Thu, 25 Feb 2021 01:45:00 GMT" header := make(http.Header) - header.Set("If-Modified-Since", recentTimestamp) - rw := s.send(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header) - res := rw.Result() + header.Set(httpheaders.IfModifiedSince, recentTimestamp) + res := s.GET(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header) s.Require().Equal(200, res.StatusCode) } @@ -525,7 +460,7 @@ func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqCompareMoreRecentLastMo config.LastModifiedEnabled = true ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { fileLastModified, _ := time.Parse(http.TimeFormat, "Wed, 21 Oct 2015 07:28:00 GMT") - modifiedSince := r.Header.Get("If-Modified-Since") + modifiedSince := r.Header.Get(httpheaders.IfModifiedSince) parsedModifiedSince, err := time.Parse(http.TimeFormat, modifiedSince) s.NoError(err) s.True(fileLastModified.Before(parsedModifiedSince)) @@ -536,18 +471,17 @@ func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqCompareMoreRecentLastMo recentTimestamp := "Thu, 25 Feb 2021 01:45:00 GMT" header := make(http.Header) - header.Set("If-Modified-Since", recentTimestamp) - rw := s.send(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header) - res := rw.Result() + header.Set(httpheaders.IfModifiedSince, recentTimestamp) + res := s.GET(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header) s.Require().Equal(304, res.StatusCode) } func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqCompareTooOldLastModifiedDisabled() { - config.LastModifiedEnabled = false - data := s.readTestFile("test1.png") + s.config().ProcessingHandler.LastModifiedEnabled = false + data := s.testData.Read("test1.png") ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - modifiedSince := r.Header.Get("If-Modified-Since") + modifiedSince := r.Header.Get(httpheaders.IfModifiedSince) s.Empty(modifiedSince) rw.WriteHeader(200) rw.Write(data) @@ -557,19 +491,18 @@ func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqCompareTooOldLastModifi oldTimestamp := "Tue, 01 Oct 2013 17:31:00 GMT" header := make(http.Header) - header.Set("If-Modified-Since", oldTimestamp) - rw := s.send(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header) - res := rw.Result() + header.Set(httpheaders.IfModifiedSince, oldTimestamp) + res := s.GET(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header) s.Require().Equal(200, res.StatusCode) } func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqCompareTooOldLastModifiedEnabled() { config.LastModifiedEnabled = true - data := s.readTestFile("test1.png") + data := s.testData.Read("test1.png") ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { fileLastModified, _ := time.Parse(http.TimeFormat, "Wed, 21 Oct 2015 07:28:00 GMT") - modifiedSince := r.Header.Get("If-Modified-Since") + modifiedSince := r.Header.Get(httpheaders.IfModifiedSince) parsedModifiedSince, err := time.Parse(http.TimeFormat, modifiedSince) s.NoError(err) s.True(fileLastModified.After(parsedModifiedSince)) @@ -581,9 +514,8 @@ func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqCompareTooOldLastModifi oldTimestamp := "Tue, 01 Oct 2013 17:31:00 GMT" header := make(http.Header) - header.Set("If-Modified-Since", oldTimestamp) - rw := s.send(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header) - res := rw.Result() + header.Set(httpheaders.IfModifiedSince, oldTimestamp) + res := s.GET(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header) s.Require().Equal(200, res.StatusCode) } @@ -591,43 +523,40 @@ func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqCompareTooOldLastModifi func (s *ProcessingHandlerTestSuite) TestAlwaysRasterizeSvg() { config.AlwaysRasterizeSvg = true - rw := s.send("/unsafe/rs:fill:40:40/plain/local:///test1.svg") - res := rw.Result() + res := s.GET("/unsafe/rs:fill:40:40/plain/local:///test1.svg") s.Require().Equal(200, res.StatusCode) - s.Require().Equal("image/png", res.Header.Get("Content-Type")) + s.Require().Equal("image/png", res.Header.Get(httpheaders.ContentType)) } func (s *ProcessingHandlerTestSuite) TestAlwaysRasterizeSvgWithEnforceAvif() { config.AlwaysRasterizeSvg = true config.EnforceWebp = true - rw := s.send("/unsafe/plain/local:///test1.svg", http.Header{"Accept": []string{"image/webp"}}) - res := rw.Result() + res := s.GET("/unsafe/plain/local:///test1.svg", http.Header{"Accept": []string{"image/webp"}}) s.Require().Equal(200, res.StatusCode) - s.Require().Equal("image/webp", res.Header.Get("Content-Type")) + s.Require().Equal("image/webp", res.Header.Get(httpheaders.ContentType)) } func (s *ProcessingHandlerTestSuite) TestAlwaysRasterizeSvgDisabled() { config.AlwaysRasterizeSvg = false config.EnforceWebp = true - rw := s.send("/unsafe/plain/local:///test1.svg") - res := rw.Result() + res := s.GET("/unsafe/plain/local:///test1.svg") s.Require().Equal(200, res.StatusCode) - s.Require().Equal("image/svg+xml", res.Header.Get("Content-Type")) + s.Require().Equal("image/svg+xml", res.Header.Get(httpheaders.ContentType)) } func (s *ProcessingHandlerTestSuite) TestAlwaysRasterizeSvgWithFormat() { config.AlwaysRasterizeSvg = true config.SkipProcessingFormats = []imagetype.Type{imagetype.SVG} - rw := s.send("/unsafe/plain/local:///test1.svg@svg") - res := rw.Result() + + res := s.GET("/unsafe/plain/local:///test1.svg@svg") s.Require().Equal(200, res.StatusCode) - s.Require().Equal("image/svg+xml", res.Header.Get("Content-Type")) + s.Require().Equal("image/svg+xml", res.Header.Get(httpheaders.ContentType)) } func (s *ProcessingHandlerTestSuite) TestMaxSrcFileSizeGlobal() { @@ -635,12 +564,11 @@ func (s *ProcessingHandlerTestSuite) TestMaxSrcFileSizeGlobal() { ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(200) - rw.Write(s.readTestFile("test1.png")) + rw.Write(s.testData.Read("test1.png")) })) defer ts.Close() - rw := s.send("/unsafe/rs:fill:4:4/plain/" + ts.URL) - res := rw.Result() + res := s.GET("/unsafe/rs:fill:4:4/plain/" + ts.URL) s.Require().Equal(422, res.StatusCode) } diff --git a/integration/test_utils.go b/integration/test_utils.go deleted file mode 100644 index 421e5a94..00000000 --- a/integration/test_utils.go +++ /dev/null @@ -1,137 +0,0 @@ -//go:build integration -// +build integration - -// Integration test helpers for imgproxy. -// We use regular `go build` instead of Docker to make sure -// tests run in the same environment as other tests, -// including in CI, where everything runs in a custom Docker image -// against the different libvips versions. - -package integration - -import ( - "context" - "fmt" - "io" - "net" - "net/http" - "os" - "os/exec" - "path" - "path/filepath" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -const ( - buildContext = ".." // Source code folder - binPath = "/tmp/imgproxy-test" // Path to the built imgproxy binary - bindPort = 9090 // Port to bind imgproxy to - bindHost = "127.0.0.1" // Host to bind imgproxy to -) - -var ( - buildCmd = []string{"build", "-v", "-ldflags=-s -w", "-o", binPath} // imgproxy build command -) - -// waitForPort tries to connect to host:port until successful or timeout -func waitForPort(host string, port int, timeout time.Duration) error { - var address string - if net.ParseIP(host) != nil && net.ParseIP(host).To4() == nil { - // IPv6 address, wrap in brackets - address = fmt.Sprintf("[%s]:%d", host, port) - } else { - address = fmt.Sprintf("%s:%d", host, port) - } - deadline := time.Now().Add(timeout) - - for time.Now().Before(deadline) { - conn, err := net.DialTimeout("tcp", address, 500*time.Millisecond) - if err == nil { - conn.Close() - return nil // port is open - } - time.Sleep(200 * time.Millisecond) - } - - return fmt.Errorf("timeout waiting for port %s", address) -} - -func startImgproxy(t *testing.T, ctx context.Context, testImagesPath string) string { - // Build the imgproxy binary - buildCmd := exec.Command("go", buildCmd...) - buildCmd.Dir = buildContext - buildCmd.Env = os.Environ() - buildOut, err := buildCmd.CombinedOutput() - require.NoError(t, err, "failed to build imgproxy: %v\n%s", err, string(buildOut)) - - // Start imgproxy in the background - cmd := exec.CommandContext(ctx, binPath) - - // Set environment variables for imgproxy - cmd.Env = append(os.Environ(), "IMGPROXY_BIND=:"+fmt.Sprintf("%d", bindPort)) - cmd.Env = append(cmd.Env, "IMGPROXY_LOCAL_FILESYSTEM_ROOT="+testImagesPath) - cmd.Env = append(cmd.Env, "IMGPROXY_MAX_ANIMATION_FRAMES=999") - cmd.Env = append(cmd.Env, "IMGPROXY_VIPS_LEAK_CHECK=true") - cmd.Env = append(cmd.Env, "IMGPROXY_LOG_MEM_STATS=true") - cmd.Env = append(cmd.Env, "IMGPROXY_DEVELOPMENT_ERRORS_MODE=true") - - // That one is for the build logs - stdout, _ := os.CreateTemp("", "imgproxy-stdout-*") - stderr, _ := os.CreateTemp("", "imgproxy-stderr-*") - cmd.Stdout = stdout - cmd.Stderr = stderr - - err = cmd.Start() - require.NoError(t, err, "failed to start imgproxy: %v", err) - - // Wait for port 8090 to be available - err = waitForPort(bindHost, bindPort, 5*time.Second) - if err != nil { - cmd.Process.Kill() - require.NoError(t, err, "imgproxy did not start in time") - } - - // Return a dummy container (nil) and connection string - t.Cleanup(func() { - cmd.Process.Kill() - stdout.Close() - stderr.Close() - os.Remove(stdout.Name()) - os.Remove(stderr.Name()) - os.Remove(binPath) - }) - - return fmt.Sprintf("%s:%d", bindHost, bindPort) -} - -// fetchImage fetches an image from the imgproxy server -func fetchImage(t *testing.T, cs string, path string) []byte { - url := fmt.Sprintf("http://%s/%s", cs, path) - - resp, err := http.Get(url) - require.NoError(t, err, "Failed to fetch image from %s", url) - defer resp.Body.Close() - - require.Equal(t, http.StatusOK, resp.StatusCode, "Expected status code 200 OK, got %d, url: %s", resp.StatusCode, url) - - bytes, err := io.ReadAll(resp.Body) - require.NoError(t, err, "Failed to read response body from %s", url) - - return bytes -} - -// testImagesPath returns the absolute path to the test images directory -func testImagesPath(t *testing.T) (string, error) { - // Get current working directory - dir, err := os.Getwd() - require.NoError(t, err) - - // Convert to absolute path (if it's not already) - absPath, err := filepath.Abs(dir) - require.NoError(t, err) - - return path.Join(absPath, "../testdata/test-images"), nil -} diff --git a/main.go b/main.go deleted file mode 100644 index 8d9af823..00000000 --- a/main.go +++ /dev/null @@ -1,276 +0,0 @@ -package main - -import ( - "context" - "flag" - "fmt" - "net/http" - "os" - "os/signal" - "syscall" - "time" - - log "github.com/sirupsen/logrus" - "go.uber.org/automaxprocs/maxprocs" - - "github.com/imgproxy/imgproxy/v3/auximageprovider" - "github.com/imgproxy/imgproxy/v3/config" - "github.com/imgproxy/imgproxy/v3/config/loadenv" - "github.com/imgproxy/imgproxy/v3/errorreport" - "github.com/imgproxy/imgproxy/v3/fetcher" - "github.com/imgproxy/imgproxy/v3/gliblog" - "github.com/imgproxy/imgproxy/v3/handlers" - processingHandler "github.com/imgproxy/imgproxy/v3/handlers/processing" - "github.com/imgproxy/imgproxy/v3/handlers/stream" - "github.com/imgproxy/imgproxy/v3/headerwriter" - "github.com/imgproxy/imgproxy/v3/ierrors" - "github.com/imgproxy/imgproxy/v3/imagedata" - "github.com/imgproxy/imgproxy/v3/logger" - "github.com/imgproxy/imgproxy/v3/memory" - "github.com/imgproxy/imgproxy/v3/monitoring" - "github.com/imgproxy/imgproxy/v3/monitoring/prometheus" - "github.com/imgproxy/imgproxy/v3/options" - "github.com/imgproxy/imgproxy/v3/processing" - "github.com/imgproxy/imgproxy/v3/semaphores" - "github.com/imgproxy/imgproxy/v3/server" - "github.com/imgproxy/imgproxy/v3/transport" - "github.com/imgproxy/imgproxy/v3/version" - "github.com/imgproxy/imgproxy/v3/vips" -) - -const ( - faviconPath = "/favicon.ico" - healthPath = "/health" - categoryConfig = "(tmp)config" // NOTE: temporary category for reporting configration errors -) - -func callHandleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) error { - // NOTE: This is temporary, will be moved level up at once - hwc, err := headerwriter.LoadConfigFromEnv(nil) - if err != nil { - return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig)) - } - - hw, err := headerwriter.New(hwc) - if err != nil { - return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig)) - } - - sc, err := stream.LoadConfigFromEnv(nil) - if err != nil { - return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig)) - } - - tcfg, err := transport.LoadConfigFromEnv(nil) - if err != nil { - return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig)) - } - - tr, err := transport.New(tcfg) - if err != nil { - return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig)) - } - - fc, err := fetcher.LoadConfigFromEnv(nil) - if err != nil { - return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig)) - } - - fetcher, err := fetcher.New(tr, fc) - if err != nil { - return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig)) - } - - idf := imagedata.NewFactory(fetcher) - - stream, err := stream.New(sc, hw, fetcher) - if err != nil { - return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig)) - } - - phc, err := processingHandler.LoadConfigFromEnv(nil) - if err != nil { - return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig)) - } - - semc, err := semaphores.LoadConfigFromEnv(nil) - if err != nil { - return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig)) - } - - semaphores, err := semaphores.New(semc) - if err != nil { - return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig)) - } - - fic, err := auximageprovider.LoadFallbackStaticConfigFromEnv(nil) - if err != nil { - return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig)) - } - - fi, err := auximageprovider.NewStaticProvider( - r.Context(), - fic, - "fallback image", - idf, - ) - if err != nil { - return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig)) - } - - wic, err := auximageprovider.LoadWatermarkStaticConfigFromEnv(nil) - if err != nil { - return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig)) - } - - wi, err := auximageprovider.NewStaticProvider( - r.Context(), - wic, - "watermark image", - idf, - ) - if err != nil { - return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig)) - } - - h, err := processingHandler.New(stream, hw, semaphores, fi, wi, idf, phc) - if err != nil { - return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryConfig)) - } - - return h.Execute(reqID, rw, r) -} - -func buildRouter(r *server.Router) *server.Router { - r.GET("/", handlers.LandingHandler) - r.GET("", handlers.LandingHandler) - - r.GET(faviconPath, r.NotFoundHandler).Silent() - r.GET(healthPath, handlers.HealthHandler).Silent() - if config.HealthCheckPath != "" { - r.GET(config.HealthCheckPath, handlers.HealthHandler).Silent() - } - - r.GET( - "/*", callHandleProcessing, - r.WithSecret, r.WithCORS, r.WithPanic, r.WithReportError, r.WithMonitoring, - ) - - r.HEAD("/*", r.OkHandler, r.WithCORS) - r.OPTIONS("/*", r.OkHandler, r.WithCORS) - - return r -} - -func initialize() error { - if err := loadenv.Load(); err != nil { - return err - } - - if err := logger.Init(); err != nil { - return err - } - - gliblog.Init() - - maxprocs.Set(maxprocs.Logger(log.Debugf)) - - if err := config.Configure(); err != nil { - return err - } - - if err := monitoring.Init(); err != nil { - return err - } - - errorreport.Init() - - if err := vips.Init(); err != nil { - return err - } - - if err := processing.ValidatePreferredFormats(); err != nil { - vips.Shutdown() - return err - } - - if err := options.ParsePresets(config.Presets); err != nil { - vips.Shutdown() - return err - } - - if err := options.ValidatePresets(); err != nil { - vips.Shutdown() - return err - } - - return nil -} - -func shutdown() { - monitoring.Stop() - errorreport.Close() - vips.Shutdown() -} - -func run(ctx context.Context) error { - if err := initialize(); err != nil { - return err - } - - defer shutdown() - - go func() { - var logMemStats = len(os.Getenv("IMGPROXY_LOG_MEM_STATS")) > 0 - - for range time.Tick(time.Duration(config.FreeMemoryInterval) * time.Second) { - memory.Free() - - if logMemStats { - memory.LogStats() - } - } - }() - - ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) - - if err := prometheus.StartServer(cancel); err != nil { - return err - } - - cfg, err := server.LoadConfigFromEnv(nil) - if err != nil { - return err - } - - r, err := server.NewRouter(cfg) - if err != nil { - return err - } - - s, err := server.Start(cancel, buildRouter(r)) - if err != nil { - return err - } - defer s.Shutdown(context.Background()) - - <-ctx.Done() - - return nil -} - -func main() { - flag.Parse() - - switch flag.Arg(0) { - case "health": - os.Exit(healthcheck()) - case "version": - fmt.Println(version.Version) - os.Exit(0) - } - - if err := run(context.Background()); err != nil { - log.Fatal(err) - } -} diff --git a/pprof.go b/pprof.go index a2141084..77ff3d20 100644 --- a/pprof.go +++ b/pprof.go @@ -1,7 +1,7 @@ //go:build pprof // +build pprof -package main +package imgproxy import ( "net/http" diff --git a/server/config.go b/server/config.go index 5ecccc46..58a46c03 100644 --- a/server/config.go +++ b/server/config.go @@ -3,6 +3,7 @@ package server import ( "errors" "fmt" + "os" "time" "github.com/imgproxy/imgproxy/v3/config" @@ -25,6 +26,10 @@ type Config struct { DevelopmentErrorsMode bool // Enable development mode for detailed error messages SocketReusePort bool // Enable SO_REUSEPORT socket option HealthCheckPath string // Health check path from config + + // TODO: We are not sure where to put it yet + FreeMemoryInterval time.Duration // Interval for freeing memory + LogMemStats bool // Log memory stats } // NewDefaultConfig returns default config values @@ -43,6 +48,8 @@ func NewDefaultConfig() Config { DevelopmentErrorsMode: false, SocketReusePort: false, HealthCheckPath: "", + FreeMemoryInterval: 10 * time.Second, + LogMemStats: false, } } @@ -62,6 +69,8 @@ func LoadConfigFromEnv(c *Config) (*Config, error) { c.DevelopmentErrorsMode = config.DevelopmentErrorsMode c.SocketReusePort = config.SoReuseport c.HealthCheckPath = config.HealthCheckPath + c.FreeMemoryInterval = time.Duration(config.FreeMemoryInterval) * time.Second + c.LogMemStats = len(os.Getenv("IMGPROXY_LOG_MEM_STATS")) > 0 return c, nil } @@ -92,5 +101,9 @@ func (c *Config) Validate() error { return fmt.Errorf("graceful timeout should be greater than or equal to 0, now - %d", c.GracefulTimeout) } + if c.FreeMemoryInterval <= 0 { + return errors.New("free memory interval should be greater than zero") + } + return nil } diff --git a/server/router.go b/server/router.go index a51393ff..c32a6e27 100644 --- a/server/router.go +++ b/server/router.go @@ -127,14 +127,16 @@ func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) { rw.Header().Set(httpheaders.XRequestID, reqID) for _, rr := range r.routes { - if rr.isMatch(req) { - if !rr.silent { - LogRequest(reqID, req) - } - - rr.handler(reqID, rw, req) - return + if !rr.isMatch(req) { + continue } + + if !rr.silent { + LogRequest(reqID, req) + } + + rr.handler(reqID, rw, req) + return } // Means that we have not found matching route diff --git a/testutil/lazy_obj.go b/testutil/lazy_obj.go new file mode 100644 index 00000000..53f8ecf6 --- /dev/null +++ b/testutil/lazy_obj.go @@ -0,0 +1,32 @@ +package testutil + +import ( + "github.com/stretchr/testify/require" +) + +// LazyObj is a function that returns an object of type T. +type LazyObj[T any] func() T + +// LazyObjInit is a function that initializes and returns an object of type T and an error if any. +type LazyObjInit[T any] func() (T, error) + +// NewLazyObj creates a new LazyObj that initializes the object on the first call. +func NewLazyObj[T any](t require.TestingT, init LazyObjInit[T]) LazyObj[T] { + if h, ok := t.(interface{ Helper() }); ok { + h.Helper() + } + + var obj *T + + return func() T { + if obj != nil { + return *obj + } + + o, err := init() + require.NoError(t, err) + + obj = &o + return o + } +} diff --git a/testutil/testutil.go b/testutil/readers_equal.go similarity index 100% rename from testutil/testutil.go rename to testutil/readers_equal.go diff --git a/testutil/test_data_provider.go b/testutil/test_data_provider.go new file mode 100644 index 00000000..00b3fd74 --- /dev/null +++ b/testutil/test_data_provider.go @@ -0,0 +1,99 @@ +package testutil + +import ( + "bytes" + "io" + "os" + "path/filepath" + + "github.com/stretchr/testify/require" +) + +const ( + // TestDataFolderName is the name of the testdata directory + TestDataFolderName = "testdata" +) + +// TestDataProvider provides access to test data images +type TestDataProvider struct { + path string + t require.TestingT +} + +// New creates a new TestDataProvider +func NewTestDataProvider(t require.TestingT) *TestDataProvider { + if h, ok := t.(interface{ Helper() }); ok { + h.Helper() + } + + path, err := findProjectRoot() + if err != nil { + require.NoError(t, err) + } + + return &TestDataProvider{ + path: filepath.Join(path, TestDataFolderName), + t: t, + } +} + +// findProjectRoot finds the absolute path to the project root by looking for go.mod +func findProjectRoot() (string, error) { + // Start from current working directory + wd, err := os.Getwd() + if err != nil { + return "", err + } + + // Walk up the directory tree looking for go.mod + dir := wd + for { + goModPath := filepath.Join(dir, "go.mod") + if _, err := os.Stat(goModPath); err == nil { + // Found go.mod, this is our project root + return dir, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + // Reached filesystem root without finding go.mod + break + } + dir = parent + } + return "", os.ErrNotExist + +} + +// Root returns the absolute path to the testdata directory +func (p *TestDataProvider) Root() string { + return p.path +} + +// Path returns the absolute path to a file in the testdata directory +func (p *TestDataProvider) Path(parts ...string) string { + allParts := append([]string{p.path}, parts...) + return filepath.Join(allParts...) +} + +// Read reads a test data file and returns it as bytes +func (p *TestDataProvider) Read(name string) []byte { + if h, ok := p.t.(interface{ Helper() }); ok { + h.Helper() + } + + data, err := os.ReadFile(p.Path(name)) + require.NoError(p.t, err) + return data +} + +// Data reads a test data file and returns it as imagedata.ImageData +func (p *TestDataProvider) Reader(name string) *bytes.Reader { + return bytes.NewReader(p.Read(name)) +} + +// FileEqualsToReader compares the contents of a test data file with the contents of the given reader +func (p *TestDataProvider) FileEqualsToReader(name string, reader io.Reader) bool { + expected := p.Reader(name) + return ReadersEqual(p.t, expected, reader) +}