Compare commits

...

64 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
Oleg Lobanov
de0b8bb7b2 chore(release): 2.3.0 2020-06-26 12:14:44 +02:00
Thomas Queste
84da110085 fix: typo in image_templates (apline -> alpine) (#1005) 2020-06-25 09:37:55 +02:00
monkeyWie
6b0d49b1fc feat: add image thumbnails support (#980)
* set max image preview size to 1080x1080px
2020-06-25 09:37:13 +02:00
Oleg Lobanov
4c20772e11 chore(release): 2.2.0 2020-06-22 19:12:12 +02:00
Oleg Lobanov
68f8348dde fix: apply all fs user rulles 2020-06-22 18:46:22 +02:00
Oleg Lobanov
5023e77296 Merge pull request #995 from ramiresviana/key-shortcuts 2020-06-22 13:48:56 +02:00
Ramires Viana
95316cbe8c feat: add key shortcuts
- 'Ctrl + a' selects all files in listing.
- 'Enter' to confirm a prompt.
2020-06-21 21:54:23 +00:00
Ramires Viana
cd454bae51 feat: upload progress based on total size (#993) 2020-06-19 09:46:33 +02:00
Oleg Lobanov
241201657c fix: add a workaround to fix window freezing when viewing a large file #992 2020-06-18 19:21:02 +02:00
Hampton
9eefaddd9b chore: fix documentation links on README (#987) 2020-06-18 17:54:37 +02:00
Oleg Lobanov
d6d47bbd6b Merge pull request #991 from ramiresviana/small-fixes 2020-06-18 09:59:27 +02:00
Ramires Viana
82c883f95e fix: save event hook
fix filebrowser/filebrowser#696
2020-06-17 22:57:13 +00:00
Ramires Viana
dd40b0d9b9 fix: frontend token validation
fix filebrowser/filebrowser#638
2020-06-17 22:57:07 +00:00
Ramires Viana
963837ef1d fix: multiple selection count
- Only add files to selected list that arent on it.
- Only shift key select when there are selected files.
2020-06-17 22:56:55 +00:00
Oleg Lobanov
66863b72f7 feat: add alpine and debian docker images 2020-06-16 23:18:22 +02:00
Ramires Viana
89773447a5 feat: add folder upload (#981)
* feat: folder upload
fix filebrowser/filebrowser#741

* fix: apply gofmt formater

* feat: upload button prompt

* feat: empty folder upload
2020-06-16 21:56:44 +02:00
62 changed files with 2736 additions and 339 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

@@ -79,3 +79,29 @@ dockers:
- "filebrowser/filebrowser:v{{ .Major }}-pi" - "filebrowser/filebrowser:v{{ .Major }}-pi"
extra_files: extra_files:
- .docker.json - .docker.json
-
dockerfile: Dockerfile.alpine
binaries:
- filebrowser
goos: linux
goarch: amd64
goarm: ''
image_templates:
- "filebrowser/filebrowser:alpine"
- "filebrowser/filebrowser:{{ .Tag }}-alpine"
- "filebrowser/filebrowser:v{{ .Major }}-alpine"
extra_files:
- .docker.json
-
dockerfile: Dockerfile.debian
binaries:
- filebrowser
goos: linux
goarch: amd64
goarm: ''
image_templates:
- "filebrowser/filebrowser:debian"
- "filebrowser/filebrowser:{{ .Tag }}-debian"
- "filebrowser/filebrowser:v{{ .Major }}-debian"
extra_files:
- .docker.json

97
CHANGELOG.md Normal file
View File

@@ -0,0 +1,97 @@
# Changelog
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)
### Features
* add image thumbnails support ([#980](https://github.com/filebrowser/filebrowser/issues/980)) ([6b0d49b](https://github.com/filebrowser/filebrowser/commit/6b0d49b1fc8bdce89576ba91cc0b8ec594fcd625))
### Bug Fixes
* typo in image_templates (apline -> alpine) ([#1005](https://github.com/filebrowser/filebrowser/issues/1005)) ([84da110](https://github.com/filebrowser/filebrowser/commit/84da11008516a371fc0446d97863dc14d337aa25))
## [2.2.0](https://github.com/filebrowser/filebrowser/compare/v2.1.2...v2.2.0) (2020-06-22)
### Features
* add alpine and debian docker images ([66863b7](https://github.com/filebrowser/filebrowser/commit/66863b72f7664e6cb9417f7da542a92fa77ca635))
* add folder upload ([#981](https://github.com/filebrowser/filebrowser/issues/981)) ([8977344](https://github.com/filebrowser/filebrowser/commit/89773447a56675b298394149d7a05c5df4039f14)), closes [filebrowser/filebrowser#741](https://github.com/filebrowser/filebrowser/issues/741)
* add key shortcuts ([95316cb](https://github.com/filebrowser/filebrowser/commit/95316cbe8c8ac3dbb28310bc11ec347c0caf699b))
* upload progress based on total size ([#993](https://github.com/filebrowser/filebrowser/issues/993)) ([cd454ba](https://github.com/filebrowser/filebrowser/commit/cd454bae51f40b1249e6fa6133c2949970eb3018))
### Bug Fixes
* add a workaround to fix window freezing when viewing a large file [#992](https://github.com/filebrowser/filebrowser/issues/992) ([2412016](https://github.com/filebrowser/filebrowser/commit/241201657c2bf01806d02a297eb846b26102a479))
* apply all fs user rulles ([68f8348](https://github.com/filebrowser/filebrowser/commit/68f8348ddeecba570a361e7aba4546052cc3e356))
* frontend token validation ([dd40b0d](https://github.com/filebrowser/filebrowser/commit/dd40b0d9b9cc6268a611306ac4684a1af852b79d)), closes [filebrowser/filebrowser#638](https://github.com/filebrowser/filebrowser/issues/638)
* multiple selection count ([963837e](https://github.com/filebrowser/filebrowser/commit/963837ef1dc6e2e84fcf924606ce388ac30f3891))
* save event hook ([82c883f](https://github.com/filebrowser/filebrowser/commit/82c883f95eead9eebe215e230f74773c945f864a)), closes [filebrowser/filebrowser#696](https://github.com/filebrowser/filebrowser/issues/696)

11
Dockerfile.alpine Normal file
View File

@@ -0,0 +1,11 @@
FROM alpine:latest as alpine
RUN apk --update add ca-certificates
RUN apk --update add mailcap
VOLUME /srv
EXPOSE 80
COPY .docker.json /.filebrowser.json
COPY filebrowser /filebrowser
ENTRYPOINT [ "/filebrowser" ]

9
Dockerfile.debian Normal file
View File

@@ -0,0 +1,9 @@
FROM debian:buster
VOLUME /srv
EXPOSE 80
COPY .docker.json /.filebrowser.json
COPY filebrowser /filebrowser
ENTRYPOINT [ "/filebrowser" ]

View File

@@ -14,16 +14,20 @@ filebrowser provides a file managing interface within a specified directory and
## Features ## Features
Please refer to our docs at [filebrowser.xyz/features](https://github.com/filebrowser/docs/tree/master/features) Please refer to our docs at [https://filebrowser.org/features](https://filebrowser.org/features)
## Install ## Install
Please refer to our docs at [filebrowser.xyz](https://github.com/filebrowser/docs/tree/master/). For installation instructions please refer to our docs at [https://filebrowser.org/installation](https://filebrowser.org/installation).
## Usage ## Configuration
Please refer to our docs at [filebrowser.xyz/usage](https://github.com/filebrowser/docs/tree/master/usage). [Authentication Method](https://filebrowser.org/configuration/authentication-method) - You can change the way the user authenticates with the filebrowser server
[Commander Runner](https://filebrowser.org/configuration/command-runner) - The command runner is a feature that enables you to execute any shell command you want before or after a certain event.
[Custom Branding](https://filebrowser.org/configuration/custom-branding) - You can customize your File Browser installation by change its name to any other you want, by adding a global custom style sheet and by using your own logotype if you want.
## Contributing ## Contributing
Please refer to our docs at [filebrowser.xyz/contributing](https://github.com/filebrowser/docs/tree/master/contributing). If you're interested in contributing to this project, our docs are best places to start [https://filebrowser.org/contributing](https://filebrowser.org/contributing).

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

@@ -8802,6 +8802,11 @@
"integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=", "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=",
"dev": true "dev": true
}, },
"lodash.throttle": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
"integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ="
},
"lodash.transform": { "lodash.transform": {
"version": "4.6.0", "version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.transform/-/lodash.transform-4.6.0.tgz", "resolved": "https://registry.npmjs.org/lodash.transform/-/lodash.transform-4.6.0.tgz",
@@ -13025,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

@@ -13,6 +13,7 @@
"clipboard": "^2.0.4", "clipboard": "^2.0.4",
"js-base64": "^2.5.1", "js-base64": "^2.5.1",
"lodash.clonedeep": "^4.5.0", "lodash.clonedeep": "^4.5.0",
"lodash.throttle": "^4.1.1",
"material-design-icons": "^3.0.1", "material-design-icons": "^3.0.1",
"moment": "^2.24.0", "moment": "^2.24.0",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
@@ -20,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

@@ -10,7 +10,11 @@ export default {
name: 'upload-button', name: 'upload-button',
methods: { methods: {
upload: function () { upload: function () {
document.getElementById('upload-input').click() if (typeof(DataTransferItem.prototype.webkitGetAsEntry) !== 'undefined') {
this.$store.commit('showHover', 'upload')
} else {
document.getElementById('upload-input').click();
}
} }
} }
} }

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: Infinity,
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

@@ -5,11 +5,10 @@
<span>{{ $t('files.lonely') }}</span> <span>{{ $t('files.lonely') }}</span>
</h2> </h2>
<input style="display:none" type="file" id="upload-input" @change="uploadInput($event)" multiple> <input style="display:none" type="file" id="upload-input" @change="uploadInput($event)" 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>
@@ -75,6 +74,7 @@
</div> </div>
<input style="display:none" type="file" id="upload-input" @change="uploadInput($event)" multiple> <input style="display:none" type="file" id="upload-input" @change="uploadInput($event)" multiple>
<input style="display:none" type="file" id="upload-folder-input" @change="uploadInput($event)" webkitdirectory multiple>
<div :class="{ active: $store.state.multiple }" id="multiple-selection"> <div :class="{ active: $store.state.multiple }" id="multiple-selection">
<p>{{ $t('files.multipleSelectionEnabled') }}</p> <p>{{ $t('files.multipleSelectionEnabled') }}</p>
@@ -90,19 +90,19 @@ import { mapState, mapMutations } from 'vuex'
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,
dragCounter: 0
} }
}, },
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')
}, },
@@ -130,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) {
@@ -170,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 () {
@@ -178,14 +180,20 @@ 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: {
...mapMutations([ 'updateUser' ]), ...mapMutations([ 'updateUser', 'addSelected' ]),
base64: function (name) { base64: function (name) {
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
} }
@@ -204,6 +212,19 @@ export default {
case 'v': case 'v':
this.paste(event) this.paste(event)
break break
case 'a':
event.preventDefault()
for (let file of this.items.files) {
if (this.$store.state.selected.indexOf(file.index) === -1) {
this.addSelected(file.index)
}
}
for (let dir of this.items.dirs) {
if (this.$store.state.selected.indexOf(dir.index) === -1) {
this.addSelected(dir.index)
}
}
break
} }
}, },
preventDefault (event) { preventDefault (event) {
@@ -230,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) {
@@ -243,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.
@@ -270,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')
@@ -282,18 +339,22 @@ 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
let files = dt.files
let el = event.target let el = event.target
if (files.length <= 0) return if (dt.files.length <= 0) return
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
if (el !== null && !el.classList.contains('item')) { if (el !== null && !el.classList.contains('item')) {
@@ -306,51 +367,65 @@ export default {
base = el.querySelector('.name').innerHTML + '/' base = el.querySelector('.name').innerHTML + '/'
} }
let files = await upload.scanFiles(dt)
let path = this.$route.path + base
let items = this.req.items
if (base !== '') { if (base !== '') {
api.fetch(this.$route.path + base) try {
.then(req => { items = (await api.fetch(path)).items
this.checkConflict(files, req.items, base) } catch (error) {
}) this.$showError(error)
.catch(this.$showError) }
}
let conflict = upload.checkConflict(files, items)
if (conflict) {
this.$store.commit('showHover', {
prompt: 'replace',
confirm: (event) => {
event.preventDefault()
this.$store.commit('closeHovers')
upload.handleFiles(files, path, true)
}
})
return return
} }
this.checkConflict(files, this.req.items, base) upload.handleFiles(files, path)
},
checkConflict (files, items, base) {
if (typeof items === 'undefined' || items === null) {
items = []
}
let conflict = false
for (let i = 0; i < files.length; i++) {
let res = items.findIndex(function hasConflict (element) {
return (element.name === this)
}, files[i].name)
if (res >= 0) {
conflict = true
break
}
}
if (!conflict) {
this.handleFiles(files, base)
return
}
this.$store.commit('showHover', {
prompt: 'replace',
confirm: (event) => {
event.preventDefault()
this.$store.commit('closeHovers')
this.handleFiles(files, base, true)
}
})
}, },
uploadInput (event) { uploadInput (event) {
this.checkConflict(event.currentTarget.files, this.req.items, '') this.$store.commit('closeHovers')
let files = event.currentTarget.files
let folder_upload = files[0].webkitRelativePath !== undefined && files[0].webkitRelativePath !== ''
if (folder_upload) {
for (let i = 0; i < files.length; i++) {
let file = files[i]
files[i].fullPath = file.webkitRelativePath
}
}
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')
@@ -359,45 +434,6 @@ export default {
file.style.opacity = 1 file.style.opacity = 1
}) })
}, },
handleFiles (files, base, overwrite = false) {
buttons.loading('upload')
let promises = []
let progress = new Array(files.length).fill(0)
let onupload = (id) => (event) => {
progress[id] = (event.loaded / event.total) * 100
let sum = 0
for (let i = 0; i < progress.length; i++) {
sum += progress[i]
}
this.$store.commit('setProgress', Math.ceil(sum / progress.length))
}
for (let i = 0; i < files.length; i++) {
let file = files[i]
let filenameEncoded = url.encodeRFC5987ValueChars(file.name)
promises.push(api.post(this.$route.path + base + filenameEncoded, file, overwrite, onupload(i)))
}
let finish = () => {
buttons.success('upload')
this.$store.commit('setProgress', 0)
}
Promise.all(promises)
.then(() => {
finish()
this.$store.commit('setReload', true)
})
.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,8 @@
:aria-label="name" :aria-label="name"
:aria-selected="isSelected"> :aria-selected="isSelected">
<div> <div>
<i class="material-icons">{{ icon }}</i> <img v-if="type==='image' && isThumbsEnabled" v-lazy="thumbnailUrl">
<i v-else class="material-icons">{{ icon }}</i>
</div> </div>
<div> <div>
@@ -30,10 +31,12 @@
</template> </template>
<script> <script>
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',
@@ -44,7 +47,7 @@ export default {
}, },
props: ['name', 'isDir', 'url', 'type', 'size', 'modified', 'index'], props: ['name', 'isDir', 'url', 'type', 'size', 'modified', 'index'],
computed: { computed: {
...mapState(['selected', 'req', 'user']), ...mapState(['selected', 'req', 'user', 'jwt']),
...mapGetters(['selectedCount']), ...mapGetters(['selectedCount']),
isSelected () { isSelected () {
return (this.selected.indexOf(this.index) !== -1) return (this.selected.indexOf(this.index) !== -1)
@@ -69,6 +72,13 @@ export default {
} }
return true return true
},
thumbnailUrl () {
const path = this.url.replace(/^\/files\//, '')
return `${baseURL}/api/preview/thumb/${path}?auth=${this.jwt}&inline=true`
},
isThumbsEnabled () {
return enableThumbs
} }
}, },
methods: { methods: {
@@ -104,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
}) })
}
let base = el.querySelector('.name').innerHTML + '/'
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)
}).catch(this.$showError)
} }
api.move(items) let conflict = upload.checkConflict(items, baseItems)
.then(() => {
this.$store.commit('setReload', true) 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()
@@ -132,7 +177,7 @@ export default {
return return
} }
if (event.shiftKey) { if (event.shiftKey && this.selected.length > 0) {
let fi = 0 let fi = 0
let la = 0 let la = 0
@@ -145,7 +190,9 @@ export default {
} }
for (; fi <= la; fi++) { for (; fi <= la; fi++) {
this.addSelected(fi) if (this.$store.state.selected.indexOf(fi) == -1) {
this.addSelected(fi)
}
} }
return return
@@ -169,4 +216,4 @@ export default {
} }
} }
} }
</script> </script>

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,36 +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 () {
if (this.req.type === 'image') {
return `${baseURL}/api/preview/big${url.encodePath(this.req.path)}?auth=${this.jwt}`
}
return `${baseURL}/api/raw${url.encodePath(this.req.path)}?auth=${this.jwt}`
}, },
raw () { raw () {
return `${this.download}&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 })
}, },
@@ -132,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

@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<component :is="currentComponent"></component> <component ref="currentComponent" :is="currentComponent"></component>
<div v-show="showOverlay" @click="resetPrompts" class="overlay"></div> <div v-show="showOverlay" @click="resetPrompts" class="overlay"></div>
</div> </div>
</template> </template>
@@ -16,7 +16,9 @@ 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 { mapState } from 'vuex' import { mapState } from 'vuex'
import buttons from '@/utils/buttons' import buttons from '@/utils/buttons'
@@ -33,7 +35,9 @@ export default {
NewFile, NewFile,
NewDir, NewDir,
Help, Help,
Replace Replace,
ReplaceRename,
Upload
}, },
data: function () { data: function () {
return { return {
@@ -44,6 +48,33 @@ export default {
} }
} }
}, },
created () {
window.addEventListener('keydown', (event) => {
if (this.show == null)
return
let prompt = this.$refs.currentComponent;
// Enter
if (event.keyCode == 13) {
switch (this.show) {
case 'delete':
prompt.submit()
break;
case 'copy':
prompt.copy(event)
break;
case 'move':
prompt.move(event)
break;
case 'replace':
prompt.showConfirm(event)
break;
}
}
})
},
computed: { computed: {
...mapState(['show', 'plugins']), ...mapState(['show', 'plugins']),
currentComponent: function () { currentComponent: function () {
@@ -58,7 +89,9 @@ export default {
'newDir', 'newDir',
'download', 'download',
'replace', 'replace',
'share' 'replace-rename',
'share',
'upload'
].indexOf(this.show) >= 0; ].indexOf(this.show) >= 0;
return matched && this.show || null; return matched && this.show || null;

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

@@ -0,0 +1,39 @@
<template>
<div class="card floating">
<div class="card-title">
<h2>{{ $t('prompts.upload') }}</h2>
</div>
<div class="card-content">
<p>{{ $t('prompts.uploadMessage') }}</p>
</div>
<div class="card-action full">
<div @click="uploadFile" class="action">
<i class="material-icons">insert_drive_file</i>
<div class="title">File</div>
</div>
<div @click="uploadFolder" class="action">
<i class="material-icons">folder</i>
<div class="title">Folder</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'upload',
methods: {
uploadFile: function () {
document.getElementById('upload-input').value = ''
document.getElementById('upload-input').click()
},
uploadFolder: function () {
document.getElementById('upload-folder-input').value = ''
document.getElementById('upload-folder-input').click()
}
}
}
</script>

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

@@ -96,6 +96,7 @@ table tr>*:last-child {
background-color: #fff; background-color: #fff;
border-radius: 2px; border-radius: 2px;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12), 0 3px 1px -2px rgba(0, 0, 0, 0.2); box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12), 0 3px 1px -2px rgba(0, 0, 0, 0.2);
overflow: auto;
} }
.card.floating { .card.floating {
@@ -366,3 +367,33 @@ table tr>*:last-child {
.card .collapsible .collapse { .card .collapsible .collapse {
padding: 0 1em; padding: 0 1em;
} }
.card .card-action.full {
padding-top: 0;
display: flex;
flex-wrap: wrap;
}
.card .card-action.full .action {
flex: 1;
padding: 2em;
border-radius: 0.2em;
border: 1px solid rgba(0, 0, 0, 0.1);
text-align: center;
}
.card .card-action.full .action {
margin: 0 0.25em 0.50em;
}
.card .card-action.full .action i {
display: block;
padding: 0;
margin-bottom: 0.25em;
font-size: 4em;
}
.card .card-action.full .action .title {
font-size: 1.5em;
font-weight: 500;
}

View File

@@ -52,6 +52,13 @@
vertical-align: bottom; vertical-align: bottom;
} }
#listing .item img {
width: 4em;
height: 4em;
margin-right: 0.1em;
vertical-align: bottom;
}
.message { .message {
text-align: center; text-align: center;
font-size: 2em; font-size: 2em;
@@ -129,6 +136,11 @@
font-size: 2em; font-size: 2em;
} }
#listing.list .item div:first-of-type img {
width: 2em;
height: 2em;
}
#listing.list .item div:last-of-type { #listing.list .item div:last-of-type {
width: calc(100% - 3em); width: calc(100% - 3em);
display: flex; display: flex;

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

@@ -116,7 +116,9 @@
"size": "Size", "size": "Size",
"schedule": "Schedule", "schedule": "Schedule",
"scheduleMessage": "Pick a date and time to schedule the publication of this post.", "scheduleMessage": "Pick a date and time to schedule the publication of this post.",
"newArchetype": "Create a new post based on an archetype. Your file will be created on content folder." "newArchetype": "Create a new post based on an archetype. Your file will be created on content folder.",
"upload": "Upload",
"uploadMessage": "Select an option to upload."
}, },
"settings": { "settings": {
"themes": { "themes": {

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

@@ -12,10 +12,6 @@ export function parseToken (token) {
const data = JSON.parse(Base64.decode(parts[1])) const data = JSON.parse(Base64.decode(parts[1]))
if (Math.round(new Date().getTime() / 1000) > data.exp) {
throw new Error('token expired')
}
localStorage.setItem('jwt', token) localStorage.setItem('jwt', token)
store.commit('setJWT', token) store.commit('setJWT', token)
store.commit('setUser', data.user) store.commit('setUser', data.user)

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) {
// Esc! if (this.show !== null) {
if (event.keyCode === 27) { // Esc!
this.$store.commit('closeHovers') if (event.keyCode === 27) {
this.$store.commit('closeHovers')
}
return
}
// Esc!
if (event.keyCode === 27) {
// 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,
} }

5
go.mod
View File

@@ -8,12 +8,13 @@ require (
github.com/caddyserver/caddy v1.0.3 github.com/caddyserver/caddy v1.0.3
github.com/daaku/go.zipexe v1.0.1 // indirect github.com/daaku/go.zipexe v1.0.1 // indirect
github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/disintegration/imaging v1.6.2
github.com/dsnet/compress v0.0.1 // indirect github.com/dsnet/compress v0.0.1 // indirect
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
@@ -24,11 +25,13 @@ require (
github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.6.1 github.com/spf13/viper v1.6.1
github.com/stretchr/testify v1.6.1
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce
github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
go.etcd.io/bbolt v1.3.3 go.etcd.io/bbolt v1.3.3
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8
golang.org/x/net v0.0.0-20200528225125-3c3fba18258b // indirect golang.org/x/net v0.0.0-20200528225125-3c3fba18258b // indirect
golang.org/x/sys v0.0.0-20200523222454-059865788121 // indirect golang.org/x/sys v0.0.0-20200523222454-059865788121 // indirect
golang.org/x/text v0.3.2 // indirect golang.org/x/text v0.3.2 // indirect

12
go.sum
View File

@@ -43,6 +43,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q= github.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q=
github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo= github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
@@ -85,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=
@@ -127,6 +127,8 @@ github.com/marten-seemann/qtls v0.2.3 h1:0yWJ43C62LsZt08vuQJDK1uC1czUc3FJeCLPoNA
github.com/marten-seemann/qtls v0.2.3/go.mod h1:xzjG7avBwGGbdZ8dTGxlBnLArsVKLvwmjgmPuiQEcYk= github.com/marten-seemann/qtls v0.2.3/go.mod h1:xzjG7avBwGGbdZ8dTGxlBnLArsVKLvwmjgmPuiQEcYk=
github.com/maruel/natural v0.0.0-20180416170133-dbcb3e2e8cf1 h1:PEhRT94KBTY4E0KdCYmhvDGWjSFBxc68j2M6PMRix8U= github.com/maruel/natural v0.0.0-20180416170133-dbcb3e2e8cf1 h1:PEhRT94KBTY4E0KdCYmhvDGWjSFBxc68j2M6PMRix8U=
github.com/maruel/natural v0.0.0-20180416170133-dbcb3e2e8cf1/go.mod h1:wI697HNhDFM/vBruYM3ckbszQ2+DOIeH9qdBKMdf288= github.com/maruel/natural v0.0.0-20180416170133-dbcb3e2e8cf1/go.mod h1:wI697HNhDFM/vBruYM3ckbszQ2+DOIeH9qdBKMdf288=
github.com/marusama/semaphore/v2 v2.4.1 h1:Y29DhhFMvreVgoqF9EtaSJAF9t2E7Sk7i5VW81sqB8I=
github.com/marusama/semaphore/v2 v2.4.1/go.mod h1:z9nMiNUekt/LTpTUQdpp+4sJeYqUGpwMHfW0Z8V8fnQ=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mholt/archiver v3.1.1+incompatible h1:1dCVxuqs0dJseYEhi5pl7MYPH9zDa1wBi7mF09cbNkU= github.com/mholt/archiver v3.1.1+incompatible h1:1dCVxuqs0dJseYEhi5pl7MYPH9zDa1wBi7mF09cbNkU=
github.com/mholt/archiver v3.1.1+incompatible/go.mod h1:Dh2dOXnSdiLxRiPoVfIr/fI1TwETms9B8CTWfeh7ROU= github.com/mholt/archiver v3.1.1+incompatible/go.mod h1:Dh2dOXnSdiLxRiPoVfIr/fI1TwETms9B8CTWfeh7ROU=
@@ -204,6 +206,8 @@ github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
@@ -239,6 +243,8 @@ golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 h1:cg5LA/zNPRzIXIWSCxQW10Rvpy94aQh3LT/ShoCpkHw= golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 h1:cg5LA/zNPRzIXIWSCxQW10Rvpy94aQh3LT/ShoCpkHw=
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225 h1:kNX+jCowfMYzvlSvJu5pQWEmyWFrBXJ3PBy10xKMXK8= golang.org/x/net v0.0.0-20180724234803-3673e40ba225 h1:kNX+jCowfMYzvlSvJu5pQWEmyWFrBXJ3PBy10xKMXK8=
@@ -314,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

@@ -26,19 +26,20 @@ type data struct {
// Check implements rules.Checker. // Check implements rules.Checker.
func (d *data) Check(path string) bool { func (d *data) Check(path string) bool {
for _, rule := range d.user.Rules { allow := true
if rule.Matches(path) {
return rule.Allow
}
}
for _, rule := range d.settings.Rules { for _, rule := range d.settings.Rules {
if rule.Matches(path) { if rule.Matches(path) {
return rule.Allow allow = rule.Allow
} }
} }
return true for _, rule := range d.user.Rules {
if rule.Matches(path) {
allow = rule.Allow
}
}
return allow
} }
func handle(fn handleFunc, prefix string, store *storage.Storage, server *settings.Server) http.Handler { func handle(fn handleFunc, prefix string, store *storage.Storage, server *settings.Server) http.Handler {

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,6 +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(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")

137
http/preview.go Normal file
View File

@@ -0,0 +1,137 @@
//go:generate go-enum --sql --marshal --names --file $GOFILE
package http
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"github.com/gorilla/mux"
"github.com/filebrowser/filebrowser/v2/files"
"github.com/filebrowser/filebrowser/v2/img"
)
/*
ENUM(
thumb
big
)
*/
type PreviewSize int
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
}
type FileCache interface {
Store(ctx context.Context, key string, value []byte) error
Load(ctx context.Context, key string) ([]byte, bool, error)
Delete(ctx context.Context, key string) error
}
func previewHandler(imgSvc ImgService, fileCache FileCache, enableThumbnails, resizePreview bool) handleFunc {
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
if !d.user.Perm.Download {
return http.StatusAccepted, nil
}
vars := mux.Vars(r)
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)
}
})
}
func handleImagePreview(w http.ResponseWriter, r *http.Request, imgSvc ImgService, fileCache FileCache,
file *files.FileInfo, previewSize PreviewSize, enableThumbnails, resizePreview bool) (int, error) {
format, err := imgSvc.FormatFromExtension(file.Extension)
if err != nil {
// Unsupported extensions directly return the raw data
if err == img.ErrUnsupportedFormat {
return rawFileHandler(w, r, file)
}
return errToStatus(err), err
}
cacheKey := previewCacheKey(file.Path, previewSize)
cachedFile, ok, err := fileCache.Load(r.Context(), cacheKey)
if err != nil {
return errToStatus(err), err
}
if ok {
_, _ = w.Write(cachedFile)
return 0, nil
}
fd, err := file.Fs.Open(file.Path)
if err != nil {
return errToStatus(err), err
}
defer fd.Close()
var (
width int
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
}
buf := &bytes.Buffer{}
if err := imgSvc.Resize(context.Background(), fd, width, height, buf, options...); err != nil {
return 0, 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
}
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))
} }
} }
@@ -58,6 +65,15 @@ func parseQueryAlgorithm(r *http.Request) (string, archiver.Writer, error) {
} }
} }
func setContentDisposition(w http.ResponseWriter, r *http.Request, file *files.FileInfo) {
if r.URL.Query().Get("inline") == "true" {
w.Header().Set("Content-Disposition", "inline")
} else {
// As per RFC6266 section 4.3
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(file.Name))
}
}
var rawHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) { var rawHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
if !d.user.Perm.Download { if !d.user.Perm.Download {
return http.StatusAccepted, nil return http.StatusAccepted, nil
@@ -168,12 +184,7 @@ func rawFileHandler(w http.ResponseWriter, r *http.Request, file *files.FileInfo
} }
defer fd.Close() defer fd.Close()
if r.URL.Query().Get("inline") == "true" { setContentDisposition(w, r, file)
w.Header().Set("Content-Disposition", "inline")
} else {
// As per RFC6266 section 4.3
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(file.Name))
}
http.ServeContent(w, r, file.Name, file.ModTime, fd) http.ServeContent(w, r, file.Name, file.ModTime, fd)
return 0, nil return 0, nil

View File

@@ -7,8 +7,11 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"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"
@@ -47,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 {
@@ -92,7 +116,18 @@ var resourcePostPutHandler = withUser(func(w http.ResponseWriter, r *http.Reques
} }
} }
action := "upload"
if r.Method == http.MethodPut {
action = "save"
}
err := d.RunHook(func() error { err := d.RunHook(func() error {
dir, _ := filepath.Split(r.URL.Path)
err := d.user.Fs.MkdirAll(dir, 0775)
if err != nil {
return err
}
file, err := d.user.Fs.OpenFile(r.URL.Path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0775) file, err := d.user.Fs.OpenFile(r.URL.Path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0775)
if err != nil { if err != nil {
return err return err
@@ -113,7 +148,11 @@ var resourcePostPutHandler = withUser(func(w http.ResponseWriter, r *http.Reques
etag := fmt.Sprintf(`"%x%x"`, info.ModTime().UnixNano(), info.Size()) etag := fmt.Sprintf(`"%x%x"`, info.ModTime().UnixNano(), info.Size())
w.Header().Set("ETag", etag) w.Header().Set("ETag", etag)
return nil return nil
}, "upload", 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
}) })
@@ -123,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 {
@@ -139,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)
@@ -152,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.