Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85b7d2762d | ||
|
|
4edabb9ccc | ||
|
|
103683069e | ||
|
|
5328e80d2e | ||
|
|
847d08bdd1 | ||
|
|
0231b7ebdf | ||
|
|
e07c59df0b | ||
|
|
0d3eb9bea9 | ||
|
|
34ae34e764 | ||
|
|
ca0108f070 | ||
|
|
a1e442ef9e | ||
|
|
6ad8160aa3 | ||
|
|
22b848f26e | ||
|
|
a418dd6bb3 | ||
|
|
d978d1ecca | ||
|
|
9cc18a81e3 | ||
|
|
e38c28273a | ||
|
|
da2dff0933 | ||
|
|
261bca67bc | ||
|
|
ca5e249e3c | ||
|
|
f4e148523e | ||
|
|
1f22fe65ec | ||
|
|
e2bdf6f747 | ||
|
|
d236f1c563 | ||
|
|
4edf425a9f | ||
|
|
a1a7ac4f04 | ||
|
|
74917c8037 | ||
|
|
7bd27f5e82 | ||
|
|
9f4288b1b4 | ||
|
|
41b801d30c | ||
|
|
da6b7ac26b | ||
|
|
7f8b920aa4 | ||
|
|
dd53644acb | ||
|
|
9b80a9aa6c | ||
|
|
0321415a15 | ||
|
|
23e84c9974 | ||
|
|
0fadf28b18 | ||
|
|
871f337892 | ||
|
|
29c73eaca6 | ||
|
|
f13c7c8cff | ||
|
|
1e03feadb5 | ||
|
|
8adf127c7d | ||
|
|
7dbf7a3528 | ||
|
|
65a837de49 | ||
|
|
876cdb3426 | ||
|
|
2f805de527 | ||
|
|
7a16129bfc | ||
|
|
0f39bd055e | ||
|
|
860c19ddf5 | ||
|
|
a8fc1657b7 | ||
|
|
c406bda0c7 | ||
|
|
d9f9460c1e | ||
|
|
126227bb27 | ||
|
|
b6a4fb1f27 | ||
|
|
2368e468b9 | ||
|
|
8d8cd26d7f | ||
|
|
8f81b77cf2 | ||
|
|
0616f6811f | ||
|
|
432f3e60ff | ||
|
|
ae72f93667 | ||
|
|
4812536555 | ||
|
|
6d44b3ae1d | ||
|
|
811cf2dfe5 |
@@ -10,7 +10,7 @@
|
||||
|
||||
Before submitting your PR, please indicate which issues are either fixed or closed by this PR. See [GitHub Help: Closing issues using keywords](https://help.github.com/articles/closing-issues-via-commit-messages/).
|
||||
|
||||
- [ ] I am aware the project is currently in maintenance-only mode. See [README](https://github.com/filebrowser/community/blob/master/README.md)
|
||||
- [ ] I am aware the project is currently in **maintenance-only** mode and new feature requests won't be accepted. See [README](https://github.com/filebrowser/community/blob/master/README.md)
|
||||
- [ ] I am aware that translations MUST be made through [Transifex](https://app.transifex.com/file-browser/file-browser/) and that this PR is NOT a translation update
|
||||
- [ ] I am making a PR against the `master` branch.
|
||||
- [ ] I am sure File Browser can be successfully built. See [builds](https://github.com/filebrowser/community/blob/master/builds.md) and [development](https://github.com/filebrowser/community/blob/master/development.md).
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
package_json_file: "frontend/package.json"
|
||||
- uses: actions/setup-node@v6
|
||||
@@ -27,6 +27,24 @@ jobs:
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm run lint
|
||||
|
||||
test-frontend:
|
||||
name: Test Frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
package_json_file: "frontend/package.json"
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24.x"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "frontend/pnpm-lock.yaml"
|
||||
- working-directory: frontend
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm run test
|
||||
|
||||
lint-backend:
|
||||
name: Lint Backend
|
||||
runs-on: ubuntu-latest
|
||||
@@ -59,7 +77,7 @@ jobs:
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.26'
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
package_json_file: "frontend/package.json"
|
||||
- uses: actions/setup-node@v6
|
||||
@@ -68,7 +86,7 @@ jobs:
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "frontend/pnpm-lock.yaml"
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@v1
|
||||
uses: go-task/setup-task@v2
|
||||
- run: task build
|
||||
|
||||
release:
|
||||
@@ -83,7 +101,7 @@ jobs:
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.26'
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
package_json_file: "frontend/package.json"
|
||||
- uses: actions/setup-node@v6
|
||||
@@ -96,7 +114,7 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@v1
|
||||
uses: go-task/setup-task@v2
|
||||
- run: task build:frontend
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@v1
|
||||
uses: go-task/setup-task@v2
|
||||
- name: Build site
|
||||
run: task docs
|
||||
|
||||
@@ -41,12 +41,12 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@v1
|
||||
uses: go-task/setup-task@v2
|
||||
- name: Build site
|
||||
run: task docs
|
||||
- name: Upload static files as artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: www/public
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
|
||||
@@ -2,6 +2,83 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
|
||||
|
||||
## [2.63.6](https://github.com/filebrowser/filebrowser/compare/v2.63.5...v2.63.6) (2026-06-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address three security disclosures (archive traversal, login DoS, symlink escape) ([847d08b](https://github.com/filebrowser/filebrowser/commit/847d08bdd135e5c3659f2e6dea2f0cd36617af9b))
|
||||
* cross-user unauthorized share-link deletion ([0231b7e](https://github.com/filebrowser/filebrowser/commit/0231b7ebdfbe77a6c54027d30c4856c3fd81ee4d))
|
||||
* incorrect access control in public directory shares via rule path rebasing ([e07c59d](https://github.com/filebrowser/filebrowser/commit/e07c59df0b850f5924d5b1683e8609661ddcf534))
|
||||
* parse csv files with uneven columns in their rows ([#5965](https://github.com/filebrowser/filebrowser/issues/5965)) ([5328e80](https://github.com/filebrowser/filebrowser/commit/5328e80d2e88d1c279a1250a7dfee4fc96f703ec))
|
||||
* remove undocumented hook auth with shell replacement ([34ae34e](https://github.com/filebrowser/filebrowser/commit/34ae34e764d72540c039f1f5ea2ec4c974168c1f))
|
||||
|
||||
## [2.63.5](https://github.com/filebrowser/filebrowser/compare/v2.63.4...v2.63.5) (2026-05-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **router:** handle undefined catchAll param on root redirect ([#5955](https://github.com/filebrowser/filebrowser/issues/5955)) ([6ad8160](https://github.com/filebrowser/filebrowser/commit/6ad8160aa3309314c1b471c5090b67c824464396))
|
||||
|
||||
## [2.63.4](https://github.com/filebrowser/filebrowser/compare/v2.63.3...v2.63.4) (2026-05-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* show item shares from all users to admins ([#5941](https://github.com/filebrowser/filebrowser/issues/5941)) ([9cc18a8](https://github.com/filebrowser/filebrowser/commit/9cc18a81e3e1b8bf96795bfbe3d83ced294ecfd7))
|
||||
|
||||
## [2.63.3](https://github.com/filebrowser/filebrowser/compare/v2.63.2...v2.63.3) (2026-05-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correct environment variable in compose.yaml ([#5910](https://github.com/filebrowser/filebrowser/issues/5910)) ([41b801d](https://github.com/filebrowser/filebrowser/commit/41b801d30c736c8ca863e2be6aece7d99e92129e))
|
||||
* Fix conflict modal and add a resume transfert option ([#5884](https://github.com/filebrowser/filebrowser/issues/5884)) ([f4e1485](https://github.com/filebrowser/filebrowser/commit/f4e148523e0dc9242081831b53544396f995c611))
|
||||
|
||||
## [2.63.2](https://github.com/filebrowser/filebrowser/compare/v2.63.1...v2.63.2) (2026-04-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **preview:** let arrow keys seek video instead of switching files ([#5895](https://github.com/filebrowser/filebrowser/issues/5895)) ([0fadf28](https://github.com/filebrowser/filebrowser/commit/0fadf28b18e506ddca0027e83ebe567ac57932bf))
|
||||
|
||||
## [2.63.1](https://github.com/filebrowser/filebrowser/compare/v2.63.0...v2.63.1) (2026-04-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* check download permission in resource handler ([#5891](https://github.com/filebrowser/filebrowser/issues/5891)) ([1e03fea](https://github.com/filebrowser/filebrowser/commit/1e03feadb550e4414b5589a6a8df57f538efba15))
|
||||
* check share owner permissions on public share access ([#5888](https://github.com/filebrowser/filebrowser/issues/5888)) ([7dbf7a3](https://github.com/filebrowser/filebrowser/commit/7dbf7a3528234b2a9ee9c4115e8ecf58d258ca51))
|
||||
* enforce directory boundary in rule path matching ([#5889](https://github.com/filebrowser/filebrowser/issues/5889)) ([8adf127](https://github.com/filebrowser/filebrowser/commit/8adf127c7d33585333b8030869f6f318e6517179))
|
||||
* restrict default permissions for proxy-auth auto-provisioned users ([#5890](https://github.com/filebrowser/filebrowser/issues/5890)) ([f13c7c8](https://github.com/filebrowser/filebrowser/commit/f13c7c8cffd6d58ff29c4a6763ced1385f69961e))
|
||||
|
||||
## [2.63.0](https://github.com/filebrowser/filebrowser/compare/v2.62.2...v2.63.0) (2026-04-04)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* enable copy operation on drag‑and‑drop with ctrl key ([#5882](https://github.com/filebrowser/filebrowser/issues/5882)) ([876cdb3](https://github.com/filebrowser/filebrowser/commit/876cdb34265b090c2a74a69509f4106f2c5e8726))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* check download permission when sharing permission is enabled ([#5875](https://github.com/filebrowser/filebrowser/issues/5875)) ([0f39bd0](https://github.com/filebrowser/filebrowser/commit/0f39bd055efdadc15abd2f8146cf5da3793f8318))
|
||||
* **tus:** reject negative upload-length to prevent inconsistent cache entry ([#5876](https://github.com/filebrowser/filebrowser/issues/5876)) ([7a16129](https://github.com/filebrowser/filebrowser/commit/7a16129bfc07dbdc2fa52b99d2985c1bc0ea12e2))
|
||||
|
||||
## [2.62.2](https://github.com/filebrowser/filebrowser/compare/v2.62.1...v2.62.2) (2026-03-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* disable scripted content in epub ([126227b](https://github.com/filebrowser/filebrowser/commit/126227bb2754eee15cd7c722916c3bb8821084a2))
|
||||
* double slash in TUS upload path when readEntries returns multiple batches ([#5848](https://github.com/filebrowser/filebrowser/issues/5848)) ([432f3e6](https://github.com/filebrowser/filebrowser/commit/432f3e60ffdf92af6f8f56119a1bac8084f52a60))
|
||||
* include filename in Content-Disposition header for inline downloads ([#5860](https://github.com/filebrowser/filebrowser/issues/5860)) ([8f81b77](https://github.com/filebrowser/filebrowser/commit/8f81b77cf2a3da0a445f3700fbf4a0091ea46c07))
|
||||
* json escaping ([c406bda](https://github.com/filebrowser/filebrowser/commit/c406bda0c73ac8b187e23a97c05521edc77efa84))
|
||||
* self-registered users don't get execute perms ([b6a4fb1](https://github.com/filebrowser/filebrowser/commit/b6a4fb1f27f4d894b384c0f3acacda276d1338a5))
|
||||
* shares listing ([a8fc165](https://github.com/filebrowser/filebrowser/commit/a8fc1657b796c5da7190466beff13e680721b6d3))
|
||||
* touch Redis upload cache key on GetLength to prevent TTL expiry ([#5850](https://github.com/filebrowser/filebrowser/issues/5850)) ([4812536](https://github.com/filebrowser/filebrowser/commit/48125365551ce2b27790aaafd7594cf5ce52f1ba))
|
||||
* use html/template ([d9f9460](https://github.com/filebrowser/filebrowser/commit/d9f9460c1e51d10a25065e10358c12d5ced66ad9))
|
||||
|
||||
## [2.62.1](https://github.com/filebrowser/filebrowser/compare/v2.62.0...v2.62.1) (2026-03-14)
|
||||
|
||||
|
||||
|
||||
@@ -86,22 +86,6 @@ func (a *HookAuth) LoginPage() bool {
|
||||
// RunCommand starts the hook command and returns the action
|
||||
func (a *HookAuth) RunCommand() (string, error) {
|
||||
command := strings.Split(a.Command, " ")
|
||||
envMapping := func(key string) string {
|
||||
switch key {
|
||||
case "USERNAME":
|
||||
return a.Cred.Username
|
||||
case "PASSWORD":
|
||||
return a.Cred.Password
|
||||
default:
|
||||
return os.Getenv(key)
|
||||
}
|
||||
}
|
||||
for i, arg := range command {
|
||||
if i == 0 {
|
||||
continue
|
||||
}
|
||||
command[i] = os.Expand(arg, envMapping)
|
||||
}
|
||||
|
||||
cmd := exec.Command(command[0], command[1:]...)
|
||||
cmd.Env = append(os.Environ(), fmt.Sprintf("USERNAME=%s", a.Cred.Username))
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeHookScript writes a POSIX shell script to a temp file and returns its
|
||||
// path, marking it executable.
|
||||
func writeHookScript(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "hook.sh")
|
||||
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body), 0o700); err != nil {
|
||||
t.Fatalf("failed to write hook script: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// TestRunCommandNoCredentialInjection ensures that attacker-controlled
|
||||
// credentials submitted at the unauthenticated login endpoint cannot be
|
||||
// injected into the hook command string. Credentials must only ever reach the
|
||||
// hook through the USERNAME/PASSWORD environment variables, never via string
|
||||
// substitution into the command itself (CWE-78/CWE-88).
|
||||
func TestRunCommandNoCredentialInjection(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses POSIX shell")
|
||||
}
|
||||
|
||||
marker := filepath.Join(t.TempDir(), "pwned")
|
||||
|
||||
// The hook simply blocks. If the credential were ever interpolated into the
|
||||
// command string and evaluated by a shell, the embedded `touch` would
|
||||
// create the marker file.
|
||||
script := writeHookScript(t, "echo hook.action=block\n")
|
||||
|
||||
a := &HookAuth{
|
||||
Command: script,
|
||||
Cred: hookCred{
|
||||
Username: `"; touch ` + marker + `; #`,
|
||||
Password: `$(touch ` + marker + `)`,
|
||||
},
|
||||
}
|
||||
|
||||
action, err := a.RunCommand()
|
||||
if err != nil {
|
||||
t.Fatalf("RunCommand returned error: %v", err)
|
||||
}
|
||||
if action != "block" {
|
||||
t.Fatalf("expected action %q, got %q", "block", action)
|
||||
}
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
t.Fatalf("credential injection executed: marker file %q was created", marker)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunCommandReceivesCredentialsViaEnv verifies the supported contract: the
|
||||
// hook receives credentials through the USERNAME and PASSWORD environment
|
||||
// variables.
|
||||
func TestRunCommandReceivesCredentialsViaEnv(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses POSIX shell")
|
||||
}
|
||||
|
||||
script := writeHookScript(t, `if [ "$USERNAME" = alice ] && [ "$PASSWORD" = secret ]; then
|
||||
echo hook.action=auth
|
||||
else
|
||||
echo hook.action=block
|
||||
fi
|
||||
`)
|
||||
|
||||
a := &HookAuth{
|
||||
Command: script,
|
||||
Cred: hookCred{
|
||||
Username: "alice",
|
||||
Password: "secret",
|
||||
},
|
||||
}
|
||||
|
||||
action, err := a.RunCommand()
|
||||
if err != nil {
|
||||
t.Fatalf("RunCommand returned error: %v", err)
|
||||
}
|
||||
if action != "auth" {
|
||||
t.Fatalf("expected action %q, got %q", "auth", action)
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,9 @@ func (a ProxyAuth) createUser(usr users.Store, setting *settings.Settings, srv *
|
||||
LockPassword: true,
|
||||
}
|
||||
setting.Defaults.Apply(user)
|
||||
user.Perm.Admin = false
|
||||
user.Perm.Execute = false
|
||||
user.Commands = []string{}
|
||||
|
||||
var userHome string
|
||||
userHome, err = setting.MakeUserDir(user.Username, user.Scope, srv.Root)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
fberrors "github.com/filebrowser/filebrowser/v2/errors"
|
||||
"github.com/filebrowser/filebrowser/v2/settings"
|
||||
"github.com/filebrowser/filebrowser/v2/users"
|
||||
)
|
||||
|
||||
type mockUserStore struct {
|
||||
users map[string]*users.User
|
||||
}
|
||||
|
||||
func (m *mockUserStore) Get(_ string, id interface{}) (*users.User, error) {
|
||||
if v, ok := id.(string); ok {
|
||||
if u, ok := m.users[v]; ok {
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
return nil, fberrors.ErrNotExist
|
||||
}
|
||||
|
||||
func (m *mockUserStore) Gets(_ string) ([]*users.User, error) { return nil, nil }
|
||||
func (m *mockUserStore) Update(_ *users.User, _ ...string) error { return nil }
|
||||
func (m *mockUserStore) Save(user *users.User) error {
|
||||
m.users[user.Username] = user
|
||||
return nil
|
||||
}
|
||||
func (m *mockUserStore) Delete(_ interface{}) error { return nil }
|
||||
func (m *mockUserStore) LastUpdate(_ uint) int64 { return 0 }
|
||||
|
||||
func TestProxyAuthCreateUserRestrictsDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := &mockUserStore{users: make(map[string]*users.User)}
|
||||
srv := &settings.Server{Root: t.TempDir()}
|
||||
|
||||
s := &settings.Settings{
|
||||
Key: []byte("key"),
|
||||
AuthMethod: MethodProxyAuth,
|
||||
Defaults: settings.UserDefaults{
|
||||
Perm: users.Permissions{
|
||||
Admin: true,
|
||||
Execute: true,
|
||||
Create: true,
|
||||
Rename: true,
|
||||
Modify: true,
|
||||
Delete: true,
|
||||
Share: true,
|
||||
Download: true,
|
||||
},
|
||||
Commands: []string{"git", "ls", "cat", "id"},
|
||||
},
|
||||
}
|
||||
|
||||
auth := ProxyAuth{Header: "X-Remote-User"}
|
||||
req, _ := http.NewRequest(http.MethodGet, "/", http.NoBody)
|
||||
req.Header.Set("X-Remote-User", "newproxyuser")
|
||||
|
||||
user, err := auth.Auth(req, store, s, srv)
|
||||
if err != nil {
|
||||
t.Fatalf("Auth() error: %v", err)
|
||||
}
|
||||
|
||||
if user.Perm.Admin {
|
||||
t.Error("auto-provisioned proxy user should not have Admin permission")
|
||||
}
|
||||
if user.Perm.Execute {
|
||||
t.Error("auto-provisioned proxy user should not have Execute permission")
|
||||
}
|
||||
if len(user.Commands) != 0 {
|
||||
t.Errorf("auto-provisioned proxy user should have empty Commands, got %v", user.Commands)
|
||||
}
|
||||
if !user.Perm.Create {
|
||||
t.Error("auto-provisioned proxy user should retain Create permission from defaults")
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -80,7 +80,7 @@ func addUserFlags(flags *pflag.FlagSet) {
|
||||
flags.Bool("singleClick", false, "use single clicks only")
|
||||
flags.Bool("redirectAfterCopyMove", false, "redirect to destination after copy/move")
|
||||
flags.Bool("dateFormat", false, "use date format (true for absolute time, false for relative)")
|
||||
flags.Bool("hideDotfiles", false, "hide dotfiles")
|
||||
flags.Bool("hideDotfiles", false, "hide dotfiles in file listings")
|
||||
flags.String("aceEditorTheme", "", "ace editor's syntax highlighting theme for users")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
# Run using:
|
||||
# docker compose -f compose.yaml -f compose.redis.yaml up --build
|
||||
|
||||
services:
|
||||
redis:
|
||||
container_name: redis
|
||||
image: redis:latest
|
||||
networks:
|
||||
- filebrowser
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
cat > /tmp/users.acl <<EOF
|
||||
user default on >filebrowser ~* +@all
|
||||
EOF
|
||||
redis-server --aclfile /tmp/users.acl
|
||||
+2
-7
@@ -1,12 +1,7 @@
|
||||
# Run using:
|
||||
# docker compose up --build
|
||||
|
||||
services:
|
||||
filebrowser:
|
||||
container_name: filebrowser
|
||||
build:
|
||||
dockerfile: Dockerfile
|
||||
context: .
|
||||
image: filebrowser/filebrowser:latest
|
||||
networks:
|
||||
- filebrowser
|
||||
ports:
|
||||
@@ -14,7 +9,7 @@ services:
|
||||
volumes:
|
||||
- filebrowser:/flux/vault
|
||||
environment:
|
||||
- REDIS_CACHE_URL=redis://default:filebrowser@redis:6379 # Use rediss:// for ssl
|
||||
- FB_REDIS_CACHE_URL=redis://default:filebrowser@redis:6379 # Use rediss:// for ssl
|
||||
|
||||
redis:
|
||||
container_name: redis
|
||||
|
||||
@@ -23,6 +23,7 @@ var (
|
||||
ErrSourceIsParent = errors.New("source is parent")
|
||||
ErrRootUserDeletion = errors.New("the sole admin can't be deleted")
|
||||
ErrCurrentPasswordIncorrect = errors.New("the current password is incorrect")
|
||||
ErrShareRequiresDownload = errors.New("permission to share requires permission to download")
|
||||
)
|
||||
|
||||
type ErrShortPassword struct {
|
||||
|
||||
@@ -133,6 +133,17 @@ func stat(opts *FileOptions) (*FileInfo, error) {
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// The path is a symlink. Refuse to follow it if its on-disk target escapes
|
||||
// the user's scoped root; otherwise a symlink that lives lexically inside
|
||||
// the scope but points outside it would let a restricted user read, write,
|
||||
// or share files beyond their boundary.
|
||||
if file != nil && file.IsSymlink {
|
||||
ok, scopeErr := WithinScope(opts.Fs, opts.Path)
|
||||
if scopeErr != nil || !ok {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
}
|
||||
|
||||
// fs doesn't support afero.Lstater interface or the file is a symlink
|
||||
info, err := opts.Fs.Stat(opts.Path)
|
||||
if err != nil {
|
||||
@@ -165,6 +176,50 @@ func stat(opts *FileOptions) (*FileInfo, error) {
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// WithinScope reports whether the on-disk target of p — after resolving any
|
||||
// symbolic links — stays within the scoped root of fsys. It exists to stop a
|
||||
// symlink that lives lexically inside a user's scope but points outside it
|
||||
// from being followed for reads, writes, or shares.
|
||||
//
|
||||
// Paths that do not exist yet (e.g. a brand-new file being created) are
|
||||
// validated against their nearest existing ancestor, so legitimate new files
|
||||
// are always allowed. For a filesystem that is not scoped with BasePathFs the
|
||||
// check is a no-op and returns true.
|
||||
//
|
||||
// Note: a dangling symlink whose target does not yet exist resolves to its
|
||||
// containing directory and is therefore allowed; writing through such a link
|
||||
// could still create a file outside the scope. Callers that create files
|
||||
// should treat this as best-effort and rely on rejecting existing escaping
|
||||
// symlinks, which covers the disclosure and overwrite vectors.
|
||||
func WithinScope(fsys afero.Fs, p string) (bool, error) {
|
||||
bfs, ok := fsys.(*afero.BasePathFs)
|
||||
if !ok {
|
||||
// Not a scoped filesystem; nothing to enforce.
|
||||
return true, nil
|
||||
}
|
||||
|
||||
root, err := filepath.EvalSymlinks(afero.FullBaseFsPath(bfs, "/"))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
target := afero.FullBaseFsPath(bfs, p)
|
||||
resolved, err := filepath.EvalSymlinks(target)
|
||||
for errors.Is(err, fs.ErrNotExist) {
|
||||
parent := filepath.Dir(target)
|
||||
if parent == target {
|
||||
break
|
||||
}
|
||||
target = parent
|
||||
resolved, err = filepath.EvalSymlinks(target)
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return resolved == root || strings.HasPrefix(resolved, root+string(filepath.Separator)), nil
|
||||
}
|
||||
|
||||
// Checksum checksums a given File for a given User, using a specific
|
||||
// algorithm. The checksums data is saved on File object.
|
||||
func (i *FileInfo) Checksum(algo string) error {
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"typecheck": "vue-tsc -p ./tsconfig.app.json --noEmit",
|
||||
"lint": "eslint src/",
|
||||
"lint:fix": "eslint --fix src/",
|
||||
"format": "prettier --write ."
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chenfengyuan/vue-number-input": "^2.0.1",
|
||||
@@ -29,7 +30,7 @@
|
||||
"js-base64": "^3.7.7",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"marked": "^17.0.0",
|
||||
"marked": "^18.0.0",
|
||||
"marked-katex-extension": "^5.1.6",
|
||||
"material-icons": "^1.13.14",
|
||||
"normalize.css": "^8.0.1",
|
||||
@@ -70,7 +71,8 @@
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.0",
|
||||
"vite-plugin-compression2": "^2.3.1",
|
||||
"vitest": "^4.1.0",
|
||||
"vue-tsc": "^3.1.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be"
|
||||
"packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800"
|
||||
}
|
||||
|
||||
Generated
+1050
-809
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,12 @@ export async function fetch(url: string, signal?: AbortSignal) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchAll(url: string): Promise<RecursiveEntry[]> {
|
||||
url = removePrefix(url);
|
||||
const res = await fetchURL(`/api/resources/recursive${url}`, {});
|
||||
return (await res.json()) as RecursiveEntry[];
|
||||
}
|
||||
|
||||
async function resourceAction(url: string, method: ApiMethod, content?: any) {
|
||||
url = removePrefix(url);
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
>
|
||||
<progress-bar :val="usage.usedPercentage" size="small"></progress-bar>
|
||||
<br />
|
||||
{{ usage.used }} of {{ usage.total }} used
|
||||
{{ $t("sidebar.diskUsed", { used: usage.used, total: usage.total }) }}
|
||||
</div>
|
||||
|
||||
<p class="credits">
|
||||
|
||||
@@ -138,7 +138,11 @@ watchEffect(() => {
|
||||
: props.content;
|
||||
parse(
|
||||
content as string,
|
||||
{ delimiter: columnSeparator.value, skip_empty_lines: true },
|
||||
{
|
||||
delimiter: columnSeparator.value,
|
||||
skip_empty_lines: true,
|
||||
relax_column_count: true,
|
||||
},
|
||||
(error, output) => {
|
||||
if (error) {
|
||||
console.error("Failed to parse CSV:", error);
|
||||
|
||||
@@ -191,18 +191,20 @@ const drop = async (event: Event) => {
|
||||
return;
|
||||
}
|
||||
const path = el.__vue__.url;
|
||||
const baseItems = (await api.fetch(path)).items;
|
||||
|
||||
const action = (overwrite?: boolean, rename?: boolean) => {
|
||||
api
|
||||
.move(items, overwrite, rename)
|
||||
const action =
|
||||
(event as KeyboardEvent).ctrlKey || (event as KeyboardEvent).metaKey
|
||||
? api.copy
|
||||
: api.move;
|
||||
action(items, overwrite, rename)
|
||||
.then(() => {
|
||||
fileStore.reload = true;
|
||||
})
|
||||
.catch($showError);
|
||||
};
|
||||
|
||||
const conflict = upload.checkConflict(items, baseItems);
|
||||
const conflict = await upload.checkConflict(items, path);
|
||||
|
||||
if (conflict.length > 0) {
|
||||
layoutStore.showHover({
|
||||
|
||||
@@ -122,8 +122,7 @@ export default {
|
||||
});
|
||||
};
|
||||
|
||||
const dstItems = (await api.fetch(this.dest)).items;
|
||||
const conflict = upload.checkConflict(items, dstItems);
|
||||
const conflict = upload.checkConflict(items, this.dest);
|
||||
|
||||
if (conflict.length > 0) {
|
||||
this.showHover({
|
||||
|
||||
@@ -122,8 +122,7 @@ export default {
|
||||
});
|
||||
};
|
||||
|
||||
const dstItems = (await api.fetch(this.dest)).items;
|
||||
const conflict = upload.checkConflict(items, dstItems);
|
||||
const conflict = upload.checkConflict(items, this.dest);
|
||||
|
||||
if (conflict.length > 0) {
|
||||
this.showHover({
|
||||
|
||||
@@ -112,6 +112,16 @@
|
||||
<i class="material-icons">undo</i>
|
||||
{{ $t("buttons.skipAll") }}
|
||||
</button>
|
||||
<button @click="(e) => resume(e)">
|
||||
<i class="material-icons">replay</i>
|
||||
{{ $t("buttons.resumeTransfer") }}
|
||||
<span class="info-tooltip" @click.stop="() => {}">
|
||||
<i class="material-icons info-icon">info_outline</i>
|
||||
<span class="info-tooltip-text">
|
||||
{{ $t("buttons.resumeTransferTooltip") }}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button @click="personalized = true">
|
||||
<i class="material-icons">checklist</i>
|
||||
{{ $t("buttons.singleDecision") }}
|
||||
@@ -194,6 +204,17 @@ const humanTime = (modified: string | number | undefined) => {
|
||||
return dayjs(modified).format("L LT");
|
||||
};
|
||||
|
||||
const resume = (event: Event) => {
|
||||
conflict.value.forEach((item) => {
|
||||
if (item.isSmallerOnServer) {
|
||||
item.checked = ["origin"];
|
||||
} else {
|
||||
item.checked = ["dest"];
|
||||
}
|
||||
});
|
||||
currentPrompt?.confirm(event, conflict.value);
|
||||
};
|
||||
|
||||
const resolve = (event: Event, result: Array<"origin" | "dest">) => {
|
||||
for (const item of conflict.value) {
|
||||
item.checked = result;
|
||||
@@ -304,4 +325,42 @@ const toogleCheckAll = (e: Event) => {
|
||||
.result-buttons > button:hover {
|
||||
border: solid 1px var(--icon-blue);
|
||||
}
|
||||
|
||||
.info-tooltip {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
font-size: 1rem;
|
||||
color: var(--icon-blue);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.info-tooltip-text {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: var(--surfacePrimary, #333);
|
||||
color: var(--textPrimary, #fff);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
width: 220px;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
transition: opacity 200ms ease-in-out;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.info-tooltip:hover .info-tooltip-text {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useFileStore } from "@/stores/file";
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
|
||||
import * as upload from "@/utils/upload";
|
||||
@@ -43,11 +42,10 @@ import * as upload from "@/utils/upload";
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const fileStore = useFileStore();
|
||||
const layoutStore = useLayoutStore();
|
||||
|
||||
// TODO: this is a copy of the same function in FileListing.vue
|
||||
const uploadInput = (event: Event) => {
|
||||
const uploadInput = async (event: Event) => {
|
||||
const files = (event.currentTarget as HTMLInputElement)?.files;
|
||||
if (files === null) return;
|
||||
|
||||
@@ -67,7 +65,8 @@ const uploadInput = (event: Event) => {
|
||||
}
|
||||
|
||||
const path = route.path.endsWith("/") ? route.path : route.path + "/";
|
||||
const conflict = upload.checkConflict(uploadFiles, fileStore.req!.items);
|
||||
|
||||
const conflict = await upload.checkConflict(uploadFiles, path);
|
||||
|
||||
if (conflict.length > 0) {
|
||||
layoutStore.showHover({
|
||||
|
||||
@@ -17,7 +17,11 @@
|
||||
{{ $t("settings.perm.delete") }}
|
||||
</p>
|
||||
<p>
|
||||
<input type="checkbox" :disabled="admin" v-model="perm.download" />
|
||||
<input
|
||||
type="checkbox"
|
||||
:disabled="admin || perm.share"
|
||||
v-model="perm.download"
|
||||
/>
|
||||
{{ $t("settings.perm.download") }}
|
||||
</p>
|
||||
<p>
|
||||
@@ -61,5 +65,15 @@ export default {
|
||||
},
|
||||
isExecEnabled: () => enableExec,
|
||||
},
|
||||
watch: {
|
||||
perm: {
|
||||
deep: true,
|
||||
handler() {
|
||||
if (this.perm.share === true) {
|
||||
this.perm.download = true;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
"rename": "Umbenennen",
|
||||
"replace": "Ersetzen",
|
||||
"reportIssue": "Problem melden",
|
||||
"resumeTransfer": "Resume previous transfer",
|
||||
"resumeTransferTooltip": "Skip all conflicting files, except the ones that are smaller on the server as we suppose that their transfert has been interrupted.",
|
||||
"save": "Speichern",
|
||||
"schedule": "Planen",
|
||||
"search": "Suchen",
|
||||
@@ -245,7 +247,7 @@
|
||||
"execute": "Befehle ausführen",
|
||||
"modify": "Dateien bearbeiten",
|
||||
"rename": "Dateien und Verzeichnisse umbenennen oder verschieben",
|
||||
"share": "Dateien teilen"
|
||||
"share": "Dateien freigeben (Download-Berechtigung erforderlich)"
|
||||
},
|
||||
"permissions": "Berechtigungen",
|
||||
"permissionsHelp": "Du kannst den Benutzer zum Administrator machen oder die Berechtigungen einzeln festlegen. Wenn du \"Administrator\" wählst, werden automatisch alle anderen Optionen aktiviert. Die Benutzerverwaltung bleibt ein Administratorrecht.\n",
|
||||
@@ -281,6 +283,7 @@
|
||||
"currentPassword": "Dein aktuelles Passwort"
|
||||
},
|
||||
"sidebar": {
|
||||
"diskUsed": "{used} of {total} used",
|
||||
"help": "Hilfe",
|
||||
"hugoNew": "Hugo Neu",
|
||||
"login": "Anmelden",
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
"rename": "Rename",
|
||||
"replace": "Replace",
|
||||
"reportIssue": "Report Issue",
|
||||
"resumeTransfer": "Resume previous transfer",
|
||||
"resumeTransferTooltip": "Skip all conflicting files, except the ones that are smaller on the server as we suppose that their transfert has been interrupted.",
|
||||
"save": "Save",
|
||||
"schedule": "Schedule",
|
||||
"search": "Search",
|
||||
@@ -245,7 +247,7 @@
|
||||
"execute": "Execute commands",
|
||||
"modify": "Edit files",
|
||||
"rename": "Rename or move files and directories",
|
||||
"share": "Share files"
|
||||
"share": "Share files (require download permission)"
|
||||
},
|
||||
"permissions": "Permissions",
|
||||
"permissionsHelp": "You can set the user to be an administrator or choose the permissions individually. If you select \"Administrator\", all of the other options will be automatically checked. The management of users remains a privilege of an administrator.\n",
|
||||
@@ -281,6 +283,7 @@
|
||||
"currentPassword": "Your Current Password"
|
||||
},
|
||||
"sidebar": {
|
||||
"diskUsed": "{used} of {total} used",
|
||||
"help": "Help",
|
||||
"hugoNew": "Hugo New",
|
||||
"login": "Login",
|
||||
|
||||
+45
-42
@@ -29,6 +29,8 @@
|
||||
"rename": "Renommer",
|
||||
"replace": "Remplacer",
|
||||
"reportIssue": "Signaler un problème",
|
||||
"resumeTransfer": "Reprendre le transfert précédent",
|
||||
"resumeTransferTooltip": "Ignorez tous les fichiers en conflit, à l'exception de ceux dont la taille est inférieure sur le serveur, car nous supposons que leur transfert a été interrompu.",
|
||||
"save": "Enregistrer",
|
||||
"schedule": "Planifier",
|
||||
"search": "Rechercher",
|
||||
@@ -42,17 +44,17 @@
|
||||
"update": "Mettre à jour",
|
||||
"upload": "Importer",
|
||||
"openFile": "Ouvrir le fichier",
|
||||
"openDirect": "View raw",
|
||||
"openDirect": "Affichage brut",
|
||||
"discardChanges": "Annuler",
|
||||
"stopSearch": "Arrêter recherche",
|
||||
"saveChanges": "Enregistrer changements",
|
||||
"editAsText": "Editer comme Texte",
|
||||
"increaseFontSize": "Augmenter taille police",
|
||||
"decreaseFontSize": "Réduire taille police",
|
||||
"overrideAll": "Replace all files in destination folder",
|
||||
"skipAll": "Skip all conflicting files",
|
||||
"renameAll": "Rename all files (create a copy)",
|
||||
"singleDecision": "Decide for each conflicting file"
|
||||
"overrideAll": "Remplacer tous les fichiers du dossier de destination",
|
||||
"skipAll": "Ignorer tous les fichiers en conflit",
|
||||
"renameAll": " Renommer tous les fichiers (créer une copie)",
|
||||
"singleDecision": "Choisissez pour chaque fichier en conflit"
|
||||
},
|
||||
"download": {
|
||||
"downloadFile": "Télécharger le fichier",
|
||||
@@ -94,7 +96,7 @@
|
||||
"semicolon": "Point-virgule (;)",
|
||||
"both": "Les (,) et (;)"
|
||||
},
|
||||
"fileEncoding": "File Encoding"
|
||||
"fileEncoding": "Encodage du fichier"
|
||||
},
|
||||
"help": {
|
||||
"click": "Sélectionner un fichier ou dossier",
|
||||
@@ -118,8 +120,8 @@
|
||||
"passwordsDontMatch": "Les mots de passe ne concordent pas",
|
||||
"signup": "S'inscrire",
|
||||
"submit": "Se connecter",
|
||||
"username": "Utilisateur·ice",
|
||||
"usernameTaken": "Le nom d'utilisateur·ice est déjà pris",
|
||||
"username": "Utilisateur",
|
||||
"usernameTaken": "Le nom d'utilisateur est déjà pris",
|
||||
"wrongCredentials": "Identifiants incorrects !",
|
||||
"passwordTooShort": "Le mot de passe doit contenir au moins {min} caractères",
|
||||
"logout_reasons": {
|
||||
@@ -134,7 +136,7 @@
|
||||
"deleteMessageMultiple": "Êtes-vous sûr de vouloir supprimer ces {count} élément(s) ?",
|
||||
"deleteMessageSingle": "Êtes-vous sûr de vouloir supprimer cet élément ?",
|
||||
"deleteMessageShare": "Êtes-vous sûr de vouloir supprimer ce partage ({path}) ?",
|
||||
"deleteUser": "Êtes-vous sûr de vouloir supprimer cet·te utilisateur·ice ?",
|
||||
"deleteUser": "Êtes-vous sûr de vouloir supprimer cet utilisateur ?",
|
||||
"deleteTitle": "Supprimer",
|
||||
"displayName": "Nom :",
|
||||
"download": "Télécharger",
|
||||
@@ -166,18 +168,18 @@
|
||||
"optionalPassword": "Mot de passe optionnel",
|
||||
"resolution": "Résolution",
|
||||
"discardEditorChanges": "Êtes-vous sûr de vouloir annuler les modifications apportées ?",
|
||||
"replaceOrSkip": "Replace or skip files",
|
||||
"resolveConflict": "Which files do you want to keep?",
|
||||
"singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.",
|
||||
"fastConflictResolve": "The destination folder there are {count} files with same name.",
|
||||
"uploadingFiles": "Uploading files",
|
||||
"filesInOrigin": "Files in origin",
|
||||
"filesInDest": "Files in destination",
|
||||
"override": "Overwrite",
|
||||
"skip": "Skip",
|
||||
"forbiddenError": "Forbidden Error",
|
||||
"currentPassword": "Your password",
|
||||
"currentPasswordMessage": "Enter your password to validate this action."
|
||||
"replaceOrSkip": "Remplacer ou ignorer les fichiers",
|
||||
"resolveConflict": "Quels fichiers souhaitez-vous conserver ?",
|
||||
"singleConflictResolve": "Si vous sélectionnez les deux versions, un numéro sera ajouté au nom du fichier copié.",
|
||||
"fastConflictResolve": "Le dossier de destination contient {count} fichiers portant le même nom.",
|
||||
"uploadingFiles": "Importation de fichiers",
|
||||
"filesInOrigin": "Fichiers d'origine",
|
||||
"filesInDest": "Fichiers de destination",
|
||||
"override": "Remplacer",
|
||||
"skip": "Passer",
|
||||
"forbiddenError": "Action non autorisée",
|
||||
"currentPassword": "Votre mot de passe",
|
||||
"currentPasswordMessage": "Entrez votre mot de passe pour valider cette action."
|
||||
},
|
||||
"search": {
|
||||
"images": "Images",
|
||||
@@ -192,12 +194,12 @@
|
||||
"settings": {
|
||||
"aceEditorTheme": "Éditeur de Thème Ace",
|
||||
"admin": "Admin",
|
||||
"administrator": "Administrateur·ice",
|
||||
"administrator": "Administrateur",
|
||||
"allowCommands": "Exécuter des commandes",
|
||||
"allowEdit": "Éditer, renommer et supprimer des fichiers ou des dossiers",
|
||||
"allowNew": "Créer de nouveaux fichiers et dossiers",
|
||||
"allowPublish": "Publier de nouveaux posts et pages",
|
||||
"allowSignup": "Autoriser les utilisateur·ices à s'inscrire",
|
||||
"allowSignup": "Autoriser les utilisateurs à s'inscrire",
|
||||
"hideLoginButton": "Cacher le bouton d’identification sur les pages publiques",
|
||||
"avoidChanges": "(Laisser vide pour conserver l'actuel)",
|
||||
"branding": "Image de marque",
|
||||
@@ -207,17 +209,17 @@
|
||||
"commandRunner": "Exécuteur de commandes",
|
||||
"commandRunnerHelp": "Ici, vous pouvez définir les commandes qui seront exécutées lors des événements nommés précédemments. Vous devez en écrire une par ligne. Les variables d'environnement {0} et {1} seront disponibles, {0} étant relatif à {1}. Pour plus d'informations sur cette fonctionnalité et les variables d'environnement disponibles, veuillez lire la {2}.",
|
||||
"commandsUpdated": "Commandes mises à jour !",
|
||||
"createUserDir": "Créer automatiquement un dossier pour l'utilisateur·ice",
|
||||
"createUserDir": "Créer automatiquement un dossier pour l'utilisateur",
|
||||
"minimumPasswordLength": "Taille minimale du mot de passe",
|
||||
"tusUploads": "Uploads segmentés",
|
||||
"tusUploadsHelp": "File Browser prend en charge les uploads segmentés afin de permettre une gestion efficace, fiable et reprenable sur des réseaux instables.",
|
||||
"tusUploadsChunkSize": "Taille maximale autorisée par segment (les uploads directs seront utilisés pour les fichiers plus petits). Vous pouvez entrer un entier en octets ou une chaîne telle que 10MB, 1GB, etc.",
|
||||
"tusUploadsRetryCount": "Nombre de tentatives en cas d'échec d'un segment.",
|
||||
"userHomeBasePath": "Chemin de base pour les dossiers personnels des utilisateur·ices",
|
||||
"userHomeBasePath": "Chemin de base pour les dossiers personnels des utilisateurs",
|
||||
"userScopeGenerationPlaceholder": "Le périmètre sera généré automatiquement",
|
||||
"createUserHomeDirectory": "Créer le dossier personnel de l'utilisateur·ice",
|
||||
"createUserHomeDirectory": "Créer le dossier personnel de l'utilisateur",
|
||||
"customStylesheet": "Feuille de style personnalisée",
|
||||
"defaultUserDescription": "Paramètres par défaut pour les nouveaux utilisateur·ices.",
|
||||
"defaultUserDescription": "Paramètres par défaut pour les nouveaux utilisateurs.",
|
||||
"disableExternalLinks": "Désactiver les liens externes (sauf la documentation)",
|
||||
"disableUsedDiskPercentage": "Désactiver le graphique de pourcentage d'utilisation du disque",
|
||||
"documentation": "documentation",
|
||||
@@ -231,7 +233,7 @@
|
||||
"insertRegex": "Insérer une expression régulière",
|
||||
"instanceName": "Nom de l'instance",
|
||||
"language": "Langue",
|
||||
"lockPassword": "Empêcher l'utilisateur·ice de changer son mot de passe",
|
||||
"lockPassword": "Empêcher l'utilisateur de changer son mot de passe",
|
||||
"newPassword": "Votre nouveau mot de passe",
|
||||
"newPasswordConfirm": "Confirmation du nouveau mot de passe",
|
||||
"newUser": "Nouvel utilisateur",
|
||||
@@ -245,17 +247,17 @@
|
||||
"execute": "Exécuter des commandes",
|
||||
"modify": "Modifier des fichiers",
|
||||
"rename": "Renommer ou déplacer des fichiers ou des dossiers",
|
||||
"share": "Partager des fichiers"
|
||||
"share": "Partager des fichiers (autorisation de téléchargement requise)"
|
||||
},
|
||||
"permissions": "Permissions",
|
||||
"permissionsHelp": "Vous pouvez définir l'utilisateur·ice comme étant administrateur·ice ou encore choisir les permissions individuellement. Si vous sélectionnez \"Administrateur·ice\", toutes les autres options seront automatiquement activées. La gestion des utilisateur·ices est un privilège que seul l'administrateur·ice possède.\n",
|
||||
"permissionsHelp": "Vous pouvez définir l'utilisateur comme étant un administrateur ou encore choisir les permissions individuellement. Si vous sélectionnez \"Administrateur\", toutes les autres options seront automatiquement activées. La gestion des utilisateurs est un privilège que seul l'administrateur possède.\n",
|
||||
"profileSettings": "Paramètres du profil",
|
||||
"redirectAfterCopyMove": "Rediriger vers la destination après une copie/déplacement",
|
||||
"ruleExample1": "Bloque l'accès à tous les fichiers commençant par un point (comme par exemple .git, .gitignore) dans tous les dossiers.\n",
|
||||
"ruleExample2": "Bloque l'accès au fichier nommé \"Caddyfile\" à la racine du dossier utilisateur·ice.",
|
||||
"ruleExample2": "Bloque l'accès au fichier nommé \"Caddyfile\" à la racine du dossier utilisateur",
|
||||
"rules": "Règles",
|
||||
"rulesHelp": "Vous pouvez définir ici un ensemble de règles pour cet utilisateur·ice. Les fichiers bloqués ne seront pas affichés et ne seront pas accessibles par l'utilisateur·ice. Les expressions régulières sont supportées et les chemins d'accès sont relatifs par rapport au dossier de l'utilisateur·ice.\n",
|
||||
"scope": "Portée du dossier utilisateur·ice",
|
||||
"rulesHelp": "Vous pouvez définir ici un ensemble de règles pour cet utilisateur. Les fichiers bloqués ne seront pas affichés et ne seront pas accessibles par l'utilisateur. Les expressions régulières sont supportées et les chemins d'accès sont relatifs par rapport au dossier de l'utilisateur.\n",
|
||||
"scope": "Portée du dossier utilisateur",
|
||||
"setDateFormat": "Définir le format de la date",
|
||||
"settingsUpdated": "Les paramètres ont été mis à jour !",
|
||||
"shareDuration": "Durée du partage",
|
||||
@@ -268,19 +270,20 @@
|
||||
"light": "Clair",
|
||||
"title": "Thème"
|
||||
},
|
||||
"user": "Utilisateur·ice",
|
||||
"user": "Utilisateur",
|
||||
"userCommands": "Commandes",
|
||||
"userCommandsHelp": "Une liste séparée par des espaces des commandes permises pour l'utilisateur·ice. Exemple :\n",
|
||||
"userCreated": "Utilisateur·ice créé !",
|
||||
"userDefaults": "Paramètres par défaut de l'utilisateur.ice",
|
||||
"userDeleted": "Utilisateur·ice supprimé !",
|
||||
"userManagement": "Gestion des utilisateur·ices",
|
||||
"userUpdated": "Utilisateur·ice mis à jour !",
|
||||
"username": "Nom d'utilisateur·ice",
|
||||
"users": "Utilisateur·ices",
|
||||
"userCommandsHelp": "Une liste séparée par des espaces des commandes permises pour l'utilisateur. Exemple :\n",
|
||||
"userCreated": "Utilisateur créé !",
|
||||
"userDefaults": "Paramètres par défaut de l'utilisateur",
|
||||
"userDeleted": "Utilisateur supprimé !",
|
||||
"userManagement": "Gestion des utilisateurs",
|
||||
"userUpdated": "Utilisateur mis à jour !",
|
||||
"username": "Nom d'utilisateur",
|
||||
"users": "Utilisateurs",
|
||||
"currentPassword": "Mot de Passe Actuel"
|
||||
},
|
||||
"sidebar": {
|
||||
"diskUsed": "{used} sur {total} utilisés",
|
||||
"help": "Aide",
|
||||
"hugoNew": "Nouveau Hugo",
|
||||
"login": "Login",
|
||||
|
||||
+25
-22
@@ -29,6 +29,8 @@
|
||||
"rename": "Preimenuj",
|
||||
"replace": "Zamijeni",
|
||||
"reportIssue": "Prijavi grešku",
|
||||
"resumeTransfer": "Resume previous transfer",
|
||||
"resumeTransferTooltip": "Skip all conflicting files, except the ones that are smaller on the server as we suppose that their transfert has been interrupted.",
|
||||
"save": "Spremi",
|
||||
"schedule": "Zakaži",
|
||||
"search": "Pretraži",
|
||||
@@ -42,17 +44,17 @@
|
||||
"update": "Ažuriraj",
|
||||
"upload": "Prenesi",
|
||||
"openFile": "Otvori datoteku",
|
||||
"openDirect": "View raw",
|
||||
"openDirect": "Pregled neobrađenog",
|
||||
"discardChanges": "Odbaci",
|
||||
"stopSearch": "Stop searching",
|
||||
"stopSearch": "Prestanak pretraživanja",
|
||||
"saveChanges": "Spremi promjene",
|
||||
"editAsText": "Uredi kao tekst",
|
||||
"increaseFontSize": "Povećaj veličinu fonta",
|
||||
"decreaseFontSize": "Smanji veličinu fonta",
|
||||
"overrideAll": "Replace all files in destination folder",
|
||||
"skipAll": "Skip all conflicting files",
|
||||
"renameAll": "Rename all files (create a copy)",
|
||||
"singleDecision": "Decide for each conflicting file"
|
||||
"overrideAll": "Zamijeni sve datoteke u destinacijskoj mapi",
|
||||
"skipAll": "Preskoči sve konfliktirajuće datoteke",
|
||||
"renameAll": "Preimenuj sve datoteke (stvori kopiju)",
|
||||
"singleDecision": "Odluči za svaku konfliktirajuću datoteku"
|
||||
},
|
||||
"download": {
|
||||
"downloadFile": "Preuzmi Datoteku",
|
||||
@@ -94,7 +96,7 @@
|
||||
"semicolon": "Točka zarez (;)",
|
||||
"both": "I (,) i (;)"
|
||||
},
|
||||
"fileEncoding": "File Encoding"
|
||||
"fileEncoding": "Kodiranje datoteka"
|
||||
},
|
||||
"help": {
|
||||
"click": "odaberi datoteku ili mapu",
|
||||
@@ -166,18 +168,18 @@
|
||||
"optionalPassword": "Opcionalna lozinka",
|
||||
"resolution": "Rezolucija",
|
||||
"discardEditorChanges": "Jeste li sigurni da želite odbaciti promjene koje ste napravili?",
|
||||
"replaceOrSkip": "Replace or skip files",
|
||||
"resolveConflict": "Which files do you want to keep?",
|
||||
"singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.",
|
||||
"fastConflictResolve": "The destination folder there are {count} files with same name.",
|
||||
"uploadingFiles": "Uploading files",
|
||||
"filesInOrigin": "Files in origin",
|
||||
"filesInDest": "Files in destination",
|
||||
"override": "Overwrite",
|
||||
"skip": "Skip",
|
||||
"forbiddenError": "Forbidden Error",
|
||||
"currentPassword": "Your password",
|
||||
"currentPasswordMessage": "Enter your password to validate this action."
|
||||
"replaceOrSkip": "Zamijeni ili preskoči datoteke",
|
||||
"resolveConflict": "Koje datoteke želite sačuvati?",
|
||||
"singleConflictResolve": "Ako odaberete obje verzije, u ime kopirane datoteke bit će dodan broj.",
|
||||
"fastConflictResolve": "Destinacijska mapa sadrži {count} istoimenih datoteka.",
|
||||
"uploadingFiles": "Prenošenje datoteka",
|
||||
"filesInOrigin": "Datoteke u izvornoj mapi",
|
||||
"filesInDest": "Datoteke u destinacijskoj mapi",
|
||||
"override": "Prebrisivanje",
|
||||
"skip": "Preskoči",
|
||||
"forbiddenError": "Greška zabrane pristupa",
|
||||
"currentPassword": "Vaša lozinka",
|
||||
"currentPasswordMessage": "Unesite Vašu lozinku da biste validirali ovu akciju."
|
||||
},
|
||||
"search": {
|
||||
"images": "Slike",
|
||||
@@ -245,12 +247,12 @@
|
||||
"execute": "Izvršavanje naredbi",
|
||||
"modify": "Uređivanje datoteka",
|
||||
"rename": "Preimenovanje ili premještanje datoteka i mapa",
|
||||
"share": "Dijeljenje datoteka"
|
||||
"share": "Share files (require download permission)"
|
||||
},
|
||||
"permissions": "Dopuštenja",
|
||||
"permissionsHelp": "Korisnika možete postaviti administratorom ili odabrati dopuštenja individualno. Odabirom na \"Administrator\", sve druge opcije bit će automatski odabrane. Upravljanje korisnicima ostaje privilegija administratora.\n",
|
||||
"profileSettings": "Postavke profila",
|
||||
"redirectAfterCopyMove": "Redirect to destination after copy/move",
|
||||
"redirectAfterCopyMove": "Preusmjeri na destinaciju nakon kopiranja/premještanja",
|
||||
"ruleExample1": "onemogućava pristup svakoj datoteci koja započinje točkom (poput .git, .gitignore) u svakoj mapi.\n",
|
||||
"ruleExample2": "blokira pristup datoteci naziva Caddyfile na korijenu opsega.",
|
||||
"rules": "Pravila",
|
||||
@@ -278,9 +280,10 @@
|
||||
"userUpdated": "Korisnik ažuriran!",
|
||||
"username": "Korisničko ime",
|
||||
"users": "Korisnici",
|
||||
"currentPassword": "Your Current Password"
|
||||
"currentPassword": "Vaša trenutna lozinka"
|
||||
},
|
||||
"sidebar": {
|
||||
"diskUsed": "{used} of {total} used",
|
||||
"help": "Pomoć",
|
||||
"hugoNew": "Hugo New",
|
||||
"login": "Prijava",
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
"rename": "이름 바꾸기",
|
||||
"replace": "대체",
|
||||
"reportIssue": "문제 보고",
|
||||
"resumeTransfer": "Resume previous transfer",
|
||||
"resumeTransferTooltip": "Skip all conflicting files, except the ones that are smaller on the server as we suppose that their transfert has been interrupted.",
|
||||
"save": "저장",
|
||||
"schedule": "일정",
|
||||
"search": "검색",
|
||||
@@ -176,8 +178,8 @@
|
||||
"override": "덮어쓰기",
|
||||
"skip": "건너뛰기",
|
||||
"forbiddenError": "권한 오류",
|
||||
"currentPassword": "Your password",
|
||||
"currentPasswordMessage": "Enter your password to validate this action."
|
||||
"currentPassword": "기존 비밀번호",
|
||||
"currentPasswordMessage": "기존 비밀번호를 입력해 이 동작을 확인하십시오."
|
||||
},
|
||||
"search": {
|
||||
"images": "이미지",
|
||||
@@ -245,7 +247,7 @@
|
||||
"execute": "명령 실행",
|
||||
"modify": "파일 편집",
|
||||
"rename": "파일 및 디렉터리의 이름 변경 또는 이동",
|
||||
"share": "파일 공유하기"
|
||||
"share": "파일 공유 (다운로드 권한 필요)"
|
||||
},
|
||||
"permissions": "권한",
|
||||
"permissionsHelp": "사용자를 관리자로 설정하거나 개별적으로 권한을 지정할 수 있습니다. \"관리자\"를 선택하면 다른 모든 옵션이 자동으로 선택됩니다. 사용자 관리는 관리자의 권한입니다.\n",
|
||||
@@ -281,6 +283,7 @@
|
||||
"currentPassword": "현재 비밀번호"
|
||||
},
|
||||
"sidebar": {
|
||||
"diskUsed": "{used} of {total} used",
|
||||
"help": "도움말",
|
||||
"hugoNew": "Hugo New",
|
||||
"login": "로그인",
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
"rename": "Pārdēvēt",
|
||||
"replace": "Aizstāt",
|
||||
"reportIssue": "Ziņot par problēmu",
|
||||
"resumeTransfer": "Resume previous transfer",
|
||||
"resumeTransferTooltip": "Skip all conflicting files, except the ones that are smaller on the server as we suppose that their transfert has been interrupted.",
|
||||
"save": "Saglabāt",
|
||||
"schedule": "Grafiks",
|
||||
"search": "Meklēt",
|
||||
@@ -245,7 +247,7 @@
|
||||
"execute": "Izpildīt komandas",
|
||||
"modify": "Rediģēt failus",
|
||||
"rename": "Pārdēvēt vai pārvietot failus un direktorijus",
|
||||
"share": "Kopīgojiet failus"
|
||||
"share": "Share files (require download permission)"
|
||||
},
|
||||
"permissions": "Atļaujas",
|
||||
"permissionsHelp": "Varat iestatīt lietotāju kā administratoru vai izvēlēties atļaujas individuāli. Ja atlasīsiet “Administrators”, visas pārējās opcijas tiks automātiski atzīmētas. Lietotāju pārvaldība joprojām ir administratora privilēģija.\n",
|
||||
@@ -281,6 +283,7 @@
|
||||
"currentPassword": "Your Current Password"
|
||||
},
|
||||
"sidebar": {
|
||||
"diskUsed": "{used} of {total} used",
|
||||
"help": "Palīdzība",
|
||||
"hugoNew": "Hugo Jauns",
|
||||
"login": "Pieteikties",
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
"rename": "Hernoemen",
|
||||
"replace": "Vervangen",
|
||||
"reportIssue": "Probleem melden",
|
||||
"resumeTransfer": "Vorige overdracht hervatten",
|
||||
"resumeTransferTooltip": "Alle conflicterende bestanden overslaan, behalve de bestanden die kleiner zijn op de server, omdat we veronderstellen dat hun overdracht is onderbroken.",
|
||||
"save": "Opslaan",
|
||||
"schedule": "Plannen",
|
||||
"search": "Zoeken",
|
||||
@@ -245,7 +247,7 @@
|
||||
"execute": "Opdrachten uitvoeren",
|
||||
"modify": "Bestanden bewerken",
|
||||
"rename": "Bestanden of mappen hernoemen of verplaatsen",
|
||||
"share": "Bestanden delen"
|
||||
"share": "Bestanden delen (download toestemming vereisen)"
|
||||
},
|
||||
"permissions": "Machtigingen",
|
||||
"permissionsHelp": " kunt de gebruiker instellen als beheerder of de machtigingen afzonderlijk kiezen. Als u \"Beheerder\" selecteert, worden alle overige opties automatisch ingeschakeld. Het beheer van gebruikers blijft een privilege van een beheerder.\n",
|
||||
@@ -281,6 +283,7 @@
|
||||
"currentPassword": "Uw huidige wachtwoord"
|
||||
},
|
||||
"sidebar": {
|
||||
"diskUsed": "{used} van {total} gebruikt",
|
||||
"help": "Hulp",
|
||||
"hugoNew": "Hugo Nieuw",
|
||||
"login": "Inloggen",
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
"rename": "Zmień nazwę",
|
||||
"replace": "Zamień",
|
||||
"reportIssue": "Zgłoś problem",
|
||||
"resumeTransfer": "Wznów poprzedni transfer",
|
||||
"resumeTransferTooltip": "Pomiń wszystkie pliki powodujące konflikty, z wyjątkiem tych, które są mniejsze na serwerze, ponieważ podejrzewamy, że ich transfer został przerwany.",
|
||||
"save": "Zapisz",
|
||||
"schedule": "Harmonogram",
|
||||
"search": "Szukaj",
|
||||
@@ -245,7 +247,7 @@
|
||||
"execute": "Wykonywanie poleceń",
|
||||
"modify": "Edytowanie plików",
|
||||
"rename": "Zmienianie nazwy lub przenoszenie plików i katalogów",
|
||||
"share": "Udostępnianie plików"
|
||||
"share": "Udostępnianie plików (wymaga uprawnienia do pobierania)"
|
||||
},
|
||||
"permissions": "Uprawnienia",
|
||||
"permissionsHelp": "Możesz ustawić użytkownika jako administratora lub wybrać uprawnienia indywidualnie. Jeśli wybierzesz „Administrator”, wszystkie pozostałe opcje zostaną automatycznie zaznaczone. Zarządzanie użytkownikami pozostaje przywilejem administratora.\n",
|
||||
@@ -281,6 +283,7 @@
|
||||
"currentPassword": "Twoje aktualne hasło"
|
||||
},
|
||||
"sidebar": {
|
||||
"diskUsed": "Wykorzystano {used} z {total}",
|
||||
"help": "Pomoc",
|
||||
"hugoNew": "Nowy Hugo",
|
||||
"login": "Zaloguj",
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
"rename": "Mudar o nome",
|
||||
"replace": "Substituir",
|
||||
"reportIssue": "Reportar problema",
|
||||
"resumeTransfer": "Resume previous transfer",
|
||||
"resumeTransferTooltip": "Skip all conflicting files, except the ones that are smaller on the server as we suppose that their transfert has been interrupted.",
|
||||
"save": "Guardar",
|
||||
"schedule": "Agendar",
|
||||
"search": "Pesquisar",
|
||||
@@ -100,7 +102,7 @@
|
||||
"click": "seleccionar ficheiro ou pasta",
|
||||
"ctrl": {
|
||||
"click": "seleccionar vários ficheiros e pastas",
|
||||
"f": "opens search",
|
||||
"f": "abre a pesquisa",
|
||||
"s": "guarda um ficheiro ou descarrega a pasta onde estiver"
|
||||
},
|
||||
"del": "elimina os itens seleccionados",
|
||||
@@ -145,7 +147,7 @@
|
||||
"lastModified": "última modificação",
|
||||
"move": "Mover",
|
||||
"moveMessage": "Escolha uma nova casa para o(s) seu(s) ficheiro(s)/pasta(s):",
|
||||
"newArchetype": "Escolha um novo post baseado no archetype. O seu ficheiro irá criado no conteúdo da pasta.",
|
||||
"newArchetype": "Criar uma nova publicação baseado no archetype. O seu ficheiro irá ser criado no conteúdo da pasta.",
|
||||
"newDir": "Nova pasta",
|
||||
"newDirMessage": "Nomeie a sua nova pasta.",
|
||||
"newFile": "Novo ficheiro",
|
||||
@@ -157,7 +159,7 @@
|
||||
"replace": "Substituir",
|
||||
"replaceMessage": "Um dos ficheiros que está a tentar enviar tem um nome em conflito. Quer ignorar este ficheiro e continuar a enviar ou substituir o existente?\none?\n",
|
||||
"schedule": "Agendar",
|
||||
"scheduleMessage": "Escolha a data e hora para agendar a autenticação desta publicação.",
|
||||
"scheduleMessage": "Escolha a data e hora para agendar a publicação desta publicação.",
|
||||
"show": "Mostrar",
|
||||
"size": "Tamanho",
|
||||
"upload": "Enviar",
|
||||
@@ -245,7 +247,7 @@
|
||||
"execute": "Executar comandos",
|
||||
"modify": "Editar ficheiros",
|
||||
"rename": "Mudar o nome ou mover ficheiros e pastas",
|
||||
"share": "Ficheiros partilhados"
|
||||
"share": "Share files (require download permission)"
|
||||
},
|
||||
"permissions": "Permissões",
|
||||
"permissionsHelp": "Pode definir um utilizador um administrador ou escolher as permissões individualmente. Se escolher \"Administrador\", todas as outras opções serão automaticamente marcadas. A gestão de utilizadores continua a ser um privilégio de um administrador.\n",
|
||||
@@ -281,6 +283,7 @@
|
||||
"currentPassword": "A Sua Palavra-passe Actual"
|
||||
},
|
||||
"sidebar": {
|
||||
"diskUsed": "{used} of {total} used",
|
||||
"help": "Ajuda",
|
||||
"hugoNew": "Hugo New",
|
||||
"login": "Iniciar sessão",
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
"rename": "Alterar nome",
|
||||
"replace": "Substituir",
|
||||
"reportIssue": "Reportar erro",
|
||||
"resumeTransfer": "Resume previous transfer",
|
||||
"resumeTransferTooltip": "Skip all conflicting files, except the ones that are smaller on the server as we suppose that their transfert has been interrupted.",
|
||||
"save": "Guardar",
|
||||
"schedule": "Agendar",
|
||||
"search": "Pesquisar",
|
||||
@@ -245,7 +247,7 @@
|
||||
"execute": "Executar comandos",
|
||||
"modify": "Editar ficheiros",
|
||||
"rename": "Alterar o nome ou mover ficheiros e pastas",
|
||||
"share": "Partilhar ficheiros"
|
||||
"share": "Share files (require download permission)"
|
||||
},
|
||||
"permissions": "Permissões",
|
||||
"permissionsHelp": "Pode definir o utilizador como administrador ou escolher as permissões manualmente. Se selecionar a opção \"Administrador\", todas as outras opções serão automaticamente selecionadas. A gestão dos utilizadores é um privilégio restringido aos administradores.\n",
|
||||
@@ -281,6 +283,7 @@
|
||||
"currentPassword": "Your Current Password"
|
||||
},
|
||||
"sidebar": {
|
||||
"diskUsed": "{used} of {total} used",
|
||||
"help": "Ajuda",
|
||||
"hugoNew": "Hugo New",
|
||||
"login": "Entrar",
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
"rename": "Переименовать",
|
||||
"replace": "Перезаписать",
|
||||
"reportIssue": "Сообщить о проблеме",
|
||||
"resumeTransfer": "Resume previous transfer",
|
||||
"resumeTransferTooltip": "Skip all conflicting files, except the ones that are smaller on the server as we suppose that their transfert has been interrupted.",
|
||||
"save": "Сохранить",
|
||||
"schedule": "Планировка",
|
||||
"search": "Поиск",
|
||||
@@ -245,7 +247,7 @@
|
||||
"execute": "Выполнять команды",
|
||||
"modify": "Редактировать файлы",
|
||||
"rename": "Переименовывать или перемещать файлы и каталоги",
|
||||
"share": "Делиться файлами"
|
||||
"share": "Share files (require download permission)"
|
||||
},
|
||||
"permissions": "Права доступа",
|
||||
"permissionsHelp": "Можно настроить пользователя как администратора или выбрать разрешения индивидуально. При выборе \"Администратор\", все остальные параметры будут автоматически выбраны. Управление пользователями - привилегия администратора.\n",
|
||||
@@ -281,6 +283,7 @@
|
||||
"currentPassword": "Your Current Password"
|
||||
},
|
||||
"sidebar": {
|
||||
"diskUsed": "{used} of {total} used",
|
||||
"help": "Помощь",
|
||||
"hugoNew": "Hugo New",
|
||||
"login": "Войти",
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
"rename": "重命名",
|
||||
"replace": "替换",
|
||||
"reportIssue": "报告问题",
|
||||
"resumeTransfer": "Resume previous transfer",
|
||||
"resumeTransferTooltip": "Skip all conflicting files, except the ones that are smaller on the server as we suppose that their transfert has been interrupted.",
|
||||
"save": "保存",
|
||||
"schedule": "计划",
|
||||
"search": "搜索",
|
||||
@@ -245,7 +247,7 @@
|
||||
"execute": "执行命令",
|
||||
"modify": "编辑",
|
||||
"rename": "重命名或移动文件和文件夹",
|
||||
"share": "分享文件"
|
||||
"share": "Share files (require download permission)"
|
||||
},
|
||||
"permissions": "权限",
|
||||
"permissionsHelp": "你可以将该用户设置为管理员或单独选择各项权限。如果你选择了“管理员”,则其他的选项会被自动选中,同时该用户可以管理其他用户。\n",
|
||||
@@ -281,6 +283,7 @@
|
||||
"currentPassword": "您当前的密码"
|
||||
},
|
||||
"sidebar": {
|
||||
"diskUsed": "{used} of {total} used",
|
||||
"help": "帮助",
|
||||
"hugoNew": "Hugo 新建",
|
||||
"login": "登录",
|
||||
|
||||
@@ -145,8 +145,11 @@ const routes = [
|
||||
},
|
||||
{
|
||||
path: "/:catchAll(.*)*",
|
||||
redirect: (to: RouteLocation) =>
|
||||
`/files/${[...to.params.catchAll].join("/")}`,
|
||||
redirect: (to: RouteLocation) => {
|
||||
const catchAll = to.params.catchAll;
|
||||
if (!catchAll) return "/files/";
|
||||
return `/files/${Array.isArray(catchAll) ? catchAll.join("/") : catchAll}`;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
Vendored
+11
-2
@@ -71,10 +71,19 @@ interface ConflictingResource {
|
||||
name: string;
|
||||
origin: ConflictingItem;
|
||||
dest: ConflictingItem;
|
||||
checked: Array<"origin" | "dest">;
|
||||
checked: Array<"origin" | "dest", "origin-resume">;
|
||||
isSmallerOnServer?: boolean;
|
||||
}
|
||||
|
||||
|
||||
interface CsvData {
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
}
|
||||
|
||||
interface RecursiveEntry {
|
||||
path: string;
|
||||
name: string;
|
||||
size: number;
|
||||
modified: string;
|
||||
isDir: boolean;
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -16,6 +16,7 @@ interface UploadEntry {
|
||||
size: number;
|
||||
isDir: boolean;
|
||||
fullPath?: string;
|
||||
to?: string;
|
||||
file?: File;
|
||||
overwrite?: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
/**
|
||||
* Reproduces the path-building logic from scanFiles() in upload.ts (lines 118-138).
|
||||
*
|
||||
* readReaderContent() is called when traversing a dropped folder. The browser's
|
||||
* FileSystemDirectoryReader.readEntries() returns entries in batches — you must
|
||||
* call it repeatedly until it returns an empty array. Each recursive call in the
|
||||
* current code appends "/" to the directory, so the second batch and beyond get
|
||||
* double (or triple, etc.) slashes in the constructed fullPath.
|
||||
*/
|
||||
|
||||
type Entry = { name: string; isFile: boolean; isDirectory: boolean };
|
||||
|
||||
function simulateScanFiles(dirName: string, entryBatches: Entry[][]): string[] {
|
||||
const paths: string[] = [];
|
||||
let batchIndex = 0;
|
||||
|
||||
// Mirrors readEntry() for files — records fullPath as `${directory}${file.name}`
|
||||
function readEntry(entry: Entry, directory = ""): void {
|
||||
if (entry.isFile) {
|
||||
paths.push(`${directory}${entry.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors readReaderContent() from upload.ts lines 118-138
|
||||
function readReaderContent(directory: string): void {
|
||||
const entries =
|
||||
batchIndex < entryBatches.length ? entryBatches[batchIndex] : [];
|
||||
batchIndex++;
|
||||
|
||||
if (entries.length > 0) {
|
||||
const dirWithSlash = directory.endsWith("/")
|
||||
? directory
|
||||
: `${directory}/`;
|
||||
for (const entry of entries) {
|
||||
readEntry(entry, dirWithSlash);
|
||||
}
|
||||
readReaderContent(dirWithSlash);
|
||||
}
|
||||
}
|
||||
|
||||
// Initial call mirrors readEntry() for a directory — upload.ts line 111-114
|
||||
readReaderContent(dirName);
|
||||
return paths;
|
||||
}
|
||||
|
||||
describe("scanFiles path construction", () => {
|
||||
it("should not produce double slashes when readEntries returns multiple batches", () => {
|
||||
// Two batches: simulates a large directory where the browser splits
|
||||
// readEntries() results across multiple calls
|
||||
const paths = simulateScanFiles("TestFolder", [
|
||||
[
|
||||
{ name: "file1.xlsx", isFile: true, isDirectory: false },
|
||||
{ name: "file2.xlsx", isFile: true, isDirectory: false },
|
||||
],
|
||||
[{ name: "file3.xlsx", isFile: true, isDirectory: false }],
|
||||
]);
|
||||
|
||||
expect(paths).toHaveLength(3);
|
||||
|
||||
for (const p of paths) {
|
||||
expect(p, `path "${p}" contains double slash`).not.toContain("//");
|
||||
}
|
||||
});
|
||||
|
||||
it("single batch should work fine (no regression)", () => {
|
||||
const paths = simulateScanFiles("TestFolder", [
|
||||
[{ name: "file1.xlsx", isFile: true, isDirectory: false }],
|
||||
]);
|
||||
|
||||
expect(paths).toEqual(["TestFolder/file1.xlsx"]);
|
||||
});
|
||||
});
|
||||
+151
-44
@@ -1,60 +1,164 @@
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
import { useUploadStore } from "@/stores/upload";
|
||||
import url from "@/utils/url";
|
||||
import { files as api } from "@/api";
|
||||
import { removePrefix } from "@/api/utils";
|
||||
|
||||
export function checkConflict(
|
||||
files: UploadList | Array<any>,
|
||||
dest: ResourceItem[]
|
||||
): ConflictingResource[] {
|
||||
if (typeof dest === "undefined" || dest === null) {
|
||||
dest = [];
|
||||
}
|
||||
const conflictingFiles: ConflictingResource[] = [];
|
||||
interface UploadEntryWithChild extends UploadEntry {
|
||||
children?: UploadEntryWithChild[];
|
||||
originalIndex: number;
|
||||
}
|
||||
|
||||
const folder_upload = files[0].fullPath !== undefined;
|
||||
/**
|
||||
* Convert UploadList into a forest (array of root nodes).
|
||||
* This properly handles uploads with multiple top-level files/folders
|
||||
* instead of assuming a single root.
|
||||
* @param flatArray
|
||||
* @param basePath
|
||||
*/
|
||||
function flatToForest(
|
||||
flatArray: UploadList,
|
||||
basePath: string
|
||||
): UploadEntryWithChild[] {
|
||||
const nodeMap: Record<string, UploadEntryWithChild> = {};
|
||||
|
||||
function getFile(name: string): ResourceItem | null {
|
||||
for (const item of dest) {
|
||||
if (item.name == name) return item;
|
||||
}
|
||||
// First pass: create all nodes
|
||||
flatArray.forEach((item, index) => {
|
||||
// File list is created from very different action and info available are not always the same.
|
||||
// By doing a drag and drop or upload a folder (both from the browser or from the OS) we have the fullPath property available
|
||||
// By uploading a single file using the file input, we only have the "name" property
|
||||
// By doing drag and drop from filebrowser to filebrowser, we have the "to" property available but not the fullPath
|
||||
const fullPathOrTo =
|
||||
item.fullPath || item.to?.replace(basePath, "") || item.name;
|
||||
nodeMap[fullPathOrTo!] = {
|
||||
fullPath: fullPathOrTo,
|
||||
isDir: item.isDir,
|
||||
name: item.name,
|
||||
size: item.size,
|
||||
originalIndex: index,
|
||||
...(item.isDir && { children: [] }),
|
||||
...(item.file && { file: item.file }),
|
||||
};
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
const roots: UploadEntryWithChild[] = [];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const name = file.name;
|
||||
// Second pass: build hierarchy
|
||||
flatArray.forEach((item) => {
|
||||
// see comment before to explanation
|
||||
const fullPathOrTo =
|
||||
item.fullPath || item.to?.replace(basePath, "") || item.name;
|
||||
|
||||
if (folder_upload && file.isDir) {
|
||||
const dirs = file.fullPath?.split("/");
|
||||
// For folder uploads, destination listing is flat and only contains
|
||||
// top-level entries. Treating every nested file as a conflict when the
|
||||
// parent folder exists blocks the whole upload (see #5798), so skip
|
||||
// preflight conflict detection for nested files.
|
||||
if (dirs && dirs.length > 1) {
|
||||
continue;
|
||||
const node = nodeMap[fullPathOrTo!];
|
||||
const lastSlash = fullPathOrTo!.lastIndexOf("/");
|
||||
|
||||
if (lastSlash === -1) {
|
||||
roots.push(node);
|
||||
} else {
|
||||
const parentPath = fullPathOrTo!.substring(0, lastSlash);
|
||||
const parent = nodeMap[parentPath];
|
||||
if (parent?.children) {
|
||||
parent.children.push(node);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const item = getFile(name);
|
||||
if (item != null) {
|
||||
conflictingFiles.push({
|
||||
index: i,
|
||||
name: item.path,
|
||||
origin: {
|
||||
lastModified: file.modified || file.file?.lastModified,
|
||||
size: file.size,
|
||||
},
|
||||
dest: {
|
||||
lastModified: item.modified,
|
||||
size: item.size,
|
||||
},
|
||||
checked: ["origin"],
|
||||
});
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return conflict files from
|
||||
* @param files - flat upload list to check
|
||||
* @param basePath - server destination path (e.g. "/files/uploads/")
|
||||
*/
|
||||
export async function checkConflict(
|
||||
files: UploadList,
|
||||
basePath: string
|
||||
): Promise<ConflictingResource[]> {
|
||||
console.log(
|
||||
"Starting conflict check, " + files.length + " possible conflict found."
|
||||
);
|
||||
|
||||
const forest = flatToForest(files, basePath);
|
||||
if (forest.length === 0) return [];
|
||||
|
||||
// Single API call: fetch the entire server tree under basePath.
|
||||
let serverEntries: RecursiveEntry[] = [];
|
||||
try {
|
||||
serverEntries = await api.fetchAll(basePath);
|
||||
} catch {
|
||||
console.error(
|
||||
`Failed to fetch recursive server listing for ${basePath}. ` +
|
||||
`Assuming directory doesn't exist and skipping conflict check.`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Build a lookup map keyed by the normalised server path for O(1) access.
|
||||
// The server returns paths relative to the user's scope (e.g. "/uploads/foo.txt").
|
||||
// We strip the basePath prefix so the key matches the upload entry's fullPath.
|
||||
const normBase = removePrefix(basePath).replace(/\/+$/, "");
|
||||
const serverMap = new Map<string, RecursiveEntry>();
|
||||
for (const entry of serverEntries) {
|
||||
// entry.path is absolute from server root, e.g. "/uploads/sub/file.txt"
|
||||
// We need the relative part after normBase, e.g. "sub/file.txt"
|
||||
let rel = entry.path;
|
||||
if (rel.startsWith(normBase)) {
|
||||
rel = rel.slice(normBase.length);
|
||||
}
|
||||
// Strip leading slash so it matches fullPath format ("sub/file.txt")
|
||||
rel = rel.replace(/^\/+/, "");
|
||||
serverMap.set(rel, entry);
|
||||
}
|
||||
|
||||
const conflicts: ConflictingResource[] = [];
|
||||
|
||||
/**
|
||||
* Walk the upload tree and compare each file node against the
|
||||
* pre-fetched server map. Directories only need to be recursed
|
||||
* when they appear in the map (otherwise no child can conflict).
|
||||
*/
|
||||
function recursiveCheckConflict(nodes: UploadEntryWithChild[]): void {
|
||||
for (const node of nodes) {
|
||||
if (node.isDir && node.children) {
|
||||
// Only recurse if this directory exists on the server
|
||||
const dirKey = node.fullPath!.replace(/^\/+/, "");
|
||||
if (serverMap.has(dirKey)) {
|
||||
recursiveCheckConflict(node.children);
|
||||
}
|
||||
} else {
|
||||
// File – check for a conflict against the server map
|
||||
const fileKey = node.fullPath!.replace(/^\/+/, "");
|
||||
const serverEntry = serverMap.get(fileKey);
|
||||
|
||||
if (serverEntry) {
|
||||
conflicts.push({
|
||||
index: node.originalIndex,
|
||||
name: serverEntry.path,
|
||||
origin: {
|
||||
lastModified: node.file?.lastModified,
|
||||
size: node.size,
|
||||
},
|
||||
dest: {
|
||||
lastModified: serverEntry.modified,
|
||||
size: serverEntry.size,
|
||||
},
|
||||
checked: ["origin"],
|
||||
isSmallerOnServer: node.size > serverEntry.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return conflictingFiles;
|
||||
// Walk all root nodes synchronously against the pre-fetched data
|
||||
recursiveCheckConflict(forest);
|
||||
|
||||
console.log(conflicts.length + " conflicts found.");
|
||||
|
||||
conflicts.sort((a, b) => a.index - b.index);
|
||||
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
export function scanFiles(dt: DataTransfer): Promise<UploadList | FileList> {
|
||||
@@ -124,11 +228,14 @@ export function scanFiles(dt: DataTransfer): Promise<UploadList | FileList> {
|
||||
reader.readEntries((entries) => {
|
||||
reading--;
|
||||
if (entries.length > 0) {
|
||||
const dirWithSlash = directory.endsWith("/")
|
||||
? directory
|
||||
: `${directory}/`;
|
||||
for (const entry of entries) {
|
||||
readEntry(entry, `${directory}/`);
|
||||
readEntry(entry, dirWithSlash);
|
||||
}
|
||||
|
||||
readReaderContent(reader, `${directory}/`);
|
||||
readReaderContent(reader, dirWithSlash);
|
||||
}
|
||||
|
||||
if (reading === 0) {
|
||||
|
||||
@@ -647,7 +647,7 @@ const copyCut = (event: Event | KeyboardEvent): void => {
|
||||
});
|
||||
};
|
||||
|
||||
const paste = (event: Event) => {
|
||||
const paste = async (event: Event) => {
|
||||
if ((event.target as HTMLElement).tagName?.toLowerCase() === "input") return;
|
||||
|
||||
// TODO router location should it be
|
||||
@@ -696,7 +696,8 @@ const paste = (event: Event) => {
|
||||
};
|
||||
}
|
||||
|
||||
const conflict = upload.checkConflict(items, fileStore.req!.items);
|
||||
const path = route.path.endsWith("/") ? route.path : route.path + "/";
|
||||
const conflict = await upload.checkConflict(items, path);
|
||||
|
||||
if (conflict.length > 0) {
|
||||
layoutStore.showHover({
|
||||
@@ -799,7 +800,6 @@ const drop = async (event: DragEvent) => {
|
||||
}
|
||||
|
||||
const files: UploadList = (await upload.scanFiles(dt)) as UploadList;
|
||||
let items = fileStore.req.items;
|
||||
let path = route.path.endsWith("/") ? route.path : route.path + "/";
|
||||
|
||||
if (
|
||||
@@ -812,13 +812,14 @@ const drop = async (event: DragEvent) => {
|
||||
path = el.__vue__.url;
|
||||
|
||||
try {
|
||||
items = (await api.fetch(path)).items;
|
||||
(await api.fetch(path)).items;
|
||||
} catch (error: any) {
|
||||
$showError(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const conflict = upload.checkConflict(files, items);
|
||||
const conflict = await upload.checkConflict(files, path);
|
||||
|
||||
const preselect = removePrefix(path) + (files[0].fullPath || files[0].name);
|
||||
|
||||
@@ -856,7 +857,7 @@ const drop = async (event: DragEvent) => {
|
||||
fileStore.preselect = preselect;
|
||||
};
|
||||
|
||||
const uploadInput = (event: Event) => {
|
||||
const uploadInput = async (event: Event) => {
|
||||
const files = (event.currentTarget as HTMLInputElement)?.files;
|
||||
if (files === null) return;
|
||||
|
||||
@@ -876,7 +877,7 @@ const uploadInput = (event: Event) => {
|
||||
}
|
||||
|
||||
const path = route.path.endsWith("/") ? route.path : route.path + "/";
|
||||
const conflict = upload.checkConflict(uploadFiles, fileStore.req!.items);
|
||||
const conflict = await upload.checkConflict(uploadFiles, path);
|
||||
|
||||
if (conflict.length > 0) {
|
||||
layoutStore.showHover({
|
||||
|
||||
@@ -84,7 +84,6 @@
|
||||
}"
|
||||
:epubOptions="{
|
||||
allowPopups: true,
|
||||
allowScriptedContent: true,
|
||||
}"
|
||||
@update:location="locationChange"
|
||||
/>
|
||||
@@ -384,11 +383,19 @@ const key = (event: KeyboardEvent) => {
|
||||
if (layoutStore.currentPrompt !== null) {
|
||||
return;
|
||||
}
|
||||
if (event.which === 13 || event.which === 39) {
|
||||
// When previewing a video, let arrow keys fall through to video.js for
|
||||
// seeking instead of switching to the prev/next file. Enter still advances.
|
||||
const isVideo = fileStore.req?.type === "video";
|
||||
if (event.which === 13) {
|
||||
// enter
|
||||
if (hasNext.value) next();
|
||||
} else if (event.which === 39) {
|
||||
// right arrow
|
||||
if (isVideo) return;
|
||||
if (hasNext.value) next();
|
||||
} else if (event.which === 37) {
|
||||
// left arrow
|
||||
if (isVideo) return;
|
||||
if (hasPrevious.value) prev();
|
||||
} else if (event.which === 27) {
|
||||
// esc
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<a
|
||||
class="link"
|
||||
target="_blank"
|
||||
href="https://filebrowser.org/configuration.html#custom-branding"
|
||||
href="https://filebrowser.org/customization.html#custom-branding"
|
||||
>{{ t("settings.documentation") }}</a
|
||||
>
|
||||
</i18n-t>
|
||||
@@ -209,7 +209,7 @@
|
||||
<a
|
||||
class="link"
|
||||
target="_blank"
|
||||
href="https://filebrowser.org/configuration.html#command-runner"
|
||||
href="https://filebrowser.org/command-execution.html#hook-runner"
|
||||
>{{ t("settings.documentation") }}</a
|
||||
>
|
||||
</i18n-t>
|
||||
|
||||
@@ -4,7 +4,7 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/asdine/storm/v3 v3.2.1
|
||||
github.com/asticode/go-astisub v0.39.0
|
||||
github.com/asticode/go-astisub v0.40.0
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/dsoprea/go-exif/v3 v3.0.1
|
||||
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568
|
||||
@@ -16,9 +16,9 @@ require (
|
||||
github.com/marusama/semaphore/v2 v2.5.0
|
||||
github.com/mholt/archives v0.1.5
|
||||
github.com/mitchellh/go-homedir v1.1.0
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
github.com/redis/go-redis/v9 v9.19.0
|
||||
github.com/samber/lo v1.53.0
|
||||
github.com/shirou/gopsutil/v4 v4.26.2
|
||||
github.com/shirou/gopsutil/v4 v4.26.4
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.10
|
||||
@@ -26,9 +26,9 @@ require (
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/image v0.37.0
|
||||
golang.org/x/text v0.35.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/image v0.39.0
|
||||
golang.org/x/text v0.36.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
@@ -44,7 +44,6 @@ require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
|
||||
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
|
||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
|
||||
@@ -77,9 +76,9 @@ require (
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
@@ -31,8 +31,8 @@ github.com/asticode/go-astikit v0.20.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xbl
|
||||
github.com/asticode/go-astikit v0.30.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0=
|
||||
github.com/asticode/go-astikit v0.56.0 h1:DmD2p7YnvxiPdF0h+dRmos3bsejNEXbycENsY5JfBqw=
|
||||
github.com/asticode/go-astikit v0.56.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE=
|
||||
github.com/asticode/go-astisub v0.39.0 h1:j1/rFLRUH0TT2CW9YCtBek9lRdMp96oxaZm6vbgE96M=
|
||||
github.com/asticode/go-astisub v0.39.0/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8=
|
||||
github.com/asticode/go-astisub v0.40.0 h1:Z8tAXHngjkh/4L9zqt49ru9UdpDTqBIe+80xexKM3/I=
|
||||
github.com/asticode/go-astisub v0.40.0/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8=
|
||||
github.com/asticode/go-astits v1.8.0/go.mod h1:DkOWmBNQpnr9mv24KfZjq4JawCFX1FCqjLVGvO0DygQ=
|
||||
github.com/asticode/go-astits v1.13.0 h1:XOgkaadfZODnyZRR5Y0/DWkA9vrkLLPLeeOvDwfKZ1c=
|
||||
github.com/asticode/go-astits v1.13.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI=
|
||||
@@ -59,8 +59,6 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
|
||||
@@ -168,8 +166,8 @@ github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zt
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid v1.2.0 h1:NMpwD2G9JSFOE1/TJjGSo5zG7Yb2bTe7eq1jH+irmeE=
|
||||
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
||||
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
@@ -205,8 +203,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k=
|
||||
github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
@@ -217,8 +215,8 @@ github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDc
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
|
||||
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||
github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI=
|
||||
github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/shirou/gopsutil/v4 v4.26.4 h1:B4SXVbcwTyrocPHEmWBC4uCYr4Xcu3MK1TXqbprAOWY=
|
||||
github.com/shirou/gopsutil/v4 v4.26.4/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
|
||||
github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
@@ -260,8 +258,8 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
@@ -283,8 +281,8 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -296,8 +294,8 @@ golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EH
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
|
||||
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
@@ -336,8 +334,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -377,8 +375,8 @@ golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -389,8 +387,8 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
||||
+15
-1
@@ -20,6 +20,8 @@ import (
|
||||
|
||||
const (
|
||||
DefaultTokenExpirationTime = time.Hour * 2
|
||||
|
||||
maxAuthBodySize = 1 << 20 // 1 MiB
|
||||
)
|
||||
|
||||
type userInfo struct {
|
||||
@@ -120,6 +122,10 @@ func withAdmin(fn handleFunc) handleFunc {
|
||||
|
||||
func loginHandler(tokenExpireTime time.Duration) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
if r.Body != nil {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxAuthBodySize)
|
||||
}
|
||||
|
||||
auther, err := d.store.Auth.Get(d.settings.AuthMethod)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
@@ -142,7 +148,7 @@ type signupBody struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
var signupHandler = func(_ http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
var signupHandler = func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
if !d.settings.Signup {
|
||||
return http.StatusMethodNotAllowed, nil
|
||||
}
|
||||
@@ -151,6 +157,8 @@ var signupHandler = func(_ http.ResponseWriter, r *http.Request, d *data) (int,
|
||||
return http.StatusBadRequest, nil
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxAuthBodySize)
|
||||
|
||||
info := &signupBody{}
|
||||
err := json.NewDecoder(r.Body).Decode(info)
|
||||
if err != nil {
|
||||
@@ -171,6 +179,12 @@ var signupHandler = func(_ http.ResponseWriter, r *http.Request, d *data) (int,
|
||||
// if that is the default permission.
|
||||
user.Perm.Admin = false
|
||||
|
||||
// Self-registered users should not inherit execution capabilities from
|
||||
// default settings, regardless of what the administrator has configured
|
||||
// as the default. Execution rights must be explicitly granted by an admin.
|
||||
user.Perm.Execute = false
|
||||
user.Commands = []string{}
|
||||
|
||||
pwd, err := users.ValidateAndHashPwd(info.Password, d.settings.MinimumPasswordLength)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
|
||||
@@ -3,6 +3,7 @@ package fbhttp
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
gopath "path"
|
||||
"strconv"
|
||||
|
||||
"github.com/tomasen/realip"
|
||||
@@ -23,10 +24,25 @@ type data struct {
|
||||
store *storage.Storage
|
||||
user *users.User
|
||||
raw interface{}
|
||||
|
||||
// checkerPrefix is prepended to every path before evaluating rules. It is
|
||||
// set when the user's filesystem has been rebased onto a subdirectory (as
|
||||
// done for public shares), so that rules — which are relative to the user's
|
||||
// original scope — are still matched against the real path instead of the
|
||||
// rebased one. Empty for regular requests.
|
||||
checkerPrefix string
|
||||
}
|
||||
|
||||
// Check implements rules.Checker.
|
||||
func (d *data) Check(path string) bool {
|
||||
// When the filesystem has been rebased (e.g. a public share rooted at a
|
||||
// subdirectory), the incoming path is relative to that root. Resolve it
|
||||
// back to the user's original scope before matching rules, otherwise rules
|
||||
// targeting paths below the share root would be silently bypassed.
|
||||
if d.checkerPrefix != "" {
|
||||
path = gopath.Join(d.checkerPrefix, path)
|
||||
}
|
||||
|
||||
if d.user.HideDotfiles && rules.MatchHidden(path) {
|
||||
return false
|
||||
}
|
||||
|
||||
+2
-1
@@ -57,6 +57,7 @@ func NewHandler(
|
||||
users.Handle("/{id:[0-9]+}", monkey(userGetHandler, "")).Methods("GET")
|
||||
users.Handle("/{id:[0-9]+}", monkey(userDeleteHandler, "")).Methods("DELETE")
|
||||
|
||||
api.PathPrefix("/resources/recursive").Handler(monkey(resourceGetRecursiveHandler, "/api/resources/recursive")).Methods("GET")
|
||||
api.PathPrefix("/resources").Handler(monkey(resourceGetHandler, "/api/resources")).Methods("GET")
|
||||
api.PathPrefix("/resources").Handler(monkey(resourceDeleteHandler(fileCache), "/api/resources")).Methods("DELETE")
|
||||
api.PathPrefix("/resources").Handler(monkey(resourcePostHandler(fileCache), "/api/resources")).Methods("POST")
|
||||
@@ -70,7 +71,7 @@ func NewHandler(
|
||||
|
||||
api.PathPrefix("/usage").Handler(monkey(diskUsage, "/api/usage")).Methods("GET")
|
||||
|
||||
api.Path("/shares").Handler(monkey(shareListHandler, "/api/shares")).Methods("GET")
|
||||
api.Handle("/shares", monkey(shareListHandler, "")).Methods("GET")
|
||||
api.PathPrefix("/share").Handler(monkey(shareGetsHandler, "/api/share")).Methods("GET")
|
||||
api.PathPrefix("/share").Handler(monkey(sharePostHandler, "/api/share")).Methods("POST")
|
||||
api.PathPrefix("/share").Handler(monkey(shareDeleteHandler, "/api/share")).Methods("DELETE")
|
||||
|
||||
@@ -33,6 +33,10 @@ var withHashFile = func(fn handleFunc) handleFunc {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
if !user.Perm.Share || !user.Perm.Download {
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
|
||||
d.user = user
|
||||
|
||||
file, err := files.NewFileInfo(&files.FileOptions{
|
||||
@@ -63,6 +67,11 @@ var withHashFile = func(fn handleFunc) handleFunc {
|
||||
// set fs root to the shared file/folder
|
||||
d.user.Fs = afero.NewBasePathFs(d.user.Fs, basePath)
|
||||
|
||||
// the filesystem is now rebased onto basePath, so paths handed to the
|
||||
// rule checker are relative to it. Resolve them back to the user's
|
||||
// original scope so deny rules below the share root keep applying.
|
||||
d.checkerPrefix = basePath
|
||||
|
||||
file, err = files.NewFileInfo(&files.FileOptions{
|
||||
Fs: d.user.Fs,
|
||||
Path: filePath,
|
||||
|
||||
+141
-1
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/asdine/storm/v3"
|
||||
"github.com/spf13/afero"
|
||||
|
||||
"github.com/filebrowser/filebrowser/v2/rules"
|
||||
"github.com/filebrowser/filebrowser/v2/settings"
|
||||
"github.com/filebrowser/filebrowser/v2/share"
|
||||
"github.com/filebrowser/filebrowser/v2/storage/bolt"
|
||||
@@ -23,38 +24,66 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
|
||||
testCases := map[string]struct {
|
||||
share *share.Link
|
||||
req *http.Request
|
||||
sharePerm bool
|
||||
downloadPerm bool
|
||||
expectedStatusCode int
|
||||
}{
|
||||
"Public share, no auth required": {
|
||||
share: &share.Link{Hash: "h", UserID: 1},
|
||||
req: newHTTPRequest(t),
|
||||
sharePerm: true,
|
||||
downloadPerm: true,
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
"Private share, no auth provided, 401": {
|
||||
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
|
||||
req: newHTTPRequest(t),
|
||||
sharePerm: true,
|
||||
downloadPerm: true,
|
||||
expectedStatusCode: 401,
|
||||
},
|
||||
"Private share, authentication via token": {
|
||||
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
|
||||
req: newHTTPRequest(t, func(r *http.Request) { r.URL.RawQuery = "token=123" }),
|
||||
sharePerm: true,
|
||||
downloadPerm: true,
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
"Private share, authentication via invalid token, 401": {
|
||||
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
|
||||
req: newHTTPRequest(t, func(r *http.Request) { r.URL.RawQuery = "token=1234" }),
|
||||
sharePerm: true,
|
||||
downloadPerm: true,
|
||||
expectedStatusCode: 401,
|
||||
},
|
||||
"Private share, authentication via password": {
|
||||
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
|
||||
req: newHTTPRequest(t, func(r *http.Request) { r.Header.Set("X-SHARE-PASSWORD", "password") }),
|
||||
sharePerm: true,
|
||||
downloadPerm: true,
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
"Private share, authentication via invalid password, 401": {
|
||||
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
|
||||
req: newHTTPRequest(t, func(r *http.Request) { r.Header.Set("X-SHARE-PASSWORD", "wrong-password") }),
|
||||
sharePerm: true,
|
||||
downloadPerm: true,
|
||||
expectedStatusCode: 401,
|
||||
},
|
||||
"Share owner lost share permission, 403": {
|
||||
share: &share.Link{Hash: "h", UserID: 1},
|
||||
req: newHTTPRequest(t),
|
||||
sharePerm: false,
|
||||
downloadPerm: true,
|
||||
expectedStatusCode: 403,
|
||||
},
|
||||
"Share owner lost download permission, 403": {
|
||||
share: &share.Link{Hash: "h", UserID: 1},
|
||||
req: newHTTPRequest(t),
|
||||
sharePerm: true,
|
||||
downloadPerm: false,
|
||||
expectedStatusCode: 403,
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range testCases {
|
||||
@@ -82,7 +111,14 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
|
||||
if err := storage.Share.Save(tc.share); err != nil {
|
||||
t.Fatalf("failed to save share: %v", err)
|
||||
}
|
||||
if err := storage.Users.Save(&users.User{Username: "username", Password: "pw"}); err != nil {
|
||||
if err := storage.Users.Save(&users.User{
|
||||
Username: "username",
|
||||
Password: "pw",
|
||||
Perm: users.Permissions{
|
||||
Share: tc.sharePerm,
|
||||
Download: tc.downloadPerm,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("failed to save user: %v", err)
|
||||
}
|
||||
if err := storage.Settings.Save(&settings.Settings{Key: []byte("key")}); err != nil {
|
||||
@@ -108,6 +144,110 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublicShareHandlerRules ensures that owner rules keep applying to paths
|
||||
// below a shared directory, even though the share rebases the filesystem onto
|
||||
// that directory. A deny rule relative to the owner's scope must not be
|
||||
// bypassable by requesting the blocked path through the public share.
|
||||
func TestPublicShareHandlerRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := map[string]struct {
|
||||
handler handleFunc
|
||||
path string
|
||||
expectedStatusCode int
|
||||
}{
|
||||
"blocked file via dl handler, 403": {
|
||||
handler: publicDlHandler,
|
||||
path: "h/private/secret.txt",
|
||||
expectedStatusCode: 403,
|
||||
},
|
||||
"blocked dir listing via share handler, 403": {
|
||||
handler: publicShareHandler,
|
||||
path: "h/private/",
|
||||
expectedStatusCode: 403,
|
||||
},
|
||||
"blocked dir download via dl handler, 403": {
|
||||
handler: publicDlHandler,
|
||||
path: "h/private/",
|
||||
expectedStatusCode: 403,
|
||||
},
|
||||
"allowed file via dl handler, 200": {
|
||||
handler: publicDlHandler,
|
||||
path: "h/public/readme.txt",
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
"allowed dir listing via share handler, 200": {
|
||||
handler: publicShareHandler,
|
||||
path: "h/public/",
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range testCases {
|
||||
name, tc := name, tc
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "db")
|
||||
db, err := storm.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Errorf("failed to close db: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
storage, err := bolt.NewStorage(db)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get storage: %v", err)
|
||||
}
|
||||
if err := storage.Share.Save(&share.Link{Hash: "h", UserID: 1, Path: "/projects"}); err != nil {
|
||||
t.Fatalf("failed to save share: %v", err)
|
||||
}
|
||||
if err := storage.Users.Save(&users.User{
|
||||
Username: "username",
|
||||
Password: "pw",
|
||||
Perm: users.Permissions{Share: true, Download: true},
|
||||
Rules: []rules.Rule{
|
||||
{Allow: false, Path: "/projects/private"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("failed to save user: %v", err)
|
||||
}
|
||||
if err := storage.Settings.Save(&settings.Settings{Key: []byte("key")}); err != nil {
|
||||
t.Fatalf("failed to save settings: %v", err)
|
||||
}
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
if err := afero.WriteFile(fs, "/projects/private/secret.txt", []byte("top secret"), 0o600); err != nil {
|
||||
t.Fatalf("failed to write secret file: %v", err)
|
||||
}
|
||||
if err := afero.WriteFile(fs, "/projects/public/readme.txt", []byte("hello"), 0o600); err != nil {
|
||||
t.Fatalf("failed to write public file: %v", err)
|
||||
}
|
||||
|
||||
storage.Users = &customFSUser{
|
||||
Store: storage.Users,
|
||||
fs: fs,
|
||||
}
|
||||
|
||||
req := newHTTPRequest(t, func(r *http.Request) { r.URL.Path = tc.path })
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler := handle(tc.handler, "", storage, &settings.Server{})
|
||||
|
||||
handler.ServeHTTP(recorder, req)
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
if result.StatusCode != tc.expectedStatusCode {
|
||||
t.Errorf("expected status code %d, got status code %d", tc.expectedStatusCode, result.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newHTTPRequest(t *testing.T, requestModifiers ...func(*http.Request)) *http.Request {
|
||||
t.Helper()
|
||||
r, err := http.NewRequest(http.MethodGet, "h", http.NoBody)
|
||||
|
||||
+8
-1
@@ -72,7 +72,8 @@ func parseQueryAlgorithm(r *http.Request) (string, archives.Archival, error) {
|
||||
|
||||
func setContentDisposition(w http.ResponseWriter, r *http.Request, file *files.FileInfo) {
|
||||
if r.URL.Query().Get("inline") == "true" {
|
||||
w.Header().Set("Content-Disposition", "inline")
|
||||
// As per RFC6266 section 4.3
|
||||
w.Header().Set("Content-Disposition", "inline; filename*=utf-8''"+url.PathEscape(file.Name))
|
||||
} else {
|
||||
// As per RFC6266 section 4.3
|
||||
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(file.Name))
|
||||
@@ -124,6 +125,12 @@ func getFiles(d *data, path, commonPath string) ([]archives.FileInfo, error) {
|
||||
nameInArchive := strings.TrimPrefix(path, commonPath)
|
||||
nameInArchive = strings.TrimPrefix(nameInArchive, string(filepath.Separator))
|
||||
nameInArchive = filepath.ToSlash(nameInArchive)
|
||||
// filepath.ToSlash only rewrites the host separator, so on a Linux
|
||||
// host a stored backslash survives and is emitted verbatim into the
|
||||
// archive. Windows extractors then treat "\" as a path separator,
|
||||
// allowing the entry to escape the extraction directory (zip-slip).
|
||||
// Strip Windows separators regardless of host OS.
|
||||
nameInArchive = strings.ReplaceAll(nameInArchive, "\\", "/")
|
||||
|
||||
archiveFiles = append(archiveFiles, archives.FileInfo{
|
||||
FileInfo: info,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package fbhttp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/filebrowser/filebrowser/v2/files"
|
||||
)
|
||||
|
||||
func TestSetContentDisposition(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := map[string]struct {
|
||||
filename string
|
||||
inline bool
|
||||
expected string
|
||||
}{
|
||||
"inline simple filename": {
|
||||
filename: "document.pdf",
|
||||
inline: true,
|
||||
expected: "inline; filename*=utf-8''" + url.PathEscape("document.pdf"),
|
||||
},
|
||||
"attachment simple filename": {
|
||||
filename: "document.pdf",
|
||||
inline: false,
|
||||
expected: "attachment; filename*=utf-8''" + url.PathEscape("document.pdf"),
|
||||
},
|
||||
"inline non-ASCII filename": {
|
||||
filename: "日本語.txt",
|
||||
inline: true,
|
||||
expected: "inline; filename*=utf-8''" + url.PathEscape("日本語.txt"),
|
||||
},
|
||||
"attachment non-ASCII filename": {
|
||||
filename: "日本語.txt",
|
||||
inline: false,
|
||||
expected: "attachment; filename*=utf-8''" + url.PathEscape("日本語.txt"),
|
||||
},
|
||||
"inline filename with spaces": {
|
||||
filename: "my file.txt",
|
||||
inline: true,
|
||||
expected: "inline; filename*=utf-8''" + url.PathEscape("my file.txt"),
|
||||
},
|
||||
"attachment filename with spaces": {
|
||||
filename: "my file.txt",
|
||||
inline: false,
|
||||
expected: "attachment; filename*=utf-8''" + url.PathEscape("my file.txt"),
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
req, err := http.NewRequest(http.MethodGet, "/test", http.NoBody)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
if tc.inline {
|
||||
req.URL.RawQuery = "inline=true"
|
||||
}
|
||||
|
||||
file := &files.FileInfo{Name: tc.filename}
|
||||
|
||||
setContentDisposition(recorder, req, file)
|
||||
|
||||
got := recorder.Header().Get("Content-Disposition")
|
||||
if got != tc.expected {
|
||||
t.Errorf("Content-Disposition = %q, want %q", got, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+76
-2
@@ -13,6 +13,7 @@ import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shirou/gopsutil/v4/disk"
|
||||
"github.com/spf13/afero"
|
||||
@@ -30,7 +31,7 @@ var resourceGetHandler = withUser(func(w http.ResponseWriter, r *http.Request, d
|
||||
Expand: true,
|
||||
ReadHeader: d.server.TypeDetectionByHeader,
|
||||
Checker: d,
|
||||
Content: true,
|
||||
Content: d.user.Perm.Download,
|
||||
})
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
@@ -42,6 +43,9 @@ var resourceGetHandler = withUser(func(w http.ResponseWriter, r *http.Request, d
|
||||
file.ApplySort()
|
||||
return renderJSON(w, r, file)
|
||||
} else if encoding == "true" {
|
||||
if !d.user.Perm.Download {
|
||||
return http.StatusAccepted, nil
|
||||
}
|
||||
if file.Type != "text" {
|
||||
return renderJSON(w, r, file)
|
||||
}
|
||||
@@ -96,7 +100,7 @@ func resourceDeleteHandler(fileCache FileCache) handleFunc {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
err = d.store.Share.DeleteWithPathPrefix(file.Path)
|
||||
err = d.store.Share.DeleteWithPathPrefix(file.Path, d.user.ID)
|
||||
if err != nil {
|
||||
log.Printf("WARNING: Error(s) occurred while deleting associated shares with file: %s", err)
|
||||
}
|
||||
@@ -291,6 +295,13 @@ func addVersionSuffix(source string, afs afero.Fs) string {
|
||||
}
|
||||
|
||||
func writeFile(afs afero.Fs, dst string, in io.Reader, fileMode, dirMode fs.FileMode) (os.FileInfo, error) {
|
||||
// Refuse to write through a symlink that escapes the user's scope, so an
|
||||
// overwrite of an existing escaping symlink cannot modify a file outside
|
||||
// the boundary.
|
||||
if ok, err := files.WithinScope(afs, dst); err != nil || !ok {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
|
||||
dir, _ := path.Split(dst)
|
||||
err := afs.MkdirAll(dir, dirMode)
|
||||
if err != nil {
|
||||
@@ -373,6 +384,69 @@ func patchAction(ctx context.Context, action, src, dst string, d *data, fileCach
|
||||
}
|
||||
}
|
||||
|
||||
// RecursiveEntry is a single file/directory entry returned by the recursive listing endpoint.
|
||||
type RecursiveEntry struct {
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime time.Time `json:"modified"`
|
||||
IsDir bool `json:"isDir"`
|
||||
}
|
||||
|
||||
// resourceGetRecursiveHandler returns a flat list of every file and directory
|
||||
// under the requested path, walking the tree recursively on the server side
|
||||
// so the client only needs a single HTTP call.
|
||||
var resourceGetRecursiveHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
rootPath := r.URL.Path
|
||||
if rootPath == "" {
|
||||
rootPath = "/"
|
||||
}
|
||||
|
||||
// Make sure the root itself exists and is a directory.
|
||||
info, err := d.user.Fs.Stat(rootPath)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return http.StatusBadRequest, fmt.Errorf("path is not a directory")
|
||||
}
|
||||
|
||||
entries := make([]RecursiveEntry, 0)
|
||||
|
||||
err = afero.Walk(d.user.Fs, rootPath, func(fPath string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil // skip entries we cannot read
|
||||
}
|
||||
|
||||
// Skip the root directory itself.
|
||||
if fPath == rootPath {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Respect user rules.
|
||||
if !d.Check(fPath) {
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
entries = append(entries, RecursiveEntry{
|
||||
Path: fPath,
|
||||
Name: info.Name(),
|
||||
Size: info.Size(),
|
||||
ModTime: info.ModTime(),
|
||||
IsDir: info.IsDir(),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
return renderJSON(w, r, entries)
|
||||
})
|
||||
|
||||
type DiskUsageResponse struct {
|
||||
Total uint64 `json:"total"`
|
||||
Used uint64 `json:"used"`
|
||||
|
||||
+9
-1
@@ -57,7 +57,15 @@ var shareListHandler = withPermShare(func(w http.ResponseWriter, r *http.Request
|
||||
})
|
||||
|
||||
var shareGetsHandler = withPermShare(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
s, err := d.store.Share.Gets(r.URL.Path, d.user.ID)
|
||||
var (
|
||||
s []*share.Link
|
||||
err error
|
||||
)
|
||||
if d.user.Perm.Admin {
|
||||
s, err = d.store.Share.GetsByPath(r.URL.Path)
|
||||
} else {
|
||||
s, err = d.store.Share.Gets(r.URL.Path, d.user.ID)
|
||||
}
|
||||
if errors.Is(err, fberrors.ErrNotExist) {
|
||||
return renderJSON(w, r, []*share.Link{})
|
||||
}
|
||||
|
||||
+6
-6
@@ -1,19 +1,19 @@
|
||||
package fbhttp
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"html/template"
|
||||
"io"
|
||||
"compress/gzip"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/filebrowser/filebrowser/v2/auth"
|
||||
"github.com/filebrowser/filebrowser/v2/settings"
|
||||
@@ -85,7 +85,7 @@ func handleWithStaticData(w http.ResponseWriter, _ *http.Request, d *data, fSys
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
data["Json"] = strings.ReplaceAll(string(b), `'`, `\'`)
|
||||
data["Json"] = template.JS(strings.ReplaceAll(string(b), `'`, `\'`))
|
||||
|
||||
fileContents, err := fs.ReadFile(fSys, file)
|
||||
if err != nil {
|
||||
@@ -153,7 +153,7 @@ func getStaticHandlers(store *storage.Storage, server *settings.Server, assetsFs
|
||||
defer f.Close()
|
||||
|
||||
acceptEncoding := r.Header.Get("Accept-Encoding")
|
||||
if strings.Contains(acceptEncoding, "gzip") {
|
||||
if strings.Contains(acceptEncoding, "gzip") {
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
|
||||
@@ -168,7 +168,7 @@ func getStaticHandlers(store *storage.Storage, server *settings.Server, assetsFs
|
||||
defer gzReader.Close()
|
||||
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
|
||||
|
||||
if _, err := io.Copy(w, gzReader); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ func tusPostHandler(cache UploadCache) handleFunc {
|
||||
}
|
||||
|
||||
uploadLength, err := getUploadLength(r)
|
||||
if err != nil {
|
||||
if err != nil || uploadLength < 0 {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid upload length: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,8 @@ func (c *redisUploadCache) GetLength(filePath string) (int64, error) {
|
||||
return 0, fmt.Errorf("invalid upload length in cache: %w", err)
|
||||
}
|
||||
|
||||
c.Touch(filePath)
|
||||
|
||||
return size, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -156,6 +156,10 @@ var userPostHandler = withAdmin(func(w http.ResponseWriter, r *http.Request, d *
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
|
||||
if req.Data.Perm.Share && !req.Data.Perm.Download {
|
||||
return http.StatusBadRequest, fberrors.ErrShareRequiresDownload
|
||||
}
|
||||
|
||||
userHome, err := d.settings.MakeUserDir(req.Data.Username, req.Data.Scope, d.server.Root)
|
||||
if err != nil {
|
||||
log.Printf("create user: failed to mkdir user home dir: [%s]", userHome)
|
||||
@@ -204,6 +208,14 @@ var userPutHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request
|
||||
return http.StatusBadRequest, nil
|
||||
}
|
||||
|
||||
for _, field := range req.Which {
|
||||
if strings.ToLower(field) == "perm" || strings.ToLower(field) == "all" {
|
||||
if req.Data.Perm.Share && !req.Data.Perm.Download {
|
||||
return http.StatusBadRequest, fberrors.ErrShareRequiresDownload
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(req.Which) == 0 || (len(req.Which) == 1 && req.Which[0] == "all") {
|
||||
if !d.user.Perm.Admin {
|
||||
return http.StatusForbidden, nil
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"group:allNonMajor",
|
||||
"group:allDigest",
|
||||
":disableDependencyDashboard",
|
||||
":disableMajorUpdates",
|
||||
":semanticCommitTypeAll(chore)"
|
||||
],
|
||||
"postUpdateOptions": [
|
||||
|
||||
+10
-1
@@ -31,7 +31,16 @@ func (r *Rule) Matches(path string) bool {
|
||||
return r.Regexp.MatchString(path)
|
||||
}
|
||||
|
||||
return strings.HasPrefix(path, r.Path)
|
||||
if path == r.Path {
|
||||
return true
|
||||
}
|
||||
|
||||
prefix := r.Path
|
||||
if prefix != "/" && !strings.HasSuffix(prefix, "/") {
|
||||
prefix += "/"
|
||||
}
|
||||
|
||||
return strings.HasPrefix(path, prefix)
|
||||
}
|
||||
|
||||
// Regexp is a wrapper to the native regexp type where we
|
||||
|
||||
@@ -2,6 +2,37 @@ package rules
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRuleMatches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
rulePath string
|
||||
testPath string
|
||||
want bool
|
||||
}{
|
||||
{"exact match", "/uploads", "/uploads", true},
|
||||
{"child path", "/uploads", "/uploads/file.txt", true},
|
||||
{"sibling prefix", "/uploads", "/uploads_backup/secret.txt", false},
|
||||
{"root rule", "/", "/anything", true},
|
||||
{"trailing slash rule", "/uploads/", "/uploads/file.txt", true},
|
||||
{"trailing slash no sibling", "/uploads/", "/uploads_backup/file.txt", false},
|
||||
{"nested child", "/data/shared", "/data/shared/docs/file.txt", true},
|
||||
{"nested sibling", "/data/shared", "/data/shared_private/file.txt", false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := &Rule{Path: tc.rulePath}
|
||||
got := r.Matches(tc.testPath)
|
||||
if got != tc.want {
|
||||
t.Errorf("Rule{Path: %q}.Matches(%q) = %v; want %v", tc.rulePath, tc.testPath, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchHidden(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"/": false,
|
||||
|
||||
+20
-3
@@ -15,7 +15,7 @@ type StorageBackend interface {
|
||||
Gets(path string, id uint) ([]*Link, error)
|
||||
Save(s *Link) error
|
||||
Delete(hash string) error
|
||||
DeleteWithPathPrefix(path string) error
|
||||
DeleteWithPathPrefix(path string, userID uint) error
|
||||
}
|
||||
|
||||
// Storage is a storage.
|
||||
@@ -110,6 +110,23 @@ func (s *Storage) Gets(path string, id uint) ([]*Link, error) {
|
||||
return links, nil
|
||||
}
|
||||
|
||||
// GetsByPath returns all non-expired links for a path.
|
||||
func (s *Storage) GetsByPath(path string) ([]*Link, error) {
|
||||
links, err := s.All()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filtered := make([]*Link, 0, len(links))
|
||||
for _, link := range links {
|
||||
if link.Path == path {
|
||||
filtered = append(filtered, link)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// Save wraps a StorageBackend.Save
|
||||
func (s *Storage) Save(l *Link) error {
|
||||
return s.back.Save(l)
|
||||
@@ -120,6 +137,6 @@ func (s *Storage) Delete(hash string) error {
|
||||
return s.back.Delete(hash)
|
||||
}
|
||||
|
||||
func (s *Storage) DeleteWithPathPrefix(path string) error {
|
||||
return s.back.DeleteWithPathPrefix(path)
|
||||
func (s *Storage) DeleteWithPathPrefix(path string, userID uint) error {
|
||||
return s.back.DeleteWithPathPrefix(path, userID)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package share
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeBackend struct {
|
||||
links []*Link
|
||||
}
|
||||
|
||||
func (f fakeBackend) All() ([]*Link, error) {
|
||||
return f.links, nil
|
||||
}
|
||||
|
||||
func (f fakeBackend) FindByUserID(id uint) ([]*Link, error) {
|
||||
var links []*Link
|
||||
for _, link := range f.links {
|
||||
if link.UserID == id {
|
||||
links = append(links, link)
|
||||
}
|
||||
}
|
||||
return links, nil
|
||||
}
|
||||
|
||||
func (f fakeBackend) GetByHash(hash string) (*Link, error) {
|
||||
for _, link := range f.links {
|
||||
if link.Hash == hash {
|
||||
return link, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f fakeBackend) GetPermanent(path string, id uint) (*Link, error) {
|
||||
for _, link := range f.links {
|
||||
if link.Path == path && link.UserID == id && link.Expire == 0 {
|
||||
return link, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f fakeBackend) Gets(path string, id uint) ([]*Link, error) {
|
||||
var links []*Link
|
||||
for _, link := range f.links {
|
||||
if link.Path == path && link.UserID == id {
|
||||
links = append(links, link)
|
||||
}
|
||||
}
|
||||
return links, nil
|
||||
}
|
||||
|
||||
func (f fakeBackend) Save(_ *Link) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f fakeBackend) Delete(_ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f fakeBackend) DeleteWithPathPrefix(_ string, _ uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGetsByPathReturnsLinksFromAllUsers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected := []*Link{
|
||||
{Hash: "a", Path: "/file.txt", UserID: 1},
|
||||
{Hash: "b", Path: "/file.txt", UserID: 2},
|
||||
}
|
||||
store := NewStorage(fakeBackend{
|
||||
links: append(expected, &Link{Hash: "c", Path: "/other.txt", UserID: 3}),
|
||||
})
|
||||
|
||||
links, err := store.GetsByPath("/file.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("GetsByPath returned error: %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(links, expected) {
|
||||
t.Fatalf("GetsByPath returned %#v, want %#v", links, expected)
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -2,6 +2,7 @@ package bolt
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/asdine/storm/v3"
|
||||
"github.com/asdine/storm/v3/q"
|
||||
@@ -76,14 +77,27 @@ func (s shareBackend) Delete(hash string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s shareBackend) DeleteWithPathPrefix(pathPrefix string) error {
|
||||
func (s shareBackend) DeleteWithPathPrefix(pathPrefix string, userID uint) error {
|
||||
var links []share.Link
|
||||
if err := s.db.Prefix("Path", pathPrefix, &links); err != nil {
|
||||
if errors.Is(err, storm.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
prefix := strings.TrimRight(pathPrefix, "/")
|
||||
|
||||
var err error
|
||||
for _, link := range links {
|
||||
if link.UserID != userID {
|
||||
continue
|
||||
}
|
||||
|
||||
if link.Path != prefix && !strings.HasPrefix(link.Path, prefix+"/") {
|
||||
continue
|
||||
}
|
||||
|
||||
err = errors.Join(err, s.db.DeleteStruct(&share.Link{Hash: link.Hash}))
|
||||
}
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package bolt
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/asdine/storm/v3"
|
||||
|
||||
"github.com/filebrowser/filebrowser/v2/share"
|
||||
)
|
||||
|
||||
func newTestShareBackend(t *testing.T) shareBackend {
|
||||
t.Helper()
|
||||
|
||||
f, err := os.CreateTemp(t.TempDir(), "shares-*.db")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp db: %v", err)
|
||||
}
|
||||
_ = f.Close()
|
||||
|
||||
db, err := storm.Open(f.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
return shareBackend{db: db}
|
||||
}
|
||||
|
||||
func remainingHashes(t *testing.T, s shareBackend) []string {
|
||||
t.Helper()
|
||||
|
||||
links, err := s.All()
|
||||
if err != nil {
|
||||
t.Fatalf("All returned error: %v", err)
|
||||
}
|
||||
|
||||
hashes := make([]string, 0, len(links))
|
||||
for _, link := range links {
|
||||
hashes = append(hashes, link.Hash)
|
||||
}
|
||||
sort.Strings(hashes)
|
||||
return hashes
|
||||
}
|
||||
|
||||
func TestDeleteWithPathPrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestShareBackend(t)
|
||||
|
||||
links := []*share.Link{
|
||||
// user 1's links
|
||||
{Hash: "u1-a", Path: "/a", UserID: 1},
|
||||
{Hash: "u1-a-child", Path: "/a/child.txt", UserID: 1},
|
||||
{Hash: "u1-abc", Path: "/abc", UserID: 1}, // not a descendant of /a
|
||||
{Hash: "u1-other", Path: "/other", UserID: 1},
|
||||
// user 2's links — must never be touched when user 1 deletes
|
||||
{Hash: "u2-a", Path: "/a", UserID: 2},
|
||||
{Hash: "u2-a-child", Path: "/a/child.txt", UserID: 2},
|
||||
}
|
||||
for _, l := range links {
|
||||
if err := s.Save(l); err != nil {
|
||||
t.Fatalf("failed to save link %s: %v", l.Hash, err)
|
||||
}
|
||||
}
|
||||
|
||||
// User 1 deletes their directory /a. Only user 1's /a and its descendants
|
||||
// should be removed; /abc (sibling sharing a byte prefix) and all of user
|
||||
// 2's links must remain.
|
||||
if err := s.DeleteWithPathPrefix("/a", 1); err != nil {
|
||||
t.Fatalf("DeleteWithPathPrefix returned error: %v", err)
|
||||
}
|
||||
|
||||
got := remainingHashes(t, s)
|
||||
want := []string{"u1-abc", "u1-other", "u2-a", "u2-a-child"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("remaining hashes = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("remaining hashes = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteWithPathPrefixNoMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestShareBackend(t)
|
||||
|
||||
// No links exist at all: the storm Prefix query returns ErrNotFound, which
|
||||
// must be treated as a no-op rather than surfaced as an error.
|
||||
if err := s.DeleteWithPathPrefix("/a", 1); err != nil {
|
||||
t.Fatalf("DeleteWithPathPrefix on empty store returned error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ filebrowser config init [flags]
|
||||
--disableTypeDetectionByHeader disables type detection by reading file headers
|
||||
--fileMode string mode bits that new files are created with (default "0o640")
|
||||
-h, --help help for init
|
||||
--hideDotfiles hide dotfiles
|
||||
--hideDotfiles hide dotfiles in file listings
|
||||
--hideLoginButton hide login button from public pages
|
||||
-k, --key string tls key
|
||||
--locale string locale for users (default "en")
|
||||
|
||||
@@ -39,7 +39,7 @@ filebrowser config set [flags]
|
||||
--disableTypeDetectionByHeader disables type detection by reading file headers
|
||||
--fileMode string mode bits that new files are created with (default "0o640")
|
||||
-h, --help help for set
|
||||
--hideDotfiles hide dotfiles
|
||||
--hideDotfiles hide dotfiles in file listings
|
||||
--hideLoginButton hide login button from public pages
|
||||
-k, --key string tls key
|
||||
--locale string locale for users (default "en")
|
||||
|
||||
@@ -17,7 +17,7 @@ filebrowser users add <username> <password> [flags]
|
||||
--commands strings a list of the commands a user can execute
|
||||
--dateFormat use date format (true for absolute time, false for relative)
|
||||
-h, --help help for add
|
||||
--hideDotfiles hide dotfiles
|
||||
--hideDotfiles hide dotfiles in file listings
|
||||
--locale string locale for users (default "en")
|
||||
--lockPassword lock password
|
||||
--perm.admin admin perm for users
|
||||
|
||||
@@ -18,7 +18,7 @@ filebrowser users update <id|username> [flags]
|
||||
--commands strings a list of the commands a user can execute
|
||||
--dateFormat use date format (true for absolute time, false for relative)
|
||||
-h, --help help for update
|
||||
--hideDotfiles hide dotfiles
|
||||
--hideDotfiles hide dotfiles in file listings
|
||||
--locale string locale for users (default "en")
|
||||
--lockPassword lock password
|
||||
-p, --password string new password
|
||||
|
||||
Reference in New Issue
Block a user