Merge pull request #1044 from filebrowser/fix_img_resize

pull/1051/head
Oleg Lobanov 2020-07-27 19:39:08 +02:00 committed by GitHub
commit 470f93cefc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1216 additions and 76 deletions

View File

@ -21,9 +21,17 @@ jobs:
root: . root: .
paths: paths:
- '*' - '*'
test:
docker:
- image: circleci/golang:1.14.6
steps:
- checkout
- run:
name: "Test"
command: go test ./...
build-go: build-go:
docker: docker:
- image: circleci/golang:1.14.3 - image: circleci/golang:1.14.6
steps: steps:
- attach_workspace: - attach_workspace:
at: '~/project' at: '~/project'
@ -57,6 +65,10 @@ workflows:
filters: filters:
tags: tags:
only: /.*/ only: /.*/
- test:
filters:
tags:
only: /.*/
- build-node: - build-node:
filters: filters:
tags: tags:
@ -68,6 +80,7 @@ workflows:
requires: requires:
- build-node - build-node
- lint - lint
- test
- release: - release:
context: deploy context: deploy
requires: requires:

View File

@ -14,13 +14,16 @@ import (
"syscall" "syscall"
homedir "github.com/mitchellh/go-homedir" homedir "github.com/mitchellh/go-homedir"
"github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag" "github.com/spf13/pflag"
v "github.com/spf13/viper" v "github.com/spf13/viper"
lumberjack "gopkg.in/natefinch/lumberjack.v2" lumberjack "gopkg.in/natefinch/lumberjack.v2"
"github.com/filebrowser/filebrowser/v2/auth" "github.com/filebrowser/filebrowser/v2/auth"
"github.com/filebrowser/filebrowser/v2/diskcache"
fbhttp "github.com/filebrowser/filebrowser/v2/http" fbhttp "github.com/filebrowser/filebrowser/v2/http"
"github.com/filebrowser/filebrowser/v2/img"
"github.com/filebrowser/filebrowser/v2/settings" "github.com/filebrowser/filebrowser/v2/settings"
"github.com/filebrowser/filebrowser/v2/storage" "github.com/filebrowser/filebrowser/v2/storage"
"github.com/filebrowser/filebrowser/v2/users" "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.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.String("socket", "", "socket to listen to (cannot be used with address, port, cert nor key flags)")
flags.StringP("baseurl", "b", "", "base url") 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{ var rootCmd = &cobra.Command{
@ -103,6 +110,24 @@ user created with the credentials from options "username" and "password".`,
quickSetup(cmd.Flags(), d) 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) server := getRunParams(cmd.Flags(), d.store)
setupLog(server.Log) setupLog(server.Log)
@ -132,7 +157,7 @@ user created with the credentials from options "username" and "password".`,
signal.Notify(sigc, os.Interrupt, syscall.SIGTERM) signal.Notify(sigc, os.Interrupt, syscall.SIGTERM)
go cleanupHandler(listener, sigc) go cleanupHandler(listener, sigc)
handler, err := fbhttp.NewHandler(d.store, server) handler, err := fbhttp.NewHandler(imgSvc, fileCache, d.store, server)
checkErr(err) checkErr(err)
defer listener.Close() defer listener.Close()
@ -205,6 +230,12 @@ func getRunParams(flags *pflag.FlagSet, st *storage.Storage) *settings.Server {
server.Socket = "" server.Socket = ""
} }
_, disableThumbnails := getParamB(flags, "disable-thumbnails")
server.EnableThumbnails = !disableThumbnails
_, disablePreviewResize := getParamB(flags, "disable-preview-resize")
server.ResizePreview = !disablePreviewResize
return server return server
} }

11
diskcache/cache.go Normal file
View File

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

110
diskcache/file_cache.go Normal file
View File

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

View File

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

24
diskcache/noop_cache.go Normal file
View File

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

View File

