├── .gitignore ├── History.md ├── docs └── flowchart.jpg ├── testdata ├── test.jpg ├── test.pdf ├── test.png └── test.webp ├── .dockerignore ├── docker-entrypoint.sh ├── go.mod ├── docker-config.yml ├── example-config.yml ├── webp_server.service ├── .github └── workflows │ └── go.yml ├── LICENSE ├── validators.go ├── main.go ├── task_manager.go ├── go.sum ├── config.go ├── Dockerfile ├── config_test.go ├── main_test.go ├── image.go ├── handlers.go ├── image_test.go ├── README.md └── handlers_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | webp-server 2 | cover.out 3 | profile.cov 4 | -------------------------------------------------------------------------------- /History.md: -------------------------------------------------------------------------------- 1 | 1.0.0 / 2021-01-02 2 | ======================= 3 | * Initial Release 4 | -------------------------------------------------------------------------------- /docs/flowchart.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mehdipourfar/webp-server/HEAD/docs/flowchart.jpg -------------------------------------------------------------------------------- /testdata/test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mehdipourfar/webp-server/HEAD/testdata/test.jpg -------------------------------------------------------------------------------- /testdata/test.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mehdipourfar/webp-server/HEAD/testdata/test.pdf -------------------------------------------------------------------------------- /testdata/test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mehdipourfar/webp-server/HEAD/testdata/test.png -------------------------------------------------------------------------------- /testdata/test.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mehdipourfar/webp-server/HEAD/testdata/test.webp -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | webp-server 3 | docs/ 4 | *.out 5 | Dockerfile 6 | README.md 7 | History.md 8 | LICENSE 9 | example-config.yml 10 | webp_server.service 11 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | if [ -z "$TOKEN" ]; then 6 | echo "TOKEN env variable must be defined." 7 | exit 1 8 | else 9 | export WEBP_SERVER_TOKEN="$TOKEN"; 10 | fi 11 | 12 | set -- webp-server -config /var/lib/webp-server/config.yml 13 | exec "$@" 14 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mehdipourfar/webp-server 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/matryer/is v1.4.0 7 | github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf 8 | github.com/valyala/bytebufferpool v1.0.0 9 | github.com/valyala/fasthttp v1.18.0 10 | gopkg.in/h2non/bimg.v1 v1.1.4 11 | gopkg.in/yaml.v2 v2.4.0 12 | ) 13 | -------------------------------------------------------------------------------- /docker-config.yml: -------------------------------------------------------------------------------- 1 | data_directory: /var/lib/webp-server/ # should be an absolute path 2 | server_address: 0.0.0.0:8080 3 | default_image_quality: 95 4 | valid_image_qualities: 5 | - 90 6 | - 95 7 | - 100 8 | valid_image_sizes: 9 | - 300x300 10 | - 500x500 11 | max_uploaded_image_size: 4 # in megabytes 12 | http_cache_ttl: 2592000 # in seconds. default is 1 month. 13 | log_path: null # default is null and logs to console 14 | debug: false 15 | -------------------------------------------------------------------------------- /example-config.yml: -------------------------------------------------------------------------------- 1 | data_directory: 2 | /opt/webp-server-data/ # should be an absolute path 3 | server_address: 4 | 127.0.0.1:8080 5 | token: 6 | 456e910f-3d07-470d-a862-1deb1494a38e # change it 7 | default_image_quality: 8 | 95 9 | valid_image_qualities: 10 | - 90 11 | - 95 12 | - 100 13 | valid_image_sizes: 14 | - 300x300 15 | - 500x500 16 | max_uploaded_image_size: 17 | 4 # in megabytes 18 | http_cache_ttl: 19 | 2592000 # in seconds. default is 1 month. 20 | log_path: 21 | null # default is null and logs to console 22 | debug: 23 | false 24 | -------------------------------------------------------------------------------- /webp_server.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=webp-server daemon 3 | After=network.target 4 | 5 | [Service] 6 | User=www-data 7 | Group=www-data 8 | EnvironmentFile=/etc/default/webp-server 9 | PIDFile=/run/webp-server/webp-server.pid 10 | KillMode=process 11 | ExecReload=/bin/kill -s HUP $MAINPID 12 | ExecStop=/bin/kill -s TERM $MAINPID 13 | PermissionsStartOnly=true 14 | PrivateTmp=true 15 | Type=simple 16 | Restart=always 17 | 18 | ExecStart=/usr/bin/webp-server 19 | ExecStartPre=-/bin/mkdir -p /run/webp-server/ 20 | ExecStartPre=/bin/chown -R www-data:www-data /run/webp-server/ 21 | 22 | [Install] 23 | WantedBy=multi-user.target 24 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-20.04 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: codecov/codecov-action@v1 15 | - name: Set up Go 16 | uses: actions/setup-go@v2 17 | with: 18 | go-version: 1.15 19 | 20 | - name: Install dependencies 21 | run: sudo apt-get install libvips-dev 22 | 23 | - name: Build 24 | run: go build -v ./... 25 | 26 | - name: Test 27 | run: go test -coverprofile=profile.cov ./... 28 | 29 | - name: Send coverage 30 | uses: shogo82148/actions-goveralls@v1 31 | with: 32 | path-to-profile: profile.cov 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Mehdi Pourfar 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | 9 | -------------------------------------------------------------------------------- /validators.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "mime/multipart" 8 | "net/http" 9 | ) 10 | 11 | func validateImage(header *multipart.FileHeader) bool { 12 | file, err := header.Open() 13 | if err != nil { 14 | log.Println(err) 15 | return false 16 | } 17 | defer file.Close() 18 | buff := make([]byte, 512) 19 | if _, err = file.Read(buff); err != nil { 20 | log.Println(err) 21 | return false 22 | } 23 | ct := http.DetectContentType(buff) 24 | 25 | switch ct { 26 | case "image/jpeg", "image/jpg", "image/png", "image/webp": 27 | return true 28 | default: 29 | return false 30 | } 31 | } 32 | 33 | func validateImageParams(imageParams *ImageParams, config *Config) error { 34 | if config.Debug { 35 | return nil 36 | } 37 | validSize := false 38 | imageSize := fmt.Sprintf("%dx%d", imageParams.Width, imageParams.Height) 39 | for _, size := range config.ValidImageSizes { 40 | if size == imageSize { 41 | validSize = true 42 | break 43 | } 44 | } 45 | 46 | if !validSize { 47 | return fmt.Errorf( 48 | "size=%dx%d is not supported by server. Contact server admin.", 49 | imageParams.Width, imageParams.Height) 50 | } 51 | 52 | validQuality := imageParams.Quality == 0 || imageParams.Quality == config.DefaultImageQuality 53 | 54 | if !validQuality && imageParams.Quality <= 100 && imageParams.Quality >= 10 { 55 | for _, val := range config.ValidImageQualities { 56 | if val == imageParams.Quality { 57 | validQuality = true 58 | break 59 | } 60 | } 61 | } 62 | 63 | if !validQuality { 64 | return fmt.Errorf( 65 | "quality=%d is not supported by server. Contact server admin.", 66 | imageParams.Quality) 67 | } 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | bimg "gopkg.in/h2non/bimg.v1" 8 | "log" 9 | "os" 10 | "os/signal" 11 | "syscall" 12 | ) 13 | 14 | func checkVipsVersion(majorVersion, minorVersion int) error { 15 | minMajorVersion, minMinorVersion := 8, 9 16 | 17 | if (majorVersion < minMajorVersion) || (majorVersion == minMajorVersion && minorVersion < minMinorVersion) { 18 | return fmt.Errorf("Install libips=>'%d.%d'. Current version is %d.%d", 19 | minMajorVersion, minMinorVersion, majorVersion, minorVersion) 20 | } 21 | return nil 22 | } 23 | 24 | func runServer(ctx context.Context) error { 25 | if err := checkVipsVersion(bimg.VipsMajorVersion, bimg.VipsMinorVersion); err != nil { 26 | return err 27 | } 28 | configPath := flag.String("config", "", "Path of config file in yml format") 29 | flag.Parse() 30 | if *configPath == "" { 31 | return fmt.Errorf("Set config.yml path via -config flag.") 32 | } 33 | file, err := os.Open(*configPath) 34 | if err != nil { 35 | return fmt.Errorf("Error loading config: %v", err) 36 | } 37 | config, err := parseConfig(file) 38 | file.Close() 39 | if err != nil { 40 | return err 41 | } 42 | if config.LogPath != "" { 43 | logFile, err := os.OpenFile(config.LogPath, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666) 44 | if err != nil { 45 | return fmt.Errorf("Could not open log file: %v", err) 46 | } 47 | defer logFile.Close() 48 | log.SetOutput(logFile) 49 | } else { 50 | log.SetOutput(os.Stdout) 51 | } 52 | 53 | server := createServer(config) 54 | 55 | done := make(chan os.Signal, 1) 56 | signal.Notify(done, syscall.SIGINT, syscall.SIGTERM) 57 | defer close(done) 58 | 59 | serverErr := make(chan error) 60 | defer close(serverErr) 61 | 62 | go func() { 63 | log.Printf("Starting server on %s", config.ServerAddress) 64 | if err := server.ListenAndServe(config.ServerAddress); err != nil { 65 | serverErr <- err 66 | } 67 | }() 68 | 69 | select { 70 | case <-done: 71 | return server.Shutdown() 72 | case <-ctx.Done(): 73 | return server.Shutdown() 74 | case err := <-serverErr: 75 | return err 76 | } 77 | } 78 | 79 | func main() { 80 | ctx := context.Background() 81 | err := runServer(ctx) 82 | if err != nil { 83 | log.Fatal(err) 84 | ctx.Done() 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /task_manager.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | ) 7 | 8 | //ProcessFunc is the function responsible for handling task 9 | type ProcessFunc func() error 10 | 11 | type task struct { 12 | finished chan struct{} 13 | function *ProcessFunc 14 | err error 15 | } 16 | 17 | func (t *task) run() { 18 | defer func() { 19 | if r := recover(); r != nil { 20 | t.err = fmt.Errorf("Task failed: %v", r) 21 | close(t.finished) 22 | } 23 | }() 24 | 25 | t.err = (*t.function)() 26 | close(t.finished) 27 | } 28 | 29 | //TaskManager is responsible for preventing 30 | //thundering herd problem in image conversion process. 31 | //When an image is recently uploaded, and multiple users 32 | //request it with the same filters, we should make sure 33 | //that the image conversion process only happens once. 34 | //TaskManager also acts a worker pool and prevents from 35 | //running Convert function in thousands of goroutines. 36 | type TaskManager struct { 37 | tasks map[string]*task 38 | request chan *task 39 | sync.Mutex 40 | } 41 | 42 | //NewTaskManager takes the number of background workers 43 | //and creates a new Task manager with spawned workers 44 | func NewTaskManager(workersCount int) *TaskManager { 45 | t := &TaskManager{ 46 | tasks: make(map[string]*task), 47 | request: make(chan *task, workersCount), 48 | } 49 | t.startWorkers(workersCount) 50 | return t 51 | } 52 | 53 | func (tm *TaskManager) startWorkers(count int) { 54 | for i := 0; i < count; i++ { 55 | go func() { 56 | for t := range tm.request { 57 | t.run() 58 | } 59 | }() 60 | } 61 | } 62 | 63 | func (tm *TaskManager) clear(taskID string) { 64 | tm.Lock() 65 | delete(tm.tasks, taskID) 66 | tm.Unlock() 67 | } 68 | 69 | //RunTask takes a uniqe taskID and a processing function 70 | //and runs the function in the background 71 | func (tm *TaskManager) RunTask(taskID string, f ProcessFunc) error { 72 | tm.Lock() 73 | t := tm.tasks[taskID] 74 | if t == nil { 75 | // similar task does not exist at the moment 76 | t = &task{finished: make(chan struct{}), function: &f} 77 | tm.tasks[taskID] = t 78 | tm.Unlock() 79 | tm.request <- t 80 | <-t.finished 81 | tm.clear(taskID) 82 | } else { 83 | // task is being done by another process 84 | tm.Unlock() 85 | <-t.finished 86 | } 87 | return t.err 88 | } 89 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/andybalholm/brotli v1.0.0 h1:7UCwP93aiSfvWpapti8g88vVVGp2qqtGyePsSuDafo4= 2 | github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= 3 | github.com/klauspost/compress v1.10.7 h1:7rix8v8GpI3ZBb0nSozFRgbtXKv+hOe+qfEpZqybrAg= 4 | github.com/klauspost/compress v1.10.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= 5 | github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= 6 | github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= 7 | github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf h1:Z2X3Os7oRzpdJ75iPqWZc0HeJWFYNCvKsfpQwFpRNTA= 8 | github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf/go.mod h1:M8agBzgqHIhgj7wEn9/0hJUZcrvt9VY+Ln+S1I5Mha0= 9 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 10 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 11 | github.com/valyala/fasthttp v1.18.0 h1:IV0DdMlatq9QO1Cr6wGJPVW1sV1Q8HvZXAIcjorylyM= 12 | github.com/valyala/fasthttp v1.18.0/go.mod h1:jjraHZVbKOXftJfsOYoAjaeygpj5hr8ermTRJNroD7A= 13 | github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio= 14 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 15 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 16 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 17 | golang.org/x/net v0.0.0-20201016165138-7b1cca2348c0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 18 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 19 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 20 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 21 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 22 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 23 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 24 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 25 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 26 | gopkg.in/h2non/bimg.v1 v1.1.4 h1:zUjVbPzrc/dFxlpb0JkFovMO08kEjbvNul/o5gHXhXw= 27 | gopkg.in/h2non/bimg.v1 v1.1.4/go.mod h1:PgsZL7dLwUbsGm1NYps320GxGgvQNTnecMCZqxV11So= 28 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 29 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 30 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | "regexp" 10 | "runtime" 11 | 12 | "gopkg.in/yaml.v2" 13 | ) 14 | 15 | //Config is global configuration of the server 16 | type Config struct { 17 | DataDir string `yaml:"data_directory"` 18 | DefaultImageQuality int `yaml:"default_image_quality"` 19 | ServerAddress string `yaml:"server_address"` 20 | Token string `yaml:"token"` 21 | ValidImageSizes []string `yaml:"valid_image_sizes"` 22 | ValidImageQualities []int `yaml:"valid_image_qualities"` 23 | MaxUploadedImageSize int `yaml:"max_uploaded_image_size"` // in megabytes 24 | HTTPCacheTTL int `yaml:"http_cache_ttl"` 25 | LogPath string `yaml:"log_path"` 26 | Debug bool `yaml:"debug"` 27 | ConvertConcurrency int `yaml:"convert_concurrency"` 28 | } 29 | 30 | func getDefaultConfig() *Config { 31 | return &Config{ 32 | DefaultImageQuality: 95, 33 | ServerAddress: "127.0.0.1:8080", 34 | ValidImageSizes: []string{"300x300", "500x500"}, 35 | MaxUploadedImageSize: 4, 36 | HTTPCacheTTL: 2592000, 37 | ConvertConcurrency: runtime.NumCPU(), 38 | } 39 | 40 | } 41 | 42 | func parseConfig(file io.Reader) (*Config, error) { 43 | cfg := getDefaultConfig() 44 | buf, err := ioutil.ReadAll(file) 45 | if err != nil { 46 | return nil, fmt.Errorf("%+v\n", err) 47 | } 48 | if err := yaml.Unmarshal(buf, &cfg); err != nil { 49 | return nil, fmt.Errorf("Invalid Config File: %v", err) 50 | } 51 | 52 | if token := os.Getenv("WEBP_SERVER_TOKEN"); len(token) != 0 { 53 | cfg.Token = token 54 | } 55 | 56 | if cfg.DataDir == "" { 57 | return nil, fmt.Errorf("Set data_directory in your config file.") 58 | } 59 | 60 | if !filepath.IsAbs(cfg.DataDir) { 61 | return nil, fmt.Errorf("Absolute path for data_dir needed but got: %s", cfg.DataDir) 62 | } 63 | 64 | if len(cfg.LogPath) > 0 && !filepath.IsAbs(cfg.LogPath) { 65 | return nil, fmt.Errorf("Absolute path for log_path needed but got: %s", cfg.LogPath) 66 | } 67 | 68 | if err := os.MkdirAll(cfg.DataDir, 0755); err != nil { 69 | return nil, fmt.Errorf("%+v\n", err) 70 | } 71 | 72 | sizePattern := regexp.MustCompile("([0-9]{1,4})x([0-9]{1,4})") 73 | for _, size := range cfg.ValidImageSizes { 74 | match := sizePattern.FindAllString(size, -1) 75 | if len(match) != 1 { 76 | return nil, fmt.Errorf("Image size %s is not valid. Try use WIDTHxHEIGHT format.", size) 77 | } 78 | } 79 | 80 | if cfg.DefaultImageQuality < 10 || cfg.DefaultImageQuality > 100 { 81 | return nil, fmt.Errorf("Default image quality should be 10 < q < 100.") 82 | } 83 | 84 | if cfg.ConvertConcurrency <= 0 { 85 | return nil, fmt.Errorf("Convert Concurrency should be greater than zero") 86 | } 87 | return cfg, nil 88 | } 89 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG GOLANG_VERSION=1.15.6 2 | FROM golang:${GOLANG_VERSION} as builder 3 | 4 | ARG WEBP_SERVER_VERSION=1.0.0 5 | ARG LIBVIPS_VERSION=8.10.5 6 | 7 | RUN DEBIAN_FRONTEND=noninteractive apt-get update && \ 8 | apt-get install --no-install-recommends -y \ 9 | file ca-certificates automake build-essential curl fftw3-dev \ 10 | liborc-0.4-dev libexif-dev libglib2.0-dev libexpat1-dev \ 11 | libpng-dev libjpeg62-turbo-dev libwebp-dev 12 | 13 | RUN cd /tmp && \ 14 | curl -fsSLO https://github.com/libvips/libvips/releases/download/v${LIBVIPS_VERSION}/vips-${LIBVIPS_VERSION}.tar.gz && \ 15 | tar zvxf vips-${LIBVIPS_VERSION}.tar.gz && \ 16 | cd /tmp/vips-${LIBVIPS_VERSION} && \ 17 | CFLAGS="-g -O3" CXXFLAGS="-D_GLIBCXX_USE_CXX11_ABI=0 -g -O3" \ 18 | ./configure \ 19 | --disable-debug \ 20 | --disable-dependency-tracking \ 21 | --disable-introspection \ 22 | --disable-static \ 23 | --without-tiff \ 24 | --enable-gtk-doc-html=no \ 25 | --enable-gtk-doc=no && \ 26 | make && \ 27 | make install && \ 28 | ldconfig 29 | 30 | WORKDIR ${GOPATH}/src/github.com/mehdipourfar/webp-server 31 | ENV GO111MODULE=on 32 | COPY go.mod . 33 | COPY go.sum . 34 | RUN go mod download 35 | COPY . . 36 | RUN go test -race 37 | RUN go build -a -o ${GOPATH}/bin/webp-server github.com/mehdipourfar/webp-server 38 | 39 | 40 | FROM debian:buster-slim 41 | 42 | ARG WEBP_SERVER_VERSION 43 | 44 | COPY --from=builder /usr/local/lib /usr/local/lib 45 | COPY --from=builder /go/bin/webp-server /usr/local/bin/webp-server 46 | COPY ./docker-entrypoint.sh /docker-entrypoint.sh 47 | 48 | RUN DEBIAN_FRONTEND=noninteractive apt-get update && \ 49 | apt-get install --no-install-recommends -y \ 50 | libexpat1 libglib2.0-0 libexif12 libjpeg62-turbo libpng16-16 \ 51 | libwebp6 libwebpmux3 libwebpdemux2 fftw3 liborc-0.4-0 curl && \ 52 | apt-get autoremove -y && \ 53 | apt-get autoclean && \ 54 | apt-get clean && \ 55 | rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*; \ 56 | groupadd -r -g 999 webp-server && useradd -r -g webp-server -u 999 --home-dir=/var/lib/webp-server webp-server; \ 57 | mkdir -p /var/lib/webp-server; \ 58 | chown webp-server:webp-server /var/lib/webp-server; 59 | 60 | ARG BUILD_DATE 61 | LABEL maintainer="mehdipourfar@gmail.com" \ 62 | org.label-schema.schema-version="1.0" \ 63 | org.label-schema.name="webp-server" \ 64 | org.label-schema.build-date=$BUILD_DATE \ 65 | org.label-schema.description="Simple and minimal image server capable of storing, resizing, converting and caching images." \ 66 | org.label-schema.url="https://github.com/mehdipourfar/webp-server" \ 67 | org.label-schema.vcs-url="https://github.com/mehdipourfar/webp-server" \ 68 | org.label-schema.version="${WEBP_SERVER_VERSION}" \ 69 | org.label-schema.docker.cmd="docker run -d -v webp_server_volume:/var/lib/webp-server --name webp-server -e TOKEN='MY_STRONG_TOKEN' -p 127.0.0.1:8080:8080 webp-server" 70 | 71 | 72 | VOLUME /var/lib/webp-server 73 | COPY ./docker-config.yml /var/lib/webp-server/config.yml 74 | ENTRYPOINT ["./docker-entrypoint.sh"] 75 | HEALTHCHECK CMD curl --fail http://localhost:8080/health/ || exit 1 76 | USER webp-server 77 | EXPOSE 8080 78 | -------------------------------------------------------------------------------- /config_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/matryer/is" 11 | ) 12 | 13 | func TestParseConfig(t *testing.T) { 14 | is := is.New(t) 15 | configFile := strings.NewReader(` 16 | data_directory: 17 | /tmp/webp-server/ 18 | default_image_quality: 19 | 80 20 | server_address: 21 | 127.0.0.1:9000 22 | token: 23 | abcdefg 24 | valid_image_sizes: 25 | - 200x200 26 | - 500x500 27 | - 600x600 28 | valid_image_qualities: 29 | - 90 30 | - 95 31 | - 100 32 | max_uploaded_image_size: 33 | 3 34 | http_cache_ttl: 35 | 10 36 | debug: 37 | true 38 | convert_concurrency: 39 | 3 40 | `) 41 | defer os.RemoveAll("/tmp/webp-server") 42 | cfg, err := parseConfig(configFile) 43 | if err != nil { 44 | t.Fatal(err) 45 | } 46 | expected := &Config{ 47 | DataDir: "/tmp/webp-server/", 48 | DefaultImageQuality: 80, 49 | ServerAddress: "127.0.0.1:9000", 50 | Token: "abcdefg", 51 | ValidImageSizes: []string{"200x200", "500x500", "600x600"}, 52 | ValidImageQualities: []int{90, 95, 100}, 53 | MaxUploadedImageSize: 3, 54 | HTTPCacheTTL: 10, 55 | Debug: true, 56 | ConvertConcurrency: 3, 57 | } 58 | 59 | is.Equal(cfg, expected) 60 | if tok := os.Getenv("WEBP_SERVER_TOKEN"); len(tok) == 0 { 61 | os.Setenv("WEBP_SERVER_TOKEN", "123") 62 | defer os.Unsetenv("WEBP_SERVER_TOKEN") 63 | } 64 | _, err = configFile.Seek(0, 0) 65 | is.NoErr(err) 66 | cfg, err = parseConfig(configFile) 67 | if err != nil { 68 | t.Fatal(err) 69 | } 70 | is.Equal(cfg.Token, os.Getenv("WEBP_SERVER_TOKEN")) 71 | } 72 | 73 | func TestParseConfigErrors(t *testing.T) { 74 | tt := []struct { 75 | name string 76 | file io.Reader 77 | err error 78 | }{ 79 | { 80 | name: "parse_error", 81 | file: strings.NewReader("----"), 82 | err: fmt.Errorf("Invalid Config File: yaml: unmarshal errors:\n line 1: cannot unmarshal !!str `----` into main.Config"), 83 | }, 84 | { 85 | name: "empty_data_dir", 86 | file: strings.NewReader("server_address: 127.0.0.1:8080"), 87 | err: fmt.Errorf("Set data_directory in your config file."), 88 | }, 89 | { 90 | name: "non_absolute_data_dir", 91 | file: strings.NewReader("data_directory: ~/data"), 92 | err: fmt.Errorf("Absolute path for data_dir needed but got: ~/data"), 93 | }, 94 | { 95 | name: "non_absolute_log_path", 96 | file: strings.NewReader("data_directory: /tmp/\nlog_path: ~/log"), 97 | err: fmt.Errorf("Absolute path for log_path needed but got: ~/log"), 98 | }, 99 | { 100 | name: "invalid_image_size", 101 | file: strings.NewReader("data_directory: /tmp/\nvalid_image_sizes:\n - 300x"), 102 | err: fmt.Errorf("Image size 300x is not valid. Try use WIDTHxHEIGHT format."), 103 | }, 104 | { 105 | name: "invalid_default_quality", 106 | file: strings.NewReader("data_directory: /tmp/\ndefault_image_quality: 120"), 107 | err: fmt.Errorf("Default image quality should be 10 < q < 100."), 108 | }, 109 | { 110 | name: "invalid_convert_concurrency", 111 | file: strings.NewReader("data_directory: /tmp/\nconvert_concurrency: 0"), 112 | err: fmt.Errorf("Convert Concurrency should be greater than zero"), 113 | }, 114 | } 115 | 116 | for _, tc := range tt { 117 | t.Run(tc.name, func(t *testing.T) { 118 | is := is.NewRelaxed(t) 119 | _, err := parseConfig(tc.file) 120 | is.True(err != nil) 121 | is.Equal(err, tc.err) 122 | }) 123 | } 124 | 125 | } 126 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "github.com/matryer/is" 8 | "io/ioutil" 9 | "os" 10 | "testing" 11 | "time" 12 | ) 13 | 14 | func TestCheckVersion(t *testing.T) { 15 | tt := []struct { 16 | name string 17 | majorVer int 18 | minorVer int 19 | err error 20 | }{ 21 | { 22 | name: "lower major version", 23 | majorVer: 7, 24 | minorVer: 2, 25 | err: fmt.Errorf("Install libips=>'8.9'. Current version is 7.2"), 26 | }, 27 | { 28 | name: "lower minor version", 29 | majorVer: 8, 30 | minorVer: 5, 31 | err: fmt.Errorf("Install libips=>'8.9'. Current version is 8.5"), 32 | }, 33 | { 34 | name: "equal version", 35 | majorVer: 8, 36 | minorVer: 9, 37 | err: nil, 38 | }, 39 | { 40 | name: "higher version", 41 | majorVer: 9, 42 | minorVer: 2, 43 | err: nil, 44 | }, 45 | } 46 | 47 | for _, tc := range tt { 48 | t.Run(fmt.Sprintf("TestCheckVersionFunction: %s", tc.name), func(t *testing.T) { 49 | is := is.NewRelaxed(t) 50 | err := checkVipsVersion(tc.majorVer, tc.minorVer) 51 | is.Equal(err, tc.err) 52 | }) 53 | } 54 | } 55 | 56 | func TestRunServerInvalidConfigFlag(t *testing.T) { 57 | is := is.New(t) 58 | ctx := context.Background() 59 | ctx, cancel := context.WithCancel(ctx) 60 | time.AfterFunc(50*time.Millisecond, func() { 61 | cancel() 62 | }) 63 | err := runServer(ctx) 64 | is.Equal(err, fmt.Errorf("Set config.yml path via -config flag.")) 65 | flag.CommandLine = flag.NewFlagSet("config", flag.ExitOnError) 66 | configPath := "/tmp/webpserver_test.yaml" 67 | os.Remove(configPath) 68 | os.Args = []string{"webp-server", "-config", configPath} 69 | err = runServer(ctx) 70 | is.Equal(err, fmt.Errorf("Error loading config: open /tmp/webpserver_test.yaml: no such file or directory")) 71 | } 72 | 73 | func TestRunServerInvalidLogPath(t *testing.T) { 74 | is := is.New(t) 75 | ctx := context.Background() 76 | ctx, cancel := context.WithCancel(ctx) 77 | time.AfterFunc(50*time.Millisecond, func() { 78 | cancel() 79 | }) 80 | 81 | configData := []byte(` 82 | data_directory: 83 | /tmp/wstest/ 84 | log_path: 85 | /tmp/wstest/sfsaf/fsfs 86 | `) 87 | configPath := "/tmp/webpserver_test.yaml" 88 | err := ioutil.WriteFile(configPath, configData, 0644) 89 | if err != nil { 90 | t.Fatal(err) 91 | } 92 | defer os.Remove(configPath) 93 | defer os.RemoveAll("/tmp/wstest/") 94 | flag.CommandLine = flag.NewFlagSet("config", flag.ExitOnError) 95 | os.Args = []string{"webp-server", "-config", configPath} 96 | err = runServer(ctx) 97 | is.Equal(err.Error(), "Could not open log file: open /tmp/wstest/sfsaf/fsfs: no such file or directory") 98 | } 99 | 100 | func TestRunServerGracefulShutdown(t *testing.T) { 101 | is := is.New(t) 102 | ctx := context.Background() 103 | configData := []byte(` 104 | data_directory: 105 | /tmp/wstest/ 106 | log_path: 107 | /tmp/wstest/wpl.log 108 | `) 109 | configPath := "/tmp/webpserver_test.yaml" 110 | err := ioutil.WriteFile(configPath, configData, 0644) 111 | if err != nil { 112 | t.Fatal(err) 113 | } 114 | defer os.Remove(configPath) 115 | defer os.RemoveAll("/tmp/wstest/") 116 | flag.CommandLine = flag.NewFlagSet("config", flag.ExitOnError) 117 | os.Args = []string{"webp-server", "-config", configPath} 118 | err = ioutil.WriteFile(configPath, configData, 0644) 119 | if err != nil { 120 | t.Fatal(err) 121 | } 122 | proc, _ := os.FindProcess(os.Getpid()) 123 | time.AfterFunc(200*time.Millisecond, func() { 124 | err := proc.Signal(os.Interrupt) 125 | if err != nil { 126 | t.Fatal(err) 127 | } 128 | }) 129 | err = runServer(ctx) 130 | is.NoErr(err) 131 | } 132 | 133 | func TestRunServerInvalidAddress(t *testing.T) { 134 | is := is.New(t) 135 | ctx := context.Background() 136 | ctx, cancel := context.WithCancel(ctx) 137 | time.AfterFunc(50*time.Millisecond, func() { 138 | cancel() 139 | }) 140 | configData := []byte(` 141 | server_address: nfsfs 142 | data_directory: 143 | /tmp/wstest/ 144 | log_path: 145 | /tmp/wstest/wpl.log 146 | `) 147 | configPath := "/tmp/webpserver_test.yaml" 148 | err := ioutil.WriteFile(configPath, configData, 0644) 149 | 150 | defer os.RemoveAll("/tmp/wstest") 151 | defer os.Remove(configPath) 152 | if err != nil { 153 | t.Fatal(err) 154 | } 155 | flag.CommandLine = flag.NewFlagSet("config", flag.ExitOnError) 156 | os.Args = []string{"webp-server", "-config", configPath} 157 | err = runServer(ctx) 158 | is.Equal(err.Error(), "listen tcp4: address nfsfs: missing port in address") 159 | 160 | } 161 | -------------------------------------------------------------------------------- /image.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/md5" 5 | "fmt" 6 | "github.com/valyala/bytebufferpool" 7 | bimg "gopkg.in/h2non/bimg.v1" 8 | "io" 9 | "io/ioutil" 10 | "os" 11 | "path/filepath" 12 | "strconv" 13 | "strings" 14 | ) 15 | 16 | func init() { 17 | bimg.VipsCacheSetMax(0) 18 | bimg.VipsCacheSetMaxMem(0) 19 | } 20 | 21 | const ( 22 | //FitCover is used for resize and crop 23 | FitCover = "cover" 24 | //FitContain is used for resize and keep aspect ratio 25 | FitContain = "contain" 26 | //FitScaleDown is like FitContain except that it prevent image to be enlarged 27 | FitScaleDown = "scale-down" 28 | ) 29 | 30 | //ImageParams is request properties for image conversion 31 | type ImageParams struct { 32 | ImageID string 33 | Width int 34 | Height int 35 | Fit string 36 | Quality int 37 | WebpAccepted bool 38 | } 39 | 40 | func createImageParams(imageID, options string, webpAccepted bool, config *Config) (*ImageParams, error) { 41 | params := &ImageParams{ 42 | ImageID: imageID, 43 | Fit: FitContain, 44 | Quality: config.DefaultImageQuality, 45 | WebpAccepted: webpAccepted, 46 | } 47 | 48 | var err error 49 | 50 | for _, op := range strings.Split(options, ",") { 51 | kv := strings.Split(op, "=") 52 | if len(kv) != 2 { 53 | return nil, fmt.Errorf("Invalid param: %s", op) 54 | } 55 | key, val := kv[0], kv[1] 56 | 57 | switch key { 58 | case "width", "w": 59 | if params.Width, err = strconv.Atoi(val); err != nil { 60 | return nil, fmt.Errorf("Width should be integer") 61 | } 62 | case "height", "h": 63 | if params.Height, err = strconv.Atoi(val); err != nil { 64 | return nil, fmt.Errorf("Height should be integer") 65 | } 66 | case "fit": 67 | switch val { 68 | case FitContain, FitCover, FitScaleDown: 69 | params.Fit = val 70 | default: 71 | return nil, fmt.Errorf("Supported fits are cover, contain and scale-down") 72 | } 73 | case "quality", "q": 74 | if params.Quality, err = strconv.Atoi(val); err != nil { 75 | return nil, fmt.Errorf("Quality should be integer") 76 | } 77 | default: 78 | return nil, fmt.Errorf("Invalid filter key: %s", key) 79 | } 80 | } 81 | 82 | return params, nil 83 | } 84 | 85 | func getFilePathFromImageID(dataDir string, imageID string) string { 86 | parentDir := fmt.Sprintf("images/%s/%s", imageID[1:2], imageID[3:5]) 87 | parentDir = filepath.Join(dataDir, parentDir) 88 | return fmt.Sprintf("%s/%s", parentDir, imageID) 89 | } 90 | 91 | func (params *ImageParams) getMd5() string { 92 | key := fmt.Sprintf( 93 | "%s:%d:%d:%s:%d:%t", 94 | params.ImageID, 95 | params.Width, 96 | params.Height, 97 | params.Fit, 98 | params.Quality, 99 | params.WebpAccepted, 100 | ) 101 | h := md5.New() 102 | _, err := io.WriteString(h, key) 103 | if err != nil { 104 | panic(err) 105 | } 106 | return fmt.Sprintf("%x", h.Sum(nil)) 107 | } 108 | 109 | func (params *ImageParams) getCachePath(dataDir string) string { 110 | md5Sum := params.getMd5() 111 | fileName := fmt.Sprintf("%s-%s", params.ImageID, md5Sum) 112 | parentDir := fmt.Sprintf("caches/%s/%s", md5Sum[31:32], md5Sum[29:31]) 113 | parentDir = filepath.Join(dataDir, parentDir) 114 | return fmt.Sprintf("%s/%s", parentDir, fileName) 115 | } 116 | 117 | func (params *ImageParams) toBimgOptions(size *bimg.ImageSize) *bimg.Options { 118 | options := &bimg.Options{ 119 | Quality: params.Quality, 120 | } 121 | 122 | if params.Fit == FitCover { 123 | options.Crop = true 124 | options.Embed = true 125 | options.Width = params.Width 126 | options.Height = params.Height 127 | } 128 | if params.Fit == FitContain || params.Fit == FitScaleDown { 129 | if params.Width == 0 || params.Height == 0 { 130 | options.Width = params.Width 131 | options.Height = params.Height 132 | } else { 133 | imageRatio := float32(size.Width) / float32(size.Height) 134 | requestRatio := float32(params.Width) / float32(params.Height) 135 | 136 | if requestRatio < imageRatio { 137 | options.Width = params.Width 138 | } else { 139 | options.Height = params.Height 140 | } 141 | } 142 | 143 | if params.Fit == FitScaleDown { 144 | if options.Width > size.Width { 145 | options.Width = size.Width 146 | } 147 | if options.Height > size.Height { 148 | options.Height = size.Height 149 | } 150 | } 151 | } 152 | 153 | if params.WebpAccepted { 154 | options.Type = bimg.WEBP 155 | } else { 156 | options.Type = bimg.JPEG 157 | } 158 | return options 159 | } 160 | 161 | func convert(inputPath, outputPath string, params *ImageParams) error { 162 | f, err := os.Open(inputPath) 163 | if err != nil { 164 | return err 165 | } 166 | buffer := bytebufferpool.Get() 167 | defer bytebufferpool.Put(buffer) 168 | _, err = buffer.ReadFrom(f) 169 | f.Close() 170 | if err != nil { 171 | return err 172 | } 173 | 174 | img := bimg.NewImage(buffer.B) 175 | size, err := img.Size() 176 | if err != nil { 177 | return err 178 | } 179 | 180 | options := params.toBimgOptions(&size) 181 | newImage, err := img.Process(*options) 182 | if err != nil { 183 | return err 184 | } 185 | 186 | if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil { 187 | return err 188 | } 189 | if err := ioutil.WriteFile(outputPath, newImage, 0604); err != nil { 190 | return err 191 | } 192 | return nil 193 | } 194 | -------------------------------------------------------------------------------- /handlers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "log" 7 | "net" 8 | "net/http" 9 | "os" 10 | "path/filepath" 11 | "time" 12 | 13 | "github.com/teris-io/shortid" 14 | "github.com/valyala/bytebufferpool" 15 | "github.com/valyala/fasthttp" 16 | "regexp" 17 | ) 18 | 19 | var ( 20 | PathHealth = []byte("/health/") 21 | PathUpload = []byte("/upload/") 22 | PathImage = []byte("/image/") 23 | PathDelete = []byte("/delete/") 24 | 25 | ImageRegex = regexp.MustCompile("/image/((?P[0-9a-z,=-]+)/)?(?P[0-9a-zA-Z_-]{9,12})$") 26 | DeleteRegex = regexp.MustCompile("/delete/(?P[0-9a-zA-Z_-]{9,12})$") 27 | 28 | CacheControlKey = []byte("Cache-Control") 29 | 30 | ErrorMethodNotAllowed = []byte(`{"error": "Method not allowed"}`) 31 | ErrorImageNotProvided = []byte(`{"error": "image_file field not provided"}`) 32 | ErrorFileIsNotImage = []byte(`{"error": "Provided file is not an accepted image"}`) 33 | ErrorInvalidToken = []byte(`{"error": "Invalid Token"}`) 34 | ErrorImageNotFound = []byte(`{"error": "Image not found"}`) 35 | ErrorAddressNotFound = []byte(`{"error": "Address not found"}`) 36 | ErrorServerError = []byte(`{"error": "Internal Server Error"}`) 37 | 38 | // This variable makes us be able to mock convert function in tests 39 | convertFunction = convert 40 | ) 41 | 42 | type Handler struct { 43 | Config *Config 44 | CacheControlHeader []byte 45 | TaskManager *TaskManager 46 | } 47 | 48 | func createServer(config *Config) *fasthttp.Server { 49 | handler := &Handler{Config: config} 50 | if config.HTTPCacheTTL == 0 { 51 | handler.CacheControlHeader = []byte("private, no-cache, no-store, must-revalidate") 52 | } else { 53 | handler.CacheControlHeader = []byte(fmt.Sprintf("max-age=%d", config.HTTPCacheTTL)) 54 | } 55 | handler.TaskManager = NewTaskManager(config.ConvertConcurrency) 56 | return &fasthttp.Server{ 57 | Handler: handler.handleRequests, 58 | ErrorHandler: handler.handleErrors, 59 | NoDefaultServerHeader: true, 60 | MaxRequestBodySize: config.MaxUploadedImageSize * 1024 * 1024, 61 | ReadTimeout: time.Duration(5 * time.Second), 62 | } 63 | } 64 | 65 | func jsonResponse(ctx *fasthttp.RequestCtx, status int, body []byte) { 66 | ctx.SetStatusCode(status) 67 | ctx.SetContentType("application/json") 68 | if body != nil { 69 | ctx.SetBody(body) 70 | } 71 | } 72 | 73 | // In case of ocurring any panic in code, this function will serve 74 | // 500 error and log the error message. 75 | func handlePanic(ctx *fasthttp.RequestCtx) { 76 | if err := recover(); err != nil { 77 | ctx.ResetBody() 78 | jsonResponse(ctx, 500, ErrorServerError) 79 | log.Println(err) 80 | } 81 | } 82 | 83 | // router function 84 | func (handler *Handler) handleRequests(ctx *fasthttp.RequestCtx) { 85 | defer handlePanic(ctx) 86 | 87 | path := ctx.Path() 88 | 89 | if bytes.HasPrefix(path, PathImage) { 90 | handler.handleFetch(ctx) 91 | } else if bytes.Equal(path, PathUpload) { 92 | handler.handleUpload(ctx) 93 | } else if bytes.HasPrefix(path, PathDelete) { 94 | handler.handleDelete(ctx) 95 | } else if bytes.Equal(path, PathHealth) { 96 | jsonResponse(ctx, 200, []byte(`{"status": "ok"}`)) 97 | } else { 98 | jsonResponse(ctx, 404, ErrorAddressNotFound) 99 | } 100 | } 101 | 102 | func (handler *Handler) tokenIsValid(ctx *fasthttp.RequestCtx) bool { 103 | return len(handler.Config.Token) == 0 || 104 | handler.Config.Token == string(ctx.Request.Header.Peek("Token")) 105 | } 106 | 107 | func (handler *Handler) handleUpload(ctx *fasthttp.RequestCtx) { 108 | if !ctx.IsPost() { 109 | jsonResponse(ctx, 405, ErrorMethodNotAllowed) 110 | return 111 | } 112 | 113 | if !handler.tokenIsValid(ctx) { 114 | jsonResponse(ctx, 401, ErrorInvalidToken) 115 | return 116 | } 117 | 118 | fileHeader, err := ctx.FormFile("image_file") 119 | if err != nil { 120 | jsonResponse(ctx, 400, ErrorImageNotProvided) 121 | return 122 | } 123 | if imageValidated := validateImage(fileHeader); !imageValidated { 124 | jsonResponse(ctx, 400, ErrorFileIsNotImage) 125 | return 126 | } 127 | 128 | imageID := shortid.GetDefault().MustGenerate() 129 | imagePath := getFilePathFromImageID(handler.Config.DataDir, imageID) 130 | if err := os.MkdirAll(filepath.Dir(imagePath), 0755); err != nil { 131 | panic(err) 132 | } 133 | if err := fasthttp.SaveMultipartFile(fileHeader, imagePath); err != nil { 134 | panic(err) 135 | } 136 | jsonResponse(ctx, 200, []byte(fmt.Sprintf(`{"image_id": "%s"}`, imageID))) 137 | } 138 | 139 | func (handler *Handler) handleDelete(ctx *fasthttp.RequestCtx) { 140 | if !ctx.IsDelete() { 141 | jsonResponse(ctx, 405, ErrorMethodNotAllowed) 142 | return 143 | } 144 | 145 | if !handler.tokenIsValid(ctx) { 146 | jsonResponse(ctx, 401, ErrorInvalidToken) 147 | return 148 | } 149 | 150 | match := DeleteRegex.FindSubmatch(ctx.Path()) 151 | if len(match) != 2 { 152 | jsonResponse(ctx, 404, ErrorAddressNotFound) 153 | return 154 | } 155 | imageID := string(match[1]) 156 | imagePath := getFilePathFromImageID(handler.Config.DataDir, imageID) 157 | 158 | err := os.Remove(imagePath) 159 | if err != nil { 160 | if os.IsNotExist(err) { 161 | jsonResponse(ctx, 404, ErrorImageNotFound) 162 | return 163 | } 164 | panic(err) 165 | } 166 | jsonResponse(ctx, 204, nil) 167 | } 168 | 169 | func (handler *Handler) handleFetch(ctx *fasthttp.RequestCtx) { 170 | if !ctx.IsGet() { 171 | jsonResponse(ctx, 405, ErrorMethodNotAllowed) 172 | return 173 | } 174 | options, imageID := parseImageURI(ctx.Path()) 175 | if len(imageID) == 0 { 176 | jsonResponse(ctx, 404, ErrorAddressNotFound) 177 | return 178 | } 179 | 180 | if len(options) == 0 { 181 | // user wants original file 182 | imagePath := getFilePathFromImageID(handler.Config.DataDir, imageID) 183 | if ok := handler.serveFileFromDisk(ctx, imagePath, true); !ok { 184 | jsonResponse(ctx, 404, ErrorImageNotFound) 185 | } 186 | return 187 | } 188 | 189 | webpAccepted := bytes.Contains(ctx.Request.Header.Peek("accept"), []byte("webp")) 190 | 191 | imageParams, err := createImageParams( 192 | imageID, 193 | options, 194 | webpAccepted, 195 | handler.Config, 196 | ) 197 | 198 | if err != nil { 199 | errorBody := []byte(fmt.Sprintf(`{"error": "Invalid options: %v"}`, err)) 200 | jsonResponse(ctx, 400, errorBody) 201 | return 202 | } 203 | 204 | if webpAccepted { 205 | ctx.SetContentType("image/webp") 206 | } else { 207 | ctx.SetContentType("image/jpeg") 208 | } 209 | 210 | cacheFilePath := imageParams.getCachePath(handler.Config.DataDir) 211 | if ok := handler.serveFileFromDisk(ctx, cacheFilePath, false); ok { 212 | // request served from cache 213 | return 214 | } 215 | // cache didn't exist 216 | 217 | if err := validateImageParams(imageParams, handler.Config); err != nil { 218 | errorBody := []byte(fmt.Sprintf(`{"error": "%v"}`, err)) 219 | jsonResponse(ctx, 400, errorBody) 220 | return 221 | } 222 | 223 | imagePath := getFilePathFromImageID(handler.Config.DataDir, imageParams.ImageID) 224 | 225 | err = handler.TaskManager.RunTask(imageParams.getMd5(), func() error { 226 | return convertFunction(imagePath, cacheFilePath, imageParams) 227 | }) 228 | 229 | if err != nil { 230 | if os.IsNotExist(err) { 231 | jsonResponse(ctx, 404, ErrorImageNotFound) 232 | return 233 | } 234 | panic(err) 235 | } 236 | 237 | ctx.Response.SetStatusCode(200) 238 | handler.serveFileFromDisk(ctx, cacheFilePath, false) 239 | } 240 | 241 | func (handler *Handler) handleErrors(ctx *fasthttp.RequestCtx, err error) { 242 | if _, ok := err.(*fasthttp.ErrSmallBuffer); ok { 243 | jsonResponse(ctx, 431, []byte(`{"error": "Too big request header"}`)) 244 | } else if netErr, ok := err.(*net.OpError); ok && netErr.Timeout() { 245 | jsonResponse(ctx, 408, []byte(`{"error": "Request timeout"}`)) 246 | } else { 247 | jsonResponse(ctx, 400, []byte(`{"error": "Error when parsing request"}`)) 248 | } 249 | } 250 | 251 | func (handler *Handler) serveFileFromDisk(ctx *fasthttp.RequestCtx, filePath string, setContentType bool) bool { 252 | f, err := os.Open(filePath) 253 | if err != nil { 254 | if !os.IsNotExist(err) { 255 | log.Println(err) 256 | } 257 | return false 258 | } 259 | buffer := bytebufferpool.Get() 260 | defer bytebufferpool.Put(buffer) 261 | _, err = buffer.ReadFrom(f) 262 | if err != nil { 263 | panic(err) 264 | } 265 | f.Close() 266 | ctx.SetBody(buffer.B) 267 | ctx.Response.Header.SetBytesKV(CacheControlKey, handler.CacheControlHeader) 268 | if setContentType { 269 | ctx.SetContentType(http.DetectContentType(buffer.B)) 270 | } 271 | return true 272 | } 273 | 274 | func parseImageURI(requestPath []byte) (options, imageID string) { 275 | // options are in the format below: 276 | // w=200,h=200,fit=cover,quality=90 277 | 278 | match := ImageRegex.FindStringSubmatch(string(requestPath)) 279 | if len(match) != 4 { 280 | return 281 | } 282 | options, imageID = match[2], match[3] 283 | return 284 | } 285 | -------------------------------------------------------------------------------- /image_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/matryer/is" 6 | bimg "gopkg.in/h2non/bimg.v1" 7 | "testing" 8 | ) 9 | 10 | func (p *ImageParams) String() string { 11 | return fmt.Sprintf( 12 | "id:%s,width:%d,height:%d,fit:%s,quality:%d,webp_accepted:%t", 13 | p.ImageID, p.Width, p.Height, p.Fit, p.Quality, p.WebpAccepted, 14 | ) 15 | } 16 | 17 | func bimgOptsToString(o *bimg.Options) string { 18 | return fmt.Sprintf( 19 | "type:%d,width:%d,height:%d,crop:%t,embed:%t", 20 | o.Type, o.Width, o.Height, o.Crop, o.Embed, 21 | ) 22 | } 23 | 24 | func bimgOptsAreEqual(o1 *bimg.Options, o2 *bimg.Options) bool { 25 | return o1.Type == o2.Type && o1.Width == o2.Width && 26 | o1.Height == o2.Height && o1.Crop == o2.Crop && o1.Embed == o2.Embed 27 | } 28 | 29 | func TestImagePath(t *testing.T) { 30 | is := is.New(t) 31 | imagePath := getFilePathFromImageID("/tmp/media", "FyBmW7C2f") 32 | is.Equal(imagePath, "/tmp/media/images/y/mW/FyBmW7C2f") 33 | } 34 | 35 | func TestCachePath(t *testing.T) { 36 | is := is.New(t) 37 | params := &ImageParams{ 38 | ImageID: "NG4uQBa2f", 39 | Width: 100, 40 | Height: 100, 41 | Fit: "cover", 42 | Quality: 90, 43 | WebpAccepted: true, 44 | } 45 | 46 | is.Equal(params.getMd5(), "c64dda22268336d2c246899c2bc79005") 47 | is.Equal( 48 | params.getCachePath("/tmp/media/"), 49 | "/tmp/media/caches/5/00/NG4uQBa2f-c64dda22268336d2c246899c2bc79005", 50 | ) 51 | } 52 | 53 | func TestGetParamsFromUri(t *testing.T) { 54 | config := &Config{ 55 | DataDir: "/tmp/media/", 56 | DefaultImageQuality: 50, 57 | ValidImageQualities: []int{50, 90, 95}, 58 | } 59 | 60 | tt := []struct { 61 | testID int 62 | imageID string 63 | options string 64 | webpAccepted bool 65 | expectedParams *ImageParams 66 | err error 67 | }{ 68 | { 69 | testID: 1, 70 | imageID: "NG4uQBa2f", 71 | options: "w=500,h=500,fit=contain", 72 | webpAccepted: false, 73 | expectedParams: &ImageParams{ 74 | ImageID: "NG4uQBa2f", 75 | Fit: "contain", 76 | Width: 500, 77 | Height: 500, 78 | Quality: 50, 79 | WebpAccepted: false, 80 | }, 81 | err: nil, 82 | }, 83 | { 84 | testID: 2, 85 | imageID: "NG4uQBa2f", 86 | options: "w=300,h=300,fit=contain", 87 | webpAccepted: false, 88 | expectedParams: &ImageParams{ 89 | ImageID: "NG4uQBa2f", 90 | Fit: "contain", 91 | Width: 300, 92 | Height: 300, 93 | Quality: 50, 94 | WebpAccepted: false, 95 | }, 96 | err: nil, 97 | }, 98 | { 99 | testID: 3, 100 | imageID: "NG4uQBa2f", 101 | options: "w=300,h=300,fit=contain", 102 | webpAccepted: true, 103 | expectedParams: &ImageParams{ 104 | ImageID: "NG4uQBa2f", 105 | Fit: "contain", 106 | Width: 300, 107 | Height: 300, 108 | Quality: 50, 109 | WebpAccepted: true, 110 | }, 111 | err: nil, 112 | }, 113 | { 114 | testID: 4, 115 | imageID: "NG4uQBa2f", 116 | options: "w=300,h=300,fit=cover", 117 | webpAccepted: true, 118 | expectedParams: &ImageParams{ 119 | ImageID: "NG4uQBa2f", 120 | Fit: "cover", 121 | Width: 300, 122 | Height: 300, 123 | Quality: 50, 124 | WebpAccepted: true, 125 | }, 126 | err: nil, 127 | }, 128 | { 129 | testID: 7, 130 | imageID: "NG4uQBa2f", 131 | options: "w=300,h=300,fit=scale-down", 132 | webpAccepted: true, 133 | expectedParams: &ImageParams{ 134 | ImageID: "NG4uQBa2f", 135 | Fit: "scale-down", 136 | Width: 300, 137 | Height: 300, 138 | Quality: 50, 139 | WebpAccepted: true, 140 | }, 141 | err: nil, 142 | }, 143 | { 144 | testID: 8, 145 | imageID: "NG4uQBa2f", 146 | options: "w=0,h=0", 147 | webpAccepted: true, 148 | expectedParams: &ImageParams{ 149 | ImageID: "NG4uQBa2f", 150 | Fit: "contain", 151 | Width: 0, 152 | Height: 0, 153 | Quality: 50, 154 | WebpAccepted: true, 155 | }, 156 | err: nil, 157 | }, 158 | { 159 | testID: 9, 160 | imageID: "NG4uQBa2f", 161 | options: "w=ff,h=0", 162 | webpAccepted: true, 163 | expectedParams: &ImageParams{}, 164 | err: fmt.Errorf("Width should be integer"), 165 | }, 166 | { 167 | testID: 10, 168 | imageID: "NG4uQBa2f", 169 | options: "w=300,h=gg", 170 | webpAccepted: true, 171 | expectedParams: &ImageParams{}, 172 | err: fmt.Errorf("Height should be integer"), 173 | }, 174 | { 175 | testID: 12, 176 | imageID: "NG4uQBa2f", 177 | options: "w==", 178 | webpAccepted: true, 179 | expectedParams: &ImageParams{}, 180 | err: fmt.Errorf("Invalid param: w=="), 181 | }, 182 | { 183 | testID: 13, 184 | imageID: "NG4uQBa2f", 185 | options: "fit=stretch", 186 | webpAccepted: true, 187 | expectedParams: &ImageParams{}, 188 | err: fmt.Errorf("Supported fits are cover, contain and scale-down"), 189 | }, 190 | { 191 | testID: 15, 192 | imageID: "NG4uQBa2f", 193 | options: "k=k", 194 | webpAccepted: true, 195 | expectedParams: &ImageParams{}, 196 | err: fmt.Errorf("Invalid filter key: k"), 197 | }, 198 | { 199 | testID: 16, 200 | imageID: "NG4uQBa2f", 201 | options: "q=95", 202 | webpAccepted: true, 203 | expectedParams: &ImageParams{ 204 | ImageID: "NG4uQBa2f", 205 | Fit: "contain", 206 | Width: 0, 207 | Height: 0, 208 | Quality: 95, 209 | WebpAccepted: true, 210 | }, 211 | err: nil, 212 | }, 213 | { 214 | testID: 17, 215 | imageID: "NG4uQBa2f", 216 | options: "q=m", 217 | webpAccepted: true, 218 | expectedParams: &ImageParams{ 219 | ImageID: "NG4uQBa2f", 220 | Fit: "contain", 221 | Width: 0, 222 | Height: 0, 223 | Quality: 95, 224 | WebpAccepted: true, 225 | }, 226 | err: fmt.Errorf("Quality should be integer"), 227 | }, 228 | } 229 | 230 | for _, tc := range tt { 231 | t.Run(fmt.Sprintf("ImageParamsFromUri %d", tc.testID), func(t *testing.T) { 232 | is := is.NewRelaxed(t) 233 | resultParams, err := createImageParams( 234 | tc.imageID, 235 | tc.options, 236 | tc.webpAccepted, 237 | config, 238 | ) 239 | 240 | if tc.err != nil { 241 | is.True(err != nil) 242 | is.Equal(tc.err.Error(), err.Error()) 243 | } else { 244 | is.Equal(tc.expectedParams, resultParams) 245 | } 246 | }) 247 | } 248 | 249 | } 250 | 251 | func TestGetParamsToBimgOptions(t *testing.T) { 252 | tt := []struct { 253 | name string 254 | imageParams *ImageParams 255 | imageSize *bimg.ImageSize 256 | options *bimg.Options 257 | }{ 258 | { 259 | name: "webp_accepted_false", 260 | imageParams: &ImageParams{ 261 | Width: 300, 262 | Height: 300, 263 | Fit: "cover", 264 | Quality: 80, 265 | WebpAccepted: false, 266 | }, 267 | imageSize: &bimg.ImageSize{ 268 | Width: 900, 269 | Height: 800, 270 | }, 271 | options: &bimg.Options{ 272 | Width: 300, 273 | Height: 300, 274 | Type: bimg.JPEG, 275 | Crop: true, 276 | Embed: true, 277 | }, 278 | }, 279 | { 280 | name: "webp_accepted_true", 281 | imageParams: &ImageParams{ 282 | Width: 300, 283 | Height: 300, 284 | Fit: "cover", 285 | Quality: 80, 286 | WebpAccepted: true, 287 | }, 288 | imageSize: &bimg.ImageSize{ 289 | Width: 900, 290 | Height: 800, 291 | }, 292 | options: &bimg.Options{ 293 | Width: 300, 294 | Height: 300, 295 | Type: bimg.WEBP, 296 | Crop: true, 297 | Embed: true, 298 | }, 299 | }, 300 | { 301 | name: "cover_landscape", 302 | imageParams: &ImageParams{ 303 | Width: 300, 304 | Height: 300, 305 | Fit: "cover", 306 | Quality: 80, 307 | WebpAccepted: true, 308 | }, 309 | imageSize: &bimg.ImageSize{ 310 | Width: 900, 311 | Height: 400, 312 | }, 313 | options: &bimg.Options{ 314 | Width: 300, 315 | Height: 300, 316 | Type: bimg.WEBP, 317 | Crop: true, 318 | Embed: true, 319 | }, 320 | }, 321 | { 322 | name: "cover_portrait", 323 | imageParams: &ImageParams{ 324 | Width: 300, 325 | Height: 300, 326 | Fit: "cover", 327 | Quality: 80, 328 | WebpAccepted: true, 329 | }, 330 | imageSize: &bimg.ImageSize{ 331 | Width: 400, 332 | Height: 900, 333 | }, 334 | options: &bimg.Options{ 335 | Width: 300, 336 | Height: 300, 337 | Type: bimg.WEBP, 338 | Crop: true, 339 | Embed: true, 340 | }, 341 | }, 342 | { 343 | name: "contain_landscape_width_restrict", 344 | imageParams: &ImageParams{ 345 | Width: 300, 346 | Height: 300, 347 | Fit: "contain", 348 | Quality: 80, 349 | WebpAccepted: true, 350 | }, 351 | imageSize: &bimg.ImageSize{ 352 | Width: 900, 353 | Height: 400, 354 | }, 355 | options: &bimg.Options{ 356 | Width: 300, 357 | Height: 0, 358 | Type: bimg.WEBP, 359 | Crop: false, 360 | Embed: false, 361 | }, 362 | }, 363 | { 364 | name: "contain_landscape_height_restrict", 365 | imageParams: &ImageParams{ 366 | Width: 900, 367 | Height: 300, 368 | Fit: "contain", 369 | Quality: 80, 370 | WebpAccepted: true, 371 | }, 372 | imageSize: &bimg.ImageSize{ 373 | Width: 900, 374 | Height: 400, 375 | }, 376 | options: &bimg.Options{ 377 | Width: 0, 378 | Height: 300, 379 | Type: bimg.WEBP, 380 | Crop: false, 381 | Embed: false, 382 | }, 383 | }, 384 | { 385 | name: "contain_only_height", 386 | imageParams: &ImageParams{ 387 | Height: 300, 388 | Fit: "contain", 389 | Quality: 80, 390 | WebpAccepted: true, 391 | }, 392 | imageSize: &bimg.ImageSize{ 393 | Width: 900, 394 | Height: 400, 395 | }, 396 | options: &bimg.Options{ 397 | Width: 0, 398 | Height: 300, 399 | Type: bimg.WEBP, 400 | Crop: false, 401 | Embed: false, 402 | }, 403 | }, 404 | { 405 | name: "contain_only_width", 406 | imageParams: &ImageParams{ 407 | Width: 300, 408 | Fit: "contain", 409 | Quality: 80, 410 | WebpAccepted: true, 411 | }, 412 | imageSize: &bimg.ImageSize{ 413 | Width: 900, 414 | Height: 400, 415 | }, 416 | options: &bimg.Options{ 417 | Width: 300, 418 | Type: bimg.WEBP, 419 | Crop: false, 420 | Embed: false, 421 | }, 422 | }, 423 | { 424 | name: "scale-down-width-gt-heigh", 425 | imageParams: &ImageParams{ 426 | Width: 1200, 427 | Fit: "scale-down", 428 | Quality: 80, 429 | WebpAccepted: true, 430 | }, 431 | imageSize: &bimg.ImageSize{ 432 | Width: 900, 433 | Height: 400, 434 | }, 435 | options: &bimg.Options{ 436 | Width: 900, 437 | Type: bimg.WEBP, 438 | Crop: false, 439 | Embed: false, 440 | }, 441 | }, 442 | { 443 | name: "scale-down-height-gt-width", 444 | imageParams: &ImageParams{ 445 | Height: 1200, 446 | Fit: "scale-down", 447 | Quality: 80, 448 | WebpAccepted: true, 449 | }, 450 | imageSize: &bimg.ImageSize{ 451 | Width: 400, 452 | Height: 900, 453 | }, 454 | options: &bimg.Options{ 455 | Height: 900, 456 | Type: bimg.WEBP, 457 | Crop: false, 458 | Embed: false, 459 | }, 460 | }, 461 | } 462 | 463 | for _, tc := range tt { 464 | t.Run(tc.name, func(t *testing.T) { 465 | opts := tc.imageParams.toBimgOptions(tc.imageSize) 466 | if !bimgOptsAreEqual(tc.options, opts) { 467 | t.Fatalf("Expected %s but result is %s", 468 | bimgOptsToString(tc.options), 469 | bimgOptsToString(opts), 470 | ) 471 | } 472 | }) 473 | } 474 | } 475 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # webp-server 2 | 3 | [![Coverage Status](https://coveralls.io/repos/github/mehdipourfar/webp-server/badge.svg?branch=master)](https://coveralls.io/github/mehdipourfar/webp-server?branch=master) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/mehdipourfar/webp-server)](https://goreportcard.com/report/github.com/mehdipourfar/webp-server) 5 | [![Release](https://img.shields.io/github/v/release/mehdipourfar/webp-server?sort=semver)](https://github.com/mehdipourfar/webp-server/releases) 6 | [![Github Workflow Status](https://github.com/mehdipourfar/webp-server/workflows/test/badge.svg)](https://github.com/mehdipourfar/webp-server/actions?query=workflow%3Atest) 7 | [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go#images) 8 | 9 | Simple and minimal image server capable of storing, resizing, converting, and caching images. You can quickly find out how it works by looking at the flowchart below. 10 | 11 |

