Compare commits

...

48 Commits

Author SHA1 Message Date
Oleg Lobanov
be8683f556 chore(release): 2.6.2 2020-08-05 11:55:16 +02:00
Davide Maggio
c3450f4614 chore: return text/plain header in auth response (#1051) 2020-08-05 10:48:03 +02:00
Oleg Lobanov
5881bc9ab0 chore: fix preview of files with non-latin names (closes #1056) 2020-08-05 10:40:03 +02:00
Oleg Lobanov
a2fb499a20 chore(release): 2.6.1 2020-07-28 13:40:19 +02:00
Oleg Lobanov
411a928fea chore: fix lint errors 2020-07-28 13:40:06 +02:00
Oleg Lobanov
f5d02cdde9 fix: delete cached previews when deleting file 2020-07-28 11:59:55 +02:00
Oleg Lobanov
c9340af8d0 fix: escape special characters in preview url (closes #1002) 2020-07-28 11:59:32 +02:00
Oleg Lobanov
a722bcc13f chore(release): 2.6.0 2020-07-27 19:52:48 +02:00
Oleg Lobanov
77fe3cfc60 ci: fix go version on release step 2020-07-27 19:51:09 +02:00
Oleg Lobanov
470f93cefc Merge pull request #1044 from filebrowser/fix_img_resize 2020-07-27 19:39:08 +02:00
Oleg Lobanov
92fde4dd12 build: set limit for vuejs build threads 2020-07-27 19:35:02 +02:00
Oleg Lobanov
95bc92955f feat: cache resized images 2020-07-27 19:26:45 +02:00
Oleg Lobanov
f2f914221c chore: bump go to 1.14.6 2020-07-27 19:26:45 +02:00
Oleg Lobanov
c2d8038c63 chore: add testing step to ci 2020-07-27 19:26:44 +02:00
Oleg Lobanov
cb8ac5ebf1 chore: add resize tests 2020-07-27 19:26:44 +02:00
Oleg Lobanov
aa78e3ab1f feat: add param to disable img resizing 2020-07-27 19:26:44 +02:00
Oleg Lobanov
bc00165094 feat: add lazy load of image thumbnails 2020-07-27 19:26:44 +02:00
Oleg Lobanov
94ef59602f feat: limit image resize workers 2020-07-27 19:26:44 +02:00
Oleg Lobanov
14e2f84ceb Merge pull request #1042 from ramiresviana/fixes-2 2020-07-23 15:03:10 +02:00
Ramires Viana
f228fa5540 fix: conflict handling on upload button 2020-07-23 12:02:09 +00:00
Ramires Viana
f2d2c1cbf8 fix: drop feedback 2020-07-23 12:02:09 +00:00
Ramires Viana
d9be370e24 fix: missing error message 2020-07-23 12:02:09 +00:00
Ramires Viana
727c63b98e fix: parent verification on copy 2020-07-23 12:02:02 +00:00
Ramires Viana
34dfb49b71 fix: path separator inconsistency on rename 2020-07-20 17:45:45 +00:00
Henrique Dias
0b0a704d44 chore: remove hacdias/fileutils dep (#1037) 2020-07-18 20:10:22 +02:00
Oleg Lobanov
2d99d0bf13 chore(release): 2.5.0 2020-07-17 18:12:00 +02:00
Oleg Lobanov
1790df2090 Merge pull request #1026 from ramiresviana/fixes 2020-07-17 17:41:17 +02:00
Ramires Viana
10570ade44 fix: reset clipboard after pasting cutted files 2020-07-17 14:11:23 +00:00
Ramires Viana
43526d9d1a feat: duplicate files in the same directory 2020-07-17 14:11:23 +00:00
Ramires Viana
2636f876ab feat: rename option on replace prompt 2020-07-17 14:11:15 +00:00
Ramires Viana
eed9da1471 feat: file copy, move and paste conflict checking 2020-07-17 12:37:52 +00:00
Ramires Viana
9a2ebbabe2 fix: blinking previewer 2020-07-17 12:37:52 +00:00
Ramires Viana
716396a726 feat: add previewer title and loading indicator 2020-07-17 12:32:21 +00:00
Ramires Viana
0727496601 fix: remove incomplete uploaded files 2020-07-14 00:21:15 +00:00
Ramires Viana
194030fcfc fix: prompt before closing window 2020-07-14 00:12:41 +00:00
Ramires Viana
b3b644527d fix: dark theme colors 2020-07-14 00:12:33 +00:00
Ramires Viana
7e5beeff46 fix: directory conflict checking 2020-07-13 14:20:56 +00:00
Oleg Lobanov
a47b69bcec Merge pull request #1021 from ramiresviana/upload-queue 2020-07-13 11:20:59 +02:00
Ramires Viana
6ec6a23861 feat: upload queue 2020-07-10 00:01:37 +00:00
Ramires Viana
c9cc0d3d5d refactor: upload vuex module 2020-07-10 00:01:37 +00:00
Ramires Viana
28d2b35718 refactor: upload utils 2020-07-10 00:01:37 +00:00
Ramires Viana
b4f131be50 refactor: uploading counters vuex state 2020-07-10 00:01:37 +00:00
Oleg Lobanov
d0b359561f chore(release): 2.4.0 2020-07-07 16:53:51 +02:00
Fabian Fritzsche
453636dfe2 fix: add preview bypass for .gif files (#1012) 2020-07-07 16:47:11 +02:00
Oleg Lobanov
b1605aa6d3 Merge pull request #1014 from ramiresviana/full-screen-editor 2020-07-06 17:06:12 +02:00
Oleg Lobanov
23503b80a4 Merge pull request #1015 from ramiresviana/prompt-key-shortcut-conflict 2020-07-06 17:03:05 +02:00
Ramires Viana
0d69fbd9a3 fix: prompt key shortcut conflict 2020-07-04 14:19:03 +00:00
Ramires Viana
0d665e528f feat: full screen editor 2020-07-04 03:11:51 +00:00
52 changed files with 2401 additions and 487 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'
@@ -41,7 +49,7 @@ jobs:
- '*' - '*'
release: release:
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

@@ -2,6 +2,69 @@
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
### [2.6.2](https://github.com/filebrowser/filebrowser/compare/v2.6.1...v2.6.2) (2020-08-05)
### [2.6.1](https://github.com/filebrowser/filebrowser/compare/v2.6.0...v2.6.1) (2020-07-28)
### Bug Fixes
* delete cached previews when deleting file ([f5d02cd](https://github.com/filebrowser/filebrowser/commit/f5d02cdde97923b963878abf5a300393b9feb348))
* escape special characters in preview url (closes [#1002](https://github.com/filebrowser/filebrowser/issues/1002)) ([c9340af](https://github.com/filebrowser/filebrowser/commit/c9340af8d045671ad3338c5d2d887c335ab92de4))
## [2.6.0](https://github.com/filebrowser/filebrowser/compare/v2.5.0...v2.6.0) (2020-07-27)
### Features
* add lazy load of image thumbnails ([bc00165](https://github.com/filebrowser/filebrowser/commit/bc001650944ae963b12b5b2538a68de7cd0d8f82))
* add param to disable img resizing ([aa78e3a](https://github.com/filebrowser/filebrowser/commit/aa78e3ab1fcae6f618e811ba4e315a7a209f9df2))
* cache resized images ([95bc929](https://github.com/filebrowser/filebrowser/commit/95bc92955f391ece22c40d9592f2a3e6e26907b9))
* limit image resize workers ([94ef596](https://github.com/filebrowser/filebrowser/commit/94ef59602fb50fc21b1164feda90a3b9aeb5e972))
### Bug Fixes
* conflict handling on upload button ([f228fa5](https://github.com/filebrowser/filebrowser/commit/f228fa55408824618e9f0879da67c86d22b0d324))
* drop feedback ([f2d2c1c](https://github.com/filebrowser/filebrowser/commit/f2d2c1cbf85fba3edffb7b079f121ed3f0bc1e02))
* missing error message ([d9be370](https://github.com/filebrowser/filebrowser/commit/d9be370e2474b8070fa58db920c9481270cc4a48))
* parent verification on copy ([727c63b](https://github.com/filebrowser/filebrowser/commit/727c63b98e2964d0960d25914c296570f6c79478))
* path separator inconsistency on rename ([34dfb49](https://github.com/filebrowser/filebrowser/commit/34dfb49b719c948e709a4639b4af2c5cb73b3887))
## [2.5.0](https://github.com/filebrowser/filebrowser/compare/v2.4.0...v2.5.0) (2020-07-17)
### Features
* add previewer title and loading indicator ([716396a](https://github.com/filebrowser/filebrowser/commit/716396a726329f0ba42fc34167dd07497c5bf47c))
* duplicate files in the same directory ([43526d9](https://github.com/filebrowser/filebrowser/commit/43526d9d1a8c837245e3f5059e0b4737583eeaeb))
* file copy, move and paste conflict checking ([eed9da1](https://github.com/filebrowser/filebrowser/commit/eed9da1471723ed3fbe6c00b1d6362b1c5fd8b04))
* rename option on replace prompt ([2636f87](https://github.com/filebrowser/filebrowser/commit/2636f876ab8f88eea6d9548de524ca2339eb0843))
* upload queue ([6ec6a23](https://github.com/filebrowser/filebrowser/commit/6ec6a2386173410f5cab9941dbf1bacb6b70ddd2))
### Bug Fixes
* blinking previewer ([9a2ebba](https://github.com/filebrowser/filebrowser/commit/9a2ebbabe2e9f0c292701d33f36f9b7a457b1164))
* dark theme colors ([b3b6445](https://github.com/filebrowser/filebrowser/commit/b3b644527d5673e16e61d404ff58a3c7bd6b6637))
* directory conflict checking ([7e5beef](https://github.com/filebrowser/filebrowser/commit/7e5beeff464e75ab185c430cd96e7cc67209ccc1))
* prompt before closing window ([194030f](https://github.com/filebrowser/filebrowser/commit/194030fcfcf54a2cf5e2f8ececcbb4754474d8f8))
* remove incomplete uploaded files ([0727496](https://github.com/filebrowser/filebrowser/commit/0727496601a9918c8131c56f62419bfac7ac589a))
* reset clipboard after pasting cutted files ([10570ad](https://github.com/filebrowser/filebrowser/commit/10570ade442b573ebe00af08369e28b1b0688df6))
## [2.4.0](https://github.com/filebrowser/filebrowser/compare/v2.3.0...v2.4.0) (2020-07-07)
### Features
* full screen editor ([0d665e5](https://github.com/filebrowser/filebrowser/commit/0d665e528f880ceda0976ceed66070ac34de7969))
### Bug Fixes
* add preview bypass for .gif files ([#1012](https://github.com/filebrowser/filebrowser/issues/1012)) ([453636d](https://github.com/filebrowser/filebrowser/commit/453636dfe2bbf177c74617862eb763485d4774bf))
* prompt key shortcut conflict ([0d69fbd](https://github.com/filebrowser/filebrowser/commit/0d69fbd9a342aa2695859021df0c423e3ae4a4fa))
## [2.3.0](https://github.com/filebrowser/filebrowser/compare/v2.2.0...v2.3.0) (2020-06-26) ## [2.3.0](https://github.com/filebrowser/filebrowser/compare/v2.2.0...v2.3.0) (2020-06-26)

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 && !errors.Is(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

@@ -16,4 +16,5 @@ var (
ErrInvalidAuthMethod = errors.New("invalid auth method") ErrInvalidAuthMethod = errors.New("invalid auth method")
ErrPermissionDenied = errors.New("permission denied") ErrPermissionDenied = errors.New("permission denied")
ErrInvalidRequestParams = errors.New("invalid request params") ErrInvalidRequestParams = errors.New("invalid request params")
ErrSourceIsParent = errors.New("source is parent")
) )

View File

@@ -2,6 +2,7 @@ package fileutils
import ( import (
"io" "io"
"os"
"path/filepath" "path/filepath"
"github.com/spf13/afero" "github.com/spf13/afero"
@@ -25,7 +26,7 @@ func CopyFile(fs afero.Fs, source, dest string) error {
} }
// Create the destination file. // Create the destination file.
dst, err := fs.Create(dest) dst, err := fs.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0775)
if err != nil { if err != nil {
return err return err
} }

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

@@ -1,7 +1,7 @@
:root { :root {
--background: #121212; --background: #141D24;
--surfacePrimary: #171819; --surfacePrimary: #20292F;
--surfaceSecondary: #212528; --surfaceSecondary: #3A4147;
--divider: rgba(255, 255, 255, 0.12); --divider: rgba(255, 255, 255, 0.12);
--icon: #ffffff; --icon: #ffffff;
--textPrimary: rgba(255, 255, 255, 0.87); --textPrimary: rgba(255, 255, 255, 0.87);
@@ -16,7 +16,7 @@ body {
#loading { #loading {
background: var(--background); background: var(--background);
} }
#loading .spinner div { #loading .spinner div, #previewer .loading .spinner div {
background: var(--icon); background: var(--icon);
} }
@@ -30,25 +30,34 @@ header {
#search #input { #search #input {
background: var(--surfaceSecondary); background: var(--surfaceSecondary);
border-color: var(--surfacePrimary);
} }
#search.active #input, #search #input input::placeholder {
#search.active .boxes { color: var(--textSecondary);
}
#search.active #input {
background: var(--surfacePrimary); background: var(--surfacePrimary);
} }
#search.active input { #search.active input {
color: var(--textPrimary); color: var(--textPrimary);
} }
#search.active #result { #search #result {
background: var(--background); background: var(--background);
color: var(--textPrimary); color: var(--textPrimary);
} }
#search.active .boxes h3 { #search .boxes {
background: var(--surfaceSecondary);
}
#search .boxes h3 {
color: var(--textPrimary); color: var(--textPrimary);
} }
.action { .action {
color: var(--textPrimary) !important; color: var(--textPrimary) !important;
} }
.action:hover {
background-color: rgba(255, 255, 255, .1);
}
.action i { .action i {
color: var(--icon) !important; color: var(--icon) !important;
} }
@@ -93,6 +102,10 @@ nav > div {
background: var(--background); background: var(--background);
} }
.message {
color: var(--textPrimary);
}
.card { .card {
background: var(--surfacePrimary); background: var(--surfacePrimary);
color: var(--textPrimary); color: var(--textPrimary);
@@ -106,9 +119,23 @@ nav > div {
.dashboard p label { .dashboard p label {
color: var(--textPrimary); color: var(--textPrimary);
} }
.card#share ul li input,
.card#share ul li select,
.input { .input {
background: var(--surfaceSecondary); background: var(--surfaceSecondary);
color: var(--textPrimary); color: var(--textPrimary);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.input:hover,
.input:focus {
border-color: rgba(255, 255, 255, 0.15);
}
.input--red {
background: #73302D;
}
.input--green {
background: #147A41;
} }
.dashboard #nav li, .dashboard #nav li,
@@ -119,10 +146,35 @@ nav > div {
color: var(--textPrimary); color: var(--textPrimary);
} }
table th {
color: var(--textSecondary);
}
.file-list li:hover {
background: var(--surfaceSecondary);
}
.file-list li:before {
color: var(--textSecondary);
}
.file-list li[aria-selected=true]:before {
color: var(--icon);
}
.shell { .shell {
background: var(--surfacePrimary); background: var(--surfacePrimary);
color: var(--textPrimary); color: var(--textPrimary);
} }
.shell__result {
border-top: 1px solid var(--divider);
}
#editor-container {
background: var(--background);
}
#editor-container .bar {
background: var(--surfacePrimary);
}
@media (max-width: 736px) { @media (max-width: 736px) {
#file-selection { #file-selection {
@@ -138,3 +190,11 @@ nav > div {
background: var(--surfaceSecondary) !important; background: var(--surfaceSecondary) !important;
} }
} }
.share__box, .share__box__download {
background: var(--surfaceSecondary) !important;
color: var(--textPrimary);
}
.share__box__download {
border-bottom-color: var(--divider);
}

View File

@@ -43,7 +43,7 @@ async function resourceAction (url, method, content) {
const res = await fetchURL(`/api/resources${url}`, opts) const res = await fetchURL(`/api/resources${url}`, opts)
if (res.status !== 200) { if (res.status !== 200) {
throw new Error(res.responseText) throw new Error(await res.text())
} else { } else {
return res return res
} }
@@ -94,9 +94,6 @@ export async function post (url, content = '', overwrite = false, onupload) {
request.upload.onprogress = onupload request.upload.onprogress = onupload
} }
// Send a message to user before closing the tab during file upload
window.onbeforeunload = () => "Files are being uploaded."
request.onload = () => { request.onload = () => {
if (request.status === 200) { if (request.status === 200) {
resolve(request.responseText) resolve(request.responseText)
@@ -112,29 +109,28 @@ export async function post (url, content = '', overwrite = false, onupload) {
} }
request.send(content) request.send(content)
// Upload is done no more message before closing the tab })
}).finally(() => { window.onbeforeunload = null })
} }
function moveCopy (items, copy = false) { function moveCopy (items, copy = false, overwrite = false, rename = false) {
let promises = [] let promises = []
for (let item of items) { for (let item of items) {
const from = removePrefix(item.from) const from = removePrefix(item.from)
const to = encodeURIComponent(removePrefix(item.to)) const to = encodeURIComponent(removePrefix(item.to))
const url = `${from}?action=${copy ? 'copy' : 'rename'}&destination=${to}` const url = `${from}?action=${copy ? 'copy' : 'rename'}&destination=${to}&override=${overwrite}&rename=${rename}`
promises.push(resourceAction(url, 'PATCH')) promises.push(resourceAction(url, 'PATCH'))
} }
return Promise.all(promises) return Promise.all(promises)
} }
export function move (items) { export function move (items, overwrite = false, rename = false) {
return moveCopy(items) return moveCopy(items, false, overwrite, rename)
} }
export function copy (items) { export function copy (items, overwrite = false, rename = false) {
return moveCopy(items, true) return moveCopy(items, true, overwrite, rename)
} }
export async function checksum (url, algo) { export async function checksum (url, algo) {

View File

@@ -1,5 +1,5 @@
<template> <template>
<header> <header v-if="!isEditor">
<div> <div>
<button @click="openSidebar" :aria-label="$t('buttons.toggleSidebar')" :title="$t('buttons.toggleSidebar')" class="action"> <button @click="openSidebar" :aria-label="$t('buttons.toggleSidebar')" :title="$t('buttons.toggleSidebar')" class="action">
<i class="material-icons">menu</i> <i class="material-icons">menu</i>
@@ -13,10 +13,6 @@
<i class="material-icons">search</i> <i class="material-icons">search</i>
</button> </button>
<button v-show="showSaveButton" :aria-label="$t('buttons.save')" :title="$t('buttons.save')" class="action" id="save-button">
<i class="material-icons">save</i>
</button>
<button @click="openMore" id="more" :aria-label="$t('buttons.more')" :title="$t('buttons.more')" class="action"> <button @click="openMore" id="more" :aria-label="$t('buttons.more')" :title="$t('buttons.more')" class="action">
<i class="material-icons">more_vert</i> <i class="material-icons">more_vert</i>
</button> </button>
@@ -129,9 +125,6 @@ export default {
showUpload () { showUpload () {
return this.isListing && this.user.perm.create return this.isListing && this.user.perm.create
}, },
showSaveButton () {
return this.isEditor && this.user.perm.modify
},
showDownloadButton () { showDownloadButton () {
return this.isFiles && this.user.perm.download return this.isFiles && this.user.perm.download
}, },

View File

@@ -136,12 +136,6 @@ export default {
} }
}, },
mounted() { mounted() {
window.addEventListener("keydown", event => {
if (event.keyCode === 27) {
this.closeHovers()
}
})
this.$refs.result.addEventListener('scroll', event => { this.$refs.result.addEventListener('scroll', event => {
if (event.target.offsetHeight + event.target.scrollTop >= event.target.scrollHeight - 100) { if (event.target.offsetHeight + event.target.scrollTop >= event.target.scrollHeight - 100) {
this.resultsCount += 50 this.resultsCount += 50

View File

@@ -1,11 +1,37 @@
<template> <template>
<form id="editor"></form> <div id="editor-container">
<div class="bar">
<button @click="back" :title="$t('files.closePreview')" :aria-label="$t('files.closePreview')" id="close" class="action">
<i class="material-icons">close</i>
</button>
<div class="title">
<span>{{ req.name }}</span>
</div>
<button @click="save" v-show="user.perm.modify" :aria-label="$t('buttons.save')" :title="$t('buttons.save')" id="save-button" class="action">
<i class="material-icons">save</i>
</button>
</div>
<div id="breadcrumbs">
<span><i class="material-icons">home</i></span>
<span v-for="(link, index) in breadcrumbs" :key="index">
<span class="chevron"><i class="material-icons">keyboard_arrow_right</i></span>
<span>{{ link.name }}</span>
</span>
</div>
<form id="editor"></form>
</div>
</template> </template>
<script> <script>
import { mapState } from 'vuex' import { mapState } from 'vuex'
import { files as api } from '@/api' import { files as api } from '@/api'
import buttons from '@/utils/buttons' import buttons from '@/utils/buttons'
import url from '@/utils/url'
import ace from 'ace-builds/src-min-noconflict/ace.js' import ace from 'ace-builds/src-min-noconflict/ace.js'
import modelist from 'ace-builds/src-min-noconflict/ext-modelist.js' import modelist from 'ace-builds/src-min-noconflict/ext-modelist.js'
@@ -14,27 +40,52 @@ import { theme } from '@/utils/constants'
export default { export default {
name: 'editor', name: 'editor',
computed: {
...mapState(['req'])
},
data: function () { data: function () {
return {} return {}
}, },
computed: {
...mapState(['req', 'user']),
breadcrumbs () {
let parts = this.$route.path.split('/')
if (parts[0] === '') {
parts.shift()
}
if (parts[parts.length - 1] === '') {
parts.pop()
}
let breadcrumbs = []
for (let i = 0; i < parts.length; i++) {
breadcrumbs.push({ name: decodeURIComponent(parts[i]) })
}
breadcrumbs.shift()
if (breadcrumbs.length > 3) {
while (breadcrumbs.length !== 4) {
breadcrumbs.shift()
}
breadcrumbs[0].name = '...'
}
return breadcrumbs
}
},
created () { created () {
window.addEventListener('keydown', this.keyEvent) window.addEventListener('keydown', this.keyEvent)
document.getElementById('save-button').addEventListener('click', this.save)
}, },
beforeDestroy () { beforeDestroy () {
window.removeEventListener('keydown', this.keyEvent) window.removeEventListener('keydown', this.keyEvent)
document.getElementById('save-button').removeEventListener('click', this.save)
this.editor.destroy(); this.editor.destroy();
}, },
mounted: function () { mounted: function () {
const fileContent = this.req.content || ''; const fileContent = this.req.content || '';
this.editor = ace.edit('editor', { this.editor = ace.edit('editor', {
maxLines: 80,
minLines: 20,
value: fileContent, value: fileContent,
showPrintMargin: false, showPrintMargin: false,
readOnly: this.req.type === 'textImmutable', readOnly: this.req.type === 'textImmutable',
@@ -48,6 +99,10 @@ export default {
} }
}, },
methods: { methods: {
back () {
let uri = url.removeLastDir(this.$route.path) + '/'
this.$router.push({ path: uri })
},
keyEvent (event) { keyEvent (event) {
if (!event.ctrlKey && !event.metaKey) { if (!event.ctrlKey && !event.metaKey) {
return return

View File

@@ -10,10 +10,12 @@
@mouseup="mouseUp" @mouseup="mouseUp"
@wheel="wheelMove" @wheel="wheelMove"
> >
<img :src="src" class="image-ex-img" ref="imgex" @load="setCenter"> <img :src="src" class="image-ex-img image-ex-img-center" ref="imgex" @load="onLoad">
</div> </div>
</template> </template>
<script> <script>
import throttle from 'lodash.throttle'
export default { export default {
props: { props: {
src: String, src: String,
@@ -50,7 +52,12 @@ export default {
inDrag: false, inDrag: false,
lastTouchDistance: 0, lastTouchDistance: 0,
moveDisabled: false, moveDisabled: false,
disabledTimer: null disabledTimer: null,
imageLoaded: false,
position: {
center: { x: 0, y: 0 },
relative: { x: 0, y: 0 }
}
} }
}, },
mounted() { mounted() {
@@ -63,24 +70,47 @@ export default {
if (getComputedStyle(container).height === "0px") { if (getComputedStyle(container).height === "0px") {
container.style.height = "100%" container.style.height = "100%"
} }
window.addEventListener('resize', this.onResize)
},
beforeDestroy () {
window.removeEventListener('resize', this.onResize)
document.removeEventListener('mouseup', this.onMouseUp)
}, },
methods: { methods: {
onLoad() {
let img = this.$refs.imgex
this.imageLoaded = true
if (img === undefined) {
return
}
img.classList.remove('image-ex-img-center')
this.setCenter()
img.classList.add('image-ex-img-ready')
document.addEventListener('mouseup', this.onMouseUp)
},
onMouseUp() {
this.inDrag = false
},
onResize: throttle(function() {
if (this.imageLoaded) {
this.setCenter()
this.doMove(this.position.relative.x, this.position.relative.y)
}
}, 100),
setCenter() { setCenter() {
let container = this.$refs.container let container = this.$refs.container
let img = this.$refs.imgex let img = this.$refs.imgex
let rate = Math.min( this.position.center.x = Math.floor((container.clientWidth - img.clientWidth) / 2)
container.clientWidth / img.clientWidth, this.position.center.y = Math.floor((container.clientHeight - img.clientHeight) / 2)
container.clientHeight / img.clientHeight
) img.style.left = this.position.center.x + 'px'
if (!this.autofill && rate > 1) { img.style.top = this.position.center.y + 'px'
rate = 1
}
// height will be auto set
img.width = Math.floor(img.clientWidth * rate)
img.style.top = `${Math.floor((container.clientHeight - img.clientHeight) / 2)}px`
img.style.left = `${Math.floor((container.clientWidth - img.clientWidth) / 2)}px`
document.addEventListener('mouseup', () => this.inDrag = false )
}, },
mousedownStart(event) { mousedownStart(event) {
this.lastX = null this.lastX = null
@@ -159,8 +189,22 @@ export default {
}, },
doMove(x, y) { doMove(x, y) {
let style = this.$refs.imgex.style let style = this.$refs.imgex.style
style.left = `${this.pxStringToNumber(style.left) + x}px` let posX = this.pxStringToNumber(style.left) + x
style.top = `${this.pxStringToNumber(style.top) + y}px` let posY = this.pxStringToNumber(style.top) + y
style.left = posX + 'px'
style.top = posY + 'px'
this.position.relative.x = Math.abs(this.position.center.x - posX)
this.position.relative.y = Math.abs(this.position.center.y - posY)
if (posX < this.position.center.x) {
this.position.relative.x = this.position.relative.x * -1
}
if (posY < this.position.center.y) {
this.position.relative.y = this.position.relative.y * -1
}
}, },
wheelMove(event) { wheelMove(event) {
this.scale += (event.wheelDeltaY / 100) * this.zoomStep this.scale += (event.wheelDeltaY / 100) * this.zoomStep
@@ -185,9 +229,20 @@ export default {
} }
.image-ex-img { .image-ex-img {
position: absolute;
}
.image-ex-img-center {
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
position: absolute;
transition: none;
}
.image-ex-img-ready {
left: 0; left: 0;
top: 0; top: 0;
position: absolute;
transition: transform 0.1s ease; transition: transform 0.1s ease;
} }
</style> </style>

View File

@@ -8,9 +8,7 @@
<input style="display:none" type="file" id="upload-folder-input" @change="uploadInput($event)" webkitdirectory multiple> <input style="display:none" type="file" id="upload-folder-input" @change="uploadInput($event)" webkitdirectory multiple>
</div> </div>
<div v-else id="listing" <div v-else id="listing"
:class="user.viewMode" :class="user.viewMode">
@dragenter="dragEnter"
@dragend="dragEnd">
<div> <div>
<div class="item header"> <div class="item header">
<div></div> <div></div>
@@ -89,29 +87,22 @@
<script> <script>
import { mapState, mapMutations } from 'vuex' import { mapState, mapMutations } from 'vuex'
import throttle from 'lodash.throttle'
import Item from './ListingItem' import Item from './ListingItem'
import css from '@/utils/css' import css from '@/utils/css'
import { users, files as api } from '@/api' import { users, files as api } from '@/api'
import buttons from '@/utils/buttons' import * as upload from '@/utils/upload'
import url from '@/utils/url'
export default { export default {
name: 'listing', name: 'listing',
components: { Item }, components: { Item },
data: function () { data: function () {
return { return {
show: 50, showLimit: 50,
uploading: { dragCounter: 0
id: 0,
count: 0,
size: 0,
progress: []
}
} }
}, },
computed: { computed: {
...mapState(['req', 'selected', 'user']), ...mapState(['req', 'selected', 'user', 'show']),
nameSorted () { nameSorted () {
return (this.req.sorting.by === 'name') return (this.req.sorting.by === 'name')
}, },
@@ -139,14 +130,14 @@ export default {
return { dirs, files } return { dirs, files }
}, },
dirs () { dirs () {
return this.items.dirs.slice(0, this.show) return this.items.dirs.slice(0, this.showLimit)
}, },
files () { files () {
let show = this.show - this.items.dirs.length let showLimit = this.showLimit - this.items.dirs.length
if (show < 0) show = 0 if (showLimit < 0) showLimit = 0
return this.items.files.slice(0, show) return this.items.files.slice(0, showLimit)
}, },
nameIcon () { nameIcon () {
if (this.nameSorted && !this.ascOrdered) { if (this.nameSorted && !this.ascOrdered) {
@@ -179,6 +170,8 @@ export default {
window.addEventListener('resize', this.resizeEvent) window.addEventListener('resize', this.resizeEvent)
window.addEventListener('scroll', this.scrollEvent) window.addEventListener('scroll', this.scrollEvent)
document.addEventListener('dragover', this.preventDefault) document.addEventListener('dragover', this.preventDefault)
document.addEventListener('dragenter', this.dragEnter)
document.addEventListener('dragleave', this.dragLeave)
document.addEventListener('drop', this.drop) document.addEventListener('drop', this.drop)
}, },
beforeDestroy () { beforeDestroy () {
@@ -187,6 +180,8 @@ export default {
window.removeEventListener('resize', this.resizeEvent) window.removeEventListener('resize', this.resizeEvent)
window.removeEventListener('scroll', this.scrollEvent) window.removeEventListener('scroll', this.scrollEvent)
document.removeEventListener('dragover', this.preventDefault) document.removeEventListener('dragover', this.preventDefault)
document.removeEventListener('dragenter', this.dragEnter)
document.removeEventListener('dragleave', this.dragLeave)
document.removeEventListener('drop', this.drop) document.removeEventListener('drop', this.drop)
}, },
methods: { methods: {
@@ -195,6 +190,10 @@ export default {
return window.btoa(unescape(encodeURIComponent(name))) return window.btoa(unescape(encodeURIComponent(name)))
}, },
keyEvent (event) { keyEvent (event) {
if (this.show !== null) {
return
}
if (!event.ctrlKey && !event.metaKey) { if (!event.ctrlKey && !event.metaKey) {
return return
} }
@@ -252,7 +251,8 @@ export default {
this.$store.commit('updateClipboard', { this.$store.commit('updateClipboard', {
key: key, key: key,
items: items items: items,
path: this.$route.path
}) })
}, },
paste (event) { paste (event) {
@@ -265,23 +265,56 @@ export default {
for (let item of this.$store.state.clipboard.items) { for (let item of this.$store.state.clipboard.items) {
const from = item.from.endsWith('/') ? item.from.slice(0, -1) : item.from const from = item.from.endsWith('/') ? item.from.slice(0, -1) : item.from
const to = this.$route.path + item.name const to = this.$route.path + item.name
items.push({ from, to }) items.push({ from, to, name: item.name })
} }
if (items.length === 0) { if (items.length === 0) {
return return
} }
if (this.$store.state.clipboard.key === 'x') { let action = (overwrite, rename) => {
api.move(items).then(() => { api.copy(items, overwrite, rename).then(() => {
this.$store.commit('setReload', true) this.$store.commit('setReload', true)
}).catch(this.$showError) }).catch(this.$showError)
}
if (this.$store.state.clipboard.key === 'x') {
action = (overwrite, rename) => {
api.move(items, overwrite, rename).then(() => {
this.$store.commit('resetClipboard')
this.$store.commit('setReload', true)
}).catch(this.$showError)
}
}
if (this.$store.state.clipboard.path == this.$route.path) {
action(false, true)
return return
} }
api.copy(items).then(() => { let conflict = upload.checkConflict(items, this.req.items)
this.$store.commit('setReload', true)
}).catch(this.$showError) let overwrite = false
let rename = false
if (conflict) {
this.$store.commit('showHover', {
prompt: 'replace-rename',
confirm: (event, option) => {
overwrite = option == 'overwrite'
rename = option == 'rename'
event.preventDefault()
this.$store.commit('closeHovers')
action(overwrite, rename)
}
})
return
}
action(overwrite, rename)
}, },
resizeEvent () { resizeEvent () {
// Update the columns size based on the window width. // Update the columns size based on the window width.
@@ -292,10 +325,12 @@ export default {
}, },
scrollEvent () { scrollEvent () {
if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight) { if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight) {
this.show += 50 this.showLimit += 50
} }
}, },
dragEnter () { dragEnter () {
this.dragCounter++
// When the user starts dragging an item, put every // When the user starts dragging an item, put every
// file on the listing with 50% opacity. // file on the listing with 50% opacity.
let items = document.getElementsByClassName('item') let items = document.getElementsByClassName('item')
@@ -304,11 +339,16 @@ export default {
file.style.opacity = 0.5 file.style.opacity = 0.5
}) })
}, },
dragEnd () { dragLeave () {
this.resetOpacity() this.dragCounter--
if (this.dragCounter == 0) {
this.resetOpacity()
}
}, },
drop: function (event) { drop: async function (event) {
event.preventDefault() event.preventDefault()
this.dragCounter = 0
this.resetOpacity() this.resetOpacity()
let dt = event.dataTransfer let dt = event.dataTransfer
@@ -327,65 +367,34 @@ export default {
base = el.querySelector('.name').innerHTML + '/' base = el.querySelector('.name').innerHTML + '/'
} }
if (base === '') { let files = await upload.scanFiles(dt)
this.scanFiles(dt).then((result) => { let path = this.$route.path + base
this.checkConflict(result, this.req.items, base) let items = this.req.items
})
} else { if (base !== '') {
this.scanFiles(dt).then((result) => { try {
api.fetch(this.$route.path + base) items = (await api.fetch(path)).items
.then(req => { } catch (error) {
this.checkConflict(result, req.items, base) this.$showError(error)
}) }
.catch(this.$showError)
})
}
},
checkConflict (files, items, base) {
if (typeof items === 'undefined' || items === null) {
items = []
} }
let folder_upload = false let conflict = upload.checkConflict(files, items)
if (files[0].fullPath !== undefined) {
folder_upload = true
}
let conflict = false if (conflict) {
for (let i = 0; i < files.length; i++) { this.$store.commit('showHover', {
let file = files[i] prompt: 'replace',
let name = file.name confirm: (event) => {
event.preventDefault()
if (folder_upload) { this.$store.commit('closeHovers')
let dirs = file.fullPath.split("/") upload.handleFiles(files, path, true)
if (dirs.length > 1) {
name = dirs[0]
} }
} })
let res = items.findIndex(function hasConflict (element) {
return (element.name === this)
}, name)
if (res >= 0) {
conflict = true
break
}
}
if (!conflict) {
this.handleFiles(files, base)
return return
} }
this.$store.commit('showHover', { upload.handleFiles(files, path)
prompt: 'replace',
confirm: (event) => {
event.preventDefault()
this.$store.commit('closeHovers')
this.handleFiles(files, base, true)
}
})
}, },
uploadInput (event) { uploadInput (event) {
this.$store.commit('closeHovers') this.$store.commit('closeHovers')
@@ -400,7 +409,23 @@ export default {
} }
} }
this.checkConflict(files, this.req.items, '') let path = this.$route.path
let conflict = upload.checkConflict(files, this.req.items)
if (conflict) {
this.$store.commit('showHover', {
prompt: 'replace',
confirm: (event) => {
event.preventDefault()
this.$store.commit('closeHovers')
upload.handleFiles(files, path, true)
}
})
return
}
upload.handleFiles(files, path)
}, },
resetOpacity () { resetOpacity () {
let items = document.getElementsByClassName('item') let items = document.getElementsByClassName('item')
@@ -409,145 +434,6 @@ export default {
file.style.opacity = 1 file.style.opacity = 1
}) })
}, },
scanFiles(dt) {
return new Promise((resolve) => {
let reading = 0
const contents = []
if (dt.items !== undefined) {
for (let item of dt.items) {
if (item.kind === "file" && typeof item.webkitGetAsEntry === "function") {
const entry = item.webkitGetAsEntry()
readEntry(entry)
}
}
} else {
resolve(dt.files)
}
function readEntry(entry, directory = "") {
if (entry.isFile) {
reading++
entry.file(file => {
reading--
file.fullPath = `${directory}${file.name}`
contents.push(file)
if (reading === 0) {
resolve(contents)
}
})
} else if (entry.isDirectory) {
const dir = {
isDir: true,
path: `${directory}${entry.name}`
}
contents.push(dir)
readReaderContent(entry.createReader(), `${directory}${entry.name}`)
}
}
function readReaderContent(reader, directory) {
reading++
reader.readEntries(function (entries) {
reading--
if (entries.length > 0) {
for (const entry of entries) {
readEntry(entry, `${directory}/`)
}
readReaderContent(reader, `${directory}/`)
}
if (reading === 0) {
resolve(contents)
}
})
}
})
},
setProgress: throttle(function() {
if (this.uploading.count == 0) {
return
}
let sum = this.uploading.progress.reduce((acc, val) => acc + val)
this.$store.commit('setProgress', Math.ceil(sum / this.uploading.size * 100))
}, 100, {leading: false, trailing: true}),
handleFiles (files, base, overwrite = false) {
if (this.uploading.count == 0) {
buttons.loading('upload')
}
let promises = []
let onupload = (id) => (event) => {
this.uploading.progress[id] = event.loaded
this.setProgress()
}
for (let i = 0; i < files.length; i++) {
let file = files[i]
if (!file.isDir) {
let filename = (file.fullPath !== undefined) ? file.fullPath : file.name
let filenameEncoded = url.encodeRFC5987ValueChars(filename)
let id = this.uploading.id
this.uploading.size += file.size
this.uploading.id++
this.uploading.count++
let promise = api.post(this.$route.path + base + filenameEncoded, file, overwrite, throttle(onupload(id), 100)).finally(() => {
this.uploading.count--
})
promises.push(promise)
} else {
let uri = this.$route.path + base
let folders = file.path.split("/")
for (let i = 0; i < folders.length; i++) {
let folder = folders[i]
let folderEncoded = encodeURIComponent(folder)
uri += folderEncoded + "/"
}
api.post(uri)
}
}
let finish = () => {
if (this.uploading.count > 0) {
return
}
buttons.success('upload')
this.$store.commit('setProgress', 0)
this.$store.commit('setReload', true)
this.uploading.id = 0
this.uploading.sizes = []
this.uploading.progress = []
}
Promise.all(promises)
.then(() => {
finish()
})
.catch(error => {
finish()
this.$showError(error)
})
return false
},
async sort (by) { async sort (by) {
let asc = false let asc = false

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,11 +31,12 @@
</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'
import { files as api } from '@/api' import { files as api } from '@/api'
import * as upload from '@/utils/upload'
export default { export default {
name: 'item', name: 'item',
@@ -75,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: {
@@ -110,26 +114,61 @@ export default {
el.style.opacity = 1 el.style.opacity = 1
}, },
drop: function (event) { drop: async function (event) {
if (!this.canDrop) return if (!this.canDrop) return
event.preventDefault() event.preventDefault()
if (this.selectedCount === 0) return if (this.selectedCount === 0) return
let el = event.target
for (let i = 0; i < 5; i++) {
if (el !== null && !el.classList.contains('item')) {
el = el.parentElement
}
}
let items = [] let items = []
for (let i of this.selected) { for (let i of this.selected) {
items.push({ items.push({
from: this.req.items[i].url, from: this.req.items[i].url,
to: this.url + this.req.items[i].name to: this.url + this.req.items[i].name,
name: this.req.items[i].name
}) })
} }
api.move(items) let base = el.querySelector('.name').innerHTML + '/'
.then(() => { let path = this.$route.path + base
let baseItems = (await api.fetch(path)).items
let action = (overwrite, rename) => {
api.move(items, overwrite, rename).then(() => {
this.$store.commit('setReload', true) this.$store.commit('setReload', true)
}).catch(this.$showError)
}
let conflict = upload.checkConflict(items, baseItems)
let overwrite = false
let rename = false
if (conflict) {
this.$store.commit('showHover', {
prompt: 'replace-rename',
confirm: (event, option) => {
overwrite = option == 'overwrite'
rename = option == 'rename'
event.preventDefault()
this.$store.commit('closeHovers')
action(overwrite, rename)
}
}) })
.catch(this.$showError)
return
}
action(overwrite, rename)
}, },
click: function (event) { click: function (event) {
if (this.selectedCount !== 0) event.preventDefault() if (this.selectedCount !== 0) event.preventDefault()

View File

@@ -5,10 +5,22 @@
<i class="material-icons">close</i> <i class="material-icons">close</i>
</button> </button>
<rename-button v-if="user.perm.rename"></rename-button> <div class="title">
<delete-button v-if="user.perm.delete"></delete-button> <span>{{ this.name }}</span>
<download-button v-if="user.perm.download"></download-button> </div>
<info-button></info-button>
<rename-button :disabled="loading" v-if="user.perm.rename"></rename-button>
<delete-button :disabled="loading" v-if="user.perm.delete"></delete-button>
<download-button :disabled="loading" v-if="user.perm.download"></download-button>
<info-button :disabled="loading"></info-button>
</div>
<div class="loading" v-if="loading">
<div class="spinner">
<div class="bounce1"></div>
<div class="bounce2"></div>
<div class="bounce3"></div>
</div>
</div> </div>
<button class="action" @click="prev" v-show="hasPrevious" :aria-label="$t('buttons.previous')" :title="$t('buttons.previous')"> <button class="action" @click="prev" v-show="hasPrevious" :aria-label="$t('buttons.previous')" :title="$t('buttons.previous')">
@@ -18,25 +30,27 @@
<i class="material-icons">chevron_right</i> <i class="material-icons">chevron_right</i>
</button> </button>
<div class="preview"> <template v-if="!loading">
<ExtendedImage v-if="req.type == 'image'" :src="raw"></ExtendedImage> <div class="preview">
<audio v-else-if="req.type == 'audio'" :src="raw" autoplay controls></audio> <ExtendedImage v-if="req.type == 'image'" :src="raw"></ExtendedImage>
<video v-else-if="req.type == 'video'" :src="raw" autoplay controls> <audio v-else-if="req.type == 'audio'" :src="raw" autoplay controls></audio>
<track <video v-else-if="req.type == 'video'" :src="raw" autoplay controls>
kind="captions" <track
v-for="(sub, index) in subtitles" kind="captions"
:key="index" v-for="(sub, index) in subtitles"
:src="sub" :key="index"
:label="'Subtitle ' + index" :default="index === 0"> :src="sub"
Sorry, your browser doesn't support embedded videos, :label="'Subtitle ' + index" :default="index === 0">
but don't worry, you can <a :href="download">download it</a> Sorry, your browser doesn't support embedded videos,
and watch it with your favorite video player! but don't worry, you can <a :href="download">download it</a>
</video> and watch it with your favorite video player!
<object v-else-if="req.extension == '.pdf'" class="pdf" :data="raw"></object> </video>
<a v-else-if="req.type == 'blob'" :href="download"> <object v-else-if="req.extension == '.pdf'" class="pdf" :data="raw"></object>
<h2 class="message">{{ $t('buttons.download') }} <i class="material-icons">file_download</i></h2> <a v-else-if="req.type == 'blob'" :href="download">
</a> <h2 class="message">{{ $t('buttons.download') }} <i class="material-icons">file_download</i></h2>
</div> </a>
</div>
</template>
</div> </div>
</template> </template>
@@ -72,11 +86,12 @@ export default {
previousLink: '', previousLink: '',
nextLink: '', nextLink: '',
listing: null, listing: null,
name: '',
subtitles: [] subtitles: []
} }
}, },
computed: { computed: {
...mapState(['req', 'user', 'oldReq', 'jwt']), ...mapState(['req', 'user', 'oldReq', 'jwt', 'loading']),
hasPrevious () { hasPrevious () {
return (this.previousLink !== '') return (this.previousLink !== '')
}, },
@@ -84,42 +99,36 @@ export default {
return (this.nextLink !== '') return (this.nextLink !== '')
}, },
download () { download () {
return `${baseURL}/api/raw${this.req.path}?auth=${this.jwt}` return `${baseURL}/api/raw${url.encodePath(this.req.path)}?auth=${this.jwt}`
}, },
previewUrl () { previewUrl () {
if (this.req.type === 'image') { if (this.req.type === 'image') {
return `${baseURL}/api/preview/big${this.req.path}?auth=${this.jwt}` return `${baseURL}/api/preview/big${url.encodePath(this.req.path)}?auth=${this.jwt}`
} }
return `${baseURL}/api/raw${this.req.path}?auth=${this.jwt}` return `${baseURL}/api/raw${url.encodePath(this.req.path)}?auth=${this.jwt}`
}, },
raw () { raw () {
return `${this.previewUrl}&inline=true` return `${this.previewUrl}&inline=true`
} }
}, },
watch: {
$route: function () {
this.updatePreview()
}
},
async mounted () { async mounted () {
window.addEventListener('keyup', this.key) window.addEventListener('keyup', this.key)
this.$store.commit('setPreviewMode', true)
if (this.req.subtitles) { this.listing = this.oldReq.items
this.subtitles = this.req.subtitles.map(sub => `${baseURL}/api/raw${sub}?auth=${this.jwt}&inline=true`) this.updatePreview()
}
try {
if (this.oldReq.items) {
this.updateLinks(this.oldReq.items)
} else {
const path = url.removeLastDir(this.$route.path)
const res = await api.fetch(path)
this.updateLinks(res.items)
}
} catch (e) {
this.$showError(e)
}
}, },
beforeDestroy () { beforeDestroy () {
window.removeEventListener('keyup', this.key) window.removeEventListener('keyup', this.key)
this.$store.commit('setPreviewMode', false)
}, },
methods: { methods: {
back () { back () {
this.$store.commit('setPreviewMode', false)
let uri = url.removeLastDir(this.$route.path) + '/' let uri = url.removeLastDir(this.$route.path) + '/'
this.$router.push({ path: uri }) this.$router.push({ path: uri })
}, },
@@ -138,22 +147,42 @@ export default {
if (this.hasPrevious) this.prev() if (this.hasPrevious) this.prev()
} }
}, },
updateLinks (items) { async updatePreview () {
for (let i = 0; i < items.length; i++) { if (this.req.subtitles) {
if (items[i].name !== this.req.name) { this.subtitles = this.req.subtitles.map(sub => `${baseURL}/api/raw${sub}?auth=${this.jwt}&inline=true`)
}
let dirs = this.$route.fullPath.split("/")
this.name = decodeURIComponent(dirs[dirs.length - 1])
if (!this.listing) {
try {
const path = url.removeLastDir(this.$route.path)
const res = await api.fetch(path)
this.listing = res.items
} catch (e) {
this.$showError(e)
}
}
this.previousLink = ''
this.nextLink = ''
for (let i = 0; i < this.listing.length; i++) {
if (this.listing[i].name !== this.name) {
continue continue
} }
for (let j = i - 1; j >= 0; j--) { for (let j = i - 1; j >= 0; j--) {
if (mediaTypes.includes(items[j].type)) { if (mediaTypes.includes(this.listing[j].type)) {
this.previousLink = items[j].url this.previousLink = this.listing[j].url
break break
} }
} }
for (let j = i + 1; j < items.length; j++) { for (let j = i + 1; j < this.listing.length; j++) {
if (mediaTypes.includes(items[j].type)) { if (mediaTypes.includes(this.listing[j].type)) {
this.nextLink = items[j].url this.nextLink = this.listing[j].url
break break
} }
} }

View File

@@ -16,7 +16,6 @@
:title="$t('buttons.cancel')">{{ $t('buttons.cancel') }}</button> :title="$t('buttons.cancel')">{{ $t('buttons.cancel') }}</button>
<button class="button button--flat" <button class="button button--flat"
@click="copy" @click="copy"
:disabled="$route.path === dest"
:aria-label="$t('buttons.copy')" :aria-label="$t('buttons.copy')"
:title="$t('buttons.copy')">{{ $t('buttons.copy') }}</button> :title="$t('buttons.copy')">{{ $t('buttons.copy') }}</button>
</div> </div>
@@ -28,6 +27,7 @@ import { mapState } from 'vuex'
import FileList from './FileList' import FileList from './FileList'
import { files as api } from '@/api' import { files as api } from '@/api'
import buttons from '@/utils/buttons' import buttons from '@/utils/buttons'
import * as upload from '@/utils/upload'
export default { export default {
name: 'copy', name: 'copy',
@@ -42,25 +42,66 @@ export default {
methods: { methods: {
copy: async function (event) { copy: async function (event) {
event.preventDefault() event.preventDefault()
buttons.loading('copy')
let items = [] let items = []
// Create a new promise for each file. // Create a new promise for each file.
for (let item of this.selected) { for (let item of this.selected) {
items.push({ items.push({
from: this.req.items[item].url, from: this.req.items[item].url,
to: this.dest + encodeURIComponent(this.req.items[item].name) to: this.dest + encodeURIComponent(this.req.items[item].name),
name: this.req.items[item].name
}) })
} }
try { let action = async (overwrite, rename) => {
await api.copy(items) buttons.loading('copy')
buttons.success('copy')
this.$router.push({ path: this.dest }) await api.copy(items, overwrite, rename).then(() => {
} catch (e) { buttons.success('copy')
buttons.done('copy')
this.$showError(e) if (this.$route.path === this.dest) {
this.$store.commit('setReload', true)
return
}
this.$router.push({ path: this.dest })
}).catch((e) => {
buttons.done('copy')
this.$showError(e)
})
} }
if (this.$route.path === this.dest) {
this.$store.commit('closeHovers')
action(false, true)
return
}
let dstItems = (await api.fetch(this.dest)).items
let conflict = upload.checkConflict(items, dstItems)
let overwrite = false
let rename = false
if (conflict) {
this.$store.commit('showHover', {
prompt: 'replace-rename',
confirm: (event, option) => {
overwrite = option == 'overwrite'
rename = option == 'rename'
event.preventDefault()
this.$store.commit('closeHovers')
action(overwrite, rename)
}
})
return
}
action(overwrite, rename)
} }
} }
} }

View File

@@ -41,19 +41,7 @@ export default {
} }
}, },
mounted () { mounted () {
// If we're showing this on a listing, this.fillOptions(this.req)
// we can use the current request object
// to fill the move options.
if (this.req.kind === 'listing') {
this.fillOptions(this.req)
return
}
// Otherwise, we must be on a preview or editor
// so we fetch the data from the previous directory.
files.fetch(url.removeLastDir(this.$route.path))
.then(this.fillOptions)
.catch(this.$showError)
}, },
methods: { methods: {
fillOptions (req) { fillOptions (req) {

View File

@@ -27,6 +27,7 @@ import { mapState } from 'vuex'
import FileList from './FileList' import FileList from './FileList'
import { files as api } from '@/api' import { files as api } from '@/api'
import buttons from '@/utils/buttons' import buttons from '@/utils/buttons'
import * as upload from '@/utils/upload'
export default { export default {
name: 'move', name: 'move',
@@ -41,26 +42,51 @@ export default {
methods: { methods: {
move: async function (event) { move: async function (event) {
event.preventDefault() event.preventDefault()
buttons.loading('move')
let items = [] let items = []
for (let item of this.selected) { for (let item of this.selected) {
items.push({ items.push({
from: this.req.items[item].url, from: this.req.items[item].url,
to: this.dest + encodeURIComponent(this.req.items[item].name) to: this.dest + encodeURIComponent(this.req.items[item].name),
name: this.req.items[item].name
}) })
} }
try { let action = async (overwrite, rename) => {
api.move(items) buttons.loading('move')
buttons.success('move')
this.$router.push({ path: this.dest }) await api.move(items, overwrite, rename).then(() => {
} catch (e) { buttons.success('move')
buttons.done('move') this.$router.push({ path: this.dest })
this.$showError(e) }).catch((e) => {
buttons.done('move')
this.$showError(e)
})
} }
event.preventDefault() let dstItems = (await api.fetch(this.dest)).items
let conflict = upload.checkConflict(items, dstItems)
let overwrite = false
let rename = false
if (conflict) {
this.$store.commit('showHover', {
prompt: 'replace-rename',
confirm: (event, option) => {
overwrite = option == 'overwrite'
rename = option == 'rename'
event.preventDefault()
this.$store.commit('closeHovers')
action(overwrite, rename)
}
})
return
}
action(overwrite, rename)
} }
} }
} }

View File

@@ -16,6 +16,7 @@ import Copy from './Copy'
import NewFile from './NewFile' import NewFile from './NewFile'
import NewDir from './NewDir' import NewDir from './NewDir'
import Replace from './Replace' import Replace from './Replace'
import ReplaceRename from './ReplaceRename'
import Share from './Share' import Share from './Share'
import Upload from './Upload' import Upload from './Upload'
import { mapState } from 'vuex' import { mapState } from 'vuex'
@@ -35,6 +36,7 @@ export default {
NewDir, NewDir,
Help, Help,
Replace, Replace,
ReplaceRename,
Upload Upload
}, },
data: function () { data: function () {
@@ -87,6 +89,7 @@ export default {
'newDir', 'newDir',
'download', 'download',
'replace', 'replace',
'replace-rename',
'share', 'share',
'upload' 'upload'
].indexOf(this.show) >= 0; ].indexOf(this.show) >= 0;

View File

@@ -0,0 +1,35 @@
<template>
<div class="card floating">
<div class="card-title">
<h2>{{ $t('prompts.replace') }}</h2>
</div>
<div class="card-content">
<p>{{ $t('prompts.replaceMessage') }}</p>
</div>
<div class="card-action">
<button class="button button--flat button--grey"
@click="$store.commit('closeHovers')"
:aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')">{{ $t('buttons.cancel') }}</button>
<button class="button button--flat button--blue"
@click="(event) => showConfirm(event, 'rename')"
:aria-label="$t('buttons.rename')"
:title="$t('buttons.rename')">{{ $t('buttons.rename') }}</button>
<button class="button button--flat button--red"
@click="(event) => showConfirm(event, 'overwrite')"
:aria-label="$t('buttons.replace')"
:title="$t('buttons.replace')">{{ $t('buttons.replace') }}</button>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex'
export default {
name: 'replace-rename',
computed: mapState(['showConfirm'])
}
</script>

View File

@@ -27,9 +27,11 @@ export default {
name: 'upload', name: 'upload',
methods: { methods: {
uploadFile: function () { uploadFile: function () {
document.getElementById('upload-input').value = ''
document.getElementById('upload-input').click() document.getElementById('upload-input').click()
}, },
uploadFolder: function () { uploadFolder: function () {
document.getElementById('upload-folder-input').value = ''
document.getElementById('upload-folder-input').click() document.getElementById('upload-folder-input').click()
} }
} }

View File

@@ -25,8 +25,8 @@
background: var(--red); background: var(--red);
} }
.button--red:hover { .button--blue {
background: var(--dark-red); background: var(--blue);
} }
.button--flat { .button--flat {

View File

@@ -125,8 +125,13 @@
height: 3.7em; height: 3.7em;
} }
#previewer .action:first-of-type { #previewer .bar .title {
margin-right: auto; margin-right: auto;
padding: 0 1em;
line-height: 2.7em;
overflow: hidden;
word-break: break-word;
color: #fff;
} }
#previewer .action i { #previewer .action i {
@@ -184,6 +189,58 @@
right: 0.5em; right: 0.5em;
} }
/* EDITOR */
#editor-container {
background-color: #fafafa;
position: fixed;
top: 0;
left: 0;
width: 100%;
z-index: 9999;
overflow: hidden;
}
#editor-container .bar {
width: 100%;
text-align: right;
display: flex;
padding: 0.5em;
height: 3.7em;
background-color: #fff;
border-bottom: 1px solid rgba(0, 0, 0, 0.075);
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
}
#editor-container .title {
margin-right: auto;
padding: 0 1em;
line-height: 2.7em;
overflow: hidden;
word-break: break-word;
}
#previewer .title span {
font-size: 1.2em;
}
#previewer .loading {
height: 100%;
width: 100%;
}
#editor-container #editor {
height: calc(100vh - 8.2em);
}
#editor-container #breadcrumbs {
height: 2.3em;
padding: 0 1em;
}
#editor-container #breadcrumbs span {
font-size: 12px;
}
/* * * * * * * * * * * * * * * * /* * * * * * * * * * * * * * * *
* PROMPT * * PROMPT *

View File

@@ -3,7 +3,15 @@ const getters = {
isFiles: state => !state.loading && state.route.name === 'Files', isFiles: state => !state.loading && state.route.name === 'Files',
isListing: (state, getters) => getters.isFiles && state.req.isDir, isListing: (state, getters) => getters.isFiles && state.req.isDir,
isEditor: (state, getters) => getters.isFiles && (state.req.type === 'text' || state.req.type === 'textImmutable'), isEditor: (state, getters) => getters.isFiles && (state.req.type === 'text' || state.req.type === 'textImmutable'),
selectedCount: state => state.selected.length selectedCount: state => state.selected.length,
progress : state => {
if (state.upload.progress.length == 0) {
return 0;
}
let sum = state.upload.progress.reduce((acc, val) => acc + val)
return Math.ceil(sum / state.upload.size * 100);
}
} }
export default getters export default getters

View File

@@ -2,6 +2,7 @@ import Vue from 'vue'
import Vuex from 'vuex' import Vuex from 'vuex'
import mutations from './mutations' import mutations from './mutations'
import getters from './getters' import getters from './getters'
import upload from './modules/upload'
Vue.use(Vuex) Vue.use(Vuex)
@@ -22,12 +23,14 @@ const state = {
show: null, show: null,
showShell: false, showShell: false,
showMessage: null, showMessage: null,
showConfirm: null showConfirm: null,
previewMode: false
} }
export default new Vuex.Store({ export default new Vuex.Store({
strict: true, strict: true,
state, state,
getters, getters,
mutations mutations,
modules: { upload }
}) })

View File

@@ -0,0 +1,102 @@
import Vue from 'vue'
import { files as api } from '@/api'
import throttle from 'lodash.throttle'
import buttons from '@/utils/buttons'
const UPLOADS_LIMIT = 5;
const state = {
id: 0,
size: 0,
progress: [],
queue: [],
uploads: {}
}
const mutations = {
setProgress(state, { id, loaded }) {
Vue.set(state.progress, id, loaded)
},
reset: (state) => {
state.id = 0
state.size = 0
state.progress = []
},
addJob: (state, item) => {
state.queue.push(item)
state.size += item.file.size
state.id++
},
moveJob(state) {
const item = state.queue[0]
state.queue.shift()
Vue.set(state.uploads, item.id, item)
},
removeJob(state, id) {
delete state.uploads[id]
}
}
const beforeUnload = (event) => {
event.preventDefault()
event.returnValue = ''
}
const actions = {
upload: (context, item) => {
let uploadsCount = Object.keys(context.state.uploads).length;
let isQueueEmpty = context.state.queue.length == 0
let isUploadsEmpty = uploadsCount == 0
if (isQueueEmpty && isUploadsEmpty) {
window.addEventListener('beforeunload', beforeUnload)
buttons.loading('upload')
}
context.commit('addJob', item)
context.dispatch('processUploads')
},
finishUpload: (context, item) => {
context.commit('setProgress', { id: item.id, loaded: item.file.size })
context.commit('removeJob', item.id)
context.dispatch('processUploads')
},
processUploads: async (context) => {
let uploadsCount = Object.keys(context.state.uploads).length;
let isBellowLimit = uploadsCount < UPLOADS_LIMIT
let isQueueEmpty = context.state.queue.length == 0
let isUploadsEmpty = uploadsCount == 0
let isFinished = isQueueEmpty && isUploadsEmpty
let canProcess = isBellowLimit && !isQueueEmpty
if (isFinished) {
window.removeEventListener('beforeunload', beforeUnload)
buttons.success('upload')
context.commit('reset')
context.commit('setReload', true, { root: true })
}
if (canProcess) {
const item = context.state.queue[0];
context.commit('moveJob')
if (item.file.isDir) {
await api.post(item.path).catch(Vue.prototype.$showError)
} else {
let onUpload = throttle(
(event) => context.commit('setProgress', { id: item.id, loaded: event.loaded }),
100, { leading: true, trailing: false }
)
await api.post(item.path, item.file, item.overwrite, onUpload).catch(Vue.prototype.$showError)
}
context.dispatch('finishUpload', item)
}
}
}
export default { state, mutations, actions, namespaced: true }

View File

@@ -78,13 +78,14 @@ const mutations = {
updateClipboard: (state, value) => { updateClipboard: (state, value) => {
state.clipboard.key = value.key state.clipboard.key = value.key
state.clipboard.items = value.items state.clipboard.items = value.items
state.clipboard.path = value.path
}, },
resetClipboard: (state) => { resetClipboard: (state) => {
state.clipboard.key = '' state.clipboard.key = ''
state.clipboard.items = [] state.clipboard.items = []
}, },
setProgress: (state, value) => { setPreviewMode(state, value) {
state.progress = value state.previewMode = value
} }
} }

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

@@ -0,0 +1,130 @@
import store from '@/store'
import url from '@/utils/url'
export function checkConflict(files, items) {
if (typeof items === 'undefined' || items === null) {
items = []
}
let folder_upload = files[0].fullPath !== undefined
let conflict = false
for (let i = 0; i < files.length; i++) {
let file = files[i]
let name = file.name
if (folder_upload) {
let dirs = file.fullPath.split("/")
if (dirs.length > 1) {
name = dirs[0]
}
}
let res = items.findIndex(function hasConflict(element) {
return (element.name === this)
}, name)
if (res >= 0) {
conflict = true
break
}
}
return conflict
}
export function scanFiles(dt) {
return new Promise((resolve) => {
let reading = 0
const contents = []
if (dt.items !== undefined) {
for (let item of dt.items) {
if (item.kind === "file" && typeof item.webkitGetAsEntry === "function") {
const entry = item.webkitGetAsEntry()
readEntry(entry)
}
}
} else {
resolve(dt.files)
}
function readEntry(entry, directory = "") {
if (entry.isFile) {
reading++
entry.file(file => {
reading--
file.fullPath = `${directory}${file.name}`
contents.push(file)
if (reading === 0) {
resolve(contents)
}
})
} else if (entry.isDirectory) {
const dir = {
isDir: true,
size: 0,
fullPath: `${directory}${entry.name}`
}
contents.push(dir)
readReaderContent(entry.createReader(), `${directory}${entry.name}`)
}
}
function readReaderContent(reader, directory) {
reading++
reader.readEntries(function (entries) {
reading--
if (entries.length > 0) {
for (const entry of entries) {
readEntry(entry, `${directory}/`)
}
readReaderContent(reader, `${directory}/`)
}
if (reading === 0) {
resolve(contents)
}
})
}
})
}
export function handleFiles(files, path, overwrite = false) {
for (let i = 0; i < files.length; i++) {
let file = files[i]
let filename = (file.fullPath !== undefined) ? file.fullPath : file.name
let filenameEncoded = url.encodeRFC5987ValueChars(filename)
let id = store.state.upload.id
let itemPath = path + filenameEncoded
if (file.isDir) {
itemPath = path
let folders = file.fullPath.split("/")
for (let i = 0; i < folders.length; i++) {
let folder = folders[i]
let folderEncoded = encodeURIComponent(folder)
itemPath += folderEncoded + "/"
}
}
const item = {
id,
path: itemPath,
file,
overwrite
}
store.dispatch('upload/upload', item);
}
}

View File

@@ -20,7 +20,12 @@ function encodeRFC5987ValueChars(str) {
replace(/%(?:7C|60|5E)/g, unescape); replace(/%(?:7C|60|5E)/g, unescape);
} }
function encodePath(str) {
return str.split('/').map(v => encodeURIComponent(v)).join('/')
}
export default { export default {
encodeRFC5987ValueChars: encodeRFC5987ValueChars, encodeRFC5987ValueChars: encodeRFC5987ValueChars,
removeLastDir: removeLastDir removeLastDir: removeLastDir,
encodePath: encodePath
} }

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,6 +1,6 @@
<template> <template>
<div> <div>
<div id="breadcrumbs"> <div id="breadcrumbs" v-if="isListing || error">
<router-link to="/files/" :aria-label="$t('files.home')" :title="$t('files.home')"> <router-link to="/files/" :aria-label="$t('files.home')" :title="$t('files.home')">
<i class="material-icons">home</i> <i class="material-icons">home</i>
</router-link> </router-link>
@@ -10,14 +10,15 @@
<router-link :to="link.url">{{ link.name }}</router-link> <router-link :to="link.url">{{ link.name }}</router-link>
</span> </span>
</div> </div>
<div v-if="error"> <div v-if="error">
<not-found v-if="error.message === '404'"></not-found> <not-found v-if="error.message === '404'"></not-found>
<forbidden v-else-if="error.message === '403'"></forbidden> <forbidden v-else-if="error.message === '403'"></forbidden>
<internal-error v-else></internal-error> <internal-error v-else></internal-error>
</div> </div>
<preview v-else-if="isPreview"></preview>
<editor v-else-if="isEditor"></editor> <editor v-else-if="isEditor"></editor>
<listing :class="{ multiple }" v-else-if="isListing"></listing> <listing :class="{ multiple }" v-else-if="isListing"></listing>
<preview v-else-if="isPreview"></preview>
<div v-else> <div v-else>
<h2 class="message"> <h2 class="message">
<span>{{ $t('files.loading') }}</span> <span>{{ $t('files.loading') }}</span>
@@ -61,10 +62,11 @@ export default {
'user', 'user',
'reload', 'reload',
'multiple', 'multiple',
'loading' 'loading',
'show'
]), ]),
isPreview () { isPreview () {
return !this.loading && !this.isListing && !this.isEditor return !this.loading && !this.isListing && !this.isEditor || this.loading && this.$store.state.previewMode
}, },
breadcrumbs () { breadcrumbs () {
let parts = this.$route.path.split('/') let parts = this.$route.path.split('/')
@@ -158,10 +160,17 @@ export default {
} }
}, },
keyEvent (event) { keyEvent (event) {
if (this.show !== null) {
// Esc!
if (event.keyCode === 27) {
this.$store.commit('closeHovers')
}
return
}
// Esc! // Esc!
if (event.keyCode === 27) { if (event.keyCode === 27) {
this.$store.commit('closeHovers')
// If we're on a listing, unselect all // If we're on a listing, unselect all
// files and folders. // files and folders.
if (this.isListing) { if (this.isListing) {

View File

@@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<div id="progress"> <div id="progress">
<div v-bind:style="{ width: $store.state.progress + '%' }"></div> <div v-bind:style="{ width: this.progress + '%' }"></div>
</div> </div>
<site-header></site-header> <site-header></site-header>
<sidebar></sidebar> <sidebar></sidebar>
@@ -29,7 +29,7 @@ export default {
Shell Shell
}, },
computed: { computed: {
...mapGetters([ 'isLogged' ]), ...mapGetters([ 'isLogged', 'progress' ]),
...mapState([ 'user' ]) ...mapState([ 'user' ])
}, },
watch: { watch: {

View File

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

4
go.mod
View File

@@ -13,8 +13,8 @@ require (
github.com/golang/snappy v0.0.1 // indirect github.com/golang/snappy v0.0.1 // indirect
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/hacdias/fileutils v0.0.0-20181202104838-227b317161a1
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
@@ -25,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

8
go.sum
View File

@@ -87,8 +87,6 @@ github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
github.com/hacdias/fileutils v0.0.0-20181202104838-227b317161a1 h1:2MkEawJQTmAr6YI7T7j7SKxdTmYJOcaJZfzeVPr56PM=
github.com/hacdias/fileutils v0.0.0-20181202104838-227b317161a1/go.mod h1:lwnswzFVSy7B/k81M5rOLUU0fOBKHrDRIkPIBZd7PBo=
github.com/hashicorp/go-syslog v1.0.0 h1:KaodqZuhUoZereWVIYmpUgZysurB1kBLX2j0MwMrUAE= github.com/hashicorp/go-syslog v1.0.0 h1:KaodqZuhUoZereWVIYmpUgZysurB1kBLX2j0MwMrUAE=
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
github.com/hashicorp/golang-lru v0.0.0-20180201235237-0fb14efe8c47/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.0.0-20180201235237-0fb14efe8c47/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
@@ -129,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=
@@ -206,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=
@@ -318,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

@@ -189,7 +189,7 @@ func printToken(w http.ResponseWriter, _ *http.Request, d *data, user *users.Use
return http.StatusInternalServerError, err return http.StatusInternalServerError, err
} }
w.Header().Set("Content-Type", "cty") w.Header().Set("Content-Type", "text/plain")
if _, err := w.Write([]byte(signed)); err != nil { if _, err := w.Write([]byte(signed)); err != nil {
return http.StatusInternalServerError, err return http.StatusInternalServerError, err
} }

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()
@@ -46,7 +46,7 @@ func NewHandler(store *storage.Storage, server *settings.Server) (http.Handler,
users.Handle("/{id:[0-9]+}", monkey(userDeleteHandler, "")).Methods("DELETE") users.Handle("/{id:[0-9]+}", monkey(userDeleteHandler, "")).Methods("DELETE")
api.PathPrefix("/resources").Handler(monkey(resourceGetHandler, "/api/resources")).Methods("GET") api.PathPrefix("/resources").Handler(monkey(resourceGetHandler, "/api/resources")).Methods("GET")
api.PathPrefix("/resources").Handler(monkey(resourceDeleteHandler, "/api/resources")).Methods("DELETE") api.PathPrefix("/resources").Handler(monkey(resourceDeleteHandler(fileCache), "/api/resources")).Methods("DELETE")
api.PathPrefix("/resources").Handler(monkey(resourcePostPutHandler, "/api/resources")).Methods("POST") api.PathPrefix("/resources").Handler(monkey(resourcePostPutHandler, "/api/resources")).Methods("POST")
api.PathPrefix("/resources").Handler(monkey(resourcePostPutHandler, "/api/resources")).Methods("PUT") api.PathPrefix("/resources").Handler(monkey(resourcePostPutHandler, "/api/resources")).Methods("PUT")
api.PathPrefix("/resources").Handler(monkey(resourcePatchHandler, "/api/resources")).Methods("PATCH") api.PathPrefix("/resources").Handler(monkey(resourcePatchHandler, "/api/resources")).Methods("PATCH")
@@ -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,76 +1,91 @@
//go:generate go-enum --sql --marshal --names --file $GOFILE
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 ( /*
sizeThumb = "thumb" ENUM(
sizeBig = "big" thumb
big
) )
*/
type PreviewSize int
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)
} Delete(ctx context.Context, key string) 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)
previewSize, err := ParsePreviewSize(vars["size"])
if err != nil {
return http.StatusBadRequest, err
}
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, previewSize, 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, previewSize PreviewSize, 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
} }
var imgProcessor imageProcessor cacheKey := previewCacheKey(file.Path, previewSize)
switch size { cachedFile, ok, err := fileCache.Load(r.Context(), cacheKey)
case sizeBig: if err != nil {
imgProcessor = func(img image.Image) (image.Image, error) { return errToStatus(err), err
return imaging.Fit(img, 1080, 1080, imaging.Lanczos), nil }
} if ok {
case sizeThumb: _, _ = w.Write(cachedFile)
imgProcessor = func(img image.Image) (image.Image, error) { return 0, nil
return imaging.Thumbnail(img, 128, 128, imaging.Box), nil
}
default:
return http.StatusBadRequest, fmt.Errorf("unsupported preview size %s", size)
} }
fd, err := file.Fs.Open(file.Path) fd, err := file.Fs.Open(file.Path)
@@ -79,16 +94,44 @@ func handleImagePreview(w http.ResponseWriter, r *http.Request, file *files.File
} }
defer fd.Close() defer fd.Close()
img, err := imaging.Decode(fd, imaging.AutoOrientation(true)) var (
if err != nil { width int
return errToStatus(err), err height int
options []img.Option
)
switch {
case previewSize == PreviewSizeBig && resizePreview && format != img.FormatGif:
width = 1080
height = 1080
options = append(options, img.WithMode(img.ResizeModeFit), img.WithQuality(img.QualityMedium))
case previewSize == PreviewSizeThumb && 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
} }
img, err = imgProcessor(img)
if err != nil { buf := &bytes.Buffer{}
return errToStatus(err), err if err := imgSvc.Resize(context.Background(), fd, width, height, buf, options...); err != nil {
} return 0, 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 return 0, nil
} }
func previewCacheKey(fPath string, previewSize PreviewSize) string {
return fPath + previewSize.String()
}

100
http/preview_enum.go Normal file
View File

@@ -0,0 +1,100 @@
// Code generated by go-enum
// DO NOT EDIT!
package http
import (
"database/sql/driver"
"fmt"
"strings"
)
const (
// PreviewSizeThumb is a PreviewSize of type Thumb
PreviewSizeThumb PreviewSize = iota
// PreviewSizeBig is a PreviewSize of type Big
PreviewSizeBig
)
const _PreviewSizeName = "thumbbig"
var _PreviewSizeNames = []string{
_PreviewSizeName[0:5],
_PreviewSizeName[5:8],
}
// PreviewSizeNames returns a list of possible string values of PreviewSize.
func PreviewSizeNames() []string {
tmp := make([]string, len(_PreviewSizeNames))
copy(tmp, _PreviewSizeNames)
return tmp
}
var _PreviewSizeMap = map[PreviewSize]string{
0: _PreviewSizeName[0:5],
1: _PreviewSizeName[5:8],
}
// String implements the Stringer interface.
func (x PreviewSize) String() string {
if str, ok := _PreviewSizeMap[x]; ok {
return str
}
return fmt.Sprintf("PreviewSize(%d)", x)
}
var _PreviewSizeValue = map[string]PreviewSize{
_PreviewSizeName[0:5]: 0,
_PreviewSizeName[5:8]: 1,
}
// ParsePreviewSize attempts to convert a string to a PreviewSize
func ParsePreviewSize(name string) (PreviewSize, error) {
if x, ok := _PreviewSizeValue[name]; ok {
return x, nil
}
return PreviewSize(0), fmt.Errorf("%s is not a valid PreviewSize, try [%s]", name, strings.Join(_PreviewSizeNames, ", "))
}
// MarshalText implements the text marshaller method
func (x PreviewSize) MarshalText() ([]byte, error) {
return []byte(x.String()), nil
}
// UnmarshalText implements the text unmarshaller method
func (x *PreviewSize) UnmarshalText(text []byte) error {
name := string(text)
tmp, err := ParsePreviewSize(name)
if err != nil {
return err
}
*x = tmp
return nil
}
// Scan implements the Scanner interface.
func (x *PreviewSize) Scan(value interface{}) error {
var name string
switch v := value.(type) {
case string:
name = v
case []byte:
name = string(v)
case nil:
*x = PreviewSize(0)
return nil
}
tmp, err := ParsePreviewSize(name)
if err != nil {
return err
}
*x = tmp
return nil
}
// Value implements the driver Valuer interface.
func (x PreviewSize) Value() (driver.Value, error) {
return x.String(), nil
}

View File

@@ -4,16 +4,23 @@ import (
"errors" "errors"
"net/http" "net/http"
"net/url" "net/url"
gopath "path"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/hacdias/fileutils"
"github.com/mholt/archiver" "github.com/mholt/archiver"
"github.com/filebrowser/filebrowser/v2/files" "github.com/filebrowser/filebrowser/v2/files"
"github.com/filebrowser/filebrowser/v2/users" "github.com/filebrowser/filebrowser/v2/users"
) )
func slashClean(name string) string {
if name == "" || name[0] != '/' {
name = "/" + name
}
return gopath.Clean(name)
}
func parseQueryFiles(r *http.Request, f *files.FileInfo, _ *users.User) ([]string, error) { func parseQueryFiles(r *http.Request, f *files.FileInfo, _ *users.User) ([]string, error) {
var fileSlice []string var fileSlice []string
names := strings.Split(r.URL.Query().Get("files"), ",") names := strings.Split(r.URL.Query().Get("files"), ",")
@@ -27,7 +34,7 @@ func parseQueryFiles(r *http.Request, f *files.FileInfo, _ *users.User) ([]strin
return nil, err return nil, err
} }
name = fileutils.SlashClean(name) name = slashClean(name)
fileSlice = append(fileSlice, filepath.Join(f.Path, name)) fileSlice = append(fileSlice, filepath.Join(f.Path, name))
} }
} }

View File

@@ -10,6 +10,8 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/spf13/afero"
"github.com/filebrowser/filebrowser/v2/errors" "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/files" "github.com/filebrowser/filebrowser/v2/files"
"github.com/filebrowser/filebrowser/v2/fileutils" "github.com/filebrowser/filebrowser/v2/fileutils"
@@ -48,21 +50,42 @@ var resourceGetHandler = withUser(func(w http.ResponseWriter, r *http.Request, d
return renderJSON(w, r, file) return renderJSON(w, r, file)
}) })
var resourceDeleteHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) { func resourceDeleteHandler(fileCache FileCache) handleFunc {
if r.URL.Path == "/" || !d.user.Perm.Delete { return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
return http.StatusForbidden, nil if r.URL.Path == "/" || !d.user.Perm.Delete {
} return http.StatusForbidden, nil
}
err := d.RunHook(func() error { file, err := files.NewFileInfo(files.FileOptions{
return d.user.Fs.RemoveAll(r.URL.Path) Fs: d.user.Fs,
}, "delete", r.URL.Path, "", d.user) Path: r.URL.Path,
Modify: d.user.Perm.Modify,
Expand: true,
Checker: d,
})
if err != nil {
return errToStatus(err), err
}
if err != nil { // delete thumbnails
return errToStatus(err), err for _, previewSizeName := range PreviewSizeNames() {
} size, _ := ParsePreviewSize(previewSizeName)
if err := fileCache.Delete(r.Context(), previewCacheKey(file.Path, size)); err != nil { //nolint:govet
return errToStatus(err), err
}
}
return http.StatusOK, nil err = d.RunHook(func() error {
}) return d.user.Fs.RemoveAll(r.URL.Path)
}, "delete", r.URL.Path, "", d.user)
if err != nil {
return errToStatus(err), err
}
return http.StatusOK, nil
})
}
var resourcePostPutHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) { var resourcePostPutHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
if !d.user.Perm.Create && r.Method == http.MethodPost { if !d.user.Perm.Create && r.Method == http.MethodPost {
@@ -127,6 +150,10 @@ var resourcePostPutHandler = withUser(func(w http.ResponseWriter, r *http.Reques
return nil return nil
}, action, r.URL.Path, "", d.user) }, action, r.URL.Path, "", d.user)
if err != nil {
_ = d.user.Fs.RemoveAll(r.URL.Path)
}
return errToStatus(err), err return errToStatus(err), err
}) })
@@ -135,14 +162,26 @@ var resourcePatchHandler = withUser(func(w http.ResponseWriter, r *http.Request,
dst := r.URL.Query().Get("destination") dst := r.URL.Query().Get("destination")
action := r.URL.Query().Get("action") action := r.URL.Query().Get("action")
dst, err := url.QueryUnescape(dst) dst, err := url.QueryUnescape(dst)
if err != nil { if err != nil {
return errToStatus(err), err return errToStatus(err), err
} }
if dst == "/" || src == "/" { if dst == "/" || src == "/" {
return http.StatusForbidden, nil return http.StatusForbidden, nil
} }
if err = checkParent(src, dst); err != nil {
return http.StatusBadRequest, err
}
override := r.URL.Query().Get("override") == "true"
rename := r.URL.Query().Get("rename") == "true"
if !override && !rename {
if _, err = d.user.Fs.Stat(dst); err == nil {
return http.StatusConflict, nil
}
}
if rename {
dst = addVersionSuffix(dst, d.user.Fs)
}
err = d.RunHook(func() error { err = d.RunHook(func() error {
switch action { switch action {
@@ -151,11 +190,14 @@ var resourcePatchHandler = withUser(func(w http.ResponseWriter, r *http.Request,
if !d.user.Perm.Create { if !d.user.Perm.Create {
return errors.ErrPermissionDenied return errors.ErrPermissionDenied
} }
return fileutils.Copy(d.user.Fs, src, dst) return fileutils.Copy(d.user.Fs, src, dst)
case "rename": case "rename":
if !d.user.Perm.Rename { if !d.user.Perm.Rename {
return errors.ErrPermissionDenied return errors.ErrPermissionDenied
} }
dst = filepath.Clean("/" + dst)
return d.user.Fs.Rename(src, dst) return d.user.Fs.Rename(src, dst)
default: default:
return fmt.Errorf("unsupported action %s: %w", action, errors.ErrInvalidRequestParams) return fmt.Errorf("unsupported action %s: %w", action, errors.ErrInvalidRequestParams)
@@ -164,3 +206,35 @@ var resourcePatchHandler = withUser(func(w http.ResponseWriter, r *http.Request,
return errToStatus(err), err return errToStatus(err), err
}) })
func checkParent(src, dst string) error {
rel, err := filepath.Rel(src, dst)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if !strings.HasPrefix(rel, "../") && rel != ".." && rel != "." {
return errors.ErrSourceIsParent
}
return nil
}
func addVersionSuffix(path string, fs afero.Fs) string {
counter := 1
dir, name := filepath.Split(path)
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
for {
if _, err := fs.Stat(path); err != nil {
break
}
renamed := fmt.Sprintf("%s(%d)%s", base, counter, ext)
path = filepath.ToSlash(dir) + renamed
counter++
}
return path
}

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.