@ -13030,6 +13030,11 @@
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-8.15.3.tgz", "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-8.15.3.tgz",
"integrity": "sha512-PVNgo6yhOmacZVFjSapZ314oewwLyXHjJwAqjnaPN1GJAJd/dvsrShGzSiJuCX4Hc36G4epJvNXUwO8y7wEKew==" "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": { "vue-loader": {
"version": "15.8.3", "version": "15.8.3",
"resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-15.8.3.tgz", "resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-15.8.3.tgz",

View File

@ -21,6 +21,7 @@
"qrcode.vue": "^1.7.0", "qrcode.vue": "^1.7.0",
"vue": "^2.6.10", "vue": "^2.6.10",
"vue-i18n": "^8.15.3", "vue-i18n": "^8.15.3",
"vue-lazyload": "^1.3.3",
"vue-router": "^3.1.3", "vue-router": "^3.1.3",
"vuex": "^3.1.2", "vuex": "^3.1.2",
"vuex-router-sync": "^5.0.0" "vuex-router-sync": "^5.0.0"

View File

@ -13,7 +13,7 @@
:aria-label="name" :aria-label="name"
:aria-selected="isSelected"> :aria-selected="isSelected">
<div> <div>
<img v-if="type==='image'" :src="thumbnailUrl"> <img v-if="type==='image' && isThumbsEnabled" v-lazy="thumbnailUrl">
<i v-else class="material-icons">{{ icon }}</i> <i v-else class="material-icons">{{ icon }}</i>
</div> </div>
@ -31,7 +31,7 @@
</template> </template>
<script> <script>
import { baseURL } from '@/utils/constants' import { baseURL, enableThumbs } from '@/utils/constants'
import { mapMutations, mapGetters, mapState } from 'vuex' import { mapMutations, mapGetters, mapState } from 'vuex'
import filesize from 'filesize' import filesize from 'filesize'
import moment from 'moment' import moment from 'moment'
@ -76,6 +76,9 @@ export default {
thumbnailUrl () { thumbnailUrl () {
const path = this.url.replace(/^\/files\//, '') const path = this.url.replace(/^\/files\//, '')
return `${baseURL}/api/preview/thumb/${path}?auth=${this.jwt}&inline=true` return `${baseURL}/api/preview/thumb/${path}?auth=${this.jwt}&inline=true`
},
isThumbsEnabled () {
return enableThumbs
} }
}, },
methods: { methods: {
@ -213,4 +216,4 @@ export default {
} }
} }
} }
</script> </script>

View File

