Compare commits

...
167 changed files with 2595 additions and 1539 deletions

No files matched your search

+2 -2
View File
@@ -28,7 +28,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.playwright
file: ./_docker/Dockerfile.playwright
push: false
push_latest_to_registry:
needs: [ test_frontend ]
@@ -65,7 +65,7 @@ jobs:
VERSION=${{ env.LATEST_TAG }}
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
file: ./Dockerfile
file: ./_docker/Dockerfile
push: true
tags: 'gtstef/filebrowser:latest'
labels: ${{ steps.meta.outputs.labels }}
+2 -2
View File
@@ -30,7 +30,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.playwright
file: ./_docker/Dockerfile.playwright
push: false
push_pr_to_registry:
name: Push PR
@@ -56,7 +56,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
file: ./_docker/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+2 -2
View File
@@ -31,7 +31,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.playwright
file: ./_docker/Dockerfile.playwright
push: false
create_release_tag:
needs: [ test_frontend ]
@@ -101,7 +101,7 @@ jobs:
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
file: ./Dockerfile
file: ./_docker/Dockerfile
push: true
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
platforms: linux/amd64
file: ./Dockerfile
file: ./_docker/Dockerfile
push: true
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
+2 -2
View File
@@ -31,7 +31,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.playwright
file: ./_docker/Dockerfile.playwright
push: false
create_release_tag:
needs: [ test_frontend ]
@@ -100,7 +100,7 @@ jobs:
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
file: ./Dockerfile
file: ./_docker/Dockerfile
push: true
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
+6 -2
View File
@@ -1,10 +1,14 @@
*.db
*.bak
*.log
*.mjs
_old
rice-box.go
.idea/
/backend
/backend.exe
/backend/backend
/backend/filebrowser
/backend/filebrowser.exe
/backend/backend.exe
/frontend/dist
/frontend/pkg
/frontend/test-results
+90 -2
View File
@@ -2,6 +2,94 @@
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.5.2-beta
**New Features**:
- Markdown file preview https://github.com/gtsteffaniak/filebrowser/issues/343
- Easy access download button https://github.com/gtsteffaniak/filebrowser/issues/341
**Notes**:
- Adds message about what sharing means when creating a link.
- api log duration is now always in milliseconds for consistency.
- advanced index config option `fileEndsWith` is now respected.
- Added Informative error for missing files for certificate load https://github.com/gtsteffaniak/filebrowser/issues/354
**BugFixes**:
- onlyoffice close window missing files issue https://github.com/gtsteffaniak/filebrowser/issues/345
- fixed download link inside file preview
## v0.5.1-beta
> Note: I changed the [config](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration#example-auth-config) for password auth again... It was a mistake just to make it a boolean, so now you can provide options, going forward this allows for more.
**New Features**:
- password length requirement config via `auth.methods.password.minLength` as a number of characters required.
**Bugfixes**:
- NoAuth error message "resource not found"
- CLI user configuration works and simplified see examples in the [Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/CLI)
## v0.5.0-beta
> Note: This Beta release includes a configuration change: `auth.method` is now deprecated. This is done to allow multiple login methods at once. Auth methods are specified via `auth.methods` instead. see [example on the wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration#example-auth-config).
**New Features**:
- Upload progress notification https://github.com/gtsteffaniak/filebrowser/issues/303
- proxy auth auto create user when `auth.methods.proxy.createUser: true` while using proxy auth.
**Notes**:
- Context menu positioning tweaks.
- using /tmp cachedir is disabled by default, cache dir can be specified via `server.cacheDir: /tmp` to enable it. https://github.com/gtsteffaniak/filebrowser/issues/326
**Bugfixes**:
- Gracefully shutdown to protect database. https://github.com/gtsteffaniak/filebrowser/issues/317
- validates auth method provided before server startup.
- fix sidebar disk space usage calculation. https://github.com/gtsteffaniak/filebrowser/issues/315
- Fixed proxy auth header support (make sure your proxy and server are secure!). https://github.com/gtsteffaniak/filebrowser/issues/322
## v0.4.2-beta
**New Features**:
- Hidden files changes
- windows hidden file properties are respected -- when running on windows binary (not docker) with NTFS filesystem.
- windows "system" files are considered hidden.
- changed user property from `hideDotFiles` to `showHidden`. Defaults to false, so a user would need to must unhide hidden files if they want to view hidden files.
**Notes**:
- cleaned up old and deprecated config.
- removed unneeded "Global settings". All system configuration is done on config yaml, See configuration wiki for more help.
**Bugfixes**:
- Another fix for memory https://github.com/gtsteffaniak/filebrowser/issues/298
## v0.4.1-beta
**New Features**:
- right-click actions are available on search. https://github.com/gtsteffaniak/filebrowser/issues/273
**Notes**:
- delete prompt now lists all items that will be affected by delete
- Debug and logger output tweaks.
**Bugfixes**:
- calculating checksums errors.
- copy/move issues for some circumstances.
- The previous position wasn't returned when closing a preview window https://github.com/gtsteffaniak/filebrowser/issues/298
- fixed sources configuration mapping error (advanced `server.sources` config)
## v0.4.0-beta
**New Features**:
- Better logging https://github.com/gtsteffaniak/filebrowser/issues/288
- highly configurable
- api logs include user
- onlyOffice support for editing only office files (inspired from https://github.com/filebrowser/filebrowser/pull/2954)
**Notes**:
- Breadcrumbs will only show on file listing (not on previews or editors)
- Config file is now optional. It will run with default settings without one and throw a `[WARN ]` message.
- Added more descriptions to swagger API
## v0.3.7-beta
**Notes**:
@@ -15,7 +103,7 @@ All notable changes to this project will be documented in this file. For commit
## v0.3.6-beta
**New Features**
**New Features**:
- Adds "externalUrl" server config https://github.com/gtsteffaniak/filebrowser/issues/272
**Notes**:
@@ -31,7 +119,7 @@ All notable changes to this project will be documented in this file. For commit
## v0.3.5
**New Features**
**New Features**:
- More indexing configuration options possible. However consider waiting on using this feature, because I will soon have a full onboarding experience in the UI to manage sources instead.
- added config file options "sources" in the server config.
- can enable/disable indexing a specified list of directories/files
-5
View File
@@ -1,5 +0,0 @@
FROM gtstef/playwright-base
WORKDIR /app
COPY [ "./backend/filebrowser*", "./"]
COPY [ "./frontend/", "./" ]
RUN ./filebrowser -c filebrowser-playwright.yaml & sleep 2 && npx playwright test
-5
View File
@@ -1,5 +0,0 @@
FROM node:22-slim
WORKDIR /app
COPY ./frontend/package.json ./
RUN npm i @playwright/test
RUN npx playwright install --with-deps firefox
+25 -27
View File
@@ -1,25 +1,28 @@
<p align="center">
<a href="https://opensource.org/license/apache-2-0/"><img src="https://img.shields.io/badge/License-Apache_2.0-blue.svg" alt="License: Apache-2.0"></a>
</p>
<p align="center">
<img src="frontend/public/img/icons/favicon-256x256.png" width="100" title="Login With Custom URL">
</p>
<h3 align="center">FileBrowser Quantum - A modern web-based file manager</h3>
<p align="center">
<img width="800" src="https://github.com/user-attachments/assets/b16acd67-0292-437a-a06c-bc83f95758e6" title="Main Screenshot">
</p>
<div align="center">
[![Go Report Card](https://goreportcard.com/badge/github.com/gtsteffaniak/filebrowser/backend)](https://goreportcard.com/report/github.com/gtsteffaniak/filebrowser/backend)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/1c48cfb7646d4009aa8c6f71287670b8)](https://www.codacy.com/gh/gtsteffaniak/filebrowser/dashboard)
[![latest version](https://img.shields.io/github/release/gtsteffaniak/filebrowser/all.svg)](https://github.com/gtsteffaniak/filebrowser/releases)
[![DockerHub Pulls](https://img.shields.io/docker/pulls/gtstef/filebrowser?label=latest%20Docker%20pulls)](https://hub.docker.com/r/gtstef/filebrowser)
[![Apache-2.0 License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0)
<img width="150" src="https://github.com/user-attachments/assets/59986a2a-f960-4536-aa35-4a9a7c98ad48" title="Logo">
<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">
</div>
> [!WARNING]
> There is no stable version yet.
> (planned for later this year after these are complete: multiple sources support, initial onboarding page, official automated docs website)
> There is no stable version -- planned 2025.
FileBrowser Quantum is a fork of the file browser open-source project with the following changes:
1. ✅ Indexes files efficiently. (See [indexing Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) for more info.)
1. ✅ Indexes files (See [indexing Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) for more info.)
- 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 and simplified GUI navbar and sidebar menu.
2. ✅ Revamped UI
- Additional compact view mode as well as refreshed view mode styles.
- Many graphical and user experience improvements.
- right-click context menu
@@ -36,16 +39,10 @@ Notable features that this fork *does not* have (removed):
- ❌ jobs/runners are not supported yet (planned).
- ❌ per-user rules are not supported yet (planned).
- ❌ pagination for directory items for extremely large directories.
- ❌ shell commands are completely removed and will not be returned.
- see feature matrix below for more.
## About
FileBrowser Quantum provides a file-managing interface within a specified directory
and can be used to upload, delete, preview, rename, and edit your files.
It allows the creation of multiple users and each user can have its directory.
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
@@ -98,6 +95,9 @@ See the [API Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/API)
Configuration is done via the `config.yaml`, see the [Configuration Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration) for available configuration options and other help.
## Office File Support
See [Office Support Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Office-Support#adding-open-office-integration-for-docker) on how to enable office file editing and office related features.
## Migration from the original filebrowser
@@ -105,7 +105,7 @@ See the [Migration Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Migrat
## Comparison Chart
Application Name | <img width="48" src="frontend/public/img/icons/favicon-256x256.png" > Quantum | <img width="48" src="https://github.com/filebrowser/filebrowser/blob/master/frontend/public/img/logo.svg" > Filebrowser | <img width="48" src="https://github.com/mickael-kerjean/filestash/blob/master/public/assets/logo/app_icon.png?raw=true" > Filestash | <img width="48" src="https://avatars.githubusercontent.com/u/19211038?s=200&v=4" > Nextcloud | <img width="48" src="https://upload.wikimedia.org/wikipedia/commons/thumb/d/da/Google_Drive_logo.png/480px-Google_Drive_logo.png" > Google_Drive | <img width="48" src="https://avatars.githubusercontent.com/u/6422152?v=4" > FileRun
Application Name | <img width="48" src="https://github.com/user-attachments/assets/59986a2a-f960-4536-aa35-4a9a7c98ad48" > Quantum | <img width="48" src="https://github.com/filebrowser/filebrowser/blob/master/frontend/public/img/logo.svg" > Filebrowser | <img width="48" src="https://github.com/mickael-kerjean/filestash/blob/master/public/assets/logo/app_icon.png?raw=true" > Filestash | <img width="48" src="https://avatars.githubusercontent.com/u/19211038?s=200&v=4" > Nextcloud | <img width="48" src="https://upload.wikimedia.org/wikipedia/commons/thumb/d/da/Google_Drive_logo.png/480px-Google_Drive_logo.png" > Google_Drive | <img width="48" src="https://avatars.githubusercontent.com/u/6422152?v=4" > FileRun
--- | --- | --- | --- | --- | --- | --- |
Filesystem support | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
Linux | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
@@ -115,7 +115,7 @@ Self hostable | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Has Stable Release? | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
webdav support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
ftp support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
Dedicated docs site? | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ |
Multiple sources at once | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
Docker image size | 31 MB | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
@@ -142,17 +142,15 @@ Event-based notifications | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Metrics | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
file space quotas | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
text-based files editor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
office file support | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
office file support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Office file previews | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
Themes | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
Branding support | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
activity log | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
Comments support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
collaboration on same file | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
trash support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
Starred/pinned files | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
Content preview icons | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ |
Plugins support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
Chromecast support | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
Share collections of files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Can archive selected files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Can browse archive files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅
Can browse archive files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
View File
File renamed without changes.
+6
View File
@@ -0,0 +1,6 @@
FROM gtstef/playwright-base
WORKDIR /app/frontend
COPY [ "./frontend/", "./" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
RUN ./filebrowser -c filebrowser-playwright.yaml & sleep 2 && cd ../frontend && npx playwright test
+4
View File
@@ -0,0 +1,4 @@
FROM node:22-slim
WORKDIR /app/frontend
RUN npm i @playwright/test
RUN npx playwright install --with-deps firefox
+15
View File
@@ -0,0 +1,15 @@
services:
nginx-proxy-auth:
image: nginx
container_name: nginx-proxy-auth
ports:
- "8080:80"
volumes:
- ./src/default.conf:/etc/nginx/conf.d/default.conf
filebrowser:
volumes:
- '../frontend:/home/frontend'
- "./src/config.yaml:/home/filebrowser/config.yaml"
build:
context: ../
dockerfile: ./_docker/Dockerfile
+8
View File
@@ -0,0 +1,8 @@
server:
port: 80
baseURL: "/"
root: "../frontend/tests/playwright-files"
auth:
method: proxy
header: X-Username
signup: false
+13
View File
@@ -0,0 +1,13 @@
server {
listen 80;
server_name localhost 127.0.0.1;
location / {
proxy_pass http://filebrowser;
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;
}
}
+18 -3
View File
@@ -10,7 +10,6 @@ builds:
main: main.go
binary: filebrowser
goos:
- darwin
- linux
goarch:
- amd64
@@ -21,7 +20,7 @@ builds:
- "7"
hooks:
post:
- upx --force-macos {{ .Path }} # Compress the binary with UPX
- upx {{ .Path }} # Compress the binary with UPX
# Build configuration for windows without arm
- id: windows
@@ -36,8 +35,24 @@ builds:
post:
- upx {{ .Path }} # Compress the binary with UPX
# Build configuration for windows without arm & upx
- id: macos
ldflags: *ldflags
main: main.go
binary: filebrowser
goos:
- darwin
goarch:
- amd64
- arm64
archives:
- name_template: "{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{ .ProjectName }}"
- name_template: >
{{- if eq .Os "windows" -}}
{{.ProjectName}}
{{- else -}}
{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{.ProjectName}}
{{- end -}}
format: binary
checksum:
+14 -14
View File
@@ -3,13 +3,13 @@ package auth
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
@@ -146,16 +146,16 @@ 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,
HideDotfiles: a.Settings.UserDefaults.HideDotfiles,
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,
}
u = a.GetUser(d)
@@ -164,7 +164,7 @@ func (a *HookAuth) SaveUser() (*users.User, error) {
return nil, fmt.Errorf("user: failed to mkdir user home dir: [%s]", userHome)
}
u.Scope = userHome
log.Printf("user: %s, home dir: [%s].", u.Username, userHome)
logger.Debug(fmt.Sprintf("user: %s, home dir: [%s].", u.Username, userHome))
err = a.Users.Save(u)
if err != nil {
@@ -209,7 +209,7 @@ func (a *HookAuth) GetUser(d *users.User) *users.User {
By: d.Sorting.By,
},
Commands: d.Commands,
HideDotfiles: d.HideDotfiles,
ShowHidden: d.ShowHidden,
Perm: perms,
LockPassword: true,
}
@@ -232,7 +232,7 @@ var validHookFields = []string{
"user.sorting.by",
"user.sorting.asc",
"user.commands",
"user.hideDotfiles",
"user.showHidden",
"user.perm.admin",
"user.perm.execute",
"user.perm.create",
+19 -2
View File
@@ -17,8 +17,25 @@ type Storage struct {
}
// NewStorage creates a auth storage from a backend.
func NewStorage(back StorageBackend, userStore *users.Storage) *Storage {
return &Storage{back: back, users: userStore}
func NewStorage(back StorageBackend, userStore *users.Storage) (*Storage, error) {
store := &Storage{back: back, users: userStore}
err := store.Save(&JSONAuth{})
if err != nil {
return nil, err
}
err = store.Save(&ProxyAuth{})
if err != nil {
return nil, err
}
err = store.Save(&HookAuth{})
if err != nil {
return nil, err
}
err = store.Save(&NoAuth{})
if err != nil {
return nil, err
}
return store, nil
}
// Get wraps a StorageBackend.Get.
+12 -5
View File
@@ -1,4 +1,4 @@
package utils
package cache
import (
"sync"
@@ -6,12 +6,13 @@ import (
)
var (
DiskUsageCache = newCache(30*time.Second, 24*time.Hour)
RealPathCache = newCache(48*time.Hour, 72*time.Hour)
SearchResultsCache = newCache(15*time.Second, time.Hour)
DiskUsage = NewCache(30*time.Second, 24*time.Hour)
RealPath = NewCache(48*time.Hour, 72*time.Hour)
SearchResults = NewCache(15*time.Second, time.Hour)
OnlyOffice = NewCache(48*time.Hour, 1*time.Hour)
)
func newCache(expires time.Duration, cleanup time.Duration) *KeyCache {
func NewCache(expires time.Duration, cleanup time.Duration) *KeyCache {
newCache := KeyCache{
data: make(map[string]cachedValue),
expiresAfter: expires, // default
@@ -40,6 +41,12 @@ func (c *KeyCache) Set(key string, value interface{}) {
}
}
func (c *KeyCache) Delete(key string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.data, key)
}
func (c *KeyCache) SetWithExp(key string, value interface{}, exp time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
+108 -44
View File
@@ -1,16 +1,19 @@
package cmd
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"strings"
"syscall"
"github.com/gtsteffaniak/filebrowser/backend/diskcache"
"github.com/gtsteffaniak/filebrowser/backend/files"
fbhttp "github.com/gtsteffaniak/filebrowser/backend/http"
"github.com/gtsteffaniak/filebrowser/backend/img"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
@@ -25,21 +28,22 @@ func getStore(config string) (*storage.Storage, bool) {
settings.Initialize(config)
store, hasDB, err := storage.InitializeDb(settings.Config.Server.Database)
if err != nil {
log.Fatal("could not load db info: ", err)
logger.Fatal(fmt.Sprintf("could not load db info: %v", err))
}
return store, hasDB
}
func generalUsage() {
fmt.Printf(`usage: ./filebrowser <command> [options]
commands:
-v Print the version
-c Print the default config file
set -u Username and password for the new user
set -a Create user as admin
set -s Specify a user scope
set -h Print this help message
` + "\n")
commands:
-h Print help
-c Print the default config file
version Print version information
set -u Username and password for the new user
set -a Create user as admin
set -s Specify a user scope
set -h Print this help message
`)
}
func StartFilebrowser() {
@@ -70,47 +74,79 @@ func StartFilebrowser() {
setCmd.StringVar(&scope, "s", "", "Specify a user scope, otherwise default user config scope is used")
setCmd.StringVar(&dbConfig, "c", "config.yaml", "Path to the config file, default: config.yaml")
// Create context and channels for graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{}) // Signals server has stopped
shutdownComplete := make(chan struct{}) // Signals shutdown process is complete
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
// Parse subcommand flags only if a subcommand is specified
if len(os.Args) > 1 {
switch os.Args[1] {
case "set":
err := setCmd.Parse(os.Args)
err := setCmd.Parse(os.Args[2:])
if err != nil {
setCmd.PrintDefaults()
os.Exit(1)
}
userInfo := strings.Split(user, ",")
if len(userInfo) < 2 {
fmt.Println("not enough info to create user: \"set -u username,password\"")
fmt.Printf("not enough info to create user: \"set -u username,password\", only provided %v\n", userInfo)
setCmd.PrintDefaults()
os.Exit(1)
}
username := userInfo[0]
password := userInfo[1]
getStore(dbConfig)
// Create the user logic
if asAdmin {
log.Printf("Creating user as admin: %s\n", username)
} else {
log.Printf("Creating user: %s\n", username)
store, ok := getStore(dbConfig)
if !ok {
logger.Fatal("could not load db info")
}
newUser := users.User{
Username: username,
Password: password,
}
if scope != "" {
newUser.Scope = scope
}
err = storage.CreateUser(newUser, asAdmin)
user, err := store.Users.Get("", username)
if err != nil {
log.Fatal("Could not create user: ", err)
newUser := users.User{
Username: username,
Password: password,
}
if scope != "" {
newUser.Scope = scope
} else {
newUser.Scope = settings.Config.UserDefaults.Scope
}
// Create the user logic
if asAdmin {
logger.Info(fmt.Sprintf("Creating user as admin: %s\n", username))
} else {
logger.Info(fmt.Sprintf("Creating non-admin user: %s\n", username))
}
err = storage.CreateUser(newUser, asAdmin)
if err != nil {
logger.Error(fmt.Sprintf("could not create user: %v", err))
}
return
}
user.Password = password
if scope != "" {
user.Scope = scope
}
if asAdmin {
user.Perm.Admin = true
}
err = store.Users.Save(user)
if err != nil {
logger.Error(fmt.Sprintf("could not update user: %v", err))
}
fmt.Printf("successfully updated user: %s\n", username)
return
case "version":
fmt.Println("FileBrowser Quantum - A modern web-based file manager")
fmt.Printf("Version : %v\n", version.Version)
fmt.Printf("Commit : %v\n", version.CommitSHA)
fmt.Printf("Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v\n", version.Version)
fmt.Printf(`FileBrowser Quantum - A modern web-based file manager
Version : %v
Commit : %v
Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v
`, version.Version, version.CommitSHA, version.Version)
return
}
}
@@ -119,16 +155,28 @@ func StartFilebrowser() {
if !dbExists {
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.Database)
}
log.Printf("Initializing FileBrowser Quantum (%v)\n", version.Version)
log.Printf("Using Config file : %v", configPath)
log.Println("Embeded frontend :", os.Getenv("FILEBROWSER_NO_EMBEDED") != "true")
log.Println(database)
sources := []string{}
for _, v := range settings.Config.Server.Sources {
sources = append(sources, v.Name+": "+v.Path)
}
log.Println("Sources :", sources)
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(database)
logger.Info(fmt.Sprintf("Sources : %v", sources))
serverConfig := settings.Config.Server
swagInfo := docs.SwaggerInfo
swagInfo.BasePath = serverConfig.BaseURL
@@ -136,23 +184,39 @@ func StartFilebrowser() {
// initialize indexing and schedule indexing ever n minutes (default 5)
sourceConfigs := settings.Config.Server.Sources
if len(sourceConfigs) == 0 {
log.Fatal("No sources configured, exiting...")
logger.Fatal("No sources configured, exiting...")
}
for _, source := range sourceConfigs {
go files.Initialize(source)
}
if err := rootCMD(store, &serverConfig); err != nil {
log.Fatal("Error starting filebrowser:", err)
// Start the rootCMD in a goroutine
go func() {
if err := rootCMD(ctx, store, &serverConfig, shutdownComplete); err != nil {
logger.Fatal(fmt.Sprintf("Error starting filebrowser: %v", err))
}
close(done) // Signal that the server has stopped
}()
// Wait for a shutdown signal or the server to stop
select {
case <-signalChan:
logger.Info("Received shutdown signal. Shutting down gracefully...")
cancel() // Trigger context cancellation
case <-done:
logger.Info("Server stopped unexpectedly. Shutting down...")
}
<-shutdownComplete // Ensure we don't exit prematurely
// Wait for the server to stop
logger.Info("Shutdown complete.")
}
func rootCMD(store *storage.Storage, serverConfig *settings.Server) error {
func rootCMD(ctx context.Context, store *storage.Storage, serverConfig *settings.Server, shutdownComplete chan struct{}) error {
if serverConfig.NumImageProcessors < 1 {
log.Fatal("Image resize workers count could not be < 1")
logger.Fatal("Image resize workers count could not be < 1")
}
imgSvc := img.New(serverConfig.NumImageProcessors)
cacheDir := "/tmp"
cacheDir := settings.Config.Server.CacheDir
var fileCache diskcache.Interface
// Use file cache if cacheDir is specified
@@ -160,13 +224,13 @@ func rootCMD(store *storage.Storage, serverConfig *settings.Server) error {
var err error
fileCache, err = diskcache.NewFileCache(cacheDir)
if err != nil {
log.Fatalf("failed to create file cache: %v", err)
logger.Fatal(fmt.Sprintf("failed to create file cache: %v", err))
}
} else {
// No-op cache if no cacheDir is specified
fileCache = diskcache.NewNoOp()
}
fbhttp.StartHttp(imgSvc, store, fileCache)
fbhttp.StartHttp(ctx, imgSvc, store, fileCache, shutdownComplete)
return nil
}
-68
View File
@@ -1,68 +0,0 @@
package cmd
import (
"strconv"
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
func init() {
rulesCmd.AddCommand(rulesRmCommand)
rulesRmCommand.Flags().Uint("index", 0, "index of rule to remove")
_ = rulesRmCommand.MarkFlagRequired("index")
}
var rulesRmCommand = &cobra.Command{
Use: "rm <index> [index_end]",
Short: "Remove a global rule or user rule",
Long: `Remove a global rule or user rule. The provided index
is the same that's printed when you run 'rules ls'. Note
that after each removal/addition, the index of the
commands change. So be careful when removing them after each
other.
You can also specify an optional parameter (index_end) so
you can remove all commands from 'index' to 'index_end',
including 'index_end'.`,
Args: func(cmd *cobra.Command, args []string) error {
if err := cobra.RangeArgs(1, 2)(cmd, args); err != nil { //nolint:gomnd
return err
}
for _, arg := range args {
if _, err := strconv.Atoi(arg); err != nil {
return err
}
}
return nil
},
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
i, err := strconv.Atoi(args[0])
utils.CheckErr("strconv.Atoi", err)
f := i
if len(args) == 2 { //nolint:gomnd
f, err = strconv.Atoi(args[1])
utils.CheckErr("strconv.Atoi", err)
}
user := func(u *users.User) {
u.Rules = append(u.Rules[:i], u.Rules[f+1:]...)
err := store.Users.Save(u)
utils.CheckErr("store.Users.Save", err)
}
global := func(s *settings.Settings) {
s.Rules = append(s.Rules[:i], s.Rules[f+1:]...)
err := store.Settings.Save(s)
utils.CheckErr("store.Settings.Save", err)
}
runRules(store, cmd, user, global)
}),
}
-86
View File
@@ -1,86 +0,0 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
func init() {
rulesCmd.PersistentFlags().StringP("username", "u", "", "username of user to which the rules apply")
rulesCmd.PersistentFlags().UintP("id", "i", 0, "id of user to which the rules apply")
}
var rulesCmd = &cobra.Command{
Use: "rules",
Short: "Rules management utility",
Long: `On each subcommand you'll have available at least two flags:
"username" and "id". You must either set only one of them
or none. If you set one of them, the command will apply to
an user, otherwise it will be applied to the global set or
rules.`,
Args: cobra.NoArgs,
}
func runRules(st *storage.Storage, cmd *cobra.Command, usersFn func(*users.User), globalFn func(*settings.Settings)) {
id := getUserIdentifier(cmd.Flags())
if id != nil {
user, err := st.Users.Get("", id)
utils.CheckErr("st.Users.Get", err)
if usersFn != nil {
usersFn(user)
}
printRules(user.Rules, id)
return
}
s, err := st.Settings.Get()
utils.CheckErr("st.Settings.Get", err)
if globalFn != nil {
globalFn(s)
}
printRules(s.Rules, id)
}
func getUserIdentifier(flags *pflag.FlagSet) interface{} {
id := mustGetUint(flags, "id")
username := mustGetString(flags, "username")
if id != 0 {
return id
} else if username != "" {
return username
}
return nil
}
func printRules(rulez []users.Rule, id interface{}) {
for id, rule := range rulez {
fmt.Printf("(%d) ", id)
if rule.Regex {
if rule.Allow {
fmt.Printf("Allow Regex: \t%s\n", rule.Regexp.Raw)
} else {
fmt.Printf("Disallow Regex: \t%s\n", rule.Regexp.Raw)
}
} else {
if rule.Allow {
fmt.Printf("Allow Path: \t%s\n", rule.Path)
} else {
fmt.Printf("Disallow Path: \t%s\n", rule.Path)
}
}
}
}
-59
View File
@@ -1,59 +0,0 @@
package cmd
import (
"regexp"
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
func init() {
rulesCmd.AddCommand(rulesAddCmd)
rulesAddCmd.Flags().BoolP("allow", "a", false, "indicates this is an allow rule")
rulesAddCmd.Flags().BoolP("regex", "r", false, "indicates this is a regex rule")
}
var rulesAddCmd = &cobra.Command{
Use: "add <path|expression>",
Short: "Add a global rule or user rule",
Long: `Add a global rule or user rule.`,
Args: cobra.ExactArgs(1),
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
allow := mustGetBool(cmd.Flags(), "allow")
regex := mustGetBool(cmd.Flags(), "regex")
exp := args[0]
if regex {
regexp.MustCompile(exp)
}
rule := users.Rule{
Allow: allow,
Regex: regex,
}
if regex {
rule.Regexp = &users.Regexp{Raw: exp}
} else {
rule.Path = exp
}
user := func(u *users.User) {
u.Rules = append(u.Rules, rule)
err := store.Users.Save(u)
utils.CheckErr("store.Users.Save", err)
}
global := func(s *settings.Settings) {
s.Rules = append(s.Rules, rule)
err := store.Settings.Save(s)
utils.CheckErr("store.Settings.Save", err)
}
runRules(store, cmd, user, global)
}),
}
-20
View File
@@ -1,20 +0,0 @@
package cmd
import (
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/spf13/cobra"
)
func init() {
rulesCmd.AddCommand(rulesLsCommand)
}
var rulesLsCommand = &cobra.Command{
Use: "ls",
Short: "List global rules or user specific rules",
Long: `List global rules or user specific rules.`,
Args: cobra.NoArgs,
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
runRules(store, cmd, nil, nil)
}),
}
-54
View File
@@ -1,54 +0,0 @@
package cmd
import (
"fmt"
"os"
"strconv"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
var usersCmd = &cobra.Command{
Use: "users",
Short: "Users management utility",
Long: `Users management utility.`,
Args: cobra.NoArgs,
}
func printUsers(usrs []*users.User) {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) //nolint:gomnd
fmt.Fprintln(w, "ID\tUsername\tScope\tLocale\tV. Mode\tS.Click\tAdmin\tExecute\tCreate\tRename\tModify\tDelete\tShare\tDownload\tPwd Lock")
for _, u := range usrs {
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t\n",
u.ID,
u.Username,
u.Scope,
u.Locale,
u.ViewMode,
u.SingleClick,
u.Perm.Admin,
u.Perm.Execute,
u.Perm.Create,
u.Perm.Rename,
u.Perm.Modify,
u.Perm.Delete,
u.Perm.Share,
u.Perm.Download,
u.LockPassword,
)
}
w.Flush()
}
func parseUsernameOrID(arg string) (username string, id uint) {
id64, err := strconv.ParseUint(arg, 10, 64)
if err != nil {
return arg, 0
}
return "", uint(id64)
}
-42
View File
@@ -1,42 +0,0 @@
package cmd
import (
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
func init() {
usersCmd.AddCommand(usersAddCmd)
}
var usersAddCmd = &cobra.Command{
Use: "add <username> <password>",
Short: "Create a new user",
Long: `Create a new user and add it to the database.`,
Args: cobra.ExactArgs(2), //nolint:gomnd
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
user := &users.User{
Username: args[0],
Password: args[1],
LockPassword: mustGetBool(cmd.Flags(), "lockPassword"),
}
servSettings, err := store.Settings.GetServer()
utils.CheckErr("store.Settings.GetServer()", err)
// since getUserDefaults() polluted s.Defaults.Scope
// which makes the Scope not the one saved in the db
// we need the right s.Defaults.Scope here
s2, err := store.Settings.Get()
utils.CheckErr("store.Settings.Get()", err)
userHome, err := s2.MakeUserDir(user.Username, user.Scope, servSettings.Root)
utils.CheckErr("s2.MakeUserDir", err)
user.Scope = userHome
err = store.Users.Save(user)
utils.CheckErr("store.Users.Save", err)
printUsers([]*users.User{user})
}),
}
-26
View File
@@ -1,26 +0,0 @@
package cmd
import (
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/utils"
"github.com/spf13/cobra"
)
func init() {
usersCmd.AddCommand(usersExportCmd)
}
var usersExportCmd = &cobra.Command{
Use: "export <path>",
Short: "Export all users to a file.",
Long: `Export all users to a json or yaml file. Please indicate the
path to the file where you want to write the users.`,
Args: jsonYamlArg,
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
list, err := store.Users.Gets("")
utils.CheckErr("store.Users.Gets", err)
err = marshal(args[0], list)
utils.CheckErr("marshal", err)
}),
}
-53
View File
@@ -1,53 +0,0 @@
package cmd
import (
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
func init() {
usersCmd.AddCommand(usersFindCmd)
usersCmd.AddCommand(usersLsCmd)
}
var usersFindCmd = &cobra.Command{
Use: "find <id|username>",
Short: "Find a user by username or id",
Long: `Find a user by username or id. If no flag is set, all users will be printed.`,
Args: cobra.ExactArgs(1),
Run: findUsers,
}
var usersLsCmd = &cobra.Command{
Use: "ls",
Short: "List all users.",
Args: cobra.NoArgs,
Run: findUsers,
}
var findUsers = cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
var (
list []*users.User
user *users.User
err error
)
if len(args) == 1 {
username, id := parseUsernameOrID(args[0])
if username != "" {
user, err = store.Users.Get("", username)
} else {
user, err = store.Users.Get("", id)
}
list = []*users.User{user}
} else {
list, err = store.Users.Gets("")
}
utils.CheckErr("findUsers", err)
printUsers(list)
})
-88
View File
@@ -1,88 +0,0 @@
package cmd
import (
"errors"
"fmt"
"os"
"strconv"
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
func init() {
usersCmd.AddCommand(usersImportCmd)
usersImportCmd.Flags().Bool("overwrite", false, "overwrite users with the same id/username combo")
usersImportCmd.Flags().Bool("replace", false, "replace the entire user base")
}
var usersImportCmd = &cobra.Command{
Use: "import <path>",
Short: "Import users from a file",
Long: `Import users from a file. The path must be for a json or yaml
file. You can use this command to import new users to your
installation. For that, just don't place their ID on the files
list or set it to 0.`,
Args: jsonYamlArg,
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
fd, err := os.Open(args[0])
utils.CheckErr("os.Open", err)
defer fd.Close()
list := []*users.User{}
err = unmarshal(args[0], &list)
utils.CheckErr("unmarshal", err)
if mustGetBool(cmd.Flags(), "replace") {
oldUsers, err := store.Users.Gets("")
utils.CheckErr("store.Users.Gets", err)
err = marshal("users.backup.json", list)
utils.CheckErr("marshal users.backup.json", err)
for _, user := range oldUsers {
err = store.Users.Delete(user.ID)
utils.CheckErr("store.Users.Delete", err)
}
}
overwrite := mustGetBool(cmd.Flags(), "overwrite")
for _, user := range list {
onDB, err := store.Users.Get("", user.ID)
// User exists in DB.
if err == nil {
if !overwrite {
newErr := errors.New("user " + strconv.Itoa(int(user.ID)) + " is already registered")
utils.CheckErr("", newErr)
}
// If the usernames mismatch, check if there is another one in the DB
// with the new username. If there is, print an error and cancel the
// operation
if user.Username != onDB.Username {
if conflictuous, err := store.Users.Get("", user.Username); err == nil { //nolint:govet
newErr := usernameConflictError(user.Username, conflictuous.ID, user.ID)
utils.CheckErr("usernameConflictError", newErr)
}
}
} else {
// If it doesn't exist, set the ID to 0 to automatically get a new
// one that make sense in this DB.
user.ID = 0
}
err = store.Users.Save(user)
utils.CheckErr("store.Users.Save", err)
}
}),
}
func usernameConflictError(username string, originalID, newID uint) error {
return fmt.Errorf(`can't import user with ID %d and username "%s" because the username is already registred with the user %d`,
newID, username, originalID)
}
-33
View File
@@ -1,33 +0,0 @@
package cmd
import (
"log"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/utils"
"github.com/spf13/cobra"
)
func init() {
usersCmd.AddCommand(usersRmCmd)
}
var usersRmCmd = &cobra.Command{
Use: "rm <id|username>",
Short: "Delete a user by username or id",
Long: `Delete a user by username or id`,
Args: cobra.ExactArgs(1),
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
username, id := parseUsernameOrID(args[0])
var err error
if username != "" {
err = store.Users.Delete(username)
} else {
err = store.Users.Delete(id)
}
utils.CheckErr("usersRmCmd", err)
log.Println("user deleted successfully")
}),
}
-40
View File
@@ -1,40 +0,0 @@
package cmd
import (
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
func init() {
usersCmd.AddCommand(usersUpdateCmd)
}
var usersUpdateCmd = &cobra.Command{
Use: "update <id|username>",
Short: "Updates an existing user",
Long: `Updates an existing user. Set the flags for the
options you want to change.`,
Args: cobra.ExactArgs(1),
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
username, id := parseUsernameOrID(args[0])
var (
err error
user *users.User
)
if id != 0 {
user, err = store.Users.Get("", id)
} else {
user, err = store.Users.Get("", username)
}
utils.CheckErr("store.Users.Get", err)
err = store.Users.Update(user)
utils.CheckErr("store.Users.Update", err)
printUsers([]*users.User{user})
}),
}
-88
View File
@@ -1,88 +0,0 @@
package cmd
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"github.com/goccy/go-yaml"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
func mustGetString(flags *pflag.FlagSet, flag string) string {
s, err := flags.GetString(flag)
utils.CheckErr("mustGetString", err)
return s
}
func mustGetBool(flags *pflag.FlagSet, flag string) bool {
b, err := flags.GetBool(flag)
utils.CheckErr("mustGetBool", err)
return b
}
func mustGetUint(flags *pflag.FlagSet, flag string) uint {
b, err := flags.GetUint(flag)
utils.CheckErr("mustGetUint", err)
return b
}
type cobraFunc func(cmd *cobra.Command, args []string)
type pythonFunc func(cmd *cobra.Command, args []string, store *storage.Storage)
func marshal(filename string, data interface{}) error {
fd, err := os.Create(filename)
utils.CheckErr("os.Create", err)
defer fd.Close()
switch ext := filepath.Ext(filename); ext {
case ".json":
encoder := json.NewEncoder(fd)
encoder.SetIndent("", " ")
return encoder.Encode(data)
case ".yml", ".yaml": //nolint:goconst
_, err := yaml.Marshal(fd)
return err
default:
return errors.New("invalid format: " + ext)
}
}
func unmarshal(filename string, data interface{}) error {
fd, err := os.Open(filename)
utils.CheckErr("os.Open", err)
defer fd.Close()
switch ext := filepath.Ext(filename); ext {
case ".json":
return json.NewDecoder(fd).Decode(data)
case ".yml", ".yaml":
return yaml.NewDecoder(fd).Decode(data)
default:
return errors.New("invalid format: " + ext)
}
}
func jsonYamlArg(cmd *cobra.Command, args []string) error {
if err := cobra.ExactArgs(1)(cmd, args); err != nil {
return err
}
switch ext := filepath.Ext(args[0]); ext {
case ".json", ".yml", ".yaml":
return nil
default:
return errors.New("invalid format: " + ext)
}
}
func cobraCmd(fn pythonFunc) cobraFunc {
return func(cmd *cobra.Command, args []string) {
}
}
-2
View File
@@ -3,13 +3,11 @@ server:
baseURL: "/"
root: "/srv"
auth:
method: password
signup: false
userDefaults:
darkMode: true
disableSettings: false
scope: "."
hideDotfiles: true
singleClick: false
permissions:
admin: false
+2
View File
@@ -0,0 +1,2 @@
server:
root: "/srv"
+8 -2
View File
@@ -1,15 +1,21 @@
server:
port: 80
baseURL: "/"
root: "./tests/playwright-files"
root: "../frontend/tests/playwright-files"
auth:
method: password
signup: false
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
externalLinks:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
userDefaults:
darkMode: true
disableSettings: false
scope: "."
hideDotfiles: true
singleClick: false
permissions:
admin: false
+9
View File
@@ -0,0 +1,9 @@
//go:build !windows
// +build !windows
package files
func checkWindowsHidden(realpath string) bool {
// Non-Windows platforms don't support hidden attributes in the same way
return false
}
+33
View File
@@ -0,0 +1,33 @@
//go:build windows
// +build windows
package files
import (
"golang.org/x/sys/windows"
)
func checkWindowsHidden(realpath string) bool {
// Convert the realpath to a UTF-16 pointer
pointer, err := windows.UTF16PtrFromString(realpath)
if err != nil {
return false
}
// Get the file attributes
attributes, err := windows.GetFileAttributes(pointer)
if err != nil {
return false
}
// Check if the hidden attribute is set
if attributes&windows.FILE_ATTRIBUTE_HIDDEN != 0 {
return true
}
// Optional: Check for system attribute
if attributes&windows.FILE_ATTRIBUTE_SYSTEM != 0 {
return true
}
return false
}
+22
View File
@@ -2,6 +2,7 @@ package files
import (
"mime"
"path/filepath"
"regexp"
"strconv"
"strings"
@@ -52,6 +53,17 @@ var documentTypes = []string{
".fb2", // FictionBook
}
var onlyOfficeSupported = []string{
".doc", ".docm", ".docx", ".dot", ".dotm", ".dotx", ".epub",
".fb2", ".fodt", ".htm", ".html", ".mht", ".mhtml", ".odt",
".ott", ".rtf", ".stw", ".sxw", ".txt", ".wps", ".wpt", ".xml",
".csv", ".et", ".ett", ".fods", ".ods", ".ots", ".sxc", ".xls",
".xlsb", ".xlsm", ".xlsx", ".xlt", ".xltm", ".xltx", ".dps",
".dpt", ".fodp", ".odp", ".otp", ".pot", ".potm", ".potx",
".pps", ".ppsm", ".ppsx", ".ppt", ".pptm", ".pptx", ".sxi",
".djvu", ".docxf", ".oform", ".oxps", ".pdf", ".xps",
}
// Text-based file extensions
var textTypes = []string{
// Common Text Formats
@@ -256,3 +268,13 @@ func isArchive(extension string) bool {
}
return false
}
func isOnlyOffice(name string) bool {
extention := filepath.Ext(name)
for _, typefile := range onlyOfficeSupported {
if extention == typefile {
return true
}
}
return false
}
+39 -15
View File
@@ -21,9 +21,12 @@ import (
"time"
"unicode/utf8"
"github.com/gtsteffaniak/filebrowser/backend/cache"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
var (
@@ -32,30 +35,33 @@ var (
)
type ItemInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
ModTime time.Time `json:"modified"`
Type string `json:"type"`
Name string `json:"name"` // name of the file
Size int64 `json:"size"` // length in bytes for regular files
ModTime time.Time `json:"modified"` // modification time
Type string `json:"type"` // type of the file, either "directory" or a file mimetype
Hidden bool `json:"hidden"` // whether the file is hidden
}
// FileInfo describes a file.
// reduced item is non-recursive reduced "Items", used to pass flat items array
type FileInfo struct {
ItemInfo
Files []ItemInfo `json:"files"`
Folders []ItemInfo `json:"folders"`
Path string `json:"path"`
Files []ItemInfo `json:"files"` // files in the directory
Folders []ItemInfo `json:"folders"` // folders in the directory
Path string `json:"path"` // path scoped to the associated index
}
// for efficiency, a response will be a pointer to the data
// extra calculated fields can be added here
type ExtendedFileInfo struct {
*FileInfo
Content string `json:"content,omitempty"`
Subtitles []string `json:"subtitles,omitempty"`
Checksums map[string]string `json:"checksums,omitempty"`
Token string `json:"token,omitempty"`
RealPath string `json:"-"`
Content string `json:"content,omitempty"` // text content of a file, if requested
Subtitles []string `json:"subtitles,omitempty"` // subtitles for video files
Checksums map[string]string `json:"checksums,omitempty"` // checksums for the file
Token string `json:"token,omitempty"` // token for the file -- used for sharing
OnlyOfficeId string `json:"onlyOfficeId,omitempty"` // id for onlyoffice files
Source string `json:"source"` // associated index source for the file
RealPath string `json:"-"`
}
// FileOptions are the options when getting a file info.
@@ -134,9 +140,23 @@ func FileInfoFaster(opts FileOptions) (ExtendedFileInfo, error) {
}
response.FileInfo = info
response.RealPath = realPath
if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && isOnlyOffice(info.Name) {
response.OnlyOfficeId = generateOfficeId(realPath)
}
return response, nil
}
func generateOfficeId(realPath string) string {
key, ok := cache.OnlyOffice.Get(realPath).(string)
if !ok {
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
documentKey := utils.HashSHA256(realPath + timestamp)
cache.OnlyOffice.Set(realPath, documentKey)
return documentKey
}
return key
}
// Checksum checksums a given File for a given User, using a specific
// algorithm. The checksums data is saved on File object.
func GetChecksum(fullPath, algo string) (map[string]string, error) {
@@ -189,11 +209,11 @@ func MoveResource(source, realsrc, realdst string, isSrcDir bool) error {
index := GetIndex(source)
// refresh info for source and dest
err = index.RefreshFileInfo(FileOptions{
Path: filepath.Dir(realsrc),
Path: realsrc,
IsDir: isSrcDir,
})
if err != nil {
return errors.ErrEmptyKey
return fmt.Errorf("could not refresh index for source: %v", err)
}
refreshConfig := FileOptions{Path: realdst, IsDir: true}
if !isSrcDir {
@@ -201,7 +221,7 @@ func MoveResource(source, realsrc, realdst string, isSrcDir bool) error {
}
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return errors.ErrEmptyKey
return fmt.Errorf("could not refresh index for dest: %v", err)
}
return nil
}
@@ -321,6 +341,10 @@ func (i *ItemInfo) DetectType(realPath string, saveContent bool) {
ext := filepath.Ext(name)
// Attempt MIME detection by file extension
if ext == ".md" {
i.Type = "text/markdown"
return
}
i.Type = strings.Split(mime.TypeByExtension(ext), ";")[0]
if i.Type == "" {
i.Type = extendedMimeTypeCheck(ext)
+51 -13
View File
@@ -2,14 +2,16 @@ package files
import (
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/backend/cache"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
@@ -52,11 +54,11 @@ func Initialize(source settings.Source) {
if !newIndex.Source.Config.Disabled {
time.Sleep(time.Second)
log.Println("Initializing index and assessing file system complexity")
logger.Info("Initializing index and assessing file system complexity")
newIndex.RunIndexing("/", false)
go newIndex.setupIndexingScanners()
} else {
log.Println("Indexing disabled for source: ", newIndex.Source.Name)
logger.Debug("Indexing disabled for source: " + newIndex.Source.Name)
}
}
@@ -96,7 +98,7 @@ func (idx *Index) indexDirectory(adjustedPath string, quick, recursive bool) err
for _, item := range cacheDirItems {
err = idx.indexDirectory(combinedPath+item.Name, quick, true)
if err != nil {
fmt.Printf("error indexing directory %v : %v", combinedPath+item.Name, err)
logger.Error(fmt.Sprintf("error indexing directory %v : %v", combinedPath+item.Name, err))
}
}
return nil
@@ -120,15 +122,16 @@ func (idx *Index) indexDirectory(adjustedPath string, quick, recursive bool) err
// Process each file and directory in the current directory
for _, file := range files {
isHidden := isHidden(file, idx.Path+combinedPath)
isDir := file.IsDir()
fullCombined := combinedPath + file.Name()
if idx.shouldSkip(isDir, isHidden(file, ""), fullCombined) {
if idx.shouldSkip(isDir, isHidden, fullCombined) {
continue
}
itemInfo := &ItemInfo{
Name: file.Name(),
ModTime: file.ModTime(),
Hidden: isHidden,
}
// fix for .app files on macos which are technically directories, but we don't want to treat them as such
@@ -147,7 +150,7 @@ func (idx *Index) indexDirectory(adjustedPath string, quick, recursive bool) err
// Recursively index the subdirectory
err = idx.indexDirectory(dirPath, quick, recursive)
if err != nil {
log.Printf("Failed to index directory %s: %v", dirPath, err)
logger.Error(fmt.Sprintf("Failed to index directory %s: %v", dirPath, err))
continue
}
}
@@ -226,8 +229,8 @@ func (idx *Index) recursiveUpdateDirSizes(childInfo *FileInfo, previousSize int6
func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
combined := append([]string{idx.Source.Path}, relativePath...)
joinedPath := filepath.Join(combined...)
isDir, _ := utils.RealPathCache.Get(joinedPath + ":isdir").(bool)
cached, ok := utils.RealPathCache.Get(joinedPath).(string)
isDir, _ := cache.RealPath.Get(joinedPath + ":isdir").(bool)
cached, ok := cache.RealPath.Get(joinedPath).(string)
if ok && cached != "" {
return cached, isDir, nil
}
@@ -239,8 +242,8 @@ func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
// Resolve symlinks and get the real path
realPath, isDir, err := resolveSymlinks(absolutePath)
if err == nil {
utils.RealPathCache.Set(joinedPath, realPath)
utils.RealPathCache.Set(joinedPath+":isdir", isDir)
cache.RealPath.Set(joinedPath, realPath)
cache.RealPath.Set(joinedPath+":isdir", isDir)
}
return realPath, isDir, err
}
@@ -281,8 +284,18 @@ func (idx *Index) RefreshFileInfo(opts FileOptions) error {
return nil
}
func isHidden(file os.FileInfo, realpath string) bool {
return file.Name()[0] == '.'
func isHidden(file os.FileInfo, srcPath string) bool {
// Check if the file starts with a dot (common on Unix systems)
if file.Name()[0] == '.' {
return true
}
if runtime.GOOS == "windows" {
return checkWindowsHidden(filepath.Join(srcPath, file.Name()))
}
// Default behavior for non-Windows systems
return false
}
func (idx *Index) shouldSkip(isDir bool, isHidden bool, fullCombined string) bool {
@@ -298,6 +311,19 @@ func (idx *Index) shouldSkip(isDir bool, isHidden bool, fullCombined string) boo
}
}
if !isDir && len(idx.Source.Config.Include.FileEndsWith) > 0 {
shouldSkip := true
for _, end := range idx.Source.Config.Include.FileEndsWith {
if strings.HasSuffix(fullCombined, end) {
shouldSkip = false
break
}
}
if shouldSkip {
return true
}
}
// check exclusions
if isDir && slices.Contains(idx.Source.Config.Exclude.Folders, fullCombined) {
return true
@@ -308,5 +334,17 @@ func (idx *Index) shouldSkip(isDir bool, isHidden bool, fullCombined string) boo
if idx.Source.Config.IgnoreHidden && isHidden {
return true
}
if !isDir && len(idx.Source.Config.Exclude.FileEndsWith) > 0 {
shouldSkip := false
for _, end := range idx.Source.Config.Exclude.FileEndsWith {
if strings.HasSuffix(fullCombined, end) {
shouldSkip = true
break
}
}
return shouldSkip
}
return false
}
+18 -7
View File
@@ -1,8 +1,10 @@
package files
import (
"log"
"fmt"
"time"
"github.com/gtsteffaniak/filebrowser/backend/logger"
)
// schedule in minutes
@@ -32,7 +34,7 @@ func (idx *Index) newScanner(origin string) {
}
// Log and sleep before indexing
log.Printf("Next scan in %v\n", sleepTime)
logger.Debug(fmt.Sprintf("Next scan in %v", sleepTime))
time.Sleep(sleepTime)
idx.scannerMu.Lock()
@@ -74,9 +76,9 @@ func (idx *Index) RunIndexing(origin string, quick bool) {
prevNumDirs := idx.NumDirs
prevNumFiles := idx.NumFiles
if quick {
log.Println("Starting quick scan")
logger.Debug("Starting quick scan")
} else {
log.Println("Starting full scan")
logger.Debug("Starting full scan")
idx.NumDirs = 0
idx.NumFiles = 0
}
@@ -85,8 +87,9 @@ func (idx *Index) RunIndexing(origin string, quick bool) {
// Perform the indexing operation
err := idx.indexDirectory("/", quick, true)
if err != nil {
log.Printf("Error during indexing: %v", err)
logger.Error(fmt.Sprintf("Error during indexing: %v", err))
}
firstRun := idx.LastIndexed == time.Time{}
// Update the LastIndexed time
idx.LastIndexed = time.Now()
idx.indexingTime = int(time.Since(startTime).Seconds())
@@ -102,12 +105,20 @@ func (idx *Index) RunIndexing(origin string, quick bool) {
} else {
idx.assessment = "normal"
}
log.Printf("Index assessment : complexity=%v directories=%v files=%v \n", idx.assessment, idx.NumDirs, idx.NumFiles)
if firstRun {
logger.Info(fmt.Sprintf("Index assessment : complexity=%v directories=%v files=%v", idx.assessment, idx.NumDirs, idx.NumFiles))
} else {
logger.Debug(fmt.Sprintf("Index assessment : complexity=%v directories=%v files=%v", idx.assessment, idx.NumDirs, idx.NumFiles))
}
if idx.NumDirs != prevNumDirs || idx.NumFiles != prevNumFiles {
idx.FilesChangedDuringIndexing = true
}
}
log.Printf("Time Spent Indexing : %v seconds\n", idx.indexingTime)
if firstRun {
logger.Info(fmt.Sprintf("Time spent indexing : %v seconds", idx.indexingTime))
} else {
logger.Debug(fmt.Sprintf("Time spent indexing : %v seconds", idx.indexingTime))
}
}
func (idx *Index) setupIndexingScanners() {
+4 -3
View File
@@ -6,6 +6,7 @@ import (
"strings"
"sync"
"github.com/gtsteffaniak/filebrowser/backend/cache"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
@@ -23,18 +24,18 @@ type SearchResult struct {
func (idx *Index) Search(search string, scope string, sourceSession string) []SearchResult {
// Remove slashes
scope = idx.makeIndexPath(scope)
runningHash := utils.GenerateRandomHash(4)
runningHash := utils.InsecureRandomIdentifier(4)
sessionInProgress.Store(sourceSession, runningHash) // Store the value in the sync.Map
searchOptions := ParseSearch(search)
results := make(map[string]SearchResult, 0)
count := 0
var directories []string
cachedDirs, ok := utils.SearchResultsCache.Get(idx.Source.Path + scope).([]string)
cachedDirs, ok := cache.SearchResults.Get(idx.Source.Path + scope).([]string)
if ok {
directories = cachedDirs
} else {
directories = idx.getDirsInScope(scope)
utils.SearchResultsCache.Set(idx.Source.Path+scope, directories)
cache.SearchResults.Set(idx.Source.Path+scope, directories)
}
for _, searchTerm := range searchOptions.Terms {
if searchTerm == "" {
+5 -1
View File
@@ -2,6 +2,8 @@ package files
import (
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/settings"
)
// UpdateFileMetadata updates the FileInfo for the specified directory in the index.
@@ -69,7 +71,9 @@ func GetIndex(name string) *Index {
defer indexesMutex.Unlock()
index, ok := indexes[name]
if !ok {
return nil
return &Index{
Source: settings.Source{Name: "default", Path: "."},
}
}
return index
}
+7
View File
@@ -1,6 +1,7 @@
package fileutils
import (
"fmt"
"io"
"os"
"path"
@@ -11,15 +12,21 @@ import (
// 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 {
fmt.Println("moving", src, dst)
if os.Rename(src, dst) == nil {
return nil
}
fmt.Println("copyfile instead", src, dst)
// fallback
err := CopyFile(src, dst)
if err != nil {
fmt.Println("ok it errored too", err)
_ = os.Remove(dst)
return err
}
fmt.Println("removing", src)
if err := os.Remove(src); err != nil {
return err
}
+10 -11
View File
@@ -1,25 +1,26 @@
module github.com/gtsteffaniak/filebrowser/backend
go 1.22.5
go 1.23
toolchain go1.23.1
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.13
github.com/goccy/go-yaml v1.15.17
github.com/golang-jwt/jwt/v4 v4.5.1
github.com/google/go-cmp v0.6.0
github.com/shirou/gopsutil/v3 v3.24.5
github.com/spf13/afero v1.11.0
github.com/spf13/cobra v1.8.1
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.9.0
github.com/stretchr/testify v1.10.0
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.4
golang.org/x/crypto v0.32.0
golang.org/x/image v0.23.0
golang.org/x/text v0.21.0
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 (
@@ -35,16 +36,14 @@ require (
github.com/go-openapi/swag v0.23.0 // indirect
github.com/golang/geo v0.0.0-20230421003525-6adc56603217 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.etcd.io/bbolt v1.3.11 // indirect
go.etcd.io/bbolt v1.4.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/tools v0.29.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+16 -24
View File
@@ -6,7 +6,6 @@ github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwv
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
github.com/asdine/storm/v3 v3.2.1/go.mod h1:LEpXwGt4pIqrE/XcTvCnZHT5MgZCV6Ub9q7yQzOFWr0=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
@@ -47,8 +46,8 @@ github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9Z
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/goccy/go-yaml v1.15.13 h1:Xd87Yddmr2rC1SLLTm2MNDcTjeO/GYo0JGiww6gSTDg=
github.com/goccy/go-yaml v1.15.13/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/goccy/go-yaml v1.15.17 h1:dK4FbbTTEOZTLH/NW3/xBqg0JdC14YKVmYwS9GT3H60=
github.com/goccy/go-yaml v1.15.17/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
@@ -65,8 +64,6 @@ github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
@@ -86,18 +83,13 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww=
@@ -110,15 +102,15 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0=
go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I=
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
@@ -137,8 +129,8 @@ golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -153,8 +145,8 @@ golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -163,8 +155,8 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+20 -13
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
libError "errors"
"fmt"
"log"
"net/http"
"net/url"
"os"
@@ -18,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"
"github.com/gtsteffaniak/filebrowser/backend/share"
"github.com/gtsteffaniak/filebrowser/backend/users"
@@ -46,9 +46,18 @@ func extractToken(r *http.Request) (string, error) {
}
}
auth := r.URL.Query().Get("auth")
if auth != "" {
hasToken = true
if strings.Count(auth, ".") == 2 {
return auth, nil
}
}
// Check for Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader != "" {
hasToken = true
// Split the header to get "Bearer {token}"
parts := strings.Split(authHeader, " ")
@@ -58,14 +67,6 @@ func extractToken(r *http.Request) (string, error) {
}
}
auth := r.URL.Query().Get("auth")
if auth != "" {
hasToken = true
if strings.Count(auth, ".") == 2 {
return auth, nil
}
}
if hasToken {
return "", fmt.Errorf("invalid token provided")
}
@@ -74,8 +75,13 @@ func extractToken(r *http.Request) (string, error) {
}
func loginHandler(w http.ResponseWriter, r *http.Request) {
if !config.Auth.Methods.PasswordAuth.Enabled {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
// currently only supports user/pass
// Get the authentication method from the settings
auther, err := store.Auth.Get(config.Auth.Method)
auther, err := store.Auth.Get("password")
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
@@ -129,12 +135,12 @@ func signupHandler(w http.ResponseWriter, r *http.Request) {
userHome, err := config.MakeUserDir(user.Username, user.Scope, files.RootPaths["default"])
if err != nil {
log.Printf("create user: failed to mkdir user home dir: [%s]", userHome)
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
log.Printf("new user: %s, home dir: [%s].", user.Username, userHome)
logger.Debug(fmt.Sprintf("new user: %s, home dir: [%s].", user.Username, userHome))
err = store.Users.Save(&user)
if err == errors.ErrExist {
http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict)
@@ -151,7 +157,8 @@ func renewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in
}
func printToken(w http.ResponseWriter, _ *http.Request, user *users.User) (int, error) {
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.GenerateRandomHash(4), time.Hour*2, user.Perm)
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*time.Duration(config.Auth.TokenExpirationHours), user.Perm)
if err != nil {
if strings.Contains(err.Error(), "key already exists with same name") {
return http.StatusConflict, err
+69 -30
View File
@@ -4,7 +4,6 @@ import (
"compress/gzip"
"encoding/json"
"fmt"
"log"
"net/http"
"path/filepath"
"strings"
@@ -12,6 +11,7 @@ import (
"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/users"
@@ -20,7 +20,8 @@ import (
type requestContext struct {
user *users.User
*runner.Runner
raw interface{}
raw interface{}
token string
}
type HttpResponse struct {
@@ -59,11 +60,10 @@ func withHashFileHelper(fn handleFunc) handleFunc {
// Get file information with options
file, err := FileInfoFasterFunc(files.FileOptions{
Path: filepath.Join(user.Scope, link.Path+"/"+path),
Modify: user.Perm.Modify,
Expand: true,
ReadHeader: config.Server.TypeDetectionByHeader,
Checker: user, // Call your checker function here
Path: filepath.Join(user.Scope, link.Path+"/"+path),
Modify: user.Perm.Modify,
Expand: true,
Checker: user, // Call your checker function here
})
file.Token = link.Token
if err != nil {
@@ -94,15 +94,44 @@ func withAdminHelper(fn handleFunc) handleFunc {
// Middleware to retrieve and authenticate user
func withUserHelper(fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
if settings.Config.Auth.Method == "noauth" {
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"], "admin")
data.user, err = store.Users.Get(files.RootPaths["default"], uint(1))
if err != nil {
logger.Error(fmt.Sprintf("no auth: %v", err))
return http.StatusInternalServerError, err
}
return fn(w, r, data)
}
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
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)
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)
if err != nil {
return http.StatusInternalServerError, err
}
data.user, err = store.Users.Get(files.RootPaths["default"], proxyUser)
if err != nil {
return http.StatusInternalServerError, err
}
} else {
return http.StatusUnauthorized, fmt.Errorf("proxy authentication failed - no user found")
}
}
setUserInResponseWriter(w, data.user)
return fn(w, r, data)
}
keyFunc := func(token *jwt.Token) (interface{}, error) {
return config.Auth.Key, nil
}
@@ -110,6 +139,7 @@ func withUserHelper(fn handleFunc) handleFunc {
if err != nil {
return http.StatusUnauthorized, err
}
data.token = tokenString
var tk users.AuthToken
token, err := jwt.ParseWithClaims(tokenString, &tk, keyFunc)
@@ -126,11 +156,15 @@ func withUserHelper(fn handleFunc) handleFunc {
if tk.Expires < time.Now().Add(time.Hour).Unix() {
w.Header().Add("X-Renew-Token", "true")
}
// Retrieve the user from the store and store it in the context
data.user, err = store.Users.Get(files.RootPaths["default"], tk.BelongsTo)
if err != nil {
return http.StatusInternalServerError, err
}
setUserInResponseWriter(w, data.user)
// Call the handler function, passing in the context
return fn(w, r, data)
}
@@ -172,14 +206,14 @@ func wrapHandler(fn handleFunc) http.HandlerFunc {
// Marshal the error response to JSON
errorBytes, marshalErr := json.Marshal(response)
if marshalErr != nil {
log.Printf("Error marshalling error response: %v", marshalErr)
logger.Error(fmt.Sprintf("Error marshalling error response: %v", marshalErr))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Write the JSON error response
if _, writeErr := w.Write(errorBytes); writeErr != nil {
log.Printf("Error writing error response: %v", writeErr)
logger.Error(fmt.Sprintf("Error writing error response: %v", writeErr))
}
return
}
@@ -233,6 +267,7 @@ type ResponseWriterWrapper struct {
StatusCode int
wroteHeader bool
PayloadSize int
User string
}
// WriteHeader captures the status code and ensures it's only written once
@@ -255,6 +290,16 @@ func (w *ResponseWriterWrapper) Write(b []byte) (int, error) {
return w.ResponseWriter.Write(b)
}
// Helper function to set the user in the ResponseWriterWrapper
func setUserInResponseWriter(w http.ResponseWriter, user *users.User) {
// Wrap the response writer to set the user field
if wrappedWriter, ok := w.(*ResponseWriterWrapper); ok {
if user != nil {
wrappedWriter.User = user.Username
}
}
}
// LoggingMiddleware logs each request and its status code.
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -267,30 +312,24 @@ func LoggingMiddleware(next http.Handler) http.Handler {
// Call the next handler.
next.ServeHTTP(wrappedWriter, r)
// Determine the color based on the status code.
color := "\033[32m" // Default green color
if wrappedWriter.StatusCode >= 300 && wrappedWriter.StatusCode < 500 {
color = "\033[33m" // Yellow for client errors (4xx)
} else if wrappedWriter.StatusCode >= 500 {
color = "\033[31m" // Red for server errors (5xx)
}
// Capture the full URL path including the query parameters.
fullURL := r.URL.Path
if r.URL.RawQuery != "" {
fullURL += "?" + r.URL.RawQuery
}
// Log the request, status code, and response size.
log.Printf("%s%-7s | %3d | %-15s | %-12s | \"%s\"%s",
color,
r.Method,
wrappedWriter.StatusCode, // Captured status code
r.RemoteAddr,
time.Since(start).String(),
fullURL,
"\033[0m", // Reset color
)
truncUser := wrappedWriter.User
if len(truncUser) > 12 {
truncUser = truncUser[:10] + ".."
}
duration := time.Since(start)
logger.Api(
fmt.Sprintf("%-7s | %3d | %-15s | %-12s | %-12s | \"%s\"",
r.Method,
wrappedWriter.StatusCode, // Captured status code
r.RemoteAddr,
truncUser,
fmt.Sprintf("%vms", duration.Milliseconds()),
fullURL), wrappedWriter.StatusCode)
})
}
+1 -1
View File
@@ -105,7 +105,7 @@ func TestWithAdminHelper(t *testing.T) {
data := &requestContext{
user: tc.user,
}
token, err := makeSignedTokenAPI(tc.user, "WEB_TOKEN_"+utils.GenerateRandomHash(4), time.Hour*2, tc.user.Perm)
token, err := makeSignedTokenAPI(tc.user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*2, tc.user.Perm)
if err != nil {
t.Fatalf("Error making token for request: %v", err)
}
+208
View File
@@ -0,0 +1,208 @@
package http
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/cache"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/settings"
)
const (
onlyOfficeStatusDocumentClosedWithChanges = 2
onlyOfficeStatusDocumentClosedWithNoChanges = 4
onlyOfficeStatusForceSaveWhileDocumentStillOpen = 6
)
type OnlyOfficeCallback struct {
ChangesURL string `json:"changesurl,omitempty"`
Key string `json:"key,omitempty"`
Status int `json:"status,omitempty"`
URL string `json:"url,omitempty"`
Users []string `json:"users,omitempty"`
UserData string `json:"userdata,omitempty"`
}
func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if settings.Config.Integrations.OnlyOffice.Url == "" {
return http.StatusInternalServerError, errors.New("only-office integration must be configured in settings")
}
if !d.user.Perm.Modify {
return http.StatusForbidden, nil
}
encodedUrl := r.URL.Query().Get("url")
source := r.URL.Query().Get("source")
if source == "" {
source = "default"
}
// Decode the URL-encoded path
url, err := url.QueryUnescape(encodedUrl)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
// get path from url
pathParts := strings.Split(url, "/api/raw?files=/")
path := pathParts[len(pathParts)-1]
urlFirst := pathParts[0]
if settings.Config.Server.InternalUrl != "" {
urlFirst = settings.Config.Server.InternalUrl
replacement := strings.Split(url, "/api/raw")[0]
url = strings.Replace(url, replacement, settings.Config.Server.InternalUrl, 1)
}
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,
})
if err != nil {
return errToStatus(err), err
}
id, err := getOnlyOfficeId(source, fileInfo.Path)
if err != nil {
return http.StatusNotFound, err
}
split := strings.Split(fileInfo.Name, ".")
fileType := split[len(split)-1]
theme := "light"
if d.user.DarkMode {
theme = "dark"
}
clientConfig := map[string]interface{}{
"document": map[string]interface{}{
"fileType": fileType,
"key": id,
"title": fileInfo.Name,
"url": url + "&auth=" + d.token,
"permissions": map[string]interface{}{
"edit": d.user.Perm.Modify,
"download": d.user.Perm.Download,
"print": d.user.Perm.Download,
},
},
"editorConfig": map[string]interface{}{
"callbackUrl": fmt.Sprintf("%v/api/onlyoffice/callback?path=%v&auth=%v", urlFirst, path, d.token),
"user": map[string]interface{}{
"id": strconv.FormatUint(uint64(d.user.ID), 10),
"name": d.user.Username,
},
"customization": map[string]interface{}{
"autosave": true,
"forcesave": true,
"uiTheme": theme,
},
"lang": d.user.Locale,
"mode": "edit",
},
}
if settings.Config.Integrations.OnlyOffice.Secret != "" {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(clientConfig))
signature, err := token.SignedString([]byte(settings.Config.Integrations.OnlyOffice.Secret))
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to sign JWT")
}
clientConfig["token"] = signature
}
return renderJSON(w, r, clientConfig)
}
func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
body, err := io.ReadAll(r.Body)
if err != nil {
return http.StatusInternalServerError, err
}
var data OnlyOfficeCallback
err = json.Unmarshal(body, &data)
if err != nil {
return http.StatusInternalServerError, err
}
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = "default"
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
if data.Status == onlyOfficeStatusDocumentClosedWithChanges ||
data.Status == onlyOfficeStatusDocumentClosedWithNoChanges {
// Refer to only-office documentation
// - https://api.onlyoffice.com/editors/coedit
// - https://api.onlyoffice.com/editors/callback
//
// When the document is fully closed by all editors,
// then the document key should no longer be re-used.
deleteOfficeId(source, path)
}
if data.Status == onlyOfficeStatusDocumentClosedWithChanges ||
data.Status == onlyOfficeStatusForceSaveWhileDocumentStillOpen {
if !d.user.Perm.Modify {
return http.StatusForbidden, nil
}
doc, err := http.Get(data.URL)
if err != nil {
return http.StatusInternalServerError, err
}
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
}
}
resp := map[string]int{
"error": 0,
}
return renderJSON(w, r, resp)
}
func getOnlyOfficeId(source, path string) (string, error) {
idx := files.GetIndex(source)
realpath, _, _ := idx.GetRealPath(path)
// error is intentionally ignored in order treat errors
// the same as a cache-miss
cachedDocumentKey, ok := cache.OnlyOffice.Get(realpath).(string)
if ok {
return cachedDocumentKey, nil
}
return "", fmt.Errorf("document key not found")
}
func deleteOfficeId(source, path string) {
idx := files.GetIndex(source)
realpath, _, _ := idx.GetRealPath(path)
cache.OnlyOffice.Delete(realpath)
}
+7 -7
View File
@@ -13,6 +13,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/img"
"github.com/gtsteffaniak/filebrowser/backend/logger"
)
type ImgService interface {
@@ -52,12 +53,11 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
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,
ReadHeader: config.Server.TypeDetectionByHeader,
Checker: d.user,
Path: filepath.Join(d.user.Scope, path),
Modify: d.user.Perm.Modify,
Source: source,
Expand: true,
Checker: d.user,
})
fileInfo := response.FileInfo
if err != nil {
@@ -141,7 +141,7 @@ func createPreview(imgSvc ImgService, fileCache FileCache, file files.ExtendedFi
go func() {
cacheKey := previewCacheKey(file.RealPath, previewSize, file.FileInfo.ModTime)
if err := fileCache.Store(context.Background(), cacheKey, buf.Bytes()); err != nil {
fmt.Printf("failed to cache resized image: %v", err)
logger.Error(fmt.Sprintf("failed to cache resized image: %v", err))
}
}()
+7 -5
View File
@@ -7,7 +7,6 @@ import (
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
@@ -15,6 +14,7 @@ import (
"strings"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/logger"
)
func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName string) {
@@ -63,6 +63,7 @@ func rawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int,
}
return rawFilesHandler(w, r, d, fileList)
}
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)
@@ -211,13 +212,14 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
default:
return http.StatusInternalServerError, errors.New("format not implemented")
}
baseDirName := filepath.Base(filepath.Dir(realPath))
if baseDirName == "" || baseDirName == "/" {
baseDirName = "download"
}
if len(fileList) == 1 && isDir {
baseDirName = filepath.Base(realPath)
}
downloadFileName := url.PathEscape(baseDirName + extension)
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+downloadFileName)
// Create the archive and stream it directly to the response
if extension == ".zip" {
@@ -242,7 +244,7 @@ func createZip(w io.Writer, d *requestContext, filenames ...string) error {
for _, fname := range filenames {
err := addFile(fname, d, nil, zipWriter, false)
if err != nil {
log.Printf("Failed to add %s to ZIP: %v", fname, err)
logger.Error(fmt.Sprintf("Failed to add %s to ZIP: %v", fname, err))
}
}
@@ -261,7 +263,7 @@ func createTarGz(w io.Writer, d *requestContext, filenames ...string) error {
for _, fname := range filenames {
err := addFile(fname, d, tarWriter, nil, false)
if err != nil {
log.Printf("Failed to add %s to TAR.GZ: %v", fname, err)
logger.Error(fmt.Sprintf("Failed to add %s to TAR.GZ: %v", fname, err))
}
}
+34 -42
View File
@@ -13,9 +13,10 @@ import (
"github.com/shirou/gopsutil/v3/disk"
"github.com/gtsteffaniak/filebrowser/backend/cache"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/utils"
"github.com/gtsteffaniak/filebrowser/backend/logger"
)
// resourceGetHandler retrieves information about a resource.
@@ -45,13 +46,12 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
fileInfo, err := files.FileInfoFaster(files.FileOptions{
Path: filepath.Join(d.user.Scope, path),
Modify: d.user.Perm.Modify,
Source: source,
Expand: true,
ReadHeader: config.Server.TypeDetectionByHeader,
Checker: d.user,
Content: r.URL.Query().Get("content") == "true",
Path: filepath.Join(d.user.Scope, path),
Modify: d.user.Perm.Modify,
Source: source,
Expand: true,
Checker: d.user,
Content: r.URL.Query().Get("content") == "true",
})
if err != nil {
return errToStatus(err), err
@@ -60,7 +60,9 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
return renderJSON(w, r, fileInfo)
}
if algo := r.URL.Query().Get("checksum"); algo != "" {
checksums, err := files.GetChecksum(fileInfo.Path, algo)
idx := files.GetIndex(source)
realPath, _, _ := idx.GetRealPath(d.user.Scope, path)
checksums, err := files.GetChecksum(realPath, algo)
if err == errors.ErrInvalidOption {
return http.StatusBadRequest, nil
} else if err != nil {
@@ -102,12 +104,11 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
return http.StatusForbidden, nil
}
fileOpts := files.FileOptions{
Path: filepath.Join(d.user.Scope, path),
Source: source,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: config.Server.TypeDetectionByHeader,
Checker: d.user,
Path: filepath.Join(d.user.Scope, path),
Source: source,
Modify: d.user.Perm.Modify,
Expand: false,
Checker: d.user,
}
fileInfo, err := files.FileInfoFaster(fileOpts)
if err != nil {
@@ -115,10 +116,7 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
}
// delete thumbnails
err = delThumbs(r.Context(), fileCache, fileInfo)
if err != nil {
return errToStatus(err), err
}
delThumbs(r.Context(), fileCache, fileInfo)
err = files.DeleteFiles(source, fileInfo.RealPath, filepath.Dir(fileInfo.RealPath))
if err != nil {
@@ -184,10 +182,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
return http.StatusForbidden, nil
}
err = delThumbs(r.Context(), fileCache, fileInfo)
if err != nil {
return errToStatus(err), err
}
delThumbs(r.Context(), fileCache, fileInfo)
}
err = files.WriteFile(fileOpts, r.Body)
if err != nil {
@@ -235,12 +230,11 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
}
fileOpts := files.FileOptions{
Path: filepath.Join(d.user.Scope, path),
Source: source,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: config.Server.TypeDetectionByHeader,
Checker: d.user,
Path: filepath.Join(d.user.Scope, path),
Source: source,
Modify: d.user.Perm.Modify,
Expand: false,
Checker: d.user,
}
err = files.WriteFile(fileOpts, r.Body)
return errToStatus(err), err
@@ -309,10 +303,12 @@ 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.RunHook(func() error {
err = d.Runner.RunHook(func() error {
return patchAction(r.Context(), action, realSrc, realDest, d, fileCache, isSrcDir, source)
}, action, realSrc, realDest, d.user)
if err != nil {
logger.Debug(fmt.Sprintf("Could not run patch action. src=%v dst=%v err=%v", realSrc, realDest, err))
}
return errToStatus(err), err
}
@@ -332,11 +328,11 @@ func addVersionSuffix(source string) string {
return source
}
func delThumbs(ctx context.Context, fileCache FileCache, file files.ExtendedFileInfo) error {
if err := fileCache.Delete(ctx, previewCacheKey(file.RealPath, "small", file.FileInfo.ModTime)); err != nil {
return err
func delThumbs(ctx context.Context, fileCache FileCache, file files.ExtendedFileInfo) {
err := fileCache.Delete(ctx, previewCacheKey(file.RealPath, "small", file.FileInfo.ModTime))
if err != nil {
logger.Debug(fmt.Sprintf("Could not delete small thumbnail: %v", err))
}
return nil
}
func patchAction(ctx context.Context, action, src, dst string, d *requestContext, fileCache FileCache, isSrcDir bool, index string) error {
@@ -348,7 +344,6 @@ func patchAction(ctx context.Context, action, src, dst string, d *requestContext
err := files.CopyResource(index, src, dst, isSrcDir)
return err
case "rename", "move":
if !d.user.Perm.Rename {
return errors.ErrPermissionDenied
}
@@ -366,10 +361,7 @@ func patchAction(ctx context.Context, action, src, dst string, d *requestContext
}
// delete thumbnails
err = delThumbs(ctx, fileCache, fileInfo)
if err != nil {
return err
}
delThumbs(ctx, fileCache, fileInfo)
return files.MoveResource(index, src, dst, isSrcDir)
default:
return fmt.Errorf("unsupported action %s: %w", action, errors.ErrInvalidRequestParams)
@@ -397,7 +389,7 @@ func diskUsage(w http.ResponseWriter, r *http.Request, d *requestContext) (int,
if source == "" {
source = "default"
}
value, ok := utils.DiskUsageCache.Get(source).(DiskUsageResponse)
value, ok := cache.DiskUsage.Get(source).(DiskUsageResponse)
if ok {
return renderJSON(w, r, &value)
}
@@ -415,7 +407,7 @@ func diskUsage(w http.ResponseWriter, r *http.Request, d *requestContext) (int,
Total: usage.Total,
Used: usage.Used,
}
utils.DiskUsageCache.Set(source, latestUsage)
cache.DiskUsage.Set(source, latestUsage)
return renderJSON(w, r, &latestUsage)
}
+77 -49
View File
@@ -1,15 +1,17 @@
package http
import (
"context"
"crypto/tls"
"embed"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"text/template"
"time"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/version"
@@ -43,7 +45,7 @@ var (
assetFs fs.FS
)
func StartHttp(Service ImgService, storage *storage.Storage, cache FileCache) {
func StartHttp(ctx context.Context, Service ImgService, storage *storage.Storage, cache FileCache, shutdownComplete chan struct{}) {
store = storage
fileCache = cache
@@ -56,7 +58,7 @@ func StartHttp(Service ImgService, storage *storage.Storage, cache FileCache) {
// Embedded mode: Serve files from the embedded assets
assetFs, err = fs.Sub(assets, "embed")
if err != nil {
log.Fatal("Could not embed frontend. Does dist exist?")
logger.Fatal("Could not embed frontend. Does dist exist?")
}
} else {
assetFs = dirFS{Dir: http.Dir("http/dist")}
@@ -114,6 +116,9 @@ func StartHttp(Service ImgService, storage *storage.Storage, cache FileCache) {
api.HandleFunc("GET /settings", withAdmin(settingsGetHandler))
api.HandleFunc("PUT /settings", withAdmin(settingsPutHandler))
api.HandleFunc("GET /onlyoffice/config", withUser(onlyofficeClientConfigGetHandler))
api.HandleFunc("POST /onlyoffice/callback", withUser(onlyofficeCallbackHandler))
api.HandleFunc("GET /search", withUser(searchHandler))
apiPath := config.Server.BaseURL + "api"
router.Handle(apiPath+"/", http.StripPrefix(apiPath, api))
@@ -137,51 +142,74 @@ func StartHttp(Service ImgService, storage *storage.Storage, cache FileCache) {
var scheme string
port := ""
// Determine whether to use HTTPS (TLS) or HTTP
if config.Server.TLSCert != "" && config.Server.TLSKey != "" {
// Load the TLS certificate and key
cer, err := tls.LoadX509KeyPair(config.Server.TLSCert, config.Server.TLSKey)
if err != nil {
log.Fatalf("could not load certificate: %v", err)
}
// Create a custom TLS listener
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
Certificates: []tls.Certificate{cer},
}
// Set HTTPS scheme and default port for TLS
scheme = "https"
// Listen on TCP and wrap with TLS
listener, err := tls.Listen("tcp", fmt.Sprintf(":%v", config.Server.Port), tlsConfig)
if err != nil {
log.Fatalf("could not start TLS server: %v", err)
}
if config.Server.Port != 443 {
port = fmt.Sprintf(":%d", config.Server.Port)
}
// Build the full URL with host and port
fullURL := fmt.Sprintf("%s://localhost%s%s", scheme, port, config.Server.BaseURL)
log.Printf("Running at : %s", fullURL)
err = http.Serve(listener, muxWithMiddleware(router))
if err != nil {
log.Fatalf("could not start server: %v", err)
}
} else {
// Set HTTP scheme and the default port for HTTP
scheme = "http"
if config.Server.Port != 80 {
port = fmt.Sprintf(":%d", config.Server.Port)
}
// Build the full URL with host and port
fullURL := fmt.Sprintf("%s://localhost%s%s", scheme, port, config.Server.BaseURL)
log.Printf("Running at : %s", fullURL)
err := http.ListenAndServe(fmt.Sprintf(":%v", config.Server.Port), muxWithMiddleware(router))
if err != nil {
log.Fatalf("could not start server: %v", err)
}
srv := &http.Server{
Addr: fmt.Sprintf(":%v", config.Server.Port),
Handler: muxWithMiddleware(router),
}
go func() {
// Determine whether to use HTTPS (TLS) or HTTP
if config.Server.TLSCert != "" && config.Server.TLSKey != "" {
// Load the TLS certificate and key
cer, err := tls.LoadX509KeyPair(config.Server.TLSCert, config.Server.TLSKey)
if err != nil {
logger.Fatal(fmt.Sprintf("Could not load certificate: %v", err))
}
// Create a custom TLS configuration
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
Certificates: []tls.Certificate{cer},
}
// Set HTTPS scheme and default port for TLS
scheme = "https"
if config.Server.Port != 443 {
port = fmt.Sprintf(":%d", config.Server.Port)
}
// Build the full URL with host and port
fullURL := fmt.Sprintf("%s://localhost%s%s", scheme, port, config.Server.BaseURL)
logger.Info(fmt.Sprintf("Running at : %s", fullURL))
// Create a TLS listener and serve
listener, err := tls.Listen("tcp", srv.Addr, tlsConfig)
if err != nil {
logger.Fatal(fmt.Sprintf("Could not start TLS server: %v", err))
}
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
logger.Fatal(fmt.Sprintf("Server error: %v", err))
}
} else {
// Set HTTP scheme and the default port for HTTP
scheme = "http"
if config.Server.Port != 80 {
port = fmt.Sprintf(":%d", config.Server.Port)
}
// Build the full URL with host and port
fullURL := fmt.Sprintf("%s://localhost%s%s", scheme, port, config.Server.BaseURL)
logger.Info(fmt.Sprintf("Running at : %s", fullURL))
// Start HTTP server
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Fatal(fmt.Sprintf("Server error: %v", err))
}
}
}()
// Wait for context cancellation to shut down the server
<-ctx.Done()
logger.Info("Shutting down HTTP server...")
// Graceful shutdown with a timeout - 30 seconds, in case downloads are happening
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error(fmt.Sprintf("HTTP server forced to shut down: %v", err))
} else {
logger.Info("HTTP server shut down gracefully.")
}
// Signal that shutdown is complete
close(shutdownComplete)
}
+8 -7
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
@@ -12,6 +11,7 @@ import (
"text/template"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/version"
)
@@ -36,7 +36,7 @@ func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data inter
func handleWithStaticData(w http.ResponseWriter, r *http.Request, file, contentType string) {
w.Header().Set("Content-Type", contentType)
auther, err := store.Auth.Get(config.Auth.Method)
auther, err := store.Auth.Get("password")
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
@@ -53,8 +53,8 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, file, contentT
"CommitSHA": version.CommitSHA,
"StaticURL": config.Server.BaseURL + "static",
"Signup": settings.Config.Auth.Signup,
"NoAuth": config.Auth.Method == "noauth",
"AuthMethod": config.Auth.Method,
"NoAuth": config.Auth.Methods.NoAuth,
"PasswordAuth": config.Auth.Methods.PasswordAuth,
"LoginPage": auther.LoginPage(),
"CSS": false,
"ReCaptcha": false,
@@ -64,6 +64,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, file, contentT
"ReCaptchaHost": config.Auth.Recaptcha.Host,
"ExternalLinks": config.Frontend.ExternalLinks,
"ExternalUrl": strings.TrimSuffix(config.Server.ExternalUrl, "/"),
"OnlyOfficeUrl": settings.Config.Integrations.OnlyOffice.Url,
}
if config.Frontend.Files != "" {
@@ -71,7 +72,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, file, contentT
_, err := os.Stat(fPath) //nolint:govet
if err != nil && !os.IsNotExist(err) {
log.Printf("couldn't load custom styles: %v", err)
logger.Error(fmt.Sprintf("couldn't load custom styles: %v", err))
}
if err == nil {
@@ -79,8 +80,8 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, file, contentT
}
}
if config.Auth.Method == "password" {
raw, err := store.Auth.Get(config.Auth.Method) //nolint:govet
if config.Auth.Methods.PasswordAuth.Enabled {
raw, err := store.Auth.Get("password") //nolint:govet
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
+1
View File
@@ -0,0 +1 @@
../../frontend/tests/playwright-files/myfolder/testdata/
+141
View File
@@ -0,0 +1,141 @@
package logger
import (
"fmt"
"io"
"log"
"os"
"slices"
"strings"
)
// Logger wraps the standard log.Logger with log level functionality
type Logger struct {
logger *log.Logger
levels []LogLevel
apiLevels []LogLevel
stdout bool
disabled bool
debugEnabled bool
disabledAPI bool
colors bool
}
var stdOutLoggerExists bool
// NewLogger creates a new Logger instance with separate file and stdout loggers
func NewLogger(filepath string, levels, apiLevels []LogLevel, noColors bool) (*Logger, error) {
var fileWriter io.Writer = io.Discard
stdout := filepath == ""
// Configure file logging
if !stdout {
file, err := os.OpenFile(filepath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
return nil, fmt.Errorf("failed to open log file: %v", err)
}
fileWriter = file
}
var flags int
if slices.Contains(levels, DEBUG) {
flags |= log.Lshortfile
}
logger := log.New(os.Stdout, "", flags)
if filepath != "" {
logger = log.New(fileWriter, "", flags)
}
if stdout {
stdOutLoggerExists = true
}
return &Logger{
logger: logger,
levels: levels,
apiLevels: apiLevels,
disabled: slices.Contains(levels, DISABLED),
debugEnabled: slices.Contains(levels, DEBUG),
disabledAPI: slices.Contains(apiLevels, DISABLED),
colors: !noColors,
stdout: stdout,
}, nil
}
// SetupLogger configures the logger with file and stdout options and their respective log levels
func SetupLogger(output, levels, apiLevels string, noColors bool) error {
upperLevels := []LogLevel{}
for _, level := range SplitByMultiple(levels) {
if level == "" {
break
}
upperLevel := strings.ToUpper(level)
if upperLevel == "WARNING" || upperLevel == "WARN" {
upperLevel = "WARN "
}
if upperLevel == "INFO" {
upperLevel = "INFO "
}
// Convert level strings to LogLevel
level, ok := stringToLevel[upperLevel]
if !ok {
loggers = []*Logger{}
return fmt.Errorf("invalid file log level: %s", upperLevel)
}
upperLevels = append(upperLevels, level)
}
if len(upperLevels) == 0 {
upperLevels = []LogLevel{INFO, ERROR, WARNING}
}
upperApiLevels := []LogLevel{}
for _, level := range SplitByMultiple(apiLevels) {
if level == "" {
break
}
upperLevel := strings.ToUpper(level)
if upperLevel == "WARNING" || upperLevel == "WARN" {
upperLevel = "WARN "
}
if upperLevel == "INFO" {
upperLevel = "INFO "
}
// Convert level strings to LogLevel
level, ok := stringToLevel[strings.ToUpper(upperLevel)]
if !ok {
return fmt.Errorf("invalid api log level: %s", upperLevel)
}
upperApiLevels = append(upperApiLevels, level)
}
if len(upperApiLevels) == 0 {
upperApiLevels = []LogLevel{INFO, ERROR, WARNING}
}
if slices.Contains(upperLevels, DISABLED) && slices.Contains(upperApiLevels, DISABLED) {
// both disabled, not creating a logger
loggers = []*Logger{}
return nil
}
outputStdout := strings.ToUpper(output)
if outputStdout == "STDOUT" {
output = ""
}
if output == "" && stdOutLoggerExists {
// stdout logger already exists... don't create another
return fmt.Errorf("stdout logger already exists, could not set config levels=[%v] apiLevels=[%v] noColors=[%v]", levels, apiLevels, noColors)
}
// Create the logger
logger, err := NewLogger(output, upperLevels, upperApiLevels, noColors)
if err != nil {
loggers = []*Logger{}
return err
}
loggers = append(loggers, logger)
return nil
}
func SplitByMultiple(str string) []string {
delimiters := []rune{'|', ',', ' '}
return strings.FieldsFunc(str, func(r rune) bool {
for _, d := range delimiters {
if r == d {
return true
}
}
return false
})
}
+146
View File
@@ -0,0 +1,146 @@
package logger
import (
"fmt"
"log"
"slices"
"time"
)
type LogLevel int
const (
DISABLED LogLevel = 0
ERROR LogLevel = 1
FATAL LogLevel = 1
WARNING LogLevel = 2
INFO LogLevel = 3
DEBUG LogLevel = 4
API LogLevel = 10
// COLORS
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
GRAY = "\033[37m"
)
var (
loggers []*Logger
)
type levelConsts struct {
INFO string
FATAL string
ERROR string
WARNING string
DEBUG string
API string
DISABLED string
}
var levels = levelConsts{
INFO: "INFO ", // with consistent space padding
FATAL: "FATAL",
ERROR: "ERROR",
WARNING: "WARN ", // with consistent space padding
DEBUG: "DEBUG",
DISABLED: "DISABLED",
API: "API",
}
// stringToLevel maps string representation to LogLevel
var stringToLevel = map[string]LogLevel{
"DEBUG": DEBUG,
"INFO ": INFO, // with consistent space padding
"ERROR": ERROR,
"DISABLED": DISABLED,
"WARN ": WARNING, // with consistent space padding
"FATAL": FATAL,
"API": API,
}
// Log prints a log message if its level is greater than or equal to the logger's levels
func Log(level string, msg string, prefix, api bool, color string) {
LEVEL := stringToLevel[level]
for _, logger := range loggers {
if api {
if logger.disabledAPI || !slices.Contains(logger.apiLevels, LEVEL) {
continue
}
} else {
if logger.disabled || !slices.Contains(logger.levels, LEVEL) {
continue
}
}
if logger.stdout && level == "FATAL" {
continue
}
writeOut := msg
formattedTime := time.Now().Format("2006/01/02 15:04:05")
if logger.colors && color != "" {
formattedTime = formattedTime + color
}
if prefix || logger.debugEnabled {
logger.logger.SetPrefix(fmt.Sprintf("%s [%s] ", formattedTime, level))
} else {
logger.logger.SetPrefix(formattedTime + " ")
}
if logger.colors && color != "" {
writeOut = writeOut + "\033[0m"
}
err := logger.logger.Output(3, writeOut) // 3 skips this function for correct file:line
if err != nil {
log.Printf("failed to log message '%v' with error `%v`", msg, err)
}
}
}
func Api(msg string, statusCode int) {
if statusCode >= 300 && statusCode < 500 {
Log(levels.WARNING, msg, false, true, YELLOW)
} else if statusCode >= 500 {
Log(levels.ERROR, msg, false, true, RED)
} else {
Log(levels.INFO, msg, false, true, GREEN)
}
}
// Helper methods for specific log levels
func Debug(msg string) {
if len(loggers) > 0 {
Log(levels.DEBUG, msg, true, false, GRAY)
} else {
log.Println("[DEBUG] ", msg)
}
}
func Info(msg string) {
if len(loggers) > 0 {
Log(levels.INFO, msg, false, false, "")
} else {
log.Println(msg)
}
}
func Warning(msg string) {
if len(loggers) > 0 {
Log(levels.WARNING, msg, true, false, YELLOW)
} else {
log.Println("[WARN ] ", msg)
}
}
func Error(msg string) {
if len(loggers) > 0 {
Log(levels.ERROR, msg, true, false, RED)
} else {
log.Println("[ERROR] ", msg)
}
}
func Fatal(msg string) {
if len(loggers) > 0 {
Log(levels.FATAL, msg, true, false, RED)
}
log.Fatal("[FATAL] ", msg)
}
-17
View File
@@ -1,17 +0,0 @@
#!/bin/sh
## TEST file used by docker testing containers
checkExit() {
if [ "$?" -ne 0 ];then
exit 1
fi
}
if command -v go &> /dev/null
then
printf "\n == Running tests == \n"
go test -race -parallel -v ./...
checkExit
else
echo "ERROR: unable to perform tests"
exit 1
fi
-14
View File
@@ -1,14 +0,0 @@
#!/usr/bin/env bash
set -e
if ! [ -x "$(command -v standard-version)" ]; then
echo "standard-version is not installed. please run 'npm i -g standard-version'"
exit 1
fi
standard-version --dry-run --skip
read -p "Continue (y/n)? " -n 1 -r
echo ;
if [[ $REPLY =~ ^[Yy]$ ]]; then
standard-version -s ;
fi
-11
View File
@@ -1,11 +0,0 @@
#!/usr/bin/env bash
set -e
if ! [ -x "$(command -v commitlint)" ]; then
echo "commitlint is not installed. please run 'npm i -g commitlint'"
exit 1
fi
for commit_hash in $(git log --pretty=format:%H origin/master..HEAD); do
commitlint -f ${commit_hash}~1 -t ${commit_hash}
done
+70 -33
View File
@@ -8,6 +8,7 @@ import (
"strings"
"github.com/goccy/go-yaml"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/version"
)
@@ -15,37 +16,68 @@ import (
var Config Settings
func Initialize(configFile string) {
yamlData := loadConfigFile(configFile)
Config = setDefaults()
err := yaml.Unmarshal(yamlData, &Config)
yamlData, err := loadConfigFile(configFile)
if err != nil && configFile != "config.yaml" {
logger.Fatal("Could not load specified config file: " + err.Error())
}
if err != nil {
log.Fatalf("Error unmarshaling YAML data: %v", err)
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 {
// TODO allow multipe sources not named default
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, err := filepath.Abs(source.Path)
if err != nil {
log.Fatalf("Error getting source path: %v", err)
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" // Modify the local copy of the map value
Config.Server.Sources["default"] = source // Assign the modified value back to the map
source.Name = "default"
Config.Server.Sources = []Source{source} // temporary set only one source
}
} else {
realPath, err := filepath.Abs(Config.Server.Root)
if err != nil {
log.Fatalf("Error getting source path: %v", err)
realPath, err2 := filepath.Abs(Config.Server.Root)
if err2 != nil {
logger.Fatal(fmt.Sprintf("Error getting source path: %v", err2))
}
Config.Server.Sources = map[string]Source{
"default": {
Config.Server.Sources = []Source{
{
Name: "default",
Path: realPath,
},
}
}
baseurl := strings.Trim(Config.Server.BaseURL, "/")
if baseurl == "" {
Config.Server.BaseURL = "/"
@@ -53,10 +85,6 @@ func Initialize(configFile string) {
Config.Server.BaseURL = "/" + baseurl + "/"
}
if !Config.Frontend.DisableDefaultLinks {
Config.Frontend.ExternalLinks = append(Config.Frontend.ExternalLinks, ExternalLink{
Text: "FileBrowser Quantum",
Url: "https://github.com/gtsteffaniak/filebrowser",
})
Config.Frontend.ExternalLinks = append(Config.Frontend.ExternalLinks, ExternalLink{
Text: fmt.Sprintf("(%v)", version.Version),
Title: version.CommitSHA,
@@ -69,26 +97,25 @@ func Initialize(configFile string) {
}
}
func loadConfigFile(configFile string) []byte {
func loadConfigFile(configFile string) ([]byte, error) {
// Open and read the YAML file
yamlFile, err := os.Open(configFile)
if err != nil {
log.Println(err)
os.Exit(1)
return nil, err
}
defer yamlFile.Close()
stat, err := yamlFile.Stat()
if err != nil {
log.Fatalf("error getting file information: %s", err.Error())
return nil, err
}
yamlData := make([]byte, stat.Size())
_, err = yamlFile.Read(yamlData)
if err != nil {
log.Fatalf("Error reading YAML data: %v", err)
return nil, err
}
return yamlData
return yamlData, nil
}
func setDefaults() Settings {
@@ -101,18 +128,28 @@ func setDefaults() Settings {
NumImageProcessors: 4,
BaseURL: "",
Database: "database.db",
Log: "stdout",
Root: ".",
},
Auth: Auth{
TokenExpirationTime: "2h",
AdminUsername: "admin",
AdminPassword: "admin",
Method: "password",
Signup: false,
AdminUsername: "admin",
AdminPassword: "admin",
TokenExpirationHours: 2,
Signup: false,
Recaptcha: Recaptcha{
Host: "",
},
Methods: LoginMethods{
ProxyAuth: ProxyAuthConfig{
Enabled: false,
CreateUser: false,
Header: "",
},
NoAuth: false,
PasswordAuth: PasswordAuthConfig{
Enabled: true,
MinLength: 5,
},
},
},
Frontend: Frontend{
Name: "FileBrowser Quantum",
@@ -121,8 +158,8 @@ func setDefaults() Settings {
StickySidebar: true,
Scope: ".",
LockPassword: false,
HideDotfiles: true,
DarkMode: false,
ShowHidden: false,
DarkMode: true,
DisableSettings: false,
ViewMode: "normal",
Locale: "en",
+1 -1
View File
@@ -35,7 +35,7 @@ func Test_loadConfigFile(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := loadConfigFile(tt.args.configFile); !reflect.DeepEqual(got, tt.want) {
if got, _ := loadConfigFile(tt.args.configFile); !reflect.DeepEqual(got, tt.want) {
t.Errorf("loadConfigFile() = %v, want %v", got, tt.want)
}
})
+3 -2
View File
@@ -3,12 +3,13 @@ package settings
import (
"errors"
"fmt"
"log"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/logger"
)
var (
@@ -22,7 +23,7 @@ func (s *Settings) MakeUserDir(username, userScope, serverRoot string) (string,
if userScope == "" && s.Server.CreateUserDir {
username = cleanUsername(username)
if username == "" || username == "-" || username == "." {
log.Printf("create user: invalid user for home dir creation: [%s]", 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)
+1 -1
View File
@@ -52,7 +52,7 @@ func ApplyUserDefaults(u users.User) users.User {
u.Perm = Config.UserDefaults.Perm
u.Sorting = Config.UserDefaults.Sorting
u.Commands = Config.UserDefaults.Commands
u.HideDotfiles = Config.UserDefaults.HideDotfiles
u.ShowHidden = Config.UserDefaults.ShowHidden
u.DateFormat = Config.UserDefaults.DateFormat
return u
}
+6 -8
View File
@@ -1,22 +1,23 @@
package settings
import (
"log"
"fmt"
"testing"
"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")
yamlData, _ := loadConfigFile("./testingConfig.yaml")
// Marshal the YAML data to a more human-readable format
newConfig := setDefaults()
Config := setDefaults()
err := yaml.Unmarshal(yamlData, &newConfig)
if err != nil {
log.Fatalf("Error unmarshaling YAML data: %v", err)
logger.Fatal(fmt.Sprintf("Error unmarshaling YAML data: %v", err))
}
// Use go-cmp to compare the two structs
if diff := cmp.Diff(newConfig, Config); diff == "" {
@@ -25,23 +26,20 @@ func TestConfigLoadChanged(t *testing.T) {
}
func TestConfigLoadSpecificValues(t *testing.T) {
yamlData := loadConfigFile("./testingConfig.yaml")
yamlData, _ := loadConfigFile("./testingConfig.yaml")
// Marshal the YAML data to a more human-readable format
newConfig := setDefaults()
Config := setDefaults()
err := yaml.Unmarshal(yamlData, &newConfig)
if err != nil {
log.Fatalf("Error unmarshaling YAML data: %v", err)
logger.Fatal(fmt.Sprintf("Error unmarshaling YAML data: %v", err))
}
testCases := []struct {
fieldName string
globalVal interface{}
newVal interface{}
}{
{"Auth.Method", Config.Auth.Method, newConfig.Auth.Method},
{"Auth.Method", Config.Auth.Method, newConfig.Auth.Method},
{"UserDefaults.HideDotfiles", Config.UserDefaults.HideDotfiles, newConfig.UserDefaults.HideDotfiles},
{"Server.Database", Config.Server.Database, newConfig.Server.Database},
}
+83 -37
View File
@@ -4,6 +4,14 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/users"
)
type AllowedMethods string
const (
ProxyAuth AllowedMethods = "proxyAuth"
NoAuth AllowedMethods = "noAuth"
PasswordAuth AllowedMethods = "passwordAuth"
)
type Settings struct {
Commands map[string][]string `json:"commands"`
Shell []string `json:"shell"`
@@ -13,19 +21,37 @@ type Settings struct {
Frontend Frontend `json:"frontend"`
Users []UserDefaults `json:"users,omitempty"`
UserDefaults UserDefaults `json:"userDefaults"`
Integrations Integrations `json:"integrations"`
}
type Auth struct {
TokenExpirationTime string `json:"tokenExpirationTime"`
Recaptcha Recaptcha `json:"recaptcha"`
Header string `json:"header"`
Method string `json:"method"`
Command string `json:"command"`
Signup bool `json:"signup"`
Shell string `json:"shell"`
AdminUsername string `json:"adminUsername"`
AdminPassword string `json:"adminPassword"`
Key []byte `json:"key"`
TokenExpirationHours int `json:"tokenExpirationHours"`
Recaptcha Recaptcha `json:"recaptcha"`
Methods LoginMethods `json:"methods"`
Command string `json:"command"`
Signup bool `json:"signup"`
Method string `json:"method"`
Shell string `json:"shell"`
Key []byte `json:"key"`
AdminUsername string `json:"adminUsername"`
AdminPassword string `json:"adminPassword"`
}
type LoginMethods struct {
ProxyAuth ProxyAuthConfig `json:"proxy"`
NoAuth bool `json:"noauth"`
PasswordAuth PasswordAuthConfig `json:"password"`
}
type PasswordAuthConfig struct {
Enabled bool `json:"enabled"`
MinLength int `json:"minLength"`
}
type ProxyAuthConfig struct {
Enabled bool `json:"enabled"`
CreateUser bool `json:"createUser"`
Header string `json:"header"`
}
type Recaptcha struct {
@@ -35,30 +61,49 @@ type Recaptcha struct {
}
type Server struct {
NumImageProcessors int `json:"numImageProcessors"`
Socket string `json:"socket"`
TLSKey string `json:"tlsKey"`
TLSCert string `json:"tlsCert"`
EnableThumbnails bool `json:"enableThumbnails"`
ResizePreview bool `json:"resizePreview"`
EnableExec bool `json:"enableExec"`
TypeDetectionByHeader bool `json:"typeDetectionByHeader"`
AuthHook string `json:"authHook"`
Port int `json:"port"`
BaseURL string `json:"baseURL"`
Address string `json:"address"`
Log string `json:"log"`
Database string `json:"database"`
Root string `json:"root"`
UserHomeBasePath string `json:"userHomeBasePath"`
CreateUserDir bool `json:"createUserDir"`
Sources map[string]Source `json:"sources"`
ExternalUrl string `json:"externalUrl"`
NumImageProcessors int `json:"numImageProcessors"`
Socket string `json:"socket"`
TLSKey string `json:"tlsKey"`
TLSCert string `json:"tlsCert"`
EnableThumbnails bool `json:"enableThumbnails"`
ResizePreview bool `json:"resizePreview"`
EnableExec bool `json:"enableExec"`
AuthHook string `json:"authHook"`
Port int `json:"port"`
BaseURL string `json:"baseURL"`
Logging []LogConfig `json:"logging"`
Database string `json:"database"`
Root string `json:"root"`
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"`
}
type Integrations struct {
OnlyOffice OnlyOffice `json:"office"`
}
// onlyoffice secret is stored in the local.json file
// docker exec <containerID> /var/www/onlyoffice/documentserver/npm/json -f /etc/onlyoffice/documentserver/local.json 'services.CoAuthoring.secret.session.string'
type OnlyOffice struct {
Url string `json:"url"`
Secret string `json:"secret"`
}
type LogConfig struct {
Levels string `json:"levels"`
ApiLevels string `json:"apiLevels"`
Output string `json:"output"`
NoColors bool `json:"noColors"`
Json bool `json:"json"`
}
type Source struct {
Path string `json:"path"`
Name string
Path string `json:"path"`
Name string `json:"name"`
Config IndexConfig `json:"config"`
}
@@ -111,10 +156,11 @@ type UserDefaults 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"`
HideDotfiles bool `json:"hideDotfiles"`
DateFormat bool `json:"dateFormat"`
ThemeColor string `json:"themeColor"`
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"`
}
+1 -6
View File
@@ -6,20 +6,16 @@ server:
tlsCert: ""
enableThumbnails: false
resizePreview: true
typeDetectionByHeader: true
port: 80
baseURL: "/"
address: ""
log: "stdout"
database: "mydb.db"
root: "/srv"
root: "."
auth:
recaptcha:
host: ""
key: ""
secret: ""
header: ""
method: json
command: ""
signup: false
shell: ""
@@ -48,5 +44,4 @@ userDefaults:
delete: true
share: true
download: true
hideDotfiles: false
dateFormat: false
+4 -1
View File
@@ -14,6 +14,9 @@ func NewStorage(db *storm.DB) (*auth.Storage, *users.Storage, *share.Storage, *s
userStore := users.NewStorage(usersBackend{db: db})
shareStore := share.NewStorage(shareBackend{db: db})
settingsStore := settings.NewStorage(settingsBackend{db: db})
authStore := auth.NewStorage(authBackend{db: db}, userStore)
authStore, err := auth.NewStorage(authBackend{db: db}, userStore)
if err != nil {
return nil, nil, nil, nil, err
}
return authStore, userStore, shareStore, settingsStore, nil
}
+3 -2
View File
@@ -1,13 +1,14 @@
package bolt
import (
"log"
"fmt"
"time"
"github.com/asdine/storm/v3"
"github.com/asdine/storm/v3/q"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/share"
)
@@ -68,7 +69,7 @@ func (s shareBackend) Gets(path string, id uint) ([]*share.Link, error) {
if v[i].Expire < time.Now().Unix() {
err = s.Delete(v[i].PasswordHash)
if err != nil {
log.Println("expired share could not be deleted: ", err.Error())
logger.Error(fmt.Sprintf("expired share could not be deleted: %v", err.Error()))
}
} else {
filteredList = append(filteredList, v[i])
+11 -2
View File
@@ -7,6 +7,7 @@ import (
"github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
@@ -19,16 +20,21 @@ func (st usersBackend) GetBy(i interface{}) (user *users.User, err error) {
user = &users.User{}
var arg string
switch i.(type) {
var val interface{}
switch i := i.(type) {
case uint:
val = i
arg = "ID"
case int:
val = uint(i)
case string:
arg = "Username"
val = i
default:
return nil, errors.ErrInvalidDataType
}
err = st.db.One(arg, i, user)
err = st.db.One(arg, val, user)
if err != nil {
if err == storm.ErrNotFound {
@@ -80,6 +86,9 @@ func (st usersBackend) Update(user *users.User, fields ...string) error {
}
func (st usersBackend) Save(user *users.User) error {
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)
}
pass, err := users.HashPwd(user.Password)
if err != nil {
return err
+9 -12
View File
@@ -2,7 +2,6 @@ package storage
import (
"fmt"
"log"
"os"
"path/filepath"
@@ -10,6 +9,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/auth"
"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/storage/bolt"
@@ -80,14 +80,6 @@ func dbExists(path string) (bool, error) {
func quickSetup(store *Storage) {
settings.Config.Auth.Key = utils.GenerateKey()
if settings.Config.Auth.Method == "noauth" {
err := store.Auth.Save(&auth.NoAuth{})
utils.CheckErr("store.Auth.Save", err)
} else {
settings.Config.Auth.Method = "password"
err := store.Auth.Save(&auth.JSONAuth{})
utils.CheckErr("store.Auth.Save", err)
}
err := store.Settings.Save(&settings.Config)
utils.CheckErr("store.Settings.Save", err)
err = store.Settings.SaveServer(&settings.Config.Server)
@@ -118,15 +110,20 @@ func CreateUser(userInfo users.User, asAdmin bool) error {
// create new home directory
userHome, err := settings.Config.MakeUserDir(newUser.Username, newUser.Scope, files.RootPaths["default"])
if err != nil {
log.Printf("create user: failed to mkdir user home dir: [%s]", userHome)
logger.Error(fmt.Sprintf("create user: failed to mkdir user home dir: [%s]", userHome))
return err
}
newUser.Scope = userHome
log.Printf("user: %s, home dir: [%s].", newUser.Username, userHome)
logger.Debug(fmt.Sprintf("user: %s, home dir: [%s].", newUser.Username, userHome))
// 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 {
log.Println("user path is not valid", newUser.Scope)
logger.Error(fmt.Sprintf("user path is not valid: %v", newUser.Scope))
return nil
}
err = store.Users.Save(&newUser)
+31 -6
View File
@@ -1171,30 +1171,41 @@ const docTemplate = `{
"type": "object",
"properties": {
"files": {
"description": "files in the directory",
"type": "array",
"items": {
"$ref": "#/definitions/files.ItemInfo"
}
},
"folders": {
"description": "folders in the directory",
"type": "array",
"items": {
"$ref": "#/definitions/files.ItemInfo"
}
},
"hidden": {
"description": "whether the file is hidden",
"type": "boolean"
},
"modified": {
"description": "modification time",
"type": "string"
},
"name": {
"description": "name of the file",
"type": "string"
},
"path": {
"description": "path scoped to the associated index",
"type": "string"
},
"size": {
"description": "length in bytes for regular files",
"type": "integer"
},
"type": {
"description": "type of the file, either \"directory\" or a file mimetype",
"type": "string"
}
}
@@ -1202,16 +1213,24 @@ const docTemplate = `{
"files.ItemInfo": {
"type": "object",
"properties": {
"hidden": {
"description": "whether the file is hidden",
"type": "boolean"
},
"modified": {
"description": "modification time",
"type": "string"
},
"name": {
"description": "name of the file",
"type": "string"
},
"size": {
"description": "length in bytes for regular files",
"type": "integer"
},
"type": {
"description": "type of the file, either \"directory\" or a file mimetype",
"type": "string"
}
}
@@ -1351,9 +1370,6 @@ const docTemplate = `{
"gallerySize": {
"type": "integer"
},
"hideDotfiles": {
"type": "boolean"
},
"locale": {
"type": "string"
},
@@ -1366,6 +1382,9 @@ const docTemplate = `{
"permissions": {
"$ref": "#/definitions/users.Permissions"
},
"quickDownload": {
"type": "boolean"
},
"rules": {
"type": "array",
"items": {
@@ -1375,6 +1394,9 @@ const docTemplate = `{
"scope": {
"type": "string"
},
"showHidden": {
"type": "boolean"
},
"singleClick": {
"type": "boolean"
},
@@ -1559,9 +1581,6 @@ const docTemplate = `{
"gallerySize": {
"type": "integer"
},
"hideDotfiles": {
"type": "boolean"
},
"id": {
"type": "integer"
},
@@ -1577,6 +1596,9 @@ const docTemplate = `{
"perm": {
"$ref": "#/definitions/users.Permissions"
},
"quickDownload": {
"type": "boolean"
},
"rules": {
"type": "array",
"items": {
@@ -1586,6 +1608,9 @@ const docTemplate = `{
"scope": {
"type": "string"
},
"showHidden": {
"type": "boolean"
},
"singleClick": {
"type": "boolean"
},
+31 -6
View File
@@ -1160,30 +1160,41 @@
"type": "object",
"properties": {
"files": {
"description": "files in the directory",
"type": "array",
"items": {
"$ref": "#/definitions/files.ItemInfo"
}
},
"folders": {
"description": "folders in the directory",
"type": "array",
"items": {
"$ref": "#/definitions/files.ItemInfo"
}
},
"hidden": {
"description": "whether the file is hidden",
"type": "boolean"
},
"modified": {
"description": "modification time",
"type": "string"
},
"name": {
"description": "name of the file",
"type": "string"
},
"path": {
"description": "path scoped to the associated index",
"type": "string"
},
"size": {
"description": "length in bytes for regular files",
"type": "integer"
},
"type": {
"description": "type of the file, either \"directory\" or a file mimetype",
"type": "string"
}
}
@@ -1191,16 +1202,24 @@
"files.ItemInfo": {
"type": "object",
"properties": {
"hidden": {
"description": "whether the file is hidden",
"type": "boolean"
},
"modified": {
"description": "modification time",
"type": "string"
},
"name": {
"description": "name of the file",
"type": "string"
},
"size": {
"description": "length in bytes for regular files",
"type": "integer"
},
"type": {
"description": "type of the file, either \"directory\" or a file mimetype",
"type": "string"
}
}
@@ -1340,9 +1359,6 @@
"gallerySize": {
"type": "integer"
},
"hideDotfiles": {
"type": "boolean"
},
"locale": {
"type": "string"
},
@@ -1355,6 +1371,9 @@
"permissions": {
"$ref": "#/definitions/users.Permissions"
},
"quickDownload": {
"type": "boolean"
},
"rules": {
"type": "array",
"items": {
@@ -1364,6 +1383,9 @@
"scope": {
"type": "string"
},
"showHidden": {
"type": "boolean"
},
"singleClick": {
"type": "boolean"
},
@@ -1548,9 +1570,6 @@
"gallerySize": {
"type": "integer"
},
"hideDotfiles": {
"type": "boolean"
},
"id": {
"type": "integer"
},
@@ -1566,6 +1585,9 @@
"perm": {
"$ref": "#/definitions/users.Permissions"
},
"quickDownload": {
"type": "boolean"
},
"rules": {
"type": "array",
"items": {
@@ -1575,6 +1597,9 @@
"scope": {
"type": "string"
},
"showHidden": {
"type": "boolean"
},
"singleClick": {
"type": "boolean"
},
+25 -4
View File
@@ -2,33 +2,50 @@ definitions:
files.FileInfo:
properties:
files:
description: files in the directory
items:
$ref: '#/definitions/files.ItemInfo'
type: array
folders:
description: folders in the directory
items:
$ref: '#/definitions/files.ItemInfo'
type: array
hidden:
description: whether the file is hidden
type: boolean
modified:
description: modification time
type: string
name:
description: name of the file
type: string
path:
description: path scoped to the associated index
type: string
size:
description: length in bytes for regular files
type: integer
type:
description: type of the file, either "directory" or a file mimetype
type: string
type: object
files.ItemInfo:
properties:
hidden:
description: whether the file is hidden
type: boolean
modified:
description: modification time
type: string
name:
description: name of the file
type: string
size:
description: length in bytes for regular files
type: integer
type:
description: type of the file, either "directory" or a file mimetype
type: string
type: object
files.SearchResult:
@@ -119,8 +136,6 @@ definitions:
type: boolean
gallerySize:
type: integer
hideDotfiles:
type: boolean
locale:
type: string
lockPassword:
@@ -129,12 +144,16 @@ definitions:
$ref: '#/definitions/users.Permissions'
permissions:
$ref: '#/definitions/users.Permissions'
quickDownload:
type: boolean
rules:
items:
$ref: '#/definitions/users.Rule'
type: array
scope:
type: string
showHidden:
type: boolean
singleClick:
type: boolean
sorting:
@@ -258,8 +277,6 @@ definitions:
type: boolean
gallerySize:
type: integer
hideDotfiles:
type: boolean
id:
type: integer
locale:
@@ -270,12 +287,16 @@ definitions:
type: string
perm:
$ref: '#/definitions/users.Permissions'
quickDownload:
type: boolean
rules:
items:
$ref: '#/definitions/users.Rule'
type: array
scope:
type: string
showHidden:
type: boolean
singleClick:
type: boolean
sorting:
View File
Whitespace-only changes.
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+2 -1
View File
@@ -52,10 +52,11 @@ type User struct {
Commands []string `json:"commands"`
Rules []Rule `json:"rules"`
ApiKeys map[string]AuthToken `json:"apiKeys,omitempty"`
HideDotfiles bool `json:"hideDotfiles"`
ShowHidden bool `json:"showHidden"`
DateFormat bool `json:"dateFormat"`
GallerySize int `json:"gallerySize"`
ThemeColor string `json:"themeColor"`
QuickDownload bool `json:"quickDownload"`
}
var PublicUser = User{
+13 -5
View File
@@ -2,17 +2,20 @@ package utils
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"log"
math "math/rand"
"reflect"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/logger"
)
func CheckErr(source string, err error) {
if err != nil {
log.Fatalf("%s: %v", source, err)
logger.Fatal(fmt.Sprintf("%s: %v", source, err))
}
}
@@ -33,7 +36,7 @@ func CapitalizeFirst(s string) string {
return strings.ToUpper(string(s[0])) + s[1:]
}
func GenerateRandomHash(length int) string {
func InsecureRandomIdentifier(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
math.New(math.NewSource(time.Now().UnixNano()))
result := make([]byte, length)
@@ -49,7 +52,7 @@ func PrintStructFields(v interface{}) {
// Ensure the input is a struct
if val.Kind() != reflect.Struct {
fmt.Println("Provided value is not a struct")
logger.Debug("Provided value is not a struct")
return
}
@@ -66,7 +69,7 @@ func PrintStructFields(v interface{}) {
fieldValue = fieldValue[:100] + "..."
}
fmt.Printf("Field: %s, %s\n", fieldType.Name, fieldValue)
logger.Debug(fmt.Sprintf("Field: %s, %s\n", fieldType.Name, fieldValue))
}
}
@@ -84,3 +87,8 @@ func GetParentDirectoryPath(path string) string {
}
return path[:lastSlash]
}
func HashSHA256(data string) string {
bytes := sha256.Sum256([]byte(data))
return hex.EncodeToString(bytes[:])
}
+1 -1
View File
@@ -10,9 +10,9 @@ async function globalSetup() {
await page.getByPlaceholder("Password").fill("admin");
await page.getByRole("button", { name: "Login" }).click();
await page.waitForURL("**/files/", { timeout: 100 });
await expect(page).toHaveTitle('FileBrowser Quantum - Files');
let cookies = await context.cookies();
expect(cookies.find((c) => c.name == "auth")?.value).toBeDefined();
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
await page.context().storageState({ path: "./loginAuth.json" });
await browser.close();
}
+4 -1
View File
@@ -20,11 +20,13 @@
"test": "vitest run "
},
"dependencies": {
"@playwright/test": "^1.49.1",
"@onlyoffice/document-editor-vue": "^1.4.0",
"ace-builds": "^1.24.2",
"clipboard": "^2.0.4",
"css-vars-ponyfill": "^2.4.3",
"dompurify": "^3.2.4",
"file-loader": "^6.2.0",
"marked": "^15.0.6",
"normalize.css": "^8.0.1",
"qrcode.vue": "^3.4.1",
"vue": "^3.4.21",
@@ -34,6 +36,7 @@
},
"devDependencies": {
"@intlify/unplugin-vue-i18n": "^4.0.0",
"@playwright/test": "^1.49.1",
"@vitejs/plugin-vue": "^5.0.4",
"@vue/eslint-config-typescript": "^13.0.0",
"eslint": "^8.57.0",
+7 -7
View File
@@ -11,21 +11,21 @@ import { defineConfig, devices } from "@playwright/test";
*/
export default defineConfig({
globalSetup: "./global-setup",
timeout: 3000,
timeout: 6000,
testDir: "./tests",
/* Run tests in files in parallel */
fullyParallel: true,
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: Boolean(process.env.CI),
forbidOnly: false,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
retries: 2,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : 1, // required for now! todo parallel some tests
workers: 1, // required for now! todo parallel some tests
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "line",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
actionTimeout: 500,
actionTimeout: 5000,
storageState: "loginAuth.json",
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://127.0.0.1",
@@ -44,4 +44,4 @@ export default defineConfig({
use: { ...devices["Desktop Firefox"] },
},
],
});
});
+6 -1
View File
@@ -13,7 +13,7 @@
<link rel="icon" type="image/png" sizes="256x256" href="{{ .StaticURL }}/img/icons/favicon-256x256.png">
<link href="https://fonts.googleapis.com/css?family=Material+Icons|Material+Symbols+Outlined" rel="stylesheet">
<!--<link href="https://fonts.googleapis.com/css?family=Material+Icons|Material+Symbols+Outlined" rel="stylesheet">-->
<!-- Add to home screen for Android and modern mobile browsers -->
<link rel="manifest" id="manifestPlaceholder" crossorigin="use-credentials">
@@ -28,6 +28,11 @@
<meta name="msapplication-TileImage" content="{{ .StaticURL }}/img/icons/mstile-144x144.png">
<meta name="msapplication-TileColor" content="{{ if .Color }}{{ .Color }}{{ else }}#2979ff{{ end }}">
<meta name="description" content="FileBrowser Quantum is a file manager for the web which can be used to manage files on your server">
<link rel="canonical" href="https://github.com/gtsteffaniak/filebrowser">
<link rel="canonical" href="https://github.com/gtsteffaniak">
<link rel="canonical" href="https://github.com/quantumx-apps">
<!-- Inject Some Variables and generate the manifest json -->
<script>
window.FileBrowser = JSON.parse('{{ .globalVars }}');
+21 -6
View File
@@ -2,6 +2,7 @@ import { fetchURL, adjustedData } from "./utils";
import { removePrefix, getApiPath } from "@/utils/url.js";
import { state } from "@/store";
import { notify } from "@/notify";
import { externalUrl } from "@/utils/constants";
// Notify if errors occur
export async function fetchFiles(url, content = false) {
@@ -78,7 +79,7 @@ export function download(format, files) {
export async function post(url, content = "", overwrite = false, onupload) {
try {
url = removePrefix(url,"files");
url = removePrefix(url, "files");
let bufferContent;
if (
@@ -99,7 +100,12 @@ export async function post(url, content = "", overwrite = false, onupload) {
request.setRequestHeader("X-Auth", state.jwt);
if (typeof onupload === "function") {
request.upload.onprogress = onupload;
request.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percentComplete = Math.round((event.loaded / event.total) * 100);
onupload(percentComplete); // Pass the percentage to the callback
}
};
}
request.onload = () => {
@@ -157,23 +163,32 @@ export async function moveCopy(items, action = "copy", overwrite = false, rename
}
export async function checksum(url, algo) {
export async function checksum(path, algo) {
try {
const data = await resourceAction(`${url}?checksum=${algo}`, "GET");
return (await data.json()).checksums[algo];
const params = {
path: encodeURIComponent(removePrefix(path, "files")),
checksum: algo,
};
const apiPath = getApiPath("api/resources", params);
const res = await fetchURL(apiPath);
const data = await res.json();
return data.checksums[algo];
} catch (err) {
notify.showError(err.message || "Error fetching checksum");
throw err;
}
}
export function getDownloadURL(path, inline) {
export function getDownloadURL(path, inline, useExternal) {
try {
const params = {
files: encodeURIComponent(removePrefix(decodeURI(path),"files")),
...(inline && { inline: "true" }),
};
const apiPath = getApiPath("api/raw", params);
if (externalUrl && useExternal) {
return externalUrl+apiPath
}
return window.origin+apiPath
} catch (err) {
notify.showError(err.message || "Error getting download URL");
Binary file not shown.
+9 -7
View File
@@ -13,21 +13,20 @@
<div v-if="selectedCount > 0" class="button selected-count-header">
<span>{{ selectedCount }} selected</span>
</div>
<action
v-if="!headerButtons.select"
v-if="!isSearchActive"
icon="create_new_folder"
:label="$t('sidebar.newFolder')"
@action="showHover('newDir')"
/>
<action
v-if="!headerButtons.select"
v-if="!headerButtons.select && !isSearchActive"
icon="note_add"
:label="$t('sidebar.newFile')"
@action="showHover('newFile')"
/>
<action
v-if="!headerButtons.select"
v-if="!headerButtons.select && !isSearchActive"
icon="file_upload"
:label="$t('buttons.upload')"
@action="uploadFunc"
@@ -40,7 +39,7 @@
show="info"
/>
<action
v-if="!isMultiple"
v-if="!isMultiple && !isSearchActive"
icon="check_circle"
:label="$t('buttons.selectMultiple')"
@action="toggleMultipleSelection"
@@ -59,7 +58,7 @@
show="share"
/>
<action
v-if="headerButtons.rename"
v-if="headerButtons.rename && !isSearchActive"
icon="mode_edit"
:label="$t('buttons.rename')"
show="rename"
@@ -102,6 +101,9 @@ export default {
};
},
computed: {
isSearchActive() {
return state.isSearchActive;
},
isMultiple() {
return state.multiple;
},
@@ -172,7 +174,7 @@ export default {
// if y is too close to the bottom edge, move it up by 400px
if (tempY > screenHeight - 400) {
tempY -= 400;
tempY -= 200;
}
this.posX = tempX;
+5
View File
@@ -140,4 +140,9 @@ export default {
.yellow-icons {
color: yellow;
}
.simple-icons {
color: white;
font-size: 1.5em !important;
}
</style>
+29
View File
@@ -0,0 +1,29 @@
<template>
<div class="card" id="popup-notification">
<i v-on:click="closePopUp" class="material-icons">close</i>
<canvas class="notification-spinner hidden" width="100" height="100"></canvas>
<div id="popup-notification-content">no info</div>
</div>
</template>
<script>
import { notify } from "@/notify";
export default {
name: "notifications",
data: function () {
return {};
},
methods: {
closePopUp() {
return notify.closePopUp();
},
},
};
</script>
<style>
canvas.notification-spinner {
color: #fff;
}
</style>
+58 -16
View File
@@ -1,5 +1,9 @@
<template>
<div id="search" :class="{ active, ongoing, 'dark-mode': isDarkMode }">
<div
id="search"
:class="{ active, ongoing, 'dark-mode': isDarkMode }"
@click="clearContext"
>
<!-- Search input section -->
<div id="input" @click="open">
<!-- Close button visible when search is active -->
@@ -115,11 +119,18 @@
</div>
<!-- List of search results -->
<ul v-show="results.length > 0">
<li v-for="(s, k) in results" :key="k" class="search-entry">
<a :href="getRelative(s.path)">
<li
v-for="(s, k) in results"
:key="k"
class="search-entry"
:class="{ active: activeStates[k] }"
:aria-label="baseName(s.path)"
>
<a :href="getRelative(s.path)" @contextmenu="addSelected(event, s)">
<Icon :mimetype="s.type" />
<span class="text-container">
{{ basePath(s.path, s.type === "directory")}}<b>{{ baseName(s.path) }}</b>
{{ basePath(s.path, s.type === "directory")
}}<b>{{ baseName(s.path) }}</b>
</span>
<div class="filesize">{{ humanSize(s.size) }}</div>
</a>
@@ -136,6 +147,7 @@ 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 Icon from "@/components/Icon.vue";
@@ -226,7 +238,7 @@ export default {
return this.isTypeSelectDisabled;
},
active() {
return getters.currentPromptName() == "search";
return state.isSearchActive;
},
isDarkMode() {
return getters.isDarkMode();
@@ -255,17 +267,22 @@ export default {
return this.showHelp;
},
getContext() {
let path = state.route.path;
path = path.slice(1);
path = "./" + path.substring(path.indexOf("/") + 1);
path = path.replace(/\/+$/, "") + "/";
if (path == "./files/") {
path = "./";
}
return path;
return this.getRelativeContext();
},
activeStates() {
// Create a Set of combined `name` and `type` keys for efficient lookup
const selectedSet = new Set(
state.selected.map((item) => `${item.name}:${item.type}`)
);
const result = this.results.map((s) => selectedSet.has(`${s.name}:${s.type}`));
// Build a map of active states for the `results` array
return result;
},
},
methods: {
getRelativeContext() {
return removePrefix(decodeURIComponent(state.route.path), "files");
},
getRelative(path) {
return removeTrailingSlash(window.location.href) + "/" + removeLeadingSlash(path);
},
@@ -304,15 +321,18 @@ export default {
return parts.pop();
},
open() {
if (!this.active) {
if (!state.isSearchActive) {
mutations.closeHovers();
mutations.closeSidebar();
mutations.resetSelected();
this.resetSearchFilters();
mutations.showHover("search");
mutations.setSearch(true);
}
},
close(event) {
this.value = "";
event.stopPropagation();
mutations.closeHovers();
mutations.setSearch(false);
},
keyup(event) {
if (event.keyCode === 27) {
@@ -381,6 +401,24 @@ export default {
toggleHelp() {
this.showHelp = !this.showHelp;
},
clearContext() {
mutations.closeHovers();
},
addSelected(event, s) {
const pathParts = s.path.split("/");
const path = removePrefix(decodeURIComponent(state.route.path), "files") + s.path;
const modifiedItem = {
name: pathParts.pop(),
path: path,
size: s.size,
type: s.type,
source: "",
url: path,
fullPath: path,
};
mutations.resetSelected();
mutations.addSelected(modifiedItem);
},
},
};
</script>
@@ -511,6 +549,10 @@ export default {
border-radius: 0.25em;
}
.search-entry.active {
background-color: var(--surfacePrimary);
}
.search-entry:hover {
background-color: var(--surfacePrimary);
}
+29 -4
View File
@@ -5,6 +5,7 @@
item: true,
'no-select': true,
activebutton: isMaximized && isSelected,
hiddenFile: isHiddenNotSelected
}"
:id="getID"
role="button"
@@ -25,6 +26,7 @@
@mouseup="cancelContext($event)"
>
<div @click="toggleClick" :class="{ activetitle: isMaximized && isSelected }">
<img
v-if="
readOnly === undefined &&
@@ -40,12 +42,14 @@
</div>
<div class="text" :class="{ activecontent: isMaximized && isSelected }">
<p class="name">{{ name }}</p>
<p class="size" :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" />
</a>
</template>
@@ -76,6 +80,8 @@
<script>
import { enableThumbs } from "@/utils/constants";
import downloadFiles from "@/utils/download";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { fromNow } from "@/utils/moment";
import { filesApi } from "@/api";
@@ -112,8 +118,15 @@ export default {
"index",
"readOnly",
"path",
"hidden"
],
computed: {
quickDownloadEnabled() {
return state.user?.quickDownload;
},
isHiddenNotSelected() {
return !this.isSelected && this.hidden
},
getID() {
return url.base64Encode(encodeURIComponent(this.name));
},
@@ -140,13 +153,11 @@ export default {
},
canDrop() {
if (!this.isDir || this.readOnly !== undefined) return false;
for (let i of this.selected) {
if (state.req.items[i].url === this.url) {
return false;
}
}
return true;
},
thumbnailUrl() {
@@ -178,6 +189,12 @@ export default {
}
},
methods: {
downloadFile(event) {
event.preventDefault();
mutations.resetSelected();
mutations.addSelected(this.index);
downloadFiles()
},
handleTouchMove(event) {
if (!state.isSafari) return;
const touch = event.touches[0];
@@ -419,7 +436,15 @@ export default {
</script>
<style>
.icon-download {
font-size: 0.5em;
}
.item {
-webkit-touch-callout: none; /* Disable the default long press preview */
}
.hiddenFile {
opacity: 0.5;
}
</style>
+18 -10
View File
@@ -52,7 +52,6 @@ import FileList from "./FileList.vue";
import { filesApi } from "@/api";
import buttons from "@/utils/buttons";
import * as upload from "@/utils/upload";
import { removePrefix } from "@/utils/url";
import { notify } from "@/notify";
export default {
@@ -74,12 +73,21 @@ export default {
},
},
mounted() {
for (let item of state.selected) {
this.items.push({
from: state.req.items[item].url,
// add to: dest
name: state.req.items[item].name,
});
if (state.isSearchActive) {
this.items = [
{
from: "/files" + state.selected[0].url,
name: state.selected[0].name,
},
];
} else {
for (let item of state.selected) {
this.items.push({
from: state.req.items[item].url,
// add to: dest
name: state.req.items[item].name,
});
}
}
},
methods: {
@@ -88,9 +96,8 @@ export default {
try {
// Define the action function
let action = async (overwrite, rename) => {
const loc = removePrefix(this.dest, "files");
for (let item of this.items) {
item.to = loc + "/" + item.name;
item.to = this.dest + item.name;
}
buttons.loading("copy");
await filesApi.moveCopy(this.items, "copy", overwrite, rename);
@@ -123,12 +130,13 @@ export default {
await action(overwrite, rename);
}
mutations.closeHovers();
mutations.setSearch(false);
notify.showSuccess("Successfully copied file/folder, redirecting...");
setTimeout(() => {
this.$router.push(this.dest);
}, 1000);
} catch (error) {
notify.error(error);
notify.showError(error);
}
},
},
+23 -2
View File
@@ -7,6 +7,9 @@
<p v-else>
{{ $t("prompts.deleteMessageMultiple", { count: selectedCount }) }}
</p>
<div style="display: grid" class="searchContext">
<span v-for="(item, index) in nav" :key="index"> {{ item }} </span>
</div>
</div>
<div class="card-action">
<button
@@ -34,6 +37,7 @@ import { filesApi } from "@/api";
import buttons from "@/utils/buttons";
import { state, getters, mutations } from "@/store";
import { notify } from "@/notify";
import { removePrefix } from "@/utils/url";
export default {
name: "delete",
@@ -47,6 +51,16 @@ export default {
currentPrompt() {
return getters.currentPrompt();
},
nav() {
if (state.isSearchActive) {
return [state.selected[0].path];
}
let paths = [];
for (let index of state.selected) {
paths.push(removePrefix(state.req.items[index].url, "files"));
}
return paths;
},
},
methods: {
closeHovers() {
@@ -56,17 +70,24 @@ export default {
buttons.loading("delete");
try {
if (state.isSearchActive) {
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);
buttons.success("delete");
notify.showSuccess("Deleted item successfully");
this.currentPrompt?.confirm();
this.closeHovers();
mutations.closeHovers();
return;
}
this.closeHovers();
mutations.closeHovers();
if (getters.selectedCount() === 0) {
return;
+23 -2
View File
@@ -99,6 +99,9 @@ export default {
return getters.isListing();
},
humanSize() {
if (state.isSearchActive) {
return getHumanReadableFilesize(state.selected[0].size);
}
if (getters.selectedCount() === 0 || !this.isListing) {
return getHumanReadableFilesize(state.req.size);
}
@@ -112,6 +115,9 @@ export default {
return getHumanReadableFilesize(sum);
},
humanTime() {
if (state.isSearchActive) {
return "unknown";
}
if (getters.selectedCount() === 0) {
return formatTimestamp(state.req.modified, state.user.locale);
}
@@ -121,19 +127,31 @@ export default {
);
},
modTime() {
if (state.isSearchActive) {
return "";
}
return new Date(Date.parse(state.req.modified)).toLocaleString();
},
name() {
if (state.isSearchActive) {
return state.selected[0].name;
}
return getters.selectedCount() === 0
? state.req.name
: state.req.items[this.selected[0]].name;
},
type() {
if (state.isSearchActive) {
return state.selected[0].type;
}
return getters.selectedCount() === 0
? state.req.type
: state.req.items[this.selected[0]].type;
},
dir() {
if (state.isSearchActive) {
return state.selected[0].type === "directory";
}
return (
getters.selectedCount() > 1 ||
(getters.selectedCount() === 0
@@ -145,9 +163,12 @@ export default {
methods: {
async checksum(event, algo) {
event.preventDefault();
let link;
if (state.isSearchActive) {
const hash = await filesApi.checksum(state.selected[0].url, algo);
event.target.innerHTML = hash;
return;
}
if (getters.selectedCount()) {
link = state.req.items[this.selected[0]].url;
} else {
+19 -15
View File
@@ -35,7 +35,6 @@
<button
class="button button--flat"
@click="move"
:disabled="$route.path === dest"
:aria-label="$t('buttons.move')"
:title="$t('buttons.move')"
>
@@ -53,7 +52,6 @@ import { filesApi } from "@/api";
import buttons from "@/utils/buttons";
import * as upload from "@/utils/upload";
import { notify } from "@/notify";
import { removePrefix } from "@/utils/url";
export default {
name: "move",
@@ -74,12 +72,21 @@ export default {
},
},
mounted() {
for (let item of state.selected) {
this.items.push({
from: state.req.items[item].url,
// add to: dest
name: state.req.items[item].name,
});
if (state.isSearchActive) {
this.items = [
{
from: "/files" + state.selected[0].url,
name: state.selected[0].name,
},
];
} else {
for (let item of state.selected) {
this.items.push({
from: state.req.items[item].url,
// add to: dest
name: state.req.items[item].name,
});
}
}
},
methods: {
@@ -88,18 +95,15 @@ export default {
try {
// Define the action function
let action = async (overwrite, rename) => {
const loc = removePrefix(this.dest, "files");
for (let item of this.items) {
item.to = loc + "/" + item.name;
item.to = this.dest + item.name;
}
buttons.loading("move");
await filesApi.moveCopy(this.items, "move", overwrite, rename);
};
// Fetch destination files
let dstResp = await filesApi.fetchFiles(this.dest);
let conflict = upload.checkConflict(this.items, dstResp.items);
let overwrite = false;
let rename = false;
@@ -125,13 +129,13 @@ export default {
await action(overwrite, rename);
}
mutations.closeHovers();
mutations.setSearch(false);
notify.showSuccess("Successfully moved file/folder, redirecting...");
setTimeout(() => {
this.$router.push(this.dest);
}, 1000);
} catch (e) {
// Catch any errors from action or other parts of the flow
notify.showError(e);
} catch (error) {
notify.showError(error);
}
},
},
+15 -5
View File
@@ -4,7 +4,7 @@
<h2>{{ $t("buttons.share") }}</h2>
</div>
<div 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>
<template v-if="listing">
<div class="card-content">
<table>
@@ -158,6 +158,9 @@ export default {
return getters.isListing(); // Access getter directly from the store
},
url() {
if (state.isSearchActive) {
return state.selected[0].url;
}
if (!this.isListing) {
return state.route.path;
}
@@ -170,11 +173,15 @@ export default {
},
async beforeMount() {
try {
let path = "." + getters.routePath("files");
if (getters.selectedCount() === 1) {
path = path + state.req.items[this.selected[0]].name;
if (state.isSearchActive) {
this.subpath = state.selected[0].path;
} else {
let path = "." + getters.routePath("files");
if (getters.selectedCount() === 1) {
path = path + state.req.items[this.selected[0]].name;
}
this.subpath = decodeURIComponent(path);
}
this.subpath = decodeURIComponent(path);
// get last element of the path
const links = await shareApi.get(this.subpath);
this.links = links;
@@ -238,6 +245,9 @@ export default {
return shareApi.getShareURL(share);
},
hasDownloadLink() {
if (state.isSearchActive) {
return state.selected[0].type != "directory";
}
return this.selected.length === 1 && !state.req.items[this.selected[0]].isDir;
},
buildDownloadLink(share) {
@@ -1,5 +1,5 @@
<template>
<div v-if="!user.perm.admin && !isNew">
<div v-if="!stateUser.perm.admin && !isNew">
<label for="password">{{ $t("settings.password") }}</label>
<input
class="input input--block"
@@ -64,7 +64,7 @@
<p v-if="!isDefault">
<input
type="checkbox"
:disabled="user.perm?.admin"
:disabled="stateUser.perm?.admin"
v-model="user.lockPassword"
@input="emitUpdate"
/>
@@ -81,6 +81,7 @@ import Languages from "./Languages.vue";
import Permissions from "./Permissions.vue";
import Commands from "./Commands.vue";
import { enableExec } from "@/utils/constants";
import { state } from "@/store";
export default {
name: "UserForm",
@@ -119,6 +120,9 @@ export default {
},
},
computed: {
stateUser() {
return state.user;
},
passwordPlaceholder() {
return this.isNew ? "" : this.$t("settings.avoidChanges");
},
+5 -5
View File
@@ -4,10 +4,10 @@
<div @click="navigateTo('/settings#profile-main')" class="inner-card">
<i class="material-icons">person</i>
{{ user.username }}
<i class="material-icons">settings</i>
<i aria-label="settings" class="material-icons">settings</i>
</div>
<div class="inner-card logout-button" @click="logout" >
<div class="inner-card logout-button" @click="logout">
<i v-if="canLogout" class="material-icons">exit_to_app</i>
</div>
</div>
@@ -94,7 +94,7 @@ export default {
};
},
mounted() {
this.updateUsage();
this.updateUsage();
},
computed: {
isSettings: () => getters.isSettings(),
@@ -154,8 +154,8 @@ export default {
}
let usage = await filesApi.usage("default");
usageStats = {
used: getHumanReadableFilesize(usage.used / 1024),
total: getHumanReadableFilesize(usage.total / 1024),
used: getHumanReadableFilesize(usage.used),
total: getHumanReadableFilesize(usage.total),
usedPercentage: Math.round((usage.used / usage.total) * 100),
};
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff. Show more