diff --git a/.circleci/config.yml b/.circleci/config.yml
index 32ecb8af..08eeac58 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -21,9 +21,17 @@ jobs:
root: .
paths:
- '*'
+ test:
+ docker:
+ - image: circleci/golang:1.14.6
+ steps:
+ - checkout
+ - run:
+ name: "Test"
+ command: go test ./...
build-go:
docker:
- - image: circleci/golang:1.14.3
+ - image: circleci/golang:1.14.6
steps:
- attach_workspace:
at: '~/project'
@@ -57,6 +65,10 @@ workflows:
filters:
tags:
only: /.*/
+ - test:
+ filters:
+ tags:
+ only: /.*/
- build-node:
filters:
tags:
@@ -68,6 +80,7 @@ workflows:
requires:
- build-node
- lint
+ - test
- release:
context: deploy
requires:
diff --git a/cmd/root.go b/cmd/root.go
index 8f23e95c..83ef240e 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -14,13 +14,16 @@ import (
"syscall"
homedir "github.com/mitchellh/go-homedir"
+ "github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
v "github.com/spf13/viper"
lumberjack "gopkg.in/natefinch/lumberjack.v2"
"github.com/filebrowser/filebrowser/v2/auth"
+ "github.com/filebrowser/filebrowser/v2/diskcache"
fbhttp "github.com/filebrowser/filebrowser/v2/http"
+ "github.com/filebrowser/filebrowser/v2/img"
"github.com/filebrowser/filebrowser/v2/settings"
"github.com/filebrowser/filebrowser/v2/storage"
"github.com/filebrowser/filebrowser/v2/users"
@@ -56,6 +59,10 @@ func addServerFlags(flags *pflag.FlagSet) {
flags.StringP("root", "r", ".", "root to prepend to relative paths")
flags.String("socket", "", "socket to listen to (cannot be used with address, port, cert nor key flags)")
flags.StringP("baseurl", "b", "", "base url")
+ flags.String("cache-dir", "", "file cache directory (disabled if empty)")
+ flags.Int("img-processors", 4, "image processors count")
+ flags.Bool("disable-thumbnails", false, "disable image thumbnails")
+ flags.Bool("disable-preview-resize", false, "disable resize of image previews")
}
var rootCmd = &cobra.Command{
@@ -103,6 +110,24 @@ user created with the credentials from options "username" and "password".`,
quickSetup(cmd.Flags(), d)
}
+ // build img service
+ workersCount, err := cmd.Flags().GetInt("img-processors")
+ checkErr(err)
+ if workersCount < 1 {
+ log.Fatal("Image resize workers count could not be < 1")
+ }
+ imgSvc := img.New(workersCount)
+
+ var fileCache diskcache.Interface = diskcache.NewNoOp()
+ cacheDir, err := cmd.Flags().GetString("cache-dir")
+ checkErr(err)
+ if cacheDir != "" {
+ if err := os.MkdirAll(cacheDir, 0700); err != nil { //nolint:govet
+ log.Fatalf("can't make directory %s: %s", cacheDir, err)
+ }
+ fileCache = diskcache.New(afero.NewOsFs(), cacheDir)
+ }
+
server := getRunParams(cmd.Flags(), d.store)
setupLog(server.Log)
@@ -132,7 +157,7 @@ user created with the credentials from options "username" and "password".`,
signal.Notify(sigc, os.Interrupt, syscall.SIGTERM)
go cleanupHandler(listener, sigc)
- handler, err := fbhttp.NewHandler(d.store, server)
+ handler, err := fbhttp.NewHandler(imgSvc, fileCache, d.store, server)
checkErr(err)
defer listener.Close()
@@ -205,6 +230,12 @@ func getRunParams(flags *pflag.FlagSet, st *storage.Storage) *settings.Server {
server.Socket = ""
}
+ _, disableThumbnails := getParamB(flags, "disable-thumbnails")
+ server.EnableThumbnails = !disableThumbnails
+
+ _, disablePreviewResize := getParamB(flags, "disable-preview-resize")
+ server.ResizePreview = !disablePreviewResize
+
return server
}
diff --git a/diskcache/cache.go b/diskcache/cache.go
new file mode 100644
index 00000000..2a2eff3d
--- /dev/null
+++ b/diskcache/cache.go
@@ -0,0 +1,11 @@
+package diskcache
+
+import (
+ "context"
+)
+
+type Interface interface {
+ Store(ctx context.Context, key string, value []byte) error
+ Load(ctx context.Context, key string) (value []byte, exist bool, err error)
+ Delete(ctx context.Context, key string) error
+}
diff --git a/diskcache/file_cache.go b/diskcache/file_cache.go
new file mode 100644
index 00000000..419f155f
--- /dev/null
+++ b/diskcache/file_cache.go
@@ -0,0 +1,110 @@
+package diskcache
+
+import (
+ "context"
+ "crypto/sha1" //nolint:gosec
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io/ioutil"
+ "os"
+ "path/filepath"
+ "sync"
+
+ "github.com/spf13/afero"
+)
+
+type FileCache struct {
+ fs afero.Fs
+
+ // granular locks
+ scopedLocks struct {
+ sync.Mutex
+ sync.Once
+ locks map[string]sync.Locker
+ }
+}
+
+func New(fs afero.Fs, root string) *FileCache {
+ return &FileCache{
+ fs: afero.NewBasePathFs(fs, root),
+ }
+}
+
+func (f *FileCache) Store(ctx context.Context, key string, value []byte) error {
+ mu := f.getScopedLocks(key)
+ mu.Lock()
+ defer mu.Unlock()
+
+ fileName := f.getFileName(key)
+ if err := f.fs.MkdirAll(filepath.Dir(fileName), 0700); err != nil {
+ return err
+ }
+
+ if err := afero.WriteFile(f.fs, fileName, value, 0700); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (f *FileCache) Load(ctx context.Context, key string) (value []byte, exist bool, err error) {
+ r, ok, err := f.open(key)
+ if err != nil || !ok {
+ return nil, ok, err
+ }
+ defer r.Close()
+
+ value, err = ioutil.ReadAll(r)
+ if err != nil {
+ return nil, false, err
+ }
+ return value, true, nil
+}
+
+func (f *FileCache) Delete(ctx context.Context, key string) error {
+ mu := f.getScopedLocks(key)
+ mu.Lock()
+ defer mu.Unlock()
+
+ fileName := f.getFileName(key)
+ if err := f.fs.Remove(fileName); err != nil && err != os.ErrNotExist {
+ return err
+ }
+ return nil
+}
+
+func (f *FileCache) open(key string) (afero.File, bool, error) {
+ fileName := f.getFileName(key)
+ file, err := f.fs.Open(fileName)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, false, nil
+ }
+ return nil, false, err
+ }
+
+ return file, true, nil
+}
+
+// getScopedLocks pull lock from the map if found or create a new one
+func (f *FileCache) getScopedLocks(key string) (lock sync.Locker) {
+ f.scopedLocks.Do(func() { f.scopedLocks.locks = map[string]sync.Locker{} })
+
+ f.scopedLocks.Lock()
+ lock, ok := f.scopedLocks.locks[key]
+ if !ok {
+ lock = &sync.Mutex{}
+ f.scopedLocks.locks[key] = lock
+ }
+ f.scopedLocks.Unlock()
+
+ return lock
+}
+
+func (f *FileCache) getFileName(key string) string {
+ hasher := sha1.New() //nolint:gosec
+ _, _ = hasher.Write([]byte(key))
+ hash := hex.EncodeToString(hasher.Sum(nil))
+ return fmt.Sprintf("%s/%s/%s", hash[:1], hash[1:3], hash)
+}
diff --git a/diskcache/file_cache_test.go b/diskcache/file_cache_test.go
new file mode 100644
index 00000000..fdb3d119
--- /dev/null
+++ b/diskcache/file_cache_test.go
@@ -0,0 +1,55 @@
+package diskcache
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+
+ "github.com/spf13/afero"
+ "github.com/stretchr/testify/require"
+)
+
+func TestFileCache(t *testing.T) {
+ ctx := context.Background()
+ const (
+ key = "key"
+ value = "some text"
+ newValue = "new text"
+ cacheRoot = "/cache"
+ cachedFilePath = "a/62/a62f2225bf70bfaccbc7f1ef2a397836717377de"
+ )
+
+ fs := afero.NewMemMapFs()
+ cache := New(fs, "/cache")
+
+ // store new key
+ err := cache.Store(ctx, key, []byte(value))
+ require.NoError(t, err)
+ checkValue(t, ctx, fs, filepath.Join(cacheRoot, cachedFilePath), cache, key, value)
+
+ // update existing key
+ err = cache.Store(ctx, key, []byte(newValue))
+ require.NoError(t, err)
+ checkValue(t, ctx, fs, filepath.Join(cacheRoot, cachedFilePath), cache, key, newValue)
+
+ // delete key
+ err = cache.Delete(ctx, key)
+ require.NoError(t, err)
+ exists, err := afero.Exists(fs, filepath.Join(cacheRoot, cachedFilePath))
+ require.NoError(t, err)
+ require.False(t, exists)
+}
+
+func checkValue(t *testing.T, ctx context.Context, fs afero.Fs, fileFullPath string, cache *FileCache, key, wantValue string) { //nolint:golint
+ t.Helper()
+ // check actual file content
+ b, err := afero.ReadFile(fs, fileFullPath)
+ require.NoError(t, err)
+ require.Equal(t, wantValue, string(b))
+
+ // check cache content
+ b, ok, err := cache.Load(ctx, key)
+ require.NoError(t, err)
+ require.True(t, ok)
+ require.Equal(t, wantValue, string(b))
+}
diff --git a/diskcache/noop_cache.go b/diskcache/noop_cache.go
new file mode 100644
index 00000000..65d57c21
--- /dev/null
+++ b/diskcache/noop_cache.go
@@ -0,0 +1,24 @@
+package diskcache
+
+import (
+ "context"
+)
+
+type NoOp struct {
+}
+
+func NewNoOp() *NoOp {
+ return &NoOp{}
+}
+
+func (n *NoOp) Store(ctx context.Context, key string, value []byte) error {
+ return nil
+}
+
+func (n *NoOp) Load(ctx context.Context, key string) (value []byte, exist bool, err error) {
+ return nil, false, nil
+}
+
+func (n *NoOp) Delete(ctx context.Context, key string) error {
+ return nil
+}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index eb5f4b61..1c67b00b 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -13030,6 +13030,11 @@
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-8.15.3.tgz",
"integrity": "sha512-PVNgo6yhOmacZVFjSapZ314oewwLyXHjJwAqjnaPN1GJAJd/dvsrShGzSiJuCX4Hc36G4epJvNXUwO8y7wEKew=="
},
+ "vue-lazyload": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/vue-lazyload/-/vue-lazyload-1.3.3.tgz",
+ "integrity": "sha512-uHnq0FTEeNmqnbBC2aRKlmtd9LofMZ6Q3mWvgfLa+i9vhxU8fDK+nGs9c1iVT85axSua/AUnMttIq3xPaU9G3A=="
+ },
"vue-loader": {
"version": "15.8.3",
"resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-15.8.3.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index e99c0643..0aea1e0d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -21,6 +21,7 @@
"qrcode.vue": "^1.7.0",
"vue": "^2.6.10",
"vue-i18n": "^8.15.3",
+ "vue-lazyload": "^1.3.3",
"vue-router": "^3.1.3",
"vuex": "^3.1.2",
"vuex-router-sync": "^5.0.0"
diff --git a/frontend/src/components/files/ListingItem.vue b/frontend/src/components/files/ListingItem.vue
index 9b0c5c6c..f7a5f85b 100644
--- a/frontend/src/components/files/ListingItem.vue
+++ b/frontend/src/components/files/ListingItem.vue
@@ -13,7 +13,7 @@
:aria-label="name"
:aria-selected="isSelected">
-
+
{{ icon }}
@@ -31,7 +31,7 @@
+
\ No newline at end of file
diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js
index ad23cc98..49a06dc6 100644
--- a/frontend/src/utils/constants.js
+++ b/frontend/src/utils/constants.js
@@ -11,6 +11,7 @@ const noAuth = window.FileBrowser.NoAuth
const authMethod = window.FileBrowser.AuthMethod
const loginPage = window.FileBrowser.LoginPage
const theme = window.FileBrowser.Theme
+const enableThumbs = window.FileBrowser.EnableThumbs
export {
name,
@@ -24,5 +25,6 @@ export {
noAuth,
authMethod,
loginPage,
- theme
+ theme,
+ enableThumbs
}
diff --git a/frontend/src/utils/vue.js b/frontend/src/utils/vue.js
index b96d5816..6cfe52b9 100644
--- a/frontend/src/utils/vue.js
+++ b/frontend/src/utils/vue.js
@@ -1,8 +1,11 @@
import Vue from 'vue'
import Noty from 'noty'
+import VueLazyload from 'vue-lazyload'
import i18n from '@/i18n'
import { disableExternal } from '@/utils/constants'
+Vue.use(VueLazyload)
+
Vue.config.productionTip = true
const notyDefault = {
diff --git a/frontend/vue.config.js b/frontend/vue.config.js
index c966ee81..610e9166 100644
--- a/frontend/vue.config.js
+++ b/frontend/vue.config.js
@@ -1,4 +1,5 @@
module.exports = {
runtimeCompiler: true,
- publicPath: '[{[ .StaticURL ]}]'
+ publicPath: '[{[ .StaticURL ]}]',
+ parallel: 2,
}
\ No newline at end of file
diff --git a/go.mod b/go.mod
index 4664fe03..7075fa31 100644
--- a/go.mod
+++ b/go.mod
@@ -14,6 +14,7 @@ require (
github.com/gorilla/mux v1.7.3
github.com/gorilla/websocket v1.4.1
github.com/maruel/natural v0.0.0-20180416170133-dbcb3e2e8cf1
+ github.com/marusama/semaphore/v2 v2.4.1
github.com/mholt/archiver v3.1.1+incompatible
github.com/mitchellh/go-homedir v1.1.0
github.com/nwaples/rardecode v1.0.0 // indirect
@@ -24,11 +25,13 @@ require (
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.6.1
+ github.com/stretchr/testify v1.6.1
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce
github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
go.etcd.io/bbolt v1.3.3
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37
+ golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8
golang.org/x/net v0.0.0-20200528225125-3c3fba18258b // indirect
golang.org/x/sys v0.0.0-20200523222454-059865788121 // indirect
golang.org/x/text v0.3.2 // indirect
diff --git a/go.sum b/go.sum
index a9813849..7c4d8d6b 100644
--- a/go.sum
+++ b/go.sum
@@ -127,6 +127,8 @@ github.com/marten-seemann/qtls v0.2.3 h1:0yWJ43C62LsZt08vuQJDK1uC1czUc3FJeCLPoNA
github.com/marten-seemann/qtls v0.2.3/go.mod h1:xzjG7avBwGGbdZ8dTGxlBnLArsVKLvwmjgmPuiQEcYk=
github.com/maruel/natural v0.0.0-20180416170133-dbcb3e2e8cf1 h1:PEhRT94KBTY4E0KdCYmhvDGWjSFBxc68j2M6PMRix8U=
github.com/maruel/natural v0.0.0-20180416170133-dbcb3e2e8cf1/go.mod h1:wI697HNhDFM/vBruYM3ckbszQ2+DOIeH9qdBKMdf288=
+github.com/marusama/semaphore/v2 v2.4.1 h1:Y29DhhFMvreVgoqF9EtaSJAF9t2E7Sk7i5VW81sqB8I=
+github.com/marusama/semaphore/v2 v2.4.1/go.mod h1:z9nMiNUekt/LTpTUQdpp+4sJeYqUGpwMHfW0Z8V8fnQ=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mholt/archiver v3.1.1+incompatible h1:1dCVxuqs0dJseYEhi5pl7MYPH9zDa1wBi7mF09cbNkU=
github.com/mholt/archiver v3.1.1+incompatible/go.mod h1:Dh2dOXnSdiLxRiPoVfIr/fI1TwETms9B8CTWfeh7ROU=
@@ -204,6 +206,8 @@ github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
@@ -316,4 +320,6 @@ gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/http/http.go b/http/http.go
index c4b99918..1633a9e2 100644
--- a/http/http.go
+++ b/http/http.go
@@ -14,7 +14,7 @@ type modifyRequest struct {
Which []string `json:"which"` // Answer to: which fields?
}
-func NewHandler(store *storage.Storage, server *settings.Server) (http.Handler, error) {
+func NewHandler(imgSvc ImgService, fileCache FileCache, store *storage.Storage, server *settings.Server) (http.Handler, error) {
server.Clean()
r := mux.NewRouter()
@@ -59,7 +59,8 @@ func NewHandler(store *storage.Storage, server *settings.Server) (http.Handler,
api.Handle("/settings", monkey(settingsPutHandler, "")).Methods("PUT")
api.PathPrefix("/raw").Handler(monkey(rawHandler, "/api/raw")).Methods("GET")
- api.PathPrefix("/preview/{size}/{path:.*}").Handler(monkey(previewHandler, "/api/preview")).Methods("GET")
+ api.PathPrefix("/preview/{size}/{path:.*}").
+ Handler(monkey(previewHandler(imgSvc, fileCache, server.EnableThumbnails, server.ResizePreview), "/api/preview")).Methods("GET")
api.PathPrefix("/command").Handler(monkey(commandsHandler, "/api/command")).Methods("GET")
api.PathPrefix("/search").Handler(monkey(searchHandler, "/api/search")).Methods("GET")
diff --git a/http/preview.go b/http/preview.go
index c219dbb2..bb3f1829 100644
--- a/http/preview.go
+++ b/http/preview.go
@@ -1,14 +1,16 @@
package http
import (
+ "bytes"
+ "context"
"fmt"
- "image"
+ "io"
"net/http"
- "github.com/disintegration/imaging"
"github.com/gorilla/mux"
"github.com/filebrowser/filebrowser/v2/files"
+ "github.com/filebrowser/filebrowser/v2/img"
)
const (
@@ -16,86 +18,110 @@ const (
sizeBig = "big"
)
-type imageProcessor func(src image.Image) (image.Image, error)
+type ImgService interface {
+ FormatFromExtension(ext string) (img.Format, error)
+ Resize(ctx context.Context, in io.Reader, width, height int, out io.Writer, options ...img.Option) error
+}
-var previewHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
- if !d.user.Perm.Download {
- return http.StatusAccepted, nil
- }
- vars := mux.Vars(r)
- size := vars["size"]
- if size != sizeBig && size != sizeThumb {
- return http.StatusNotImplemented, nil
- }
+type FileCache interface {
+ Store(ctx context.Context, key string, value []byte) error
+ Load(ctx context.Context, key string) ([]byte, bool, error)
+}
- file, err := files.NewFileInfo(files.FileOptions{
- Fs: d.user.Fs,
- Path: "/" + vars["path"],
- Modify: d.user.Perm.Modify,
- Expand: true,
- Checker: d,
+func previewHandler(imgSvc ImgService, fileCache FileCache, enableThumbnails, resizePreview bool) handleFunc {
+ return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
+ if !d.user.Perm.Download {
+ return http.StatusAccepted, nil
+ }
+ vars := mux.Vars(r)
+ size := vars["size"]
+ if size != sizeBig && size != sizeThumb {
+ return http.StatusNotImplemented, nil
+ }
+
+ file, err := files.NewFileInfo(files.FileOptions{
+ Fs: d.user.Fs,
+ Path: "/" + vars["path"],
+ Modify: d.user.Perm.Modify,
+ Expand: true,
+ Checker: d,
+ })
+ if err != nil {
+ return errToStatus(err), err
+ }
+
+ setContentDisposition(w, r, file)
+
+ switch file.Type {
+ case "image":
+ return handleImagePreview(w, r, imgSvc, fileCache, file, size, enableThumbnails, resizePreview)
+ default:
+ return http.StatusNotImplemented, fmt.Errorf("can't create preview for %s type", file.Type)
+ }
})
- if err != nil {
- return errToStatus(err), err
- }
+}
- setContentDisposition(w, r, file)
-
- switch file.Type {
- case "image":
- return handleImagePreview(w, r, file, size)
- default:
- return http.StatusNotImplemented, fmt.Errorf("can't create preview for %s type", file.Type)
- }
-})
-
-func handleImagePreview(w http.ResponseWriter, r *http.Request, file *files.FileInfo, size string) (int, error) {
- format, err := imaging.FormatFromExtension(file.Extension)
+func handleImagePreview(w http.ResponseWriter, r *http.Request, imgSvc ImgService, fileCache FileCache,
+ file *files.FileInfo, size string, enableThumbnails, resizePreview bool) (int, error) {
+ format, err := imgSvc.FormatFromExtension(file.Extension)
if err != nil {
// Unsupported extensions directly return the raw data
- if err == imaging.ErrUnsupportedFormat {
+ if err == img.ErrUnsupportedFormat {
return rawFileHandler(w, r, file)
}
return errToStatus(err), err
}
+ cacheKey := file.Path + size
+ cachedFile, ok, err := fileCache.Load(r.Context(), cacheKey)
+ if err != nil {
+ return errToStatus(err), err
+ }
+ if ok {
+ _, _ = w.Write(cachedFile)
+ return 0, nil
+ }
+
fd, err := file.Fs.Open(file.Path)
if err != nil {
return errToStatus(err), err
}
defer fd.Close()
- if format == imaging.GIF && size == sizeBig {
- if _, err := rawFileHandler(w, r, file); err != nil { //nolint: govet
+ var (
+ width int
+ height int
+ options []img.Option
+ )
+
+ switch {
+ case size == sizeBig && resizePreview && format != img.FormatGif:
+ width = 1080
+ height = 1080
+ options = append(options, img.WithMode(img.ResizeModeFit), img.WithQuality(img.QualityMedium))
+ case size == sizeThumb && enableThumbnails:
+ width = 128
+ height = 128
+ options = append(options, img.WithMode(img.ResizeModeFill), img.WithQuality(img.QualityLow), img.WithFormat(img.FormatJpeg))
+ default:
+ if _, err := rawFileHandler(w, r, file); err != nil {
return errToStatus(err), err
}
return 0, nil
}
- var imgProcessor imageProcessor
- switch size {
- case sizeBig:
- imgProcessor = func(img image.Image) (image.Image, error) {
- return imaging.Fit(img, 1080, 1080, imaging.Lanczos), nil
- }
- case sizeThumb:
- imgProcessor = func(img image.Image) (image.Image, error) {
- return imaging.Thumbnail(img, 128, 128, imaging.Box), nil
- }
- default:
- return http.StatusBadRequest, fmt.Errorf("unsupported preview size %s", size)
+ buf := &bytes.Buffer{}
+ if err := imgSvc.Resize(context.Background(), fd, width, height, buf, options...); err != nil {
+ return 0, err
}
- img, err := imaging.Decode(fd, imaging.AutoOrientation(true))
- if err != nil {
- return errToStatus(err), err
- }
- img, err = imgProcessor(img)
- if err != nil {
- return errToStatus(err), err
- }
- if imaging.Encode(w, img, format) != nil {
- return errToStatus(err), err
- }
+ go func() {
+ if err := fileCache.Store(context.Background(), cacheKey, buf.Bytes()); err != nil {
+ fmt.Printf("failed to cache resized image: %v", err)
+ }
+ }()
+
+ _, _ = w.Write(buf.Bytes())
+
return 0, nil
}
diff --git a/http/static.go b/http/static.go
index d97559b6..117095c9 100644
--- a/http/static.go
+++ b/http/static.go
@@ -39,6 +39,7 @@ func handleWithStaticData(w http.ResponseWriter, _ *http.Request, d *data, box *
"CSS": false,
"ReCaptcha": false,
"Theme": d.settings.Branding.Theme,
+ "EnableThumbs": d.server.EnableThumbnails,
}
if d.settings.Branding.Files != "" {
diff --git a/img/service.go b/img/service.go
new file mode 100644
index 00000000..1cb4fff7
--- /dev/null
+++ b/img/service.go
@@ -0,0 +1,185 @@
+//go:generate go-enum --sql --marshal --file $GOFILE
+package img
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "image"
+ "io"
+
+ "github.com/disintegration/imaging"
+ "github.com/marusama/semaphore/v2"
+)
+
+// ErrUnsupportedFormat means the given image format is not supported.
+var ErrUnsupportedFormat = errors.New("unsupported image format")
+
+// Service
+type Service struct {
+ sem semaphore.Semaphore
+}
+
+func New(workers int) *Service {
+ return &Service{
+ sem: semaphore.New(workers),
+ }
+}
+
+// Format is an image file format.
+/*
+ENUM(
+jpeg
+png
+gif
+tiff
+bmp
+)
+*/
+type Format int
+
+func (x Format) toImaging() imaging.Format {
+ switch x {
+ case FormatJpeg:
+ return imaging.JPEG
+ case FormatPng:
+ return imaging.PNG
+ case FormatGif:
+ return imaging.GIF
+ case FormatTiff:
+ return imaging.TIFF
+ case FormatBmp:
+ return imaging.BMP
+ default:
+ return imaging.JPEG
+ }
+}
+
+/*
+ENUM(
+high
+medium
+low
+)
+*/
+type Quality int
+
+func (x Quality) resampleFilter() imaging.ResampleFilter {
+ switch x {
+ case QualityHigh:
+ return imaging.Lanczos
+ case QualityMedium:
+ return imaging.Box
+ case QualityLow:
+ return imaging.NearestNeighbor
+ default:
+ return imaging.Box
+ }
+}
+
+/*
+ENUM(
+fit
+fill
+)
+*/
+type ResizeMode int
+
+func (s *Service) FormatFromExtension(ext string) (Format, error) {
+ format, err := imaging.FormatFromExtension(ext)
+ if err != nil {
+ return -1, ErrUnsupportedFormat
+ }
+ switch format {
+ case imaging.JPEG:
+ return FormatJpeg, nil
+ case imaging.PNG:
+ return FormatPng, nil
+ case imaging.GIF:
+ return FormatGif, nil
+ case imaging.TIFF:
+ return FormatTiff, nil
+ case imaging.BMP:
+ return FormatBmp, nil
+ }
+ return -1, ErrUnsupportedFormat
+}
+
+type resizeConfig struct {
+ format Format
+ resizeMode ResizeMode
+ quality Quality
+}
+
+type Option func(*resizeConfig)
+
+func WithFormat(format Format) Option {
+ return func(config *resizeConfig) {
+ config.format = format
+ }
+}
+
+func WithMode(mode ResizeMode) Option {
+ return func(config *resizeConfig) {
+ config.resizeMode = mode
+ }
+}
+
+func WithQuality(quality Quality) Option {
+ return func(config *resizeConfig) {
+ config.quality = quality
+ }
+}
+
+func (s *Service) Resize(ctx context.Context, in io.Reader, width, height int, out io.Writer, options ...Option) error {
+ if err := s.sem.Acquire(ctx, 1); err != nil {
+ return err
+ }
+ defer s.sem.Release(1)
+
+ format, wrappedReader, err := s.detectFormat(in)
+ if err != nil {
+ return err
+ }
+
+ config := resizeConfig{
+ format: format,
+ resizeMode: ResizeModeFit,
+ quality: QualityMedium,
+ }
+ for _, option := range options {
+ option(&config)
+ }
+
+ img, err := imaging.Decode(wrappedReader, imaging.AutoOrientation(true))
+ if err != nil {
+ return err
+ }
+
+ switch config.resizeMode {
+ case ResizeModeFill:
+ img = imaging.Fill(img, width, height, imaging.Center, config.quality.resampleFilter())
+ default:
+ img = imaging.Fit(img, width, height, config.quality.resampleFilter())
+ }
+
+ return imaging.Encode(out, img, config.format.toImaging())
+}
+
+func (s *Service) detectFormat(in io.Reader) (Format, io.Reader, error) {
+ buf := &bytes.Buffer{}
+ r := io.TeeReader(in, buf)
+
+ _, imgFormat, err := image.DecodeConfig(r)
+ if err != nil {
+ return 0, nil, fmt.Errorf("%s: %w", err.Error(), ErrUnsupportedFormat)
+ }
+
+ format, err := ParseFormat(imgFormat)
+ if err != nil {
+ return 0, nil, ErrUnsupportedFormat
+ }
+
+ return format, io.MultiReader(buf, in), nil
+}
diff --git a/img/service_enum.go b/img/service_enum.go
new file mode 100644
index 00000000..33826438
--- /dev/null
+++ b/img/service_enum.go
@@ -0,0 +1,259 @@
+// Code generated by go-enum
+// DO NOT EDIT!
+
+package img
+
+import (
+ "database/sql/driver"
+ "fmt"
+)
+
+const (
+ // FormatJpeg is a Format of type Jpeg
+ FormatJpeg Format = iota
+ // FormatPng is a Format of type Png
+ FormatPng
+ // FormatGif is a Format of type Gif
+ FormatGif
+ // FormatTiff is a Format of type Tiff
+ FormatTiff
+ // FormatBmp is a Format of type Bmp
+ FormatBmp
+)
+
+const _FormatName = "jpegpnggiftiffbmp"
+
+var _FormatMap = map[Format]string{
+ 0: _FormatName[0:4],
+ 1: _FormatName[4:7],
+ 2: _FormatName[7:10],
+ 3: _FormatName[10:14],
+ 4: _FormatName[14:17],
+}
+
+// String implements the Stringer interface.
+func (x Format) String() string {
+ if str, ok := _FormatMap[x]; ok {
+ return str
+ }
+ return fmt.Sprintf("Format(%d)", x)
+}
+
+var _FormatValue = map[string]Format{
+ _FormatName[0:4]: 0,
+ _FormatName[4:7]: 1,
+ _FormatName[7:10]: 2,
+ _FormatName[10:14]: 3,
+ _FormatName[14:17]: 4,
+}
+
+// ParseFormat attempts to convert a string to a Format
+func ParseFormat(name string) (Format, error) {
+ if x, ok := _FormatValue[name]; ok {
+ return x, nil
+ }
+ return Format(0), fmt.Errorf("%s is not a valid Format", name)
+}
+
+// MarshalText implements the text marshaller method
+func (x Format) MarshalText() ([]byte, error) {
+ return []byte(x.String()), nil
+}
+
+// UnmarshalText implements the text unmarshaller method
+func (x *Format) UnmarshalText(text []byte) error {
+ name := string(text)
+ tmp, err := ParseFormat(name)
+ if err != nil {
+ return err
+ }
+ *x = tmp
+ return nil
+}
+
+// Scan implements the Scanner interface.
+func (x *Format) Scan(value interface{}) error {
+ var name string
+
+ switch v := value.(type) {
+ case string:
+ name = v
+ case []byte:
+ name = string(v)
+ case nil:
+ *x = Format(0)
+ return nil
+ }
+
+ tmp, err := ParseFormat(name)
+ if err != nil {
+ return err
+ }
+ *x = tmp
+ return nil
+}
+
+// Value implements the driver Valuer interface.
+func (x Format) Value() (driver.Value, error) {
+ return x.String(), nil
+}
+
+const (
+ // QualityHigh is a Quality of type High
+ QualityHigh Quality = iota
+ // QualityMedium is a Quality of type Medium
+ QualityMedium
+ // QualityLow is a Quality of type Low
+ QualityLow
+)
+
+const _QualityName = "highmediumlow"
+
+var _QualityMap = map[Quality]string{
+ 0: _QualityName[0:4],
+ 1: _QualityName[4:10],
+ 2: _QualityName[10:13],
+}
+
+// String implements the Stringer interface.
+func (x Quality) String() string {
+ if str, ok := _QualityMap[x]; ok {
+ return str
+ }
+ return fmt.Sprintf("Quality(%d)", x)
+}
+
+var _QualityValue = map[string]Quality{
+ _QualityName[0:4]: 0,
+ _QualityName[4:10]: 1,
+ _QualityName[10:13]: 2,
+}
+
+// ParseQuality attempts to convert a string to a Quality
+func ParseQuality(name string) (Quality, error) {
+ if x, ok := _QualityValue[name]; ok {
+ return x, nil
+ }
+ return Quality(0), fmt.Errorf("%s is not a valid Quality", name)
+}
+
+// MarshalText implements the text marshaller method
+func (x Quality) MarshalText() ([]byte, error) {
+ return []byte(x.String()), nil
+}
+
+// UnmarshalText implements the text unmarshaller method
+func (x *Quality) UnmarshalText(text []byte) error {
+ name := string(text)
+ tmp, err := ParseQuality(name)
+ if err != nil {
+ return err
+ }
+ *x = tmp
+ return nil
+}
+
+// Scan implements the Scanner interface.
+func (x *Quality) Scan(value interface{}) error {
+ var name string
+
+ switch v := value.(type) {
+ case string:
+ name = v
+ case []byte:
+ name = string(v)
+ case nil:
+ *x = Quality(0)
+ return nil
+ }
+
+ tmp, err := ParseQuality(name)
+ if err != nil {
+ return err
+ }
+ *x = tmp
+ return nil
+}
+
+// Value implements the driver Valuer interface.
+func (x Quality) Value() (driver.Value, error) {
+ return x.String(), nil
+}
+
+const (
+ // ResizeModeFit is a ResizeMode of type Fit
+ ResizeModeFit ResizeMode = iota
+ // ResizeModeFill is a ResizeMode of type Fill
+ ResizeModeFill
+)
+
+const _ResizeModeName = "fitfill"
+
+var _ResizeModeMap = map[ResizeMode]string{
+ 0: _ResizeModeName[0:3],
+ 1: _ResizeModeName[3:7],
+}
+
+// String implements the Stringer interface.
+func (x ResizeMode) String() string {
+ if str, ok := _ResizeModeMap[x]; ok {
+ return str
+ }
+ return fmt.Sprintf("ResizeMode(%d)", x)
+}
+
+var _ResizeModeValue = map[string]ResizeMode{
+ _ResizeModeName[0:3]: 0,
+ _ResizeModeName[3:7]: 1,
+}
+
+// ParseResizeMode attempts to convert a string to a ResizeMode
+func ParseResizeMode(name string) (ResizeMode, error) {
+ if x, ok := _ResizeModeValue[name]; ok {
+ return x, nil
+ }
+ return ResizeMode(0), fmt.Errorf("%s is not a valid ResizeMode", name)
+}
+
+// MarshalText implements the text marshaller method
+func (x ResizeMode) MarshalText() ([]byte, error) {
+ return []byte(x.String()), nil
+}
+
+// UnmarshalText implements the text unmarshaller method
+func (x *ResizeMode) UnmarshalText(text []byte) error {
+ name := string(text)
+ tmp, err := ParseResizeMode(name)
+ if err != nil {
+ return err
+ }
+ *x = tmp
+ return nil
+}
+
+// Scan implements the Scanner interface.
+func (x *ResizeMode) Scan(value interface{}) error {
+ var name string
+
+ switch v := value.(type) {
+ case string:
+ name = v
+ case []byte:
+ name = string(v)
+ case nil:
+ *x = ResizeMode(0)
+ return nil
+ }
+
+ tmp, err := ParseResizeMode(name)
+ if err != nil {
+ return err
+ }
+ *x = tmp
+ return nil
+}
+
+// Value implements the driver Valuer interface.
+func (x ResizeMode) Value() (driver.Value, error) {
+ return x.String(), nil
+}
diff --git a/img/service_test.go b/img/service_test.go
new file mode 100644
index 00000000..eca36fb9
--- /dev/null
+++ b/img/service_test.go
@@ -0,0 +1,398 @@
+package img
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "image"
+ "image/gif"
+ "image/jpeg"
+ "image/png"
+ "io"
+ "testing"
+
+ "github.com/spf13/afero"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/image/bmp"
+ "golang.org/x/image/tiff"
+)
+
+func TestService_Resize(t *testing.T) {
+ testCases := map[string]struct {
+ options []Option
+ width int
+ height int
+ source func(t *testing.T) afero.File
+ matcher func(t *testing.T, reader io.Reader)
+ wantErr bool
+ }{
+ "fill upscale": {
+ options: []Option{WithMode(ResizeModeFill)},
+ width: 100,
+ height: 100,
+ source: func(t *testing.T) afero.File {
+ t.Helper()
+ return newGrayJpeg(t, 50, 20)
+ },
+ matcher: sizeMatcher(100, 100),
+ },
+ "fill downscale": {
+ options: []Option{WithMode(ResizeModeFill)},
+ width: 100,
+ height: 100,
+ source: func(t *testing.T) afero.File {
+ t.Helper()
+ return newGrayJpeg(t, 200, 150)
+ },
+ matcher: sizeMatcher(100, 100),
+ },
+ "fit upscale": {
+ options: []Option{WithMode(ResizeModeFit)},
+ width: 100,
+ height: 100,
+ source: func(t *testing.T) afero.File {
+ t.Helper()
+ return newGrayJpeg(t, 50, 20)
+ },
+ matcher: sizeMatcher(50, 20),
+ },
+ "fit downscale": {
+ options: []Option{WithMode(ResizeModeFit)},
+ width: 100,
+ height: 100,
+ source: func(t *testing.T) afero.File {
+ t.Helper()
+ return newGrayJpeg(t, 200, 150)
+ },
+ matcher: sizeMatcher(100, 75),
+ },
+ "keep original format": {
+ options: []Option{},
+ width: 100,
+ height: 100,
+ source: func(t *testing.T) afero.File {
+ t.Helper()
+ return newGrayPng(t, 200, 150)
+ },
+ matcher: formatMatcher(FormatPng),
+ },
+ "convert to jpeg": {
+ options: []Option{WithFormat(FormatJpeg)},
+ width: 100,
+ height: 100,
+ source: func(t *testing.T) afero.File {
+ t.Helper()
+ return newGrayJpeg(t, 200, 150)
+ },
+ matcher: formatMatcher(FormatJpeg),
+ },
+ "convert to png": {
+ options: []Option{WithFormat(FormatPng)},
+ width: 100,
+ height: 100,
+ source: func(t *testing.T) afero.File {
+ t.Helper()
+ return newGrayJpeg(t, 200, 150)
+ },
+ matcher: formatMatcher(FormatPng),
+ },
+ "convert to gif": {
+ options: []Option{WithFormat(FormatGif)},
+ width: 100,
+ height: 100,
+ source: func(t *testing.T) afero.File {
+ t.Helper()
+ return newGrayJpeg(t, 200, 150)
+ },
+ matcher: formatMatcher(FormatGif),
+ },
+ "convert to tiff": {
+ options: []Option{WithFormat(FormatTiff)},
+ width: 100,
+ height: 100,
+ source: func(t *testing.T) afero.File {
+ t.Helper()
+ return newGrayJpeg(t, 200, 150)
+ },
+ matcher: formatMatcher(FormatTiff),
+ },
+ "convert to bmp": {
+ options: []Option{WithFormat(FormatBmp)},
+ width: 100,
+ height: 100,
+ source: func(t *testing.T) afero.File {
+ t.Helper()
+ return newGrayJpeg(t, 200, 150)
+ },
+ matcher: formatMatcher(FormatBmp),
+ },
+ "convert to unknown": {
+ options: []Option{WithFormat(Format(-1))},
+ width: 100,
+ height: 100,
+ source: func(t *testing.T) afero.File {
+ t.Helper()
+ return newGrayJpeg(t, 200, 150)
+ },
+ matcher: formatMatcher(FormatJpeg),
+ },
+ "resize png": {
+ options: []Option{WithMode(ResizeModeFill)},
+ width: 100,
+ height: 100,
+ source: func(t *testing.T) afero.File {
+ t.Helper()
+ return newGrayPng(t, 200, 150)
+ },
+ matcher: sizeMatcher(100, 100),
+ },
+ "resize gif": {
+ options: []Option{WithMode(ResizeModeFill)},
+ width: 100,
+ height: 100,
+ source: func(t *testing.T) afero.File {
+ t.Helper()
+ return newGrayGif(t, 200, 150)
+ },
+ matcher: sizeMatcher(100, 100),
+ },
+ "resize tiff": {
+ options: []Option{WithMode(ResizeModeFill)},
+ width: 100,
+ height: 100,
+ source: func(t *testing.T) afero.File {
+ t.Helper()
+ return newGrayTiff(t, 200, 150)
+ },
+ matcher: sizeMatcher(100, 100),
+ },
+ "resize bmp": {
+ options: []Option{WithMode(ResizeModeFill)},
+ width: 100,
+ height: 100,
+ source: func(t *testing.T) afero.File {
+ t.Helper()
+ return newGrayBmp(t, 200, 150)
+ },
+ matcher: sizeMatcher(100, 100),
+ },
+ "resize with high quality": {
+ options: []Option{WithMode(ResizeModeFill), WithQuality(QualityHigh)},
+ width: 100,
+ height: 100,
+ source: func(t *testing.T) afero.File {
+ t.Helper()
+ return newGrayJpeg(t, 200, 150)
+ },
+ matcher: sizeMatcher(100, 100),
+ },
+ "resize with medium quality": {
+ options: []Option{WithMode(ResizeModeFill), WithQuality(QualityMedium)},
+ width: 100,
+ height: 100,
+ source: func(t *testing.T) afero.File {
+ t.Helper()
+ return newGrayJpeg(t, 200, 150)
+ },
+ matcher: sizeMatcher(100, 100),
+ },
+ "resize with low quality": {
+ options: []Option{WithMode(ResizeModeFill), WithQuality(QualityLow)},
+ width: 100,
+ height: 100,
+ source: func(t *testing.T) afero.File {
+ t.Helper()
+ return newGrayJpeg(t, 200, 150)
+ },
+ matcher: sizeMatcher(100, 100),
+ },
+ "resize with unknown quality": {
+ options: []Option{WithMode(ResizeModeFill), WithQuality(Quality(-1))},
+ width: 100,
+ height: 100,
+ source: func(t *testing.T) afero.File {
+ t.Helper()
+ return newGrayJpeg(t, 200, 150)
+ },
+ matcher: sizeMatcher(100, 100),
+ },
+ "broken file": {
+ options: []Option{WithMode(ResizeModeFit)},
+ width: 100,
+ height: 100,
+ source: func(t *testing.T) afero.File {
+ t.Helper()
+ fs := afero.NewMemMapFs()
+ file, err := fs.Create("image.jpg")
+ require.NoError(t, err)
+
+ _, err = file.WriteString("this is not an image")
+ require.NoError(t, err)
+
+ return file
+ },
+ wantErr: true,
+ },
+ }
+
+ for name, test := range testCases {
+ t.Run(name, func(t *testing.T) {
+ svc := New(1)
+ source := test.source(t)
+ defer source.Close()
+
+ buf := &bytes.Buffer{}
+ err := svc.Resize(context.Background(), source, test.width, test.height, buf, test.options...)
+ if (err != nil) != test.wantErr {
+ t.Fatalf("GetMarketSpecs() error = %v, wantErr %v", err, test.wantErr)
+ }
+ if err != nil {
+ return
+ }
+ test.matcher(t, buf)
+ })
+ }
+}
+
+func sizeMatcher(width, height int) func(t *testing.T, reader io.Reader) {
+ return func(t *testing.T, reader io.Reader) {
+ resizedImg, _, err := image.Decode(reader)
+ require.NoError(t, err)
+
+ require.Equal(t, width, resizedImg.Bounds().Dx())
+ require.Equal(t, height, resizedImg.Bounds().Dy())
+ }
+}
+
+func formatMatcher(format Format) func(t *testing.T, reader io.Reader) {
+ return func(t *testing.T, reader io.Reader) {
+ _, decodedFormat, err := image.DecodeConfig(reader)
+ require.NoError(t, err)
+
+ require.Equal(t, format.String(), decodedFormat)
+ }
+}
+
+func newGrayJpeg(t *testing.T, width, height int) afero.File {
+ fs := afero.NewMemMapFs()
+ file, err := fs.Create("image.jpg")
+ require.NoError(t, err)
+
+ img := image.NewGray(image.Rect(0, 0, width, height))
+ err = jpeg.Encode(file, img, &jpeg.Options{Quality: 90})
+ require.NoError(t, err)
+
+ _, err = file.Seek(0, io.SeekStart)
+ require.NoError(t, err)
+
+ return file
+}
+
+func newGrayPng(t *testing.T, width, height int) afero.File {
+ fs := afero.NewMemMapFs()
+ file, err := fs.Create("image.png")
+ require.NoError(t, err)
+
+ img := image.NewGray(image.Rect(0, 0, width, height))
+ err = png.Encode(file, img)
+ require.NoError(t, err)
+
+ _, err = file.Seek(0, io.SeekStart)
+ require.NoError(t, err)
+
+ return file
+}
+
+func newGrayGif(t *testing.T, width, height int) afero.File {
+ fs := afero.NewMemMapFs()
+ file, err := fs.Create("image.gif")
+ require.NoError(t, err)
+
+ img := image.NewGray(image.Rect(0, 0, width, height))
+ err = gif.Encode(file, img, nil)
+ require.NoError(t, err)
+
+ _, err = file.Seek(0, io.SeekStart)
+ require.NoError(t, err)
+
+ return file
+}
+
+func newGrayTiff(t *testing.T, width, height int) afero.File {
+ fs := afero.NewMemMapFs()
+ file, err := fs.Create("image.tiff")
+ require.NoError(t, err)
+
+ img := image.NewGray(image.Rect(0, 0, width, height))
+ err = tiff.Encode(file, img, nil)
+ require.NoError(t, err)
+
+ _, err = file.Seek(0, io.SeekStart)
+ require.NoError(t, err)
+
+ return file
+}
+
+func newGrayBmp(t *testing.T, width, height int) afero.File {
+ fs := afero.NewMemMapFs()
+ file, err := fs.Create("image.bmp")
+ require.NoError(t, err)
+
+ img := image.NewGray(image.Rect(0, 0, width, height))
+ err = bmp.Encode(file, img)
+ require.NoError(t, err)
+
+ _, err = file.Seek(0, io.SeekStart)
+ require.NoError(t, err)
+
+ return file
+}
+
+func TestService_FormatFromExtension(t *testing.T) {
+ testCases := map[string]struct {
+ ext string
+ want Format
+ wantErr error
+ }{
+ "jpg": {
+ ext: ".jpg",
+ want: FormatJpeg,
+ },
+ "jpeg": {
+ ext: ".jpeg",
+ want: FormatJpeg,
+ },
+ "png": {
+ ext: ".png",
+ want: FormatPng,
+ },
+ "gif": {
+ ext: ".gif",
+ want: FormatGif,
+ },
+ "tiff": {
+ ext: ".tiff",
+ want: FormatTiff,
+ },
+ "bmp": {
+ ext: ".bmp",
+ want: FormatBmp,
+ },
+ "unknown": {
+ ext: ".mov",
+ wantErr: ErrUnsupportedFormat,
+ },
+ }
+
+ for name, test := range testCases {
+ t.Run(name, func(t *testing.T) {
+ svc := New(1)
+ got, err := svc.FormatFromExtension(test.ext)
+ require.Truef(t, errors.Is(err, test.wantErr), "error = %v, wantErr %v", err, test.wantErr)
+ if err != nil {
+ return
+ }
+ require.Equal(t, test.want, got)
+ })
+ }
+}
diff --git a/settings/settings.go b/settings/settings.go
index 104d9f30..c6dbb0f9 100644
--- a/settings/settings.go
+++ b/settings/settings.go
@@ -30,14 +30,16 @@ func (s *Settings) GetRules() []rules.Rule {
// Server specific settings.
type Server struct {
- Root string `json:"root"`
- BaseURL string `json:"baseURL"`
- Socket string `json:"socket"`
- TLSKey string `json:"tlsKey"`
- TLSCert string `json:"tlsCert"`
- Port string `json:"port"`
- Address string `json:"address"`
- Log string `json:"log"`
+ Root string `json:"root"`
+ BaseURL string `json:"baseURL"`
+ Socket string `json:"socket"`
+ TLSKey string `json:"tlsKey"`
+ TLSCert string `json:"tlsCert"`
+ Port string `json:"port"`
+ Address string `json:"address"`
+ Log string `json:"log"`
+ EnableThumbnails bool `json:"enableThumbnails"`
+ ResizePreview bool `json:"resizePreview"`
}
// Clean cleans any variables that might need cleaning.