@ -11,6 +11,7 @@ const noAuth = window.FileBrowser.NoAuth
const authMethod = window.FileBrowser.AuthMethod const authMethod = window.FileBrowser.AuthMethod
const loginPage = window.FileBrowser.LoginPage const loginPage = window.FileBrowser.LoginPage
const theme = window.FileBrowser.Theme const theme = window.FileBrowser.Theme
const enableThumbs = window.FileBrowser.EnableThumbs
export { export {
name, name,
@ -24,5 +25,6 @@ export {
noAuth, noAuth,
authMethod, authMethod,
loginPage, loginPage,
theme theme,
enableThumbs
} }

View File

@ -1,8 +1,11 @@
import Vue from 'vue' import Vue from 'vue'
import Noty from 'noty' import Noty from 'noty'
import VueLazyload from 'vue-lazyload'
import i18n from '@/i18n' import i18n from '@/i18n'
import { disableExternal } from '@/utils/constants' import { disableExternal } from '@/utils/constants'
Vue.use(VueLazyload)
Vue.config.productionTip = true Vue.config.productionTip = true
const notyDefault = { const notyDefault = {

View File

@ -1,4 +1,5 @@
module.exports = { module.exports = {
runtimeCompiler: true, runtimeCompiler: true,
publicPath: '[{[ .StaticURL ]}]' publicPath: '[{[ .StaticURL ]}]',
parallel: 2,
} }

3
go.mod
View File

@ -14,6 +14,7 @@ require (
github.com/gorilla/mux v1.7.3 github.com/gorilla/mux v1.7.3
github.com/gorilla/websocket v1.4.1 github.com/gorilla/websocket v1.4.1
github.com/maruel/natural v0.0.0-20180416170133-dbcb3e2e8cf1 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/mholt/archiver v3.1.1+incompatible
github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/go-homedir v1.1.0
github.com/nwaples/rardecode v1.0.0 // indirect github.com/nwaples/rardecode v1.0.0 // indirect
@ -24,11 +25,13 @@ require (
github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.6.1 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/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce
github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
go.etcd.io/bbolt v1.3.3 go.etcd.io/bbolt v1.3.3
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 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/net v0.0.0-20200528225125-3c3fba18258b // indirect
golang.org/x/sys v0.0.0-20200523222454-059865788121 // indirect golang.org/x/sys v0.0.0-20200523222454-059865788121 // indirect
golang.org/x/text v0.3.2 // indirect golang.org/x/text v0.3.2 // indirect

6
go.sum
View File

@ -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/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 h1:PEhRT94KBTY4E0KdCYmhvDGWjSFBxc68j2M6PMRix8U=
github.com/maruel/natural v0.0.0-20180416170133-dbcb3e2e8cf1/go.mod h1:wI697HNhDFM/vBruYM3ckbszQ2+DOIeH9qdBKMdf288= 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/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 h1:1dCVxuqs0dJseYEhi5pl7MYPH9zDa1wBi7mF09cbNkU=
github.com/mholt/archiver v3.1.1+incompatible/go.mod h1:Dh2dOXnSdiLxRiPoVfIr/fI1TwETms9B8CTWfeh7ROU= 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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 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.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 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= 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= 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.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 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 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= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View File

@ -14,7 +14,7 @@ type modifyRequest struct {
Which []string `json:"which"` // Answer to: which fields? 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() server.Clean()
r := mux.NewRouter() 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.Handle("/settings", monkey(settingsPutHandler, "")).Methods("PUT")
api.PathPrefix("/raw").Handler(monkey(rawHandler, "/api/raw")).Methods("GET") 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("/command").Handler(monkey(commandsHandler, "/api/command")).Methods("GET")
api.PathPrefix("/search").Handler(monkey(searchHandler, "/api/search")).Methods("GET") api.PathPrefix("/search").Handler(monkey(searchHandler, "/api/search")).Methods("GET")

View File

@ -1,14 +1,16 @@
package http package http
import ( import (
"bytes"
"context"
"fmt" "fmt"
"image" "io"
"net/http" "net/http"
"github.com/disintegration/imaging"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/filebrowser/filebrowser/v2/files" "github.com/filebrowser/filebrowser/v2/files"
"github.com/filebrowser/filebrowser/v2/img"
) )
const ( const (
@ -16,86 +18,110 @@ const (
sizeBig = "big" 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) { type FileCache interface {
if !d.user.Perm.Download { Store(ctx context.Context, key string, value []byte) error
return http.StatusAccepted, nil Load(ctx context.Context, key string) ([]byte, bool, error)
} }
vars := mux.Vars(r)
size := vars["size"]
if size != sizeBig && size != sizeThumb {
return http.StatusNotImplemented, nil
}
file, err := files.NewFileInfo(files.FileOptions{ func previewHandler(imgSvc ImgService, fileCache FileCache, enableThumbnails, resizePreview bool) handleFunc {
Fs: d.user.Fs, return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
Path: "/" + vars["path"], if !d.user.Perm.Download {
Modify: d.user.Perm.Modify, return http.StatusAccepted, nil
Expand: true, }
Checker: d, 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) func handleImagePreview(w http.ResponseWriter, r *http.Request, imgSvc ImgService, fileCache FileCache,
file *files.FileInfo, size string, enableThumbnails, resizePreview bool) (int, error) {
switch file.Type { format, err := imgSvc.FormatFromExtension(file.Extension)
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)
if err != nil { if err != nil {
// Unsupported extensions directly return the raw data // Unsupported extensions directly return the raw data
if err == imaging.ErrUnsupportedFormat { if err == img.ErrUnsupportedFormat {
return rawFileHandler(w, r, file) return rawFileHandler(w, r, file)
} }
return errToStatus(err), err 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) fd, err := file.Fs.Open(file.Path)
if err != nil { if err != nil {
return errToStatus(err), err return errToStatus(err), err
} }
defer fd.Close() defer fd.Close()
if format == imaging.GIF && size == sizeBig { var (
if _, err := rawFileHandler(w, r, file); err != nil { //nolint: govet 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 errToStatus(err), err
} }
return 0, nil return 0, nil
} }
var imgProcessor imageProcessor buf := &bytes.Buffer{}
switch size { if err := imgSvc.Resize(context.Background(), fd, width, height, buf, options...); err != nil {
case sizeBig: return 0, err
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)
} }
img, err := imaging.Decode(fd, imaging.AutoOrientation(true)) go func() {
if err != nil { if err := fileCache.Store(context.Background(), cacheKey, buf.Bytes()); err != nil {
return errToStatus(err), err fmt.Printf("failed to cache resized image: %v", err)
} }
img, err = imgProcessor(img) }()
if err != nil {
return errToStatus(err), err _, _ = w.Write(buf.Bytes())
}
if imaging.Encode(w, img, format) != nil {
return errToStatus(err), err
}
return 0, nil return 0, nil
} }

View File

@ -39,6 +39,7 @@ func handleWithStaticData(w http.ResponseWriter, _ *http.Request, d *data, box *
"CSS": false, "CSS": false,
"ReCaptcha": false, "ReCaptcha": false,
"Theme": d.settings.Branding.Theme, "Theme": d.settings.Branding.Theme,
"EnableThumbs": d.server.EnableThumbnails,
} }
if d.settings.Branding.Files != "" { if d.settings.Branding.Files != "" {

185
img/service.go Normal file
View File

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

259
img/service_enum.go Normal file
View File

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

398
img/service_test.go Normal file
View File

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

View File

@ -30,14 +30,16 @@ func (s *Settings) GetRules() []rules.Rule {
// Server specific settings. // Server specific settings.
type Server struct { type Server struct {
Root string `json:"root"` Root string `json:"root"`
BaseURL string `json:"baseURL"` BaseURL string `json:"baseURL"`
Socket string `json:"socket"` Socket string `json:"socket"`
TLSKey string `json:"tlsKey"` TLSKey string `json:"tlsKey"`
TLSCert string `json:"tlsCert"` TLSCert string `json:"tlsCert"`
Port string `json:"port"` Port string `json:"port"`
Address string `json:"address"` Address string `json:"address"`
Log string `json:"log"` Log string `json:"log"`
EnableThumbnails bool `json:"enableThumbnails"`
ResizePreview bool `json:"resizePreview"`
} }
// Clean cleans any variables that might need cleaning. // Clean cleans any variables that might need cleaning.