Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c746c1931d | ||
|
|
586d198d47 | ||
|
|
9515ceeb42 | ||
|
|
e8b4e9af46 | ||
|
|
10e399b3c3 | ||
|
|
dcbc3286e2 | ||
|
|
b185f9b56e | ||
|
|
7096b3dab9 | ||
|
|
36cacdf598 | ||
|
|
4e48ffc14d | ||
|
|
e119bc55ea | ||
|
|
1ce3068a99 | ||
|
|
d562d1a60d | ||
|
|
9f858398ab | ||
|
|
0ac80e8387 | ||
|
|
0dca0b92d1 | ||
|
|
c9b36ba32e | ||
|
|
f2c4e78381 | ||
|
|
05bff54b71 | ||
|
|
2bd163d92a | ||
|
|
5e27ba5c8c | ||
|
|
5aaeb3b76d | ||
|
|
36fb9f562a | ||
|
|
ad99bf1801 | ||
|
|
4c2a094255 | ||
|
|
97693cc611 | ||
|
|
c6d4fcd08f | ||
|
|
dd7b9ddd85 | ||
|
|
26d62e4117 | ||
|
|
babd7783af | ||
|
|
1529e796df | ||
|
|
d4b904b92b | ||
|
|
12d4177823 | ||
|
|
8142b32f38 | ||
|
|
c5abbb4e1c | ||
|
|
65ac73414f | ||
|
|
ede4213c8e | ||
|
|
b60d291490 | ||
|
|
b9ede79888 | ||
|
|
3d2cb838d1 | ||
|
|
778734419d | ||
|
|
be8683f556 | ||
|
|
c3450f4614 | ||
|
|
5881bc9ab0 | ||
|
|
a2fb499a20 | ||
|
|
411a928fea | ||
|
|
f5d02cdde9 | ||
|
|
c9340af8d0 | ||
|
|
a722bcc13f | ||
|
|
77fe3cfc60 | ||
|
|
470f93cefc | ||
|
|
92fde4dd12 | ||
|
|
95bc92955f | ||
|
|
f2f914221c | ||
|
|
c2d8038c63 | ||
|
|
cb8ac5ebf1 | ||
|
|
aa78e3ab1f | ||
|
|
bc00165094 | ||
|
|
94ef59602f | ||
|
|
14e2f84ceb | ||
|
|
f228fa5540 | ||
|
|
f2d2c1cbf8 | ||
|
|
d9be370e24 | ||
|
|
727c63b98e | ||
|
|
34dfb49b71 | ||
|
|
0b0a704d44 | ||
|
|
2d99d0bf13 | ||
|
|
1790df2090 | ||
|
|
10570ade44 | ||
|
|
43526d9d1a | ||
|
|
2636f876ab | ||
|
|
eed9da1471 | ||
|
|
9a2ebbabe2 | ||
|
|
716396a726 | ||
|
|
0727496601 | ||
|
|
194030fcfc | ||
|
|
b3b644527d | ||
|
|
7e5beeff46 | ||
|
|
a47b69bcec | ||
|
|
6ec6a23861 | ||
|
|
c9cc0d3d5d | ||
|
|
28d2b35718 | ||
|
|
b4f131be50 | ||
|
|
d0b359561f | ||
|
|
453636dfe2 | ||
|
|
b1605aa6d3 | ||
|
|
23503b80a4 | ||
|
|
0d69fbd9a3 | ||
|
|
0d665e528f |
@@ -21,9 +21,17 @@ jobs:
|
||||
root: .
|
||||
paths:
|
||||
- '*'
|
||||
test:
|
||||
docker:
|
||||
- image: circleci/golang:1.15.2
|
||||
steps:
|
||||
- checkout
|
||||
- run:
|
||||
name: "Test"
|
||||
command: go test ./...
|
||||
build-go:
|
||||
docker:
|
||||
- image: circleci/golang:1.14.3
|
||||
- image: circleci/golang:1.15.2
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: '~/project'
|
||||
@@ -41,12 +49,12 @@ jobs:
|
||||
- '*'
|
||||
release:
|
||||
docker:
|
||||
- image: circleci/golang:1.14.3
|
||||
- image: circleci/golang:1.15.2
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: '~/project'
|
||||
- setup_remote_docker
|
||||
- run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD
|
||||
- run: echo $DOCKER_PASSWORD | docker login --username $DOCKER_USERNAME --password-stdin
|
||||
- run: curl -sL https://git.io/goreleaser | bash
|
||||
- run: docker logout
|
||||
workflows:
|
||||
@@ -57,6 +65,10 @@ workflows:
|
||||
filters:
|
||||
tags:
|
||||
only: /.*/
|
||||
- test:
|
||||
filters:
|
||||
tags:
|
||||
only: /.*/
|
||||
- build-node:
|
||||
filters:
|
||||
tags:
|
||||
@@ -68,6 +80,7 @@ workflows:
|
||||
requires:
|
||||
- build-node
|
||||
- lint
|
||||
- test
|
||||
- release:
|
||||
context: deploy
|
||||
requires:
|
||||
|
||||
124
CHANGELOG.md
124
CHANGELOG.md
@@ -2,6 +2,130 @@
|
||||
|
||||
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.10.0](https://github.com/filebrowser/filebrowser/compare/v2.9.0...v2.10.0) (2020-11-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add hide dotfiles param ([#1148](https://github.com/filebrowser/filebrowser/issues/1148)) ([10e399b](https://github.com/filebrowser/filebrowser/commit/10e399b3c3dbdcfb4465a9d4138e1da6bae0873d))
|
||||
* add single click mode ([#1139](https://github.com/filebrowser/filebrowser/issues/1139)) ([e8b4e9a](https://github.com/filebrowser/filebrowser/commit/e8b4e9af46d6e99dbeb965dd9727d9ed017d52a2))
|
||||
* automatically jump to the next photo when deleting while previewing ([#1143](https://github.com/filebrowser/filebrowser/issues/1143)) ([9515cee](https://github.com/filebrowser/filebrowser/commit/9515ceeb42e5ef5267400220a2082dec775e843d))
|
||||
* shared folder file listing ([e119bc5](https://github.com/filebrowser/filebrowser/commit/e119bc55ea82cefcbcc0571650107dfd5d73f570))
|
||||
* shared item information ([36cacdf](https://github.com/filebrowser/filebrowser/commit/36cacdf598e4e09f064c8ace0ca7a6c24b23028e))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* empty folder in archive ([7096b3d](https://github.com/filebrowser/filebrowser/commit/7096b3dab92441981c9964e4a6175af0a255d2be))
|
||||
* fix hanging when reading a named pipe file (closes [#1155](https://github.com/filebrowser/filebrowser/issues/1155)) ([586d198](https://github.com/filebrowser/filebrowser/commit/586d198d47b525eeccc6fe587573a3ad83adb4f6))
|
||||
* previewer title overflow ([4e48ffc](https://github.com/filebrowser/filebrowser/commit/4e48ffc14d09dabeea12dc495144277db62b5b7d))
|
||||
* resource rename action invalid path ([1ce3068](https://github.com/filebrowser/filebrowser/commit/1ce3068a99c80c153fd41359255d173bce6e79e8))
|
||||
|
||||
## [2.9.0](https://github.com/filebrowser/filebrowser/compare/v2.8.0...v2.9.0) (2020-10-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* support WKWebview custom protocol ([#1113](https://github.com/filebrowser/filebrowser/issues/1113)) ([0ac80e8](https://github.com/filebrowser/filebrowser/commit/0ac80e8387a69924284259bde448af2813d84ed1))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow start from Windows explorer ([f2c4e78](https://github.com/filebrowser/filebrowser/commit/f2c4e78381610879eda5316d38a999c89df6c14a))
|
||||
* file upload missing path slash ([5e27ba5](https://github.com/filebrowser/filebrowser/commit/5e27ba5c8c1be603c6ae7fec8de48e3532dea1f7))
|
||||
* preview case sensitive file extension ([05bff54](https://github.com/filebrowser/filebrowser/commit/05bff54b71543fd232f1089c40504d0cbfd106be))
|
||||
* search missing path slash ([2bd163d](https://github.com/filebrowser/filebrowser/commit/2bd163d92a856d65c8d4615e37898470c1edf2f4))
|
||||
|
||||
## [2.8.0](https://github.com/filebrowser/filebrowser/compare/v2.7.0...v2.8.0) (2020-10-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add disable exec flag ([#1090](https://github.com/filebrowser/filebrowser/issues/1090)) ([97693cc](https://github.com/filebrowser/filebrowser/commit/97693cc6117ce1c956baede91de5dd48b904e175))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* empty commands setting ([c6d4fcd](https://github.com/filebrowser/filebrowser/commit/c6d4fcd08f5f1531c2cef514dc86019e23e7289f))
|
||||
* file upload path encoding ([babd778](https://github.com/filebrowser/filebrowser/commit/babd7783afe85b790e1c558375d7b5013b2d366f))
|
||||
* fix empty command name ([#1106](https://github.com/filebrowser/filebrowser/issues/1106)) ([36fb9f5](https://github.com/filebrowser/filebrowser/commit/36fb9f562a2c005ca4390fdebde0b4690201dff9))
|
||||
* fix panic when accessing nonexistent .js file in static path ([#1105](https://github.com/filebrowser/filebrowser/issues/1105)) ([ad99bf1](https://github.com/filebrowser/filebrowser/commit/ad99bf180197e0e6d82231a86457585de16366a8))
|
||||
* preview key shortcut conflict ([dd7b9dd](https://github.com/filebrowser/filebrowser/commit/dd7b9ddd8546361060ef99e838a691b2fc6c495a))
|
||||
* search results absolute url ([26d62e4](https://github.com/filebrowser/filebrowser/commit/26d62e411716a5eb9a5a703e47484cfb3fbf3bd0))
|
||||
|
||||
## [2.7.0](https://github.com/filebrowser/filebrowser/compare/v2.6.2...v2.7.0) (2020-09-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add --socket-perm flag to control unix socket file permissions (closes [#1060](https://github.com/filebrowser/filebrowser/issues/1060)) ([65ac734](https://github.com/filebrowser/filebrowser/commit/65ac73414fadc4686c94803a93ff319e8f7ce9d1))
|
||||
* preview mobile dropdown ([7787344](https://github.com/filebrowser/filebrowser/commit/778734419de314d4cb64d07109bbab73f8e2e42a))
|
||||
* preview size button ([3d2cb83](https://github.com/filebrowser/filebrowser/commit/3d2cb838d111ee61047599f49e76de80c821f341))
|
||||
* put selected files in the root of the archive (closes [#1065](https://github.com/filebrowser/filebrowser/issues/1065)) ([8142b32](https://github.com/filebrowser/filebrowser/commit/8142b32f3865eccd3331328e0d087f805d186ed5))
|
||||
|
||||
### [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)
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ For installation instructions please refer to our docs at [https://filebrowser.o
|
||||
|
||||
[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.
|
||||
[Command 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.
|
||||
|
||||
|
||||
@@ -140,10 +140,12 @@ func printSettings(ser *settings.Server, set *settings.Settings, auther auth.Aut
|
||||
fmt.Fprintf(w, "\tAddress:\t%s\n", ser.Address)
|
||||
fmt.Fprintf(w, "\tTLS Cert:\t%s\n", ser.TLSCert)
|
||||
fmt.Fprintf(w, "\tTLS Key:\t%s\n", ser.TLSKey)
|
||||
fmt.Fprintf(w, "\tExec Enabled:\t%t\n", ser.EnableExec)
|
||||
fmt.Fprintln(w, "\nDefaults:")
|
||||
fmt.Fprintf(w, "\tScope:\t%s\n", set.Defaults.Scope)
|
||||
fmt.Fprintf(w, "\tLocale:\t%s\n", set.Defaults.Locale)
|
||||
fmt.Fprintf(w, "\tView mode:\t%s\n", set.Defaults.ViewMode)
|
||||
fmt.Fprintf(w, "\tSingle Click:\t%t\n", set.Defaults.SingleClick)
|
||||
fmt.Fprintf(w, "\tCommands:\t%s\n", strings.Join(set.Defaults.Commands, " "))
|
||||
fmt.Fprintf(w, "\tSorting:\n")
|
||||
fmt.Fprintf(w, "\t\tBy:\t%s\n", set.Defaults.Sorting.By)
|
||||
|
||||
@@ -2,7 +2,6 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -32,7 +31,7 @@ override the options.`,
|
||||
s := &settings.Settings{
|
||||
Key: generateKey(),
|
||||
Signup: mustGetBool(flags, "signup"),
|
||||
Shell: strings.Split(strings.TrimSpace(mustGetString(flags, "shell")), " "),
|
||||
Shell: convertCmdStrToCmdArray(mustGetString(flags, "shell")),
|
||||
AuthMethod: authMethod,
|
||||
Defaults: defaults,
|
||||
Branding: settings.Branding{
|
||||
@@ -62,7 +61,7 @@ override the options.`,
|
||||
|
||||
fmt.Printf(`
|
||||
Congratulations! You've set up your database to use with File Browser.
|
||||
Now add your first user via 'filebrowser users new' and then you just
|
||||
Now add your first user via 'filebrowser users add' and then you just
|
||||
need to call the main command to boot up the server.
|
||||
`)
|
||||
printSettings(ser, s, auther)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
@@ -50,7 +48,7 @@ you want to change. Other options will remain unchanged.`,
|
||||
case "auth.method":
|
||||
hasAuth = true
|
||||
case "shell":
|
||||
set.Shell = strings.Split(strings.TrimSpace(mustGetString(flags, flag.Name)), " ")
|
||||
set.Shell = convertCmdStrToCmdArray(mustGetString(flags, flag.Name))
|
||||
case "branding.name":
|
||||
set.Branding.Name = mustGetString(flags, flag.Name)
|
||||
case "branding.disableExternal":
|
||||
|
||||
48
cmd/root.go
48
cmd/root.go
@@ -14,13 +14,16 @@ import (
|
||||
"syscall"
|
||||
|
||||
homedir "github.com/mitchellh/go-homedir"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
v "github.com/spf13/viper"
|
||||
lumberjack "gopkg.in/natefinch/lumberjack.v2"
|
||||
|
||||
"github.com/filebrowser/filebrowser/v2/auth"
|
||||
"github.com/filebrowser/filebrowser/v2/diskcache"
|
||||
fbhttp "github.com/filebrowser/filebrowser/v2/http"
|
||||
"github.com/filebrowser/filebrowser/v2/img"
|
||||
"github.com/filebrowser/filebrowser/v2/settings"
|
||||
"github.com/filebrowser/filebrowser/v2/storage"
|
||||
"github.com/filebrowser/filebrowser/v2/users"
|
||||
@@ -32,6 +35,7 @@ var (
|
||||
|
||||
func init() {
|
||||
cobra.OnInitialize(initConfig)
|
||||
cobra.MousetrapHelpText = ""
|
||||
|
||||
rootCmd.SetVersionTemplate("File Browser version {{printf \"%s\" .Version}}\n")
|
||||
|
||||
@@ -55,7 +59,13 @@ func addServerFlags(flags *pflag.FlagSet) {
|
||||
flags.StringP("key", "k", "", "tls key")
|
||||
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.Uint32("socket-perm", 0666, "unix socket file permissions")
|
||||
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")
|
||||
flags.Bool("disable-exec", false, "disables Command Runner feature")
|
||||
}
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
@@ -103,6 +113,24 @@ user created with the credentials from options "username" and "password".`,
|
||||
quickSetup(cmd.Flags(), d)
|
||||
}
|
||||
|
||||
// build img service
|
||||
workersCount, err := cmd.Flags().GetInt("img-processors")
|
||||
checkErr(err)
|
||||
if workersCount < 1 {
|
||||
log.Fatal("Image resize workers count could not be < 1")
|
||||
}
|
||||
imgSvc := img.New(workersCount)
|
||||
|
||||
var fileCache diskcache.Interface = diskcache.NewNoOp()
|
||||
cacheDir, err := cmd.Flags().GetString("cache-dir")
|
||||
checkErr(err)
|
||||
if cacheDir != "" {
|
||||
if err := os.MkdirAll(cacheDir, 0700); err != nil { //nolint:govet
|
||||
log.Fatalf("can't make directory %s: %s", cacheDir, err)
|
||||
}
|
||||
fileCache = diskcache.New(afero.NewOsFs(), cacheDir)
|
||||
}
|
||||
|
||||
server := getRunParams(cmd.Flags(), d.store)
|
||||
setupLog(server.Log)
|
||||
|
||||
@@ -118,6 +146,10 @@ user created with the credentials from options "username" and "password".`,
|
||||
case server.Socket != "":
|
||||
listener, err = net.Listen("unix", server.Socket)
|
||||
checkErr(err)
|
||||
socketPerm, err := cmd.Flags().GetUint32("socket-perm") //nolint:govet
|
||||
checkErr(err)
|
||||
err = os.Chmod(server.Socket, os.FileMode(socketPerm))
|
||||
checkErr(err)
|
||||
case server.TLSKey != "" && server.TLSCert != "":
|
||||
cer, err := tls.LoadX509KeyPair(server.TLSCert, server.TLSKey) //nolint:shadow
|
||||
checkErr(err)
|
||||
@@ -132,7 +164,7 @@ user created with the credentials from options "username" and "password".`,
|
||||
signal.Notify(sigc, os.Interrupt, syscall.SIGTERM)
|
||||
go cleanupHandler(listener, sigc)
|
||||
|
||||
handler, err := fbhttp.NewHandler(d.store, server)
|
||||
handler, err := fbhttp.NewHandler(imgSvc, fileCache, d.store, server)
|
||||
checkErr(err)
|
||||
|
||||
defer listener.Close()
|
||||
@@ -205,6 +237,15 @@ func getRunParams(flags *pflag.FlagSet, st *storage.Storage) *settings.Server {
|
||||
server.Socket = ""
|
||||
}
|
||||
|
||||
_, disableThumbnails := getParamB(flags, "disable-thumbnails")
|
||||
server.EnableThumbnails = !disableThumbnails
|
||||
|
||||
_, disablePreviewResize := getParamB(flags, "disable-preview-resize")
|
||||
server.ResizePreview = !disablePreviewResize
|
||||
|
||||
_, disableExec := getParamB(flags, "disable-exec")
|
||||
server.EnableExec = !disableExec
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
@@ -261,8 +302,9 @@ func quickSetup(flags *pflag.FlagSet, d pythonData) {
|
||||
Signup: false,
|
||||
CreateUserDir: false,
|
||||
Defaults: settings.UserDefaults{
|
||||
Scope: ".",
|
||||
Locale: "en",
|
||||
Scope: ".",
|
||||
Locale: "en",
|
||||
SingleClick: false,
|
||||
Perm: users.Permissions{
|
||||
Admin: false,
|
||||
Execute: true,
|
||||
|
||||
@@ -27,15 +27,16 @@ var usersCmd = &cobra.Command{
|
||||
|
||||
func printUsers(usrs []*users.User) {
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ID\tUsername\tScope\tLocale\tV. Mode\tAdmin\tExecute\tCreate\tRename\tModify\tDelete\tShare\tDownload\tPwd Lock")
|
||||
fmt.Fprintln(w, "ID\tUsername\tScope\tLocale\tV. Mode\tS.Click\tAdmin\tExecute\tCreate\tRename\tModify\tDelete\tShare\tDownload\tPwd Lock")
|
||||
|
||||
for _, u := range usrs {
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t\n",
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t\n",
|
||||
u.ID,
|
||||
u.Username,
|
||||
u.Scope,
|
||||
u.Locale,
|
||||
u.ViewMode,
|
||||
u.SingleClick,
|
||||
u.Perm.Admin,
|
||||
u.Perm.Execute,
|
||||
u.Perm.Create,
|
||||
@@ -75,6 +76,7 @@ func addUserFlags(flags *pflag.FlagSet) {
|
||||
flags.String("scope", ".", "scope for users")
|
||||
flags.String("locale", "en", "locale for users")
|
||||
flags.String("viewMode", string(users.ListViewMode), "view mode for users")
|
||||
flags.Bool("singleClick", false, "use single clicks only")
|
||||
}
|
||||
|
||||
func getViewMode(flags *pflag.FlagSet) users.ViewMode {
|
||||
@@ -95,6 +97,8 @@ func getUserDefaults(flags *pflag.FlagSet, defaults *settings.UserDefaults, all
|
||||
defaults.Locale = mustGetString(flags, flag.Name)
|
||||
case "viewMode":
|
||||
defaults.ViewMode = getViewMode(flags)
|
||||
case "singleClick":
|
||||
defaults.SingleClick = mustGetBool(flags, flag.Name)
|
||||
case "perm.admin":
|
||||
defaults.Perm.Admin = mustGetBool(flags, flag.Name)
|
||||
case "perm.execute":
|
||||
|
||||
@@ -41,17 +41,19 @@ options you want to change.`,
|
||||
checkErr(err)
|
||||
|
||||
defaults := settings.UserDefaults{
|
||||
Scope: user.Scope,
|
||||
Locale: user.Locale,
|
||||
ViewMode: user.ViewMode,
|
||||
Perm: user.Perm,
|
||||
Sorting: user.Sorting,
|
||||
Commands: user.Commands,
|
||||
Scope: user.Scope,
|
||||
Locale: user.Locale,
|
||||
ViewMode: user.ViewMode,
|
||||
SingleClick: user.SingleClick,
|
||||
Perm: user.Perm,
|
||||
Sorting: user.Sorting,
|
||||
Commands: user.Commands,
|
||||
}
|
||||
getUserDefaults(flags, &defaults, false)
|
||||
user.Scope = defaults.Scope
|
||||
user.Locale = defaults.Locale
|
||||
user.ViewMode = defaults.ViewMode
|
||||
user.SingleClick = defaults.SingleClick
|
||||
user.Perm = defaults.Perm
|
||||
user.Commands = defaults.Commands
|
||||
user.Sorting = defaults.Sorting
|
||||
|
||||
13
cmd/utils.go
13
cmd/utils.go
@@ -7,6 +7,7 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/asdine/storm"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -178,3 +179,15 @@ func cleanUpMapValue(v interface{}) interface{} {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// convertCmdStrToCmdArray checks if cmd string is blank (whitespace included)
|
||||
// then returns empty string array, else returns the splitted word array of cmd.
|
||||
// This is to ensure the result will never be []string{""}
|
||||
func convertCmdStrToCmdArray(cmd string) []string {
|
||||
var cmdArray []string
|
||||
trimmedCmdStr := strings.TrimSpace(cmd)
|
||||
if trimmedCmdStr != "" {
|
||||
cmdArray = strings.Split(trimmedCmdStr, " ")
|
||||
}
|
||||
return cmdArray
|
||||
}
|
||||
|
||||
11
diskcache/cache.go
Normal file
11
diskcache/cache.go
Normal 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
110
diskcache/file_cache.go
Normal 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)
|
||||
}
|
||||
55
diskcache/file_cache_test.go
Normal file
55
diskcache/file_cache_test.go
Normal 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
24
diskcache/noop_cache.go
Normal 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
|
||||
}
|
||||
@@ -16,4 +16,5 @@ var (
|
||||
ErrInvalidAuthMethod = errors.New("invalid auth method")
|
||||
ErrPermissionDenied = errors.New("permission denied")
|
||||
ErrInvalidRequestParams = errors.New("invalid request params")
|
||||
ErrSourceIsParent = errors.New("source is parent")
|
||||
)
|
||||
|
||||
@@ -135,6 +135,10 @@ func (i *FileInfo) Checksum(algo string) error {
|
||||
//nolint:goconst
|
||||
//TODO: use constants
|
||||
func (i *FileInfo) detectType(modify, saveContent bool) error {
|
||||
if IsNamedPipe(i.Mode) {
|
||||
i.Type = "blob"
|
||||
return nil
|
||||
}
|
||||
// failing to detect the type should not return error.
|
||||
// imagine the situation where a file in a dir with thousands
|
||||
// of files couldn't be opened: we'd have immediately
|
||||
@@ -232,9 +236,9 @@ func (i *FileInfo) readListing(checker rules.Checker) error {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(f.Mode().String(), "L") {
|
||||
if IsSymlink(f.Mode()) {
|
||||
// It's a symbolic link. We try to follow it. If it doesn't work,
|
||||
// we stay with the link information instead if the target's.
|
||||
// we stay with the link information instead of the target's.
|
||||
info, err := i.Fs.Stat(fPath)
|
||||
if err == nil {
|
||||
f = info
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"os"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
@@ -48,3 +49,11 @@ func isBinary(content []byte, _ int) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func IsNamedPipe(mode os.FileMode) bool {
|
||||
return mode&os.ModeNamedPipe != 0
|
||||
}
|
||||
|
||||
func IsSymlink(mode os.FileMode) bool {
|
||||
return mode&os.ModeSymlink != 0
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package fileutils
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
@@ -25,7 +27,7 @@ func CopyFile(fs afero.Fs, source, dest string) error {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
@@ -49,3 +51,59 @@ func CopyFile(fs afero.Fs, source, dest string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CommonPrefix returns common directory path of provided files
|
||||
func CommonPrefix(sep byte, paths ...string) string {
|
||||
// Handle special cases.
|
||||
switch len(paths) {
|
||||
case 0:
|
||||
return ""
|
||||
case 1:
|
||||
return path.Clean(paths[0])
|
||||
}
|
||||
|
||||
// Note, we treat string as []byte, not []rune as is often
|
||||
// done in Go. (And sep as byte, not rune). This is because
|
||||
// most/all supported OS' treat paths as string of non-zero
|
||||
// bytes. A filename may be displayed as a sequence of Unicode
|
||||
// runes (typically encoded as UTF-8) but paths are
|
||||
// not required to be valid UTF-8 or in any normalized form
|
||||
// (e.g. "é" (U+00C9) and "é" (U+0065,U+0301) are different
|
||||
// file names.
|
||||
c := []byte(path.Clean(paths[0]))
|
||||
|
||||
// We add a trailing sep to handle the case where the
|
||||
// common prefix directory is included in the path list
|
||||
// (e.g. /home/user1, /home/user1/foo, /home/user1/bar).
|
||||
// path.Clean will have cleaned off trailing / separators with
|
||||
// the exception of the root directory, "/" (in which case we
|
||||
// make it "//", but this will get fixed up to "/" bellow).
|
||||
c = append(c, sep)
|
||||
|
||||
// Ignore the first path since it's already in c
|
||||
for _, v := range paths[1:] {
|
||||
// Clean up each path before testing it
|
||||
v = path.Clean(v) + string(sep)
|
||||
|
||||
// Find the first non-common byte and truncate c
|
||||
if len(v) < len(c) {
|
||||
c = c[:len(v)]
|
||||
}
|
||||
for i := 0; i < len(c); i++ {
|
||||
if v[i] != c[i] {
|
||||
c = c[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove trailing non-separator characters and the final separator
|
||||
for i := len(c) - 1; i >= 0; i-- {
|
||||
if c[i] == sep {
|
||||
c = c[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return string(c)
|
||||
}
|
||||
|
||||
46
fileutils/file_test.go
Normal file
46
fileutils/file_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package fileutils
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCommonPrefix(t *testing.T) {
|
||||
testCases := map[string]struct {
|
||||
paths []string
|
||||
want string
|
||||
}{
|
||||
"same lvl": {
|
||||
paths: []string{
|
||||
"/home/user/file1",
|
||||
"/home/user/file2",
|
||||
},
|
||||
want: "/home/user",
|
||||
},
|
||||
"sub folder": {
|
||||
paths: []string{
|
||||
"/home/user/folder",
|
||||
"/home/user/folder/file",
|
||||
},
|
||||
want: "/home/user/folder",
|
||||
},
|
||||
"relative path": {
|
||||
paths: []string{
|
||||
"/home/user/folder",
|
||||
"/home/user/folder/../folder2",
|
||||
},
|
||||
want: "/home/user",
|
||||
},
|
||||
"no common path": {
|
||||
paths: []string{
|
||||
"/home/user/folder",
|
||||
"/etc/file",
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for name, tt := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if got := CommonPrefix('/', tt.paths...); got != tt.want {
|
||||
t.Errorf("CommonPrefix() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
5
frontend/package-lock.json
generated
5
frontend/package-lock.json
generated
@@ -13030,6 +13030,11 @@
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-8.15.3.tgz",
|
||||
"integrity": "sha512-PVNgo6yhOmacZVFjSapZ314oewwLyXHjJwAqjnaPN1GJAJd/dvsrShGzSiJuCX4Hc36G4epJvNXUwO8y7wEKew=="
|
||||
},
|
||||
"vue-lazyload": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/vue-lazyload/-/vue-lazyload-1.3.3.tgz",
|
||||
"integrity": "sha512-uHnq0FTEeNmqnbBC2aRKlmtd9LofMZ6Q3mWvgfLa+i9vhxU8fDK+nGs9c1iVT85axSua/AUnMttIq3xPaU9G3A=="
|
||||
},
|
||||
"vue-loader": {
|
||||
"version": "15.8.3",
|
||||
"resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-15.8.3.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"qrcode.vue": "^1.7.0",
|
||||
"vue": "^2.6.10",
|
||||
"vue-i18n": "^8.15.3",
|
||||
"vue-lazyload": "^1.3.3",
|
||||
"vue-router": "^3.1.3",
|
||||
"vuex": "^3.1.2",
|
||||
"vuex-router-sync": "^5.0.0"
|
||||
|
||||
@@ -13,24 +13,25 @@
|
||||
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="[{[ .StaticURL ]}]/img/icons/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="[{[ .StaticURL ]}]/img/icons/favicon-16x16.png">
|
||||
|
||||
<!-- Add to home screen for Android and modern mobile browsers -->
|
||||
<link rel="manifest" id="manifestPlaceholder" crossorigin="use-credentials">
|
||||
<meta name="theme-color" content="#2979ff">
|
||||
|
||||
<!-- Add to home screen for Safari on iOS -->
|
||||
<!-- Add to home screen for Safari on iOS/iPadOS -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="assets">
|
||||
<link rel="apple-touch-icon" href="[{[ .StaticURL ]}]/img/icons/apple-touch-icon-152x152.png">
|
||||
<link rel="apple-touch-icon" href="[{[ .StaticURL ]}]/img/icons/apple-touch-icon.png">
|
||||
|
||||
<!-- Add to home screen for Windows -->
|
||||
<meta name="msapplication-TileImage" content="[{[ .StaticURL ]}]/img/icons/msapplication-icon-144x144.png">
|
||||
<meta name="msapplication-TileImage" content="[{[ .StaticURL ]}]/img/icons/mstile-144x144.png">
|
||||
<meta name="msapplication-TileColor" content="#2979ff">
|
||||
|
||||
<!-- Inject Some Variables and generate the manifest json -->
|
||||
<script>
|
||||
window.FileBrowser = JSON.parse(`[{[ .Json ]}]`);
|
||||
|
||||
|
||||
var fullStaticURL = window.location.origin + window.FileBrowser.StaticURL;
|
||||
var dynamicManifest = {
|
||||
"name": window.FileBrowser.Name || 'File Browser',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
:root {
|
||||
--background: #121212;
|
||||
--surfacePrimary: #171819;
|
||||
--surfaceSecondary: #212528;
|
||||
--background: #141D24;
|
||||
--surfacePrimary: #20292F;
|
||||
--surfaceSecondary: #3A4147;
|
||||
--divider: rgba(255, 255, 255, 0.12);
|
||||
--icon: #ffffff;
|
||||
--textPrimary: rgba(255, 255, 255, 0.87);
|
||||
@@ -16,7 +16,7 @@ body {
|
||||
#loading {
|
||||
background: var(--background);
|
||||
}
|
||||
#loading .spinner div {
|
||||
#loading .spinner div, #previewer .loading .spinner div {
|
||||
background: var(--icon);
|
||||
}
|
||||
|
||||
@@ -30,25 +30,34 @@ header {
|
||||
|
||||
#search #input {
|
||||
background: var(--surfaceSecondary);
|
||||
border-color: var(--surfacePrimary);
|
||||
}
|
||||
#search.active #input,
|
||||
#search.active .boxes {
|
||||
#search #input input::placeholder {
|
||||
color: var(--textSecondary);
|
||||
}
|
||||
#search.active #input {
|
||||
background: var(--surfacePrimary);
|
||||
}
|
||||
#search.active input {
|
||||
color: var(--textPrimary);
|
||||
}
|
||||
#search.active #result {
|
||||
#search #result {
|
||||
background: var(--background);
|
||||
color: var(--textPrimary);
|
||||
}
|
||||
#search.active .boxes h3 {
|
||||
#search .boxes {
|
||||
background: var(--surfaceSecondary);
|
||||
}
|
||||
#search .boxes h3 {
|
||||
color: var(--textPrimary);
|
||||
}
|
||||
|
||||
.action {
|
||||
color: var(--textPrimary) !important;
|
||||
}
|
||||
.action:hover {
|
||||
background-color: rgba(255, 255, 255, .1);
|
||||
}
|
||||
.action i {
|
||||
color: var(--icon) !important;
|
||||
}
|
||||
@@ -93,6 +102,10 @@ nav > div {
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.message {
|
||||
color: var(--textPrimary);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surfacePrimary);
|
||||
color: var(--textPrimary);
|
||||
@@ -106,9 +119,23 @@ nav > div {
|
||||
.dashboard p label {
|
||||
color: var(--textPrimary);
|
||||
}
|
||||
.card#share ul li input,
|
||||
.card#share ul li select,
|
||||
.input {
|
||||
background: var(--surfaceSecondary);
|
||||
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,
|
||||
@@ -119,10 +146,35 @@ nav > div {
|
||||
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 {
|
||||
background: var(--surfacePrimary);
|
||||
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) {
|
||||
#file-selection {
|
||||
@@ -138,3 +190,12 @@ nav > div {
|
||||
background: var(--surfaceSecondary) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.share__box {
|
||||
background: var(--surfacePrimary) !important;
|
||||
color: var(--textPrimary);
|
||||
}
|
||||
|
||||
.share__box__element {
|
||||
border-top-color: var(--divider);
|
||||
}
|
||||
@@ -43,7 +43,7 @@ async function resourceAction (url, method, content) {
|
||||
const res = await fetchURL(`/api/resources${url}`, opts)
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error(res.responseText)
|
||||
throw new Error(await res.text())
|
||||
} else {
|
||||
return res
|
||||
}
|
||||
@@ -85,6 +85,11 @@ export function download (format, ...files) {
|
||||
export async function post (url, content = '', overwrite = false, onupload) {
|
||||
url = removePrefix(url)
|
||||
|
||||
let bufferContent
|
||||
if (content instanceof Blob && !['http:', 'https:'].includes(window.location.protocol)) {
|
||||
bufferContent = await new Response(content).arrayBuffer()
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let request = new XMLHttpRequest()
|
||||
request.open('POST', `${baseURL}/api/resources${url}?override=${overwrite}`, true)
|
||||
@@ -94,9 +99,6 @@ export async function post (url, content = '', overwrite = false, 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 = () => {
|
||||
if (request.status === 200) {
|
||||
resolve(request.responseText)
|
||||
@@ -111,30 +113,29 @@ export async function post (url, content = '', overwrite = false, onupload) {
|
||||
reject(error)
|
||||
}
|
||||
|
||||
request.send(content)
|
||||
// Upload is done no more message before closing the tab
|
||||
}).finally(() => { window.onbeforeunload = null })
|
||||
request.send(bufferContent || content)
|
||||
})
|
||||
}
|
||||
|
||||
function moveCopy (items, copy = false) {
|
||||
function moveCopy (items, copy = false, overwrite = false, rename = false) {
|
||||
let promises = []
|
||||
|
||||
for (let item of items) {
|
||||
const from = removePrefix(item.from)
|
||||
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'))
|
||||
}
|
||||
|
||||
return Promise.all(promises)
|
||||
}
|
||||
|
||||
export function move (items) {
|
||||
return moveCopy(items)
|
||||
export function move (items, overwrite = false, rename = false) {
|
||||
return moveCopy(items, false, overwrite, rename)
|
||||
}
|
||||
|
||||
export function copy (items) {
|
||||
return moveCopy(items, true)
|
||||
export function copy (items, overwrite = false, rename = false) {
|
||||
return moveCopy(items, true, overwrite, rename)
|
||||
}
|
||||
|
||||
export async function checksum (url, algo) {
|
||||
|
||||
@@ -1,8 +1,31 @@
|
||||
import { fetchJSON, removePrefix } from './utils'
|
||||
import { fetchURL, removePrefix } from './utils'
|
||||
import url from '../utils/url'
|
||||
|
||||
export default async function search (url, query) {
|
||||
url = removePrefix(url)
|
||||
export default async function search (base, query) {
|
||||
base = removePrefix(base)
|
||||
query = encodeURIComponent(query)
|
||||
|
||||
return fetchJSON(`/api/search${url}?query=${query}`, {})
|
||||
}
|
||||
if (!base.endsWith('/')) {
|
||||
base += '/'
|
||||
}
|
||||
|
||||
let res = await fetchURL(`/api/search${base}?query=${query}`, {})
|
||||
|
||||
if (res.status === 200) {
|
||||
let data = await res.json()
|
||||
|
||||
data = data.map((item) => {
|
||||
item.url = `/files${base}` + url.encodePath(item.path)
|
||||
|
||||
if (item.dir) {
|
||||
item.url += '/'
|
||||
}
|
||||
|
||||
return item
|
||||
})
|
||||
|
||||
return data
|
||||
} else {
|
||||
throw Error(res.status)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<header>
|
||||
<header v-if="!isEditor && !isPreview">
|
||||
<div>
|
||||
<button @click="openSidebar" :aria-label="$t('buttons.toggleSidebar')" :title="$t('buttons.toggleSidebar')" class="action">
|
||||
<i class="material-icons">menu</i>
|
||||
@@ -13,10 +13,6 @@
|
||||
<i class="material-icons">search</i>
|
||||
</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">
|
||||
<i class="material-icons">more_vert</i>
|
||||
</button>
|
||||
@@ -41,7 +37,7 @@
|
||||
<delete-button v-show="showDeleteButton"></delete-button>
|
||||
</div>
|
||||
|
||||
<shell-button v-show="user.perm.execute" />
|
||||
<shell-button v-if="isExecEnabled && user.perm.execute" />
|
||||
<switch-button v-show="isListing"></switch-button>
|
||||
<download-button v-show="showDownloadButton"></download-button>
|
||||
<upload-button v-show="showUpload"></upload-button>
|
||||
@@ -72,7 +68,7 @@ import CopyButton from './buttons/Copy'
|
||||
import ShareButton from './buttons/Share'
|
||||
import ShellButton from './buttons/Shell'
|
||||
import {mapGetters, mapState} from 'vuex'
|
||||
import { logoURL } from '@/utils/constants'
|
||||
import { logoURL, enableExec } from '@/utils/constants'
|
||||
import * as api from '@/api'
|
||||
import buttons from '@/utils/buttons'
|
||||
|
||||
@@ -112,6 +108,7 @@ export default {
|
||||
'selectedCount',
|
||||
'isFiles',
|
||||
'isEditor',
|
||||
'isPreview',
|
||||
'isListing',
|
||||
'isLogged'
|
||||
]),
|
||||
@@ -123,15 +120,13 @@ export default {
|
||||
'multiple'
|
||||
]),
|
||||
logoURL: () => logoURL,
|
||||
isExecEnabled: () => enableExec,
|
||||
isMobile () {
|
||||
return this.width <= 736
|
||||
},
|
||||
showUpload () {
|
||||
return this.isListing && this.user.perm.create
|
||||
},
|
||||
showSaveButton () {
|
||||
return this.isEditor && this.user.perm.modify
|
||||
},
|
||||
showDownloadButton () {
|
||||
return this.isFiles && this.user.perm.download
|
||||
},
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
</template>
|
||||
<ul v-show="results.length > 0">
|
||||
<li v-for="(s,k) in filteredResults" :key="k">
|
||||
<router-link @click.native="close" :to="'./' + s.path">
|
||||
<router-link @click.native="close" :to="s.url">
|
||||
<i v-if="s.dir" class="material-icons">folder</i>
|
||||
<i v-else class="material-icons">insert_drive_file</i>
|
||||
<span>./{{ s.path }}</span>
|
||||
@@ -136,12 +136,6 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener("keydown", event => {
|
||||
if (event.keyCode === 27) {
|
||||
this.closeHovers()
|
||||
}
|
||||
})
|
||||
|
||||
this.$refs.result.addEventListener('scroll', event => {
|
||||
if (event.target.offsetHeight + event.target.scrollTop >= event.target.scrollHeight - 100) {
|
||||
this.resultsCount += 50
|
||||
@@ -189,8 +183,12 @@ export default {
|
||||
|
||||
this.ongoing = true
|
||||
|
||||
try {
|
||||
this.results = await search(path, this.value)
|
||||
} catch (error) {
|
||||
this.$showError(error)
|
||||
}
|
||||
|
||||
this.results = await search(path, this.value)
|
||||
this.ongoing = false
|
||||
}
|
||||
}
|
||||
|
||||
22
frontend/src/components/buttons/PreviewSize.vue
Normal file
22
frontend/src/components/buttons/PreviewSize.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<button :title="$t('buttons.info')" :aria-label="$t('buttons.info')" class="action" @click="$emit('change-size')">
|
||||
<i class="material-icons">{{ this.icon }}</i>
|
||||
<span>{{ $t('buttons.info') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'preview-size-button',
|
||||
props: [ 'size' ],
|
||||
computed: {
|
||||
icon () {
|
||||
if (this.size) {
|
||||
return 'photo_size_select_large'
|
||||
}
|
||||
|
||||
return 'hd'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,11 +1,37 @@
|
||||
<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>
|
||||
|
||||
<script>
|
||||
import { mapState } from 'vuex'
|
||||
import { files as api } from '@/api'
|
||||
import buttons from '@/utils/buttons'
|
||||
import url from '@/utils/url'
|
||||
|
||||
import ace from 'ace-builds/src-min-noconflict/ace.js'
|
||||
import modelist from 'ace-builds/src-min-noconflict/ext-modelist.js'
|
||||
@@ -14,27 +40,52 @@ import { theme } from '@/utils/constants'
|
||||
|
||||
export default {
|
||||
name: 'editor',
|
||||
computed: {
|
||||
...mapState(['req'])
|
||||
},
|
||||
data: function () {
|
||||
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 () {
|
||||
window.addEventListener('keydown', this.keyEvent)
|
||||
document.getElementById('save-button').addEventListener('click', this.save)
|
||||
},
|
||||
beforeDestroy () {
|
||||
window.removeEventListener('keydown', this.keyEvent)
|
||||
document.getElementById('save-button').removeEventListener('click', this.save)
|
||||
this.editor.destroy();
|
||||
},
|
||||
mounted: function () {
|
||||
mounted: function () {
|
||||
const fileContent = this.req.content || '';
|
||||
|
||||
this.editor = ace.edit('editor', {
|
||||
maxLines: 80,
|
||||
minLines: 20,
|
||||
value: fileContent,
|
||||
showPrintMargin: false,
|
||||
readOnly: this.req.type === 'textImmutable',
|
||||
@@ -48,6 +99,10 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
back () {
|
||||
let uri = url.removeLastDir(this.$route.path) + '/'
|
||||
this.$router.push({ path: uri })
|
||||
},
|
||||
keyEvent (event) {
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
return
|
||||
|
||||
@@ -10,10 +10,12 @@
|
||||
@mouseup="mouseUp"
|
||||
@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>
|
||||
</template>
|
||||
<script>
|
||||
import throttle from 'lodash.throttle'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
src: String,
|
||||
@@ -50,7 +52,12 @@ export default {
|
||||
inDrag: false,
|
||||
lastTouchDistance: 0,
|
||||
moveDisabled: false,
|
||||
disabledTimer: null
|
||||
disabledTimer: null,
|
||||
imageLoaded: false,
|
||||
position: {
|
||||
center: { x: 0, y: 0 },
|
||||
relative: { x: 0, y: 0 }
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -63,24 +70,54 @@ export default {
|
||||
if (getComputedStyle(container).height === "0px") {
|
||||
container.style.height = "100%"
|
||||
}
|
||||
|
||||
window.addEventListener('resize', this.onResize)
|
||||
},
|
||||
beforeDestroy () {
|
||||
window.removeEventListener('resize', this.onResize)
|
||||
document.removeEventListener('mouseup', this.onMouseUp)
|
||||
},
|
||||
watch: {
|
||||
src: function () {
|
||||
this.scale = 1
|
||||
this.setZoom()
|
||||
this.setCenter()
|
||||
}
|
||||
},
|
||||
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() {
|
||||
let container = this.$refs.container
|
||||
let img = this.$refs.imgex
|
||||
|
||||
let rate = Math.min(
|
||||
container.clientWidth / img.clientWidth,
|
||||
container.clientHeight / img.clientHeight
|
||||
)
|
||||
if (!this.autofill && rate > 1) {
|
||||
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 )
|
||||
this.position.center.x = Math.floor((container.clientWidth - img.clientWidth) / 2)
|
||||
this.position.center.y = Math.floor((container.clientHeight - img.clientHeight) / 2)
|
||||
|
||||
img.style.left = this.position.center.x + 'px'
|
||||
img.style.top = this.position.center.y + 'px'
|
||||
},
|
||||
mousedownStart(event) {
|
||||
this.lastX = null
|
||||
@@ -159,8 +196,22 @@ export default {
|
||||
},
|
||||
doMove(x, y) {
|
||||
let style = this.$refs.imgex.style
|
||||
style.left = `${this.pxStringToNumber(style.left) + x}px`
|
||||
style.top = `${this.pxStringToNumber(style.top) + y}px`
|
||||
let posX = this.pxStringToNumber(style.left) + x
|
||||
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) {
|
||||
this.scale += (event.wheelDeltaY / 100) * this.zoomStep
|
||||
@@ -185,9 +236,20 @@ export default {
|
||||
}
|
||||
|
||||
.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;
|
||||
top: 0;
|
||||
position: absolute;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
<input style="display:none" type="file" id="upload-folder-input" @change="uploadInput($event)" webkitdirectory multiple>
|
||||
</div>
|
||||
<div v-else id="listing"
|
||||
:class="user.viewMode"
|
||||
@dragenter="dragEnter"
|
||||
@dragend="dragEnd">
|
||||
:class="user.viewMode">
|
||||
<div>
|
||||
<div class="item header">
|
||||
<div></div>
|
||||
@@ -89,29 +87,22 @@
|
||||
|
||||
<script>
|
||||
import { mapState, mapMutations } from 'vuex'
|
||||
import throttle from 'lodash.throttle'
|
||||
import Item from './ListingItem'
|
||||
import css from '@/utils/css'
|
||||
import { users, files as api } from '@/api'
|
||||
import buttons from '@/utils/buttons'
|
||||
import url from '@/utils/url'
|
||||
import * as upload from '@/utils/upload'
|
||||
|
||||
export default {
|
||||
name: 'listing',
|
||||
components: { Item },
|
||||
data: function () {
|
||||
return {
|
||||
show: 50,
|
||||
uploading: {
|
||||
id: 0,
|
||||
count: 0,
|
||||
size: 0,
|
||||
progress: []
|
||||
}
|
||||
showLimit: 50,
|
||||
dragCounter: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState(['req', 'selected', 'user']),
|
||||
...mapState(['req', 'selected', 'user', 'show']),
|
||||
nameSorted () {
|
||||
return (this.req.sorting.by === 'name')
|
||||
},
|
||||
@@ -139,14 +130,14 @@ export default {
|
||||
return { dirs, files }
|
||||
},
|
||||
dirs () {
|
||||
return this.items.dirs.slice(0, this.show)
|
||||
return this.items.dirs.slice(0, this.showLimit)
|
||||
},
|
||||
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 () {
|
||||
if (this.nameSorted && !this.ascOrdered) {
|
||||
@@ -179,6 +170,8 @@ export default {
|
||||
window.addEventListener('resize', this.resizeEvent)
|
||||
window.addEventListener('scroll', this.scrollEvent)
|
||||
document.addEventListener('dragover', this.preventDefault)
|
||||
document.addEventListener('dragenter', this.dragEnter)
|
||||
document.addEventListener('dragleave', this.dragLeave)
|
||||
document.addEventListener('drop', this.drop)
|
||||
},
|
||||
beforeDestroy () {
|
||||
@@ -187,6 +180,8 @@ export default {
|
||||
window.removeEventListener('resize', this.resizeEvent)
|
||||
window.removeEventListener('scroll', this.scrollEvent)
|
||||
document.removeEventListener('dragover', this.preventDefault)
|
||||
document.removeEventListener('dragenter', this.dragEnter)
|
||||
document.removeEventListener('dragleave', this.dragLeave)
|
||||
document.removeEventListener('drop', this.drop)
|
||||
},
|
||||
methods: {
|
||||
@@ -195,6 +190,10 @@ export default {
|
||||
return window.btoa(unescape(encodeURIComponent(name)))
|
||||
},
|
||||
keyEvent (event) {
|
||||
if (this.show !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
return
|
||||
}
|
||||
@@ -252,7 +251,8 @@ export default {
|
||||
|
||||
this.$store.commit('updateClipboard', {
|
||||
key: key,
|
||||
items: items
|
||||
items: items,
|
||||
path: this.$route.path
|
||||
})
|
||||
},
|
||||
paste (event) {
|
||||
@@ -265,23 +265,56 @@ export default {
|
||||
for (let item of this.$store.state.clipboard.items) {
|
||||
const from = item.from.endsWith('/') ? item.from.slice(0, -1) : item.from
|
||||
const to = this.$route.path + item.name
|
||||
items.push({ from, to })
|
||||
items.push({ from, to, name: item.name })
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.$store.state.clipboard.key === 'x') {
|
||||
api.move(items).then(() => {
|
||||
let action = (overwrite, rename) => {
|
||||
api.copy(items, overwrite, rename).then(() => {
|
||||
this.$store.commit('setReload', true)
|
||||
}).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
|
||||
}
|
||||
|
||||
api.copy(items).then(() => {
|
||||
this.$store.commit('setReload', true)
|
||||
}).catch(this.$showError)
|
||||
let conflict = upload.checkConflict(items, this.req.items)
|
||||
|
||||
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 () {
|
||||
// Update the columns size based on the window width.
|
||||
@@ -292,10 +325,12 @@ export default {
|
||||
},
|
||||
scrollEvent () {
|
||||
if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight) {
|
||||
this.show += 50
|
||||
this.showLimit += 50
|
||||
}
|
||||
},
|
||||
dragEnter () {
|
||||
this.dragCounter++
|
||||
|
||||
// When the user starts dragging an item, put every
|
||||
// file on the listing with 50% opacity.
|
||||
let items = document.getElementsByClassName('item')
|
||||
@@ -304,11 +339,16 @@ export default {
|
||||
file.style.opacity = 0.5
|
||||
})
|
||||
},
|
||||
dragEnd () {
|
||||
this.resetOpacity()
|
||||
dragLeave () {
|
||||
this.dragCounter--
|
||||
|
||||
if (this.dragCounter == 0) {
|
||||
this.resetOpacity()
|
||||
}
|
||||
},
|
||||
drop: function (event) {
|
||||
drop: async function (event) {
|
||||
event.preventDefault()
|
||||
this.dragCounter = 0
|
||||
this.resetOpacity()
|
||||
|
||||
let dt = event.dataTransfer
|
||||
@@ -327,65 +367,34 @@ export default {
|
||||
base = el.querySelector('.name').innerHTML + '/'
|
||||
}
|
||||
|
||||
if (base === '') {
|
||||
this.scanFiles(dt).then((result) => {
|
||||
this.checkConflict(result, this.req.items, base)
|
||||
})
|
||||
} else {
|
||||
this.scanFiles(dt).then((result) => {
|
||||
api.fetch(this.$route.path + base)
|
||||
.then(req => {
|
||||
this.checkConflict(result, req.items, base)
|
||||
})
|
||||
.catch(this.$showError)
|
||||
})
|
||||
}
|
||||
},
|
||||
checkConflict (files, items, base) {
|
||||
if (typeof items === 'undefined' || items === null) {
|
||||
items = []
|
||||
let files = await upload.scanFiles(dt)
|
||||
let path = this.$route.path.endsWith('/') ? this.$route.path + base : this.$route.path + '/' + base
|
||||
let items = this.req.items
|
||||
|
||||
if (base !== '') {
|
||||
try {
|
||||
items = (await api.fetch(path)).items
|
||||
} catch (error) {
|
||||
this.$showError(error)
|
||||
}
|
||||
}
|
||||
|
||||
let folder_upload = false
|
||||
if (files[0].fullPath !== undefined) {
|
||||
folder_upload = true
|
||||
}
|
||||
let conflict = upload.checkConflict(files, items)
|
||||
|
||||
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]
|
||||
if (conflict) {
|
||||
this.$store.commit('showHover', {
|
||||
prompt: 'replace',
|
||||
confirm: (event) => {
|
||||
event.preventDefault()
|
||||
this.$store.commit('closeHovers')
|
||||
upload.handleFiles(files, path, true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let res = items.findIndex(function hasConflict (element) {
|
||||
return (element.name === this)
|
||||
}, name)
|
||||
|
||||
if (res >= 0) {
|
||||
conflict = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!conflict) {
|
||||
this.handleFiles(files, base)
|
||||
return
|
||||
}
|
||||
|
||||
this.$store.commit('showHover', {
|
||||
prompt: 'replace',
|
||||
confirm: (event) => {
|
||||
event.preventDefault()
|
||||
this.$store.commit('closeHovers')
|
||||
this.handleFiles(files, base, true)
|
||||
}
|
||||
})
|
||||
upload.handleFiles(files, path)
|
||||
},
|
||||
uploadInput (event) {
|
||||
this.$store.commit('closeHovers')
|
||||
@@ -400,7 +409,23 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
this.checkConflict(files, this.req.items, '')
|
||||
let path = this.$route.path.endsWith('/') ? this.$route.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 () {
|
||||
let items = document.getElementsByClassName('item')
|
||||
@@ -409,145 +434,6 @@ export default {
|
||||
file.style.opacity = 1
|
||||
})
|
||||
},
|
||||
scanFiles(dt) {
|
||||
return new Promise((resolve) => {
|
||||
let reading = 0
|
||||
const contents = []
|
||||
|
||||
if (dt.items !== undefined) {
|
||||
for (let item of dt.items) {
|
||||
if (item.kind === "file" && typeof item.webkitGetAsEntry === "function") {
|
||||
const entry = item.webkitGetAsEntry()
|
||||
readEntry(entry)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
resolve(dt.files)
|
||||
}
|
||||
|
||||
function readEntry(entry, directory = "") {
|
||||
if (entry.isFile) {
|
||||
reading++
|
||||
entry.file(file => {
|
||||
reading--
|
||||
|
||||
file.fullPath = `${directory}${file.name}`
|
||||
contents.push(file)
|
||||
|
||||
if (reading === 0) {
|
||||
resolve(contents)
|
||||
}
|
||||
})
|
||||
} else if (entry.isDirectory) {
|
||||
const dir = {
|
||||
isDir: true,
|
||||
path: `${directory}${entry.name}`
|
||||
}
|
||||
|
||||
contents.push(dir)
|
||||
|
||||
readReaderContent(entry.createReader(), `${directory}${entry.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
function readReaderContent(reader, directory) {
|
||||
reading++
|
||||
|
||||
reader.readEntries(function (entries) {
|
||||
reading--
|
||||
if (entries.length > 0) {
|
||||
for (const entry of entries) {
|
||||
readEntry(entry, `${directory}/`)
|
||||
}
|
||||
|
||||
readReaderContent(reader, `${directory}/`)
|
||||
}
|
||||
|
||||
if (reading === 0) {
|
||||
resolve(contents)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
setProgress: throttle(function() {
|
||||
if (this.uploading.count == 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let sum = this.uploading.progress.reduce((acc, val) => acc + val)
|
||||
this.$store.commit('setProgress', Math.ceil(sum / this.uploading.size * 100))
|
||||
}, 100, {leading: false, trailing: true}),
|
||||
handleFiles (files, base, overwrite = false) {
|
||||
if (this.uploading.count == 0) {
|
||||
buttons.loading('upload')
|
||||
}
|
||||
|
||||
let promises = []
|
||||
|
||||
let onupload = (id) => (event) => {
|
||||
this.uploading.progress[id] = event.loaded
|
||||
this.setProgress()
|
||||
}
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
let file = files[i]
|
||||
|
||||
if (!file.isDir) {
|
||||
let filename = (file.fullPath !== undefined) ? file.fullPath : file.name
|
||||
let filenameEncoded = url.encodeRFC5987ValueChars(filename)
|
||||
|
||||
let id = this.uploading.id
|
||||
|
||||
this.uploading.size += file.size
|
||||
this.uploading.id++
|
||||
this.uploading.count++
|
||||
|
||||
let promise = api.post(this.$route.path + base + filenameEncoded, file, overwrite, throttle(onupload(id), 100)).finally(() => {
|
||||
this.uploading.count--
|
||||
})
|
||||
|
||||
promises.push(promise)
|
||||
} else {
|
||||
let uri = this.$route.path + base
|
||||
let folders = file.path.split("/")
|
||||
|
||||
for (let i = 0; i < folders.length; i++) {
|
||||
let folder = folders[i]
|
||||
let folderEncoded = encodeURIComponent(folder)
|
||||
uri += folderEncoded + "/"
|
||||
}
|
||||
|
||||
api.post(uri)
|
||||
}
|
||||
}
|
||||
|
||||
let finish = () => {
|
||||
if (this.uploading.count > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
buttons.success('upload')
|
||||
|
||||
this.$store.commit('setProgress', 0)
|
||||
this.$store.commit('setReload', true)
|
||||
|
||||
this.uploading.id = 0
|
||||
this.uploading.sizes = []
|
||||
this.uploading.progress = []
|
||||
}
|
||||
|
||||
Promise.all(promises)
|
||||
.then(() => {
|
||||
finish()
|
||||
})
|
||||
.catch(error => {
|
||||
finish()
|
||||
this.$showError(error)
|
||||
})
|
||||
|
||||
return false
|
||||
},
|
||||
async sort (by) {
|
||||
let asc = false
|
||||
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
@dragstart="dragStart"
|
||||
@dragover="dragOver"
|
||||
@drop="drop"
|
||||
@click="click"
|
||||
@dblclick="open"
|
||||
@click="itemClick"
|
||||
@dblclick="dblclick"
|
||||
@touchstart="touchstart"
|
||||
:data-dir="isDir"
|
||||
:aria-label="name"
|
||||
:aria-selected="isSelected">
|
||||
<div>
|
||||
<img v-if="type==='image'" :src="thumbnailUrl">
|
||||
<img v-if="type==='image' && isThumbsEnabled" v-lazy="thumbnailUrl">
|
||||
<i v-else class="material-icons">{{ icon }}</i>
|
||||
</div>
|
||||
|
||||
@@ -31,11 +31,12 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { baseURL } from '@/utils/constants'
|
||||
import { baseURL, enableThumbs } from '@/utils/constants'
|
||||
import { mapMutations, mapGetters, mapState } from 'vuex'
|
||||
import filesize from 'filesize'
|
||||
import moment from 'moment'
|
||||
import { files as api } from '@/api'
|
||||
import * as upload from '@/utils/upload'
|
||||
|
||||
export default {
|
||||
name: 'item',
|
||||
@@ -46,7 +47,7 @@ export default {
|
||||
},
|
||||
props: ['name', 'isDir', 'url', 'type', 'size', 'modified', 'index'],
|
||||
computed: {
|
||||
...mapState(['selected', 'req', 'user', 'jwt']),
|
||||
...mapState(['user', 'selected', 'req', 'user', 'jwt']),
|
||||
...mapGetters(['selectedCount']),
|
||||
isSelected () {
|
||||
return (this.selected.indexOf(this.index) !== -1)
|
||||
@@ -75,6 +76,9 @@ export default {
|
||||
thumbnailUrl () {
|
||||
const path = this.url.replace(/^\/files\//, '')
|
||||
return `${baseURL}/api/preview/thumb/${path}?auth=${this.jwt}&inline=true`
|
||||
},
|
||||
isThumbsEnabled () {
|
||||
return enableThumbs
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -110,29 +114,68 @@ export default {
|
||||
|
||||
el.style.opacity = 1
|
||||
},
|
||||
drop: function (event) {
|
||||
drop: async function (event) {
|
||||
if (!this.canDrop) return
|
||||
event.preventDefault()
|
||||
|
||||
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 = []
|
||||
|
||||
for (let i of this.selected) {
|
||||
items.push({
|
||||
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)
|
||||
.then(() => {
|
||||
this.$store.commit('setReload', true)
|
||||
let conflict = upload.checkConflict(items, baseItems)
|
||||
|
||||
let overwrite = false
|
||||
let rename = false
|
||||
|
||||
if (conflict) {
|
||||
this.$store.commit('showHover', {
|
||||
prompt: 'replace-rename',
|
||||
confirm: (event, option) => {
|
||||
overwrite = option == 'overwrite'
|
||||
rename = option == 'rename'
|
||||
|
||||
event.preventDefault()
|
||||
this.$store.commit('closeHovers')
|
||||
action(overwrite, rename)
|
||||
}
|
||||
})
|
||||
.catch(this.$showError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
action(overwrite, rename)
|
||||
},
|
||||
itemClick: function(event) {
|
||||
if (this.user.singleClick && !this.$store.state.multiple) this.open()
|
||||
else this.click(event)
|
||||
},
|
||||
click: function (event) {
|
||||
if (this.selectedCount !== 0) event.preventDefault()
|
||||
if (!this.user.singleClick && this.selectedCount !== 0) event.preventDefault()
|
||||
if (this.$store.state.selected.indexOf(this.index) !== -1) {
|
||||
this.removeSelected(this.index)
|
||||
return
|
||||
@@ -159,9 +202,12 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
if (!event.ctrlKey && !this.$store.state.multiple) this.resetSelected()
|
||||
if (!this.user.singleClick && !event.ctrlKey && !this.$store.state.multiple) this.resetSelected()
|
||||
this.addSelected(this.index)
|
||||
},
|
||||
dblclick: function () {
|
||||
if (!this.user.singleClick) this.open()
|
||||
},
|
||||
touchstart () {
|
||||
setTimeout(() => {
|
||||
this.touches = 0
|
||||
@@ -177,4 +223,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
@@ -5,10 +5,27 @@
|
||||
<i class="material-icons">close</i>
|
||||
</button>
|
||||
|
||||
<rename-button v-if="user.perm.rename"></rename-button>
|
||||
<delete-button v-if="user.perm.delete"></delete-button>
|
||||
<download-button v-if="user.perm.download"></download-button>
|
||||
<info-button></info-button>
|
||||
<div class="title">{{ this.name }}</div>
|
||||
|
||||
<preview-size-button v-if="isResizeEnabled && this.req.type === 'image'" @change-size="toggleSize" v-bind:size="fullSize" :disabled="loading"></preview-size-button>
|
||||
<button @click="openMore" id="more" :aria-label="$t('buttons.more')" :title="$t('buttons.more')" class="action">
|
||||
<i class="material-icons">more_vert</i>
|
||||
</button>
|
||||
|
||||
<div id="dropdown" :class="{ active : showMore }">
|
||||
<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>
|
||||
|
||||
<div class="loading" v-if="loading">
|
||||
<div class="spinner">
|
||||
<div class="bounce1"></div>
|
||||
<div class="bounce2"></div>
|
||||
<div class="bounce3"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="action" @click="prev" v-show="hasPrevious" :aria-label="$t('buttons.previous')" :title="$t('buttons.previous')">
|
||||
@@ -18,33 +35,38 @@
|
||||
<i class="material-icons">chevron_right</i>
|
||||
</button>
|
||||
|
||||
<div class="preview">
|
||||
<ExtendedImage v-if="req.type == 'image'" :src="raw"></ExtendedImage>
|
||||
<audio v-else-if="req.type == 'audio'" :src="raw" autoplay controls></audio>
|
||||
<video v-else-if="req.type == 'video'" :src="raw" autoplay controls>
|
||||
<track
|
||||
kind="captions"
|
||||
v-for="(sub, index) in subtitles"
|
||||
:key="index"
|
||||
:src="sub"
|
||||
:label="'Subtitle ' + index" :default="index === 0">
|
||||
Sorry, your browser doesn't support embedded videos,
|
||||
but don't worry, you can <a :href="download">download it</a>
|
||||
and watch it with your favorite video player!
|
||||
</video>
|
||||
<object v-else-if="req.extension == '.pdf'" class="pdf" :data="raw"></object>
|
||||
<a v-else-if="req.type == 'blob'" :href="download">
|
||||
<h2 class="message">{{ $t('buttons.download') }} <i class="material-icons">file_download</i></h2>
|
||||
</a>
|
||||
</div>
|
||||
<template v-if="!loading">
|
||||
<div class="preview">
|
||||
<ExtendedImage v-if="req.type == 'image'" :src="raw"></ExtendedImage>
|
||||
<audio v-else-if="req.type == 'audio'" :src="raw" autoplay controls></audio>
|
||||
<video v-else-if="req.type == 'video'" :src="raw" autoplay controls>
|
||||
<track
|
||||
kind="captions"
|
||||
v-for="(sub, index) in subtitles"
|
||||
:key="index"
|
||||
:src="sub"
|
||||
:label="'Subtitle ' + index" :default="index === 0">
|
||||
Sorry, your browser doesn't support embedded videos,
|
||||
but don't worry, you can <a :href="download">download it</a>
|
||||
and watch it with your favorite video player!
|
||||
</video>
|
||||
<object v-else-if="req.extension.toLowerCase() == '.pdf'" class="pdf" :data="raw"></object>
|
||||
<a v-else-if="req.type == 'blob'" :href="download">
|
||||
<h2 class="message">{{ $t('buttons.download') }} <i class="material-icons">file_download</i></h2>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-show="showMore" @click="resetPrompts" class="overlay"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState } from 'vuex'
|
||||
import url from '@/utils/url'
|
||||
import { baseURL } from '@/utils/constants'
|
||||
import { baseURL, resizePreview } from '@/utils/constants'
|
||||
import { files as api } from '@/api'
|
||||
import PreviewSizeButton from '@/components/buttons/PreviewSize'
|
||||
import InfoButton from '@/components/buttons/Info'
|
||||
import DeleteButton from '@/components/buttons/Delete'
|
||||
import RenameButton from '@/components/buttons/Rename'
|
||||
@@ -61,6 +83,7 @@ const mediaTypes = [
|
||||
export default {
|
||||
name: 'preview',
|
||||
components: {
|
||||
PreviewSizeButton,
|
||||
InfoButton,
|
||||
DeleteButton,
|
||||
RenameButton,
|
||||
@@ -72,11 +95,13 @@ export default {
|
||||
previousLink: '',
|
||||
nextLink: '',
|
||||
listing: null,
|
||||
subtitles: []
|
||||
name: '',
|
||||
subtitles: [],
|
||||
fullSize: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState(['req', 'user', 'oldReq', 'jwt']),
|
||||
...mapState(['req', 'user', 'oldReq', 'jwt', 'loading', 'show']),
|
||||
hasPrevious () {
|
||||
return (this.previousLink !== '')
|
||||
},
|
||||
@@ -84,42 +109,55 @@ export default {
|
||||
return (this.nextLink !== '')
|
||||
},
|
||||
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${this.req.path}?auth=${this.jwt}`
|
||||
if (this.req.type === 'image' && !this.fullSize) {
|
||||
return `${baseURL}/api/preview/big${url.encodePath(this.req.path)}?auth=${this.jwt}`
|
||||
}
|
||||
return `${baseURL}/api/raw${this.req.path}?auth=${this.jwt}`
|
||||
return `${baseURL}/api/raw${url.encodePath(this.req.path)}?auth=${this.jwt}`
|
||||
},
|
||||
raw () {
|
||||
return `${this.previewUrl}&inline=true`
|
||||
},
|
||||
showMore () {
|
||||
return this.$store.state.show === 'more'
|
||||
},
|
||||
isResizeEnabled () {
|
||||
return resizePreview
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
$route: function () {
|
||||
this.updatePreview()
|
||||
}
|
||||
},
|
||||
async mounted () {
|
||||
window.addEventListener('keyup', this.key)
|
||||
|
||||
if (this.req.subtitles) {
|
||||
this.subtitles = this.req.subtitles.map(sub => `${baseURL}/api/raw${sub}?auth=${this.jwt}&inline=true`)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
window.addEventListener('keydown', this.key)
|
||||
this.$store.commit('setPreviewMode', true)
|
||||
this.listing = this.oldReq.items
|
||||
this.$root.$on('preview-deleted', this.deleted)
|
||||
this.updatePreview()
|
||||
},
|
||||
beforeDestroy () {
|
||||
window.removeEventListener('keyup', this.key)
|
||||
window.removeEventListener('keydown', this.key)
|
||||
this.$store.commit('setPreviewMode', false)
|
||||
this.$root.$off('preview-deleted', this.deleted)
|
||||
},
|
||||
methods: {
|
||||
deleted () {
|
||||
this.listing = this.listing.filter(item => item.name !== this.name)
|
||||
|
||||
if (this.hasNext) {
|
||||
this.next()
|
||||
} else if (!this.hasPrevious && !this.hasNext) {
|
||||
this.back()
|
||||
} else {
|
||||
this.prev()
|
||||
}
|
||||
},
|
||||
back () {
|
||||
this.$store.commit('setPreviewMode', false)
|
||||
let uri = url.removeLastDir(this.$route.path) + '/'
|
||||
this.$router.push({ path: uri })
|
||||
},
|
||||
@@ -130,7 +168,10 @@ export default {
|
||||
this.$router.push({ path: this.nextLink })
|
||||
},
|
||||
key (event) {
|
||||
event.preventDefault()
|
||||
|
||||
if (this.show !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.which === 13 || event.which === 39) { // right arrow
|
||||
if (this.hasNext) this.next()
|
||||
@@ -138,28 +179,57 @@ export default {
|
||||
if (this.hasPrevious) this.prev()
|
||||
}
|
||||
},
|
||||
updateLinks (items) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].name !== this.req.name) {
|
||||
async updatePreview () {
|
||||
if (this.req.subtitles) {
|
||||
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
|
||||
}
|
||||
|
||||
for (let j = i - 1; j >= 0; j--) {
|
||||
if (mediaTypes.includes(items[j].type)) {
|
||||
this.previousLink = items[j].url
|
||||
if (mediaTypes.includes(this.listing[j].type)) {
|
||||
this.previousLink = this.listing[j].url
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for (let j = i + 1; j < items.length; j++) {
|
||||
if (mediaTypes.includes(items[j].type)) {
|
||||
this.nextLink = items[j].url
|
||||
for (let j = i + 1; j < this.listing.length; j++) {
|
||||
if (mediaTypes.includes(this.listing[j].type)) {
|
||||
this.nextLink = this.listing[j].url
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
},
|
||||
openMore () {
|
||||
this.$store.commit('showHover', 'more')
|
||||
},
|
||||
resetPrompts () {
|
||||
this.$store.commit('closeHovers')
|
||||
},
|
||||
toggleSize () {
|
||||
this.fullSize = !this.fullSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
:title="$t('buttons.cancel')">{{ $t('buttons.cancel') }}</button>
|
||||
<button class="button button--flat"
|
||||
@click="copy"
|
||||
:disabled="$route.path === dest"
|
||||
:aria-label="$t('buttons.copy')"
|
||||
:title="$t('buttons.copy')">{{ $t('buttons.copy') }}</button>
|
||||
</div>
|
||||
@@ -28,6 +27,7 @@ import { mapState } from 'vuex'
|
||||
import FileList from './FileList'
|
||||
import { files as api } from '@/api'
|
||||
import buttons from '@/utils/buttons'
|
||||
import * as upload from '@/utils/upload'
|
||||
|
||||
export default {
|
||||
name: 'copy',
|
||||
@@ -42,25 +42,66 @@ export default {
|
||||
methods: {
|
||||
copy: async function (event) {
|
||||
event.preventDefault()
|
||||
buttons.loading('copy')
|
||||
let items = []
|
||||
|
||||
// Create a new promise for each file.
|
||||
for (let item of this.selected) {
|
||||
items.push({
|
||||
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 {
|
||||
await api.copy(items)
|
||||
buttons.success('copy')
|
||||
this.$router.push({ path: this.dest })
|
||||
} catch (e) {
|
||||
buttons.done('copy')
|
||||
this.$showError(e)
|
||||
let action = async (overwrite, rename) => {
|
||||
buttons.loading('copy')
|
||||
|
||||
await api.copy(items, overwrite, rename).then(() => {
|
||||
buttons.success('copy')
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
<script>
|
||||
import {mapGetters, mapMutations, mapState} from 'vuex'
|
||||
import { files as api } from '@/api'
|
||||
import url from '@/utils/url'
|
||||
import buttons from '@/utils/buttons'
|
||||
|
||||
export default {
|
||||
@@ -32,17 +31,20 @@ export default {
|
||||
methods: {
|
||||
...mapMutations(['closeHovers']),
|
||||
submit: async function () {
|
||||
this.closeHovers()
|
||||
buttons.loading('delete')
|
||||
|
||||
try {
|
||||
if (!this.isListing) {
|
||||
await api.remove(this.$route.path)
|
||||
buttons.success('delete')
|
||||
this.$router.push({ path: url.removeLastDir(this.$route.path) + '/' })
|
||||
|
||||
this.$root.$emit('preview-deleted')
|
||||
this.closeHovers()
|
||||
return
|
||||
}
|
||||
|
||||
this.closeHovers()
|
||||
|
||||
if (this.selectedCount === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -41,19 +41,7 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
// If we're showing this on a listing,
|
||||
// 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)
|
||||
this.fillOptions(this.req)
|
||||
},
|
||||
methods: {
|
||||
fillOptions (req) {
|
||||
|
||||
@@ -27,6 +27,7 @@ import { mapState } from 'vuex'
|
||||
import FileList from './FileList'
|
||||
import { files as api } from '@/api'
|
||||
import buttons from '@/utils/buttons'
|
||||
import * as upload from '@/utils/upload'
|
||||
|
||||
export default {
|
||||
name: 'move',
|
||||
@@ -41,26 +42,51 @@ export default {
|
||||
methods: {
|
||||
move: async function (event) {
|
||||
event.preventDefault()
|
||||
buttons.loading('move')
|
||||
let items = []
|
||||
|
||||
for (let item of this.selected) {
|
||||
items.push({
|
||||
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 {
|
||||
api.move(items)
|
||||
buttons.success('move')
|
||||
this.$router.push({ path: this.dest })
|
||||
} catch (e) {
|
||||
buttons.done('move')
|
||||
this.$showError(e)
|
||||
let action = async (overwrite, rename) => {
|
||||
buttons.loading('move')
|
||||
|
||||
await api.move(items, overwrite, rename).then(() => {
|
||||
buttons.success('move')
|
||||
this.$router.push({ path: this.dest })
|
||||
}).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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import Copy from './Copy'
|
||||
import NewFile from './NewFile'
|
||||
import NewDir from './NewDir'
|
||||
import Replace from './Replace'
|
||||
import ReplaceRename from './ReplaceRename'
|
||||
import Share from './Share'
|
||||
import Upload from './Upload'
|
||||
import { mapState } from 'vuex'
|
||||
@@ -35,6 +36,7 @@ export default {
|
||||
NewDir,
|
||||
Help,
|
||||
Replace,
|
||||
ReplaceRename,
|
||||
Upload
|
||||
},
|
||||
data: function () {
|
||||
@@ -52,7 +54,7 @@ export default {
|
||||
return
|
||||
|
||||
let prompt = this.$refs.currentComponent;
|
||||
|
||||
|
||||
// Enter
|
||||
if (event.keyCode == 13) {
|
||||
switch (this.show) {
|
||||
@@ -87,6 +89,7 @@ export default {
|
||||
'newDir',
|
||||
'download',
|
||||
'replace',
|
||||
'replace-rename',
|
||||
'share',
|
||||
'upload'
|
||||
].indexOf(this.show) >= 0;
|
||||
|
||||
35
frontend/src/components/prompts/ReplaceRename.vue
Normal file
35
frontend/src/components/prompts/ReplaceRename.vue
Normal 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>
|
||||
@@ -27,9 +27,11 @@ 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,11 @@ export default {
|
||||
return this.commands.join(' ')
|
||||
},
|
||||
set (value) {
|
||||
this.$emit('update:commands', value.split(' '))
|
||||
if (value !== '') {
|
||||
this.$emit('update:commands', value.split(' '))
|
||||
} else {
|
||||
this.$emit('update:commands', [])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,14 @@
|
||||
<p><input type="checkbox" :disabled="admin" v-model="perm.delete"> {{ $t('settings.perm.delete') }}</p>
|
||||
<p><input type="checkbox" :disabled="admin" v-model="perm.download"> {{ $t('settings.perm.download') }}</p>
|
||||
<p><input type="checkbox" :disabled="admin" v-model="perm.modify"> {{ $t('settings.perm.modify') }}</p>
|
||||
<p><input type="checkbox" :disabled="admin" v-model="perm.execute"> {{ $t('settings.perm.execute') }}</p>
|
||||
<p v-if="isExecEnabled"><input type="checkbox" :disabled="admin" v-model="perm.execute"> {{ $t('settings.perm.execute') }}</p>
|
||||
<p><input type="checkbox" :disabled="admin" v-model="perm.rename"> {{ $t('settings.perm.rename') }}</p>
|
||||
<p><input type="checkbox" :disabled="admin" v-model="perm.share"> {{ $t('settings.perm.share') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { enableExec } from '@/utils/constants'
|
||||
export default {
|
||||
name: 'permissions',
|
||||
props: ['perm'],
|
||||
@@ -33,7 +34,8 @@ export default {
|
||||
|
||||
this.perm.admin = value
|
||||
}
|
||||
}
|
||||
},
|
||||
isExecEnabled: () => enableExec
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -24,8 +24,12 @@
|
||||
<input type="checkbox" :disabled="user.perm.admin" v-model="user.lockPassword"> {{ $t('settings.lockPassword') }}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<input type="checkbox" v-model="user.singleClick"> {{ $t('settings.singleClick') }}
|
||||
</p>
|
||||
|
||||
<permissions :perm.sync="user.perm" />
|
||||
<commands :commands.sync="user.commands" />
|
||||
<commands v-if="isExecEnabled" :commands.sync="user.commands" />
|
||||
|
||||
<div v-if="!isDefault">
|
||||
<h3>{{ $t('settings.rules') }}</h3>
|
||||
@@ -40,6 +44,7 @@ import Languages from './Languages'
|
||||
import Rules from './Rules'
|
||||
import Permissions from './Permissions'
|
||||
import Commands from './Commands'
|
||||
import { enableExec } from '@/utils/constants'
|
||||
|
||||
export default {
|
||||
name: 'user',
|
||||
@@ -53,7 +58,8 @@ export default {
|
||||
computed: {
|
||||
passwordPlaceholder () {
|
||||
return this.isNew ? '' : this.$t('settings.avoidChanges')
|
||||
}
|
||||
},
|
||||
isExecEnabled: () => enableExec
|
||||
},
|
||||
watch: {
|
||||
'user.perm.admin': function () {
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
.button--red:hover {
|
||||
background: var(--dark-red);
|
||||
.button--blue {
|
||||
background: var(--blue);
|
||||
}
|
||||
|
||||
.button--flat {
|
||||
|
||||
@@ -1,29 +1,61 @@
|
||||
.share__box {
|
||||
text-align: center;
|
||||
box-shadow: rgba(0, 0, 0, 0.06) 0px 1px 3px, rgba(0, 0, 0, 0.12) 0px 1px 2px;
|
||||
background: #fff;
|
||||
display: block;
|
||||
border-radius: 0.2em;
|
||||
width: 90%;
|
||||
max-width: 25em;
|
||||
margin: 6em auto;
|
||||
.share {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.share__box__download {
|
||||
width: 100%;
|
||||
@media (max-width: 736px) {
|
||||
.share {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.share__box {
|
||||
box-shadow: rgba(0, 0, 0, 0.06) 0px 1px 3px, rgba(0, 0, 0, 0.12) 0px 1px 2px;
|
||||
background: #fff;
|
||||
border-radius: 0.2em;
|
||||
margin: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.share__box__header {
|
||||
padding: 1em;
|
||||
cursor: pointer;
|
||||
background: #ffffff;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.share__box__icon i {
|
||||
font-size: 10em;
|
||||
color: #40c4ff;
|
||||
}
|
||||
|
||||
.share__box__center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.share__box__info {
|
||||
padding: 2em 3em;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.share__box__title {
|
||||
margin-top: .2em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
.share__box__element {
|
||||
padding: 1em;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.share__box__items {
|
||||
text-align: left;
|
||||
flex: 10 0 25em;
|
||||
}
|
||||
|
||||
.share__box__items #listing.list .item {
|
||||
cursor: auto;
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
border-bottom: 0;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.share__box__items #listing.list .item .name {
|
||||
width: auto;
|
||||
}
|
||||
@@ -119,14 +119,24 @@
|
||||
|
||||
#previewer .bar {
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
display: flex;
|
||||
padding: 0.5em;
|
||||
height: 3.7em;
|
||||
}
|
||||
|
||||
#previewer .action:first-of-type {
|
||||
margin-right: auto;
|
||||
#previewer .bar > * {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
#previewer .bar .title {
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
padding: 0 1em;
|
||||
line-height: 2.3em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 1.2em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#previewer .action i {
|
||||
@@ -184,6 +194,54 @@
|
||||
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 .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 *
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
"toggleSidebar": "Toggle sidebar",
|
||||
"update": "Update",
|
||||
"upload": "Upload",
|
||||
"permalink": "Get Permanent Link"
|
||||
"permalink": "Get Permanent Link",
|
||||
"hideDotfiles": "Hide dotfiles"
|
||||
},
|
||||
"success": {
|
||||
"linkCopied": "Link copied!"
|
||||
@@ -173,13 +174,14 @@
|
||||
"globalRules": "This is a global set of allow and disallow rules. They apply to every user. You can define specific rules on each user's settings to override this ones.",
|
||||
"allowSignup": "Allow users to signup",
|
||||
"createUserDir": "Auto create user home dir while adding new user",
|
||||
"singleClick": "Use single clicks to open files and directories",
|
||||
"insertRegex": "Insert regex expression",
|
||||
"insertPath": "Insert the path",
|
||||
"userUpdated": "User updated!",
|
||||
"userDefaults": "User default settings",
|
||||
"defaultUserDescription": "This are the default settings for new users.",
|
||||
"executeOnShell": "Execute on shell",
|
||||
"executeOnShellDescription": "By default, File Browser executes the commands by calling their binaries directly. If you want to run them on a shell instead (such as Bash or PowerShell), you can define it here with the required arguments and flags. If set, the command you execute will be appended as an argument. This apply to both user commands and event hooks.",
|
||||
"executeOnShellDescription": "By default, File Browser executes the commands by calling their binaries directly. If you want to run them on a shell instead (such as Bash or PowerShell), you can define it here with the required arguments and flags. If set, the command you execute will be appended as an argument. This apply to both user commands and event hooks.",
|
||||
"perm": {
|
||||
"create": "Create files and directories",
|
||||
"delete": "Delete files and directories",
|
||||
@@ -188,7 +190,8 @@
|
||||
"execute": "Execute commands",
|
||||
"rename": "Rename or move files and directories",
|
||||
"share": "Share files"
|
||||
}
|
||||
},
|
||||
"hideDotfiles": "Hide dotfiles"
|
||||
},
|
||||
"sidebar": {
|
||||
"help": "Help",
|
||||
@@ -244,4 +247,4 @@
|
||||
"downloadFile": "Download File",
|
||||
"downloadFolder": "Download Folder"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"home": "Accueil",
|
||||
"lastModified": "Dernière modification",
|
||||
"loading": "Chargement...",
|
||||
"lonely": "Il semble qu'il n'y ai rien par ici...",
|
||||
"lonely": "Il semble qu'il n'y ait rien par ici...",
|
||||
"metadata": "Metadonnées",
|
||||
"multipleSelectionEnabled": "Sélection multiple activée",
|
||||
"name": "Nom",
|
||||
|
||||
@@ -90,8 +90,8 @@
|
||||
"copy": "复制",
|
||||
"copyMessage": "请选择欲复制至的目录:",
|
||||
"currentlyNavigating": "当前目录:",
|
||||
"deleteMessageMultiple": "你确定要删除这 {count} 个文件吗?",
|
||||
"deleteMessageSingle": "你确定要删除这个文件/文件夹吗?",
|
||||
"deleteMessageMultiple": "你确定要删除这 {count} 个文件吗?",
|
||||
"deleteMessageSingle": "你确定要删除这个文件/文件夹吗?",
|
||||
"deleteTitle": "删除文件",
|
||||
"displayName": "名称:",
|
||||
"download": "下载文件",
|
||||
@@ -112,31 +112,38 @@
|
||||
"replaceMessage": "您尝试上传的文件中有一个与现有文件的名称存在冲突。是否替换现有的同名文件?",
|
||||
"rename": "重命名",
|
||||
"renameMessage": "请输入新名称,旧名称为:",
|
||||
"show": "揭示",
|
||||
"show": "点击以显示",
|
||||
"size": "大小",
|
||||
"schedule": "计划",
|
||||
"scheduleMessage": "请选择发布这篇帖子的日期。",
|
||||
"newArchetype": "创建一个基于原型的新帖子。您的文件将会创建在内容文件夹中。"
|
||||
"newArchetype": "创建一个基于原型的新帖子。您的文件将会创建在内容文件夹中。",
|
||||
"upload": "上传",
|
||||
"uploadMessage": "选择上传项。"
|
||||
},
|
||||
"settings": {
|
||||
"instanceName": "Instance name",
|
||||
"themes": {
|
||||
"title": "主题",
|
||||
"light": "浅色",
|
||||
"dark": "深色"
|
||||
},
|
||||
"instanceName": "实例名称",
|
||||
"brandingDirectoryPath": "品牌信息文件夹路径",
|
||||
"documentation": "帮助文档",
|
||||
"branding": "品牌",
|
||||
"disableExternalLinks": "禁止外部链接(帮助文档除外)",
|
||||
"brandingHelp": "您可以通过改变名称,更换商标,加入自定义样式,甚至禁用外部链接来自定义File Browser的外观和给人的感觉。\n想获得更多信息,请查看 {0} 。",
|
||||
"brandingHelp": "您可以通过改变实例名称,更换Logo,加入自定义样式,甚至禁用到Github的外部链接来自定义File Browser的外观和给人的感觉。\n想获得更多信息,请查看 {0} 。",
|
||||
"admin": "管理员",
|
||||
"administrator": "管理员",
|
||||
"allowCommands": "执行命令(Linux 代码)",
|
||||
"allowCommands": "执行命令(shell 命令)",
|
||||
"allowEdit": "编辑、重命名或删除文件/目录",
|
||||
"allowNew": "创建新文件和目录",
|
||||
"allowPublish": "发布新的帖子与页面",
|
||||
"avoidChanges": "(留空以避免更改)",
|
||||
"avoidChanges": "(留空以避免更改)",
|
||||
"changePassword": "更改密码",
|
||||
"commandRunner": "命令执行器",
|
||||
"commandRunnerHelp": "Here you can set commands that are executed in the named events. You must write one per line. The environment variables {0} and {1} will be available, being {0} relative to {1}. For more information about this feature and the available environment variables, please read the {2}.",
|
||||
"commandRunnerHelp": "在这里你可以设置在下面的事件中执行的命令。每行必须写一条命令。可以在命令中使用环境变量 {0} 和 {1}。关于此功能和可用环境变量的更多信息,请阅读{2}.",
|
||||
"commandsUpdated": "命令已更新!",
|
||||
"customStylesheet": "自定义样式表",
|
||||
"customStylesheet": "自定义样式表(CSS)",
|
||||
"examples": "例子",
|
||||
"globalSettings": "全局设置",
|
||||
"language": "语言",
|
||||
@@ -149,15 +156,15 @@
|
||||
"permissions": "权限",
|
||||
"permissionsHelp": "您可以将该用户设置为管理员,也可以单独选择各项权限。如果选择了“管理员”,则其他的选项会被自动勾上,同时该用户可以管理其他用户。",
|
||||
"profileSettings": "个人设置",
|
||||
"ruleExample1": "阻止用户访问所有文件夹下任何以 . 开头的文件(隐藏文件, 例如: .git, .gitignore)。",
|
||||
"ruleExample1": "阻止用户访问所有文件夹下任何以 . 开头的文件(隐藏文件, 例如: .git, .gitignore)。",
|
||||
"ruleExample2": "阻止用户访问其目录范围的根目录下名为 Caddyfile 的文件。",
|
||||
"rules": "规则",
|
||||
"rulesHelp": "您可以为该用户制定一组黑名单或白名单式的规则,被屏蔽的文件将不会显示在列表中,用户也无权限访问,支持相对于目录范围的路径。",
|
||||
"scope": "目录范围",
|
||||
"settingsUpdated": "设置已更新!",
|
||||
"user": "用户",
|
||||
"userCommands": "用户命令(Linux 代码)",
|
||||
"userCommandsHelp": "指定该用户可以执行的命令(Linux 代码),用空格分隔。例如:",
|
||||
"userCommands": "用户命令(shell 命令)",
|
||||
"userCommandsHelp": "指定该用户可以执行的命令(shell 代码),用空格分隔。例如:",
|
||||
"userCreated": "用户已创建!",
|
||||
"userDeleted": "用户已删除!",
|
||||
"userManagement": "用户管理",
|
||||
@@ -167,12 +174,12 @@
|
||||
"allowSignup": "允许用户注册",
|
||||
"createUserDir": "在添加新用户的同时自动创建用户的个人目录",
|
||||
"insertRegex": "插入正则表达式",
|
||||
"insertPath": "Insert the path",
|
||||
"insertPath": "插入路径",
|
||||
"userUpdated": "用户已更新!",
|
||||
"userDefaults": "用户默认设置",
|
||||
"defaultUserDescription": "这些是新用户的默认设置",
|
||||
"defaultUserDescription": "这些是新用户的默认设置。",
|
||||
"executeOnShell": "在Shell中执行",
|
||||
"executeOnShellDescription": "By default, File Browser executes the commands by calling their binaries directly. If you want to run them on a shell instead (such as Bash or PowerShell), you can define it here with the required arguments and flags. If set, the command you execute will be appended as an argument. This apply to both user commands and event hooks.",
|
||||
"executeOnShellDescription": "默认情况下,File Browser通过直接调用命令的二进制包来执行命令,如果想在shell中执行(如Bash、PowerShell),你可以在这里定义所使用的shell和参数。如果设置了这个选项,所执行的命令会作为参数追加在后面。本选项对用户命令和事件钩子都生效。",
|
||||
"perm": {
|
||||
"create": "创建文件和文件夹",
|
||||
"delete": "删除文件和文件夹",
|
||||
@@ -209,18 +216,22 @@
|
||||
"languages": {
|
||||
"ar": "العربية",
|
||||
"en": "English",
|
||||
"is": "Icelandic",
|
||||
"it": "Italiano",
|
||||
"fr": "Français",
|
||||
"pt": "Português",
|
||||
"ptBR": "Português (Brasil)",
|
||||
"ptBR": "Português(Brasil)",
|
||||
"ja": "日本語",
|
||||
"zhCN": "中文 (简体)",
|
||||
"zhTW": "中文 (繁體)",
|
||||
"zhCN": "中文(简体)",
|
||||
"zhTW": "中文(繁體)",
|
||||
"es": "Español",
|
||||
"de": "Deutsch",
|
||||
"ru": "Русский",
|
||||
"pl": "Polski",
|
||||
"ko": "한국어"
|
||||
"ko": "한국어",
|
||||
"nlBE": "Dutch(Belgium)",
|
||||
"ro": "Romanian",
|
||||
"svSE": "Swedish(Sweden)"
|
||||
},
|
||||
"time": {
|
||||
"unit": "时间单位",
|
||||
|
||||
@@ -116,15 +116,22 @@
|
||||
"size": "大小",
|
||||
"schedule": "計畫",
|
||||
"scheduleMessage": "請選擇發佈這篇貼文的日期。",
|
||||
"newArchetype": "建立一個基於原型的新貼文。您的檔案將會建立在內容資料夾中。"
|
||||
"newArchetype": "建立一個基於原型的新貼文。您的檔案將會建立在內容資料夾中。",
|
||||
"upload": "上傳",
|
||||
"uploadMessage": "選擇上傳項。"
|
||||
},
|
||||
"settings": {
|
||||
"instanceName": "Instance name",
|
||||
"brandingDirectoryPath": "Branding directory path",
|
||||
"documentation": "documentation",
|
||||
"branding": "Branding",
|
||||
"disableExternalLinks": "Disable external links (except documentation)",
|
||||
"brandingHelp": "You can costumize how your File Browser instance looks and feels by changing its name, replacing the logo, adding custom styles and even disable external links to GitHub.\nFor more information about custom branding, please check out the {0}.",
|
||||
"themes": {
|
||||
"title": "主題",
|
||||
"light": "淺色",
|
||||
"dark": "深色"
|
||||
},
|
||||
"instanceName": "例項名稱",
|
||||
"brandingDirectoryPath": "品牌資訊資料夾路徑",
|
||||
"documentation": "幫助文件",
|
||||
"branding": "品牌",
|
||||
"disableExternalLinks": "禁止外部連結(幫助文件除外)",
|
||||
"brandingHelp": "您可以通過改變例項名稱,更換Logo,加入自定義樣式,甚至禁用到Github的外部連結來自定義File Browser的外觀和給人的感覺。\n想獲得更多資訊,請檢視 {0} 。",
|
||||
"admin": "管理員",
|
||||
"administrator": "管理員",
|
||||
"allowCommands": "執行命令",
|
||||
@@ -133,8 +140,8 @@
|
||||
"allowPublish": "發佈新的貼文與頁面",
|
||||
"avoidChanges": "(留空以避免更改)",
|
||||
"changePassword": "更改密碼",
|
||||
"commandRunner": "Command runner",
|
||||
"commandRunnerHelp": "Here you can set commands that are executed in the named events. You must write one per line. The environment variables {0} and {1} will be available, being {0} relative to {1}. For more information about this feature and the available environment variables, please read the {2}.",
|
||||
"commandRunner": "命令執行器",
|
||||
"commandRunnerHelp": "在這裡你可以設定在下面的事件中執行的命令。每行必須寫一條命令。可以在命令中使用環境變數 {0} 和 {1}。關於此功能和可用環境變數的更多資訊,請閱讀{2}.",
|
||||
"commandsUpdated": "命令已更新!",
|
||||
"customStylesheet": "自定義樣式表",
|
||||
"examples": "範例",
|
||||
@@ -163,22 +170,22 @@
|
||||
"userManagement": "使用者管理",
|
||||
"username": "使用者名稱",
|
||||
"users": "使用者",
|
||||
"globalRules": "This is a global set of allow and disallow rules. They apply to every user. You can define specific rules on each user's settings to override this ones.",
|
||||
"allowSignup": "Allow users to signup",
|
||||
"createUserDir": "Auto create user home dir while adding new user",
|
||||
"insertRegex": "Insert regex expression",
|
||||
"insertPath": "Insert the path",
|
||||
"globalRules": "這是全局允許與禁止規則。它們作用於所有使用者。您可以給每個使用者定義單獨的特殊規則來覆蓋全局規則。",
|
||||
"allowSignup": "允許使用者註冊",
|
||||
"createUserDir": "在新增新使用者的同時自動建立使用者的個人目錄",
|
||||
"insertRegex": "插入正規表示式",
|
||||
"insertPath": "插入路徑",
|
||||
"userUpdated": "使用者已更新!",
|
||||
"userDefaults": "User default settings",
|
||||
"defaultUserDescription": "This are the default settings for new users.",
|
||||
"executeOnShell": "Execute on shell",
|
||||
"executeOnShellDescription": "By default, File Browser executes the commands by calling their binaries directly. If you want to run them on a shell instead (such as Bash or PowerShell), you can define it here with the required arguments and flags. If set, the command you execute will be appended as an argument. This apply to both user commands and event hooks.",
|
||||
"userDefaults": "使用者預設選項",
|
||||
"defaultUserDescription": "這些是新使用者的預設設定。",
|
||||
"executeOnShell": "在Shell中執行",
|
||||
"executeOnShellDescription": "預設情況下,File Browser通過直接呼叫命令的二進位制包來執行命令,如果想在shell中執行(如Bash、PowerShell),你可以在這裡定義所使用的shell和參數。如果設定了這個選項,所執行的命令會作為參數追加在後面。本選項對使用者命令和事件鉤子都生效。",
|
||||
"perm": {
|
||||
"create": "建立檔案和資料夾",
|
||||
"delete": "刪除檔案和資料夾",
|
||||
"download": "下載",
|
||||
"modify": "編輯檔案",
|
||||
"execute": "Execute commands",
|
||||
"execute": "執行命令",
|
||||
"rename": "重命名或移動檔案/資料夾",
|
||||
"share": "分享檔案"
|
||||
}
|
||||
@@ -203,12 +210,13 @@
|
||||
"types": "類型",
|
||||
"video": "影片",
|
||||
"search": "搜尋...",
|
||||
"typeToSearch": "Type to search...",
|
||||
"pressToSearch": "Press enter to search..."
|
||||
"typeToSearch": "輸入以搜尋...",
|
||||
"pressToSearch": "按確認鍵搜尋..."
|
||||
},
|
||||
"languages": {
|
||||
"ar": "العربية",
|
||||
"en": "English",
|
||||
"is": "Icelandic",
|
||||
"it": "Italiano",
|
||||
"fr": "Français",
|
||||
"pt": "Português",
|
||||
@@ -220,7 +228,10 @@
|
||||
"de": "Deutsch",
|
||||
"ru": "Русский",
|
||||
"pl": "Polski",
|
||||
"ko": "한국어"
|
||||
"ko": "한국어",
|
||||
"nlBE": "Dutch(Belgium)",
|
||||
"ro": "Romanian",
|
||||
"svSE": "Swedish(Sweden)"
|
||||
},
|
||||
"time": {
|
||||
"unit": "時間單位",
|
||||
|
||||
@@ -3,7 +3,16 @@ const getters = {
|
||||
isFiles: state => !state.loading && state.route.name === 'Files',
|
||||
isListing: (state, getters) => getters.isFiles && state.req.isDir,
|
||||
isEditor: (state, getters) => getters.isFiles && (state.req.type === 'text' || state.req.type === 'textImmutable'),
|
||||
selectedCount: state => state.selected.length
|
||||
isPreview: state => state.previewMode,
|
||||
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
|
||||
|
||||
@@ -2,6 +2,7 @@ import Vue from 'vue'
|
||||
import Vuex from 'vuex'
|
||||
import mutations from './mutations'
|
||||
import getters from './getters'
|
||||
import upload from './modules/upload'
|
||||
|
||||
Vue.use(Vuex)
|
||||
|
||||
@@ -22,12 +23,14 @@ const state = {
|
||||
show: null,
|
||||
showShell: false,
|
||||
showMessage: null,
|
||||
showConfirm: null
|
||||
showConfirm: null,
|
||||
previewMode: false
|
||||
}
|
||||
|
||||
export default new Vuex.Store({
|
||||
strict: true,
|
||||
state,
|
||||
getters,
|
||||
mutations
|
||||
mutations,
|
||||
modules: { upload }
|
||||
})
|
||||
|
||||
102
frontend/src/store/modules/upload.js
Normal file
102
frontend/src/store/modules/upload.js
Normal 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 }
|
||||
@@ -78,13 +78,14 @@ const mutations = {
|
||||
updateClipboard: (state, value) => {
|
||||
state.clipboard.key = value.key
|
||||
state.clipboard.items = value.items
|
||||
state.clipboard.path = value.path
|
||||
},
|
||||
resetClipboard: (state) => {
|
||||
state.clipboard.key = ''
|
||||
state.clipboard.items = []
|
||||
},
|
||||
setProgress: (state, value) => {
|
||||
state.progress = value
|
||||
setPreviewMode(state, value) {
|
||||
state.previewMode = value
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ const noAuth = window.FileBrowser.NoAuth
|
||||
const authMethod = window.FileBrowser.AuthMethod
|
||||
const loginPage = window.FileBrowser.LoginPage
|
||||
const theme = window.FileBrowser.Theme
|
||||
const enableThumbs = window.FileBrowser.EnableThumbs
|
||||
const resizePreview = window.FileBrowser.ResizePreview
|
||||
const enableExec = window.FileBrowser.EnableExec
|
||||
|
||||
export {
|
||||
name,
|
||||
@@ -24,5 +27,8 @@ export {
|
||||
noAuth,
|
||||
authMethod,
|
||||
loginPage,
|
||||
theme
|
||||
theme,
|
||||
enableThumbs,
|
||||
resizePreview,
|
||||
enableExec
|
||||
}
|
||||
|
||||
124
frontend/src/utils/upload.js
Normal file
124
frontend/src/utils/upload.js
Normal file
@@ -0,0 +1,124 @@
|
||||
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, base, overwrite = false) {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
let id = store.state.upload.id
|
||||
let path = base
|
||||
let file = files[i]
|
||||
|
||||
if (file.fullPath !== undefined) {
|
||||
path += url.encodePath(file.fullPath)
|
||||
} else {
|
||||
path += url.encodeRFC5987ValueChars(file.name)
|
||||
}
|
||||
|
||||
if (file.isDir) {
|
||||
path += '/'
|
||||
}
|
||||
|
||||
const item = {
|
||||
id,
|
||||
path,
|
||||
file,
|
||||
overwrite
|
||||
}
|
||||
|
||||
store.dispatch('upload/upload', item);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,12 @@ function encodeRFC5987ValueChars(str) {
|
||||
replace(/%(?:7C|60|5E)/g, unescape);
|
||||
}
|
||||
|
||||
function encodePath(str) {
|
||||
return str.split('/').map(v => encodeURIComponent(v)).join('/')
|
||||
}
|
||||
|
||||
export default {
|
||||
encodeRFC5987ValueChars: encodeRFC5987ValueChars,
|
||||
removeLastDir: removeLastDir
|
||||
removeLastDir: removeLastDir,
|
||||
encodePath: encodePath
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import Vue from 'vue'
|
||||
import Noty from 'noty'
|
||||
import VueLazyload from 'vue-lazyload'
|
||||
import i18n from '@/i18n'
|
||||
import { disableExternal } from '@/utils/constants'
|
||||
|
||||
Vue.use(VueLazyload)
|
||||
|
||||
Vue.config.productionTip = true
|
||||
|
||||
const notyDefault = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<div id="breadcrumbs">
|
||||
<div id="breadcrumbs" v-if="isListing || error">
|
||||
<router-link to="/files/" :aria-label="$t('files.home')" :title="$t('files.home')">
|
||||
<i class="material-icons">home</i>
|
||||
</router-link>
|
||||
@@ -10,14 +10,15 @@
|
||||
<router-link :to="link.url">{{ link.name }}</router-link>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="error">
|
||||
<not-found v-if="error.message === '404'"></not-found>
|
||||
<forbidden v-else-if="error.message === '403'"></forbidden>
|
||||
<internal-error v-else></internal-error>
|
||||
</div>
|
||||
<preview v-else-if="isPreview"></preview>
|
||||
<editor v-else-if="isEditor"></editor>
|
||||
<listing :class="{ multiple }" v-else-if="isListing"></listing>
|
||||
<preview v-else-if="isPreview"></preview>
|
||||
<div v-else>
|
||||
<h2 class="message">
|
||||
<span>{{ $t('files.loading') }}</span>
|
||||
@@ -61,10 +62,11 @@ export default {
|
||||
'user',
|
||||
'reload',
|
||||
'multiple',
|
||||
'loading'
|
||||
'loading',
|
||||
'show'
|
||||
]),
|
||||
isPreview () {
|
||||
return !this.loading && !this.isListing && !this.isEditor
|
||||
return !this.loading && !this.isListing && !this.isEditor || this.loading && this.$store.state.previewMode
|
||||
},
|
||||
breadcrumbs () {
|
||||
let parts = this.$route.path.split('/')
|
||||
@@ -158,10 +160,17 @@ export default {
|
||||
}
|
||||
},
|
||||
keyEvent (event) {
|
||||
// Esc!
|
||||
if (event.keyCode === 27) {
|
||||
this.$store.commit('closeHovers')
|
||||
if (this.show !== null) {
|
||||
// Esc!
|
||||
if (event.keyCode === 27) {
|
||||
this.$store.commit('closeHovers')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Esc!
|
||||
if (event.keyCode === 27) {
|
||||
// If we're on a listing, unselect all
|
||||
// files and folders.
|
||||
if (this.isListing) {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div>
|
||||
<div id="progress">
|
||||
<div v-bind:style="{ width: $store.state.progress + '%' }"></div>
|
||||
<div v-bind:style="{ width: this.progress + '%' }"></div>
|
||||
</div>
|
||||
<site-header></site-header>
|
||||
<sidebar></sidebar>
|
||||
<main>
|
||||
<router-view></router-view>
|
||||
<shell v-if="isLogged && user.perm.execute" />
|
||||
<shell v-if="isExecEnabled && isLogged && user.perm.execute" />
|
||||
</main>
|
||||
<prompts></prompts>
|
||||
</div>
|
||||
@@ -19,6 +19,7 @@ import Sidebar from '@/components/Sidebar'
|
||||
import Prompts from '@/components/prompts/Prompts'
|
||||
import SiteHeader from '@/components/Header'
|
||||
import Shell from '@/components/Shell'
|
||||
import { enableExec } from '@/utils/constants'
|
||||
|
||||
export default {
|
||||
name: 'layout',
|
||||
@@ -29,8 +30,9 @@ export default {
|
||||
Shell
|
||||
},
|
||||
computed: {
|
||||
...mapGetters([ 'isLogged' ]),
|
||||
...mapState([ 'user' ])
|
||||
...mapGetters([ 'isLogged', 'progress' ]),
|
||||
...mapState([ 'user' ]),
|
||||
isExecEnabled: () => enableExec
|
||||
},
|
||||
watch: {
|
||||
'$route': function () {
|
||||
|
||||
@@ -1,29 +1,56 @@
|
||||
<template>
|
||||
<div class="share" v-if="loaded">
|
||||
<a target="_blank" :href="link">
|
||||
<div class="share__box">
|
||||
<div class="share__box__download" v-if="file.isDir">{{ $t('download.downloadFolder') }}</div>
|
||||
<div class="share__box__download" v-else>{{ $t('download.downloadFile') }}</div>
|
||||
<div class="share__box__info">
|
||||
<svg v-if="file.isDir" fill="#40c4ff" height="150" viewBox="0 0 24 24" width="150" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/>
|
||||
<path d="M0 0h24v24H0z" fill="none"/>
|
||||
</svg>
|
||||
<svg v-else fill="#40c4ff" height="150" viewBox="0 0 24 24" width="150" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/>
|
||||
<path d="M0 0h24v24H0z" fill="none"/>
|
||||
</svg>
|
||||
<h1 class="share__box__title">{{ file.name }}</h1>
|
||||
<div class="share__box share__box__info">
|
||||
<div class="share__box__header">
|
||||
{{ file.isDir ? $t('download.downloadFolder') : $t('download.downloadFile') }}
|
||||
</div>
|
||||
<div class="share__box__element share__box__center share__box__icon">
|
||||
<i class="material-icons">{{ file.isDir ? 'folder' : 'insert_drive_file'}}</i>
|
||||
</div>
|
||||
<div class="share__box__element">
|
||||
<strong>{{ $t('prompts.displayName') }}</strong> {{ file.name }}
|
||||
</div>
|
||||
<div class="share__box__element">
|
||||
<strong>{{ $t('prompts.lastModified') }}:</strong> {{ humanTime }}
|
||||
</div>
|
||||
<div class="share__box__element">
|
||||
<strong>{{ $t('prompts.size') }}:</strong> {{ humanSize }}
|
||||
</div>
|
||||
<div class="share__box__element share__box__center">
|
||||
<a target="_blank" :href="link" class="button button--flat">{{ $t('buttons.download') }}</a>
|
||||
</div>
|
||||
<div class="share__box__element share__box__center">
|
||||
<qrcode-vue :value="fullLink" size="200" level="M"></qrcode-vue>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="file.isDir" class="share__box share__box__items">
|
||||
<div class="share__box__header" v-if="file.isDir">
|
||||
{{ $t('files.files') }}
|
||||
</div>
|
||||
</a>
|
||||
<div id="listing" class="list">
|
||||
<div class="item" v-for="(item) in file.items.slice(0, this.showLimit)" :key="base64(item.name)">
|
||||
<div>
|
||||
<i class="material-icons">{{ item.isDir ? 'folder' : (item.type==='image') ? 'insert_photo' : 'insert_drive_file' }}</i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="name">{{ item.name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="file.items.length > showLimit" class="item">
|
||||
<div>
|
||||
<p class="name"> + {{ file.items.length - showLimit }} </p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { share as api } from '@/api'
|
||||
import { baseURL } from '@/utils/constants'
|
||||
import filesize from 'filesize'
|
||||
import moment from 'moment'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
|
||||
export default {
|
||||
@@ -34,7 +61,8 @@ export default {
|
||||
data: () => ({
|
||||
loaded: false,
|
||||
notFound: false,
|
||||
file: null
|
||||
file: null,
|
||||
showLimit: 500
|
||||
}),
|
||||
watch: {
|
||||
'$route': 'fetchData'
|
||||
@@ -52,8 +80,21 @@ export default {
|
||||
fullLink: function () {
|
||||
return window.location.origin + this.link
|
||||
},
|
||||
humanSize: function () {
|
||||
if (this.file.isDir) {
|
||||
return this.file.items.length
|
||||
}
|
||||
|
||||
return filesize(this.file.size)
|
||||
},
|
||||
humanTime: function () {
|
||||
return moment(this.file.modified).fromNow()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
base64: function (name) {
|
||||
return window.btoa(unescape(encodeURIComponent(name)))
|
||||
},
|
||||
fetchData: async function () {
|
||||
try {
|
||||
this.file = await api.getHash(this.hash)
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
<p class="small">{{ $t('settings.globalRules') }}</p>
|
||||
<rules :rules.sync="settings.rules" />
|
||||
|
||||
<h3>{{ $t('settings.executeOnShell') }}</h3>
|
||||
<p class="small">{{ $t('settings.executeOnShellDescription') }}</p>
|
||||
<input class="input input--block" type="text" placeholder="bash -c, cmd /c, ..." v-model="settings.shell" />
|
||||
<div v-if="isExecEnabled">
|
||||
<h3>{{ $t('settings.executeOnShell') }}</h3>
|
||||
<p class="small">{{ $t('settings.executeOnShellDescription') }}</p>
|
||||
<input class="input input--block" type="text" placeholder="bash -c, cmd /c, ..." v-model="settings.shell" />
|
||||
</div>
|
||||
|
||||
<h3>{{ $t('settings.branding') }}</h3>
|
||||
|
||||
@@ -67,7 +69,7 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form class="card" @submit.prevent="save">
|
||||
<form v-if="isExecEnabled" class="card" @submit.prevent="save">
|
||||
<div class="card-title">
|
||||
<h2>{{ $t('settings.commandRunner') }}</h2>
|
||||
</div>
|
||||
@@ -104,6 +106,7 @@ import { settings as api } from '@/api'
|
||||
import UserForm from '@/components/settings/UserForm'
|
||||
import Rules from '@/components/settings/Rules'
|
||||
import Themes from '@/components/settings/Themes'
|
||||
import { enableExec } from '@/utils/constants'
|
||||
|
||||
export default {
|
||||
name: 'settings',
|
||||
@@ -119,7 +122,8 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState([ 'user' ])
|
||||
...mapState([ 'user' ]),
|
||||
isExecEnabled: () => enableExec
|
||||
},
|
||||
async created () {
|
||||
try {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<p><input type="checkbox" v-model="hideDotfiles"> {{ $t('settings.hideDotfiles') }}</p>
|
||||
<h3>{{ $t('settings.language') }}</h3>
|
||||
<languages class="input input--block" :locale.sync="locale"></languages>
|
||||
</div>
|
||||
@@ -67,6 +68,7 @@ export default {
|
||||
},
|
||||
created () {
|
||||
this.locale = this.user.locale
|
||||
this.hideDotfiles = this.user.hideDotfiles
|
||||
},
|
||||
methods: {
|
||||
...mapMutations([ 'updateUser' ]),
|
||||
@@ -90,8 +92,8 @@ export default {
|
||||
event.preventDefault()
|
||||
|
||||
try {
|
||||
const data = { id: this.user.id, locale: this.locale }
|
||||
await api.update(data, ['locale'])
|
||||
const data = { id: this.user.id, locale: this.locale, hideDotfiles: this.hideDotfiles }
|
||||
await api.update(data, ['locale', 'hideDotfiles'])
|
||||
this.updateUser(data)
|
||||
this.$showSuccess(this.$t('settings.settingsUpdated'))
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
module.exports = {
|
||||
runtimeCompiler: true,
|
||||
publicPath: '[{[ .StaticURL ]}]'
|
||||
publicPath: '[{[ .StaticURL ]}]',
|
||||
parallel: 2,
|
||||
}
|
||||
4
go.mod
4
go.mod
@@ -13,8 +13,8 @@ require (
|
||||
github.com/golang/snappy v0.0.1 // indirect
|
||||
github.com/gorilla/mux v1.7.3
|
||||
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/marusama/semaphore/v2 v2.4.1
|
||||
github.com/mholt/archiver v3.1.1+incompatible
|
||||
github.com/mitchellh/go-homedir v1.1.0
|
||||
github.com/nwaples/rardecode v1.0.0 // indirect
|
||||
@@ -25,11 +25,13 @@ require (
|
||||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/spf13/viper v1.6.1
|
||||
github.com/stretchr/testify v1.6.1
|
||||
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce
|
||||
github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
||||
go.etcd.io/bbolt v1.3.3
|
||||
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8
|
||||
golang.org/x/net v0.0.0-20200528225125-3c3fba18258b // indirect
|
||||
golang.org/x/sys v0.0.0-20200523222454-059865788121 // indirect
|
||||
golang.org/x/text v0.3.2 // indirect
|
||||
|
||||
8
go.sum
8
go.sum
@@ -87,8 +87,6 @@ github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
|
||||
github.com/grpc-ecosystem/go-grpc-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/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/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
|
||||
github.com/hashicorp/golang-lru v0.0.0-20180201235237-0fb14efe8c47/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
@@ -129,6 +127,8 @@ github.com/marten-seemann/qtls v0.2.3 h1:0yWJ43C62LsZt08vuQJDK1uC1czUc3FJeCLPoNA
|
||||
github.com/marten-seemann/qtls v0.2.3/go.mod h1:xzjG7avBwGGbdZ8dTGxlBnLArsVKLvwmjgmPuiQEcYk=
|
||||
github.com/maruel/natural v0.0.0-20180416170133-dbcb3e2e8cf1 h1:PEhRT94KBTY4E0KdCYmhvDGWjSFBxc68j2M6PMRix8U=
|
||||
github.com/maruel/natural v0.0.0-20180416170133-dbcb3e2e8cf1/go.mod h1:wI697HNhDFM/vBruYM3ckbszQ2+DOIeH9qdBKMdf288=
|
||||
github.com/marusama/semaphore/v2 v2.4.1 h1:Y29DhhFMvreVgoqF9EtaSJAF9t2E7Sk7i5VW81sqB8I=
|
||||
github.com/marusama/semaphore/v2 v2.4.1/go.mod h1:z9nMiNUekt/LTpTUQdpp+4sJeYqUGpwMHfW0Z8V8fnQ=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/mholt/archiver v3.1.1+incompatible h1:1dCVxuqs0dJseYEhi5pl7MYPH9zDa1wBi7mF09cbNkU=
|
||||
github.com/mholt/archiver v3.1.1+incompatible/go.mod h1:Dh2dOXnSdiLxRiPoVfIr/fI1TwETms9B8CTWfeh7ROU=
|
||||
@@ -206,6 +206,8 @@ github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
|
||||
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
|
||||
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
|
||||
@@ -318,4 +320,6 @@ gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo=
|
||||
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
|
||||
@@ -23,9 +23,11 @@ type userInfo struct {
|
||||
ID uint `json:"id"`
|
||||
Locale string `json:"locale"`
|
||||
ViewMode users.ViewMode `json:"viewMode"`
|
||||
SingleClick bool `json:"singleClick"`
|
||||
Perm users.Permissions `json:"perm"`
|
||||
Commands []string `json:"commands"`
|
||||
LockPassword bool `json:"lockPassword"`
|
||||
HideDotfiles bool `json:"hideDotfiles"`
|
||||
}
|
||||
|
||||
type authToken struct {
|
||||
@@ -172,9 +174,11 @@ func printToken(w http.ResponseWriter, _ *http.Request, d *data, user *users.Use
|
||||
ID: user.ID,
|
||||
Locale: user.Locale,
|
||||
ViewMode: user.ViewMode,
|
||||
SingleClick: user.SingleClick,
|
||||
Perm: user.Perm,
|
||||
LockPassword: user.LockPassword,
|
||||
Commands: user.Commands,
|
||||
HideDotfiles: user.HideDotfiles,
|
||||
},
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
IssuedAt: time.Now().Unix(),
|
||||
@@ -189,7 +193,7 @@ func printToken(w http.ResponseWriter, _ *http.Request, d *data, user *users.Use
|
||||
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 {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ var commandsHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *d
|
||||
}
|
||||
}
|
||||
|
||||
if !d.user.CanExecute(strings.Split(raw, " ")[0]) {
|
||||
if !d.server.EnableExec || !d.user.CanExecute(strings.Split(raw, " ")[0]) {
|
||||
if err := conn.WriteMessage(websocket.TextMessage, cmdNotAllowed); err != nil { //nolint:shadow
|
||||
wsErr(conn, r, http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/tomasen/realip"
|
||||
|
||||
"github.com/filebrowser/filebrowser/v2/rules"
|
||||
"github.com/filebrowser/filebrowser/v2/runner"
|
||||
"github.com/filebrowser/filebrowser/v2/settings"
|
||||
"github.com/filebrowser/filebrowser/v2/storage"
|
||||
@@ -26,6 +27,10 @@ type data struct {
|
||||
|
||||
// Check implements rules.Checker.
|
||||
func (d *data) Check(path string) bool {
|
||||
if d.user.HideDotfiles && rules.MatchHidden(path) {
|
||||
return false
|
||||
}
|
||||
|
||||
allow := true
|
||||
for _, rule := range d.settings.Rules {
|
||||
if rule.Matches(path) {
|
||||
@@ -51,7 +56,7 @@ func handle(fn handleFunc, prefix string, store *storage.Storage, server *settin
|
||||
}
|
||||
|
||||
status, err := fn(w, r, &data{
|
||||
Runner: &runner.Runner{Settings: settings},
|
||||
Runner: &runner.Runner{Enabled: server.EnableExec, Settings: settings},
|
||||
store: store,
|
||||
settings: settings,
|
||||
server: server,
|
||||
|
||||
@@ -14,7 +14,7 @@ type modifyRequest struct {
|
||||
Which []string `json:"which"` // Answer to: which fields?
|
||||
}
|
||||
|
||||
func NewHandler(store *storage.Storage, server *settings.Server) (http.Handler, error) {
|
||||
func NewHandler(imgSvc ImgService, fileCache FileCache, store *storage.Storage, server *settings.Server) (http.Handler, error) {
|
||||
server.Clean()
|
||||
|
||||
r := mux.NewRouter()
|
||||
@@ -46,7 +46,7 @@ func NewHandler(store *storage.Storage, server *settings.Server) (http.Handler,
|
||||
users.Handle("/{id:[0-9]+}", monkey(userDeleteHandler, "")).Methods("DELETE")
|
||||
|
||||
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("PUT")
|
||||
api.PathPrefix("/resources").Handler(monkey(resourcePatchHandler, "/api/resources")).Methods("PATCH")
|
||||
@@ -59,7 +59,8 @@ func NewHandler(store *storage.Storage, server *settings.Server) (http.Handler,
|
||||
api.Handle("/settings", monkey(settingsPutHandler, "")).Methods("PUT")
|
||||
|
||||
api.PathPrefix("/raw").Handler(monkey(rawHandler, "/api/raw")).Methods("GET")
|
||||
api.PathPrefix("/preview/{size}/{path:.*}").Handler(monkey(previewHandler, "/api/preview")).Methods("GET")
|
||||
api.PathPrefix("/preview/{size}/{path:.*}").
|
||||
Handler(monkey(previewHandler(imgSvc, fileCache, server.EnableThumbnails, server.ResizePreview), "/api/preview")).Methods("GET")
|
||||
api.PathPrefix("/command").Handler(monkey(commandsHandler, "/api/command")).Methods("GET")
|
||||
api.PathPrefix("/search").Handler(monkey(searchHandler, "/api/search")).Methods("GET")
|
||||
|
||||
|
||||
159
http/preview.go
159
http/preview.go
@@ -1,76 +1,91 @@
|
||||
//go:generate go-enum --sql --marshal --names --file $GOFILE
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/filebrowser/filebrowser/v2/files"
|
||||
"github.com/filebrowser/filebrowser/v2/img"
|
||||
)
|
||||
|
||||
const (
|
||||
sizeThumb = "thumb"
|
||||
sizeBig = "big"
|
||||
/*
|
||||
ENUM(
|
||||
thumb
|
||||
big
|
||||
)
|
||||
*/
|
||||
type PreviewSize int
|
||||
|
||||
type imageProcessor func(src image.Image) (image.Image, error)
|
||||
type ImgService interface {
|
||||
FormatFromExtension(ext string) (img.Format, error)
|
||||
Resize(ctx context.Context, in io.Reader, width, height int, out io.Writer, options ...img.Option) error
|
||||
}
|
||||
|
||||
var previewHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
if !d.user.Perm.Download {
|
||||
return http.StatusAccepted, nil
|
||||
}
|
||||
vars := mux.Vars(r)
|
||||
size := vars["size"]
|
||||
if size != sizeBig && size != sizeThumb {
|
||||
return http.StatusNotImplemented, nil
|
||||
}
|
||||
type FileCache interface {
|
||||
Store(ctx context.Context, key string, value []byte) error
|
||||
Load(ctx context.Context, key string) ([]byte, bool, error)
|
||||
Delete(ctx context.Context, key string) error
|
||||
}
|
||||
|
||||
file, err := files.NewFileInfo(files.FileOptions{
|
||||
Fs: d.user.Fs,
|
||||
Path: "/" + vars["path"],
|
||||
Modify: d.user.Perm.Modify,
|
||||
Expand: true,
|
||||
Checker: d,
|
||||
func previewHandler(imgSvc ImgService, fileCache FileCache, enableThumbnails, resizePreview bool) handleFunc {
|
||||
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
if !d.user.Perm.Download {
|
||||
return http.StatusAccepted, nil
|
||||
}
|
||||
vars := mux.Vars(r)
|
||||
|
||||
previewSize, err := ParsePreviewSize(vars["size"])
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
|
||||
file, err := files.NewFileInfo(files.FileOptions{
|
||||
Fs: d.user.Fs,
|
||||
Path: "/" + vars["path"],
|
||||
Modify: d.user.Perm.Modify,
|
||||
Expand: true,
|
||||
Checker: d,
|
||||
})
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
setContentDisposition(w, r, file)
|
||||
|
||||
switch file.Type {
|
||||
case "image":
|
||||
return handleImagePreview(w, r, imgSvc, fileCache, file, previewSize, enableThumbnails, resizePreview)
|
||||
default:
|
||||
return http.StatusNotImplemented, fmt.Errorf("can't create preview for %s type", file.Type)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
}
|
||||
|
||||
setContentDisposition(w, r, file)
|
||||
|
||||
switch file.Type {
|
||||
case "image":
|
||||
return handleImagePreview(w, r, file, size)
|
||||
default:
|
||||
return http.StatusNotImplemented, fmt.Errorf("can't create preview for %s type", file.Type)
|
||||
}
|
||||
})
|
||||
|
||||
func handleImagePreview(w http.ResponseWriter, r *http.Request, file *files.FileInfo, size string) (int, error) {
|
||||
format, err := imaging.FormatFromExtension(file.Extension)
|
||||
func handleImagePreview(w http.ResponseWriter, r *http.Request, imgSvc ImgService, fileCache FileCache,
|
||||
file *files.FileInfo, 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 == imaging.ErrUnsupportedFormat {
|
||||
if err == img.ErrUnsupportedFormat {
|
||||
return rawFileHandler(w, r, file)
|
||||
}
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
var imgProcessor imageProcessor
|
||||
switch size {
|
||||
case sizeBig:
|
||||
imgProcessor = func(img image.Image) (image.Image, error) {
|
||||
return imaging.Fit(img, 1080, 1080, imaging.Lanczos), nil
|
||||
}
|
||||
case sizeThumb:
|
||||
imgProcessor = func(img image.Image) (image.Image, error) {
|
||||
return imaging.Thumbnail(img, 128, 128, imaging.Box), nil
|
||||
}
|
||||
default:
|
||||
return http.StatusBadRequest, fmt.Errorf("unsupported preview size %s", size)
|
||||
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)
|
||||
@@ -79,16 +94,44 @@ func handleImagePreview(w http.ResponseWriter, r *http.Request, file *files.File
|
||||
}
|
||||
defer fd.Close()
|
||||
|
||||
img, err := imaging.Decode(fd, imaging.AutoOrientation(true))
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
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
|
||||
}
|
||||
img, err = imgProcessor(img)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
if imaging.Encode(w, img, format) != nil {
|
||||
return errToStatus(err), err
|
||||
|
||||
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
100
http/preview_enum.go
Normal 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
|
||||
}
|
||||
@@ -28,7 +28,7 @@ var withHashFile = func(fn handleFunc) handleFunc {
|
||||
Fs: d.user.Fs,
|
||||
Path: link.Path,
|
||||
Modify: d.user.Perm.Modify,
|
||||
Expand: false,
|
||||
Expand: true,
|
||||
Checker: d,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -54,7 +54,15 @@ func ifPathWithName(r *http.Request) string {
|
||||
}
|
||||
|
||||
var publicShareHandler = withHashFile(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
return renderJSON(w, r, d.raw)
|
||||
file := d.raw.(*files.FileInfo)
|
||||
|
||||
if file.IsDir {
|
||||
file.Listing.Sorting = files.Sorting{By: "name", Asc: false}
|
||||
file.Listing.ApplySort()
|
||||
return renderJSON(w, r, file)
|
||||
}
|
||||
|
||||
return renderJSON(w, r, file)
|
||||
})
|
||||
|
||||
var publicDlHandler = withHashFile(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
|
||||
65
http/raw.go
65
http/raw.go
@@ -1,19 +1,30 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
gopath "path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/hacdias/fileutils"
|
||||
"github.com/mholt/archiver"
|
||||
"github.com/spf13/afero"
|
||||
|
||||
"github.com/filebrowser/filebrowser/v2/files"
|
||||
"github.com/filebrowser/filebrowser/v2/fileutils"
|
||||
"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) {
|
||||
var fileSlice []string
|
||||
names := strings.Split(r.URL.Query().Get("files"), ",")
|
||||
@@ -27,7 +38,7 @@ func parseQueryFiles(r *http.Request, f *files.FileInfo, _ *users.User) ([]strin
|
||||
return nil, err
|
||||
}
|
||||
|
||||
name = fileutils.SlashClean(name)
|
||||
name = slashClean(name)
|
||||
fileSlice = append(fileSlice, filepath.Join(f.Path, name))
|
||||
}
|
||||
}
|
||||
@@ -83,6 +94,11 @@ var rawHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data)
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
if files.IsNamedPipe(file.Mode) {
|
||||
setContentDisposition(w, r, file)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
if !file.IsDir {
|
||||
return rawFileHandler(w, r, file)
|
||||
}
|
||||
@@ -90,7 +106,7 @@ var rawHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data)
|
||||
return rawDirHandler(w, r, d, file)
|
||||
})
|
||||
|
||||
func addFile(ar archiver.Writer, d *data, path string) error {
|
||||
func addFile(ar archiver.Writer, d *data, path, commonPath string) error {
|
||||
// Checks are always done with paths with "/" as path separator.
|
||||
path = strings.Replace(path, "\\", "/", -1)
|
||||
if !d.Check(path) {
|
||||
@@ -102,21 +118,32 @@ func addFile(ar archiver.Writer, d *data, path string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
file, err := d.user.Fs.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
var (
|
||||
file afero.File
|
||||
arcReadCloser = ioutil.NopCloser(&bytes.Buffer{})
|
||||
)
|
||||
if !files.IsNamedPipe(info.Mode()) {
|
||||
file, err = d.user.Fs.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
arcReadCloser = file
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
err = ar.Write(archiver.File{
|
||||
FileInfo: archiver.FileInfo{
|
||||
FileInfo: info,
|
||||
CustomName: strings.TrimPrefix(path, "/"),
|
||||
},
|
||||
ReadCloser: file,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
if path != commonPath {
|
||||
filename := strings.TrimPrefix(path, commonPath)
|
||||
filename = strings.TrimPrefix(filename, "/")
|
||||
err = ar.Write(archiver.File{
|
||||
FileInfo: archiver.FileInfo{
|
||||
FileInfo: info,
|
||||
CustomName: filename,
|
||||
},
|
||||
ReadCloser: arcReadCloser,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
@@ -126,7 +153,7 @@ func addFile(ar archiver.Writer, d *data, path string) error {
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
err = addFile(ar, d, filepath.Join(path, name))
|
||||
err = addFile(ar, d, filepath.Join(path, name), commonPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -160,8 +187,10 @@ func rawDirHandler(w http.ResponseWriter, r *http.Request, d *data, file *files.
|
||||
}
|
||||
defer ar.Close()
|
||||
|
||||
commonDir := fileutils.CommonPrefix('/', filenames...)
|
||||
|
||||
for _, fname := range filenames {
|
||||
err = addFile(ar, d, fname)
|
||||
err = addFile(ar, d, fname, commonDir)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
106
http/resource.go
106
http/resource.go
@@ -7,9 +7,12 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
|
||||
"github.com/filebrowser/filebrowser/v2/errors"
|
||||
"github.com/filebrowser/filebrowser/v2/files"
|
||||
"github.com/filebrowser/filebrowser/v2/fileutils"
|
||||
@@ -48,21 +51,42 @@ var resourceGetHandler = withUser(func(w http.ResponseWriter, r *http.Request, d
|
||||
return renderJSON(w, r, file)
|
||||
})
|
||||
|
||||
var resourceDeleteHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
if r.URL.Path == "/" || !d.user.Perm.Delete {
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
func resourceDeleteHandler(fileCache FileCache) handleFunc {
|
||||
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
if r.URL.Path == "/" || !d.user.Perm.Delete {
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
|
||||
err := d.RunHook(func() error {
|
||||
return d.user.Fs.RemoveAll(r.URL.Path)
|
||||
}, "delete", r.URL.Path, "", d.user)
|
||||
file, err := files.NewFileInfo(files.FileOptions{
|
||||
Fs: d.user.Fs,
|
||||
Path: r.URL.Path,
|
||||
Modify: d.user.Perm.Modify,
|
||||
Expand: true,
|
||||
Checker: d,
|
||||
})
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
// delete thumbnails
|
||||
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) {
|
||||
if !d.user.Perm.Create && r.Method == http.MethodPost {
|
||||
@@ -99,7 +123,7 @@ var resourcePostPutHandler = withUser(func(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
err := d.RunHook(func() error {
|
||||
dir, _ := filepath.Split(r.URL.Path)
|
||||
dir, _ := path.Split(r.URL.Path)
|
||||
err := d.user.Fs.MkdirAll(dir, 0775)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -127,6 +151,10 @@ var resourcePostPutHandler = withUser(func(w http.ResponseWriter, r *http.Reques
|
||||
return nil
|
||||
}, action, r.URL.Path, "", d.user)
|
||||
|
||||
if err != nil {
|
||||
_ = d.user.Fs.RemoveAll(r.URL.Path)
|
||||
}
|
||||
|
||||
return errToStatus(err), err
|
||||
})
|
||||
|
||||
@@ -135,14 +163,26 @@ var resourcePatchHandler = withUser(func(w http.ResponseWriter, r *http.Request,
|
||||
dst := r.URL.Query().Get("destination")
|
||||
action := r.URL.Query().Get("action")
|
||||
dst, err := url.QueryUnescape(dst)
|
||||
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
if dst == "/" || src == "/" {
|
||||
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 {
|
||||
switch action {
|
||||
@@ -151,11 +191,15 @@ var resourcePatchHandler = withUser(func(w http.ResponseWriter, r *http.Request,
|
||||
if !d.user.Perm.Create {
|
||||
return errors.ErrPermissionDenied
|
||||
}
|
||||
|
||||
return fileutils.Copy(d.user.Fs, src, dst)
|
||||
case "rename":
|
||||
if !d.user.Perm.Rename {
|
||||
return errors.ErrPermissionDenied
|
||||
}
|
||||
src = path.Clean("/" + src)
|
||||
dst = path.Clean("/" + dst)
|
||||
|
||||
return d.user.Fs.Rename(src, dst)
|
||||
default:
|
||||
return fmt.Errorf("unsupported action %s: %w", action, errors.ErrInvalidRequestParams)
|
||||
@@ -164,3 +208,35 @@ var resourcePatchHandler = withUser(func(w http.ResponseWriter, r *http.Request,
|
||||
|
||||
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(source string, fs afero.Fs) string {
|
||||
counter := 1
|
||||
dir, name := path.Split(source)
|
||||
ext := filepath.Ext(name)
|
||||
base := strings.TrimSuffix(name, ext)
|
||||
|
||||
for {
|
||||
if _, err := fs.Stat(source); err != nil {
|
||||
break
|
||||
}
|
||||
renamed := fmt.Sprintf("%s(%d)%s", base, counter, ext)
|
||||
source = path.Join(dir, renamed)
|
||||
counter++
|
||||
}
|
||||
|
||||
return source
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ func handleWithStaticData(w http.ResponseWriter, _ *http.Request, d *data, box *
|
||||
"CSS": false,
|
||||
"ReCaptcha": false,
|
||||
"Theme": d.settings.Branding.Theme,
|
||||
"EnableThumbs": d.server.EnableThumbnails,
|
||||
"ResizePreview": d.server.ResizePreview,
|
||||
"EnableExec": d.server.EnableExec,
|
||||
}
|
||||
|
||||
if d.settings.Branding.Files != "" {
|
||||
@@ -76,7 +79,14 @@ func handleWithStaticData(w http.ResponseWriter, _ *http.Request, d *data, box *
|
||||
|
||||
data["Json"] = string(b)
|
||||
|
||||
index := template.Must(template.New("index").Delims("[{[", "]}]").Parse(box.MustString(file)))
|
||||
fileContents, err := box.String(file)
|
||||
if err != nil {
|
||||
if err == os.ErrNotExist {
|
||||
return http.StatusNotFound, err
|
||||
}
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
index := template.Must(template.New("index").Delims("[{[", "]}]").Parse(fileContents))
|
||||
err = index.Execute(w, data)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
|
||||
185
img/service.go
Normal file
185
img/service.go
Normal 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
259
img/service_enum.go
Normal 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
398
img/service_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
@@ -18,6 +19,12 @@ type Rule struct {
|
||||
Regexp *Regexp `json:"regexp"`
|
||||
}
|
||||
|
||||
// MatchHidden matches paths with a basename
|
||||
// that begins with a dot.
|
||||
func MatchHidden(path string) bool {
|
||||
return strings.HasPrefix(filepath.Base(path), ".")
|
||||
}
|
||||
|
||||
// Matches matches a path against a rule.
|
||||
func (r *Rule) Matches(path string) bool {
|
||||
if r.Regex {
|
||||
|
||||
23
rules/rules_test.go
Normal file
23
rules/rules_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package rules
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMatchHidden(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"/": false,
|
||||
"/src": false,
|
||||
"/src/": false,
|
||||
"/.circleci": true,
|
||||
"/a/b/c/.docker.json": true,
|
||||
".docker.json": true,
|
||||
"Dockerfile": false,
|
||||
"/Dockerfile": false,
|
||||
}
|
||||
|
||||
for path, want := range cases {
|
||||
got := MatchHidden(path)
|
||||
if got != want {
|
||||
t.Errorf("MatchHidden(%s)=%v; want %v", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
// Runner is a commands runner.
|
||||
type Runner struct {
|
||||
Enabled bool
|
||||
*settings.Settings
|
||||
}
|
||||
|
||||
@@ -21,11 +22,13 @@ func (r *Runner) RunHook(fn func() error, evt, path, dst string, user *users.Use
|
||||
path = user.FullPath(path)
|
||||
dst = user.FullPath(dst)
|
||||
|
||||
if val, ok := r.Commands["before_"+evt]; ok {
|
||||
for _, command := range val {
|
||||
err := r.exec(command, "before_"+evt, path, dst, user)
|
||||
if err != nil {
|
||||
return err
|
||||
if r.Enabled {
|
||||
if val, ok := r.Commands["before_"+evt]; ok {
|
||||
for _, command := range val {
|
||||
err := r.exec(command, "before_"+evt, path, dst, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,11 +38,13 @@ func (r *Runner) RunHook(fn func() error, evt, path, dst string, user *users.Use
|
||||
return err
|
||||
}
|
||||
|
||||
if val, ok := r.Commands["after_"+evt]; ok {
|
||||
for _, command := range val {
|
||||
err := r.exec(command, "after_"+evt, path, dst, user)
|
||||
if err != nil {
|
||||
return err
|
||||
if r.Enabled {
|
||||
if val, ok := r.Commands["after_"+evt]; ok {
|
||||
for _, command := range val {
|
||||
err := r.exec(command, "after_"+evt, path, dst, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,9 @@ func Search(fs afero.Fs, scope, query string, checker rules.Checker, found func(
|
||||
if len(search.Terms) > 0 {
|
||||
for _, term := range search.Terms {
|
||||
if strings.Contains(path, term) {
|
||||
return found(strings.TrimPrefix(originalPath, scope), f)
|
||||
originalPath = strings.TrimPrefix(originalPath, scope)
|
||||
originalPath = strings.TrimPrefix(originalPath, "/")
|
||||
return found(originalPath, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,14 @@ import (
|
||||
// UserDefaults is a type that holds the default values
|
||||
// for some fields on User.
|
||||
type UserDefaults struct {
|
||||
Scope string `json:"scope"`
|
||||
Locale string `json:"locale"`
|
||||
ViewMode users.ViewMode `json:"viewMode"`
|
||||
Sorting files.Sorting `json:"sorting"`
|
||||
Perm users.Permissions `json:"perm"`
|
||||
Commands []string `json:"commands"`
|
||||
Scope string `json:"scope"`
|
||||
Locale string `json:"locale"`
|
||||
ViewMode users.ViewMode `json:"viewMode"`
|
||||
SingleClick bool `json:"singleClick"`
|
||||
Sorting files.Sorting `json:"sorting"`
|
||||
Perm users.Permissions `json:"perm"`
|
||||
Commands []string `json:"commands"`
|
||||
HideDotfiles bool `json:"hideDotfiles"`
|
||||
}
|
||||
|
||||
// Apply applies the default options to a user.
|
||||
@@ -21,7 +23,9 @@ func (d *UserDefaults) Apply(u *users.User) {
|
||||
u.Scope = d.Scope
|
||||
u.Locale = d.Locale
|
||||
u.ViewMode = d.ViewMode
|
||||
u.SingleClick = d.SingleClick
|
||||
u.Perm = d.Perm
|
||||
u.Sorting = d.Sorting
|
||||
u.Commands = d.Commands
|
||||
u.HideDotfiles = d.HideDotfiles
|
||||
}
|
||||
|
||||
@@ -30,14 +30,17 @@ func (s *Settings) GetRules() []rules.Rule {
|
||||
|
||||
// Server specific settings.
|
||||
type Server struct {
|
||||
Root string `json:"root"`
|
||||
BaseURL string `json:"baseURL"`
|
||||
Socket string `json:"socket"`
|
||||
TLSKey string `json:"tlsKey"`
|
||||
TLSCert string `json:"tlsCert"`
|
||||
Port string `json:"port"`
|
||||
Address string `json:"address"`
|
||||
Log string `json:"log"`
|
||||
Root string `json:"root"`
|
||||
BaseURL string `json:"baseURL"`
|
||||
Socket string `json:"socket"`
|
||||
TLSKey string `json:"tlsKey"`
|
||||
TLSCert string `json:"tlsCert"`
|
||||
Port string `json:"port"`
|
||||
Address string `json:"address"`
|
||||
Log string `json:"log"`
|
||||
EnableThumbnails bool `json:"enableThumbnails"`
|
||||
ResizePreview bool `json:"resizePreview"`
|
||||
EnableExec bool `json:"enableExec"`
|
||||
}
|
||||
|
||||
// Clean cleans any variables that might need cleaning.
|
||||
|
||||
@@ -28,11 +28,13 @@ type User struct {
|
||||
Locale string `json:"locale"`
|
||||
LockPassword bool `json:"lockPassword"`
|
||||
ViewMode ViewMode `json:"viewMode"`
|
||||
SingleClick bool `json:"singleClick"`
|
||||
Perm Permissions `json:"perm"`
|
||||
Commands []string `json:"commands"`
|
||||
Sorting files.Sorting `json:"sorting"`
|
||||
Fs afero.Fs `json:"-" yaml:"-"`
|
||||
Rules []rules.Rule `json:"rules"`
|
||||
HideDotfiles bool `json:"hideDotfiles"`
|
||||
}
|
||||
|
||||
// GetRules implements rules.Provider.
|
||||
|
||||
Reference in New Issue
Block a user