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.