Beta/v0.5.0 (#333)

This commit is contained in:
Graham Steffaniak 2025-01-31 15:26:21 -05:00 committed by GitHub
parent 0bc789f9bd
commit e269fc8c0a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 644 additions and 227 deletions

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 }}

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 }}

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 }}

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 }}

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 }}

View File

@ -2,9 +2,27 @@
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.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**
**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.
@ -19,10 +37,10 @@ All notable changes to this project will be documented in this file. For commit
## v0.4.1-beta
**New Features**
**New Features**:
- right-click actions are available on search. https://github.com/gtsteffaniak/filebrowser/issues/273
**Notes**
**Notes**:
- delete prompt now lists all items that will be affected by delete
- Debug and logger output tweaks.
@ -34,13 +52,13 @@ All notable changes to this project will be documented in this file. For commit
## v0.4.0-beta
**New Features**
**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**
**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
@ -58,7 +76,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**:
@ -74,7 +92,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

View File

@ -1,13 +1,17 @@
<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">
<div align="center">
[![Go Report Card](https://goreportcard.com/badge/github.com/gtsteffaniak/filebrowser/backend)](https://goreportcard.com/report/github.com/gtsteffaniak/filebrowser/backend)
[![latest version](https://img.shields.io/github/release/gtsteffaniak/filebrowser/all.svg)](https://github.com/gtsteffaniak/filebrowser/releases)
[![Apache-2.0 License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/1c48cfb7646d4009aa8c6f71287670b8)](https://www.codacy.com/gh/gtsteffaniak/filebrowser/dashboard)
[![DockerHub Pulls](https://img.shields.io/docker/pulls/gtstef/filebrowser?label=latest%20Docker%20pulls)](https://hub.docker.com/r/gtstef/filebrowser)
<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">
</p>
</div>
> [!WARNING]
> There is no stable version -- planned 2025.

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
_docker/src/config.yaml Normal file
View File

@ -0,0 +1,8 @@
server:
port: 80
baseURL: "/"
root: "../frontend/tests/playwright-files"
auth:
method: proxy
header: X-Username
signup: false

13
_docker/src/default.conf Normal file
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;
}
}

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.

View File

@ -1,10 +1,13 @@
package cmd
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"github.com/gtsteffaniak/filebrowser/backend/diskcache"
"github.com/gtsteffaniak/filebrowser/backend/files"
@ -71,6 +74,15 @@ 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] {
@ -116,6 +128,7 @@ Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v
return
}
}
store, dbExists := getStore(configPath)
database := fmt.Sprintf("Using existing database : %v", settings.Config.Server.Database)
if !dbExists {
@ -125,9 +138,22 @@ Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v
for _, v := range settings.Config.Server.Sources {
sources = append(sources, v.Name+": "+v.Path)
}
authMethods := []string{}
if settings.Config.Auth.Methods.PasswordAuth {
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.Debug(fmt.Sprintf("Embeded frontend : %v", os.Getenv("FILEBROWSER_NO_EMBEDED") != "true"))
logger.Info(fmt.Sprintf("Auth Methods : %v", authMethods))
logger.Info(database)
logger.Info(fmt.Sprintf("Sources : %v", sources))
serverConfig := settings.Config.Server
@ -142,18 +168,34 @@ Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v
for _, source := range sourceConfigs {
go files.Initialize(source)
}
if err := rootCMD(store, &serverConfig); err != nil {
// 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...")
}
func rootCMD(store *storage.Storage, serverConfig *settings.Server) error {
<-shutdownComplete // Ensure we don't exit prematurely
// Wait for the server to stop
logger.Info("Shutdown complete.")
}
func rootCMD(ctx context.Context, store *storage.Storage, serverConfig *settings.Server, shutdownComplete chan struct{}) error {
if serverConfig.NumImageProcessors < 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
@ -167,7 +209,7 @@ func rootCMD(store *storage.Storage, serverConfig *settings.Server) error {
// 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
}

View File

@ -3,7 +3,6 @@ server:
baseURL: "/"
root: "/srv"
auth:
method: password
signup: false
userDefaults:
darkMode: true

View File

@ -75,8 +75,13 @@ func extractToken(r *http.Request) (string, error) {
}
func loginHandler(w http.ResponseWriter, r *http.Request) {
if !config.Auth.Methods.PasswordAuth {
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

View File

@ -13,6 +13,7 @@ import (
"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"
)
@ -93,13 +94,42 @@ 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 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"], 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) {

View File

@ -1,6 +1,7 @@
package http
import (
"context"
"crypto/tls"
"embed"
"fmt"
@ -8,6 +9,7 @@ import (
"net/http"
"os"
"text/template"
"time"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
@ -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
@ -140,16 +142,20 @@ func StartHttp(Service ImgService, storage *storage.Storage, cache FileCache) {
var scheme string
port := ""
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))
logger.Fatal(fmt.Sprintf("Could not load certificate: %v", err))
}
// Create a custom TLS listener
// Create a custom TLS configuration
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
Certificates: []tls.Certificate{cer},
@ -157,21 +163,21 @@ func StartHttp(Service ImgService, storage *storage.Storage, cache FileCache) {
// 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 {
logger.Fatal(fmt.Sprintf("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)
logger.Info(fmt.Sprintf("Running at : %s", fullURL))
err = http.Serve(listener, muxWithMiddleware(router))
// Create a TLS listener and serve
listener, err := tls.Listen("tcp", srv.Addr, tlsConfig)
if err != nil {
logger.Fatal(fmt.Sprintf("could not start server: %v", err))
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
@ -179,12 +185,31 @@ func StartHttp(Service ImgService, storage *storage.Storage, cache FileCache) {
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))
err := http.ListenAndServe(fmt.Sprintf(":%v", config.Server.Port), muxWithMiddleware(router))
if err != nil {
logger.Fatal(fmt.Sprintf("could not start server: %v", err))
// 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)
}

View File

@ -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,
@ -80,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 {
raw, err := store.Auth.Get("password") //nolint:govet
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return

View File

@ -46,7 +46,9 @@ func Initialize(configFile string) {
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 {
@ -129,14 +131,22 @@ func setDefaults() Settings {
Root: ".",
},
Auth: Auth{
TokenExpirationHours: 2,
AdminUsername: "admin",
AdminPassword: "admin",
Method: "password",
TokenExpirationHours: 2,
Signup: false,
Recaptcha: Recaptcha{
Host: "",
},
Methods: LoginMethods{
ProxyAuth: ProxyAuthConfig{
Enabled: false,
CreateUser: false,
Header: "",
},
NoAuth: false,
PasswordAuth: true,
},
},
Frontend: Frontend{
Name: "FileBrowser Quantum",
@ -145,8 +155,8 @@ func setDefaults() Settings {
StickySidebar: true,
Scope: ".",
LockPassword: false,
ShowHidden: true,
DarkMode: false,
ShowHidden: false,
DarkMode: true,
DisableSettings: false,
ViewMode: "normal",
Locale: "en",

View File

@ -40,8 +40,6 @@ func TestConfigLoadSpecificValues(t *testing.T) {
globalVal interface{}
newVal interface{}
}{
{"Auth.Method", Config.Auth.Method, newConfig.Auth.Method},
{"Auth.Method", Config.Auth.Method, newConfig.Auth.Method},
{"Server.Database", Config.Server.Database, newConfig.Server.Database},
}

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"`
@ -19,14 +27,26 @@ type Settings struct {
type Auth struct {
TokenExpirationHours int `json:"tokenExpirationHours"`
Recaptcha Recaptcha `json:"recaptcha"`
Header string `json:"header"`
Method string `json:"method"`
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"`
Key []byte `json:"key"`
}
type LoginMethods struct {
ProxyAuth ProxyAuthConfig `json:"proxy"`
NoAuth bool `json:"noauth"`
PasswordAuth bool `json:"password"`
}
type ProxyAuthConfig struct {
Enabled bool `json:"enabled"`
CreateUser bool `json:"createUser"`
Header string `json:"header"`
}
type Recaptcha struct {
@ -54,6 +74,7 @@ type Server struct {
Sources []Source `json:"sources"`
ExternalUrl string `json:"externalUrl"`
InternalUrl string `json:"internalUrl"` // used by integrations
CacheDir string `json:"cacheDir"`
}
type Integrations struct {

View File

@ -16,7 +16,6 @@ auth:
key: ""
secret: ""
header: ""
method: json
command: ""
signup: false
shell: ""

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
}

View File

@ -19,9 +19,11 @@ func (st usersBackend) GetBy(i interface{}) (user *users.User, err error) {
user = &users.User{}
var arg string
switch i.(type) {
switch val := i.(type) {
case uint:
arg = "ID"
case int:
i = uint(val)
case string:
arg = "Username"
default:

View File

@ -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)

View File

@ -1184,6 +1184,10 @@ const docTemplate = `{
"$ref": "#/definitions/files.ItemInfo"
}
},
"hidden": {
"description": "whether the file is hidden",
"type": "boolean"
},
"modified": {
"description": "modification time",
"type": "string"
@ -1209,6 +1213,10 @@ const docTemplate = `{
"files.ItemInfo": {
"type": "object",
"properties": {
"hidden": {
"description": "whether the file is hidden",
"type": "boolean"
},
"modified": {
"description": "modification time",
"type": "string"
@ -1362,9 +1370,6 @@ const docTemplate = `{
"gallerySize": {
"type": "integer"
},
"hideDotfiles": {
"type": "boolean"
},
"locale": {
"type": "string"
},
@ -1386,6 +1391,9 @@ const docTemplate = `{
"scope": {
"type": "string"
},
"showHidden": {
"type": "boolean"
},
"singleClick": {
"type": "boolean"
},
@ -1570,9 +1578,6 @@ const docTemplate = `{
"gallerySize": {
"type": "integer"
},
"hideDotfiles": {
"type": "boolean"
},
"id": {
"type": "integer"
},
@ -1597,6 +1602,9 @@ const docTemplate = `{
"scope": {
"type": "string"
},
"showHidden": {
"type": "boolean"
},
"singleClick": {
"type": "boolean"
},

View File

@ -1173,6 +1173,10 @@
"$ref": "#/definitions/files.ItemInfo"
}
},
"hidden": {
"description": "whether the file is hidden",
"type": "boolean"
},
"modified": {
"description": "modification time",
"type": "string"
@ -1198,6 +1202,10 @@
"files.ItemInfo": {
"type": "object",
"properties": {
"hidden": {
"description": "whether the file is hidden",
"type": "boolean"
},
"modified": {
"description": "modification time",
"type": "string"
@ -1351,9 +1359,6 @@
"gallerySize": {
"type": "integer"
},
"hideDotfiles": {
"type": "boolean"
},
"locale": {
"type": "string"
},
@ -1375,6 +1380,9 @@
"scope": {
"type": "string"
},
"showHidden": {
"type": "boolean"
},
"singleClick": {
"type": "boolean"
},
@ -1559,9 +1567,6 @@
"gallerySize": {
"type": "integer"
},
"hideDotfiles": {
"type": "boolean"
},
"id": {
"type": "integer"
},
@ -1586,6 +1591,9 @@
"scope": {
"type": "string"
},
"showHidden": {
"type": "boolean"
},
"singleClick": {
"type": "boolean"
},

View File

@ -11,6 +11,9 @@ definitions:
items:
$ref: '#/definitions/files.ItemInfo'
type: array
hidden:
description: whether the file is hidden
type: boolean
modified:
description: modification time
type: string
@ -29,6 +32,9 @@ definitions:
type: object
files.ItemInfo:
properties:
hidden:
description: whether the file is hidden
type: boolean
modified:
description: modification time
type: string
@ -130,8 +136,6 @@ definitions:
type: boolean
gallerySize:
type: integer
hideDotfiles:
type: boolean
locale:
type: string
lockPassword:
@ -146,6 +150,8 @@ definitions:
type: array
scope:
type: string
showHidden:
type: boolean
singleClick:
type: boolean
sorting:
@ -269,8 +275,6 @@ definitions:
type: boolean
gallerySize:
type: integer
hideDotfiles:
type: boolean
id:
type: integer
locale:
@ -287,6 +291,8 @@ definitions:
type: array
scope:
type: string
showHidden:
type: boolean
singleClick:
type: boolean
sorting:

View File

@ -16,7 +16,7 @@ export default defineConfig({
/* Run tests in files in parallel */
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: 2,
/* Opt out of parallel tests on CI. */

View File

@ -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 }}');

View File

@ -100,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 = () => {

View File

@ -174,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;

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>

View File

@ -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");
},

View File

@ -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),
};

View File

@ -12,7 +12,7 @@
<a :href="item.url" target="_blank" :title="item.title">{{ item.text }}</a>
</span>
<span v-if="name != ''">
<h4>{{ name }}</h4>
<h4 style="margin: 0">{{ name }}</h4>
</span>
</div>
</nav>

View File

@ -90,7 +90,7 @@ body.rtl #listingView {
#listingView {
padding-top: 1em;
padding-bottom: 1em;
padding-bottom: 30em;
}
#listingView.gallery .item,

View File

@ -1,5 +1,9 @@
import * as messageFunctions from "./message.js";
import * as loadingSpinnerFunctions from "./loadingSpinner.js";
import * as notify from "./message.js";
export {
notify,
const notify = {
...messageFunctions,
...loadingSpinnerFunctions
};
export { notify };

View File

@ -0,0 +1,87 @@
export function startLoading (from, to) {
if (from == to) {
return
}
console.log('startLoading', from, to)
// Get the spinner canvas element
let spinner = document.querySelector('.notification-spinner')
if (!spinner) {
console.error('Spinner canvas element not found')
return
}
spinner.classList.remove('hidden')
// Get the 2D context of the canvas
let ctx = spinner.getContext('2d')
if (!ctx) {
console.error('Could not get 2D context')
return
}
// Set canvas dimensions
let width = spinner.width
let height = spinner.height
// Initialize variables
let degrees = from * 3.6 // Convert percentage to degrees
let new_degrees = to * 3.6 // Convert percentage to degrees
let difference = new_degrees - degrees
let color = spinner.style.color || '#666'
let bgcolor = '#fff'
let animation_loop
// Clear any existing animation loop
if (animation_loop !== undefined) clearInterval(animation_loop)
// Calculate the increment per 10ms
let duration = 300 // Duration of the animation in ms
let increment = difference / (duration / 10)
// Start the animation loop
animation_loop = setInterval(function () {
// Check if the animation should stop
if (
(increment > 0 && degrees >= new_degrees) ||
(increment < 0 && degrees <= new_degrees)
) {
clearInterval(animation_loop)
return
}
// Update the degrees
degrees += increment
// Clear the canvas
ctx.clearRect(0, 0, width, height)
// Draw the background circle
ctx.beginPath()
ctx.strokeStyle = bgcolor
ctx.lineWidth = 10
ctx.arc(width / 2, height / 2, height / 3, 0, Math.PI * 2, false)
ctx.stroke()
// Draw the foreground circle
let radians = (degrees * Math.PI) / 180
ctx.beginPath()
ctx.strokeStyle = color
ctx.lineWidth = 10
ctx.arc(
width / 2,
height / 2,
height / 3,
0 - (90 * Math.PI) / 180,
radians - (90 * Math.PI) / 180,
false
)
ctx.stroke()
// Draw the text
ctx.fillStyle = '#fff'
ctx.font = '1.2em Roboto'
let text = Math.floor((degrees / 360) * 100) + '%'
let text_width = ctx.measureText(text).width
ctx.fillText(text, width / 2 - text_width / 2, height / 2 + 8)
}, 10) // Update every 10ms
}

View File

@ -1,78 +1,96 @@
import { mutations, state } from "@/store";
import { mutations, state } from '@/store'
export function showPopup(type, message) {
const [popup, popupContent] = getElements();
if (popup === undefined) {
return;
let active = false
let closeTimeout // Store timeout ID
export function showPopup(type, message, autoclose = true) {
if (active) {
clearTimeout(closeTimeout) // Clear the existing timeout
}
popup.classList.remove('success', 'error'); // Clear previous types
popup.classList.add(type);
let apiMessage;
const [popup, popupContent] = getElements()
if (popup === undefined) {
return
}
popup.classList.remove('success', 'error') // Clear previous types
popup.classList.add(type)
active = true
let apiMessage
try {
apiMessage = JSON.parse(message);
// Check if 'apiMessage' has 'status' and 'message' properties
if (apiMessage &&
Object.prototype.hasOwnProperty.call(apiMessage, "status") &&
Object.prototype.hasOwnProperty.call(apiMessage, "message")) {
popupContent.textContent = "Error " + apiMessage.status + ": " + apiMessage.message;
apiMessage = JSON.parse(message)
if (
apiMessage &&
Object.prototype.hasOwnProperty.call(apiMessage, 'status') &&
Object.prototype.hasOwnProperty.call(apiMessage, 'message')
) {
popupContent.textContent =
'Error ' + apiMessage.status + ': ' + apiMessage.message
}
} catch (error) {
popupContent.textContent = message;
popupContent.textContent = message
}
popup.style.right = '1em';
popup.style.right = '0em'
// don't hide for actions
if (type === "action") {
popup.classList.add("success");
return;
// Don't auto-hide for 'action' type popups
if (type === 'action') {
popup.classList.add('success')
return
}
// Start animation: bring the popup into view
// Automatically hide after 10 seconds
setTimeout(() => {
closePopUp();
}, 10000);
if (!autoclose || !active) {
return
}
// Set a new timeout for closing
closeTimeout = setTimeout(() => {
if (active) {
closePopUp()
}
}, 5000)
}
export function closePopUp() {
const [popup, popupContent] = getElements();
active = false
const [popup, popupContent] = getElements()
if (popupContent == undefined) {
return
}
if (popupContent.textContent == "Multiple Selection Enabled" && state.multiple) {
if (
popupContent.textContent == 'Multiple Selection Enabled' &&
state.multiple
) {
mutations.setMultiple(false)
}
popup.style.right = '-50em'; // Slide out
popupContent.textContent = "no content";
popup.style.right = '-50em' // Slide out
popupContent.textContent = 'no content'
}
function getElements() {
const popup = document.getElementById('popup-notification');
const popup = document.getElementById('popup-notification')
if (!popup) {
return [null, null];
return [null, null]
}
const popupContent = popup.querySelector('#popup-notification-content');
const popupContent = popup.querySelector('#popup-notification-content')
if (!popupContent) {
return [null, null];
return [null, null]
}
return [popup, popupContent];
return [popup, popupContent]
}
export function showSuccess(message) {
showPopup('success', message);
showPopup('success', message)
}
export function showError(message) {
showPopup('error', message);
showPopup('error', message)
console.error(message)
}
export function showMultipleSelection() {
showPopup("action", "Multiple Selection Enabled");
showPopup('action', 'Multiple Selection Enabled')
}

View File

@ -23,7 +23,7 @@ export const state = reactive({
perm: {},
rules: [], // Default to an empty array
permissions: {}, // Default to an empty object for permissions
darkMode: false, // Default to false, assuming this is a boolean
darkMode: true, // Default to false, assuming this is a boolean
profile: { // Example of additional user properties
username: '', // Default to an empty string
email: '', // Default to an empty string

View File

@ -12,7 +12,6 @@ const version = window.FileBrowser.Version;
const commitSHA = window.FileBrowser.CommitSHA;
const logoURL = `${staticURL}/img/logo.png`;
const noAuth = window.FileBrowser.NoAuth;
const authMethod = window.FileBrowser.AuthMethod;
const loginPage = window.FileBrowser.LoginPage;
const enableThumbs = window.FileBrowser.EnableThumbs;
const resizePreview = window.FileBrowser.ResizePreview;
@ -43,7 +42,6 @@ export {
version,
commitSHA,
noAuth,
authMethod,
loginPage,
enableThumbs,
resizePreview,

View File

@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest';
import { getHumanReadableFilesize } from './filesizes.js';
describe('testSort', () => {
it('validate human readable sizes', () => {
const tests = [
{input: 1, expected:"1.0 bytes"},
{input: 1150, expected:"1.1 KB"},
{input: 5105650, expected:"4.9 MB"},
{input: 156518899684, expected:"145.8 GB"},
{input: 1020993183744, expected:"950.9 GB"},
{input: 4891498498488, expected:"4.4 TB"},
{input: 11991498498488488, expected:"10.7 PB"},
]
for (let i in tests) {
expect(getHumanReadableFilesize(tests[i].input)).toEqual(tests[i].expected);
}
});
});

View File

@ -1,6 +1,7 @@
import { state } from "@/store";
import url from "@/utils/url.js";
import { filesApi } from "@/api";
import { notify } from "@/notify";
export function checkConflict(files, items) {
if (typeof items === "undefined" || items === null) {
@ -102,7 +103,11 @@ export function scanFiles(dt) {
}
export async function handleFiles(files, base, overwrite = false) {
let blockUpdates = false;
let c = 0
let count = files.length
for (const file of files) {
c += 1
const id = state.upload.id;
let path = base;
@ -122,15 +127,39 @@ export async function handleFiles(files, base, overwrite = false) {
file: file.file, // Ensure `file.file` is the Blob or File
overwrite,
};
await filesApi.post(item.path, item.file, item.overwrite, (event) => {
console.log(`Upload progress: ${Math.round((event.loaded / event.total) * 100)}%`);
})
let last = 0;
notify.showPopup("success",`(${c} of ${count}) Uploading ${file.name}`,false);
await filesApi.post(
item.path,
item.file,
item.overwrite,
(percentComplete) => {
if (blockUpdates) {
return;
}
blockUpdates = true;
// Set a timeout to reset blockUpdates after 500ms
notify.startLoading(last, percentComplete);
last = percentComplete;
setTimeout(() => {
blockUpdates = false;
}, 200);
}
)
.then(response => {
console.log("Upload successful:", response);
let spinner = document.querySelector('.notification-spinner')
if (spinner) {
spinner.classList.add('hidden')
}
console.log("Upload successful!",response);
notify.showSuccess("Upload successful!");
})
.catch(error => {
console.error("Upload error:", error);
let spinner = document.querySelector('.notification-spinner')
if (spinner) {
spinner.classList.add('hidden')
}
notify.showError("Error uploading file: "+error);
});
}
}

View File

@ -30,11 +30,7 @@
</main>
<prompts :class="{ 'dark-mode': isDarkMode }"></prompts>
</div>
<div class="card" id="popup-notification">
<i v-on:click="closePopUp" class="material-icons">close</i>
<div id="popup-notification-content">no info</div>
</div>
<Notifications />
<ContextMenu></ContextMenu>
</template>
<script>
@ -44,8 +40,8 @@ import listingBar from "./bars/ListingBar.vue";
import Prompts from "@/components/prompts/Prompts.vue";
import Sidebar from "@/components/sidebar/Sidebar.vue";
import ContextMenu from "@/components/ContextMenu.vue";
import Notifications from "@/components/Notifications.vue";
import { notify } from "@/notify";
import { enableExec } from "@/utils/constants";
import { state, getters, mutations } from "@/store";
@ -53,6 +49,7 @@ export default {
name: "layout",
components: {
ContextMenu,
Notifications,
defaultBar,
editorBar,
listingBar,
@ -75,7 +72,7 @@ export default {
},
computed: {
showPadding() {
return getters.showBreadCrumbs();
return getters.showBreadCrumbs() || getters.currentView() === "settings";
},
isLoggedIn() {
return getters.isLoggedIn();
@ -83,9 +80,6 @@ export default {
moveWithSidebar() {
return getters.isSidebarVisible() && getters.isStickySidebar();
},
closePopUp() {
return notify.closePopUp;
},
progress() {
return getters.progress(); // Access getter directly from the store
},

View File

@ -70,7 +70,7 @@ export default {
name: () => name || "FileBrowser Quantum",
logoURL: () => logoURL,
isDarkMode() {
return darkMode === true;
return darkMode;
},
loginName() {
return name

View File

@ -1,5 +1,5 @@
<template>
<div class="no-select" style="padding-bottom: 35vh">
<div class="no-select">
<div v-if="loading">
<h2 class="message delayed">
<div class="spinner">

View File

@ -14,7 +14,7 @@ update:
cd backend && go get -u ./... && go mod tidy && cd ../frontend && npm update
build:
docker build --build-arg="VERSION=testing" --build-arg="REVISION=n/a" -t gtstef/filebrowser .
docker build --build-arg="VERSION=testing" --build-arg="REVISION=n/a" -t gtstef/filebrowser -f _docker/Dockerfile .
build-backend:
cd backend && go build -o filebrowser --ldflags="-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/version.CommitSHA=testingCommit' -X 'github.com/gtsteffaniak/filebrowser/backend/version.Version=testing'"
@ -53,7 +53,7 @@ test-frontend:
test-playwright: run-frontend
cd backend && GOOS=linux go build -o filebrowser . && cd .. && \
docker build -t filebrowser-playwright-tests -f Dockerfile.playwright .
docker build -t filebrowser-playwright-tests -f _docker/Dockerfile.playwright .
docker run --rm --name filebrowser-playwright-tests filebrowser-playwright-tests
# Run on a windows machine!