Introduced instance

This commit is contained in:
Viktor Sokolov
2025-09-02 11:43:59 +02:00
parent e11afc18a8
commit 6af2aa4666
28 changed files with 1040 additions and 805 deletions

View File

@@ -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

View File

@@ -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

66
Makefile Normal file
View File

@@ -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
@:

View File

@@ -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)

View File

@@ -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
}

86
cli/main.go Normal file
View File

@@ -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)
}
}

86
config.go Normal file
View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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
}

3
go.mod
View File

@@ -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

9
go.sum
View File

@@ -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=

View File

@@ -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)

View File

@@ -0,0 +1 @@
package processing

View File

@@ -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

170
imgproxy.go Normal file
View File

@@ -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()
}
}
}
}

74
init.go Normal file
View File

@@ -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()
}

View File

@@ -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))
}

20
integration/main_test.go Normal file
View File

@@ -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()
}

View File

@@ -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)
}

View File

@@ -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
}

276
main.go
View File

@@ -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)
}
}

View File

@@ -1,7 +1,7 @@
//go:build pprof
// +build pprof
package main
package imgproxy
import (
"net/http"

View File

@@ -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
}

View File

@@ -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

32
testutil/lazy_obj.go Normal file
View File

@@ -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
}
}

View File

@@ -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)
}