Compare commits

...
12 Commits
117 changed files with 3358 additions and 2269 deletions

No files matched your search

+6 -16
View File
@@ -1,20 +1,10 @@
**Description**
<!--
Please explain the changes you made here.
If the feature changes current behaviour, explain why your solution is better.
-->
:rotating_light: Before submitting your PR, please read [community](https://github.com/filebrowser/community), and indicate which issues (in any of the repos) 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/).
According to the [contributing guide](https://github.com/gtsteffaniak/filebrowser/wiki/Contributing#contributing-as-an-unofficial-contributor), A PR should contain:
- [ ] DO make sure you are requesting to **pull a topic/feature/bugfix branch** (right side). Don't request your master!
- [ ] DO make sure you are making a pull request against the **master branch** (left side). Also you should start *your branch* off *our master*.
- [ ] DO make sure that 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).
- [ ] DO make sure that related issues are opened in other repositories. I.e., the frontend, caddy plugins or the web page need to be updated accordingly.
- [ ] AVOID breaking the continuous integration build.
- [ ] A clear description of why it was opened.
- [ ] A short title that best describes the change.
- [ ] Must pass unit and integration tests, which can be run checked locally prior to opening a PR.
- [ ] Any additional details for functionality not covered by tests.
**Further comments**
<!--
If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did, what alternatives you considered, etc.
:heart: Thank you!
-->
**Additional Details**
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
file: ./_docker/Dockerfile.playwright-regular
file: ./_docker/Dockerfile.playwright-general
push: false
push_latest_to_registry:
needs: [ test_playwright ]
+4 -4
View File
@@ -9,7 +9,7 @@ on:
jobs:
test_playwright:
name: Test Playwright
name: Test Playwright - regular
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -30,10 +30,10 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
file: ./_docker/Dockerfile.playwright-regular
file: ./_docker/Dockerfile.playwright-general
push: false
test_playwright_proxy:
name: Test Playwright - regular
name: Test Playwright - proxy
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -57,7 +57,7 @@ jobs:
file: ./_docker/Dockerfile.playwright-proxy
push: false
test_playwright_noauth:
name: Test Playwright - regular
name: Test Playwright - noauth
runs-on: ubuntu-latest
steps:
- name: Checkout
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
file: ./_docker/Dockerfile.playwright-regular
file: ./_docker/Dockerfile.playwright-general
push: false
create_release_tag:
needs: [ test_playwright ]
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
file: ./_docker/Dockerfile.playwright-regular
file: ./_docker/Dockerfile.playwright-general
push: false
create_release_tag:
needs: [ test_playwright ]
-3
View File
@@ -54,17 +54,14 @@ jobs:
id: modify-json
run: |
JSON='${{ steps.meta.outputs.json }}' # Assuming the JSON output is stored in steps.meta.outputs.json
# Process the JSON array using jq
MODIFIED_JSON=$(echo "$JSON" | jq '
.tags |= map(sub(":v"; ":")) |
if any(.tags[]; test("beta")) then .tags += ["gtstef/filebrowser:beta"] else . end |
if any(.tags[]; test("stable")) then .tags += ["gtstef/filebrowser:stable"] else . end
')
# Convert JSON to a comma-separated string for Docker tags
TAGS_CSV=$(echo "$MODIFIED_JSON" | jq -r '.tags | join(",")')
echo "$TAGS_CSV" > modified_tags.txt # Save for debugging
echo "cleaned_tag=$TAGS_CSV" >> $GITHUB_OUTPUT
+38 -2
View File
@@ -2,6 +2,40 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v0.6.0-beta
> [!WARNING]
> This release includes several config changes that could cause issues. Please backup your database file before upgrading.
This release has several changes that should work without issues... however, still backup your database file first and proceed with caution. User permissions and source config changes have been updated -- and the `server.root` paramter is no longer used.
This is a significant step towards a stable release. There shouldn't be any major breaking config changes after this.
**New Features**:
- multiple sources support https://github.com/gtsteffaniak/filebrowser/issues/360
- listing view keeps them independant, you switch between the two and the url address will have a prefix `/files/<sourcename>/path/to/file` when there is more than 1 source.
- search also happens independantly, with a selection toggle per source. searching current source searches the current scope in the listing view, if you toggle to an alternative source it will search from the source root.
- copy/moving is currently only supported within the same source -- that will come in a future release.
- `FILEBROWSER_CONFIG` environment variable is respected if no CLI config parameter is provided. https://github.com/gtsteffaniak/filebrowser/issues/413
**Notes**:
- downloads no longer open new window.
- swagger updated with auth api help for things like api token.
- GET api keys now uses `name` query instead of `key`. eg `GET /api/auth/tokens?name=apikeyname`
- user permissions simplified to four permission groups (no config change required):
- **removed** : create, rename, delete, download
- **remaining**: admin, modify, share, api
- `scope` is deprecated, but still supported, applies to default source. if using multiple sources, set `defaultUserScope` at the [source config](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration#default-source-configuration) instead.
- **removed** user rules and commands.
- commands feature has never been enabled so just removing the references.
- rules will come back in a different form (not applied to the user).
- `server.root` is completely removed in favor of `server.sources`
**BugFixes**:
- fix conflict resolution issue https://github.com/gtsteffaniak/filebrowser/issues/384
- many user creation page bugfixes.
- fix share delete issue https://github.com/gtsteffaniak/filebrowser/issues/408
## v0.5.4-beta
**BugFixes**:
@@ -9,10 +43,12 @@ All notable changes to this project will be documented in this file. For commit
- drag and drop on empty folders https://github.com/gtsteffaniak/filebrowser/issues/361
- preview navigation issue https://github.com/gtsteffaniak/filebrowser/issues/372
- auth proxy password length error https://github.com/gtsteffaniak/filebrowser/issues/375
<img width="294" alt="image" src="https://github.com/user-attachments/assets/669bca75-98d4-47c1-838b-1ffee2967d7d" />
## v0.5.3-beta
**New Features**
**New Features**:
- onlyoffice disable filetypes for user specified file types. https://github.com/gtsteffaniak/filebrowser/issues/346
**Notes**:
+10 -17
View File
@@ -13,7 +13,7 @@
<h3>FileBrowser Quantum</h3>
A modern web-based file manager
<br/><br/>
<img width="800" src="https://github.com/user-attachments/assets/c991fc69-a05b-4f34-b915-0d3cded887a7" title="Main Screenshot">
<img width="800" src="https://github.com/user-attachments/assets/c35a8455-c32d-47f5-853d-f285e160b022" title="Main Screenshot">
</div>
> [!WARNING]
@@ -25,33 +25,28 @@ FileBrowser Quantum is a fork of the file browser open-source project with the f
- Real-time search results as you type
- Search supports file/folder sizes and many file type filters.
- Enhanced interactive results that show file/folder sizes.
2. ✅ Revamped UI
2. ✅ Multiple sources support
3. ✅ Revamped UI
- Additional compact view mode as well as refreshed view mode styles.
- Many graphical and user experience improvements.
- right-click context menu
3. ✅ Revamped and simplified configuration via `config.yaml` config file.
4. ✅ Better listing browsing
4. ✅ Revamped and simplified configuration via `config.yaml` config file.
5. ✅ Better listing browsing
- Instantly Switches view modes and sort order without reloading data.
- Folder sizes are displayed
- Navigating remembers the scroll position, navigating back keeps the last scroll position.
5. ✅ Developer API support
6. ✅ Developer API support
- Ability to create long-live API Tokens.
- Helpful Swagger page available at `/swagger` endpoint.
Notable features that this fork *does not* have (removed):
- ❌ jobs/runners are not supported yet (planned).
- ❌ per-user rules are not supported yet (planned).
- ❌ jobs are not supported yet (planned).
- ❌ rules are not supported yet (planned).
- ❌ shell commands are completely removed and will not be returned.
## About
This repository is a fork of the original [filebrowser](https://github.com/filebrowser/filebrowser)
with a collection of changes that make this program work better in terms of
aesthetics and performance. Improved search, simplified UI
(without removing features) and more secure and up-to-date
build are just a few examples.
FileBrowser Quantum differs significantly from the original.
There are hundreds of thousands of lines changed and they are generally
no longer compatible with each other. This has been intentional -- the
@@ -77,9 +72,7 @@ action panel. If the action is available based on context, it will show up as
a popup menu.
<p align="center">
<img width="800" src="https://github.com/user-attachments/assets/2be7a6c5-0f95-4d9f-bc05-484ee71246d8" title="Search GIF">
<img width="800" src="https://github.com/user-attachments/assets/f55a6f1f-b930-4399-98b5-94da6e90527a" title="Navigation GIF">
<img width="800" src="https://github.com/user-attachments/assets/93b019de-d38f-4aaa-bde3-3ba4e99ecd25" title="Main Screenshot">
<img width="1000" src="https://github.com/user-attachments/assets/aa32b05c-f917-47bb-b07f-857edc5e47f7" title="Search GIF">
</p>
## Install and Configuration
@@ -120,7 +113,7 @@ S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
webdav support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
Dedicated docs site? | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ |
Multiple sources at once | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
Multiple sources at once | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Docker image size | 31 MB | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
Min. Memory Requirements | 128 MB | 128 MB | 128 MB (main image) | 128 MB | ❌ | 4 GB |
has standalone binary | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
File renamed without changes.
+1
View File
@@ -7,6 +7,7 @@ services:
volumes:
- ./src/proxy/backend/default.conf:/etc/nginx/conf.d/default.conf
filebrowser:
hostname: filebrowser
volumes:
- '../frontend:/home/frontend'
- "./src/proxy/backend/config.yaml:/home/filebrowser/config.yaml"
+4 -1
View File
@@ -1,7 +1,10 @@
server:
port: 80
baseURL: "/"
root: "../frontend/tests/playwright-files"
logging:
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
auth:
signup: false
methods:
+5 -8
View File
@@ -1,7 +1,10 @@
server:
port: 8080
baseURL: "/"
root: "../frontend/tests/playwright-files"
logging:
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
@@ -12,9 +15,6 @@ frontend:
auth:
signup: false
methods:
password:
enabled: false
minLength: 0
proxy:
enabled: true
header: "X-Username"
@@ -27,9 +27,6 @@ userDefaults:
singleClick: false
permissions:
admin: false
create: false
rename: false
modify: false
delete: false
share: false
download: false
api: false
+8 -2
View File
@@ -1,13 +1,19 @@
map $remote_addr $uuid {
default "demo-${remote_addr}";
}
server {
listen 80;
server_name localhost 127.0.0.1;
location / {
proxy_set_header X-Username $uuid;
add_header X-Username $uuid;
proxy_pass http://localhost:8080;
proxy_set_header X-Username "proxy-user";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
+7 -15
View File
@@ -1,24 +1,16 @@
server:
port: 80
baseURL: "/"
root: "../frontend/tests/playwright-files"
logging:
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
- path: "."
name: "docker"
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
externalLinks:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
userDefaults:
darkMode: true
disableSettings: false
scope: "."
singleClick: false
permissions:
admin: false
create: false
rename: false
modify: false
delete: false
share: false
download: false
title: "Playwright"
+47 -49
View File
@@ -9,6 +9,7 @@ import (
"strings"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
@@ -39,6 +40,7 @@ func (a *HookAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error
err := json.NewDecoder(r.Body).Decode(&cred)
if err != nil {
logger.Error("decode body error")
return nil, os.ErrPermission
}
@@ -59,11 +61,19 @@ func (a *HookAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error
}
return u, nil
case "block":
logger.Error("block error")
return nil, os.ErrPermission
case "pass":
u, err := a.Users.Get(a.Server.Root, a.Cred.Username)
if err != nil || !users.CheckPwd(a.Cred.Password, u.Password) {
return nil, os.ErrPermission
logger.Error("pass error")
u, err := a.Users.Get(a.Cred.Username)
if err != nil {
return nil, fmt.Errorf("unable to get user from store: %v", err)
}
err = users.CheckPwd(cred.Password, u.Password)
if err != nil {
return nil, err
}
return u, nil
default:
@@ -138,7 +148,7 @@ func (a *HookAuth) GetValues(s string) {
// SaveUser updates the existing user or creates a new one when not found
func (a *HookAuth) SaveUser() (*users.User, error) {
u, err := a.Users.Get(a.Server.Root, a.Cred.Username)
u, err := a.Users.Get(a.Cred.Username)
if err != nil && err != errors.ErrNotExist {
return nil, err
}
@@ -146,34 +156,35 @@ func (a *HookAuth) SaveUser() (*users.User, error) {
if u == nil {
// create user with the provided credentials
d := &users.User{
Username: a.Cred.Username,
Password: a.Cred.Password,
Scope: a.Settings.UserDefaults.Scope,
Locale: a.Settings.UserDefaults.Locale,
ViewMode: a.Settings.UserDefaults.ViewMode,
SingleClick: a.Settings.UserDefaults.SingleClick,
Sorting: a.Settings.UserDefaults.Sorting,
Perm: a.Settings.UserDefaults.Perm,
Commands: a.Settings.UserDefaults.Commands,
ShowHidden: a.Settings.UserDefaults.ShowHidden,
NonAdminEditable: users.NonAdminEditable{
Password: a.Cred.Password,
Locale: a.Settings.UserDefaults.Locale,
ViewMode: a.Settings.UserDefaults.ViewMode,
SingleClick: a.Settings.UserDefaults.SingleClick,
ShowHidden: a.Settings.UserDefaults.ShowHidden,
},
Username: a.Cred.Username,
Perm: a.Settings.UserDefaults.Perm,
}
u = a.GetUser(d)
userHome, err := a.Settings.MakeUserDir(u.Username, u.Scope, a.Server.Root)
if err != nil {
return nil, fmt.Errorf("user: failed to mkdir user home dir: [%s]", userHome)
}
u.Scope = userHome
logger.Debug(fmt.Sprintf("user: %s, home dir: [%s].", u.Username, userHome))
files.MakeUserDirs(u)
err = a.Users.Save(u)
err = a.Users.Save(u, false)
if err != nil {
return nil, err
}
} else if p := !users.CheckPwd(a.Cred.Password, u.Password); len(a.Fields.Values) > 1 || p {
return u, nil
}
err = users.CheckPwd(a.Cred.Password, u.Password)
if err != nil {
return nil, err
}
if len(a.Fields.Values) > 1 {
u = a.GetUser(u)
// update user with provided fields
err := a.Users.Update(u)
err := a.Users.Update(u, true)
if err != nil {
return nil, err
}
@@ -187,29 +198,21 @@ func (a *HookAuth) GetUser(d *users.User) *users.User {
// adds all permissions when user is admin
isAdmin := d.Perm.Admin
perms := users.Permissions{
Admin: isAdmin,
Execute: isAdmin || d.Perm.Execute,
Create: isAdmin || d.Perm.Create,
Rename: isAdmin || d.Perm.Rename,
Modify: isAdmin || d.Perm.Modify,
Delete: isAdmin || d.Perm.Delete,
Share: isAdmin || d.Perm.Share,
Download: isAdmin || d.Perm.Download,
Admin: isAdmin,
Modify: isAdmin || d.Perm.Modify,
Share: isAdmin || d.Perm.Share,
}
user := users.User{
ID: d.ID,
Username: d.Username,
Password: d.Password,
Scope: d.Scope,
Locale: d.Locale,
ViewMode: d.ViewMode,
SingleClick: d.SingleClick,
Sorting: users.Sorting{
Asc: d.Sorting.Asc,
By: d.Sorting.By,
NonAdminEditable: users.NonAdminEditable{
Password: d.Password,
Locale: a.Fields.GetString("user.locale", d.Locale),
ViewMode: a.Fields.GetString("user.viewMode", d.ViewMode),
SingleClick: a.Fields.GetBoolean("user.singleClick", d.SingleClick),
ShowHidden: a.Fields.GetBoolean("user.showHidden", d.ShowHidden),
},
Commands: d.Commands,
ShowHidden: d.ShowHidden,
ID: d.ID,
Username: d.Username,
Scopes: d.Scopes,
Perm: perms,
LockPassword: true,
}
@@ -231,16 +234,11 @@ var validHookFields = []string{
"user.singleClick",
"user.sorting.by",
"user.sorting.asc",
"user.commands",
"user.showHidden",
"user.perm.admin",
"user.perm.execute",
"user.perm.create",
"user.perm.rename",
"user.perm.modify",
"user.perm.delete",
"user.perm.share",
"user.perm.download",
"user.perm.api",
}
// IsValid checks if the provided field is on the valid fields list
+11 -4
View File
@@ -2,12 +2,13 @@ package auth
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
@@ -27,10 +28,12 @@ func (a JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User,
var cred jsonCred
if r.Body == nil {
logger.Error("nil body error")
return nil, os.ErrPermission
}
err := json.NewDecoder(r.Body).Decode(&cred)
if err != nil {
logger.Error("decode body error")
return nil, os.ErrPermission
}
@@ -46,9 +49,13 @@ func (a JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User,
return nil, os.ErrPermission
}
}
u, err := userStore.Get(files.RootPaths["default"], cred.Username)
if err != nil || !users.CheckPwd(cred.Password, u.Password) {
return nil, os.ErrPermission
u, err := userStore.Get(cred.Username)
if err != nil {
return nil, fmt.Errorf("unable to get user from store: %v", err)
}
err = users.CheckPwd(cred.Password, u.Password)
if err != nil {
return nil, err
}
return u, nil
+1 -2
View File
@@ -3,7 +3,6 @@ package auth
import (
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
@@ -15,7 +14,7 @@ type NoAuth struct{}
// Auth uses authenticates user 1.
func (a NoAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
return usr.Get(files.RootPaths["default"], uint(1))
return usr.Get(uint(1))
}
// LoginPage tells that no auth doesn't require a login page.
+1 -2
View File
@@ -5,7 +5,6 @@ import (
"os"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
@@ -20,7 +19,7 @@ type ProxyAuth struct {
// Auth authenticates the user via an HTTP header.
func (a ProxyAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
username := r.Header.Get(a.Header)
user, err := usr.Get(files.RootPaths["default"], username)
user, err := usr.Get(username)
if err == errors.ErrNotExist {
return nil, os.ErrPermission
}
+76 -34
View File
@@ -23,13 +23,57 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/version"
)
func getStore(config string) (*storage.Storage, bool) {
func getStore(configFile string) (*storage.Storage, bool) {
// Use the config file (global flag)
settings.Initialize(config)
settings.Initialize(configFile)
store, hasDB, err := storage.InitializeDb(settings.Config.Server.Database)
if err != nil {
logger.Fatal(fmt.Sprintf("could not load db info: %v", err))
}
// update source info for users if names/sources/paths might have changed
usersList, err2 := store.Users.Gets()
if err2 != nil {
logger.Fatal(fmt.Sprintf("could not load users: %v", err2))
}
// this function adds scopes as needed on startup
for _, user := range usersList {
updateUser := false
newScopes := []users.SourceScope{}
for _, source := range settings.Config.Server.SourceMap {
scopePath := ""
if !user.Perm.Admin {
scopePath = source.Config.DefaultUserScope
}
scope, err := settings.GetScopeFromSourcePath(user.Scopes, source.Path)
if err != nil {
if user.Perm.Admin || source.Config.DefaultEnabled {
newScopes = append(newScopes, users.SourceScope{Scope: scopePath, Name: source.Path}) // backend name is path
updateUser = true
}
} else {
newScopes = append(newScopes, users.SourceScope{Scope: scope, Name: source.Path}) // backend name is path
}
}
user.Scopes = newScopes
// maintain backwards compatibility, update user scope from scopes
if len(user.Scopes) == 0 {
user.Scopes = []users.SourceScope{
{
Scope: user.Scope,
Name: settings.Config.Server.DefaultSource.Path, // backend name is path
},
}
updateUser = true
}
if !updateUser {
continue
}
err := store.Users.Save(user, false)
if err != nil {
logger.Error(fmt.Sprintf("could not update user: %v", err))
}
}
return store, hasDB
}
@@ -52,9 +96,17 @@ func StartFilebrowser() {
var help bool
// Override the default usage output to use generalUsage()
flag.Usage = generalUsage
flag.StringVar(&configPath, "c", "config.yaml", "Path to the config file, default: config.yaml")
flag.StringVar(&configPath, "c", "", "Path to the config file, default: config.yaml")
flag.BoolVar(&help, "h", false, "Get help about commands")
if configPath == "" {
configPath = os.Getenv("FILEBROWSER_CONFIG")
}
if configPath == "" {
configPath = "config.yaml"
}
// Parse global flags (before subcommands)
flag.Parse() // print generalUsage on error
@@ -104,17 +156,23 @@ func StartFilebrowser() {
if !ok {
logger.Fatal("could not load db info")
}
user, err := store.Users.Get("", username)
user, err := store.Users.Get(username)
if err != nil {
newUser := users.User{
Username: username,
Password: password,
NonAdminEditable: users.NonAdminEditable{
Password: password,
},
}
if scope != "" {
newUser.Scope = scope
} else {
newUser.Scope = settings.Config.UserDefaults.Scope
for _, source := range settings.Config.Server.SourceMap {
if source.Config.DefaultEnabled {
newUser.Scopes = append(newUser.Scopes, users.SourceScope{
Name: source.Name,
Scope: source.Config.DefaultUserScope,
})
}
}
// Create the user logic
if asAdmin {
logger.Info(fmt.Sprintf("Creating user as admin: %s\n", username))
@@ -128,13 +186,10 @@ func StartFilebrowser() {
return
}
user.Password = password
if scope != "" {
user.Scope = scope
}
if asAdmin {
user.Perm.Admin = true
}
err = store.Users.Save(user)
err = store.Users.Save(user, true)
if err != nil {
logger.Error(fmt.Sprintf("could not update user: %v", err))
}
@@ -150,43 +205,30 @@ Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v
return
}
}
store, dbExists := getStore(configPath)
database := fmt.Sprintf("Using existing database : %v", settings.Config.Server.Database)
if !dbExists {
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.Database)
}
sources := []string{}
for _, v := range settings.Config.Server.Sources {
sources = append(sources, v.Name+": "+v.Path)
sourceList := []string{}
for path, source := range settings.Config.Server.SourceMap {
sourceList = append(sourceList, fmt.Sprintf("%v: %v", source.Name, path))
}
authMethods := []string{}
if settings.Config.Auth.Methods.PasswordAuth.Enabled {
authMethods = append(authMethods, "Password")
}
if settings.Config.Auth.Methods.ProxyAuth.Enabled {
authMethods = append(authMethods, "Proxy")
}
if settings.Config.Auth.Methods.NoAuth {
logger.Warning("Configured with no authentication, this is not recommended.")
authMethods = []string{"Disabled"}
}
logger.Info(fmt.Sprintf("Initializing FileBrowser Quantum (%v)", version.Version))
logger.Info(fmt.Sprintf("Using Config file : %v", configPath))
logger.Info(fmt.Sprintf("Auth Methods : %v", authMethods))
logger.Info(fmt.Sprintf("Auth Methods : %v", settings.Config.Auth.AuthMethods))
logger.Info(database)
logger.Info(fmt.Sprintf("Sources : %v", sources))
logger.Info(fmt.Sprintf("Sources : %v", sourceList))
serverConfig := settings.Config.Server
swagInfo := docs.SwaggerInfo
swagInfo.BasePath = serverConfig.BaseURL
swag.Register(docs.SwaggerInfo.InstanceName(), swagInfo)
// initialize indexing and schedule indexing ever n minutes (default 5)
sourceConfigs := settings.Config.Server.Sources
if len(sourceConfigs) == 0 {
if len(settings.Config.Server.SourceMap) == 0 {
logger.Fatal("No sources configured, exiting...")
}
for _, source := range sourceConfigs {
for _, source := range settings.Config.Server.SourceMap {
go files.Initialize(source)
}
// Start the rootCMD in a goroutine
+3 -5
View File
@@ -1,7 +1,8 @@
server:
port: 80
baseURL: "/"
root: "/srv"
sources:
- path: "/srv"
auth:
signup: false
userDefaults:
@@ -11,9 +12,6 @@ userDefaults:
singleClick: false
permissions:
admin: false
create: false
rename: false
modify: false
delete: false
share: false
download: false
api: false
-2
View File
@@ -1,2 +0,0 @@
server:
root: "/srv"
+48 -18
View File
@@ -26,7 +26,6 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
@@ -73,7 +72,6 @@ type FileOptions struct {
Modify bool
Expand bool
ReadHeader bool
Checker users.Checker
Content bool
}
@@ -84,7 +82,7 @@ func (f FileOptions) Components() (string, string) {
func FileInfoFaster(opts FileOptions) (ExtendedFileInfo, error) {
response := ExtendedFileInfo{}
if opts.Source == "" {
opts.Source = "default"
opts.Source = settings.Config.Server.DefaultSource.Name
}
index := GetIndex(opts.Source)
if index == nil {
@@ -95,9 +93,6 @@ func FileInfoFaster(opts FileOptions) (ExtendedFileInfo, error) {
pathMutex := getMutex(opts.Path)
pathMutex.Lock()
defer pathMutex.Unlock()
if !opts.Checker.Check(opts.Path) {
return response, os.ErrPermission
}
realPath, isDir, err := index.GetRealPath(opts.Path)
if err != nil {
@@ -133,7 +128,7 @@ func FileInfoFaster(opts FileOptions) (ExtendedFileInfo, error) {
return response, fmt.Errorf("could not get metadata for path: %v", opts.Path)
}
if opts.Content {
content, err := getContent("default", opts.Path)
content, err := getContent(realPath)
if err != nil {
return response, err
}
@@ -198,6 +193,9 @@ func DeleteFiles(source, absPath string, dirPath string) error {
return err
}
index := GetIndex(source)
if index == nil {
return fmt.Errorf("could not get index: %v ", source)
}
refreshConfig := FileOptions{Path: dirPath, IsDir: true}
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
@@ -206,12 +204,16 @@ func DeleteFiles(source, absPath string, dirPath string) error {
return nil
}
func MoveResource(source, realsrc, realdst string, isSrcDir bool) error {
func MoveResource(sourceIndex, dstIndex, realsrc, realdst string, isSrcDir bool) error {
err := fileutils.MoveFile(realsrc, realdst)
if err != nil {
return err
}
index := GetIndex(source)
index := GetIndex(sourceIndex)
if index == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
// refresh info for source and dest
err = index.RefreshFileInfo(FileOptions{
Path: realsrc,
@@ -224,6 +226,10 @@ func MoveResource(source, realsrc, realdst string, isSrcDir bool) error {
if !isSrcDir {
refreshConfig.Path = filepath.Dir(realdst)
}
index = GetIndex(dstIndex)
if index == nil {
return fmt.Errorf("could not get index: %v ", dstIndex)
}
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return fmt.Errorf("could not refresh index for dest: %v", err)
@@ -231,25 +237,48 @@ func MoveResource(source, realsrc, realdst string, isSrcDir bool) error {
return nil
}
func CopyResource(source, realsrc, realdst string, isSrcDir bool) error {
func CopyResource(sourceIndex, dstIndex, realsrc, realdst string, isSrcDir bool) error {
err := fileutils.CopyFile(realsrc, realdst)
if err != nil {
return err
}
index := GetIndex(source)
index := GetIndex(sourceIndex)
if index == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
// refresh info for source and dest
err = index.RefreshFileInfo(FileOptions{
Path: realsrc,
IsDir: isSrcDir,
})
if err != nil {
return fmt.Errorf("could not refresh index for source: %v", err)
}
refreshConfig := FileOptions{Path: realdst, IsDir: true}
if !isSrcDir {
refreshConfig.Path = filepath.Dir(realdst)
}
index = GetIndex(dstIndex)
if index == nil {
return fmt.Errorf("could not get index: %v ", dstIndex)
}
refreshConfig = FileOptions{Path: realdst, IsDir: true}
if !isSrcDir {
refreshConfig.Path = filepath.Dir(realdst)
}
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return errors.ErrEmptyKey
}
return nil
}
func WriteDirectory(opts FileOptions) error {
idx := GetIndex(opts.Source)
if idx == nil {
return fmt.Errorf("could not get index: %v ", opts.Source)
}
realPath, _, _ := idx.GetRealPath(opts.Path)
// Ensure the parent directories exist
err := os.MkdirAll(realPath, 0775)
@@ -265,6 +294,9 @@ func WriteDirectory(opts FileOptions) error {
func WriteFile(opts FileOptions, in io.Reader) error {
idx := GetIndex(opts.Source)
if idx == nil {
return fmt.Errorf("could not get index: %v ", opts.Source)
}
dst, _, _ := idx.GetRealPath(opts.Path)
parentDir := filepath.Dir(dst)
// Create the directory and all necessary parents
@@ -319,13 +351,7 @@ func resolveSymlinks(path string) (string, bool, error) {
}
// addContent reads and sets content based on the file type.
func getContent(source, path string) (string, error) {
idx := GetIndex(source)
realPath, _, err := idx.GetRealPath(path)
if err != nil {
return "", err
}
func getContent(realPath string) (string, error) {
content, err := os.ReadFile(realPath)
if err != nil {
return "", err
@@ -377,6 +403,10 @@ func (i *ExtendedFileInfo) detectSubtitles(path string) {
}
idx := GetIndex(i.Source)
if idx == nil {
return
}
parentInfo, exists := idx.GetReducedMetadata(filepath.Dir(i.Path), true)
if !exists {
return
+6 -9
View File
@@ -35,23 +35,20 @@ type Index struct {
var (
indexes map[string]*Index
indexesMutex sync.RWMutex
RootPaths map[string]string
)
func init() {
indexes = make(map[string]*Index)
}
func Initialize(source settings.Source) {
indexesMutex.RLock()
indexesMutex.Lock()
newIndex := Index{
Source: source,
Directories: make(map[string]*FileInfo),
}
if RootPaths == nil {
RootPaths = make(map[string]string)
}
RootPaths[source.Name] = source.Path
indexes = make(map[string]*Index)
indexes[newIndex.Source.Name] = &newIndex
indexesMutex.RUnlock()
indexesMutex.Unlock()
if !newIndex.Source.Config.Disabled {
time.Sleep(time.Second)
logger.Info(fmt.Sprintf("initializing index: [%v]", newIndex.Source.Name))
+5 -4
View File
@@ -11,8 +11,8 @@ import (
var scanSchedule = []time.Duration{
5 * time.Minute, // 5 minute quick scan & 25 minutes for a full scan
10 * time.Minute,
20 * time.Minute, // [3] element is 20 minutes, reset anchor for full scan
40 * time.Minute,
20 * time.Minute,
40 * time.Minute, // [3] element is 40 minutes, reset anchor for full scan
1 * time.Hour,
2 * time.Hour,
3 * time.Hour,
@@ -48,6 +48,7 @@ func (idx *Index) newScanner(origin string) {
// Adjust schedule based on file changes
if idx.FilesChangedDuringIndexing {
logger.Debug(fmt.Sprintf("Files changed during indexing [%v], adjusting schedule.", idx.Name))
// Move to at least the full-scan anchor or reduce interval
if idx.currentSchedule > fullScanAnchor {
idx.currentSchedule = fullScanAnchor
@@ -106,9 +107,9 @@ func (idx *Index) RunIndexing(origin string, quick bool) {
idx.assessment = "normal"
}
if firstRun {
logger.Info(fmt.Sprintf("Index assessment : index=%v complexity=%v directories=%v files=%v", idx.Source.Name, idx.assessment, idx.NumDirs, idx.NumFiles))
logger.Info(fmt.Sprintf("Index assessment : [%v] complexity=%v directories=%v files=%v", idx.Source.Name, idx.assessment, idx.NumDirs, idx.NumFiles))
} else {
logger.Debug(fmt.Sprintf("Index assessment : iindex=%v complexity=%v directories=%v files=%v", idx.Source.Name, idx.assessment, idx.NumDirs, idx.NumFiles))
logger.Debug(fmt.Sprintf("Index assessment : [%v] complexity=%v directories=%v files=%v", idx.Source.Name, idx.assessment, idx.NumDirs, idx.NumFiles))
}
if idx.NumDirs != prevNumDirs || idx.NumFiles != prevNumFiles {
idx.FilesChangedDuringIndexing = true
+5 -2
View File
@@ -16,6 +16,9 @@ func BenchmarkFillIndex(b *testing.B) {
Path: "/srv",
})
idx := GetIndex("test")
if idx == nil {
b.Fatal("index is nil")
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
@@ -165,7 +168,7 @@ func BenchmarkCheckIndexExclude(b *testing.B) {
idx := Index{
Source: settings.Source{
Name: "files",
Config: settings.IndexConfig{
Config: settings.SourceConfig{
IgnoreHidden: true,
Exclude: settings.IndexFilter{
Files: []string{"test", "filepath", ".test", ".filepath", "test", "filepath", ".test", ".filepath"},
@@ -202,7 +205,7 @@ func BenchmarkCheckIndexConditionsInclude(b *testing.B) {
idx2 := Index{
Source: settings.Source{
Name: "files",
Config: settings.IndexConfig{
Config: settings.SourceConfig{
IgnoreHidden: true,
Include: settings.IndexFilter{
Files: []string{"test", "filepath", ".test", ".filepath", "test", "filepath", ".test", ".filepath"},
+3 -4
View File
@@ -1,9 +1,10 @@
package files
import (
"fmt"
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/logger"
)
// UpdateFileMetadata updates the FileInfo for the specified directory in the index.
@@ -71,9 +72,7 @@ func GetIndex(name string) *Index {
defer indexesMutex.Unlock()
index, ok := indexes[name]
if !ok {
return &Index{
Source: settings.Source{Name: "default", Path: "."},
}
logger.Error(fmt.Sprintf("index %s not found", name))
}
return index
}
@@ -1,7 +1,6 @@
package settings
package files
import (
"errors"
"fmt"
"os"
"path"
@@ -10,6 +9,8 @@ import (
"strings"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
var (
@@ -17,25 +18,39 @@ var (
dashes = regexp.MustCompile(`[\-]+`)
)
// todo fix this!!
// MakeUserDir makes the user directory according to settings.
func (s *Settings) MakeUserDir(username, userScope, serverRoot string) (string, error) {
userScope = strings.TrimSpace(userScope)
if userScope == "" && s.Server.CreateUserDir {
func (idx *Index) MakeUserDir(username string, scope string) error {
if idx.Config.CreateUserDir {
username = cleanUsername(username)
if username == "" || username == "-" || username == "." {
logger.Error(fmt.Sprintf("create user: invalid user for home dir creation: [%s]", username))
return "", errors.New("invalid user for home dir creation")
}
userScope = path.Join(s.Server.UserHomeBasePath, username)
}
userScope = path.Join("/", userScope)
fullPath := filepath.Join(serverRoot, userScope)
userScope := path.Join("/", scope)
fullPath := filepath.Join(idx.Path, userScope)
logger.Debug(fmt.Sprintf("creating user home dir: [%s]", fullPath))
if err := os.MkdirAll(fullPath, os.ModePerm); err != nil {
return "", fmt.Errorf("failed to create user home dir: [%s]: %w", userScope, err)
return err
}
return nil
}
func MakeUserDirs(u *users.User) {
for _, scope := range u.Scopes {
source := settings.Config.Server.NameToSource[scope.Name]
if source.Config.CreateUserDir {
idx := GetIndex(source.Name)
if idx == nil {
logger.Error(fmt.Sprintf("create user: failed to find source index for user home dir creation: %s", source.Name))
continue
}
err := idx.MakeUserDir(u.Username, scope.Scope)
if err != nil {
logger.Error(fmt.Sprintf("create user: failed to create user home dir: %s", err))
}
}
}
return userScope, nil
}
func cleanUsername(s string) string {
+21 -6
View File
@@ -1,28 +1,43 @@
package fileutils
import (
"fmt"
"io"
"os"
"path"
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/logger"
)
// MoveFile moves a file from src to dst.
// By default, the rename system call is used. If src and dst point to different volumes,
// the file copy is used as a fallback.
func MoveFile(src, dst string) error {
if os.Rename(src, dst) == nil {
err := os.Rename(src, dst)
if err == nil {
return nil
}
// fallback
err := CopyFile(src, dst)
err = CopyFile(src, dst)
if err != nil {
_ = os.Remove(dst)
return err
}
if err := os.Remove(src); err != nil {
go func() {
err = os.Remove(src)
if err != nil {
logger.Error(fmt.Sprintf("os.Remove failed %v %v ", src, err))
}
}()
return err
}
go func() {
err = os.Remove(src)
if err != nil {
logger.Error(fmt.Sprintf("os.Remove failed %v %v ", src, err))
}
}()
return nil
}
+1 -2
View File
@@ -8,7 +8,6 @@ require (
github.com/asdine/storm/v3 v3.2.1
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
github.com/goccy/go-yaml v1.15.23
github.com/golang-jwt/jwt/v4 v4.5.1
github.com/google/go-cmp v0.6.0
@@ -20,7 +19,6 @@ require (
golang.org/x/crypto v0.33.0
golang.org/x/image v0.24.0
golang.org/x/sys v0.30.0
golang.org/x/text v0.22.0
)
require (
@@ -44,6 +42,7 @@ require (
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.etcd.io/bbolt v1.4.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/tools v0.30.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
-2
View File
@@ -27,8 +27,6 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003142440-7a1927d49d9d/go.mod h1:LV
github.com/dsoprea/go-utility/v2 v2.0.0-20221003160719-7bc88537c05e/go.mod h1:VZ7cB0pTjm1ADBWhJUOHESu4ZYy9JN+ZPqjfiW09EPU=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje0z+3UQ5YjYiSRRzVdtamFpvBQXKwMglWqw=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ=
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
+55 -14
View File
@@ -10,31 +10,45 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/users"
)
// createApiKeyHandler creates an API key for the user.
// @Summary Create API key
// @Description Create an API key with specified name, duration, and permissions.
// @Tags API Keys
// @Accept json
// @Produce json
// @Param name query string true "Name of the API key"
// @Param days query string true "Duration of the API key in days"
// @Param permissions query string true "Permissions for the API key (comma-separated)"
// @Success 200 {object} HttpResponse "Token created successfully, resoponse contains json object with token key"
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 404 {object} map[string]string "Not found"
// @Failure 409 {object} map[string]string "Conflict"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/createApiKey [post]
func createApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
name := r.URL.Query().Get("name")
durationStr := r.URL.Query().Get("days")
permissionsStr := r.URL.Query().Get("permissions")
if !d.user.Perm.Api {
return http.StatusForbidden, fmt.Errorf("user does not have permission to create api keys")
}
if name == "" {
return http.StatusInternalServerError, fmt.Errorf("api name must be valid")
return http.StatusBadRequest, fmt.Errorf("api name must be valid")
}
if durationStr == "" {
return http.StatusInternalServerError, fmt.Errorf("api duration must be valid")
return http.StatusBadRequest, fmt.Errorf("api duration must be valid")
}
if permissionsStr == "" {
return http.StatusInternalServerError, fmt.Errorf("api permissions must be valid")
return http.StatusBadRequest, fmt.Errorf("api permissions must be valid")
}
// Parse permissions from the query parameter
permissions := users.Permissions{
Api: strings.Contains(permissionsStr, "api") && d.user.Perm.Api,
Admin: strings.Contains(permissionsStr, "admin") && d.user.Perm.Admin,
Execute: strings.Contains(permissionsStr, "execute") && d.user.Perm.Execute,
Create: strings.Contains(permissionsStr, "create") && d.user.Perm.Create,
Rename: strings.Contains(permissionsStr, "rename") && d.user.Perm.Rename,
Modify: strings.Contains(permissionsStr, "modify") && d.user.Perm.Modify,
Delete: strings.Contains(permissionsStr, "delete") && d.user.Perm.Delete,
Share: strings.Contains(permissionsStr, "share") && d.user.Perm.Share,
Download: strings.Contains(permissionsStr, "download") && d.user.Perm.Download,
Api: strings.Contains(permissionsStr, "api") && d.user.Perm.Api,
Admin: strings.Contains(permissionsStr, "admin") && d.user.Perm.Admin,
Modify: strings.Contains(permissionsStr, "modify") && d.user.Perm.Modify,
Share: strings.Contains(permissionsStr, "share") && d.user.Perm.Share,
}
// Convert the duration string to an int64
@@ -45,7 +59,6 @@ func createApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// Here we assume the duration is in seconds; convert to time.Duration
duration := time.Duration(durationInt) * time.Hour * 24
// get request body like:
token, err := makeSignedTokenAPI(d.user, name, duration, permissions)
if err != nil {
@@ -61,8 +74,22 @@ func createApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestConte
return renderJSON(w, r, response)
}
// deleteApiKeyHandler deletes an API key for the user.
// @Summary Delete API key
// @Description Delete an API key with specified name.
// @Tags API Keys
// @Accept json
// @Produce json
// @Param name query string true "Name of the API key to delete"
// @Success 200 {object} HttpResponse "API key deleted successfully"
// @Failure 404 {object} map[string]string "Not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/deleteApiKey [delete]
func deleteApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
name := r.URL.Query().Get("name")
if !d.user.Perm.Api {
return http.StatusForbidden, fmt.Errorf("user does not have permission to delete api keys")
}
keyInfo, ok := d.user.ApiKeys[name]
if !ok {
@@ -89,8 +116,22 @@ type AuthTokenMin struct {
Permissions users.Permissions `json:"Permissions"`
}
// listApiKeysHandler lists all API keys or retrieves details for a specific key.
// @Summary List API keys
// @Description List all API keys or retrieve details for a specific key.
// @Tags API Keys
// @Accept json
// @Produce json
// @Param name query string false "Name of the API to retrieve details"
// @Success 200 {object} AuthTokenMin "List of API keys or specific key details"
// @Failure 404 {object} map[string]string "Not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/listApiKeys [get]
func listApiKeysHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
key := r.URL.Query().Get("key")
key := r.URL.Query().Get("name")
if !d.user.Perm.Api {
return http.StatusForbidden, fmt.Errorf("user does not have permission to list api keys")
}
if key != "" {
keyInfo, ok := d.user.ApiKeys[key]
+39 -17
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
@@ -17,7 +16,6 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/share"
"github.com/gtsteffaniak/filebrowser/backend/users"
@@ -74,6 +72,16 @@ func extractToken(r *http.Request) (string, error) {
return "", request.ErrNoTokenInRequest
}
// loginHandler handles user authentication via password.
// @Summary User login
// @Description Authenticate a user with a username and password.
// @Tags Auth
// @Accept json
// @Produce json
// @Success 200 {string} string "JWT token for authentication"
// @Failure 403 {object} map[string]string "Forbidden - authentication failed"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/login [post]
func loginHandler(w http.ResponseWriter, r *http.Request) {
if !config.Auth.Methods.PasswordAuth.Enabled {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
@@ -88,12 +96,9 @@ func loginHandler(w http.ResponseWriter, r *http.Request) {
}
// Authenticate the user based on the request
user, err := auther.Auth(r, store.Users)
if err == os.ErrPermission {
if err != nil {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
} else if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
status, err := printToken(w, r, user) // Pass the data object
if err != nil {
@@ -106,6 +111,19 @@ type signupBody struct {
Password string `json:"password"`
}
// signupHandler registers a new user account.
// @Summary User signup
// @Description Register a new user account with a username and password.
// @Tags Auth
// @Accept json
// @Produce json
// @Param body body signupBody true "User signup details"
// @Success 201 {string} string "User created successfully"
// @Failure 400 {object} map[string]string "Bad request - invalid input"
// @Failure 405 {object} map[string]string "Method not allowed - signup is disabled"
// @Failure 409 {object} map[string]string "Conflict - user already exists"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/signup [post]
func signupHandler(w http.ResponseWriter, r *http.Request) {
if !settings.Config.Auth.Signup {
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
@@ -128,20 +146,14 @@ func signupHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
user := settings.ApplyUserDefaults(users.User{})
user := &users.User{}
settings.ApplyUserDefaults(user)
user.Username = info.Username
user.Password = info.Password
userHome, err := config.MakeUserDir(user.Username, user.Scope, files.RootPaths["default"])
if err != nil {
logger.Error(fmt.Sprintf("create user: failed to mkdir user home dir: [%s]", userHome))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
user.Scope = userHome
logger.Debug(fmt.Sprintf("new user: %s, home dir: [%s].", user.Username, userHome))
err = store.Users.Save(&user)
files.MakeUserDirs(user)
err = store.Users.Save(user, true)
if err == errors.ErrExist {
http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict)
return
@@ -151,6 +163,16 @@ func signupHandler(w http.ResponseWriter, r *http.Request) {
}
}
// renewHandler refreshes the authentication token for a logged-in user.
// @Summary Renew authentication token
// @Description Refresh the authentication token for a logged-in user.
// @Tags Auth
// @Accept json
// @Produce json
// @Success 200 {string} string "New JWT token generated"
// @Failure 401 {object} map[string]string "Unauthorized - invalid token"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/renew [post]
func renewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// check if x-auth header is present and token is
return printToken(w, r, d.user)
+18 -27
View File
@@ -5,23 +5,23 @@ import (
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"strings"
"time"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/runner"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/share"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
type requestContext struct {
user *users.User
*runner.Runner
user *users.User
raw interface{}
path string
token string
share *share.Link
}
type HttpResponse struct {
@@ -42,13 +42,12 @@ func withHashFileHelper(fn handleFunc) handleFunc {
hash := r.URL.Query().Get("hash")
// Retrieve the user (using the public user by default)
data.user = &users.PublicUser
data.user.Scope = ""
// Get the file link by hash
link, err := store.Share.GetByHash(hash)
if err != nil {
return http.StatusNotFound, fmt.Errorf("share not found")
}
data.share = link
// Authenticate the share request if needed
var status int
if link.Hash != "" {
@@ -57,16 +56,13 @@ func withHashFileHelper(fn handleFunc) handleFunc {
return status, fmt.Errorf("could not authenticate share request")
}
}
// Set the scope to configured defaults
scope := settings.Config.UserDefaults.Scope
data.path = link.Path + "/" + path
// Get file information with options
file, err := FileInfoFasterFunc(files.FileOptions{
Path: filepath.Join(scope, link.Path+"/"+path),
Modify: data.user.Perm.Modify,
Expand: true,
Checker: data.user, // Call your checker function here
Path: data.path,
Source: link.Source,
Modify: false,
Expand: true,
})
file.Token = link.Token
if err != nil {
@@ -88,8 +84,6 @@ func withAdminHelper(fn handleFunc) handleFunc {
if !data.user.Perm.Admin {
return http.StatusForbidden, nil
}
// Proceed to the actual handler if the user is admin
return fn(w, r, data)
})
}
@@ -100,7 +94,7 @@ func withUserHelper(fn handleFunc) handleFunc {
if config.Auth.Methods.NoAuth {
var err error
// Retrieve the user from the store and store it in the context
data.user, err = store.Users.Get(files.RootPaths["default"], uint(1))
data.user, err = store.Users.Get(uint(1))
if err != nil {
logger.Error(fmt.Sprintf("no auth: %v", err))
return http.StatusInternalServerError, err
@@ -111,20 +105,19 @@ func withUserHelper(fn handleFunc) handleFunc {
if config.Auth.Methods.ProxyAuth.Enabled && proxyUser != "" {
var err error
// Retrieve the user from the store and store it in the context
data.user, err = store.Users.Get(files.RootPaths["default"], proxyUser)
data.user, err = store.Users.Get(proxyUser)
if err != nil {
if err.Error() != "the resource does not exist" {
return http.StatusInternalServerError, err
}
if config.Auth.Methods.ProxyAuth.CreateUser {
newUser := settings.ApplyUserDefaults(users.User{
Username: proxyUser,
})
err := store.Users.Save(&newUser)
newUser := &users.User{Username: proxyUser}
settings.ApplyUserDefaults(newUser)
err := store.Users.Save(newUser, false)
if err != nil {
return http.StatusInternalServerError, err
}
data.user, err = store.Users.Get(files.RootPaths["default"], proxyUser)
data.user, err = store.Users.Get(proxyUser)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -161,7 +154,7 @@ func withUserHelper(fn handleFunc) handleFunc {
}
// Retrieve the user from the store and store it in the context
data.user, err = store.Users.Get(files.RootPaths["default"], tk.BelongsTo)
data.user, err = store.Users.Get(tk.BelongsTo)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -187,9 +180,7 @@ func withSelfOrAdminHelper(fn handleFunc) handleFunc {
func wrapHandler(fn handleFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data := &requestContext{
Runner: &runner.Runner{Enabled: config.Server.EnableExec, Settings: config},
}
data := &requestContext{}
// Call the actual handler function and get status code and error
status, err := fn(w, r, data)
+2 -2
View File
@@ -75,10 +75,10 @@ func TestWithAdminHelper(t *testing.T) {
Perm: users.Permissions{Admin: false}, // Non-admin user
}
// Save the users to the mock database
if err := store.Users.Save(adminUser); err != nil {
if err := store.Users.Save(adminUser, true); err != nil {
t.Fatal("failed to save admin user:", err)
}
if err := store.Users.Save(nonAdminUser); err != nil {
if err := store.Users.Save(nonAdminUser, true); err != nil {
t.Fatal("failed to save non-admin user:", err)
}
// Test cases for different scenarios
+27 -23
View File
@@ -14,6 +14,7 @@ import (
jwt "github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/cache"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
)
@@ -43,7 +44,7 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
encodedUrl := r.URL.Query().Get("url")
source := r.URL.Query().Get("source")
if source == "" {
source = "default"
source = settings.Config.Server.DefaultSource.Name
}
// Decode the URL-encoded path
url, err := url.QueryUnescape(encodedUrl)
@@ -60,12 +61,15 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
replacement := strings.Split(url, "/api/raw")[0]
url = strings.Replace(url, replacement, settings.Config.Server.InternalUrl, 1)
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
return http.StatusForbidden, err
}
fileInfo, err := files.FileInfoFaster(files.FileOptions{
Path: filepath.Join(d.user.Scope, path),
Modify: d.user.Perm.Modify,
Source: source,
Expand: false,
Checker: d.user,
Path: filepath.Join(userscope, path),
Modify: d.user.Perm.Modify,
Source: source,
Expand: false,
})
if err != nil {
@@ -92,8 +96,8 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
"url": url + "&auth=" + d.token,
"permissions": map[string]interface{}{
"edit": d.user.Perm.Modify,
"download": d.user.Perm.Download,
"print": d.user.Perm.Download,
"download": true,
"print": true,
},
},
"editorConfig": map[string]interface{}{
@@ -137,7 +141,7 @@ func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *reques
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = "default"
source = settings.Config.Server.DefaultSource.Name
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
@@ -167,20 +171,13 @@ func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *reques
}
defer doc.Body.Close()
err = d.Runner.RunHook(func() error {
fileOpts := files.FileOptions{
Path: path,
Source: source,
}
writeErr := files.WriteFile(fileOpts, doc.Body)
if writeErr != nil {
return writeErr
}
return nil
}, "save", path, "", d.user)
if err != nil {
return http.StatusInternalServerError, err
fileOpts := files.FileOptions{
Path: path,
Source: source,
}
writeErr := files.WriteFile(fileOpts, doc.Body)
if writeErr != nil {
return http.StatusInternalServerError, writeErr
}
}
@@ -192,6 +189,9 @@ func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *reques
func getOnlyOfficeId(source, path string) (string, error) {
idx := files.GetIndex(source)
if idx == nil {
return "", fmt.Errorf("source not found")
}
realpath, _, _ := idx.GetRealPath(path)
// error is intentionally ignored in order treat errors
// the same as a cache-miss
@@ -203,6 +203,10 @@ func getOnlyOfficeId(source, path string) (string, error) {
}
func deleteOfficeId(source, path string) {
idx := files.GetIndex(source)
if idx == nil {
logger.Error(fmt.Sprintf("deleteOfficeId: failed to find source index for user home dir creation: %s", source))
return
}
realpath, _, _ := idx.GetRealPath(path)
cache.OnlyOffice.Delete(realpath)
}
+21 -18
View File
@@ -14,6 +14,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/img"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
)
type ImgService interface {
@@ -45,6 +46,9 @@ type FileCache interface {
func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = settings.Config.Server.DefaultSource.Name
}
previewSize := r.URL.Query().Get("size")
if previewSize != "small" {
previewSize = "large"
@@ -52,14 +56,16 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
if path == "" {
return http.StatusBadRequest, fmt.Errorf("invalid request path")
}
response, err := files.FileInfoFaster(files.FileOptions{
Path: filepath.Join(d.user.Scope, path),
Modify: d.user.Perm.Modify,
Source: source,
Expand: true,
Checker: d.user,
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
return http.StatusForbidden, err
}
fileInfo, err := files.FileInfoFaster(files.FileOptions{
Path: filepath.Join(userscope, path),
Modify: d.user.Perm.Modify,
Source: source,
Expand: true,
})
fileInfo := response.FileInfo
if err != nil {
return errToStatus(err), err
}
@@ -73,37 +79,31 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
if (previewSize == "large" && !config.Server.ResizePreview) ||
(previewSize == "small" && !config.Server.EnableThumbnails) {
if !d.user.Perm.Download {
return http.StatusAccepted, nil
}
return rawFileHandler(w, r, fileInfo)
}
format, err := imgSvc.FormatFromExtension(filepath.Ext(fileInfo.Name))
// Unsupported extensions directly return the raw data
if err == img.ErrUnsupportedFormat || format == img.FormatGif {
if !d.user.Perm.Download {
return http.StatusAccepted, nil
}
return rawFileHandler(w, r, fileInfo)
}
if err != nil {
return errToStatus(err), err
}
cacheKey := previewCacheKey(response.RealPath, previewSize, fileInfo.ModTime)
cacheKey := previewCacheKey(fileInfo.RealPath, previewSize, fileInfo.ModTime)
resizedImage, ok, err := fileCache.Load(r.Context(), cacheKey)
if err != nil {
return errToStatus(err), err
}
if !ok {
resizedImage, err = createPreview(imgSvc, fileCache, response, previewSize)
resizedImage, err = createPreview(imgSvc, fileCache, fileInfo, previewSize)
if err != nil {
return errToStatus(err), err
}
}
w.Header().Set("Cache-Control", "private")
http.ServeContent(w, r, response.RealPath, fileInfo.ModTime, bytes.NewReader(resizedImage))
http.ServeContent(w, r, fileInfo.RealPath, fileInfo.ModTime, bytes.NewReader(resizedImage))
return 0, nil
}
@@ -153,8 +153,11 @@ func previewCacheKey(realPath, previewSize string, modTime time.Time) string {
return fmt.Sprintf("%x%x%x", realPath, modTime.Unix(), previewSize)
}
func rawFileHandler(w http.ResponseWriter, r *http.Request, file files.FileInfo) (int, error) {
idx := files.GetIndex("default")
func rawFileHandler(w http.ResponseWriter, r *http.Request, file files.ExtendedFileInfo) (int, error) {
idx := files.GetIndex(file.Source)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source not found: %s", file.Source)
}
realPath, _, _ := idx.GetRealPath(file.Path)
fd, err := os.Open(realPath)
if err != nil {
+36 -1
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/files"
@@ -12,12 +13,46 @@ import (
_ "github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
)
// rawHandler serves the raw content of a file, multiple files, or directory in various formats.
// @Summary Get raw content of a file, multiple files, or directory
// @Description Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.
// @Tags Resources
// @Accept json
// @Produce json
// @Param files query string true "a list of files in the following format 'source::filename' and separated by '||' with additional items in the list. (required)"
// @Param inline query bool false "If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults to 'attachment'."
// @Param algo query string false "Compression algorithm for archiving multiple files or directories. Options: 'zip' and 'tar.gz'. Default is 'zip'."
// @Success 200 {file} file "Raw file or directory content, or archive for multiple files"
// @Failure 202 {object} map[string]string "Modify permissions required"
// @Failure 400 {object} map[string]string "Invalid request path"
// @Failure 404 {object} map[string]string "File or directory not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /public/dl [get]
func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
filePrefix := ""
file, ok := d.raw.(files.ExtendedFileInfo)
if ok {
filePrefix = file.Source + "::"
}
encodedFiles := file.Path + r.URL.Query().Get("files")
// Decode the URL-encoded path
files, err := url.QueryUnescape(encodedFiles)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
fileList := strings.Split(files, "||")
for i, f := range fileList {
fileList[i] = filePrefix + f
}
return rawFilesHandler(w, r, d, fileList)
}
func publicShareHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
file, ok := d.raw.(files.ExtendedFileInfo)
if !ok {
return http.StatusInternalServerError, fmt.Errorf("failed to assert type files.FileInfo")
}
file.Path = strings.TrimPrefix(file.Path, files.RootPaths["default"])
file.Path = strings.TrimPrefix(file.Path, file.Source)
return renderJSON(w, r, file)
}
+43 -16
View File
@@ -15,6 +15,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
)
func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName string) {
@@ -32,20 +33,16 @@ func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName stri
// @Tags Resources
// @Accept json
// @Produce json
// @Param files query string true "Comma-separated list of specific files within the directory (required)"
// @Param files query string true "a list of files in the following format 'source::filename' and separated by '||' with additional items in the list. (required)"
// @Param inline query bool false "If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults to 'attachment'."
// @Param algo query string false "Compression algorithm for archiving multiple files or directories. Options: 'zip' and 'tar.gz'. Default is 'zip'."
// @Success 200 {file} file "Raw file or directory content, or archive for multiple files"
// @Failure 202 {object} map[string]string "Download permissions required"
// @Failure 202 {object} map[string]string "Modify permissions required"
// @Failure 400 {object} map[string]string "Invalid request path"
// @Failure 404 {object} map[string]string "File or directory not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/raw [get]
func rawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.user.Perm.Download {
return http.StatusAccepted, nil
}
filePrefix := ""
file, ok := d.raw.(files.ExtendedFileInfo)
if ok {
@@ -57,7 +54,7 @@ func rawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int,
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
fileList := strings.Split(files, ",|")
fileList := strings.Split(files, "||")
for i, f := range fileList {
fileList[i] = filepath.Join(filePrefix, f)
}
@@ -65,10 +62,25 @@ func rawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int,
}
func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *zip.Writer, flatten bool) error {
idx := files.GetIndex("default")
realPath, _, _ := idx.GetRealPath(d.user.Scope, path)
if !d.user.Check(realPath) {
return nil
splitFile := strings.Split(path, "::")
if len(splitFile) != 2 {
return fmt.Errorf("invalid file in files requested: %v", splitFile)
}
source := splitFile[0]
path = splitFile[1]
userScope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if d.share == nil && err != nil {
return fmt.Errorf("source %s is not available for user %s", source, d.user.Username)
}
idx := files.GetIndex(source)
if idx == nil {
return fmt.Errorf("source %s is not available", source)
}
realPath, _, err := idx.GetRealPath(userScope, path)
if err != nil {
return fmt.Errorf("failed to get real path for %s: %v", path, err)
}
info, err := os.Stat(realPath)
if err != nil {
@@ -173,10 +185,24 @@ func addSingleFile(realPath, archivePath string, zipWriter *zip.Writer, tarWrite
}
func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext, fileList []string) (int, error) {
filePath := fileList[0]
fileName := filepath.Base(filePath)
idx := files.GetIndex("default")
realPath, isDir, err := idx.GetRealPath(d.user.Scope, filePath)
splitFile := strings.Split(fileList[0], "::")
if len(splitFile) != 2 {
return http.StatusBadRequest, fmt.Errorf("invalid file in files request: %v", fileList[0])
}
firstFileSource := splitFile[0]
firstFilePath := splitFile[1]
fileName := filepath.Base(firstFilePath)
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, firstFileSource)
if err != nil {
return http.StatusForbidden, err
}
idx := files.GetIndex(firstFileSource)
if idx == nil {
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", firstFileSource)
}
realPath, isDir, err := idx.GetRealPath(userscope, firstFilePath)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -246,9 +272,9 @@ func createZip(w io.Writer, d *requestContext, filenames ...string) error {
err := addFile(fname, d, nil, zipWriter, false)
if err != nil {
logger.Error(fmt.Sprintf("Failed to add %s to ZIP: %v", fname, err))
return err
}
}
return nil
}
@@ -265,6 +291,7 @@ func createTarGz(w io.Writer, d *requestContext, filenames ...string) error {
err := addFile(fname, d, tarWriter, nil, false)
if err != nil {
logger.Error(fmt.Sprintf("Failed to add %s to TAR.GZ: %v", fname, err))
return err
}
}
+113 -61
View File
@@ -17,6 +17,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
)
// resourceGetHandler retrieves information about a resource.
@@ -38,19 +39,22 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = "default"
source = config.Server.DefaultSource.Name
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
return http.StatusForbidden, err
}
fileInfo, err := files.FileInfoFaster(files.FileOptions{
Path: filepath.Join(d.user.Scope, path),
Path: filepath.Join(userscope, path),
Modify: d.user.Perm.Modify,
Source: source,
Expand: true,
Checker: d.user,
Content: r.URL.Query().Get("content") == "true",
})
if err != nil {
@@ -61,7 +65,10 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
}
if algo := r.URL.Query().Get("checksum"); algo != "" {
idx := files.GetIndex(source)
realPath, _, _ := idx.GetRealPath(d.user.Scope, path)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
realPath, _, _ := idx.GetRealPath(userscope, path)
checksums, err := files.GetChecksum(realPath, algo)
if err == errors.ErrInvalidOption {
return http.StatusBadRequest, nil
@@ -93,22 +100,25 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = "default"
source = config.Server.DefaultSource.Name
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
if path == "/" || !d.user.Perm.Delete {
if path == "/" {
return http.StatusForbidden, nil
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
return http.StatusForbidden, err
}
fileOpts := files.FileOptions{
Path: filepath.Join(d.user.Scope, path),
Source: source,
Modify: d.user.Perm.Modify,
Expand: false,
Checker: d.user,
Path: filepath.Join(userscope, path),
Source: source,
Modify: d.user.Perm.Modify,
Expand: false,
}
fileInfo, err := files.FileInfoFaster(fileOpts)
if err != nil {
@@ -146,22 +156,27 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = "default"
source = config.Server.DefaultSource.Name
}
if !d.user.Perm.Modify {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
if !d.user.Perm.Create || !d.user.Check(path) {
return http.StatusForbidden, nil
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
return http.StatusForbidden, err
}
fileOpts := files.FileOptions{
Path: filepath.Join(d.user.Scope, path),
Source: source,
Modify: d.user.Perm.Modify,
Expand: false,
Checker: d.user,
Path: filepath.Join(userscope, path),
Source: source,
Modify: d.user.Perm.Modify,
Expand: false,
}
// Directories creation on POST.
if strings.HasSuffix(path, "/") {
@@ -174,6 +189,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
fileInfo, err := files.FileInfoFaster(fileOpts)
if err == nil {
if r.URL.Query().Get("override") != "true" {
logger.Debug(fmt.Sprintf("Resource already exists: %v", fileInfo.RealPath))
return http.StatusConflict, nil
}
@@ -210,9 +226,11 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
source := r.URL.Query().Get("source")
if source == "" {
source = "default"
source = config.Server.DefaultSource.Name
}
if !d.user.Perm.Modify {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
encodedPath := r.URL.Query().Get("path")
// Decode the URL-encoded path
@@ -220,21 +238,19 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
if !d.user.Perm.Modify || !d.user.Check(path) {
return http.StatusForbidden, nil
}
// Only allow PUT for files.
if strings.HasSuffix(path, "/") {
return http.StatusMethodNotAllowed, nil
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
return http.StatusForbidden, err
}
fileOpts := files.FileOptions{
Path: filepath.Join(d.user.Scope, path),
Source: source,
Modify: d.user.Perm.Modify,
Expand: false,
Checker: d.user,
Path: filepath.Join(userscope, path),
Source: source,
Modify: d.user.Perm.Modify,
Expand: false,
}
err = files.WriteFile(fileOpts, r.Body)
return errToStatus(err), err
@@ -261,10 +277,11 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// TODO source := r.URL.Query().Get("source")
action := r.URL.Query().Get("action")
source := r.URL.Query().Get("source")
if source == "" {
source = "default"
if !d.user.Perm.Modify {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
encodedFrom := r.URL.Query().Get("from")
// Decode the URL-encoded path
src, err := url.QueryUnescape(encodedFrom)
@@ -276,21 +293,52 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
if err != nil {
return errToStatus(err), err
}
if !d.user.Check(src) || !d.user.Check(dst) {
return http.StatusForbidden, fmt.Errorf("forbidden: user rules deny access to source or destination")
splitSrc := strings.Split(src, "::")
if len(splitSrc) <= 1 {
return http.StatusBadRequest, fmt.Errorf("invalid source path: %v", src)
}
srcIndex := splitSrc[0]
src = splitSrc[1]
splitDst := strings.Split(dst, "::")
if len(splitDst) <= 1 {
return http.StatusBadRequest, fmt.Errorf("invalid destination path: %v", dst)
}
dstIndex := splitDst[0]
dst = splitDst[1]
if dst == "/" || src == "/" {
return http.StatusForbidden, fmt.Errorf("forbidden: source or destination is attempting to modify root")
}
idx := files.GetIndex(source)
// check target dir exists
parentDir, _, err := idx.GetRealPath(d.user.Scope, filepath.Dir(dst))
userscopeDst, err := settings.GetScopeFromSourceName(d.user.Scopes, dstIndex)
if err != nil {
return http.StatusForbidden, err
}
userscopeSrc, err := settings.GetScopeFromSourceName(d.user.Scopes, srcIndex)
if err != nil {
return http.StatusForbidden, err
}
idx := files.GetIndex(dstIndex)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", dstIndex)
}
// check target dir exists
parentDir, _, err := idx.GetRealPath(userscopeDst, filepath.Dir(dst))
if err != nil {
logger.Debug(fmt.Sprintf("Could not get real path for parent dir: %v %v %v", userscopeDst, filepath.Dir(dst), err))
return http.StatusNotFound, err
}
realDest := parentDir + "/" + filepath.Base(dst)
realSrc, isSrcDir, err := idx.GetRealPath(d.user.Scope, src)
idx2 := files.GetIndex(srcIndex)
if idx2 == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", srcIndex)
}
realSrc, isSrcDir, err := idx2.GetRealPath(userscopeSrc, src)
if err != nil {
return http.StatusNotFound, err
}
@@ -303,9 +351,7 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
if overwrite && !d.user.Perm.Modify {
return http.StatusForbidden, fmt.Errorf("forbidden: user does not have permission to overwrite file")
}
err = d.Runner.RunHook(func() error {
return patchAction(r.Context(), action, realSrc, realDest, d, fileCache, isSrcDir, source)
}, action, realSrc, realDest, d.user)
err = patchAction(r.Context(), action, realSrc, realDest, d, fileCache, isSrcDir, srcIndex, dstIndex)
if err != nil {
logger.Debug(fmt.Sprintf("Could not run patch action. src=%v dst=%v err=%v", realSrc, realDest, err))
}
@@ -335,34 +381,34 @@ func delThumbs(ctx context.Context, fileCache FileCache, file files.ExtendedFile
}
}
func patchAction(ctx context.Context, action, src, dst string, d *requestContext, fileCache FileCache, isSrcDir bool, index string) error {
func patchAction(ctx context.Context, action, src, dst string, d *requestContext, fileCache FileCache, isSrcDir bool, srcIndex, destIndex string) error {
switch action {
case "copy":
if !d.user.Perm.Create {
if !d.user.Perm.Modify {
return errors.ErrPermissionDenied
}
err := files.CopyResource(index, src, dst, isSrcDir)
err := files.CopyResource(srcIndex, destIndex, src, dst, isSrcDir)
return err
case "rename", "move":
if !d.user.Perm.Rename {
if !d.user.Perm.Modify {
return errors.ErrPermissionDenied
}
fileInfo, err := files.FileInfoFaster(files.FileOptions{
Path: src,
Source: index,
Source: srcIndex,
IsDir: isSrcDir,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: false,
Checker: d.user,
})
if err != nil {
return err
}
// delete thumbnails
delThumbs(ctx, fileCache, fileInfo)
return files.MoveResource(index, src, dst, isSrcDir)
return files.MoveResource(srcIndex, destIndex, src, dst, isSrcDir)
default:
return fmt.Errorf("unsupported action %s: %w", action, errors.ErrInvalidRequestParams)
}
@@ -385,21 +431,23 @@ type DiskUsageResponse struct {
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/usage [get]
func diskUsage(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
source := r.URL.Query().Get("source")
if source == "" {
source = "default"
sourceName := r.URL.Query().Get("source")
if sourceName == "" {
sourceName = config.Server.DefaultSource.Name
}
value, ok := cache.DiskUsage.Get(source).(DiskUsageResponse)
value, ok := cache.DiskUsage.Get(sourceName).(DiskUsageResponse)
if ok {
return renderJSON(w, r, &value)
}
rootPath, ok := files.RootPaths[source]
source, ok := config.Server.NameToSource[sourceName]
if !ok {
return 400, fmt.Errorf("bad source path provided: %v", source)
return 403, fmt.Errorf("source '%s' either does not exist or user does not have permission to it", sourceName)
}
usage, err := disk.UsageWithContext(r.Context(), rootPath)
_, err := settings.GetScopeFromSourceName(d.user.Scopes, sourceName)
if err != nil {
return 403, fmt.Errorf("user doesn't have access to '%s', contact your administrator for access", sourceName)
}
usage, err := disk.UsageWithContext(r.Context(), source.Path)
if err != nil {
return errToStatus(err), err
}
@@ -407,7 +455,7 @@ func diskUsage(w http.ResponseWriter, r *http.Request, d *requestContext) (int,
Total: usage.Total,
Used: usage.Used,
}
cache.DiskUsage.Set(source, latestUsage)
cache.DiskUsage.Set(sourceName, latestUsage)
return renderJSON(w, r, &latestUsage)
}
@@ -415,12 +463,16 @@ func inspectIndex(w http.ResponseWriter, r *http.Request) {
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = "default"
source = config.Server.DefaultSource.Name
}
// Decode the URL-encoded path
path, _ := url.QueryUnescape(encodedPath)
isNotDir := r.URL.Query().Get("isDir") == "false" // default to isDir true
index := files.GetIndex(source)
if index == nil {
http.Error(w, "source not found", http.StatusNotFound)
return
}
info, _ := index.GetReducedMetadata(path, !isNotDir)
renderJSON(w, r, info) // nolint:errcheck
}
+1 -1
View File
@@ -109,7 +109,7 @@ func StartHttp(ctx context.Context, Service ImgService, storage *storage.Storage
// Public routes
api.HandleFunc("GET /public/publicUser", publicUserGetHandler)
api.HandleFunc("GET /public/dl", withHashFile(rawHandler))
api.HandleFunc("GET /public/dl", withHashFile(publicRawHandler))
api.HandleFunc("GET /public/share", withHashFile(publicShareHandler))
// Settings routes
+11 -2
View File
@@ -1,10 +1,12 @@
package http
import (
"fmt"
"net/http"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/settings"
)
// searchHandler handles search requests for files based on the provided query.
@@ -55,14 +57,21 @@ func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
query := r.URL.Query().Get("query")
source := r.URL.Query().Get("source")
if source == "" {
source = "default"
source = config.Server.DefaultSource.Name
}
searchScope := strings.TrimPrefix(r.URL.Query().Get("scope"), ".")
searchScope = strings.TrimPrefix(searchScope, "/")
// Retrieve the User-Agent and X-Auth headers from the request
sessionId := r.Header.Get("SessionId")
index := files.GetIndex(source)
userScope := strings.TrimPrefix(d.user.Scope, ".")
if index == nil {
return http.StatusBadRequest, fmt.Errorf("index not found for source %s", source)
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
return http.StatusForbidden, err
}
userScope := strings.TrimPrefix(userscope, ".")
combinedScope := strings.TrimPrefix(userScope+"/"+searchScope, "/")
// Perform the search using the provided query and user scope
+21 -16
View File
@@ -5,7 +5,6 @@ import (
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
type settingsData struct {
@@ -13,35 +12,43 @@ type settingsData struct {
CreateUserDir bool `json:"createUserDir"`
UserHomeBasePath string `json:"userHomeBasePath"`
Defaults settings.UserDefaults `json:"defaults"`
Rules []users.Rule `json:"rules"`
Frontend settings.Frontend `json:"frontend"`
Commands map[string][]string `json:"commands"`
}
// settingsGetHandler retrieves the current system settings.
// @Summary Get system settings
// @Description Returns the current configuration settings for signup, user directories, rules, frontend, and commands.
// @Description Returns the current configuration settings for signup, user directories, rules, frontend.
// @Tags Settings
// @Accept json
// @Produce json
// @Param property query string false "Property to retrieve"
// @Success 200 {object} settingsData "System settings data"
// @Router /api/settings [get]
func settingsGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
data := &settingsData{
Signup: config.Auth.Signup,
CreateUserDir: config.Server.CreateUserDir,
UserHomeBasePath: config.Server.UserHomeBasePath,
Defaults: config.UserDefaults,
Rules: config.Rules,
Frontend: config.Frontend,
property := r.URL.Query().Get("property")
if property != "" {
// get property by name
switch property {
case "userDefaults":
return renderJSON(w, r, config.UserDefaults)
case "frontend":
return renderJSON(w, r, config.Frontend)
case "auth":
return renderJSON(w, r, config.Auth)
case "server":
return renderJSON(w, r, config.Server)
case "sources":
return renderJSON(w, r, config.Server.Sources)
default:
return http.StatusNotFound, nil
}
}
return renderJSON(w, r, data)
return renderJSON(w, r, config)
}
// settingsPutHandler updates the system settings.
// @Summary Update system settings
// @Description Updates the system configuration settings for signup, user directories, rules, frontend, and commands.
// @Description Updates the system configuration settings for signup, user directories, rules, frontend.
// @Tags Settings
// @Accept json
// @Produce json
@@ -57,10 +64,8 @@ func settingsPutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
return http.StatusBadRequest, err
}
config.Server.CreateUserDir = req.CreateUserDir
config.Server.UserHomeBasePath = req.UserHomeBasePath
config.UserDefaults = req.Defaults
config.Rules = req.Rules
config.Frontend = req.Frontend
config.Auth.Signup = req.Signup
err = store.Settings.Save(config)
+15 -2
View File
@@ -13,6 +13,7 @@ import (
"golang.org/x/crypto/bcrypt"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/share"
)
@@ -59,12 +60,17 @@ func shareListHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
// @Accept json
// @Produce json
// @Param path query string true "Resource path for which to retrieve share links"
// @Param source query string true "Source name for share links"
// @Success 200 {array} share.Link "List of share links for the specified path"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/share [get]
func shareGetsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.URL.Query().Get("path")
s, err := store.Share.Gets(path, d.user.ID)
source := r.URL.Query().Get("source")
if source == "" {
source = settings.Config.Server.DefaultSource.Name
}
s, err := store.Share.Gets(path, source, d.user.ID)
if err == errors.ErrNotExist {
return renderJSON(w, r, []*share.Link{})
}
@@ -108,6 +114,8 @@ func shareDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContex
// @Accept json
// @Produce json
// @Param body body share.CreateBody true "Share link creation parameters"
// @Param path path string true "Source Path of the files to share"
// @Param source path string true "Source name of the files to share"
// @Success 200 {object} share.Link "Created share link"
// @Failure 400 {object} map[string]string "Bad request - failed to decode body"
// @Failure 500 {object} map[string]string "Internal server error"
@@ -159,16 +167,21 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
var token string
if len(hash) > 0 {
tokenBuffer := make([]byte, 24) //nolint:gomnd
if _, err := rand.Read(tokenBuffer); err != nil {
if _, err = rand.Read(tokenBuffer); err != nil {
return http.StatusInternalServerError, err
}
token = base64.URLEncoding.EncodeToString(tokenBuffer)
stringHash = string(hash)
}
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = config.Server.DefaultSource.Name
}
s = &share.Link{
Path: path,
Hash: secure_hash,
Source: source,
Expire: expire,
UserID: d.user.ID,
PasswordHash: stringHash,
+11 -85
View File
@@ -5,28 +5,15 @@ import (
"fmt"
"io"
"net/http"
"reflect"
"sort"
"strconv"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
var (
NonModifiableFieldsForNonAdmin = []string{"Username", "Scope", "LockPassword", "Perm", "Commands", "Rules"}
)
// SortingSettings represents the sorting settings.
type Sorting struct {
By string `json:"by"`
Asc bool `json:"asc"`
}
type UserRequest struct {
What string `json:"what"`
Which []string `json:"which"`
@@ -56,15 +43,14 @@ func userGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
givenUserId = d.user.ID
} else if givenUserIdString == "" {
userList, err := store.Users.Gets(files.RootPaths["default"])
userList, err := store.Users.Gets()
if err != nil {
return http.StatusInternalServerError, err
}
selfUserList := []*users.User{}
for _, u := range userList {
u.Password = ""
u.ApiKeys = nil
prepForFrontend(u)
if u.ID == d.user.ID {
selfUserList = append(selfUserList, u)
}
@@ -88,22 +74,21 @@ func userGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
}
// Fetch the user details
u, err := store.Users.Get(files.RootPaths["default"], givenUserId)
u, err := store.Users.Get(givenUserId)
if err == errors.ErrNotExist {
return http.StatusNotFound, err
}
if err != nil {
return http.StatusInternalServerError, err
}
prepForFrontend(u)
return renderJSON(w, r, u)
}
// Remove the password from the response if the user is not an admin
func prepForFrontend(u *users.User) {
u.Password = ""
u.ApiKeys = nil
if !d.user.Perm.Admin {
u.Scope = ""
}
return renderJSON(w, r, u)
u.Scopes = settings.ConvertToFrontendScopes(u.Scopes)
}
// userDeleteHandler deletes a user by ID.
@@ -157,26 +142,17 @@ func usersPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if !d.user.Perm.Admin {
return http.StatusForbidden, nil
}
// Validate the user's scope
idx := files.GetIndex("default")
_, _, err := idx.GetRealPath(d.user.Scope)
if err != nil {
return http.StatusBadRequest, err
}
// Read the JSON body
body, err := io.ReadAll(r.Body)
if err != nil {
return http.StatusInternalServerError, err
}
defer r.Body.Close()
// Parse the JSON into the UserRequest struct
var req UserRequest
if err = json.Unmarshal(body, &req); err != nil {
return http.StatusBadRequest, err
}
r.Body.Close()
if len(req.Which) != 0 {
return http.StatusBadRequest, nil
@@ -217,13 +193,6 @@ func userPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
return http.StatusForbidden, nil
}
// Validate the user's scope
idx := files.GetIndex("default")
_, _, err := idx.GetRealPath(d.user.Scope)
if err != nil {
return http.StatusBadRequest, err
}
// Read the JSON body
body, err := io.ReadAll(r.Body)
if err != nil {
@@ -237,51 +206,8 @@ func userPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
return http.StatusBadRequest, err
}
// If `Which` is not specified, default to updating all fields
if len(req.Which) == 0 || req.Which[0] == "all" {
req.Which = []string{}
v := reflect.ValueOf(req.Data)
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
t := v.Type()
// Dynamically populate fields to update
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.Name == "Password" && req.Data.Password != "" {
req.Which = append(req.Which, field.Name)
} else if field.Name != "Password" && field.Name != "Fs" {
req.Which = append(req.Which, field.Name)
}
}
}
// Process the fields to update
for _, field := range req.Which {
// Title case field names
field = cases.Title(language.English, cases.NoLower).String(field)
// Handle password update
if field == "Password" {
if !d.user.Perm.Admin && d.user.LockPassword {
return http.StatusForbidden, nil
}
req.Data.Password, err = users.HashPwd(req.Data.Password)
if err != nil {
return http.StatusInternalServerError, err
}
}
// Prevent non-admins from modifying certain fields
for _, restrictedField := range NonModifiableFieldsForNonAdmin {
if !d.user.Perm.Admin && field == restrictedField {
return http.StatusForbidden, nil
}
}
}
// Perform the user update
err = store.Users.Update(req.Data, req.Which...)
err = store.Users.Update(req.Data, d.user.Perm.Admin, req.Which...)
if err != nil {
return http.StatusInternalServerError, err
}
-136
View File
@@ -1,136 +0,0 @@
// Copyright 2015 Light Code Labs, LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package runner
import (
"errors"
"runtime"
"unicode"
shlex "github.com/flynn/go-shlex"
)
const (
osWindows = "windows"
osLinux = "linux"
)
var runtimeGoos = runtime.GOOS
// SplitCommandAndArgs takes a command string and parses it shell-style into the
// command and its separate arguments.
func SplitCommandAndArgs(command string) (cmd string, args []string, err error) {
var parts []string
if runtimeGoos == osWindows {
parts = parseWindowsCommand(command) // parse it Windows-style
} else {
parts, err = parseUnixCommand(command) // parse it Unix-style
if err != nil {
err = errors.New("error parsing command: " + err.Error())
return
}
}
if len(parts) == 0 {
err = errors.New("no command contained in '" + command + "'")
return
}
cmd = parts[0]
if len(parts) > 1 {
args = parts[1:]
}
return
}
// parseUnixCommand parses a unix style command line and returns the
// command and its arguments or an error
func parseUnixCommand(cmd string) ([]string, error) {
return shlex.Split(cmd)
}
// parseWindowsCommand parses windows command lines and
// returns the command and the arguments as an array. It
// should be able to parse commonly used command lines.
// Only basic syntax is supported:
// - spaces in double quotes are not token delimiters
// - double quotes are escaped by either backspace or another double quote
// - except for the above case backspaces are path separators (not special)
//
// Many sources point out that escaping quotes using backslash can be unsafe.
// Use two double quotes when possible. (Source: http://stackoverflow.com/a/31413730/2616179 )
//
// This function has to be used on Windows instead
// of the shlex package because this function treats backslash
// characters properly.
func parseWindowsCommand(cmd string) []string {
const backslash = '\\'
const quote = '"'
var parts []string
var part string
var inQuotes bool
var lastRune rune
for i, ch := range cmd {
if i != 0 {
lastRune = rune(cmd[i-1])
}
if ch == backslash {
// put it in the part - for now we don't know if it's an
// escaping char or path separator
part += string(ch)
continue
}
if ch == quote {
if lastRune == backslash {
// remove the backslash from the part and add the escaped quote instead
part = part[:len(part)-1]
part += string(ch)
continue
}
if lastRune == quote {
// revert the last change of the inQuotes state
// it was an escaping quote
inQuotes = !inQuotes
part += string(ch)
continue
}
// normal escaping quotes
inQuotes = !inQuotes
continue
}
if unicode.IsSpace(ch) && !inQuotes && len(part) > 0 {
parts = append(parts, part)
part = ""
continue
}
part += string(ch)
}
if len(part) > 0 {
parts = append(parts, part)
}
return parts
}
-304
View File
@@ -1,304 +0,0 @@
// Copyright 2015 Light Code Labs, LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package runner
import (
"fmt"
"runtime"
"strings"
"testing"
)
func TestParseUnixCommand(t *testing.T) {
tests := []struct {
input string
expected []string
}{
// 0 - empty command
{
input: ``,
expected: []string{},
},
// 1 - command without arguments
{
input: `command`,
expected: []string{`command`},
},
// 2 - command with single argument
{
input: `command arg1`,
expected: []string{`command`, `arg1`},
},
// 3 - command with multiple arguments
{
input: `command arg1 arg2`,
expected: []string{`command`, `arg1`, `arg2`},
},
// 4 - command with single argument with space character - in quotes
{
input: `command "arg1 arg1"`,
expected: []string{`command`, `arg1 arg1`},
},
// 5 - command with multiple spaces and tab character
{
input: "command arg1 arg2\targ3",
expected: []string{`command`, `arg1`, `arg2`, `arg3`},
},
// 6 - command with single argument with space character - escaped with backspace
{
input: `command arg1\ arg2`,
expected: []string{`command`, `arg1 arg2`},
},
// 7 - single quotes should escape special chars
{
input: `command 'arg1\ arg2'`,
expected: []string{`command`, `arg1\ arg2`},
},
}
for i, test := range tests {
errorPrefix := fmt.Sprintf("Test [%d]: ", i)
errorSuffix := fmt.Sprintf(" Command to parse: [%s]", test.input)
actual, _ := parseUnixCommand(test.input)
if len(actual) != len(test.expected) {
t.Errorf(errorPrefix+"Expected %d parts, got %d: %#v."+errorSuffix, len(test.expected), len(actual), actual)
continue
}
for j := 0; j < len(actual); j++ {
if expectedPart, actualPart := test.expected[j], actual[j]; expectedPart != actualPart {
t.Errorf(errorPrefix+"Expected: %v Actual: %v (index %d)."+errorSuffix, expectedPart, actualPart, j)
}
}
}
}
func TestParseWindowsCommand(t *testing.T) {
tests := []struct {
input string
expected []string
}{
{ // 0 - empty command - do not fail
input: ``,
expected: []string{},
},
{ // 1 - cmd without args
input: `cmd`,
expected: []string{`cmd`},
},
{ // 2 - multiple args
input: `cmd arg1 arg2`,
expected: []string{`cmd`, `arg1`, `arg2`},
},
{ // 3 - multiple args with space
input: `cmd "combined arg" arg2`,
expected: []string{`cmd`, `combined arg`, `arg2`},
},
{ // 4 - path without spaces
input: `mkdir C:\Windows\foo\bar`,
expected: []string{`mkdir`, `C:\Windows\foo\bar`},
},
{ // 5 - command with space in quotes
input: `"command here"`,
expected: []string{`command here`},
},
{ // 6 - argument with escaped quotes (two quotes)
input: `cmd ""arg""`,
expected: []string{`cmd`, `"arg"`},
},
{ // 7 - argument with escaped quotes (backslash)
input: `cmd \"arg\"`,
expected: []string{`cmd`, `"arg"`},
},
{ // 8 - two quotes (escaped) inside an inQuote element
input: `cmd "a ""quoted value"`,
expected: []string{`cmd`, `a "quoted value`},
},
// TODO - see how many quotes are displayed if we use "", """, """""""
{ // 9 - two quotes outside an inQuote element
input: `cmd a ""quoted value`,
expected: []string{`cmd`, `a`, `"quoted`, `value`},
},
{ // 10 - path with space in quotes
input: `mkdir "C:\directory name\foobar"`,
expected: []string{`mkdir`, `C:\directory name\foobar`},
},
{ // 11 - space without quotes
input: `mkdir C:\ space`,
expected: []string{`mkdir`, `C:\`, `space`},
},
{ // 12 - space in quotes
input: `mkdir "C:\ space"`,
expected: []string{`mkdir`, `C:\ space`},
},
{ // 13 - UNC
input: `mkdir \\?\C:\Users`,
expected: []string{`mkdir`, `\\?\C:\Users`},
},
{ // 14 - UNC with space
input: `mkdir "\\?\C:\Program Files"`,
expected: []string{`mkdir`, `\\?\C:\Program Files`},
},
{ // 15 - unclosed quotes - treat as if the path ends with quote
input: `mkdir "c:\Program files`,
expected: []string{`mkdir`, `c:\Program files`},
},
{ // 16 - quotes used inside the argument
input: `mkdir "c:\P"rogra"m f"iles`,
expected: []string{`mkdir`, `c:\Program files`},
},
}
for i, test := range tests {
errorPrefix := fmt.Sprintf("Test [%d]: ", i)
errorSuffix := fmt.Sprintf(" Command to parse: [%s]", test.input)
actual := parseWindowsCommand(test.input)
if len(actual) != len(test.expected) {
t.Errorf(errorPrefix+"Expected %d parts, got %d: %#v."+errorSuffix, len(test.expected), len(actual), actual)
continue
}
for j := 0; j < len(actual); j++ {
if expectedPart, actualPart := test.expected[j], actual[j]; expectedPart != actualPart {
t.Errorf(errorPrefix+"Expected: %v Actual: %v (index %d)."+errorSuffix, expectedPart, actualPart, j)
}
}
}
}
func TestSplitCommandAndArgs(t *testing.T) {
// force linux parsing. It's more robust and covers error cases
runtimeGoos = osLinux
defer func() {
runtimeGoos = runtime.GOOS
}()
var parseErrorContent = "error parsing command:"
var noCommandErrContent = "no command contained in"
tests := []struct {
input string
expectedCommand string
expectedArgs []string
expectedErrContent string
}{
// 0 - empty command
{
input: ``,
expectedCommand: ``,
expectedArgs: nil,
expectedErrContent: noCommandErrContent,
},
// 1 - command without arguments
{
input: `command`,
expectedCommand: `command`,
expectedArgs: nil,
expectedErrContent: ``,
},
// 2 - command with single argument
{
input: `command arg1`,
expectedCommand: `command`,
expectedArgs: []string{`arg1`},
expectedErrContent: ``,
},
// 3 - command with multiple arguments
{
input: `command arg1 arg2`,
expectedCommand: `command`,
expectedArgs: []string{`arg1`, `arg2`},
expectedErrContent: ``,
},
// 4 - command with unclosed quotes
{
input: `command "arg1 arg2`,
expectedCommand: "",
expectedArgs: nil,
expectedErrContent: parseErrorContent,
},
// 5 - command with unclosed quotes
{
input: `command 'arg1 arg2"`,
expectedCommand: "",
expectedArgs: nil,
expectedErrContent: parseErrorContent,
},
}
for i, test := range tests {
errorPrefix := fmt.Sprintf("Test [%d]: ", i)
errorSuffix := fmt.Sprintf(" Command to parse: [%s]", test.input)
actualCommand, actualArgs, actualErr := SplitCommandAndArgs(test.input)
// test if error matches expectation
if test.expectedErrContent != "" {
if actualErr == nil {
t.Errorf(errorPrefix+"Expected error with content [%s], found no error."+errorSuffix, test.expectedErrContent)
} else if !strings.Contains(actualErr.Error(), test.expectedErrContent) {
t.Errorf(errorPrefix+"Expected error with content [%s], found [%v]."+errorSuffix, test.expectedErrContent, actualErr)
}
} else if actualErr != nil {
t.Errorf(errorPrefix+"Expected no error, found [%v]."+errorSuffix, actualErr)
}
// test if command matches
if test.expectedCommand != actualCommand {
t.Errorf(errorPrefix+"Expected command: [%s], actual: [%s]."+errorSuffix, test.expectedCommand, actualCommand)
}
// test if arguments match
if len(test.expectedArgs) != len(actualArgs) {
t.Errorf(errorPrefix+"Wrong number of arguments! Expected [%v], actual [%v]."+errorSuffix, test.expectedArgs, actualArgs)
} else {
// test args only if the count matches.
for j, actualArg := range actualArgs {
expectedArg := test.expectedArgs[j]
if actualArg != expectedArg {
t.Errorf(errorPrefix+"Argument at position [%d] differ! Expected [%s], actual [%s]"+errorSuffix, j, expectedArg, actualArg)
}
}
}
}
}
func ExampleSplitCommandAndArgs() {
var commandLine string
var command string
var args []string
// just for the test - change GOOS and reset it at the end of the test
runtimeGoos = osWindows
defer func() {
runtimeGoos = runtime.GOOS
}()
commandLine = `mkdir /P "C:\Program Files"`
command, args, _ = SplitCommandAndArgs(commandLine)
fmt.Printf("Windows: %s: %s [%s]\n", commandLine, command, strings.Join(args, ","))
// set GOOS to linux
runtimeGoos = osLinux
commandLine = `mkdir -p /path/with\ space`
command, args, _ = SplitCommandAndArgs(commandLine)
fmt.Printf("Linux: %s: %s [%s]\n", commandLine, command, strings.Join(args, ","))
// Output:
// Windows: mkdir /P "C:\Program Files": mkdir [/P,C:\Program Files]
// Linux: mkdir -p /path/with\ space: mkdir [-p,/path/with space]
}
-33
View File
@@ -1,33 +0,0 @@
package runner
import (
"os/exec"
"github.com/gtsteffaniak/filebrowser/backend/settings"
)
// ParseCommand parses the command taking in account if the current
// instance uses a shell to run the commands or just calls the binary
// directyly.
func ParseCommand(s *settings.Settings, raw string) ([]string, error) {
var command []string
if len(s.Shell) == 0 {
cmd, args, err := SplitCommandAndArgs(raw)
if err != nil {
return nil, err
}
_, err = exec.LookPath(cmd)
if err != nil {
return nil, err
}
command = append(command, cmd)
command = append(command, args...)
} else {
command = append(s.Shell, raw) //nolint:gocritic
}
return command, nil
}
-120
View File
@@ -1,120 +0,0 @@
package runner
import (
"fmt"
"log"
"os"
"os/exec"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
// Runner is a commands runner.
type Runner struct {
Enabled bool
*settings.Settings
}
// RunHook runs the hooks for the before and after event.
func (r *Runner) RunHook(fn func() error, evt, path, dst string, user *users.User) error {
idx := files.GetIndex("default")
path, _, _ = idx.GetRealPath(user.Scope, path)
dst, _, _ = idx.GetRealPath(user.Scope, dst)
if r.Enabled {
if val, ok := r.Settings.Commands["before_"+evt]; ok {
for _, command := range val {
err := r.exec(command, "before_"+evt, path, dst, user)
if err != nil {
return err
}
}
}
}
err := fn()
if err != nil {
return err
}
if r.Enabled {
if val, ok := r.Settings.Commands["after_"+evt]; ok {
for _, command := range val {
err := r.exec(command, "after_"+evt, path, dst, user)
if err != nil {
return err
}
}
}
}
return nil
}
func (r *Runner) exec(raw, evt, path, dst string, user *users.User) error {
blocking := true
if strings.HasSuffix(raw, "&") {
blocking = false
raw = strings.TrimSpace(strings.TrimSuffix(raw, "&"))
}
command, err := ParseCommand(r.Settings, raw)
if err != nil {
return err
}
envMapping := func(key string) string {
switch key {
case "FILE":
return path
case "SCOPE":
return user.Scope
case "TRIGGER":
return evt
case "USERNAME":
return user.Username
case "DESTINATION":
return dst
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:]...) //nolint:gosec
cmd.Env = append(os.Environ(), fmt.Sprintf("FILE=%s", path))
cmd.Env = append(cmd.Env, fmt.Sprintf("SCOPE=%s", user.Scope)) //nolint:gocritic
cmd.Env = append(cmd.Env, fmt.Sprintf("TRIGGER=%s", evt))
cmd.Env = append(cmd.Env, fmt.Sprintf("USERNAME=%s", user.Username))
cmd.Env = append(cmd.Env, fmt.Sprintf("DESTINATION=%s", dst))
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if !blocking {
log.Printf("Nonblocking Command: \"%s\"", strings.Join(command, " "))
defer func() {
go func() {
err := cmd.Wait()
if err != nil {
log.Printf("Nonblocking Command \"%s\" failed: %s", strings.Join(command, " "), err)
}
}()
}()
return cmd.Start()
}
log.Printf("Blocking Command: \"%s\"", strings.Join(command, " "))
return cmd.Run()
}
+227 -88
View File
@@ -5,85 +5,31 @@ import (
"log"
"os"
"path/filepath"
"slices"
"strings"
yaml "github.com/goccy/go-yaml"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
"github.com/gtsteffaniak/filebrowser/backend/version"
)
var Config Settings
func Initialize(configFile string) {
yamlData, err := loadConfigFile(configFile)
if err != nil && configFile != "config.yaml" {
logger.Fatal("Could not load specified config file: " + err.Error())
}
err := loadConfigWithDefaults(configFile)
if err != nil {
logger.Warning(fmt.Sprintf("Could not load config file '%v', using default settings: %v", configFile, err))
}
Config = setDefaults()
err = yaml.Unmarshal(yamlData, &Config)
if err != nil {
logger.Fatal(fmt.Sprintf("Error unmarshaling YAML data: %v", err))
}
if len(Config.Server.Logging) == 0 {
Config.Server.Logging = []LogConfig{
{
Output: "stdout",
},
}
}
for _, logConfig := range Config.Server.Logging {
err = logger.SetupLogger(
logConfig.Output,
logConfig.Levels,
logConfig.ApiLevels,
logConfig.NoColors,
)
if err != nil {
log.Println("[ERROR] Failed to set up logger:", err)
}
}
if Config.Auth.Method != "" {
logger.Warning("The `auth.method` setting is deprecated and will be removed in a future version. Please use `auth.methods` instead.")
}
Config.UserDefaults.Perm = Config.UserDefaults.Permissions
// Convert relative path to absolute path
if len(Config.Server.Sources) > 0 {
if Config.Server.Root != "" {
logger.Warning("`server.root` is configured but will be ignored in favor of `server.sources`")
}
// TODO allow multiple sources not named default
for _, source := range Config.Server.Sources {
realPath, err2 := filepath.Abs(source.Path)
if err2 != nil {
logger.Fatal(fmt.Sprintf("Error getting source path: %v", err2))
}
source.Path = realPath
source.Name = "default"
Config.Server.Sources = []Source{source} // temporary set only one source
}
} else {
realPath, err2 := filepath.Abs(Config.Server.Root)
if err2 != nil {
logger.Fatal(fmt.Sprintf("Error getting source path: %v", err2))
}
Config.Server.Sources = []Source{
{
Name: "default",
Path: realPath,
},
}
logger.Fatal(err.Error())
}
setupLogging()
setupAuth()
setupSources()
setupBaseURL()
setupFrontend()
}
baseurl := strings.Trim(Config.Server.BaseURL, "/")
if baseurl == "" {
Config.Server.BaseURL = "/"
} else {
Config.Server.BaseURL = "/" + baseurl + "/"
}
func setupFrontend() {
if !Config.Frontend.DisableDefaultLinks {
Config.Frontend.ExternalLinks = append(Config.Frontend.ExternalLinks, ExternalLink{
Text: fmt.Sprintf("(%v)", version.Version),
@@ -97,25 +43,153 @@ func Initialize(configFile string) {
}
}
func loadConfigFile(configFile string) ([]byte, error) {
func getRealPath(path string) string {
realPath, err := filepath.Abs(path)
if err != nil {
logger.Fatal(fmt.Sprintf("could not find configured source path: %v", err))
}
// check path exists
if _, err = os.Stat(realPath); os.IsNotExist(err) {
logger.Fatal(fmt.Sprintf("configured source path does not exist: %v", realPath))
}
return realPath
}
func setupSources() {
if len(Config.Server.Sources) == 0 {
logger.Fatal("There are no `server.sources` configured. If you have `server.root` configured, please update the config and add at least one `server.sources` with a `path` configured.")
} else {
for k, source := range Config.Server.Sources {
realPath := getRealPath(source.Path)
name := utils.GetLastComponent(realPath)
source.Path = realPath // use absolute path
if source.Name == "" {
_, ok := Config.Server.SourceMap[source.Path]
if ok {
source.Name = name + fmt.Sprintf("-%v", k)
} else {
source.Name = name
}
if Config.Server.DefaultSource.Path == "" {
Config.Server.DefaultSource = source
}
}
Config.Server.SourceMap[source.Path] = source
Config.Server.NameToSource[source.Name] = source
}
}
// clean up the in memory source list to be accurate and unique
sourceList := []Source{}
defaultScopes := []users.SourceScope{}
allSourceNames := []string{}
first := true
for _, sourcePathOnly := range Config.Server.Sources {
realPath := getRealPath(sourcePathOnly.Path)
source, ok := Config.Server.SourceMap[realPath]
if ok && !slices.Contains(allSourceNames, source.Name) {
if first {
source.Config.DefaultEnabled = true
Config.Server.SourceMap[source.Path] = source
Config.Server.NameToSource[source.Name] = source
Config.Server.DefaultSource = source
}
first = false
sourceList = append(sourceList, source)
if source.Config.DefaultEnabled {
Config.Server.DefaultSource = source
defaultScopes = append(defaultScopes, users.SourceScope{
Name: source.Path,
Scope: source.Config.DefaultUserScope,
})
}
allSourceNames = append(allSourceNames, source.Name)
} else {
logger.Warning(fmt.Sprintf("source %v is not configured correctly, skipping", sourcePathOnly.Path))
}
}
Config.UserDefaults.DefaultScopes = defaultScopes
Config.Server.Sources = sourceList
}
func setupBaseURL() {
baseurl := strings.Trim(Config.Server.BaseURL, "/")
if baseurl == "" {
Config.Server.BaseURL = "/"
} else {
Config.Server.BaseURL = "/" + baseurl + "/"
}
}
func setupAuth() {
if Config.Auth.Method != "" {
logger.Warning("The `auth.method` setting is deprecated and will be removed in a future version. Please use `auth.methods` instead.")
}
Config.UserDefaults.Perm = Config.UserDefaults.Permissions
if Config.Auth.Methods.PasswordAuth.Enabled {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "Password")
}
if Config.Auth.Methods.ProxyAuth.Enabled {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "Proxy")
}
if Config.Auth.Methods.NoAuth {
logger.Warning("Configured with no authentication, this is not recommended.")
Config.Auth.AuthMethods = []string{"Disabled"}
}
// use password auth as default if no auth methods are set
if len(Config.Auth.AuthMethods) == 0 {
Config.Auth.Methods.PasswordAuth.Enabled = true
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "Password")
}
}
func setupLogging() {
if len(Config.Server.Logging) == 0 {
Config.Server.Logging = []LogConfig{
{
Output: "stdout",
},
}
}
for _, logConfig := range Config.Server.Logging {
err := logger.SetupLogger(
logConfig.Output,
logConfig.Levels,
logConfig.ApiLevels,
logConfig.NoColors,
)
if err != nil {
log.Println("[ERROR] Failed to set up logger:", err)
}
}
}
func loadConfigWithDefaults(configFile string) error {
// Open and read the YAML file
yamlFile, err := os.Open(configFile)
if err != nil {
return nil, err
return err
}
defer yamlFile.Close()
stat, err := yamlFile.Stat()
if err != nil {
return nil, err
return err
}
yamlData := make([]byte, stat.Size())
_, err = yamlFile.Read(yamlData)
if err != nil {
return nil, err
if err != nil && configFile != "config.yaml" {
return fmt.Errorf("could not load specified config file: " + err.Error())
}
return yamlData, nil
if err != nil {
logger.Warning(fmt.Sprintf("Could not load config file '%v', using default settings: %v", configFile, err))
}
Config = setDefaults()
err = yaml.Unmarshal(yamlData, &Config)
if err != nil {
return fmt.Errorf("error unmarshaling YAML data: %v", err)
}
return nil
}
func setDefaults() Settings {
@@ -128,7 +202,8 @@ func setDefaults() Settings {
NumImageProcessors: 4,
BaseURL: "",
Database: "database.db",
Root: ".",
SourceMap: map[string]Source{},
NameToSource: map[string]Source{},
},
Auth: Auth{
AdminUsername: "admin",
@@ -139,14 +214,7 @@ func setDefaults() Settings {
Host: "",
},
Methods: LoginMethods{
ProxyAuth: ProxyAuthConfig{
Enabled: false,
CreateUser: false,
Header: "",
},
NoAuth: false,
PasswordAuth: PasswordAuthConfig{
Enabled: true,
MinLength: 5,
},
},
@@ -157,7 +225,6 @@ func setDefaults() Settings {
UserDefaults: UserDefaults{
DisableOnlyOfficeExt: ".txt .csv .html",
StickySidebar: true,
Scope: ".",
LockPassword: false,
ShowHidden: false,
DarkMode: true,
@@ -166,15 +233,87 @@ func setDefaults() Settings {
Locale: "en",
GallerySize: 3,
Permissions: users.Permissions{
Create: false,
Rename: false,
Modify: false,
Delete: false,
Share: false,
Download: false,
Admin: false,
Api: false,
Modify: false,
Share: false,
Admin: false,
Api: false,
},
},
}
}
func ConvertToBackendScopes(scopes []users.SourceScope) ([]users.SourceScope, error) {
if len(scopes) == 0 {
return Config.UserDefaults.DefaultScopes, nil
}
newScopes := []users.SourceScope{}
for _, scope := range scopes {
// first check if its already a path name and keep it
source, ok := Config.Server.SourceMap[scope.Name]
if ok {
newScopes = append(newScopes, users.SourceScope{
Name: source.Path, // backend name is path
Scope: scope.Scope,
})
continue
}
// check if its the name of a source and convert it to a path
source, ok = Config.Server.NameToSource[scope.Name]
if !ok {
return newScopes, fmt.Errorf("invalid scope for source %v", scope.Name)
}
newScopes = append(newScopes, users.SourceScope{
Name: source.Path, // backend name is path
Scope: scope.Scope,
})
}
return newScopes, nil
}
func ConvertToFrontendScopes(scopes []users.SourceScope) []users.SourceScope {
newScopes := make([]users.SourceScope, 0, len(scopes)) // Preserve original order
for _, scope := range scopes {
if source, ok := Config.Server.SourceMap[scope.Name]; ok {
// Replace scope.Name with source.Path while keeping the same Scope value
newScopes = append(newScopes, users.SourceScope{
Name: source.Name,
Scope: scope.Scope,
})
}
}
return newScopes
}
func HasSourceByPath(scopes []users.SourceScope, sourcePath string) bool {
for _, scope := range scopes {
if scope.Name == sourcePath {
return true
}
}
return false
}
func GetScopeFromSourceName(scopes []users.SourceScope, sourceName string) (string, error) {
source, ok := Config.Server.NameToSource[sourceName]
if !ok {
logger.Debug(fmt.Sprint("Could not get scope from source name: ", sourceName))
return "", fmt.Errorf("source with name not found %v", sourceName)
}
for _, scope := range scopes {
if scope.Name == source.Path {
return scope.Scope, nil
}
}
logger.Debug(fmt.Sprintf("scope not found for source %v", sourceName))
return "", fmt.Errorf("scope not found for source %v", sourceName)
}
func GetScopeFromSourcePath(scopes []users.SourceScope, sourcePath string) (string, error) {
for _, scope := range scopes {
if scope.Name == sourcePath {
return scope.Scope, nil
}
}
return "", fmt.Errorf("scope not found for source %v", sourcePath)
}
-20
View File
@@ -22,26 +22,6 @@ func TestInitialize(t *testing.T) {
}
}
func Test_loadConfigFile(t *testing.T) {
type args struct {
configFile string
}
tests := []struct {
name string
args args
want []byte
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got, _ := loadConfigFile(tt.args.configFile); !reflect.DeepEqual(got, tt.want) {
t.Errorf("loadConfigFile() = %v, want %v", got, tt.want)
}
})
}
}
func Test_setDefaults(t *testing.T) {
tests := []struct {
name string
-81
View File
@@ -1,81 +0,0 @@
package settings
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
func TestSettings_MakeUserDir(t *testing.T) {
type fields struct {
Signup bool
CreateUserDir bool
UserHomeBasePath string
Commands map[string][]string
Shell []string
AdminUsername string
AdminPassword string
Rules []users.Rule
Server Server
Auth Auth
Frontend Frontend
Users []UserDefaults
UserDefaults UserDefaults
}
type args struct {
username string
userScope string
serverRoot string
}
tests := []struct {
name string
fields fields
args args
want string
wantErr bool
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &Settings{
Commands: tt.fields.Commands,
Shell: tt.fields.Shell,
Rules: tt.fields.Rules,
Server: tt.fields.Server,
Auth: tt.fields.Auth,
Frontend: tt.fields.Frontend,
Users: tt.fields.Users,
UserDefaults: tt.fields.UserDefaults,
}
got, err := s.MakeUserDir(tt.args.username, tt.args.userScope, tt.args.serverRoot)
if (err != nil) != tt.wantErr {
t.Errorf("Settings.MakeUserDir() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("Settings.MakeUserDir() = %v, want %v", got, tt.want)
}
})
}
}
func Test_cleanUsername(t *testing.T) {
type args struct {
s string
}
tests := []struct {
name string
args args
want string
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := cleanUsername(tt.args.s); got != tt.want {
t.Errorf("cleanUsername() = %v, want %v", got, tt.want)
}
})
}
}
+11 -13
View File
@@ -29,30 +29,28 @@ func GetSettingsConfig(nameType string, Value string) string {
func AdminPerms() users.Permissions {
return users.Permissions{
Create: true,
Rename: true,
Modify: true,
Delete: true,
Share: true,
Download: true,
Admin: true,
Api: true,
Modify: true,
Share: true,
Admin: true,
Api: true,
}
}
// Apply applies the default options to a user.
func ApplyUserDefaults(u users.User) users.User {
func ApplyUserDefaults(u *users.User) {
u.StickySidebar = Config.UserDefaults.StickySidebar
u.DisableSettings = Config.UserDefaults.DisableSettings
u.DarkMode = Config.UserDefaults.DarkMode
u.Scope = Config.UserDefaults.Scope
u.Locale = Config.UserDefaults.Locale
u.ViewMode = Config.UserDefaults.ViewMode
u.SingleClick = Config.UserDefaults.SingleClick
u.Perm = Config.UserDefaults.Perm
u.Sorting = Config.UserDefaults.Sorting
u.Commands = Config.UserDefaults.Commands
u.ShowHidden = Config.UserDefaults.ShowHidden
u.DateFormat = Config.UserDefaults.DateFormat
return u
u.Scopes = []users.SourceScope{
{
Scope: Config.Server.DefaultSource.Config.DefaultUserScope,
Name: Config.Server.DefaultSource.Path,
},
}
}
+8 -19
View File
@@ -1,46 +1,35 @@
package settings
import (
"fmt"
"testing"
yaml "github.com/goccy/go-yaml"
"github.com/google/go-cmp/cmp"
"github.com/gtsteffaniak/filebrowser/backend/logger"
)
func TestConfigLoadChanged(t *testing.T) {
yamlData, _ := loadConfigFile("./testingConfig.yaml")
// Marshal the YAML data to a more human-readable format
newConfig := setDefaults()
Config := setDefaults()
err := yaml.Unmarshal(yamlData, &newConfig)
defaultConfig := setDefaults()
err := loadConfigWithDefaults("./testingConfig.yaml")
if err != nil {
logger.Fatal(fmt.Sprintf("Error unmarshaling YAML data: %v", err))
t.Fatalf("error loading config file: %v", err)
}
// Use go-cmp to compare the two structs
if diff := cmp.Diff(newConfig, Config); diff == "" {
if diff := cmp.Diff(defaultConfig, Config); diff == "" {
t.Errorf("No change when there should have been (-want +got):\n%s", diff)
}
}
func TestConfigLoadSpecificValues(t *testing.T) {
yamlData, _ := loadConfigFile("./testingConfig.yaml")
// Marshal the YAML data to a more human-readable format
newConfig := setDefaults()
Config := setDefaults()
err := yaml.Unmarshal(yamlData, &newConfig)
defaultConfig := setDefaults()
err := loadConfigWithDefaults("./testingConfig.yaml")
if err != nil {
logger.Fatal(fmt.Sprintf("Error unmarshaling YAML data: %v", err))
t.Fatalf("error loading config file: %v", err)
}
testCases := []struct {
fieldName string
globalVal interface{}
newVal interface{}
}{
{"Server.Database", Config.Server.Database, newConfig.Server.Database},
{"Server.Database", Config.Server.Database, defaultConfig.Server.Database},
}
for _, tc := range testCases {
-31
View File
@@ -2,7 +2,6 @@ package settings
import (
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
// StorageBackend is a settings storage backend.
@@ -35,14 +34,6 @@ func (s *Storage) Get() (*Settings, error) {
return set, nil
}
var defaultEvents = []string{
"save",
"copy",
"rename",
"upload",
"delete",
}
// Save saves the settings for the current instance.
func (s *Storage) Save(set *Settings) error {
if len(set.Auth.Key) == 0 {
@@ -53,32 +44,10 @@ func (s *Storage) Save(set *Settings) error {
set.UserDefaults.Locale = "en"
}
if set.UserDefaults.Commands == nil {
set.UserDefaults.Commands = []string{}
}
if set.UserDefaults.ViewMode == "" {
set.UserDefaults.ViewMode = "normal"
}
if set.Rules == nil {
set.Rules = []users.Rule{}
}
if set.Commands == nil {
set.Commands = map[string][]string{}
}
for _, event := range defaultEvents {
if _, ok := set.Commands["before_"+event]; !ok {
set.Commands["before_"+event] = []string{}
}
if _, ok := set.Commands["after_"+event]; !ok {
set.Commands["after_"+event] = []string{}
}
}
err := s.back.Save(set)
if err != nil {
return err
+31 -29
View File
@@ -15,7 +15,6 @@ const (
type Settings struct {
Commands map[string][]string `json:"commands"`
Shell []string `json:"shell"`
Rules []users.Rule `json:"rules"`
Server Server `json:"server"`
Auth Auth `json:"auth"`
Frontend Frontend `json:"frontend"`
@@ -35,6 +34,7 @@ type Auth struct {
Key []byte `json:"key"`
AdminUsername string `json:"adminUsername"`
AdminPassword string `json:"adminPassword"`
AuthMethods []string
}
type LoginMethods struct {
@@ -73,13 +73,16 @@ type Server struct {
BaseURL string `json:"baseURL"`
Logging []LogConfig `json:"logging"`
Database string `json:"database"`
Root string `json:"root"`
Root string `json:"root"` // deprecated, use sources
UserHomeBasePath string `json:"userHomeBasePath"`
CreateUserDir bool `json:"createUserDir"`
Sources []Source `json:"sources"`
ExternalUrl string `json:"externalUrl"`
InternalUrl string `json:"internalUrl"` // used by integrations
CacheDir string `json:"cacheDir"`
// not exposed to config
SourceMap map[string]Source // uses realpath as key
NameToSource map[string]Source // uses name as key
DefaultSource Source
}
type Integrations struct {
@@ -102,12 +105,12 @@ type LogConfig struct {
}
type Source struct {
Path string `json:"path"`
Name string `json:"name"`
Config IndexConfig `json:"config"`
Path string `json:"path"` // can be relative, filesystem path
Name string `json:"name"` // display name
Config SourceConfig `json:"config"`
}
type IndexConfig struct {
type SourceConfig struct {
IndexingInterval uint32 `json:"indexingInterval"`
Disabled bool `json:"disabled"`
MaxWatchers int `json:"maxWatchers"`
@@ -116,6 +119,9 @@ type IndexConfig struct {
IgnoreZeroSizeFolders bool `json:"ignoreZeroSizeFolders"`
Exclude IndexFilter `json:"exclude"`
Include IndexFilter `json:"include"`
DefaultUserScope string `json:"defaultUserScope"` // default "" should match folders under path
DefaultEnabled bool `json:"defaultEnabled"`
CreateUserDir bool `json:"createUserDir"`
}
type IndexFilter struct {
@@ -142,26 +148,22 @@ type ExternalLink struct {
// UserDefaults is a type that holds the default values
// for some fields on User.
type UserDefaults struct {
StickySidebar bool `json:"stickySidebar"`
DarkMode bool `json:"darkMode"`
LockPassword bool `json:"lockPassword"`
DisableSettings bool `json:"disableSettings,omitempty"`
Scope string `json:"scope"`
Locale string `json:"locale"`
ViewMode string `json:"viewMode"`
GallerySize int `json:"gallerySize"`
SingleClick bool `json:"singleClick"`
Rules []users.Rule `json:"rules"`
Sorting struct {
By string `json:"by"`
Asc bool `json:"asc"`
} `json:"sorting"`
Perm users.Permissions `json:"perm"`
Permissions users.Permissions `json:"permissions"`
Commands []string `json:"commands,omitempty"`
ShowHidden bool `json:"showHidden"`
DateFormat bool `json:"dateFormat"`
ThemeColor string `json:"themeColor"`
QuickDownload bool `json:"quickDownload"`
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"`
StickySidebar bool `json:"stickySidebar"`
DarkMode bool `json:"darkMode"`
LockPassword bool `json:"lockPassword"`
DisableSettings bool `json:"disableSettings,omitempty"`
Scope string `json:"scope"` // deprecated
Locale string `json:"locale"`
ViewMode string `json:"viewMode"`
GallerySize int `json:"gallerySize"`
SingleClick bool `json:"singleClick"`
Perm users.Permissions `json:"perm"`
Permissions users.Permissions `json:"permissions"`
Commands []string `json:"commands,omitempty"`
ShowHidden bool `json:"showHidden"`
DateFormat bool `json:"dateFormat"`
ThemeColor string `json:"themeColor"`
QuickDownload bool `json:"quickDownload"`
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"`
DefaultScopes []users.SourceScope `json:"-"`
}
+1
View File
@@ -10,6 +10,7 @@ type CreateBody struct {
type Link struct {
Hash string `json:"hash" storm:"id,index"`
Path string `json:"path" storm:"index"`
Source string `json:"source" storm:"index"`
UserID uint `json:"userID"`
Expire int64 `json:"expire"`
PasswordHash string `json:"password_hash,omitempty"`
+6 -6
View File
@@ -11,8 +11,8 @@ type StorageBackend interface {
All() ([]*Link, error)
FindByUserID(id uint) ([]*Link, error)
GetByHash(hash string) (*Link, error)
GetPermanent(path string, id uint) (*Link, error)
Gets(path string, id uint) ([]*Link, error)
GetPermanent(path, source string, id uint) (*Link, error)
Gets(path, source string, id uint) ([]*Link, error)
Save(s *Link) error
Delete(hash string) error
}
@@ -85,13 +85,13 @@ func (s *Storage) GetByHash(hash string) (*Link, error) {
}
// GetPermanent wraps a StorageBackend.GetPermanent
func (s *Storage) GetPermanent(path string, id uint) (*Link, error) {
return s.back.GetPermanent(path, id)
func (s *Storage) GetPermanent(path, source string, id uint) (*Link, error) {
return s.back.GetPermanent(path, source, id)
}
// Gets wraps a StorageBackend.Gets
func (s *Storage) Gets(path string, id uint) ([]*Link, error) {
links, err := s.back.Gets(path, id)
func (s *Storage) Gets(path, source string, id uint) ([]*Link, error) {
links, err := s.back.Gets(path, source, id)
if err != nil {
return nil, err
+27 -15
View File
@@ -46,27 +46,26 @@ func (s shareBackend) GetByHash(hash string) (*share.Link, error) {
return &v, err
}
func (s shareBackend) GetPermanent(path string, id uint) (*share.Link, error) {
func (s shareBackend) GetPermanent(path, source string, id uint) (*share.Link, error) {
var v share.Link
err := s.db.Select(q.Eq("Path", path), q.Eq("Expire", 0), q.Eq("UserID", id)).First(&v)
if err == storm.ErrNotFound {
return nil, errors.ErrNotExist
}
return &v, err
var legacy []*share.Link
// todo remove legacy and return notfound errors
_ = s.db.Select(q.Eq("Path", path), q.Eq("Source", source), q.Eq("Expire", 0), q.Eq("UserID", id)).First(&v)
_ = s.db.Select(q.Eq("Path", path), q.Eq("Source", ""), q.Eq("UserID", id)).Find(&legacy)
return &v, nil
}
func (s shareBackend) Gets(path string, id uint) ([]*share.Link, error) {
func (s shareBackend) Gets(path, source string, id uint) ([]*share.Link, error) {
var v []*share.Link
err := s.db.Select(q.Eq("Path", path), q.Eq("UserID", id)).Find(&v)
if err == storm.ErrNotFound {
return v, errors.ErrNotExist
}
var legacy []*share.Link
// todo remove legacy and return notfound errors
_ = s.db.Select(q.Eq("Path", path), q.Eq("Source", source), q.Eq("UserID", id)).Find(&v)
_ = s.db.Select(q.Eq("Path", path), q.Eq("Source", ""), q.Eq("UserID", id)).Find(&legacy)
filteredList := []*share.Link{}
// automatically delete and clear expired shares
var err error
// through and filter out expired share
for i := range v {
if v[i].Expire < time.Now().Unix() {
if v[i].Expire < time.Now().Unix() && v[i].Expire != 0 {
err = s.Delete(v[i].PasswordHash)
if err != nil {
logger.Error(fmt.Sprintf("expired share could not be deleted: %v", err.Error()))
@@ -76,6 +75,19 @@ func (s shareBackend) Gets(path string, id uint) ([]*share.Link, error) {
}
}
// automatically delete and clear expired shares
// todo remove after some time.
for i := range legacy {
if legacy[i].Expire < time.Now().Unix() && legacy[i].Expire != 0 {
err = s.Delete(legacy[i].PasswordHash)
if err != nil {
logger.Error(fmt.Sprintf("expired share could not be deleted: %v", err.Error()))
}
} else {
filteredList = append(filteredList, legacy[i])
}
}
return filteredList, err
}
+117 -18
View File
@@ -3,6 +3,7 @@ package bolt
import (
"fmt"
"reflect"
"slices"
storm "github.com/asdine/storm/v3"
@@ -58,37 +59,68 @@ func (st usersBackend) Gets() ([]*users.User, error) {
return allUsers, err
}
func (st usersBackend) Update(user *users.User, fields ...string) error {
if len(fields) == 0 {
return st.Save(user)
func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...string) error {
existingUser, err := st.GetBy(user.ID)
if err != nil {
return err
}
val := reflect.ValueOf(user).Elem()
fields, err = parseFields(user, fields)
if err != nil {
return err
}
if !actorIsAdmin {
err := checkRestrictedFields(existingUser, fields)
if err != nil {
return err
}
}
if len(fields) == 0 {
return fmt.Errorf("no fields to update")
}
// converting scopes to map of paths intead of names (names can change)
if slices.Contains(fields, "scopes") || slices.Contains(fields, "Scopes") {
adjustedScopes, err := settings.ConvertToBackendScopes(user.Scopes)
if err != nil {
return err
}
user.Scopes = adjustedScopes
}
// Use reflection to access struct fields
userFields := reflect.ValueOf(user).Elem() // Get struct value
for _, field := range fields {
// Capitalize the first letter (you can adjust this based on your field naming convention)
correctedField := utils.CapitalizeFirst(field)
userField := val.FieldByName(correctedField)
if !userField.IsValid() {
return fmt.Errorf("invalid field: %s", field)
}
if !userField.CanSet() {
return fmt.Errorf("cannot update unexported field: %s", field)
// Get the corresponding field using reflection
fieldValue := userFields.FieldByName(correctedField)
if !fieldValue.IsValid() {
return fmt.Errorf("invalid field: %s", correctedField)
}
val := userField.Interface()
// Ensure the field is settable
if !fieldValue.CanSet() {
return fmt.Errorf("cannot set value of field: %s", correctedField)
}
// Get the value to be stored
val := fieldValue.Interface()
// Update the database
if err := st.db.UpdateField(user, correctedField, val); err != nil {
return fmt.Errorf("Error updating user field: %s, error: %v", correctedField, err.Error())
return fmt.Errorf("failed to update user field: %s, error: %v", correctedField, err)
}
}
return nil
}
func (st usersBackend) Save(user *users.User) error {
if settings.Config.Auth.Methods.PasswordAuth.Enabled {
if len(user.Password) < settings.Config.Auth.Methods.PasswordAuth.MinLength {
return fmt.Errorf("password must be at least %d characters long", settings.Config.Auth.Methods.PasswordAuth.MinLength)
func (st usersBackend) Save(user *users.User, changePass bool) error {
if settings.Config.Auth.Methods.PasswordAuth.Enabled && changePass {
err := checkPassword(user.Password)
if err != nil {
return err
}
pass, err := users.HashPwd(user.Password)
if err != nil {
@@ -96,7 +128,15 @@ func (st usersBackend) Save(user *users.User) error {
}
user.Password = pass
}
err := st.db.Save(user)
// converting scopes to map of paths intead of names (names can change)
adjustedScopes, err := settings.ConvertToBackendScopes(user.Scopes)
if err != nil {
return err
}
user.Scopes = adjustedScopes
err = st.db.Save(user)
if err == storm.ErrAlreadyExists {
return errors.ErrExist
}
@@ -115,3 +155,62 @@ func (st usersBackend) DeleteByUsername(username string) error {
return st.db.DeleteStruct(user)
}
// Define a function to check for restricted fields
func checkRestrictedFields(existingUser *users.User, fields []string) error {
// Check if 'password' field is locked
if existingUser.LockPassword && slices.Contains(fields, "password") {
return fmt.Errorf("password is locked")
}
// Use reflection to get the field names of NonAdminEditable
editableFields := reflect.ValueOf(existingUser.NonAdminEditable)
if editableFields.Kind() == reflect.Struct {
for i := 0; i < editableFields.NumField(); i++ {
fieldName := editableFields.Type().Field(i).Name
if slices.Contains(fields, fieldName) {
return fmt.Errorf("non-admins cannot modify field: %s", fieldName)
}
}
}
// No restricted fields found, return nil (no error)
return nil
}
func parseFields(user *users.User, fields []string) ([]string, error) {
// If `Which` is not specified, default to updating all fields
if len(fields) == 0 || fields[0] == "all" {
fields = []string{}
v := reflect.ValueOf(user)
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
t := v.Type()
// Dynamically populate fields to update
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if (field.Name == "password" && user.Password != "") || field.Name != "password" {
fields = append(fields, field.Name)
}
}
} else {
if slices.Contains(fields, "password") && user.Password != "" {
if settings.Config.Auth.Methods.PasswordAuth.Enabled {
pass, err := users.HashPwd(user.Password)
if err != nil {
return fields, err
}
user.Password = pass
}
}
}
return fields, nil
}
func checkPassword(password string) error {
if len(password) < settings.Config.Auth.Methods.PasswordAuth.MinLength {
return fmt.Errorf("password must be at least %d characters long", settings.Config.Auth.Methods.PasswordAuth.MinLength)
}
return nil
}
+20 -24
View File
@@ -41,7 +41,7 @@ func InitializeDb(path string) (*Storage, bool, error) {
return nil, exists, err
}
err = bolt.Save(db, "version", 2) //nolint:gomnd
err = bolt.Save(db, "version", 2)
if err != nil {
return nil, exists, err
}
@@ -84,49 +84,45 @@ func quickSetup(store *Storage) {
utils.CheckErr("store.Settings.Save", err)
err = store.Settings.SaveServer(&settings.Config.Server)
utils.CheckErr("store.Settings.SaveServer", err)
user := settings.ApplyUserDefaults(users.User{})
user := &users.User{}
settings.ApplyUserDefaults(user)
user.Username = settings.Config.Auth.AdminUsername
user.Password = settings.Config.Auth.AdminPassword
user.Perm.Admin = true
user.Scope = "./"
user.Scopes = []users.SourceScope{}
for _, val := range settings.Config.Server.Sources {
user.Scopes = append(user.Scopes, users.SourceScope{
Name: val.Path, // backend name is path
Scope: "",
})
}
user.DarkMode = true
user.ViewMode = "normal"
user.LockPassword = false
user.Perm = settings.AdminPerms()
err = store.Users.Save(&user)
logger.Debug(fmt.Sprintf("Creating user as admin: %v %v\n", user.Username, user.Password))
err = store.Users.Save(user, true)
utils.CheckErr("store.Users.Save", err)
}
// create new user
func CreateUser(userInfo users.User, asAdmin bool) error {
newUser := &userInfo
// must have username or password to create
if userInfo.Username == "" || userInfo.Password == "" {
return errors.ErrInvalidRequestParams
}
newUser := settings.ApplyUserDefaults(userInfo)
settings.ApplyUserDefaults(newUser)
if asAdmin {
newUser.Perm = settings.AdminPerms()
userInfo.Perm = settings.AdminPerms()
}
// create new home directory
userHome, err := settings.Config.MakeUserDir(newUser.Username, newUser.Scope, files.RootPaths["default"])
if err != nil {
logger.Error(fmt.Sprintf("create user: failed to mkdir user home dir: [%s]", userHome))
return err
if len(userInfo.Scopes) == 0 {
userInfo.Scopes = settings.Config.UserDefaults.DefaultScopes
}
newUser.Scope = userHome
logger.Debug(fmt.Sprintf("user: %s, home dir: [%s].", newUser.Username, userHome))
// create new home directories
files.MakeUserDirs(newUser)
// todo: fix this, requries index path to be set
idx := files.GetIndex("default")
if idx == nil {
idx = files.GetIndex("default")
}
_, _, err = idx.GetRealPath(newUser.Scope)
if err != nil {
logger.Error(fmt.Sprintf("user path is not valid: %v", newUser.Scope))
return nil
}
err = store.Users.Save(&newUser)
err := store.Users.Save(newUser, true)
if err != nil {
return err
}
+498 -91
View File
@@ -15,6 +15,228 @@ const docTemplate = `{
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/api/createApiKey": {
"post": {
"description": "Create an API key with specified name, duration, and permissions.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"API Keys"
],
"summary": "Create API key",
"parameters": [
{
"type": "string",
"description": "Name of the API key",
"name": "name",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Duration of the API key in days",
"name": "days",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Permissions for the API key (comma-separated)",
"name": "permissions",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "Token created successfully, resoponse contains json object with token key",
"schema": {
"$ref": "#/definitions/http.HttpResponse"
}
},
"400": {
"description": "Bad request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"409": {
"description": "Conflict",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/deleteApiKey": {
"delete": {
"description": "Delete an API key with specified name.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"API Keys"
],
"summary": "Delete API key",
"parameters": [
{
"type": "string",
"description": "Name of the API key to delete",
"name": "name",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "API key deleted successfully",
"schema": {
"$ref": "#/definitions/http.HttpResponse"
}
},
"404": {
"description": "Not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/listApiKeys": {
"get": {
"description": "List all API keys or retrieve details for a specific key.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"API Keys"
],
"summary": "List API keys",
"parameters": [
{
"type": "string",
"description": "Name of the API to retrieve details",
"name": "name",
"in": "query"
}
],
"responses": {
"200": {
"description": "List of API keys or specific key details",
"schema": {
"$ref": "#/definitions/http.AuthTokenMin"
}
},
"404": {
"description": "Not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/login": {
"post": {
"description": "Authenticate a user with a username and password.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "User login",
"responses": {
"200": {
"description": "JWT token for authentication",
"schema": {
"type": "string"
}
},
"403": {
"description": "Forbidden - authentication failed",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/preview": {
"get": {
"description": "Returns a preview image based on the requested path and size.",
@@ -114,7 +336,7 @@ const docTemplate = `{
"parameters": [
{
"type": "string",
"description": "Comma-separated list of specific files within the directory (required)",
"description": "a list of files in the following format 'source::filename' and separated by '||' with additional items in the list. (required)",
"name": "files",
"in": "query",
"required": true
@@ -140,7 +362,7 @@ const docTemplate = `{
}
},
"202": {
"description": "Download permissions required",
"description": "Modify permissions required",
"schema": {
"type": "object",
"additionalProperties": {
@@ -178,6 +400,47 @@ const docTemplate = `{
}
}
},
"/api/renew": {
"post": {
"description": "Refresh the authentication token for a logged-in user.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Renew authentication token",
"responses": {
"200": {
"description": "New JWT token generated",
"schema": {
"type": "string"
}
},
"401": {
"description": "Unauthorized - invalid token",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/resources": {
"get": {
"description": "Returns metadata and optionally file contents for a specified resource path.",
@@ -627,7 +890,7 @@ const docTemplate = `{
},
"/api/settings": {
"get": {
"description": "Returns the current configuration settings for signup, user directories, rules, frontend, and commands.",
"description": "Returns the current configuration settings for signup, user directories, rules, frontend.",
"consumes": [
"application/json"
],
@@ -638,6 +901,14 @@ const docTemplate = `{
"Settings"
],
"summary": "Get system settings",
"parameters": [
{
"type": "string",
"description": "Property to retrieve",
"name": "property",
"in": "query"
}
],
"responses": {
"200": {
"description": "System settings data",
@@ -648,7 +919,7 @@ const docTemplate = `{
}
},
"put": {
"description": "Updates the system configuration settings for signup, user directories, rules, frontend, and commands.",
"description": "Updates the system configuration settings for signup, user directories, rules, frontend.",
"consumes": [
"application/json"
],
@@ -715,6 +986,13 @@ const docTemplate = `{
"name": "path",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name for share links",
"name": "source",
"in": "query",
"required": true
}
],
"responses": {
@@ -794,6 +1072,20 @@ const docTemplate = `{
"schema": {
"$ref": "#/definitions/share.CreateBody"
}
},
{
"type": "string",
"description": "Source Path of the files to share",
"name": "path",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Source name of the files to share",
"name": "source",
"in": "path",
"required": true
}
],
"responses": {
@@ -871,6 +1163,76 @@ const docTemplate = `{
}
}
},
"/api/signup": {
"post": {
"description": "Register a new user account with a username and password.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "User signup",
"parameters": [
{
"description": "User signup details",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/http.signupBody"
}
}
],
"responses": {
"201": {
"description": "User created successfully",
"schema": {
"type": "string"
}
},
"400": {
"description": "Bad request - invalid input",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"405": {
"description": "Method not allowed - signup is disabled",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"409": {
"description": "Conflict - user already exists",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/usage": {
"get": {
"description": "Returns the total and used disk space for a specified directory.",
@@ -1164,6 +1526,86 @@ const docTemplate = `{
}
}
}
},
"/public/dl": {
"get": {
"description": "Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Resources"
],
"summary": "Get raw content of a file, multiple files, or directory",
"parameters": [
{
"type": "string",
"description": "a list of files in the following format 'source::filename' and separated by '||' with additional items in the list. (required)",
"name": "files",
"in": "query",
"required": true
},
{
"type": "boolean",
"description": "If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults to 'attachment'.",
"name": "inline",
"in": "query"
},
{
"type": "string",
"description": "Compression algorithm for archiving multiple files or directories. Options: 'zip' and 'tar.gz'. Default is 'zip'.",
"name": "algo",
"in": "query"
}
],
"responses": {
"200": {
"description": "Raw file or directory content, or archive for multiple files",
"schema": {
"type": "file"
}
},
"202": {
"description": "Modify permissions required",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"400": {
"description": "Invalid request path",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "File or directory not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
}
},
"definitions": {
@@ -1249,6 +1691,26 @@ const docTemplate = `{
}
}
},
"http.AuthTokenMin": {
"type": "object",
"properties": {
"Permissions": {
"$ref": "#/definitions/users.Permissions"
},
"created": {
"type": "integer"
},
"expires": {
"type": "integer"
},
"key": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"http.DiskUsageResponse": {
"type": "object",
"properties": {
@@ -1277,15 +1739,6 @@ const docTemplate = `{
"http.settingsData": {
"type": "object",
"properties": {
"commands": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
},
"createUserDir": {
"type": "boolean"
},
@@ -1295,12 +1748,6 @@ const docTemplate = `{
"frontend": {
"$ref": "#/definitions/settings.Frontend"
},
"rules": {
"type": "array",
"items": {
"$ref": "#/definitions/users.Rule"
}
},
"signup": {
"type": "boolean"
},
@@ -1309,6 +1756,17 @@ const docTemplate = `{
}
}
},
"http.signupBody": {
"type": "object",
"properties": {
"password": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"settings.ExternalLink": {
"type": "object",
"properties": {
@@ -1388,13 +1846,8 @@ const docTemplate = `{
"quickDownload": {
"type": "boolean"
},
"rules": {
"type": "array",
"items": {
"$ref": "#/definitions/users.Rule"
}
},
"scope": {
"description": "deprecated",
"type": "string"
},
"showHidden": {
@@ -1403,17 +1856,6 @@ const docTemplate = `{
"singleClick": {
"type": "boolean"
},
"sorting": {
"type": "object",
"properties": {
"asc": {
"type": "boolean"
},
"by": {
"type": "string"
}
}
},
"stickySidebar": {
"type": "boolean"
},
@@ -1454,6 +1896,9 @@ const docTemplate = `{
"path": {
"type": "string"
},
"source": {
"type": "string"
},
"token": {
"description": "Token is a random value that will only be set when PasswordHash is set. It is\nURL-Safe and is used to download links in password-protected shares via a\nquery arg.",
"type": "string"
@@ -1495,57 +1940,14 @@ const docTemplate = `{
"api": {
"type": "boolean"
},
"create": {
"type": "boolean"
},
"delete": {
"type": "boolean"
},
"download": {
"type": "boolean"
},
"execute": {
"type": "boolean"
},
"modify": {
"type": "boolean"
},
"rename": {
"type": "boolean"
},
"share": {
"type": "boolean"
}
}
},
"users.Regexp": {
"type": "object",
"properties": {
"raw": {
"type": "string"
}
}
},
"users.Rule": {
"type": "object",
"properties": {
"allow": {
"type": "boolean"
},
"id": {
"type": "string"
},
"path": {
"type": "string"
},
"regex": {
"type": "boolean"
},
"regexp": {
"$ref": "#/definitions/users.Regexp"
}
}
},
"users.Sorting": {
"type": "object",
"properties": {
@@ -1557,6 +1959,17 @@ const docTemplate = `{
}
}
},
"users.SourceScope": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"scope": {
"type": "string"
}
}
},
"users.User": {
"type": "object",
"properties": {
@@ -1566,12 +1979,6 @@ const docTemplate = `{
"$ref": "#/definitions/users.AuthToken"
}
},
"commands": {
"type": "array",
"items": {
"type": "string"
}
},
"darkMode": {
"type": "boolean"
},
@@ -1605,15 +2012,15 @@ const docTemplate = `{
"quickDownload": {
"type": "boolean"
},
"rules": {
"type": "array",
"items": {
"$ref": "#/definitions/users.Rule"
}
},
"scope": {
"type": "string"
},
"scopes": {
"type": "array",
"items": {
"$ref": "#/definitions/users.SourceScope"
}
},
"showHidden": {
"type": "boolean"
},
+498 -91
View File
@@ -4,6 +4,228 @@
"contact": {}
},
"paths": {
"/api/createApiKey": {
"post": {
"description": "Create an API key with specified name, duration, and permissions.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"API Keys"
],
"summary": "Create API key",
"parameters": [
{
"type": "string",
"description": "Name of the API key",
"name": "name",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Duration of the API key in days",
"name": "days",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Permissions for the API key (comma-separated)",
"name": "permissions",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "Token created successfully, resoponse contains json object with token key",
"schema": {
"$ref": "#/definitions/http.HttpResponse"
}
},
"400": {
"description": "Bad request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"409": {
"description": "Conflict",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/deleteApiKey": {
"delete": {
"description": "Delete an API key with specified name.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"API Keys"
],
"summary": "Delete API key",
"parameters": [
{
"type": "string",
"description": "Name of the API key to delete",
"name": "name",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "API key deleted successfully",
"schema": {
"$ref": "#/definitions/http.HttpResponse"
}
},
"404": {
"description": "Not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/listApiKeys": {
"get": {
"description": "List all API keys or retrieve details for a specific key.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"API Keys"
],
"summary": "List API keys",
"parameters": [
{
"type": "string",
"description": "Name of the API to retrieve details",
"name": "name",
"in": "query"
}
],
"responses": {
"200": {
"description": "List of API keys or specific key details",
"schema": {
"$ref": "#/definitions/http.AuthTokenMin"
}
},
"404": {
"description": "Not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/login": {
"post": {
"description": "Authenticate a user with a username and password.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "User login",
"responses": {
"200": {
"description": "JWT token for authentication",
"schema": {
"type": "string"
}
},
"403": {
"description": "Forbidden - authentication failed",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/preview": {
"get": {
"description": "Returns a preview image based on the requested path and size.",
@@ -103,7 +325,7 @@
"parameters": [
{
"type": "string",
"description": "Comma-separated list of specific files within the directory (required)",
"description": "a list of files in the following format 'source::filename' and separated by '||' with additional items in the list. (required)",
"name": "files",
"in": "query",
"required": true
@@ -129,7 +351,7 @@
}
},
"202": {
"description": "Download permissions required",
"description": "Modify permissions required",
"schema": {
"type": "object",
"additionalProperties": {
@@ -167,6 +389,47 @@
}
}
},
"/api/renew": {
"post": {
"description": "Refresh the authentication token for a logged-in user.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Renew authentication token",
"responses": {
"200": {
"description": "New JWT token generated",
"schema": {
"type": "string"
}
},
"401": {
"description": "Unauthorized - invalid token",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/resources": {
"get": {
"description": "Returns metadata and optionally file contents for a specified resource path.",
@@ -616,7 +879,7 @@
},
"/api/settings": {
"get": {
"description": "Returns the current configuration settings for signup, user directories, rules, frontend, and commands.",
"description": "Returns the current configuration settings for signup, user directories, rules, frontend.",
"consumes": [
"application/json"
],
@@ -627,6 +890,14 @@
"Settings"
],
"summary": "Get system settings",
"parameters": [
{
"type": "string",
"description": "Property to retrieve",
"name": "property",
"in": "query"
}
],
"responses": {
"200": {
"description": "System settings data",
@@ -637,7 +908,7 @@
}
},
"put": {
"description": "Updates the system configuration settings for signup, user directories, rules, frontend, and commands.",
"description": "Updates the system configuration settings for signup, user directories, rules, frontend.",
"consumes": [
"application/json"
],
@@ -704,6 +975,13 @@
"name": "path",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name for share links",
"name": "source",
"in": "query",
"required": true
}
],
"responses": {
@@ -783,6 +1061,20 @@
"schema": {
"$ref": "#/definitions/share.CreateBody"
}
},
{
"type": "string",
"description": "Source Path of the files to share",
"name": "path",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Source name of the files to share",
"name": "source",
"in": "path",
"required": true
}
],
"responses": {
@@ -860,6 +1152,76 @@
}
}
},
"/api/signup": {
"post": {
"description": "Register a new user account with a username and password.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "User signup",
"parameters": [
{
"description": "User signup details",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/http.signupBody"
}
}
],
"responses": {
"201": {
"description": "User created successfully",
"schema": {
"type": "string"
}
},
"400": {
"description": "Bad request - invalid input",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"405": {
"description": "Method not allowed - signup is disabled",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"409": {
"description": "Conflict - user already exists",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/usage": {
"get": {
"description": "Returns the total and used disk space for a specified directory.",
@@ -1153,6 +1515,86 @@
}
}
}
},
"/public/dl": {
"get": {
"description": "Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Resources"
],
"summary": "Get raw content of a file, multiple files, or directory",
"parameters": [
{
"type": "string",
"description": "a list of files in the following format 'source::filename' and separated by '||' with additional items in the list. (required)",
"name": "files",
"in": "query",
"required": true
},
{
"type": "boolean",
"description": "If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults to 'attachment'.",
"name": "inline",
"in": "query"
},
{
"type": "string",
"description": "Compression algorithm for archiving multiple files or directories. Options: 'zip' and 'tar.gz'. Default is 'zip'.",
"name": "algo",
"in": "query"
}
],
"responses": {
"200": {
"description": "Raw file or directory content, or archive for multiple files",
"schema": {
"type": "file"
}
},
"202": {
"description": "Modify permissions required",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"400": {
"description": "Invalid request path",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "File or directory not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
}
},
"definitions": {
@@ -1238,6 +1680,26 @@
}
}
},
"http.AuthTokenMin": {
"type": "object",
"properties": {
"Permissions": {
"$ref": "#/definitions/users.Permissions"
},
"created": {
"type": "integer"
},
"expires": {
"type": "integer"
},
"key": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"http.DiskUsageResponse": {
"type": "object",
"properties": {
@@ -1266,15 +1728,6 @@
"http.settingsData": {
"type": "object",
"properties": {
"commands": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
},
"createUserDir": {
"type": "boolean"
},
@@ -1284,12 +1737,6 @@
"frontend": {
"$ref": "#/definitions/settings.Frontend"
},
"rules": {
"type": "array",
"items": {
"$ref": "#/definitions/users.Rule"
}
},
"signup": {
"type": "boolean"
},
@@ -1298,6 +1745,17 @@
}
}
},
"http.signupBody": {
"type": "object",
"properties": {
"password": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"settings.ExternalLink": {
"type": "object",
"properties": {
@@ -1377,13 +1835,8 @@
"quickDownload": {
"type": "boolean"
},
"rules": {
"type": "array",
"items": {
"$ref": "#/definitions/users.Rule"
}
},
"scope": {
"description": "deprecated",
"type": "string"
},
"showHidden": {
@@ -1392,17 +1845,6 @@
"singleClick": {
"type": "boolean"
},
"sorting": {
"type": "object",
"properties": {
"asc": {
"type": "boolean"
},
"by": {
"type": "string"
}
}
},
"stickySidebar": {
"type": "boolean"
},
@@ -1443,6 +1885,9 @@
"path": {
"type": "string"
},
"source": {
"type": "string"
},
"token": {
"description": "Token is a random value that will only be set when PasswordHash is set. It is\nURL-Safe and is used to download links in password-protected shares via a\nquery arg.",
"type": "string"
@@ -1484,57 +1929,14 @@
"api": {
"type": "boolean"
},
"create": {
"type": "boolean"
},
"delete": {
"type": "boolean"
},
"download": {
"type": "boolean"
},
"execute": {
"type": "boolean"
},
"modify": {
"type": "boolean"
},
"rename": {
"type": "boolean"
},
"share": {
"type": "boolean"
}
}
},
"users.Regexp": {
"type": "object",
"properties": {
"raw": {
"type": "string"
}
}
},
"users.Rule": {
"type": "object",
"properties": {
"allow": {
"type": "boolean"
},
"id": {
"type": "string"
},
"path": {
"type": "string"
},
"regex": {
"type": "boolean"
},
"regexp": {
"$ref": "#/definitions/users.Regexp"
}
}
},
"users.Sorting": {
"type": "object",
"properties": {
@@ -1546,6 +1948,17 @@
}
}
},
"users.SourceScope": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"scope": {
"type": "string"
}
}
},
"users.User": {
"type": "object",
"properties": {
@@ -1555,12 +1968,6 @@
"$ref": "#/definitions/users.AuthToken"
}
},
"commands": {
"type": "array",
"items": {
"type": "string"
}
},
"darkMode": {
"type": "boolean"
},
@@ -1594,15 +2001,15 @@
"quickDownload": {
"type": "boolean"
},
"rules": {
"type": "array",
"items": {
"$ref": "#/definitions/users.Rule"
}
},
"scope": {
"type": "string"
},
"scopes": {
"type": "array",
"items": {
"$ref": "#/definitions/users.SourceScope"
}
},
"showHidden": {
"type": "boolean"
},
+337 -61
View File
@@ -57,6 +57,19 @@ definitions:
type:
type: string
type: object
http.AuthTokenMin:
properties:
Permissions:
$ref: '#/definitions/users.Permissions'
created:
type: integer
expires:
type: integer
key:
type: string
name:
type: string
type: object
http.DiskUsageResponse:
properties:
total:
@@ -75,27 +88,24 @@ definitions:
type: object
http.settingsData:
properties:
commands:
additionalProperties:
items:
type: string
type: array
type: object
createUserDir:
type: boolean
defaults:
$ref: '#/definitions/settings.UserDefaults'
frontend:
$ref: '#/definitions/settings.Frontend'
rules:
items:
$ref: '#/definitions/users.Rule'
type: array
signup:
type: boolean
userHomeBasePath:
type: string
type: object
http.signupBody:
properties:
password:
type: string
username:
type: string
type: object
settings.ExternalLink:
properties:
text:
@@ -148,23 +158,13 @@ definitions:
$ref: '#/definitions/users.Permissions'
quickDownload:
type: boolean
rules:
items:
$ref: '#/definitions/users.Rule'
type: array
scope:
description: deprecated
type: string
showHidden:
type: boolean
singleClick:
type: boolean
sorting:
properties:
asc:
type: boolean
by:
type: string
type: object
stickySidebar:
type: boolean
themeColor:
@@ -191,6 +191,8 @@ definitions:
type: string
path:
type: string
source:
type: string
token:
description: |-
Token is a random value that will only be set when PasswordHash is set. It is
@@ -221,39 +223,11 @@ definitions:
type: boolean
api:
type: boolean
create:
type: boolean
delete:
type: boolean
download:
type: boolean
execute:
type: boolean
modify:
type: boolean
rename:
type: boolean
share:
type: boolean
type: object
users.Regexp:
properties:
raw:
type: string
type: object
users.Rule:
properties:
allow:
type: boolean
id:
type: string
path:
type: string
regex:
type: boolean
regexp:
$ref: '#/definitions/users.Regexp'
type: object
users.Sorting:
properties:
asc:
@@ -261,16 +235,19 @@ definitions:
by:
type: string
type: object
users.SourceScope:
properties:
name:
type: string
scope:
type: string
type: object
users.User:
properties:
apiKeys:
additionalProperties:
$ref: '#/definitions/users.AuthToken'
type: object
commands:
items:
type: string
type: array
darkMode:
type: boolean
dateFormat:
@@ -293,12 +270,12 @@ definitions:
$ref: '#/definitions/users.Permissions'
quickDownload:
type: boolean
rules:
items:
$ref: '#/definitions/users.Rule'
type: array
scope:
type: string
scopes:
items:
$ref: '#/definitions/users.SourceScope'
type: array
showHidden:
type: boolean
singleClick:
@@ -317,6 +294,154 @@ definitions:
info:
contact: {}
paths:
/api/createApiKey:
post:
consumes:
- application/json
description: Create an API key with specified name, duration, and permissions.
parameters:
- description: Name of the API key
in: query
name: name
required: true
type: string
- description: Duration of the API key in days
in: query
name: days
required: true
type: string
- description: Permissions for the API key (comma-separated)
in: query
name: permissions
required: true
type: string
produces:
- application/json
responses:
"200":
description: Token created successfully, resoponse contains json object
with token key
schema:
$ref: '#/definitions/http.HttpResponse'
"400":
description: Bad request
schema:
additionalProperties:
type: string
type: object
"404":
description: Not found
schema:
additionalProperties:
type: string
type: object
"409":
description: Conflict
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
additionalProperties:
type: string
type: object
summary: Create API key
tags:
- API Keys
/api/deleteApiKey:
delete:
consumes:
- application/json
description: Delete an API key with specified name.
parameters:
- description: Name of the API key to delete
in: query
name: name
required: true
type: string
produces:
- application/json
responses:
"200":
description: API key deleted successfully
schema:
$ref: '#/definitions/http.HttpResponse'
"404":
description: Not found
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
additionalProperties:
type: string
type: object
summary: Delete API key
tags:
- API Keys
/api/listApiKeys:
get:
consumes:
- application/json
description: List all API keys or retrieve details for a specific key.
parameters:
- description: Name of the API to retrieve details
in: query
name: name
type: string
produces:
- application/json
responses:
"200":
description: List of API keys or specific key details
schema:
$ref: '#/definitions/http.AuthTokenMin'
"404":
description: Not found
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
additionalProperties:
type: string
type: object
summary: List API keys
tags:
- API Keys
/api/login:
post:
consumes:
- application/json
description: Authenticate a user with a username and password.
produces:
- application/json
responses:
"200":
description: JWT token for authentication
schema:
type: string
"403":
description: Forbidden - authentication failed
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
additionalProperties:
type: string
type: object
summary: User login
tags:
- Auth
/api/preview:
get:
consumes:
@@ -380,7 +505,8 @@ paths:
description: Returns the raw content of a file, multiple files, or a directory.
Supports downloading files as archives in various formats.
parameters:
- description: Comma-separated list of specific files within the directory (required)
- description: a list of files in the following format 'source::filename' and
separated by '||' with additional items in the list. (required)
in: query
name: files
required: true
@@ -403,7 +529,7 @@ paths:
schema:
type: file
"202":
description: Download permissions required
description: Modify permissions required
schema:
additionalProperties:
type: string
@@ -429,6 +555,33 @@ paths:
summary: Get raw content of a file, multiple files, or directory
tags:
- Resources
/api/renew:
post:
consumes:
- application/json
description: Refresh the authentication token for a logged-in user.
produces:
- application/json
responses:
"200":
description: New JWT token generated
schema:
type: string
"401":
description: Unauthorized - invalid token
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
additionalProperties:
type: string
type: object
summary: Renew authentication token
tags:
- Auth
/api/resources:
delete:
consumes:
@@ -736,7 +889,12 @@ paths:
consumes:
- application/json
description: Returns the current configuration settings for signup, user directories,
rules, frontend, and commands.
rules, frontend.
parameters:
- description: Property to retrieve
in: query
name: property
type: string
produces:
- application/json
responses:
@@ -751,7 +909,7 @@ paths:
consumes:
- application/json
description: Updates the system configuration settings for signup, user directories,
rules, frontend, and commands.
rules, frontend.
parameters:
- description: Settings data to update
in: body
@@ -791,6 +949,11 @@ paths:
name: path
required: true
type: string
- description: Source name for share links
in: query
name: source
required: true
type: string
produces:
- application/json
responses:
@@ -845,6 +1008,16 @@ paths:
required: true
schema:
$ref: '#/definitions/share.CreateBody'
- description: Source Path of the files to share
in: path
name: path
required: true
type: string
- description: Source name of the files to share
in: path
name: source
required: true
type: string
produces:
- application/json
responses:
@@ -898,6 +1071,52 @@ paths:
summary: Delete a share link
tags:
- Shares
/api/signup:
post:
consumes:
- application/json
description: Register a new user account with a username and password.
parameters:
- description: User signup details
in: body
name: body
required: true
schema:
$ref: '#/definitions/http.signupBody'
produces:
- application/json
responses:
"201":
description: User created successfully
schema:
type: string
"400":
description: Bad request - invalid input
schema:
additionalProperties:
type: string
type: object
"405":
description: Method not allowed - signup is disabled
schema:
additionalProperties:
type: string
type: object
"409":
description: Conflict - user already exists
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
additionalProperties:
type: string
type: object
summary: User signup
tags:
- Auth
/api/usage:
get:
consumes:
@@ -1092,4 +1311,61 @@ paths:
summary: Health Check
tags:
- Health
/public/dl:
get:
consumes:
- application/json
description: Returns the raw content of a file, multiple files, or a directory.
Supports downloading files as archives in various formats.
parameters:
- description: a list of files in the following format 'source::filename' and
separated by '||' with additional items in the list. (required)
in: query
name: files
required: true
type: string
- description: If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults
to 'attachment'.
in: query
name: inline
type: boolean
- description: 'Compression algorithm for archiving multiple files or directories.
Options: ''zip'' and ''tar.gz''. Default is ''zip''.'
in: query
name: algo
type: string
produces:
- application/json
responses:
"200":
description: Raw file or directory content, or archive for multiple files
schema:
type: file
"202":
description: Modify permissions required
schema:
additionalProperties:
type: string
type: object
"400":
description: Invalid request path
schema:
additionalProperties:
type: string
type: object
"404":
description: File or directory not found
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
additionalProperties:
type: string
type: object
summary: Get raw content of a file, multiple files, or directory
tags:
- Resources
swagger: "2.0"
+2 -3
View File
@@ -11,7 +11,6 @@ func HashPwd(password string) (string, error) {
}
// CheckPwd checks if a password is correct.
func CheckPwd(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
func CheckPwd(password, hash string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}
-64
View File
@@ -1,64 +0,0 @@
package users
import (
"path/filepath"
"regexp"
"strings"
)
// Checker is a Rules checker.
type Checker interface {
Check(path string) bool
}
// Check implements rules.Checker.
func (user *User) Check(path string) bool {
allow := true
for _, rule := range user.Rules {
if rule.Matches(path) {
allow = rule.Allow
}
}
return allow
}
// Rule is a allow/disallow rule.
type Rule struct {
Regex bool `json:"regex"`
Allow bool `json:"allow"`
Path string `json:"path"`
Regexp *Regexp `json:"regexp"`
Id string `json:"id"`
}
// MatchHidden matches paths with a basename
// that begins with a dot.
func MatchHidden(path string) bool {
return path != "" && strings.HasPrefix(filepath.Base(path), ".")
}
// Matches matches a path against a rule.
func (r *Rule) Matches(path string) bool {
if r.Regex {
return r.Regexp.MatchString(path)
}
return strings.HasPrefix(path, r.Path)
}
// Regexp is a wrapper to the native regexp type where we
// save the raw expression.
type Regexp struct {
Raw string `json:"raw"`
regexp *regexp.Regexp
}
// MatchString checks if a string matches the regexp.
func (r *Regexp) MatchString(s string) bool {
if r.regexp == nil {
r.regexp = regexp.MustCompile(r.Raw)
}
return r.regexp.MatchString(s)
}
+16 -60
View File
@@ -11,24 +11,22 @@ import (
type StorageBackend interface {
GetBy(interface{}) (*User, error)
Gets() ([]*User, error)
Save(u *User) error
Update(u *User, fields ...string) error
Save(u *User, changePass bool) error
Update(u *User, adminActor bool, fields ...string) error
DeleteByID(uint) error
DeleteByUsername(string) error
}
// Store is an interface for user storage.
type Store interface {
Get(baseScope string, id interface{}) (user *User, err error)
Gets(baseScope string) ([]*User, error)
Update(user *User, fields ...string) error
Save(user *User) error
Get(id interface{}) (user *User, err error)
Gets() ([]*User, error)
Update(user *User, adminActor bool, fields ...string) error
Save(user *User, changePass bool) error
Delete(id interface{}) error
LastUpdate(id uint) int64
AddApiKey(username uint, name string, key AuthToken) error
DeleteApiKey(username uint, name string) error
AddRule(username string, rule Rule) error
DeleteRule(username string, ruleID string) error
}
// Storage is a users storage.
@@ -49,7 +47,7 @@ func NewStorage(back StorageBackend) *Storage {
// Get allows you to get a user by its name or username. The provided
// id must be a string for username lookup or a uint for id lookup. If id
// is neither, a ErrInvalidDataType will be returned.
func (s *Storage) Get(baseScope string, id interface{}) (user *User, err error) {
func (s *Storage) Get(id interface{}) (user *User, err error) {
user, err = s.back.GetBy(id)
if err != nil {
return
@@ -58,7 +56,7 @@ func (s *Storage) Get(baseScope string, id interface{}) (user *User, err error)
}
// Gets gets a list of all users.
func (s *Storage) Gets(baseScope string) ([]*User, error) {
func (s *Storage) Gets() ([]*User, error) {
users, err := s.back.Gets()
if err != nil {
return nil, err
@@ -67,8 +65,8 @@ func (s *Storage) Gets(baseScope string) ([]*User, error) {
}
// Update updates a user in the database.
func (s *Storage) Update(user *User, fields ...string) error {
err := s.back.Update(user, fields...)
func (s *Storage) Update(user *User, adminIActor bool, fields ...string) error {
err := s.back.Update(user, adminIActor, fields...)
if err != nil {
return err
}
@@ -79,25 +77,8 @@ func (s *Storage) Update(user *User, fields ...string) error {
return nil
}
// AddRule adds a rule to the user's rules list and updates the user in the database.
func (s *Storage) AddRule(userID string, rule Rule) error {
user, err := s.Get("", userID)
if err != nil {
return err
}
user.Rules = append(user.Rules, rule)
err = s.Update(user, "Rules")
if err != nil {
return err
}
return nil
}
func (s *Storage) AddApiKey(userID uint, name string, key AuthToken) error {
user, err := s.Get("", userID)
user, err := s.Get(userID)
if err != nil {
return err
}
@@ -106,7 +87,7 @@ func (s *Storage) AddApiKey(userID uint, name string, key AuthToken) error {
user.ApiKeys = make(map[string]AuthToken)
}
user.ApiKeys[name] = key
err = s.Update(user, "ApiKeys")
err = s.Update(user, false, "ApiKeys")
if err != nil {
return err
}
@@ -115,7 +96,7 @@ func (s *Storage) AddApiKey(userID uint, name string, key AuthToken) error {
}
func (s *Storage) DeleteApiKey(userID uint, name string) error {
user, err := s.Get("", userID)
user, err := s.Get(userID)
if err != nil {
return err
}
@@ -124,32 +105,7 @@ func (s *Storage) DeleteApiKey(userID uint, name string) error {
user.ApiKeys = make(map[string]AuthToken)
}
delete(user.ApiKeys, name)
err = s.Update(user, "ApiKeys")
if err != nil {
return err
}
return nil
}
// DeleteRule deletes a rule specified by ID from the user's rules list and updates the user in the database.
func (s *Storage) DeleteRule(userID string, ruleID string) error {
user, err := s.Get("", userID)
if err != nil {
return err
}
// Find and remove the rule with the specified ID
var updatedRules []Rule
for _, r := range user.Rules {
if r.Id != ruleID {
updatedRules = append(updatedRules, r)
}
}
user.Rules = updatedRules
err = s.Update(user, "Rules")
err = s.Update(user, false, "ApiKeys")
if err != nil {
return err
}
@@ -158,8 +114,8 @@ func (s *Storage) DeleteRule(userID string, ruleID string) error {
}
// Save saves the user in a storage.
func (s *Storage) Save(user *User) error {
return s.back.Save(user)
func (s *Storage) Save(user *User, changePass bool) error {
return s.back.Save(user, changePass)
}
// Delete allows you to delete a user by its name or username. The provided
+42 -64
View File
@@ -1,8 +1,6 @@
package users
import (
"regexp"
jwt "github.com/golang-jwt/jwt/v4"
)
@@ -17,15 +15,10 @@ type AuthToken struct {
}
type Permissions struct {
Api bool `json:"api"`
Admin bool `json:"admin"`
Execute bool `json:"execute"`
Create bool `json:"create"`
Rename bool `json:"rename"`
Modify bool `json:"modify"`
Delete bool `json:"delete"`
Share bool `json:"share"`
Download bool `json:"download"`
Api bool `json:"api"`
Admin bool `json:"admin"`
Modify bool `json:"modify"`
Share bool `json:"share"`
}
// SortingSettings represents the sorting settings.
@@ -36,64 +29,49 @@ type Sorting struct {
// User describes a user.
type User struct {
StickySidebar bool `json:"stickySidebar"`
DarkMode bool `json:"darkMode"`
DisableSettings bool `json:"disableSettings"`
ID uint `storm:"id,increment" json:"id"`
Username string `storm:"unique" json:"username"`
Password string `json:"password,omitempty"`
Scope string `json:"scope"`
Locale string `json:"locale"`
LockPassword bool `json:"lockPassword"`
ViewMode string `json:"viewMode"`
SingleClick bool `json:"singleClick"`
Sorting Sorting `json:"sorting"`
Perm Permissions `json:"perm"`
Commands []string `json:"commands"`
Rules []Rule `json:"rules"`
ApiKeys map[string]AuthToken `json:"apiKeys,omitempty"`
ShowHidden bool `json:"showHidden"`
DateFormat bool `json:"dateFormat"`
GallerySize int `json:"gallerySize"`
ThemeColor string `json:"themeColor"`
QuickDownload bool `json:"quickDownload"`
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"`
NonAdminEditable
StickySidebar bool `json:"stickySidebar"`
DisableSettings bool `json:"disableSettings"`
ID uint `storm:"id,increment" json:"id"`
Username string `storm:"unique" json:"username"`
Scopes []SourceScope `json:"scopes"`
Scope string `json:"scope,omitempty"`
LockPassword bool `json:"lockPassword"`
Perm Permissions `json:"perm"`
ApiKeys map[string]AuthToken `json:"apiKeys,omitempty"`
}
type SourceScope struct {
Name string `json:"name"`
Scope string `json:"scope"`
}
type NonAdminEditable struct {
DarkMode bool `json:"darkMode"`
Password string `json:"password,omitempty"`
Locale string `json:"locale"`
ViewMode string `json:"viewMode"`
SingleClick bool `json:"singleClick"`
Sorting Sorting `json:"sorting"`
ShowHidden bool `json:"showHidden"`
DateFormat bool `json:"dateFormat"`
GallerySize int `json:"gallerySize"`
ThemeColor string `json:"themeColor"`
QuickDownload bool `json:"quickDownload"`
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"`
}
var PublicUser = User{
NonAdminEditable: NonAdminEditable{
Password: "publicUser", // temp user not registered
ViewMode: "normal",
},
Username: "publicUser", // temp user not registered
Password: "publicUser", // temp user not registered
Scope: "/does/not/exist",
ViewMode: "normal",
LockPassword: true,
Perm: Permissions{
Create: false,
Rename: false,
Modify: false,
Delete: false,
Share: false,
Download: true,
Admin: false,
Api: false,
Modify: false,
Share: false,
Admin: false,
Api: false,
},
}
// GetRules implements rules.Provider.
func (u *User) GetRules() []Rule {
return u.Rules
}
// CanExecute checks if an user can execute a specific command.
func (u *User) CanExecute(command string) bool {
if !u.Perm.Execute {
return false
}
for _, cmd := range u.Commands {
if regexp.MustCompile(cmd).MatchString(command) {
return true
}
}
return false
}
+12
View File
@@ -92,3 +92,15 @@ func HashSHA256(data string) string {
bytes := sha256.Sum256([]byte(data))
return hex.EncodeToString(bytes[:])
}
func GetLastComponent(path string) string {
if path == "" {
return ""
}
path = strings.TrimSuffix(path, "/") // Remove trailing slash if any
lastSlash := strings.LastIndex(path, "/")
if lastSlash == -1 {
return path // No parent directory for a relative path without slashes
}
return path[lastSlash+1:]
}
+2 -2
View File
@@ -45,9 +45,9 @@
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-vue": "^9.24.0",
"jsdom": "^25.0.1",
"vite": "^5.2.7",
"vite": "^6.2.0",
"vite-plugin-compression2": "^1.0.0",
"vitest": "^2.1.5",
"vitest": "^3.0.7",
"vue-tsc": "^2.0.7"
}
}
+46 -40
View File
@@ -1,5 +1,5 @@
import { fetchURL, adjustedData } from './utils'
import { removePrefix, getApiPath } from '@/utils/url.js'
import { getApiPath, extractSourceFromPath } from '@/utils/url.js'
import { state } from '@/store'
import { notify } from '@/notify'
import { externalUrl } from '@/utils/constants'
@@ -7,10 +7,11 @@ import { externalUrl } from '@/utils/constants'
// Notify if errors occur
export async function fetchFiles (url, content = false) {
try {
let path = encodeURIComponent(removePrefix(url, 'files'))
const result = extractSourceFromPath(url)
const apiPath = getApiPath('api/resources', {
path: path,
content: content
path: encodeURIComponent(result.path),
source: result.source,
...(content && { content: 'true' })
})
const res = await fetchURL(apiPath)
const data = await res.json()
@@ -24,12 +25,15 @@ export async function fetchFiles (url, content = false) {
async function resourceAction (url, method, content) {
try {
const result = extractSourceFromPath(url)
let source = result.source
let path = result.path
let opts = { method }
if (content) {
opts.body = content
}
let path = encodeURIComponent(removePrefix(url, 'files'))
const apiPath = getApiPath('api/resources', { path: path })
path = encodeURIComponent(path)
const apiPath = getApiPath('api/resources', { path: path, source: source })
const res = await fetchURL(apiPath, opts)
return res
} catch (err) {
@@ -40,8 +44,7 @@ async function resourceAction (url, method, content) {
export async function remove (url) {
try {
let path = encodeURIComponent(removePrefix(url, 'files'))
return await resourceAction(path, 'DELETE')
return await resourceAction(url, 'DELETE')
} catch (err) {
notify.showError(err.message || 'Error deleting resource')
throw err
@@ -50,8 +53,7 @@ export async function remove (url) {
export async function put (url, content = '') {
try {
let path = encodeURIComponent(removePrefix(url, 'files'))
return await resourceAction(path, 'PUT', content)
return await resourceAction(url, 'PUT', content)
} catch (err) {
notify.showError(err.message || 'Error putting resource')
throw err
@@ -59,25 +61,34 @@ export async function put (url, content = '') {
}
export function download (format, files) {
if (format != 'zip') {
if (format !== 'zip') {
format = 'tar.gz'
}
try {
let fileargs = ''
if (files.length === 1) {
fileargs = decodeURI(removePrefix(files[0], 'files'))
const result = extractSourceFromPath(decodeURI(files[0]))
fileargs = result.source + '::' + result.path + '||'
} else {
for (let file of files) {
fileargs += decodeURI(removePrefix(file, 'files')) + ',|'
const result = extractSourceFromPath(decodeURI(file))
fileargs += result.source + '::' + result.path + '||'
}
fileargs = fileargs.substring(0, fileargs.length - 1)
}
fileargs = fileargs.slice(0, -2) // remove trailing "||"
const apiPath = getApiPath('api/raw', {
files: encodeURIComponent(fileargs),
algo: format
})
const url = window.origin + apiPath
window.open(url)
// Create a temporary <a> element to trigger the download
const link = document.createElement('a')
link.href = url
link.setAttribute('download', '') // Ensures it triggers a download
document.body.appendChild(link)
link.click()
document.body.removeChild(link) // Clean up
} catch (err) {
notify.showError(err.message || 'Error downloading files')
}
@@ -85,8 +96,7 @@ export function download (format, files) {
export async function post (url, content = '', overwrite = false, onupload) {
try {
url = removePrefix(url, 'files')
const result = extractSourceFromPath(url)
let bufferContent
if (
content instanceof Blob &&
@@ -96,7 +106,8 @@ export async function post (url, content = '', overwrite = false, onupload) {
}
const apiPath = getApiPath('api/resources', {
path: url,
path: result.path,
source: result.source,
override: overwrite
})
return new Promise((resolve, reject) => {
@@ -146,15 +157,17 @@ export async function moveCopy (
try {
// Create an array of fetch calls
let promises = items.map(item => {
let toPath = encodeURIComponent(removePrefix(decodeURI(item.to), 'files'))
let fromPath = encodeURIComponent(
removePrefix(decodeURI(item.from), 'files')
)
let localParams = { ...params, destination: toPath, from: fromPath }
const fromResult = extractSourceFromPath(item.from)
const toResult = extractSourceFromPath(item.to)
let localParams = {
...params,
destination: toResult.source + '::' + toResult.path,
from: fromResult.source + '::' + fromResult.path
}
const apiPath = getApiPath('api/resources', localParams)
return fetch(apiPath, { method: 'PATCH' }).then(response => {
if (!response.ok) {
// Throw an error if the fetch fails
return response.text().then(text => {
throw new Error(
`Failed to move/copy: ${text || response.statusText}`
@@ -173,10 +186,12 @@ export async function moveCopy (
}
}
export async function checksum (path, algo) {
export async function checksum (url, algo) {
try {
const result = extractSourceFromPath(url)
const params = {
path: encodeURIComponent(removePrefix(path, 'files')),
path: encodeURIComponent(result.path),
source: result.source,
checksum: algo
}
const apiPath = getApiPath('api/resources', params)
@@ -189,10 +204,10 @@ export async function checksum (path, algo) {
}
}
export function getDownloadURL (path, inline, useExternal) {
export function getDownloadURL (source, path, inline, useExternal) {
try {
const params = {
files: encodeURIComponent(removePrefix(decodeURI(path), 'files')),
files: source + '::' + encodeURIComponent(path),
...(inline && { inline: 'true' })
}
const apiPath = getApiPath('api/raw', params)
@@ -206,12 +221,13 @@ export function getDownloadURL (path, inline, useExternal) {
}
}
export function getPreviewURL (path, size, modified) {
export function getPreviewURL (source, path, size, modified) {
try {
const params = {
path: encodeURIComponent(removePrefix(decodeURI(path), 'files')),
path: encodeURIComponent(path),
size: size,
key: Date.parse(modified),
source: source,
inline: 'true'
}
const apiPath = getApiPath('api/preview', params)
@@ -222,16 +238,6 @@ export function getPreviewURL (path, size, modified) {
}
}
export function getSubtitlesURL (path) {
const params = {
inline: true,
files: path,
source: 'default'
}
const apiPath = getApiPath('api/raw', params)
return window.origin + apiPath
}
export async function usage (source) {
try {
const apiPath = getApiPath('api/usage', { source: source })
+21 -7
View File
@@ -1,5 +1,5 @@
import { adjustedData } from "./utils";
import { getApiPath, removePrefix } from "@/utils/url.js";
import { getApiPath, removePrefix,extractSourceFromPath } from "@/utils/url.js";
import { notify } from "@/notify";
// Fetch public share data
@@ -25,16 +25,23 @@ export async function fetchPub(path, hash, password = "") {
// Download files with given parameters
export function download(share, ...files) {
try {
let fileInfo = files[0]
if (files.length > 1) {
fileInfo = files.map(encodeURIComponent).join(",");
let fileargs = ''
if (files.length === 1) {
const result = extractSourceFromPath(decodeURI(files[0]))
fileargs = result.path + '||'
} else {
for (let file of files) {
const result = extractSourceFromPath(decodeURI(file))
fileargs += result.path + '||'
}
}
fileargs = fileargs.slice(0, -2); // remove trailing "||"
const params = {
"path": removePrefix(share.path, "share"),
"files": fileargs,
"hash": share.hash,
"token": share.token,
"inline": share.inline,
"files": fileInfo,
};
const apiPath = getApiPath("api/public/dl", params);
window.open(window.origin+apiPath)
@@ -57,7 +64,14 @@ export async function getPublicUser() {
}
// Generate a download URL
export function getDownloadURL(share) {
const apiPath = getApiPath("api/public/dl", share);
export function getDownloadURL(share,files) {
const params = {
path: share.path,
files: files,
hash: share.hash,
token: share.token,
...(share.inline && { inline: 'true' })
}
const apiPath = getApiPath("api/public/dl", params);
return window.origin+apiPath
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { fetchURL } from "./utils";
import { notify } from "@/notify"; // Import notify for error handling
import { removePrefix, getApiPath } from "@/utils/url.js";
export default async function search(base, query) {
export default async function search(base, source, query) {
try {
base = removePrefix(base,"files");
query = encodeURIComponent(query);
@@ -11,7 +11,7 @@ export default async function search(base, query) {
base += "/";
}
const apiPath = getApiPath("api/search", { scope: base, query: query });
const apiPath = getApiPath("api/search", { scope: base, query: query, source: source });
const res = await fetchURL(apiPath);
let data = await res.json();
+4 -5
View File
@@ -1,15 +1,14 @@
import { fetchURL, fetchJSON } from "./utils";
import { getApiPath } from "@/utils/url.js";
const apiPath = getApiPath("api/settings");
export function get() {
return fetchJSON(apiPath);
export function get(property="") {
const path = getApiPath("api/settings", { property });
return fetchJSON(path);
}
export async function update(settings) {
await fetchURL(apiPath, {
await fetchURL("api/settings", {
method: "PUT",
body: JSON.stringify(settings),
});
+2 -2
View File
@@ -28,8 +28,8 @@ export async function remove(hash) {
});
}
export async function create(path, password = "", expires = "", unit = "hours") {
const params = { path };
export async function create(path, source, password = "", expires = "", unit = "hours") {
const params = { path: path, source: source };
const apiPath = getApiPath("api/share",params);
let body = "{}";
if (password != "" || expires !== "" || unit !== "hours") {
+1 -5
View File
@@ -10,14 +10,9 @@ export async function fetchURL(url, opts, auth = true) {
let res;
try {
let userScope = "";
if (state.user) {
userScope = state.user.scope;
}
res = await fetch(url, {
headers: {
"sessionId": state.sessionId,
"userScope": userScope,
...headers,
},
...rest,
@@ -88,3 +83,4 @@ export function adjustedData(data, url) {
}
return data;
}
+5 -6
View File
@@ -2,6 +2,11 @@ import { describe, it, expect, vi } from 'vitest';
import { adjustedData } from './utils.js';
describe('adjustedData', () => {
vi.doMock('@/utils/constants', () => {
return {
baseURL: "unit-testing",
};
});
it('should append the URL and process directory data correctly', () => {
const input = {
type: "directory",
@@ -89,9 +94,3 @@ describe('adjustedData', () => {
});
vi.mock('@/utils/constants', () => {
return {
baseURL: "unit-testing",
};
});
+28 -17
View File
@@ -2,7 +2,7 @@
<div class="breadcrumbs">
<component
:is="element"
:to="base || ''"
:to="homePath"
:aria-label="$t('files.home')"
:title="$t('files.home')"
>
@@ -32,7 +32,7 @@
<script>
import { state, mutations, getters } from "@/store";
import { removePrefix } from "@/utils/url.js";
import { extractSourceFromPath, removePrefix, removeLeadingSlash } from "@/utils/url.js";
import Action from "@/components/Action.vue";
export default {
@@ -43,28 +43,25 @@ export default {
data() {
return {
gallerySize: state.user.gallerySize,
homePath: "/files/",
path: "",
};
},
props: ["base", "noLink"],
mounted() {
this.updatePaths();
},
watch: {
$route() {
this.updatePaths();
},
},
computed: {
isCardView() {
return getters.isCardView();
},
items() {
let relativePath = removePrefix(state.route.path, "files");
if (getters.currentView() == "share") {
// Split the path, filter out any empty elements, then join again with slashes
relativePath = removePrefix(state.route.path, "share");
}
let parts = relativePath.split("/");
if (parts[0] === "") {
parts.shift();
}
if (getters.currentView() == "share") {
parts.shift();
}
let parts = removeLeadingSlash(this.path).split("/");
if (parts[parts.length - 1] === "") {
parts.pop();
}
@@ -104,11 +101,25 @@ export default {
},
showShare() {
return (
state.user?.perm && state.user?.perm.share && state.user.username != "publicUser" && getters.currentView() != "share"
state.user?.perm &&
state.user?.perm.share &&
state.user.username != "publicUser" &&
getters.currentView() != "share"
);
},
},
methods: {
updatePaths() {
const result = extractSourceFromPath(getters.routePath());
this.path = removePrefix(result.path, "files");
if (getters.currentView() == "share") {
this.homePath = getters.sharePathBase();
this.path = removePrefix(getters.routePath(), this.homePath + "/");
}
if (state.sources.count > 1) {
this.homePath = "/files/" + result.source;
}
},
updateGallerySize(event) {
this.gallerySize = parseInt(event.target.value, 10);
},
+19 -24
View File
@@ -15,34 +15,35 @@
</div>
<action
v-if="!showCreate"
v-if="!showCreate && !isSearchActive && userPerms.modify"
icon="add"
label="New"
@action="startShowCreate"
/>
<action
v-if="showCreate && !isSearchActive"
v-if="showCreate && !isSearchActive && userPerms.modify"
icon="create_new_folder"
:label="$t('sidebar.newFolder')"
@action="showHover('newDir')"
/>
<action
v-if="showCreate && !isSearchActive"
v-if="showCreate && userPerms.modify && !isSearchActive"
icon="note_add"
:label="$t('sidebar.newFile')"
@action="showHover('newFile')"
/>
<action
v-if="showCreate && !isSearchActive"
v-if="showCreate && userPerms.modify && !isSearchActive"
icon="file_upload"
:label="$t('buttons.upload')"
@action="uploadFunc"
/>
<action
v-if="!showCreate && headerButtons.select"
v-if="!showCreate && selectedCount == 1"
icon="info"
:label="$t('buttons.info')"
show="info"
@@ -54,38 +55,38 @@
@action="toggleMultipleSelection"
/>
<action
v-if="!showCreate && headerButtons.download"
v-if="!showCreate && selectedCount > 0"
icon="file_download"
:label="$t('buttons.download')"
@action="startDownload"
:counter="selectedCount"
/>
<action
v-if="!showCreate && headerButtons.share"
v-if="!showCreate && selectedCount == 1 && userPerms.share"
icon="share"
:label="$t('buttons.share')"
show="share"
/>
<action
v-if="!showCreate && headerButtons.rename && !isSearchActive"
v-if="!showCreate && selectedCount == 1 && userPerms.modify && !isSearchActive"
icon="mode_edit"
:label="$t('buttons.rename')"
show="rename"
/>
<action
v-if="!showCreate && headerButtons.copy"
v-if="!showCreate && selectedCount > 0 && userPerms.modify"
icon="content_copy"
:label="$t('buttons.copyFile')"
show="copy"
/>
<action
v-if="!showCreate && headerButtons.move"
v-if="!showCreate && selectedCount > 0 && userPerms.modify"
icon="forward"
:label="$t('buttons.moveFile')"
show="move"
/>
<action
v-if="!showCreate && headerButtons.delete"
v-if="!showCreate && selectedCount > 0 && userPerms.modify"
icon="delete"
:label="$t('buttons.delete')"
show="delete"
@@ -99,7 +100,6 @@ import { state, getters, mutations } from "@/store"; // Import your custom store
import Action from "@/components/Action.vue";
import { onlyOfficeUrl } from "@/utils/constants.js";
export default {
name: "ContextMenu",
components: {
@@ -151,21 +151,16 @@ export default {
isDarkMode() {
return getters.isDarkMode();
},
headerButtons() {
return {
select: state.selected.length > 0,
upload: state.user.perm?.create && state.selected.length > 0,
download: state.user.perm.download && state.selected.length > 0,
delete: state.selected.length > 0 && state.user.perm.delete,
rename: state.selected.length === 1 && state.user.perm.rename,
share: state.selected.length === 1 && state.user.perm.share,
move: state.selected.length > 0 && state.user.perm.rename,
copy: state.selected.length > 0 && state.user.perm?.create,
};
},
selectedCount() {
return getters.selectedCount();
},
userPerms() {
return {
upload: state.user.perm?.modify && state.selected.length > 0,
share: state.user.perm.share,
modify: state.user.perm.modify,
};
},
},
methods: {
startShowCreate() {
+8 -31
View File
@@ -2,41 +2,19 @@
since it didnt support Vue 3 but the component itself does
https://raw.githubusercontent.com/dzwillia/vue-simple-progress/master/src/components/Progress.vue -->
<template>
<div>
<div class="vue-simple-progress" :style="progress_style">
<div
class="vue-simple-progress-text"
:style="text_style"
v-if="text.length > 0 && textPosition == 'top'"
v-if="text.length > 0 && textPosition == 'middle'"
>
{{ text }}
</div>
<div class="vue-simple-progress" :style="progress_style">
<div
class="vue-simple-progress-text"
:style="text_style"
v-if="text.length > 0 && textPosition == 'middle'"
>
<div class="vue-simple-progress-bar" :style="bar_style">
<div :style="text_style" v-if="text.length > 0 && textPosition == 'inside'">
{{ text }}
</div>
<div
style="position: relative; left: -9999px"
:style="text_style"
v-if="text.length > 0 && textPosition == 'inside'"
>
{{ text }}
</div>
<div class="vue-simple-progress-bar" :style="bar_style">
<div :style="text_style" v-if="text.length > 0 && textPosition == 'inside'">
{{ text }}
</div>
</div>
</div>
<div
class="vue-simple-progress-text"
:style="text_style"
v-if="text.length > 0 && textPosition == 'bottom'"
>
{{ text }}
</div>
</div>
</template>
@@ -117,7 +95,7 @@ export default {
case "medium":
return 8;
case "large":
return 12;
return 10;
case "big":
return 16;
case "huge":
@@ -162,7 +140,6 @@ export default {
};
if (this.textPosition == "middle" || this.textPosition == "inside") {
style["position"] = "relative";
style["min-height"] = this.size_px + "px";
style["z-index"] = "-2";
}
@@ -185,9 +162,10 @@ export default {
}
if (this.textPosition == "middle" || this.textPosition == "inside") {
style["position"] = "absolute";
//style["position"] = "absolute";
style["top"] = "0";
style["height"] = "100%";
style["min-width"] = "1.5em";
(style["min-height"] = this.size_px + "px"), (style["z-index"] = "-1");
}
@@ -212,7 +190,6 @@ export default {
this.textPosition == "inside"
)
style["padding-top"] = this.text_padding + "px";
return style;
},
},
+66 -15
View File
@@ -35,7 +35,24 @@
<!-- Search results for desktop -->
<div v-show="active" id="results" ref="result">
<div aria-label="search-path" class="searchContext">Search Context: {{ getContext }}</div>
<div class="inputWrapper" style="display: flex">
<select
v-if="multipleSources"
class="searchContext input"
aria-label="search-path"
v-model="selectedSource"
:value="selectedSource"
@change="updateSource"
>
<option v-for="(info, name) in sourceInfo" :key="info" :value="name">
{{ name }}
</option>
</select>
<!-- Formatted display of selected value -->
<div class="searchContext">{{ contextText }}</div>
</div>
<div id="result-list">
<div>
<div v-if="active">
@@ -146,8 +163,7 @@ import ButtonGroup from "./ButtonGroup.vue";
import { search } from "@/api";
import { getters, mutations, state } from "@/store";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { removeTrailingSlash, removeLeadingSlash } from "@/utils/url";
import { removePrefix } from "@/utils/url.js";
import { url } from "@/utils/";
import Icon from "@/components/Icon.vue";
@@ -193,6 +209,7 @@ export default {
reload: false,
scrollable: null,
hiddenOptions: true,
selectedSource: "",
};
},
watch: {
@@ -215,6 +232,9 @@ export default {
mutations.closeHovers();
return;
}
if (state.sources.count > 1) {
this.selectedSource = state.sources.current;
}
setTimeout(() => {
resultList.classList.add("active");
document.getElementById("main-input").focus();
@@ -227,6 +247,11 @@ export default {
}
},
},
mounted() {
if (state.sources.count > 1) {
this.selectedSource = state.sources.current;
}
},
computed: {
showOptions() {
return !this.hiddenOptions || !this.isMobile;
@@ -266,9 +291,6 @@ export default {
searchHelp() {
return this.showHelp;
},
getContext() {
return this.getRelativeContext();
},
activeStates() {
// Create a Set of combined `name` and `type` keys for efficient lookup
const selectedSet = new Set(
@@ -278,13 +300,31 @@ export default {
// Build a map of active states for the `results` array
return result;
},
sourceInfo() {
return state.sources.info;
},
multipleSources() {
return Object.keys(state.sources.info).length > 1;
},
getContext() {
let result = url.extractSourceFromPath(decodeURIComponent(state.route.path));
if (this.selectedSource === "" || result.source === this.selectedSource) {
return result.path;
} else {
return "/"; // if searching on non-current source, search the whole thing
}
},
contextText() {
return `Search Context: ${this.getContext}`; // FIX: Use `this.getContext` as a property, not a function call
},
},
methods: {
getRelativeContext() {
return removePrefix(decodeURIComponent(state.route.path), "files");
updateSource(event) {
this.selectedSource = event.target.value; // Correct way to get the new value
this.submit();
},
getRelative(path) {
return removeTrailingSlash(window.location.href) + "/" + removeLeadingSlash(path);
return "/files/" + this.selectedSource + "/" + path;
},
getIcon(mimetype) {
return getMaterialIconForType(mimetype);
@@ -388,10 +428,12 @@ export default {
if (this.smallerThan != "") {
searchTypesFull = searchTypesFull + "type:smallerThan=" + this.smallerThan + " ";
}
let path = state.route.path;
this.ongoing = true;
this.results = await search(path, searchTypesFull + this.value);
let source = this.selectedSource;
if (source == "") {
source = state.sources.current;
}
this.results = await search(this.getContext, source, searchTypesFull + this.value);
this.ongoing = false;
if (this.results.length == 0) {
@@ -405,8 +447,8 @@ export default {
mutations.closeHovers();
},
addSelected(event, s) {
const pathParts = s.path.split("/");
const path = removePrefix(decodeURIComponent(state.route.path), "files") + s.path;
const pathParts = url.removeTrailingSlash(s.path).split("/");
const path = this.getContext + s.path;
const modifiedItem = {
name: pathParts.pop(),
path: path,
@@ -438,6 +480,15 @@ export default {
word-wrap: break-word;
}
.searchContext.input {
background-color: var(--primaryColor) !important;
border-radius: 0em;
border: unset;
width: 25%;
min-width: 7em;
max-width: 15em;
}
#results > #result-list {
max-height: 80vh;
width: 35em;
@@ -735,7 +786,7 @@ body.rtl #search .boxes h3 {
}
.filesize {
background-color: var(--surfaceSecondary);
background: var(--alt-background);
border-radius: 1em;
padding: 0.25em;
padding-left: 0.5em;
+20 -15
View File
@@ -5,7 +5,7 @@
item: true,
'no-select': true,
activebutton: isMaximized && isSelected,
hiddenFile: isHiddenNotSelected
hiddenFile: isHiddenNotSelected,
}"
:id="getID"
role="button"
@@ -26,7 +26,6 @@
@mouseup="cancelContext($event)"
>
<div @click="toggleClick" :class="{ activetitle: isMaximized && isSelected }">
<img
v-if="
readOnly === undefined &&
@@ -42,14 +41,24 @@
</div>
<div class="text" :class="{ activecontent: isMaximized && isSelected }">
<p :class="{'adjustment':quickDownloadEnabled}" class="name">{{ name }}</p>
<p class="size" :class="{'adjustment':quickDownloadEnabled}" :data-order="humanSize()">{{ humanSize() }}</p>
<p :class="{ adjustment: quickDownloadEnabled }" class="name">{{ name }}</p>
<p
class="size"
:class="{ adjustment: quickDownloadEnabled }"
:data-order="humanSize()"
>
{{ humanSize() }}
</p>
<p class="modified">
<time :datetime="modified">{{ getTime() }}</time>
</p>
</div>
<Icon @click="downloadFile" v-if="quickDownloadEnabled" mimetype="file_download" style="padding-right: 0.5em" />
<Icon
@click="downloadFile"
v-if="quickDownloadEnabled"
mimetype="file_download"
style="padding-right: 0.5em"
/>
</a>
</template>
@@ -118,14 +127,14 @@ export default {
"index",
"readOnly",
"path",
"hidden"
"hidden",
],
computed: {
quickDownloadEnabled() {
return state.user?.quickDownload;
},
isHiddenNotSelected() {
return !this.isSelected && this.hidden
return !this.isSelected && this.hidden;
},
getID() {
return url.base64Encode(encodeURIComponent(this.name));
@@ -161,11 +170,8 @@ export default {
return true;
},
thumbnailUrl() {
let path = state.req.path;
if (state.req.path == "/") {
path = "";
}
return filesApi.getPreviewURL(path + "/" + this.name, "small", state.req.modified);
let path = url.removeTrailingSlash(state.req.path) + "/" + this.name;
return filesApi.getPreviewURL(state.req.source, path, "small", state.req.modified);
},
isThumbsEnabled() {
return enableThumbs;
@@ -193,7 +199,7 @@ export default {
event.preventDefault();
mutations.resetSelected();
mutations.addSelected(this.index);
downloadFiles()
downloadFiles();
},
handleTouchMove(event) {
if (!state.isSafari) return;
@@ -446,5 +452,4 @@ export default {
.hiddenFile {
opacity: 0.5;
}
</style>
+3 -3
View File
@@ -13,7 +13,7 @@
class="card-action"
style="display: flex; align-items: center; justify-content: space-between"
>
<template v-if="user.perm.create">
<template v-if="user.perm.modify">
<button
class="button button--flat"
@click="$refs.fileList.createDir()"
@@ -76,14 +76,14 @@ export default {
if (state.isSearchActive) {
this.items = [
{
from: "/files" + state.selected[0].path,
from: state.selected[0].url,
name: state.selected[0].name,
},
];
} else {
for (let item of state.selected) {
this.items.push({
from: state.req.items[item].path,
from: state.req.items[item].url,
// add to: dest
name: state.req.items[item].name,
});
@@ -110,7 +110,7 @@ export default {
permissions: permissionsString,
};
usersApi.createApiKey(params);
await usersApi.createApiKey(params);
notify.showSuccess("successfully created!");
window.location.reload();
} catch (error) {
+3 -4
View File
@@ -70,14 +70,14 @@ export default {
try {
if (state.isSearchActive) {
await filesApi.remove(state.selected[0].path);
await filesApi.remove(state.selected[0].url);
buttons.success("delete");
notify.showSuccess("Deleted item successfully");
mutations.closeHovers();
return;
}
if (!this.isListing) {
await filesApi.remove(state.route.path);
await filesApi.remove(state.req.items[state.selected[0]].url);
buttons.success("delete");
notify.showSuccess("Deleted item successfully");
@@ -94,14 +94,13 @@ export default {
let promises = [];
for (let index of state.selected) {
promises.push(filesApi.remove(state.req.items[index].path));
promises.push(filesApi.remove(state.req.items[index].url));
}
await Promise.all(promises);
buttons.success("delete");
notify.showSuccess("Deleted item successfully! reloading...");
mutations.setReload(true); // Handle reload as neededs
} catch (e) {
buttons.done("delete");
notify.showError(e);
+3 -3
View File
@@ -12,7 +12,7 @@
class="card-action"
style="display: flex; align-items: center; justify-content: space-between"
>
<template v-if="user.perm.create">
<template v-if="user.perm.modify">
<button
class="button button--flat"
@click="$refs.fileList.createDir()"
@@ -75,14 +75,14 @@ export default {
if (state.isSearchActive) {
this.items = [
{
from: "/files" + state.selected[0].path,
from: state.selected[0].url,
name: state.selected[0].name,
},
];
} else {
for (let item of state.selected) {
this.items.push({
from: state.req.items[item].path,
from: state.req.items[item].url,
// add to: dest
name: state.req.items[item].name,
});
+2 -1
View File
@@ -73,8 +73,9 @@ export default {
if (event.keyCode === 27) {
event.stopImmediatePropagation();
mutations.closeHovers();
} else if (event.KeyCode === 8) {
event.preventDefault();
}
// Enter
if (event.keyCode === 13) {
switch (currentPrompt.name) {
+26 -6
View File
@@ -4,7 +4,10 @@
<h2>{{ $t("buttons.share") }}</h2>
</div>
<div aria-label="share-paths" class="searchContext">Path: {{ subpath }}</div>
<p>Note: anyone who has access to the link (and optional password) can access the shared files. There is no need to be logged in.</p>
<p>
Note: anyone who has access to the link (and optional password) can access the
shared files. There is no requirement to be logged in.
</p>
<template v-if="listing">
<div class="card-content">
<table>
@@ -126,6 +129,7 @@ import { notify } from "@/notify";
import { state, getters, mutations } from "@/store";
import { shareApi, publicApi } from "@/api";
import { fromNow } from "@/utils/moment";
import { removePrefix } from "@/utils/url";
import Clipboard from "clipboard";
export default {
@@ -137,6 +141,7 @@ export default {
links: [],
clip: null,
subpath: "",
source: "",
password: "",
listing: true,
};
@@ -173,15 +178,27 @@ export default {
},
async beforeMount() {
try {
let path = getters.routePath("files");
if (state.isSearchActive) {
this.subpath = state.selected[0].path;
path = state.selected[0].path;
this.source = state.selected[0].source;
} else {
let path = "." + getters.routePath("files");
this.source = state.req.source;
if (getters.selectedCount() === 1) {
path = path + state.req.items[this.selected[0]].name;
if (state.sources.count > 1) {
path = removePrefix(
state.req.items[this.selected[0]].url,
"files/" + state.req.source
);
} else {
path = removePrefix(state.req.items[this.selected[0]].url, "files");
}
}
if (state.sources.count > 1) {
path = removePrefix(path, state.req.source);
}
this.subpath = decodeURIComponent(path);
}
this.subpath = decodeURIComponent(path);
// get last element of the path
const links = await shareApi.get(this.subpath);
this.links = links;
@@ -211,10 +228,11 @@ export default {
let res = null;
if (isPermanent) {
res = await shareApi.create(this.subpath, this.password);
res = await shareApi.create(this.subpath, this.source, this.password);
} else {
res = await shareApi.create(
this.subpath,
this.source,
this.password,
this.time.toString(),
this.unit
@@ -251,6 +269,8 @@ export default {
return this.selected.length === 1 && !state.req.items[this.selected[0]].isDir;
},
buildDownloadLink(share) {
share.source = this.source;
share.path = this.subpath;
return publicApi.getDownloadURL(share);
},
sort() {
@@ -1,7 +1,7 @@
<template>
<div class="card floating">
<div class="card-content">
<p>{{ $t("prompts.deleteMessageShare", { path: "" }) }}</p>
<p>{{ $t("prompts.deleteMessageShare", { path }) }}</p>
</div>
<div class="card-action">
<button
@@ -24,10 +24,13 @@
</div>
</template>
<script>
import { getters } from "@/store"; // Import your custom store
import { getters, mutations } from "@/store"; // Import your custom store
export default {
name: "share-delete",
props: {
path: String,
},
computed: {
closeHovers() {
return mutations.closeHovers();
@@ -2,36 +2,14 @@
<div>
<h3>{{ $t("settings.permissions") }}</h3>
<p class="small">{{ $t("settings.permissionsHelp") }}</p>
<p>
<input type="checkbox" v-model="admin" />
{{ $t("settings.administrator") }}
</p>
<p>
<input type="checkbox" :disabled="admin" v-model="perm.create" />
{{ $t("settings.perm.create") }}
</p>
<p>
<input type="checkbox" :disabled="admin" v-model="perm.delete" />
{{ $t("settings.perm.delete") }}
</p>
<p>
<input type="checkbox" :disabled="admin" v-model="perm.download" />
{{ $t("settings.perm.download") }}
</p>
<p>
<input type="checkbox" :disabled="admin" v-model="perm.modify" />
{{ $t("settings.perm.modify") }}
</p>
<p v-if="isExecEnabled">
<input type="checkbox" :disabled="admin" v-model="perm.execute" />
{{ $t("settings.perm.execute") }}
</p>
<p>
<input type="checkbox" :disabled="admin" v-model="perm.rename" />
{{ $t("settings.perm.rename") }}
</p>
<p>
<input type="checkbox" :disabled="admin" v-model="perm.share" />
{{ $t("settings.perm.share") }}
+154 -30
View File
@@ -7,11 +7,11 @@
placeholder="enter new password"
v-model="user.password"
id="password"
@input="emitUpdate"
@input="setUpdatePassword"
/>
</div>
<div v-else>
<p v-if="!isDefault">
<p>
<label for="username">{{ $t("settings.username") }}</label>
<input
class="input input--block"
@@ -22,7 +22,7 @@
/>
</p>
<p v-if="!isDefault">
<p>
<label for="password">{{ $t("settings.password") }}</label>
<input
class="input input--block"
@@ -34,18 +34,57 @@
/>
</p>
<p>
<label for="scope">{{ $t("settings.scope") }}</label>
<p v-if="!isNew">
<input
:disabled="createUserDir"
:placeholder="scopePlaceholder"
class="input input--block"
type="text"
v-model="user.scope"
id="scope"
type="checkbox"
:checked="updatePassword"
@change="(event) => $emit('update:updatePassword', event.target.checked)"
/>
Change password on save
</p>
<p>
<input
type="checkbox"
:disabled="!stateUser.perm?.admin"
v-model="user.lockPassword"
@input="emitUpdate"
/>
{{ $t("settings.lockPassword") }}
</p>
<label for="scopes">{{ $t("settings.scopes") }}</label>
<div class="scope-list" v-for="(source, index) in selectedSources" :key="index">
<!-- Select dropdown -->
<select class="input flat-right" v-model="source.name">
<option v-for="s in sourceList" :key="s" :value="s.name">
{{ s.name }}
</option>
</select>
<!-- Input field for scope, bound to the selectedSources array -->
<input
class="input flat-left scope-input"
placeholder="scope eg. 'subfolder', leave blank for root"
@input="updateParent({ source: source, input: $event })"
:value="source.scope"
:class="{ 'flat-right': index != 0 }"
/>
<!-- Remove button -->
<button
v-if="index != 0"
class="button flat-left no-height"
@click="removeScope(index)"
>
<i class="material-icons material-size">delete</i>
</button>
</div>
<!-- Button to add more sources -->
<button v-if="hasMoreSources" @click="addNewScopeSource" class="button no-height">
<i class="material-icons material-size">add</i>
</button>
<p class="small" v-if="displayHomeDirectoryCheckbox">
<input type="checkbox" v-model="createUserDir" />
{{ $t("settings.createUserHomeDirectory") }}
@@ -61,16 +100,6 @@
></languages>
</p>
<p v-if="!isDefault">
<input
type="checkbox"
:disabled="stateUser.perm?.admin"
v-model="user.lockPassword"
@input="emitUpdate"
/>
{{ $t("settings.lockPassword") }}
</p>
<permissions :perm="localUser.perm" />
<commands v-if="isExecEnabled" v-model:commands="user.commands" />
</div>
@@ -82,6 +111,7 @@ import Permissions from "./Permissions.vue";
import Commands from "./Commands.vue";
import { enableExec } from "@/utils/constants";
import { state } from "@/store";
import { settingsApi } from "@/api";
export default {
name: "UserForm",
@@ -95,31 +125,60 @@ export default {
createUserDir: false,
originalUserScope: ".",
localUser: { ...this.user },
sourceList: [],
availableSources: [],
selectedSources: [],
};
},
props: {
user: Object, // Define user as a prop
isDefault: Boolean,
updatePassword: Boolean,
isNew: Boolean,
},
async mounted() {
if (!this.stateUser.perm.admin) {
this.sourceList = this.user.scopes;
} else {
this.sourceList = await settingsApi.get("sources");
}
this.localUser = { ...this.user };
this.selectedSources = [];
this.availableSources = [...this.sourceList];
if (this.isNew) {
const newSource = this.availableSources.shift(); // Take the first item instead of last
if (newSource) {
this.selectedSources.push(newSource);
}
} else {
// Populate selectedSources with existing user scopes
if (this.user.scopes) {
this.selectedSources = this.user.scopes;
// remove items with same name from availableSources
for (const source of this.selectedSources) {
this.availableSources = this.availableSources.filter(
(s) => source.name != s.name
);
}
}
}
},
watch: {
user: {
handler(newUser) {
this.localUser = { ...newUser }; // Watch for changes in the parent and update the local copy
},
immediate: true,
deep: true,
},
"user.perm.admin": function (newValue) {
if (newValue) {
this.user.lockPassword = false;
}
},
createUserDir(newVal) {
this.user.scope = newVal ? "" : this.originalUserScope;
this.user.scopes = newVal ? { default: "" } : this.originalUserScope;
},
},
computed: {
hasMoreSources() {
return this.availableSources.length > 0;
},
sourceInfo() {
return state.sources.info;
},
stateUser() {
return state.user;
},
@@ -138,5 +197,70 @@ export default {
return enableExec; // Removed arrow function
},
},
methods: {
setUpdatePassword() {
this.$emit("update:updatePassword", true);
},
updateParent(input) {
let updatedScopes = {};
// Update the selectedSources array directly
this.selectedSources.forEach((source) => {
if (source.name === input.source.name) {
updatedScopes[source.name] = input.input.target.value;
} else {
updatedScopes[source.name] = source.scope;
}
});
let intermediate = [];
Object.entries(updatedScopes).forEach(([key, value]) => {
intermediate.push({ name: key, scope: value });
});
let final = [];
for (const source of intermediate) {
final.push(source);
}
this.selectedSources = final;
this.$emit("update:user", { ...this.user, scopes: this.selectedSources });
},
addNewScopeSource(event) {
event.preventDefault();
if (this.availableSources.length > 0) {
const newSource = this.availableSources.pop();
if (newSource) {
this.selectedSources.push({ name: newSource.name, scope: "" });
}
}
},
removeScope(index) {
const removedSource = this.selectedSources.splice(index, 1)[0];
this.availableSources.push({ name: removedSource.name }); // Make source available again
},
},
};
</script>
<style>
.scope-list {
display: flex;
}
.flat-right {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.flat-left {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}
.scope-input {
width: 100%;
}
.no-height {
height: unset;
}
.material-size {
font-size: 1em !important;
}
</style>
+66 -38
View File
@@ -1,14 +1,18 @@
<template>
<div class="card clickable" style="min-height: 4em">
<div class="card" style="min-height: 4em">
<div class="card-wrapper user-card">
<div @click="navigateTo('/settings#profile-main')" class="inner-card">
<i class="material-icons">person</i>
{{ user.username }}
<i aria-label="settings" class="material-icons">settings</i>
<button class="person-button action">
<i class="material-icons">person</i>
{{ user.username }}
<i aria-label="settings" class="material-icons">settings</i>
</button>
</div>
<div class="inner-card logout-button" @click="logout">
<i v-if="canLogout" class="material-icons">exit_to_app</i>
<div class="inner-card" @click="logout">
<button aria-label="logout-button" class="logout-button action">
<i v-if="canLogout" class="material-icons">exit_to_app</i>
</button>
</div>
</div>
</div>
@@ -43,24 +47,32 @@
</div>
<!-- Section for logged-in users -->
<div v-if="loginCheck" class="sidebar-scroll-list">
<div v-if="loginCheck && !disableUsedPercentage" class="sidebar-scroll-list">
<div class="sources card">
<span>Sources</span>
<div class="inner-card">
<!-- My Files button -->
<button
class="action"
@click="navigateTo('/files/')"
v-for="(info, name) in sourceInfo"
:key="name"
class="action source-button"
:class="{ active: activeSource == name }"
@click="navigateTo('/files/' + info.pathPrefix)"
:aria-label="$t('sidebar.myFiles')"
title="default"
:title="name"
>
<i class="material-icons">folder</i>
<span>default</span>
<i class="material-icons source-icon">folder</i>
<span>{{ name }}</span>
<div>
<progress-bar :val="usage.usedPercentage" size="medium"></progress-bar>
<progress-bar
:val="info.usedPercentage"
text-position="inside"
:text="info.usedPercentage + '%'"
size="large"
text-fg-color="white"
></progress-bar>
<div class="usage-info">
<span>{{ usage.usedPercentage }}%</span>
<span>{{ usage.used }} of {{ usage.total }} used</span>
<span>{{ info.used }} of {{ info.total }} used</span>
</div>
</div>
</button>
@@ -93,9 +105,6 @@ export default {
hoverText: "Quick Toggles", // Initially empty
};
},
mounted() {
this.updateUsage();
},
computed: {
isSettings: () => getters.isSettings(),
isStickySidebar: () => getters.isStickySidebar(),
@@ -112,8 +121,9 @@ export default {
disableExternal: () => disableExternal,
disableUsedPercentage: () => disableUsedPercentage,
canLogout: () => !noAuth && loginPage,
usage: () => state.usage,
route: () => state.route,
sourceInfo: () => state.sources.info,
activeSource: () => state.sources.current,
},
watch: {
route() {
@@ -125,7 +135,27 @@ export default {
}
},
},
mounted() {
if (this.loginCheck) {
this.updateUsage();
}
},
methods: {
async updateUsage() {
if (!disableUsedPercentage) {
if (state.sources.info === undefined) {
return;
}
for (const source of Object.keys(state.sources.info)) {
let usage = await filesApi.usage(source);
let sourceInfo = state.sources.info[source];
sourceInfo.used = getHumanReadableFilesize(usage.used);
sourceInfo.total = getHumanReadableFilesize(usage.total);
sourceInfo.usedPercentage = Math.round((usage.used / usage.total) * 100);
mutations.updateSource(source, sourceInfo);
}
}
},
checkLogin() {
return getters.isLoggedIn() && !getters.routePath().startsWith("/share");
},
@@ -144,24 +174,6 @@ export default {
toggleSticky() {
mutations.updateCurrentUser({ stickySidebar: !state.user.stickySidebar });
},
async updateUsage() {
if (!this.checkLogin()) {
return;
}
let usageStats = { used: "0 B", total: "0 B", usedPercentage: 0 };
if (this.disableUsedPercentage) {
return usageStats;
}
let usage = await filesApi.usage("default");
usageStats = {
used: getHumanReadableFilesize(usage.used),
total: getHumanReadableFilesize(usage.total),
usedPercentage: Math.round((usage.used / usage.total) * 100),
};
mutations.setUsage(usageStats);
},
navigateTo(path) {
const hashIndex = path.indexOf("#");
if (hashIndex !== -1) {
@@ -213,7 +225,6 @@ export default {
align-items: center;
flex-direction: column;
padding: 1em;
margin-top: 0.5em !important;
}
.sources .inner-card {
@@ -250,9 +261,26 @@ button.action {
background-color: var(--primaryColor) !important;
border-radius: 10em;
}
.inner-card {
display: flex;
align-items: center;
padding: 0px !important;
}
.source-button {
margin-top: 0.5em !important;
}
.source-button.active {
background: var(--alt-background);
}
.source-icon {
padding: 0.1em !important;
}
.logout-button,
.person-button {
padding: 0 !important;
}
</style>
@@ -109,6 +109,7 @@ body.rtl nav {
white-space: nowrap;
height: 100%;
overflow: hidden;
padding: 0.5em;
text-overflow: ellipsis;
}
+3 -2
View File
@@ -131,7 +131,7 @@
"currentlyNavigating": "Currently navigating on:",
"deleteMessageMultiple": "Are you sure you want to delete {count} file(s)?",
"deleteMessageSingle": "Are you sure you want to delete this file/folder?",
"deleteMessageShare": "Are you sure you want to delete this share({path})?",
"deleteMessageShare": "Are you sure you want to delete this share: {path} ?",
"deleteTitle": "Delete files",
"displayName": "Display Name:",
"download": "Download files",
@@ -173,6 +173,7 @@
"video": "Video"
},
"settings": {
"scopes": "Define allowed sources and the user scope within them",
"UserManagement": "User Management",
"admin": "Admin",
"administrator": "Administrator",
@@ -181,7 +182,7 @@
"allowNew": "Create new files and directories",
"allowPublish": "Publish new posts and pages",
"allowSignup": "Allow users to signup",
"avoidChanges": "(leave blank to avoid changes)",
"avoidChanges": "also tick 'change password' to apply this password",
"branding": "Branding",
"brandingDirectoryPath": "Branding directory path",
"brandingHelp": "You can customize how your File Browser instance looks and feels by changing its name, replacing the logo, adding custom styles and even disable external links to GitHub.\nFor more information about custom branding, please check out the {0}.",
+1
View File
@@ -167,6 +167,7 @@
"video": "Vídeo"
},
"settings": {
"scopes": "Definir fuentes permitidas y el alcance del usuario dentro de ellas",
"admin": "Admin",
"administrator": "Administrador",
"allowCommands": "Ejecutar comandos",
+1
View File
@@ -167,6 +167,7 @@
"video": "Video"
},
"settings": {
"scopes": "Définir les sources autorisées et la portée de l'utilisateur au sein de celles-ci",
"admin": "Admin",
"administrator": "Administrateur",
"allowCommands": "Exécuter des commandes",
+1
View File
@@ -166,6 +166,7 @@
"video": "视频"
},
"settings": {
"scopes": "定义允许的来源及其中的用户范围",
"admin": "管理员",
"administrator": "管理员",
"allowCommands": "执行命令(Shell 命令)",
+1 -1
View File
@@ -9,7 +9,7 @@ export function showPopup(type, message, autoclose = true) {
}
const [popup, popupContent] = getElements()
if (popup === undefined) {
if (popup === undefined || popup == null) {
return
}
popup.classList.remove('success', 'error') // Clear previous types
+8 -1
View File
@@ -2,7 +2,7 @@ import { removePrefix } from "@/utils/url.js";
import { getFileExtension } from "@/utils/files.js";
import { state } from "./state.js";
import { mutations } from "./mutations.js";
import { noAuth } from "@/utils/constants.js";
import { noAuth,baseURL } from "@/utils/constants.js";
export const getters = {
isCardView: () => (state.user.viewMode == "gallery" || state.user.viewMode == "normal" ) && getters.currentView() == "listingView" ,
@@ -137,6 +137,13 @@ export const getters = {
routePath: (trimModifier="") => {
return removePrefix(state.route.path,trimModifier)
},
sharePathBase: () => {
let urlPath = getters.routePath("share");
// Step 1: Split the path by '/'
let parts = urlPath.split("/");
// Step 2: Assign hash to the second part (index 2) and join the rest for subPath
return baseURL + "share/"+ parts[1];
},
currentView: () => {
const pathname = getters.routePath()
if (pathname.startsWith(`/settings`)) {
+46 -19
View File
@@ -1,12 +1,35 @@
import * as i18n from "@/i18n";
import { state } from "./state.js";
import router from "@/router";
import { emitStateChanged } from './eventBus'; // Import the function from eventBus.js
import { usersApi } from "@/api";
import { notify } from "@/notify";
import { sortedItems } from "@/utils/sort.js";
export const mutations = {
setCurrentSource: (value) => {
state.sources.current = value;
emitStateChanged();
},
updateSource: (sourcename,value) => {
if (state.sources.info[sourcename]) {
state.sources.info[sourcename] = value;
}
emitStateChanged();
},
setSources: (user) => {
const currentSource = user.scopes.length > 0 ? user.scopes[0].name : "";
let sources = {info: {}, current: currentSource, count: user.scopes.length};
for (const source of user.scopes) {
sources.info[source.name] = {
pathPrefix: sources.count == 1 ? "" : source.name,
used: 0,
total: 0,
usedPercentage: 0,
};
}
state.sources = sources;
emitStateChanged();
},
setGallerySize: (value) => {
state.user.gallerySize = value
emitStateChanged();
@@ -14,7 +37,8 @@ export const mutations = {
},
setActiveSettingsView: (value) => {
state.activeSettingsView = value;
router.push({ hash: "#" + value });
// Update the hash in the URL without reloading or changing history state
window.history.replaceState(null, "", "#" + value);
const element = document.getElementById(value);
if (element) {
element.scrollIntoView({
@@ -57,8 +81,8 @@ export const mutations = {
state.upload = value;
emitStateChanged();
},
setUsage: (value) => {
state.usage = value;
setUsage: (source,value) => {
state.usages[source] = value;
emitStateChanged();
},
closeHovers: () => {
@@ -103,23 +127,26 @@ export const mutations = {
emitStateChanged();
},
setCurrentUser: (value) => {
localStorage.setItem("userData", undefined);
// If value is null or undefined, emit state change and exit early
if (!value) {
try {
localStorage.setItem("userData", undefined);
// If value is null or undefined, emit state change and exit early
if (!value) {
state.user = value;
emitStateChanged();
return;
}
mutations.setSources(value);
if (value.username != "publicUser") {
localStorage.setItem("userData", JSON.stringify(value));
}
// Ensure locale exists and is valid
if (!value.locale) {
value.locale = i18n.detectLocale(); // Default to detected locale if missing
}
state.user = value;
emitStateChanged();
return;
} catch (error) {
console.log(error);
}
if (value.username != "publicUser") {
localStorage.setItem("userData", JSON.stringify(value));
}
// Ensure locale exists and is valid
if (!value.locale) {
value.locale = i18n.detectLocale(); // Default to detected locale if missing
}
state.user = value;
// Emit state change after setting the user and locale
emitStateChanged();
},
setJWT: (value) => {
+7 -5
View File
@@ -8,12 +8,13 @@ export const state = reactive({
isMobile: window.innerWidth <= 800,
isSearchActive: false,
showSidebar: false,
usage: {
used: "0 B",
total: "0 B",
usedPercentage: 0
},
usages: {},
editor: null,
sources: {
current: "",
count: 1,
info: {},
},
user: {
quickDownloadEnabled: false,
gallarySize: 0,
@@ -22,6 +23,7 @@ export const state = reactive({
locale: detectLocale(), // Default to the locale from moment
viewMode: 'normal', // Default to mosaic view
showHidden: false, // Default to false, assuming this is a boolean
scopes: [],
perm: {},
rules: [], // Default to an empty array
permissions: {}, // Default to an empty object for permissions
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more