12 | Flowchart 13 |

14 | 15 | 16 | ## Contents 17 | 18 | - [Quickstart](#quickstart) 19 | - [FAQ](#faq) 20 | - [Installation](#installation) 21 | - [Docker](#docker) 22 | - [Download Binary](#download-binary) 23 | - [Build From Source](#build-from-source) 24 | - [Configuration](#configuration) 25 | - [Backend APIs](#backend-apis) 26 | - [Frontend APIs](#frontend-apis) 27 | - [Reverse Proxy](#reverse-proxy) 28 | 29 | 30 | ## Quickstart 31 | Run a docker container of `webp-server`. 32 | ```sh 33 | docker run -d -v webp_server_volume:/var/lib/webp-server --name webp-server -e TOKEN='MY_STRONG_TOKEN' -p 127.0.0.1:8080:8080 ms68/webp-server 34 | ``` 35 | Upload an image: 36 | 37 | ``` sh 38 | curl -H 'Token: MY_STRONG_TOKEN' -X POST -F 'image_file=@/path/to/image.jpg' http://127.0.0.1:8080/upload/ 39 | 40 | # this api will return an image_id 41 | ``` 42 | 43 | Open these urls in your browser. 44 | 45 | ``` 46 | http://127.0.0.1:8080/image/width=500,height=500,fit=contain,quality=100/{image_id} 47 | http://127.0.0.1:8080/image/width=300,height=300,fit=cover,quality=90/{image_id} 48 | ``` 49 | 50 | For supporting more image sizes and qualities, you should edit the config file which resides in `webp_server_volume`: 51 | 52 | ``` sh 53 | docker volume ls -f name=webp_server_volume --format "{{ .Mountpoint }}" 54 | ``` 55 | 56 | And then, restart the server: 57 | 58 | ``` sh 59 | docker container restart webp-server 60 | ``` 61 | 62 | 63 | 64 | ## FAQ 65 | * ### What is webp-server? 66 | `webp-server` is a dynamic image resizer and format converter server built on top of [libvips](https://github.com/libvips/libvips), [bimg](https://github.com/h2non/bimg), and [fasthttp](https://github.com/valyala/fasthttp). Backend developers can run this program on their server machines and upload images to it instead of storing them. It will return an `image_id` which needs to be saved on a database by the backend application (on a `varchar` field with a length of at least 12). 67 | By using that `image_id`, web clients can request images from `webp-server` and get them in the appropriate size and format. 68 | 69 | Here is an example request URL for an image cropped to 500x500 size. 70 | 71 | ```code 72 | https://example.com/image/w=500,h=500,fit=cover/(image_id) 73 | ``` 74 | 75 | * ### What are the benfits of serving images in WebP format? 76 | According to Google Developers website: 77 | > WebP is a modern image format that provides superior lossless and lossy compression for images on the web. Using WebP, webmasters and web developers can create smaller, richer images that make the web faster. 78 | 79 | Although nowadays most web browsers support WebP, less than 1% of websites serve their images in this format. That's maybe because converting images to WebP can be complicated and time-consuming or developers don't know what to do with the browsers which don't support WebP. 80 | 81 | * ### What can webp-server do about the browsers which don't support WebP? 82 | When browsers request an image, they will send an accept header containing supported image formats. `webp-server` will lookup that header to see if the browser supports WebP or not. If not, it will send the image in JPEG. 83 | 84 | * ### Isn't it resource expensive to convert images on each request? 85 | Yes, it is. For this reason, `webp-server` will cache each converted image after the first request. 86 | 87 | * ### What about security topics such as DOS attacks or heavy storage usage? 88 | It is up to you. You can limit the combinations of widths and heights or qualities that you accept from the client in `webp-server` configuration file, and by doing that you will narrow down the type of accepted requests for generating images. In case of serving requests from the cache, powered by `fasthttp`, `webp-server` can be blazingly fast. 89 | 90 | * ### Can web clients upload images to `webp-server` and send the `image_id` to a web server? 91 | It is strongly recommended not to do this and also do not share your `webp-server` token with frontend applications for security reasons. Process should be like this: Frontend uploads the image to the backend, backend uploads it to wepb-server, and stores the returning `image_id` in database. 92 | 93 | * ### What is the advantage of using `webp-server` instead of similar projects? 94 | It is simple and minimal and has been designed to work along with the backend applications for serving images of websites in WebP format. It does not support all kinds of manipulations that one can do with images. It does a few things and tries to do them perfectly. 95 | 96 | 97 | ## Installation 98 | There are two methods for running `webp-server`. Either use docker or build it yourself: 99 | 100 | ### Docker 101 | 102 | ```sh 103 | docker run -d -v webp_server_volume:/var/lib/webp-server --name webp-server -e TOKEN='MY_STRONG_TOKEN' -p 127.0.0.1:8080:8080 ms68/webp-server 104 | ``` 105 | 106 | ### Download Binary 107 | `webp-server` is depending on libvips=>8.9. On Ubuntu 20.04 You can install it By the command below: 108 | 109 | ``` sh 110 | sudo apt install libvips 111 | ``` 112 | After installation, you can check your `libvips` version by running this command: 113 | 114 | ```sh 115 | vips -v 116 | ``` 117 | Download the binary from here: 118 | 119 | ``` sh 120 | wget https://github.com/mehdipourfar/webp-server/releases/download/v1.0.0/webp-server_1.0.0_linux_amd64.tar.gz 121 | ``` 122 | 123 | ### Build From Source 124 | 125 | ``` sh 126 | sudo apt install libvips-dev git 127 | 128 | 129 | ## in Case you don't have Golang installed on your system. 130 | 131 | wget https://golang.org/dl/go1.15.6.linux-amd64.tar.gz 132 | sudo tar -C /usr/local -xzf go1.15.6.linux-amd64.tar.gz 133 | export GOPATH=$HOME/go 134 | export PATH=$PATH:/usr/local/go/bin 135 | 136 | go get -u -v github.com/mehdipourfar/webp-server 137 | sudo cp $HOME/go/bin/webp-server /usr/bin/ 138 | 139 | 140 | # Download and edit `example-config.yml` to your desired config 141 | wget https://raw.githubusercontent.com/mehdipourfar/webp-server/master/example-config.yml 142 | 143 | # Run the server: 144 | webp-server -config example-config.yml 145 | 146 | ``` 147 | 148 | 149 | ## Configuration 150 | There is an example configuration file [example-config.yml](https://github.com/mehdipourfar/webp-server/blob/master/example-config.yml) in the code directory. Here is the list of parameters that you can configure: 151 | 152 | * `data_dir`: Data directory in which images and cached images are stored. Note that in this directory, there will be two separate directories named `images` and `caches`. You can remove the caches directory at any point in time if you wanted to free up some disk space. 153 | 154 | * `server_address`: Combination of ip:port. Default value is 127.0.0.1:8080. 155 | 156 | * `token`: The token that your backend application should send in the request header for upload and delete operations. 157 | 158 | * `default_image_quality`: When converting images, `webp-server` uses this value for conversion quality in case the user omits the quality option in the request. The default value is 95. By decreasing this value, size and quality of the image will be decreased. 159 | 160 | * `valid_image_qualities`: List of integer values from 10 to 100 which will be 161 | accepted from users as the quality option. 162 | (Narrow down these values to prevent attackers from creating too many cache files for your images.) 163 | 164 | * `valid_image_sizes`: List of string values in (width)x(height) format which will be accepted from users as width and height options. In case you want your users to be able to set width=500 without providing height, you can add 500x0 to the values list. 165 | (Narrow down these values to prevent attackers from creating too many cache files for your images.) 166 | 167 | * `max_uploaded_image_size`: Maximum size of accepted uploaded images in Megabytes. 168 | 169 | * `debug`: When set to `true` `/image/` API does not check if width, height, and quality are included in `valid_image_sizes` and `valid_image_qualities`. It can be useful when you are developing your frontend applications and you are not yet sure which sizes and qualities you want. But do not set it to `true` on production server. 170 | 171 | 172 | ## Backend APIs 173 | * `/upload/ [Method: POST]`: Accepts image in multipart/form-data format with a field name of `image_file`. You should also pass the `Token` previously set in your configuration file as a header. All responses are in JSON format. If request is successful, you will get `200` status code with such body: `{"image_id": "lulRDHbMg"}` (Note that `image_id` length can vary from 9 to 12). Otherwise, depending on the error, you will get `4xx` or `5xx` status code with a body like this: `{"error": "reason of error"}`. 174 | 175 | Example: 176 | ```sh 177 | curl -H 'Token: 456e910f-3d07-470d-a862-1deb1494a38e' -X POST -F 'image_file=@/path/to/image.png' http://127.0.0.1:8080/upload/ 178 | ``` 179 | 180 | * `/delete/(image_id) [Method: DELETE]`: Accepts `image_id` as URL parameter. If the image is deleted without a problem, the server will return `204` status code with an empty body. Otherwise, it will return `4xx` or `5xx` with an error message in JSON format. 181 | 182 | Example: 183 | ```sh 184 | curl -H 'Token: 456e910f-3d07-470d-a862-1deb1494a38e' -X DELETE "http://localhost:8080/delete/lulRDHbMg"; 185 | ``` 186 | 187 | * `/health/ [Method: GET]`: It returns `200` status code if the server is up and running. It can be used by container managers to check the status of a `webp-server` container. 188 | 189 | 190 | ## Frontend APIs 191 | * `/image/(image_id) [Method: GET]`: Returns the image which has been uploaded to `webp-server` in original size and format. 192 | 193 | * `/image/(filter_options)/(image_id) [Method: GET]`: Returns the filtered image with content-type based on `Accept` header of the browser. Filter options can be these parameters: 194 | * `w`, `width`: Width of the requested image. 195 | * `h`, `height`: Height of the requested image. 196 | * `q`, `quality`: Quality of the requested image. The default value should be set in the server config. 197 | * `fit`: Accepts `cover`, `contain` and `scale-down` as value. 198 | * `contain`: Image will be resized (shrunk or enlarged) to be as large as possible within the given `width` or `height` while preserving the aspect ratio. This is the default value for fit. 199 | * `scale-down`: Image will be shrunk in size to fully fit within the given `width` or `height`, but won’t be enlarged. 200 | * `cover`: Image will be resized to exactly fill the entire area specified by `width` and `height`, and will cropped if necessary. 201 | 202 | Some example image urls: 203 | ``` 204 | http://example.com/image/w=500,h=500/lulRDHbMg 205 | http://example.com/image/w=500,h=500,q=95/lulRDHbMg 206 | http://example.com/image/w=500,h=500,fit=cover/lulRDHbMg 207 | http://example.com/image/w=500,h=500,fit=contain/lulRDHbMg 208 | http://example.com/image/w=500,fit=contain/lulRDHbMg 209 | ``` 210 | 211 | ## Reverse Proxy 212 | 213 | `webp-server` does not support SSL or domain name validation. It is recommended to use a reverse proxy such as [nginx](https://www.nginx.com/) in front of it. It should only cover frontend APIs. Backend APIs should be called locally. Here is a minimal `nginx` configuration that redirects all the paths which start with `/image/` to `webp-server`. 214 | 215 | ``` nginx 216 | 217 | upstream webp_server { 218 | server 127.0.0.1:8080 fail_timeout=0; 219 | } 220 | 221 | server { 222 | # ... 223 | 224 | location /image/ { 225 | proxy_redirect off; 226 | proxy_set_header X-Real-IP $remote_addr; 227 | proxy_set_header X-Scheme $scheme; 228 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 229 | proxy_set_header X-Forwarded-Protocol $scheme; 230 | proxy_pass http://webp_server; 231 | } 232 | } 233 | 234 | ``` 235 | 236 | ## Security Checklist 237 | * Set `debug` config to `false` value in production. 238 | * Narrow down `valid_image_qualities` and `valid_image_sizes` to the values you really want. 239 | * From the outside of the server, `webp-server` address should not be accessible, and users should only be able to see the `/image/` path through your reverse proxy. 240 | -------------------------------------------------------------------------------- /handlers_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/matryer/is" 8 | "github.com/valyala/fasthttp" 9 | "github.com/valyala/fasthttp/fasthttputil" 10 | bimg "gopkg.in/h2non/bimg.v1" 11 | "io/ioutil" 12 | "log" 13 | "mime/multipart" 14 | "net" 15 | "os" 16 | "sync" 17 | "sync/atomic" 18 | "testing" 19 | ) 20 | 21 | var ( 22 | defaultToken = []byte("123") 23 | testFilePNG = "./testdata/test.png" 24 | testFileJPEG = "./testdata/test.jpg" 25 | testFileWEBP = "./testdata/test.webp" 26 | testFilePDF = "./testdata/test.pdf" 27 | ) 28 | 29 | type UploadResult struct { 30 | ImageID string `json:"image_id"` 31 | } 32 | 33 | type ErrorResult struct { 34 | Error string `json:"error"` 35 | } 36 | 37 | func createRequest(uri, method string, token []byte, body *bytes.Buffer) *fasthttp.Request { 38 | req := fasthttp.AcquireRequest() 39 | req.SetRequestURI(uri) 40 | if method != "GET" { 41 | req.Header.SetMethod(method) 42 | } 43 | if token != nil { 44 | req.Header.SetBytesKV([]byte("Token"), token) 45 | } 46 | if body != nil { 47 | req.SetBody(body.Bytes()) 48 | } 49 | return req 50 | } 51 | 52 | func createUploadRequest( 53 | method string, 54 | token []byte, 55 | paramName, path string, 56 | ) *fasthttp.Request { 57 | file, err := os.Open(path) 58 | if err != nil { 59 | panic(err) 60 | } 61 | fileContents, err := ioutil.ReadAll(file) 62 | if err != nil { 63 | panic(err) 64 | } 65 | fi, err := file.Stat() 66 | if err != nil { 67 | panic(err) 68 | } 69 | file.Close() 70 | 71 | body := new(bytes.Buffer) 72 | writer := multipart.NewWriter(body) 73 | part, err := writer.CreateFormFile(paramName, fi.Name()) 74 | if err != nil { 75 | panic(err) 76 | } 77 | _, err = part.Write(fileContents) 78 | if err != nil { 79 | panic(err) 80 | } 81 | ct := writer.FormDataContentType() 82 | err = writer.Close() 83 | if err != nil { 84 | panic(err) 85 | } 86 | req := createRequest("http://test/upload/", method, token, body) 87 | req.Header.SetContentType(ct) 88 | return req 89 | } 90 | 91 | func serve(server *fasthttp.Server, req *fasthttp.Request) *fasthttp.Response { 92 | ln := fasthttputil.NewInmemoryListener() 93 | defer ln.Close() 94 | 95 | go func() { 96 | err := server.Serve(ln) 97 | if err != nil { 98 | panic(fmt.Errorf("failed to serve: %v", err)) 99 | } 100 | }() 101 | 102 | client := fasthttp.Client{ 103 | Dial: func(addr string) (net.Conn, error) { 104 | return ln.Dial() 105 | }, 106 | } 107 | resp := fasthttp.AcquireResponse() 108 | err := client.Do(req, resp) 109 | if err != nil { 110 | panic(err) 111 | } 112 | return resp 113 | } 114 | 115 | func getTestConfig() *Config { 116 | cfg := getDefaultConfig() 117 | dir, err := ioutil.TempDir("", "test") 118 | if err != nil { 119 | panic(err) 120 | } 121 | cfg.DataDir = dir 122 | cfg.Token = string(defaultToken) 123 | cfg.DefaultImageQuality = 90 124 | cfg.ValidImageSizes = []string{"500x200", "500x500", "100x100"} 125 | cfg.ValidImageQualities = []int{80, 90, 95, 100} 126 | return cfg 127 | } 128 | 129 | func TestHealthFunc(t *testing.T) { 130 | is := is.New(t) 131 | server := createServer(&Config{}) 132 | req := fasthttp.AcquireRequest() 133 | req.SetRequestURI("http://test/health/") 134 | defer fasthttp.ReleaseRequest(req) 135 | 136 | resp := serve(server, req) 137 | is.Equal(resp.Header.StatusCode(), 200) 138 | is.Equal(resp.Body(), []byte(`{"status": "ok"}`)) 139 | } 140 | 141 | func TestUploadFunc(t *testing.T) { 142 | is := is.New(t) 143 | config := getTestConfig() 144 | server := createServer(config) 145 | defer os.RemoveAll(config.DataDir) 146 | 147 | tt := []struct { 148 | name string 149 | method string 150 | imagePath string 151 | imageParamName string 152 | token []byte 153 | expectedStatus int 154 | expectedError []byte 155 | }{ 156 | { 157 | name: "Incorrect Method", 158 | method: "GET", 159 | imagePath: testFileJPEG, 160 | imageParamName: "image_file", 161 | token: nil, 162 | expectedStatus: 405, 163 | expectedError: ErrorMethodNotAllowed, 164 | }, 165 | { 166 | name: "Missing Token", 167 | method: "POST", 168 | imagePath: testFileJPEG, 169 | imageParamName: "image_file", 170 | token: nil, 171 | expectedStatus: 401, 172 | expectedError: ErrorInvalidToken, 173 | }, 174 | { 175 | name: "Invalid Param Name", 176 | method: "POST", 177 | imagePath: testFileJPEG, 178 | imageParamName: "image_fileee", 179 | token: defaultToken, 180 | expectedStatus: 400, 181 | expectedError: ErrorImageNotProvided, 182 | }, 183 | { 184 | name: "Successful Jpeg Upload", 185 | method: "POST", 186 | imagePath: testFileJPEG, 187 | imageParamName: "image_file", 188 | token: defaultToken, 189 | expectedStatus: 200, 190 | expectedError: nil, 191 | }, 192 | { 193 | name: "Successful PNG Upload", 194 | method: "POST", 195 | imagePath: testFilePNG, 196 | imageParamName: "image_file", 197 | token: defaultToken, 198 | expectedStatus: 200, 199 | expectedError: nil, 200 | }, 201 | { 202 | name: "Successful WEBP Upload", 203 | method: "POST", 204 | imagePath: testFileWEBP, 205 | imageParamName: "image_file", 206 | token: defaultToken, 207 | expectedStatus: 200, 208 | expectedError: nil, 209 | }, 210 | { 211 | name: "Failed pdf Upload", 212 | method: "POST", 213 | imagePath: testFilePDF, 214 | imageParamName: "image_file", 215 | token: defaultToken, 216 | expectedStatus: 400, 217 | expectedError: ErrorFileIsNotImage, 218 | }, 219 | } 220 | 221 | for _, tc := range tt { 222 | t.Run(fmt.Sprintf("Test upload errors %s", tc.name), func(t *testing.T) { 223 | is := is.NewRelaxed(t) 224 | req := createUploadRequest( 225 | tc.method, tc.token, 226 | tc.imageParamName, tc.imagePath, 227 | ) 228 | resp := serve(server, req) 229 | body := resp.Body() 230 | is.Equal(resp.Header.ContentType(), []byte("application/json")) 231 | is.Equal(resp.Header.StatusCode(), tc.expectedStatus) 232 | if tc.expectedError != nil { 233 | is.Equal(body, tc.expectedError) 234 | } 235 | if resp.Header.StatusCode() != 200 { 236 | errResult := &ErrorResult{} 237 | err := json.Unmarshal(body, errResult) 238 | is.NoErr(err) 239 | is.True(errResult.Error != "") 240 | } 241 | }) 242 | } 243 | } 244 | 245 | func TestFetchFunc(t *testing.T) { 246 | config := getTestConfig() 247 | server := createServer(config) 248 | defer os.RemoveAll(config.DataDir) 249 | tt := []struct { 250 | name string 251 | uploadFilePath string 252 | fetchOpts string 253 | webpAccepted bool 254 | expectedStatus int 255 | expectedError []byte 256 | expectedCt string 257 | expectedWidth int 258 | expectedHeight int 259 | }{ 260 | { 261 | name: "test png with webp accepted false", 262 | uploadFilePath: testFilePNG, 263 | fetchOpts: "w=500,h=500,fit=cover", 264 | webpAccepted: false, 265 | expectedStatus: 200, 266 | expectedError: nil, 267 | expectedCt: "image/jpeg", 268 | expectedWidth: 500, 269 | expectedHeight: 500, 270 | }, 271 | { 272 | name: "test png with webp accepted true", 273 | uploadFilePath: testFilePNG, 274 | fetchOpts: "w=500,h=500,fit=cover", 275 | webpAccepted: true, 276 | expectedStatus: 200, 277 | expectedError: nil, 278 | expectedCt: "image/webp", 279 | expectedWidth: 500, 280 | expectedHeight: 500, 281 | }, 282 | { 283 | name: "test webp with webp accepted false", 284 | uploadFilePath: testFileWEBP, 285 | fetchOpts: "w=500,h=500,fit=cover", 286 | webpAccepted: false, 287 | expectedStatus: 200, 288 | expectedError: nil, 289 | expectedCt: "image/jpeg", 290 | expectedWidth: 500, 291 | expectedHeight: 500, 292 | }, 293 | { 294 | name: "test string as width", 295 | uploadFilePath: testFileJPEG, 296 | fetchOpts: "w=hi,h=500,fit=cover", 297 | webpAccepted: false, 298 | expectedStatus: 400, 299 | expectedError: []byte(`{"error": "Invalid options: Width should be integer"}`), 300 | expectedCt: "application/json", 301 | expectedWidth: 500, 302 | expectedHeight: 500, 303 | }, 304 | { 305 | name: "test inacceptable dimensions", 306 | uploadFilePath: testFileJPEG, 307 | fetchOpts: "w=300,h=200,fit=cover", 308 | webpAccepted: false, 309 | expectedStatus: 400, 310 | expectedError: []byte(`{"error": "size=300x200 is not supported by server. Contact server admin."}`), 311 | expectedCt: "application/json", 312 | expectedWidth: 0, 313 | expectedHeight: 0, 314 | }, 315 | { 316 | name: "test inacceptable quality", 317 | uploadFilePath: testFileJPEG, 318 | fetchOpts: "w=500,h=500,q=60", 319 | webpAccepted: false, 320 | expectedStatus: 400, 321 | expectedError: []byte(`{"error": "quality=60 is not supported by server. Contact server admin."}`), 322 | expectedCt: "application/json", 323 | expectedWidth: 0, 324 | expectedHeight: 0, 325 | }, 326 | { 327 | name: "acceptable quality", 328 | uploadFilePath: testFileJPEG, 329 | fetchOpts: "w=500,h=500,q=80", 330 | webpAccepted: false, 331 | expectedStatus: 200, 332 | expectedError: nil, 333 | expectedCt: "image/jpeg", 334 | expectedWidth: 500, 335 | expectedHeight: 313, 336 | }, 337 | } 338 | for _, tc := range tt { 339 | t.Run(fmt.Sprintf("Test upload errors %s", tc.name), func(t *testing.T) { 340 | is := is.NewRelaxed(t) 341 | uploadReq := createUploadRequest( 342 | "POST", defaultToken, 343 | "image_file", tc.uploadFilePath, 344 | ) 345 | uploadResp := serve(server, uploadReq) 346 | is.Equal(uploadResp.Header.StatusCode(), 200) 347 | uploadResult := &UploadResult{} 348 | err := json.Unmarshal(uploadResp.Body(), uploadResult) 349 | is.Equal(err, nil) 350 | fetchURI := fmt.Sprintf("http://test/image/%s/%s", tc.fetchOpts, uploadResult.ImageID) 351 | fetchReq := createRequest(fetchURI, "GET", nil, nil) 352 | if tc.webpAccepted { 353 | fetchReq.Header.SetBytesKV([]byte("accept"), []byte("webp")) 354 | } 355 | fetchResp := serve(server, fetchReq) 356 | status := fetchResp.Header.StatusCode() 357 | is.Equal(status, tc.expectedStatus) 358 | is.Equal(string(fetchResp.Header.ContentType()), tc.expectedCt) 359 | body := fetchResp.Body() 360 | if status != 200 { 361 | is.Equal(string(tc.expectedError), string(body)) 362 | errResult := &ErrorResult{} 363 | err := json.Unmarshal(body, errResult) 364 | is.NoErr(err) 365 | is.True(errResult.Error != "") 366 | } else { 367 | img := bimg.NewImage(body) 368 | size, err := img.Size() 369 | is.NoErr(err) 370 | is.Equal(size.Width, tc.expectedWidth) 371 | is.Equal(size.Height, tc.expectedHeight) 372 | } 373 | }) 374 | } 375 | } 376 | 377 | func Test404(t *testing.T) { 378 | is := is.New(t) 379 | config := &Config{} 380 | server := createServer(config) 381 | req := createRequest("http://test/hey", "GET", nil, nil) 382 | resp := serve(server, req) 383 | is.Equal(resp.StatusCode(), 404) 384 | is.Equal(string(resp.Header.ContentType()), "application/json") 385 | is.Equal(resp.Body(), ErrorAddressNotFound) 386 | req = createRequest("http://test/image/w=500/", "GET", nil, nil) 387 | resp = serve(server, req) 388 | is.Equal(resp.StatusCode(), 404) 389 | } 390 | 391 | func TestFetchFuncMethodShouldBeGet(t *testing.T) { 392 | is := is.New(t) 393 | config := getTestConfig() 394 | server := createServer(config) 395 | defer os.RemoveAll(config.DataDir) 396 | req := createRequest("http://test/image/w=500,h=500/NG4uQBa2f", "POST", nil, nil) 397 | resp := serve(server, req) 398 | is.Equal(resp.StatusCode(), 405) 399 | } 400 | 401 | func TestFetchFuncWithInvalidImageID(t *testing.T) { 402 | is := is.New(t) 403 | config := getTestConfig() 404 | server := createServer(config) 405 | defer os.RemoveAll(config.DataDir) 406 | req := createRequest("http://test/image/w=500,h=500/NG4uQBa2f", "GET", nil, nil) 407 | resp := serve(server, req) 408 | is.Equal(resp.StatusCode(), 404) 409 | is.Equal(string(resp.Header.ContentType()), "application/json") 410 | is.Equal(resp.Body(), ErrorImageNotFound) 411 | } 412 | 413 | func TestCacheFileIsCreatedAfterFetch(t *testing.T) { 414 | is := is.New(t) 415 | config := getTestConfig() 416 | server := createServer(config) 417 | defer os.RemoveAll(config.DataDir) 418 | uploadReq := createUploadRequest( 419 | "POST", defaultToken, 420 | "image_file", testFileJPEG, 421 | ) 422 | uploadResp := serve(server, uploadReq) 423 | 424 | is.Equal(uploadResp.Header.StatusCode(), 200) 425 | uploadResult := &UploadResult{} 426 | err := json.Unmarshal(uploadResp.Body(), uploadResult) 427 | is.NoErr(err) 428 | fetchURI := fmt.Sprintf("http://test/image/w=500,h=500,fit=cover/%s", uploadResult.ImageID) 429 | fetchReq := createRequest(fetchURI, "GET", nil, nil) 430 | imageParams := &ImageParams{ 431 | ImageID: uploadResult.ImageID, 432 | Width: 500, 433 | Height: 500, 434 | Quality: config.DefaultImageQuality, 435 | Fit: FitCover, 436 | } 437 | cachePath := imageParams.getCachePath(config.DataDir) 438 | imagePath := getFilePathFromImageID(config.DataDir, uploadResult.ImageID) 439 | 440 | serve(server, fetchReq) 441 | buf, err := bimg.Read(cachePath) 442 | is.NoErr(err) 443 | img := bimg.NewImage(buf) 444 | size, _ := img.Size() 445 | is.Equal(size.Width, 500) 446 | is.Equal(size.Height, 500) 447 | is.NoErr(os.Remove(imagePath)) 448 | resp := serve(server, fetchReq) 449 | is.Equal(resp.StatusCode(), 200) 450 | } 451 | 452 | func TestDeleteHandler(t *testing.T) { 453 | is := is.New(t) 454 | config := getTestConfig() 455 | server := createServer(config) 456 | defer os.RemoveAll(config.DataDir) 457 | uploadReq := createUploadRequest( 458 | "POST", defaultToken, 459 | "image_file", testFileJPEG, 460 | ) 461 | uploadResp := serve(server, uploadReq) 462 | 463 | is.Equal(uploadResp.Header.StatusCode(), 200) 464 | uploadResult := &UploadResult{} 465 | err := json.Unmarshal(uploadResp.Body(), uploadResult) 466 | is.NoErr(err) 467 | tt := []struct { 468 | name string 469 | method string 470 | imageID string 471 | token []byte 472 | expectedStatus int 473 | expectedBody []byte 474 | }{ 475 | { 476 | name: "Invalid Method", 477 | method: "GET", 478 | imageID: "123456789", 479 | token: nil, 480 | expectedStatus: 405, 481 | expectedBody: ErrorMethodNotAllowed, 482 | }, 483 | { 484 | name: "Invalid Address", 485 | method: "DELETE", 486 | imageID: "123456789", 487 | token: nil, 488 | expectedStatus: 401, 489 | expectedBody: ErrorInvalidToken, 490 | }, 491 | { 492 | name: "Invalid Address", 493 | method: "DELETE", 494 | imageID: "123456789/123", 495 | token: defaultToken, 496 | expectedStatus: 404, 497 | expectedBody: ErrorAddressNotFound, 498 | }, 499 | { 500 | name: "Invalid Image", 501 | method: "DELETE", 502 | imageID: "123456789", 503 | token: defaultToken, 504 | expectedStatus: 404, 505 | expectedBody: ErrorImageNotFound, 506 | }, 507 | { 508 | name: "Valid Image", 509 | method: "DELETE", 510 | imageID: uploadResult.ImageID, 511 | token: defaultToken, 512 | expectedStatus: 204, 513 | expectedBody: nil, 514 | }, 515 | } 516 | 517 | for _, tc := range tt { 518 | t.Run(fmt.Sprintf("Test delete errors %s", tc.name), func(t *testing.T) { 519 | is := is.NewRelaxed(t) 520 | uri := fmt.Sprintf("http://test/delete/%s", tc.imageID) 521 | req := createRequest(uri, tc.method, tc.token, nil) 522 | resp := serve(server, req) 523 | is.Equal(resp.StatusCode(), tc.expectedStatus) 524 | is.Equal(string(resp.Header.ContentType()), "application/json") 525 | body := resp.Body() 526 | is.Equal(body, tc.expectedBody) 527 | if body != nil { 528 | errResult := &ErrorResult{} 529 | err := json.Unmarshal(body, errResult) 530 | is.Equal(err, nil) 531 | is.True(errResult.Error != "") 532 | } 533 | }) 534 | } 535 | 536 | imagePath := getFilePathFromImageID(config.DataDir, uploadResult.ImageID) 537 | _, err = os.Stat(imagePath) 538 | is.True(os.IsNotExist(err)) 539 | 540 | } 541 | 542 | func TestGettingOriginalImage(t *testing.T) { 543 | is := is.New(t) 544 | config := getTestConfig() 545 | server := createServer(config) 546 | 547 | defer os.RemoveAll(config.DataDir) 548 | uploadReq := createUploadRequest( 549 | "POST", defaultToken, 550 | "image_file", testFilePNG, 551 | ) 552 | uploadResult := &UploadResult{} 553 | uploadResp := serve(server, uploadReq) 554 | err := json.Unmarshal(uploadResp.Body(), uploadResult) 555 | is.NoErr(err) 556 | uri := fmt.Sprintf("http://test/image/%s", "123456789") 557 | req := createRequest(uri, "GET", nil, nil) 558 | resp := serve(server, req) 559 | is.Equal(resp.StatusCode(), 404) 560 | is.Equal(string(resp.Header.ContentType()), "application/json") 561 | is.Equal(resp.Body(), ErrorImageNotFound) 562 | uri = fmt.Sprintf("http://test/image/%s", uploadResult.ImageID) 563 | req = createRequest(uri, "GET", nil, nil) 564 | resp = serve(server, req) 565 | is.Equal(resp.StatusCode(), 200) 566 | is.Equal(string(resp.Header.ContentType()), "image/png") 567 | img := bimg.NewImage(resp.Body()) 568 | size, _ := img.Size() 569 | is.Equal(size.Width, 1680) 570 | is.Equal(size.Height, 1050) 571 | 572 | } 573 | 574 | func TestConcurentConversionRequests(t *testing.T) { 575 | is := is.New(t) 576 | config := getTestConfig() 577 | server := createServer(config) 578 | 579 | defer os.RemoveAll(config.DataDir) 580 | uploadReq := createUploadRequest( 581 | "POST", defaultToken, 582 | "image_file", testFilePNG, 583 | ) 584 | uploadResult := &UploadResult{} 585 | uploadResp := serve(server, uploadReq) 586 | err := json.Unmarshal(uploadResp.Body(), uploadResult) 587 | is.NoErr(err) 588 | 589 | var wg sync.WaitGroup 590 | reqURI := fmt.Sprintf("http://test/image/w=500,h=500,fit=cover/%s", uploadResult.ImageID) 591 | 592 | var functionCalls int64 593 | 594 | // override convertFunction which is used in handleFetch api 595 | convertFunction = func(inputPath, outputPath string, params *ImageParams) error { 596 | atomic.AddInt64(&functionCalls, 1) 597 | return convert(inputPath, outputPath, params) 598 | } 599 | 600 | for i := 0; i < 10; i++ { 601 | wg.Add(1) 602 | go func() { 603 | defer wg.Done() 604 | fetchReq := createRequest(reqURI, "GET", nil, nil) 605 | resp := serve(server, fetchReq) 606 | is.Equal(resp.StatusCode(), 200) 607 | }() 608 | } 609 | wg.Wait() 610 | is.Equal(functionCalls, int64(1)) 611 | 612 | // test task 500 response on convert panic 613 | 614 | log.SetOutput(ioutil.Discard) 615 | convertFunction = func(inputPath, outputPath string, params *ImageParams) error { 616 | panic("Bizzare error") 617 | } 618 | 619 | reqURI = fmt.Sprintf("http://test/image/w=100,h=100,fit=cover/%s", uploadResult.ImageID) 620 | fetchReq := createRequest(reqURI, "GET", nil, nil) 621 | resp := serve(server, fetchReq) 622 | is.Equal(resp.StatusCode(), 500) 623 | is.Equal(resp.Body(), ErrorServerError) 624 | convertFunction = convert 625 | log.SetOutput(os.Stdout) 626 | } 627 | 628 | func TestAllSizesAndQualitiesAreAvailableWhenDebugging(t *testing.T) { 629 | is := is.New(t) 630 | config := getTestConfig() 631 | server := createServer(config) 632 | 633 | defer os.RemoveAll(config.DataDir) 634 | uploadReq := createUploadRequest( 635 | "POST", defaultToken, 636 | "image_file", testFilePNG, 637 | ) 638 | uploadResult := &UploadResult{} 639 | uploadResp := serve(server, uploadReq) 640 | err := json.Unmarshal(uploadResp.Body(), uploadResult) 641 | is.NoErr(err) 642 | uri := fmt.Sprintf("http://test/image/w=800,h=900,q=72/%s", uploadResult.ImageID) 643 | req := createRequest(uri, "GET", nil, nil) 644 | resp := serve(server, req) 645 | is.Equal(resp.StatusCode(), 400) 646 | config.Debug = true 647 | resp = serve(server, req) 648 | is.Equal(resp.StatusCode(), 200) 649 | } 650 | --------------------------------------------------------------------------------