Compare commits

...
12 Commits
66 changed files with 1086 additions and 435 deletions

No files matched your search

+25
View File
@@ -2,6 +2,31 @@
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).
## v1.1.4-beta
**New Features**:
- toggle to show searching results with thumbnails #1545
**Notes**:
- file list has loading spinner to help prevent double click issues and give better feedback
- improved cacheDir startup checks
- warning for slow read/write speeds below 50MB/s
- warning for low free space below 20GB
- warning if read/write latency is slow
- fatal for any errors reading or writing to the directory
- links to official docs for more info
- access rule changes:
- denied folders won't show up in parent directory listing view (#1684)
- tools will respect access rules
- shares will check access rules based on user that created the share
**BugFixes**:
- passwords with special characters not working properly (#1648)
- encoded filename navigation issue (#1711)
- Fix/Improve some behaviors in nextPrevious (#1707)
- Files recognized as folder if they have the same name as previously deleted folders #1697
- multi logger support issue #1701
## v1.1.3-beta
**Notes**:
+3 -1
View File
@@ -7,6 +7,7 @@ COPY [ "./_docker/src/settings/", "./" ]
WORKDIR /app/frontend
COPY [ "./_docker/src/settings/frontend/", "./" ]
COPY [ "./frontend/tests/", "./tests" ]
COPY [ "./frontend/tests/playwright-files", "/tests/playwright-files" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
@@ -17,4 +18,5 @@ COPY [ "./backend/http/embed/", "./http/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser set -u testuser1,testuser1
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
RUN ./filebrowser set rule -f /tests/playwright-files -p / -r user -v admin -allow -c config.yaml
RUN ./filebrowser set rule -f /tests/playwright-files -p /excluded -r user -v admin -c config.yaml
+5
View File
@@ -13,6 +13,11 @@ server:
config:
disableIndexing: true
defaultEnabled: true
- path: "/tests/playwright-files"
name: "access"
config:
defaultEnabled: true
denyByDefault: true
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
+7 -13
View File
@@ -74,9 +74,7 @@ func FileInfoFaster(opts utils.FileOptions, access *access.Storage) (*iteminfo.E
response.RealPath = realPath
response.Source = opts.Source
if access != nil && !access.Permitted(index.Path, opts.Path, opts.Username) {
// User doesn't have access to the current folder, but check if they have access to any subitems
// This allows specific allow rules on subfolders/files to work even when parent is denied
if access != nil {
err := access.CheckChildItemAccess(response, index, opts.Username)
if err != nil {
return response, err
@@ -349,6 +347,10 @@ func DeleteFiles(source, absPath string, absDirPath string, isDir bool) error {
return err
}
// Clear cache entries
indexing.RealPathCache.Delete(absPath)
indexing.IsDirCache.Delete(absPath + ":isdir")
// Remove metadata from index
if isDir {
// Recursively remove directory and all subdirectories from index
@@ -402,6 +404,8 @@ func RefreshIndex(source string, path string, isDir bool, recursive bool) error
if !Exists(realPath) {
// Directory no longer exists, remove it from the index
// This clears both Directories and DirectoriesLedger maps
indexing.RealPathCache.Delete(realPath)
indexing.IsDirCache.Delete(realPath + ":isdir")
idx.DeleteMetadata(path, true, false)
return nil
}
@@ -474,16 +478,11 @@ func MoveResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
// Handle SOURCE cleanup (treat as deletion)
if !srcIdx.Config.DisableIndexing {
// Remove metadata from source index
if isSrcDir {
// Recursively remove directory and all subdirectories from source index
srcIdx.DeleteMetadata(srcIndexPath, true, true)
} else {
// Remove file from source parent's file list
srcIdx.DeleteMetadata(srcIndexPath, false, false)
}
// Refresh the source parent directory to recalculate sizes and update counts
go RefreshIndex(sourceIndex, srcParentPath, true, false) //nolint:errcheck
}
@@ -491,16 +490,11 @@ func MoveResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
if !dstIdx.Config.DisableIndexing {
if isSrcDir {
go func() {
// When moving a folder, refresh the folder itself recursively FIRST
// Must complete before parent refresh to avoid race condition
RefreshIndex(destIndex, realdst, true, true) //nolint:errcheck
// THEN refresh the parent directory so it sees the newly indexed child
parentDir := filepath.Dir(realdst)
RefreshIndex(destIndex, parentDir, true, false) //nolint:errcheck
}()
} else {
// If moving a file, just refresh the parent directory
parentDir := filepath.Dir(realdst)
go RefreshIndex(destIndex, parentDir, true, false) //nolint:errcheck
}
@@ -0,0 +1,31 @@
//go:build !windows
package fileutils
import (
"syscall"
)
// GetPartitionSize returns the filesystem size for Unix systems
func GetPartitionSize(path string) (uint64, error) {
var stat syscall.Statfs_t
err := syscall.Statfs(path, &stat)
if err != nil {
return 0, err
}
// Total size in bytes: Blocks * Block size
total := uint64(stat.Blocks) * uint64(stat.Bsize)
return total, nil
}
// GetFreeSpace returns the available free space for Unix systems
func GetFreeSpace(path string) (uint64, error) {
var stat syscall.Statfs_t
err := syscall.Statfs(path, &stat)
if err != nil {
return 0, err
}
// Available free space in bytes: Available blocks * Block size
free := uint64(stat.Bavail) * uint64(stat.Bsize)
return free, nil
}
@@ -0,0 +1,35 @@
//go:build windows
package fileutils
import (
"golang.org/x/sys/windows"
)
// GetPartitionSize returns the filesystem size for Windows systems
func GetPartitionSize(path string) (uint64, error) {
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
return 0, err
}
var freeBytes, totalBytes, totalFreeBytes uint64
err = windows.GetDiskFreeSpaceEx(pathPtr, &freeBytes, &totalBytes, &totalFreeBytes)
if err != nil {
return 0, err
}
return totalBytes, nil
}
// GetFreeSpace returns the available free space for Windows systems
func GetFreeSpace(path string) (uint64, error) {
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
return 0, err
}
var freeBytes, totalBytes, totalFreeBytes uint64
err = windows.GetDiskFreeSpaceEx(pathPtr, &freeBytes, &totalBytes, &totalFreeBytes)
if err != nil {
return 0, err
}
return freeBytes, nil
}
+5
View File
@@ -22,6 +22,11 @@ func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.U
username := r.URL.Query().Get("username")
recaptcha := r.URL.Query().Get("recaptcha")
password := r.Header.Get("X-Password")
// URL-decode password to support special characters in headers
password, err := url.QueryUnescape(password)
if err != nil {
return nil, fmt.Errorf("invalid password encoding: %v", err)
}
totpCode := r.Header.Get("X-Secret")
// If ReCaptcha is enabled, check the code.
+156 -59
View File
@@ -60,10 +60,17 @@ func runCLI() bool {
setCmd := flag.NewFlagSet("set", flag.ExitOnError)
var user, dbConfig string
var asAdmin bool
var fsPath, indexPath, ruleCategory, value string
var allow bool
setCmd.StringVar(&user, "u", "", "Comma-separated username and password: \"set -u <username>,<password>\"")
setCmd.BoolVar(&asAdmin, "a", false, "Create a user as admin user, used in combination with -u")
setCmd.StringVar(&dbConfig, "c", "config.yaml", "Path to the config file, default: config.yaml")
setCmd.StringVar(&fsPath, "f", "", "Real filesystem path (e.g. /mnt/storage) for rule command")
setCmd.StringVar(&indexPath, "p", "", "Index path (e.g. /secret) for rule command")
setCmd.StringVar(&ruleCategory, "r", "", "Rule category: 'user', 'group', or 'all' (for deny only)")
setCmd.StringVar(&value, "v", "", "Value: username or groupname (not required if ruleCategory is 'all')")
setCmd.BoolVar(&allow, "allow", false, "Allow access (default: false, which means deny)")
// Parse subcommand flags only if a subcommand is specified
if len(os.Args) > 1 {
@@ -72,72 +79,38 @@ func runCLI() bool {
createConfig(configPath)
return false
case "set":
err := setCmd.Parse(os.Args[2:])
if err != nil {
setCmd.PrintDefaults()
if len(os.Args) < 3 {
fmt.Printf("error: missing subcommand for 'set'. Use 'set user' or 'set rule'\n")
os.Exit(1)
}
userInfo := strings.Split(user, ",")
if len(userInfo) < 2 {
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) // ignore bool check
user, err := store.Users.Get(username)
if err != nil {
newUser := users.User{
Username: username,
LoginMethod: users.LoginMethodPassword,
NonAdminEditable: users.NonAdminEditable{
Password: password,
},
}
for _, source := range settings.Config.Server.SourceMap {
if source.Config.DefaultEnabled {
newUser.Scopes = append(newUser.Scopes, users.SourceScope{
Name: source.Name,
Scope: source.Config.DefaultUserScope,
})
}
}
subcommand := os.Args[2]
// Create the user logic
if asAdmin {
logger.Infof("Creating user as admin: %s\n", username)
} else {
logger.Infof("Creating non-admin user: %s\n", username)
}
newUser.Permissions = settings.ConvertPermissionsToUsers(settings.Config.UserDefaults.Permissions)
newUser.Permissions.Admin = asAdmin
err = storage.CreateUser(newUser, newUser.Permissions)
// New pattern: subcommand-based (e.g., "set user" or "set rule")
// Parse flags starting from os.Args[3:] to skip the subcommand
err := setCmd.Parse(os.Args[3:])
if err != nil {
setCmd.PrintDefaults()
os.Exit(1)
}
switch subcommand {
case "-u":
err := setUser(dbConfig, asAdmin)
if err != nil {
logger.Errorf("could not create user: %v", err)
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
return false
case "rule":
err := setRule(dbConfig, fsPath, indexPath, ruleCategory, value, allow)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
return false
default:
fmt.Printf("error: unknown subcommand '%s' for 'set'. Use 'set user' or 'set rule'\n", subcommand)
os.Exit(1)
}
if user.LoginMethod != users.LoginMethodPassword {
logger.Fatalf("user %s is not allowed to login with password authentication, cannot set password", username)
}
user.Password = password
user.TOTPSecret = "" // reset TOTP secret if it exists
user.TOTPNonce = "" // reset TOTP nonce if it exists
user.LoginMethod = users.LoginMethodPassword
if asAdmin {
user.Permissions.Admin = true
}
// Ensure version is set for existing users being updated
if user.Version == 0 {
user.Version = 1
}
err = store.Users.Save(user, true, false)
if err != nil {
logger.Errorf("could not update user: %v", err)
}
fmt.Printf("successfully updated user: %s\n", username)
return false
case "version":
fmt.Printf(`FileBrowser Quantum - A modern web-based file manager
@@ -151,6 +124,130 @@ func runCLI() bool {
return true
}
func setRule(dbConfig, fsPath, indexPath, ruleCategory, value string, allow bool) error {
// Validate required parameters
if fsPath == "" {
return fmt.Errorf("real filesystem path is required: use -f <fsPath>")
}
if indexPath == "" {
return fmt.Errorf("index path is required: use -p <indexPath>")
}
if ruleCategory == "" {
return fmt.Errorf("ruleCategory is required: use -r <user|group|all>")
}
if ruleCategory != "all" && value == "" {
return fmt.Errorf("value is required when ruleCategory is 'user' or 'group': use -v <username|groupname>")
}
// Initialize store and settings
_ = getStore(dbConfig) // ignore bool check
// Apply the rule based on allow flag and ruleCategory
var err error
if allow {
switch ruleCategory {
case "user":
err = store.Access.AllowUser(fsPath, indexPath, value)
case "group":
err = store.Access.AllowGroup(fsPath, indexPath, value)
default:
return fmt.Errorf("invalid ruleCategory for allow: must be 'user' or 'group'")
}
} else {
switch ruleCategory {
case "user":
err = store.Access.DenyUser(fsPath, indexPath, value)
case "group":
err = store.Access.DenyGroup(fsPath, indexPath, value)
case "all":
err = store.Access.DenyAll(fsPath, indexPath)
default:
return fmt.Errorf("invalid ruleCategory: must be 'user', 'group', or 'all'")
}
}
if err != nil {
return fmt.Errorf("failed to add or update rule: %w", err)
}
action := "deny"
if allow {
action = "allow"
}
if ruleCategory == "all" {
fmt.Printf("successfully added %s rule for all users on index path '%s' in filesystem path '%s'\n", action, indexPath, fsPath)
} else {
fmt.Printf("successfully added %s rule for %s '%s' on index path '%s' in filesystem path '%s'\n", action, ruleCategory, value, indexPath, fsPath)
}
return nil
}
func setUser(dbConfig string, asAdmin bool) error {
if len(os.Args) < 4 {
return fmt.Errorf("missing username and password for 'set user'. Use 'set -u username,password'")
}
userInfo := strings.Split(os.Args[3], ",")
if len(userInfo) < 2 {
return fmt.Errorf("not enough info to create user: \"set -u username,password\", only provided %v", userInfo)
}
username := userInfo[0]
password := userInfo[1]
_ = getStore(dbConfig) // ignore bool check
user, err := store.Users.Get(username)
if err != nil {
newUser := users.User{
Username: username,
LoginMethod: users.LoginMethodPassword,
NonAdminEditable: users.NonAdminEditable{
Password: password,
},
}
for _, source := range settings.Config.Server.SourceMap {
if source.Config.DefaultEnabled {
newUser.Scopes = append(newUser.Scopes, users.SourceScope{
Name: source.Name,
Scope: source.Config.DefaultUserScope,
})
}
}
// Create the user logic
if asAdmin {
logger.Infof("Creating user as admin: %s\n", username)
} else {
logger.Infof("Creating non-admin user: %s\n", username)
}
newUser.Permissions = settings.ConvertPermissionsToUsers(settings.Config.UserDefaults.Permissions)
newUser.Permissions.Admin = asAdmin
err = storage.CreateUser(newUser, newUser.Permissions)
if err != nil {
return fmt.Errorf("could not create user: %v", err)
}
fmt.Printf("successfully created user")
return nil
}
if user.LoginMethod != users.LoginMethodPassword {
return fmt.Errorf("user %s is not allowed to login with password authentication, cannot set password", username)
}
user.Password = password
user.TOTPSecret = "" // reset TOTP secret if it exists
user.TOTPNonce = "" // reset TOTP nonce if it exists
user.LoginMethod = users.LoginMethodPassword
if asAdmin {
user.Permissions.Admin = true
}
// Ensure version is set for existing users being updated
if user.Version == 0 {
user.Version = 1
}
err = store.Users.Save(user, true, false)
if err != nil {
return fmt.Errorf("could not update user: %v", err)
}
fmt.Printf("successfully updated user: %s\n", username)
return nil
}
// UserDefaults defines default settings for new users.
type UserDefaults struct {
Permissions users.Permissions `yaml:"permissions"`
+124 -8
View File
@@ -3,6 +3,7 @@ package settings
import (
"bytes"
"fmt"
"io"
"log"
"os"
"path/filepath"
@@ -87,17 +88,132 @@ func setupFs() {
}
fileutils.SetFsPermissions(os.FileMode(filePermOctal), os.FileMode(dirPermOctal))
// try writing to cache dir
absCacheDir, err := filepath.Abs(Config.Server.CacheDir)
// Perform mandatory cache directory speed test
testCacheDirSpeed()
logger.Infof("cache directory setup successfully: %v", Config.Server.CacheDir)
}
// testCacheDirSpeed performs a mandatory speed test on the cache directory by writing,
// reading, and deleting a 10MB test file. Reports write and read performance in MB/s.
func testCacheDirSpeed() {
msgPrfx := "cacheDir"
failSuffix := "Please review documentation to ensure a valid cache directory is configured https://filebrowserquantum.com/en/docs/configuration/server/#cachedir"
const testFileSize = 10 * 1024 * 1024 // 10MB
// Ensure cache directory exists
err := os.MkdirAll(Config.Server.CacheDir, fileutils.PermDir)
if err != nil {
logger.Errorf("error getting absolute path for 'server.cacheDir: %v': %v", Config.Server.CacheDir, err)
}
cacheDir := filepath.Join(absCacheDir, "init_test")
err = os.MkdirAll(cacheDir, fileutils.PermDir)
if err != nil {
logger.Errorf("Unable to write to 'server.cacheDir: %v', please ensure the cache directory is writable: %v", absCacheDir, err)
logger.Fatalf("%s failed to create cache directory: %v\n%s", msgPrfx, err, failSuffix)
}
testFileName := filepath.Join(Config.Server.CacheDir, "speed_test.tmp")
// Create test data (10MB of zeros)
testData := make([]byte, testFileSize)
// Test write performance
writeStart := time.Now()
file, err := os.Create(testFileName)
if err != nil {
logger.Fatalf("%s failed to create test file: %v\n%s", msgPrfx, err, failSuffix)
}
written, err := file.Write(testData)
if err != nil {
file.Close()
os.Remove(testFileName)
logger.Fatalf("%s failed to write test file: %v\n%s", msgPrfx, err, failSuffix)
}
err = file.Sync() // Ensure data is written to disk
if err != nil {
file.Close()
os.Remove(testFileName)
logger.Fatalf("%s failed to sync test file to disk: %v\n%s", msgPrfx, err, failSuffix)
}
err = file.Close()
if err != nil {
os.Remove(testFileName)
logger.Fatalf("%s failed to close test file: %v\n%s", msgPrfx, err, failSuffix)
}
writeDuration := time.Since(writeStart)
writeSpeedMBs := float64(written) / (1024 * 1024) / writeDuration.Seconds()
// Test read performance
readStart := time.Now()
file, err = os.Open(testFileName)
if err != nil {
os.Remove(testFileName)
logger.Fatalf("%s failed to open test file for reading: %v\n%s", msgPrfx, err, failSuffix)
}
readData := make([]byte, testFileSize)
readBytes, err := io.ReadFull(file, readData)
if err != nil {
file.Close()
os.Remove(testFileName)
logger.Fatalf("%s failed to read test file: %v\n%s", msgPrfx, err, failSuffix)
}
err = file.Close()
if err != nil {
os.Remove(testFileName)
logger.Fatalf("%s failed to close test file after reading: %v\n%s", msgPrfx, err, failSuffix)
}
readDuration := time.Since(readStart)
readSpeedMBs := float64(readBytes) / (1024 * 1024) / readDuration.Seconds()
// Verify data integrity
if readBytes != written {
os.Remove(testFileName)
logger.Fatalf("%s data integrity check failed: wrote %d bytes but read %d bytes\n%s", msgPrfx, written, readBytes, failSuffix)
}
// Clean up test file
err = os.Remove(testFileName)
if err != nil {
logger.Fatalf("%s failed to remove test file: %v\n%s", msgPrfx, err, failSuffix)
}
// Log performance results
writeDurationMs := writeDuration.Seconds() * 1000
readDurationMs := readDuration.Seconds() * 1000
const highLatencyThresholdMs = 1000.0 // 1 second for 10MB file indicates high latency
if writeSpeedMBs < 50 {
slowSuffix := " Ensure you configure a faster cache directory via the `server.cacheDir` configuration option."
logger.Warningf("%s slow write speed detected: %.2f MB/s (%.2f ms)\n%s\n%s", msgPrfx, writeSpeedMBs, writeDurationMs, failSuffix, slowSuffix)
} else if writeDurationMs > highLatencyThresholdMs {
slowSuffix := " Ensure you configure a faster cache directory via the `server.cacheDir` configuration option."
logger.Warningf("%s high write latency detected: %.2f ms for 10MB file (speed: %.2f MB/s). This may indicate network storage or I/O issues.\n%s\n%s", msgPrfx, writeDurationMs, writeSpeedMBs, failSuffix, slowSuffix)
} else {
logger.Debugf("%s write speed: %.2f MB/s (%.2f ms)", msgPrfx, writeSpeedMBs, writeDurationMs)
}
if readSpeedMBs < 50 {
slowSuffix := " Ensure you configure a faster cache directory via the `server.cacheDir` configuration option."
logger.Warningf("%s slow read speed detected: %.2f MB/s (%.2f ms)\n%s\n%s", msgPrfx, readSpeedMBs, readDurationMs, failSuffix, slowSuffix)
} else if readDurationMs > highLatencyThresholdMs {
slowSuffix := " Ensure you configure a faster cache directory via the `server.cacheDir` configuration option."
logger.Warningf("%s high read latency detected: %.2f ms for 10MB file (speed: %.2f MB/s). This may indicate network storage or I/O issues.\n%s\n%s", msgPrfx, readDurationMs, readSpeedMBs, failSuffix, slowSuffix)
} else {
logger.Debugf("%s read speed : %.2f MB/s (%.2f ms)", msgPrfx, readSpeedMBs, readDurationMs)
}
// check cache directory disk free space
freeSpace, err := fileutils.GetFreeSpace(Config.Server.CacheDir)
if err != nil {
logger.Fatalf("%s failed to get free space for cache directory: %v\n%s", msgPrfx, err, failSuffix)
}
freeSpaceGB := float64(freeSpace) / (1024 * 1024 * 1024)
logger.Debugf("%s cache directory has %.2f GB of free space", msgPrfx, freeSpaceGB)
const minRecommendedGB = 20.0
if freeSpaceGB < minRecommendedGB {
logger.Warningf("%s only has %.2f GB of free space, this is less than the %.0f GB minimum recommended free space.\n%s", msgPrfx, freeSpaceGB, minRecommendedGB, failSuffix)
}
}
func setupFrontend(generate bool) {
+4 -8
View File
@@ -292,11 +292,11 @@ func (s *Storage) DenyAll(sourcePath, indexPath string) error {
// Permitted checks if a username is permitted for a given sourcePath and indexPath, recursively checking parent directories.
func (s *Storage) Permitted(sourcePath, indexPath, username string) bool {
// SECURITY: All paths MUST start with "/" - reject any path that doesn't
// This prevents path normalization bypass attacks
// Ensure leading slash
if !strings.HasPrefix(indexPath, "/") {
return false
indexPath = "/" + indexPath
}
indexPath = utils.AddTrailingSlashIfNotExists(indexPath)
// Get current version for the sourcePath
versionKey := "version:" + sourcePath
@@ -1035,11 +1035,7 @@ func (s *Storage) GetAllRulesByGroups(sourcePath string) map[string]map[string]F
// This is used to determine if a user should see a folder's contents even when
// they don't have direct access to the parent folder.
func (s *Storage) HasAnyVisibleItems(sourcePath, parentPath string, itemNames []string, username string) bool {
// Ensure parentPath has trailing slash for proper path construction
if !strings.HasSuffix(parentPath, "/") {
parentPath = parentPath + "/"
}
parentPath = utils.AddTrailingSlashIfNotExists(parentPath)
// Check if user has access to any of the items
for _, itemName := range itemNames {
indexPath := parentPath + itemName
+1 -1
View File
@@ -22,7 +22,7 @@ func (s *Storage) CheckChildItemAccess(response *iteminfo.ExtendedFileInfo, inde
parentPath := index.MakeIndexPath(response.Path)
// Check if user has access to any items
if !s.HasAnyVisibleItems(index.Path, parentPath, allItemNames, username) {
if !s.HasAnyVisibleItems(index.Path, parentPath, allItemNames, username) && len(allItemNames) > 0 {
return errors.ErrAccessDenied
}
+28 -27
View File
@@ -4,27 +4,27 @@ go 1.25.0
require (
github.com/asdine/storm/v3 v3.2.1
github.com/coreos/go-oidc/v3 v3.16.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/gen2brain/go-fitz v1.24.15
github.com/go-playground/validator/v10 v10.28.0
github.com/goccy/go-yaml v1.18.0
github.com/go-playground/validator/v10 v10.29.0
github.com/goccy/go-yaml v1.19.0
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.7.0
github.com/gtsteffaniak/go-cache v1.1.0
github.com/gtsteffaniak/go-logger v0.2.3
github.com/gtsteffaniak/go-logger v0.2.4
github.com/kovidgoyal/imaging v1.7.2
github.com/pquerna/otp v1.5.0
github.com/spf13/afero v1.15.0
github.com/stretchr/testify v1.11.1
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.6
golang.org/x/crypto v0.45.0
golang.org/x/image v0.33.0
golang.org/x/mod v0.30.0
golang.org/x/oauth2 v0.33.0
golang.org/x/sys v0.38.0
golang.org/x/crypto v0.46.0
golang.org/x/image v0.34.0
golang.org/x/mod v0.31.0
golang.org/x/oauth2 v0.34.0
golang.org/x/sys v0.39.0
golang.org/x/time v0.14.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.40.1
@@ -105,16 +105,16 @@ require (
github.com/go-critic/go-critic v0.14.2 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-openapi/jsonpointer v0.22.3 // indirect
github.com/go-openapi/jsonreference v0.21.3 // indirect
github.com/go-openapi/spec v0.22.1 // indirect
github.com/go-openapi/swag/conv v0.25.3 // indirect
github.com/go-openapi/swag/jsonname v0.25.3 // indirect
github.com/go-openapi/swag/jsonutils v0.25.3 // indirect
github.com/go-openapi/swag/loading v0.25.3 // indirect
github.com/go-openapi/swag/stringutils v0.25.3 // indirect
github.com/go-openapi/swag/typeutils v0.25.3 // indirect
github.com/go-openapi/swag/yamlutils v0.25.3 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/jsonreference v0.21.4 // indirect
github.com/go-openapi/spec v0.22.2 // indirect
github.com/go-openapi/swag/conv v0.25.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
github.com/go-openapi/swag/loading v0.25.4 // indirect
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
@@ -130,7 +130,7 @@ require (
github.com/godoc-lint/godoc-lint v0.10.1 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gohugoio/hugo v0.149.1 // indirect
github.com/golang/geo v0.0.0-20251117194806-05dcfdd28b33 // indirect
github.com/golang/geo v0.0.0-20251209161508-25c597310d4b // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golangci/asciicheck v0.5.0 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
@@ -164,6 +164,7 @@ require (
github.com/kisielk/errcheck v1.9.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
github.com/kovidgoyal/go-parallel v1.1.1 // indirect
github.com/kovidgoyal/go-shm v1.0.0 // indirect
github.com/kulti/thelper v0.7.1 // indirect
github.com/kunwardeep/paralleltest v1.0.15 // indirect
github.com/lasiar/canonicalheader v1.1.2 // indirect
@@ -193,7 +194,7 @@ require (
github.com/moricho/tparallel v0.3.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/nakabonne/nestif v0.3.1 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nishanths/exhaustive v0.12.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.21.2 // indirect
@@ -263,17 +264,17 @@ require (
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/tools v0.39.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.40.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.6.1 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/libc v1.67.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
+61
View File
@@ -197,6 +197,8 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -294,27 +296,48 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k=
github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA=
github.com/go-openapi/spec v0.22.2 h1:KEU4Fb+Lp1qg0V4MxrSCPv403ZjBl8Lx1a83gIPU8Qc=
github.com/go-openapi/spec v0.22.2/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag/conv v0.25.3 h1:PcB18wwfba7MN5BVlBIV+VxvUUeC2kEuCEyJ2/t2X7E=
github.com/go-openapi/swag/conv v0.25.3/go.mod h1:n4Ibfwhn8NJnPXNRhBO5Cqb9ez7alBR40JS4rbASUPU=
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
github.com/go-openapi/swag/jsonname v0.25.3 h1:U20VKDS74HiPaLV7UZkztpyVOw3JNVsit+w+gTXRj0A=
github.com/go-openapi/swag/jsonname v0.25.3/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/swag/jsonutils v0.25.3 h1:kV7wer79KXUM4Ea4tBdAVTU842Rg6tWstX3QbM4fGdw=
github.com/go-openapi/swag/jsonutils v0.25.3/go.mod h1:ILcKqe4HC1VEZmJx51cVuZQ6MF8QvdfXsQfiaCs0z9o=
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3 h1:/i3E9hBujtXfHy91rjtwJ7Fgv5TuDHgnSrYjhFxwxOw=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3/go.mod h1:8kYfCR2rHyOj25HVvxL5Nm8wkfzggddgjZm6RgjT8Ao=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
github.com/go-openapi/swag/loading v0.25.3 h1:Nn65Zlzf4854MY6Ft0JdNrtnHh2bdcS/tXckpSnOb2Y=
github.com/go-openapi/swag/loading v0.25.3/go.mod h1:xajJ5P4Ang+cwM5gKFrHBgkEDWfLcsAKepIuzTmOb/c=
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
github.com/go-openapi/swag/stringutils v0.25.3 h1:nAmWq1fUTWl/XiaEPwALjp/8BPZJun70iDHRNq/sH6w=
github.com/go-openapi/swag/stringutils v0.25.3/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
github.com/go-openapi/swag/typeutils v0.25.3 h1:2w4mEEo7DQt3V4veWMZw0yTPQibiL3ri2fdDV4t2TQc=
github.com/go-openapi/swag/typeutils v0.25.3/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
github.com/go-openapi/swag/yamlutils v0.25.3 h1:LKTJjCn/W1ZfMec0XDL4Vxh8kyAnv1orH5F2OREDUrg=
github.com/go-openapi/swag/yamlutils v0.25.3/go.mod h1:Y7QN6Wc5DOBXK14/xeo1cQlq0EA0wvLoSv13gDQoCao=
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
@@ -327,6 +350,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-playground/validator/v10 v10.29.0 h1:lQlF5VNJWNlRbRZNeOIkWElR+1LL/OuHcc0Kp14w1xk=
github.com/go-playground/validator/v10 v10.29.0/go.mod h1:D6QxqeMlgIPuT02L66f2ccrZ7AGgHkzKmmTMZhk/Kc4=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
@@ -361,6 +386,8 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godoc-lint/godoc-lint v0.10.1 h1:ZPUVzlDtJfA+P688JfPJPkI/SuzcBr/753yGIk5bOPA=
github.com/godoc-lint/godoc-lint v0.10.1/go.mod h1:KleLcHu/CGSvkjUH2RvZyoK1MBC7pDQg4NxMYLcBBsw=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
@@ -389,6 +416,8 @@ github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgR
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20251117194806-05dcfdd28b33 h1:1iCpF+lKihFpyN/Ujqvy9cWVlB516bGZItvb3l9HOSo=
github.com/golang/geo v0.0.0-20251117194806-05dcfdd28b33/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/geo v0.0.0-20251209161508-25c597310d4b h1:6y9D6yfaR5FyqoNoV2S+XJyhzeMUlkdIeUX1Ssj0FJQ=
github.com/golang/geo v0.0.0-20251209161508-25c597310d4b/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -494,6 +523,8 @@ github.com/gtsteffaniak/go-cache v1.1.0 h1:ni/2cJpCxpGmaMZd92iTDIGOy+yUcMcxQuW7b
github.com/gtsteffaniak/go-cache v1.1.0/go.mod h1:rHUZvf4huhMX/bnNK4/oQ3sNAE2fYC2jFpAWZiBPWac=
github.com/gtsteffaniak/go-logger v0.2.3 h1:8nSC5xSMnMYQI61tsBS1tARpnkv/rOh5lzEXtAG72Nw=
github.com/gtsteffaniak/go-logger v0.2.3/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
github.com/gtsteffaniak/go-logger v0.2.4 h1:CGioQehoo84xufxtS57gqEAYcXdLc5hzA71CPvGQ/3s=
github.com/gtsteffaniak/go-logger v0.2.4/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
github.com/hairyhenderson/go-codeowners v0.7.0 h1:s0W4wF8bdsBEjTWzwzSlsatSthWtTAF2xLgo4a4RwAo=
github.com/hairyhenderson/go-codeowners v0.7.0/go.mod h1:wUlNgQ3QjqC4z8DnM5nnCYVq/icpqXJyJOukKx5U8/Q=
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo=
@@ -550,8 +581,12 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811uiUXyI7ok=
github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw=
github.com/kovidgoyal/go-shm v1.0.0 h1:HJEel9D1F9YhULvClEHJLawoRSj/1u/EDV7MJbBPgQo=
github.com/kovidgoyal/go-shm v1.0.0/go.mod h1:Yzb80Xf9L3kaoB2RGok9hHwMIt7Oif61kT6t3+VnZds=
github.com/kovidgoyal/imaging v1.7.2 h1:mmT6k6Az3mC6dbqdZ6Q9KQCdZFWTAQ+q97NyGZgJ/2c=
github.com/kovidgoyal/imaging v1.7.2/go.mod h1:GdkCORjfZMMGFY0Pb7TDmRhj7PDhxF/QShKukSCj0VU=
github.com/kovidgoyal/imaging v1.8.17 h1:IDc7lbN2Qrn8s50y7Zt355HhOc+jUpvsScYAaGCW8vs=
github.com/kovidgoyal/imaging v1.8.17/go.mod h1:uD4XKN42lLV9du0TsPkwi53yw23vz/qDmfpiDWCSUCE=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -641,6 +676,8 @@ github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81
github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0=
github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48=
github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg=
@@ -901,6 +938,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -913,6 +952,8 @@ golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EH
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 h1:HDjDiATsGqvuqvkDvgJjD1IgPrVekcSXVVE21JwvzGE=
@@ -921,6 +962,8 @@ golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMx
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -948,6 +991,8 @@ golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -992,6 +1037,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1000,6 +1047,8 @@ golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4Iltr
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -1017,6 +1066,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -1072,6 +1123,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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=
@@ -1090,6 +1143,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -1147,6 +1202,8 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
@@ -1266,8 +1323,10 @@ honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=
honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
@@ -1276,6 +1335,8 @@ modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/libc v1.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk=
modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+5 -1
View File
@@ -111,10 +111,14 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
// loginHandler handles user authentication via password.
// @Summary User login
// @Description Authenticate a user with a username and password.
// @Description Authenticate a user with a username and password. The password must be URL-encoded and sent in the X-Password header to support special characters (e.g., ^, %, £, €, etc.).
// @Tags Auth
// @Accept json
// @Produce json
// @Param username query string true "Username"
// @Param recaptcha query string false "ReCaptcha response token (if enabled)"
// @Param X-Password header string true "URL-encoded password"
// @Param X-Secret header string false "TOTP code (if 2FA is enabled)"
// @Success 200 {string} string "JWT token for authentication"
// @Failure 403 {object} map[string]string "Forbidden - authentication failed"
// @Failure 500 {object} map[string]string "Internal server error"
+19 -3
View File
@@ -14,6 +14,7 @@ import (
"unicode"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/sql"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/go-cache/cache"
@@ -78,6 +79,16 @@ func duplicatesHandler(w http.ResponseWriter, r *http.Request, d *requestContext
if index == nil {
return http.StatusBadRequest, fmt.Errorf("index not found for source %s", opts.source)
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, index.Name)
if err != nil {
return http.StatusForbidden, err
}
userscope = strings.TrimRight(userscope, "/")
scopePath := utils.JoinPathAsUnix(userscope, opts.searchScope)
fullPath := index.MakeIndexPath(scopePath)
if !store.Access.Permitted(index.Path, fullPath, d.user.Username) {
return http.StatusForbidden, fmt.Errorf("user is not allowed to access this location")
}
// Generate cache key from all input parameters that affect results
// Checksums are always enabled, so cache key doesn't need to include that flag
@@ -101,7 +112,7 @@ func duplicatesHandler(w http.ResponseWriter, r *http.Request, d *requestContext
// Find duplicates using index-native approach to minimize memory allocation
// This avoids creating SearchResult objects until we know the final limited set
duplicateGroups := findDuplicatesInIndex(index, opts)
duplicateGroups := findDuplicatesInIndex(index, opts, d.user.Username)
// Cache the results before returning
duplicateResultsCache.Set(cacheKey, duplicateGroups)
@@ -115,7 +126,7 @@ func duplicatesHandler(w http.ResponseWriter, r *http.Request, d *requestContext
// 2. Querying SQLite for size groups with 2+ files
// 3. Processing each size group sequentially (only one in memory at a time)
// 4. Only creating SearchResult objects for final verified duplicates
func findDuplicatesInIndex(index *indexing.Index, opts *duplicatesOptions) []duplicateGroup {
func findDuplicatesInIndex(index *indexing.Index, opts *duplicatesOptions, username string) []duplicateGroup {
// Create temporary SQLite database for streaming in cache directory
tempDB, err := sql.NewTempDB("duplicates", &sql.TempDBConfig{
@@ -256,8 +267,12 @@ func findDuplicatesInIndex(index *indexing.Index, opts *duplicatesOptions) []dup
continue
}
// Construct full path
// Construct full path (index-relative)
fullPath := filepath.Join(loc.DirPath, file.Name)
// Check access control using index-relative path
if store.Access != nil && !store.Access.Permitted(index.Path, fullPath, username) {
continue // Silently skip this file
}
// Remove the user scope from path
adjustedPath := strings.TrimPrefix(fullPath, opts.combinedPath)
@@ -305,6 +320,7 @@ func findDuplicatesInIndex(index *indexing.Index, opts *duplicatesOptions) []dup
func prepDuplicatesOptions(r *http.Request, d *requestContext) (*duplicatesOptions, error) {
source := r.URL.Query().Get("source")
scope := r.URL.Query().Get("scope")
minSizeMbStr := r.URL.Query().Get("minSizeMb")
// Checksums are always enabled by default for final verification
// First pass uses filename matching for speed, then checksums verify final groups
+6 -1
View File
@@ -100,13 +100,18 @@ func withHashFileHelper(fn handleFunc) handleFunc {
if link.DisableFileViewer || reachedDownloadsLimit {
getContent = false
}
shareCreatedByUser, err := store.Users.Get(link.UserID)
if err != nil {
return http.StatusNotFound, fmt.Errorf("user for share no longer exists")
}
file, err := FileInfoFasterFunc(utils.FileOptions{
Path: utils.JoinPathAsUnix(link.Path, path),
Source: link.Source,
Username: shareCreatedByUser.Username,
Expand: true,
Content: getContent,
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles && link.ExtractEmbeddedSubtitles,
}, nil)
}, store.Access)
if err != nil {
logger.Errorf("error fetching file info for share. hash=%v path=%v error=%v", hash, path, err)
return errToStatus(err), fmt.Errorf("error fetching share from server")
+12 -1
View File
@@ -140,6 +140,16 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
const passwordBcrypt = "$2y$10$TFAmdCbyd/mEZDe5fUeZJu.MaJQXRTwdqb/IQV.eTn6dWrF58gCSe" // bcrypt hashed password
// Create and save a dummy user with ID 1 (all shares use UserID: 1)
dummyUser := &users.User{
ID: 1,
Username: "testuser",
Permissions: users.Permissions{Admin: false},
}
if err := store.Users.Save(dummyUser, true, true); err != nil {
t.Fatal("failed to save dummy user:", err)
}
testCases := []struct {
name string
share *share.Link
@@ -151,7 +161,8 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
{
name: "Public share, no auth required",
share: &share.Link{
Hash: "public_hash",
Hash: "public_hash",
UserID: 1,
CommonShare: share.CommonShare{
Source: "/srv",
},
+1 -4
View File
@@ -184,11 +184,8 @@ func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *z
// Check access control for each file/folder during walk
if d.share == nil {
// Construct the index-relative path for this file/folder
// relPath is relative to realPath, so we need to join it with the original path
indexRelPath := filepath.Join(path, relPath)
indexRelPath := utils.JoinPathAsUnix(path, relPath)
indexRelPath = filepath.ToSlash(indexRelPath) // Normalize separators
if !store.Access.Permitted(idx.Path, indexRelPath, d.user.Username) {
// Skip this file/folder silently
if fileInfo.IsDir() {
+11 -2
View File
@@ -8,6 +8,7 @@ import (
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
)
@@ -77,14 +78,22 @@ func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
// Perform the search using the provided query and user scope
response := index.Search(searchOptions.query, searchOptions.combinedPath, searchOptions.sessionId, searchOptions.largest, indexing.DefaultSearchResults)
// Remove the user scope from the path (modifying in place is safe - these are fresh allocations)
// Filter out items that are not permitted according to access rules
filteredResponse := make([]*indexing.SearchResult, 0, len(response))
for _, result := range response {
indexPath := utils.JoinPathAsUnix(searchOptions.combinedPath, result.Path)
if store.Access != nil && !store.Access.Permitted(index.Path, indexPath, d.user.Username) {
continue // Silently skip this file/folder
}
// Remove the user scope from the path (modifying in place is safe - these are fresh allocations)
result.Path = strings.TrimPrefix(result.Path, searchOptions.combinedPath)
if result.Path == "" {
result.Path = "/"
}
filteredResponse = append(filteredResponse, result)
}
return renderJSON(w, r, response)
return renderJSON(w, r, filteredResponse)
}
func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, error) {
+7 -3
View File
@@ -10,11 +10,13 @@ import (
// generateOTPHandler handles the generation of a new TOTP secret and QR code.
// @Summary Generate OTP
// @Description Generates a new TOTP secret and QR code for the authenticated user.
// @Description Generates a new TOTP secret and QR code for the authenticated user. The password must be URL-encoded and sent in the X-Password header to support special characters.
// @Tags OTP
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param username query string true "Username"
// @Param X-Password header string true "URL-encoded password"
// @Success 200 {object} map[string]string "OTP secret generated successfully."
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/auth/otp/generate [post]
@@ -33,12 +35,14 @@ func generateOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContex
// verifyOTPHandler handles the verification of a TOTP code.
// @Summary Verify OTP
// @Description Verifies the provided TOTP code for the authenticated user.
// @Description Verifies the provided TOTP code for the authenticated user. The password must be URL-encoded and sent in the X-Password header to support special characters.
// @Tags OTP
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param code query string true "TOTP code to verify"
// @Param username query string true "Username"
// @Param X-Password header string true "URL-encoded password"
// @Param X-Secret header string true "TOTP code to verify"
// @Success 200 {object} HttpResponse "OTP token is valid."
// @Failure 401 {object} map[string]string "Unauthorized - invalid TOTP token"
// @Router /api/auth/otp/verify [post]
-2
View File
@@ -234,8 +234,6 @@ func (idx *Index) GetFsDirInfo(adjustedPath string) (*iteminfo.FileInfo, error)
}
if !dirInfo.IsDir() {
// Use handleFile for consistent size calculation across platforms
// API calls (GetFsDirInfo) should not update totalSize, so pass false for isRoutineScan
realSize, _ := idx.handleFile(dirInfo, adjustedPath, realPath)
size := int64(realSize)
fileInfo := iteminfo.FileInfo{
+7 -2
View File
@@ -5,6 +5,7 @@ import (
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
@@ -21,7 +22,6 @@ func (idx *Index) UpdateMetadata(info *iteminfo.FileInfo) bool {
// DeleteMetadata removes the specified path from the index.
// If recursive is true and the path is a directory, it will also remove all subdirectories.
// NOTE: path should already be an index path (with trailing slash for directories)
func (idx *Index) DeleteMetadata(path string, isDir bool, recursive bool) bool {
idx.mu.Lock()
defer idx.mu.Unlock()
@@ -32,6 +32,11 @@ func (idx *Index) DeleteMetadata(path string, isDir bool, recursive bool) bool {
indexPath = utils.AddTrailingSlashIfNotExists(path)
}
// Clear cache entries
joinedPath := filepath.Join(idx.Path, indexPath)
RealPathCache.Delete(joinedPath)
IsDirCache.Delete(joinedPath + ":isdir")
if !isDir {
// For files, remove from parent directory's Files slice
parentPath := utils.AddTrailingSlashIfNotExists(filepath.Dir(strings.TrimSuffix(path, "/")))
@@ -194,7 +199,7 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
_, ok = utils.DiskUsageCache.Get(cacheKey)
if !ok {
// Only fetch disk total if not cached (this is expensive, so we cache it)
totalBytes, err := getPartitionSize(sourcePath)
totalBytes, err := fileutils.GetPartitionSize(sourcePath)
if err != nil {
idx.mu.Lock()
idx.Status = UNAVAILABLE
-12
View File
@@ -15,18 +15,6 @@ func CheckWindowsHidden(realpath string) bool {
return false
}
// getFilesystemSize returns the filesystem size (fallback method)
func getPartitionSize(path string) (uint64, error) {
var stat syscall.Statfs_t
err := syscall.Statfs(path, &stat)
if err != nil {
return 0, err
}
// Total size in bytes: Blocks * Block size
total := stat.Blocks * uint64(stat.Bsize)
return total, nil
}
func getFileDetails(sys any, filePath string) (uint64, uint64, uint64, bool) {
// On Unix, we should always be able to get syscall info
// First try from file.Sys() if available (fast path)
-13
View File
@@ -34,19 +34,6 @@ func CheckWindowsHidden(realpath string) bool {
return false
}
func getPartitionSize(path string) (uint64, error) {
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
return 0, err
}
var freeBytes, totalBytes, totalFreeBytes uint64
err = windows.GetDiskFreeSpaceEx(pathPtr, &freeBytes, &totalBytes, &totalFreeBytes)
if err != nil {
return 0, err
}
return totalBytes, nil
}
// handleFile processes a file and returns its size and whether it should be counted
// On Windows, uses file.Size() directly (no syscall support for allocated size)
func (idx *Index) handleFile(file os.FileInfo, fullCombined string, realFilePath string) (size uint64, shouldCountSize bool) {
+63 -5
View File
@@ -466,7 +466,7 @@ const docTemplate = `{
},
"/api/auth/login": {
"post": {
"description": "Authenticate a user with a username and password.",
"description": "Authenticate a user with a username and password. The password must be URL-encoded and sent in the X-Password header to support special characters (e.g., ^, %, £, €, etc.).",
"consumes": [
"application/json"
],
@@ -477,6 +477,34 @@ const docTemplate = `{
"Auth"
],
"summary": "User login",
"parameters": [
{
"type": "string",
"description": "Username",
"name": "username",
"in": "query",
"required": true
},
{
"type": "string",
"description": "ReCaptcha response token (if enabled)",
"name": "recaptcha",
"in": "query"
},
{
"type": "string",
"description": "URL-encoded password",
"name": "X-Password",
"in": "header",
"required": true
},
{
"type": "string",
"description": "TOTP code (if 2FA is enabled)",
"name": "X-Secret",
"in": "header"
}
],
"responses": {
"200": {
"description": "JWT token for authentication",
@@ -610,7 +638,7 @@ const docTemplate = `{
},
"/api/auth/otp/generate": {
"post": {
"description": "Generates a new TOTP secret and QR code for the authenticated user.",
"description": "Generates a new TOTP secret and QR code for the authenticated user. The password must be URL-encoded and sent in the X-Password header to support special characters.",
"consumes": [
"application/json"
],
@@ -621,6 +649,22 @@ const docTemplate = `{
"OTP"
],
"summary": "Generate OTP",
"parameters": [
{
"type": "string",
"description": "Username",
"name": "username",
"in": "query",
"required": true
},
{
"type": "string",
"description": "URL-encoded password",
"name": "X-Password",
"in": "header",
"required": true
}
],
"responses": {
"200": {
"description": "OTP secret generated successfully.",
@@ -650,7 +694,7 @@ const docTemplate = `{
},
"/api/auth/otp/verify": {
"post": {
"description": "Verifies the provided TOTP code for the authenticated user.",
"description": "Verifies the provided TOTP code for the authenticated user. The password must be URL-encoded and sent in the X-Password header to support special characters.",
"consumes": [
"application/json"
],
@@ -664,10 +708,24 @@ const docTemplate = `{
"parameters": [
{
"type": "string",
"description": "TOTP code to verify",
"name": "code",
"description": "Username",
"name": "username",
"in": "query",
"required": true
},
{
"type": "string",
"description": "URL-encoded password",
"name": "X-Password",
"in": "header",
"required": true
},
{
"type": "string",
"description": "TOTP code to verify",
"name": "X-Secret",
"in": "header",
"required": true
}
],
"responses": {
+63 -5
View File
@@ -455,7 +455,7 @@
},
"/api/auth/login": {
"post": {
"description": "Authenticate a user with a username and password.",
"description": "Authenticate a user with a username and password. The password must be URL-encoded and sent in the X-Password header to support special characters (e.g., ^, %, £, €, etc.).",
"consumes": [
"application/json"
],
@@ -466,6 +466,34 @@
"Auth"
],
"summary": "User login",
"parameters": [
{
"type": "string",
"description": "Username",
"name": "username",
"in": "query",
"required": true
},
{
"type": "string",
"description": "ReCaptcha response token (if enabled)",
"name": "recaptcha",
"in": "query"
},
{
"type": "string",
"description": "URL-encoded password",
"name": "X-Password",
"in": "header",
"required": true
},
{
"type": "string",
"description": "TOTP code (if 2FA is enabled)",
"name": "X-Secret",
"in": "header"
}
],
"responses": {
"200": {
"description": "JWT token for authentication",
@@ -599,7 +627,7 @@
},
"/api/auth/otp/generate": {
"post": {
"description": "Generates a new TOTP secret and QR code for the authenticated user.",
"description": "Generates a new TOTP secret and QR code for the authenticated user. The password must be URL-encoded and sent in the X-Password header to support special characters.",
"consumes": [
"application/json"
],
@@ -610,6 +638,22 @@
"OTP"
],
"summary": "Generate OTP",
"parameters": [
{
"type": "string",
"description": "Username",
"name": "username",
"in": "query",
"required": true
},
{
"type": "string",
"description": "URL-encoded password",
"name": "X-Password",
"in": "header",
"required": true
}
],
"responses": {
"200": {
"description": "OTP secret generated successfully.",
@@ -639,7 +683,7 @@
},
"/api/auth/otp/verify": {
"post": {
"description": "Verifies the provided TOTP code for the authenticated user.",
"description": "Verifies the provided TOTP code for the authenticated user. The password must be URL-encoded and sent in the X-Password header to support special characters.",
"consumes": [
"application/json"
],
@@ -653,10 +697,24 @@
"parameters": [
{
"type": "string",
"description": "TOTP code to verify",
"name": "code",
"description": "Username",
"name": "username",
"in": "query",
"required": true
},
{
"type": "string",
"description": "URL-encoded password",
"name": "X-Password",
"in": "header",
"required": true
},
{
"type": "string",
"description": "TOTP code to verify",
"name": "X-Secret",
"in": "header",
"required": true
}
],
"responses": {
+50 -4
View File
@@ -1871,7 +1871,28 @@ paths:
post:
consumes:
- application/json
description: Authenticate a user with a username and password.
description: Authenticate a user with a username and password. The password
must be URL-encoded and sent in the X-Password header to support special characters
(e.g., ^, %, £, €, etc.).
parameters:
- description: Username
in: query
name: username
required: true
type: string
- description: ReCaptcha response token (if enabled)
in: query
name: recaptcha
type: string
- description: URL-encoded password
in: header
name: X-Password
required: true
type: string
- description: TOTP code (if 2FA is enabled)
in: header
name: X-Secret
type: string
produces:
- application/json
responses:
@@ -1966,6 +1987,19 @@ paths:
consumes:
- application/json
description: Generates a new TOTP secret and QR code for the authenticated user.
The password must be URL-encoded and sent in the X-Password header to support
special characters.
parameters:
- description: Username
in: query
name: username
required: true
type: string
- description: URL-encoded password
in: header
name: X-Password
required: true
type: string
produces:
- application/json
responses:
@@ -1990,11 +2024,23 @@ paths:
post:
consumes:
- application/json
description: Verifies the provided TOTP code for the authenticated user.
description: Verifies the provided TOTP code for the authenticated user. The
password must be URL-encoded and sent in the X-Password header to support
special characters.
parameters:
- description: TOTP code to verify
- description: Username
in: query
name: code
name: username
required: true
type: string
- description: URL-encoded password
in: header
name: X-Password
required: true
type: string
- description: TOTP code to verify
in: header
name: X-Secret
required: true
type: string
produces:
+3 -3
View File
@@ -18,7 +18,7 @@ export async function generateOTP(username, password) {
const res = await fetch(apiPath, {
method: 'POST',
headers: {
'X-Password': password,
'X-Password': encodeURIComponent(password),
}
})
return await res.json()
@@ -35,7 +35,7 @@ export async function verifyOtp(username, password, otp) {
const res = await fetch(apiPath, {
method: 'POST',
headers: {
'X-Password': password,
'X-Password': encodeURIComponent(password),
'X-Secret': otp,
}
})
@@ -65,7 +65,7 @@ export async function login(username, password, recaptcha, otp) {
method: 'POST',
credentials: 'same-origin', // Ensure cookies can be set during login
headers: {
'X-Password': password,
'X-Password': encodeURIComponent(password),
'X-Secret': otp,
}
});
+66 -87
View File
@@ -1,12 +1,8 @@
<template>
<div
id="search"
:class="{ active, ongoing, 'dark-mode': isDarkMode }"
@click="clearContext"
>
<div id="search" :class="{ active, ongoing, 'dark-mode': isDarkMode }" @click="clearContext">
<!-- Search input section -->
<div id="search-input" @click="open" :class="{'halloween-eyes': eventTheme === 'halloween'}">
<div id="halloween-eyes" v-if="eventTheme=== 'halloween' && active">
<div id="search-input" @click="open" :class="{ 'halloween-eyes': eventTheme === 'halloween' }">
<div id="halloween-eyes" v-if="eventTheme === 'halloween' && active">
<div class="eye left">
<div class="pupil"></div>
</div>
@@ -15,44 +11,23 @@
</div>
</div>
<!-- Close button visible when search is active -->
<button
v-if="active"
class="action"
@click="close"
:aria-label="$t('general.close')"
:title="$t('general.close')"
>
<button v-if="active" class="action" @click="close" :aria-label="$t('general.close')"
:title="$t('general.close')">
<i class="material-icons">close</i>
</button>
<!-- Search icon when search is not active -->
<i v-else class="material-icons">search</i>
<!-- Input field for search -->
<input
id="main-input"
class="main-input"
:class="{ 'halloween-theme': eventTheme === 'halloween' }"
type="text"
@keyup.exact="keyup"
@input="submit"
ref="input"
:autofocus="active"
v-model.trim="value"
aria-label="search input"
:placeholder="$t('general.search', { suffix: '...' })"
/>
<input id="main-input" class="main-input" :class="{ 'halloween-theme': eventTheme === 'halloween' }" type="text"
@keyup.exact="keyup" @input="submit" ref="input" :autofocus="active" v-model.trim="value"
aria-label="search input" :placeholder="$t('general.search', { suffix: '...' })" />
</div>
<!-- Search results for desktop -->
<div v-show="active" id="results" class="fb-shadow" ref="result">
<div v-show="active" id="results" class="fb-shadow" ref="result">
<div class="inputWrapper">
<select
v-if="multipleSources"
class="searchContext button input"
aria-label="search-path"
v-model="selectedSource"
:value="selectedSource"
@change="updateSource"
>
<select v-if="multipleSources" class="searchContext button input" aria-label="search sources dropdown"
v-model="selectedSource" :value="selectedSource" @change="updateSource">
<option v-for="(info, name) in sourceInfo" :key="info" :value="name">
{{ name }}
</option>
@@ -66,50 +41,36 @@
<div v-if="!disableSearchOptions">
<div v-if="active">
<div v-if="isMobile">
<ButtonGroup
:buttons="toggleOptionButton"
@button-clicked="enableOptions"
@remove-button-clicked="disableOptions"
/>
<ButtonGroup :buttons="toggleOptionButton" @button-clicked="enableOptions"
@remove-button-clicked="disableOptions" />
</div>
<transition name="expand">
<div v-show="showOptions" class="search-options">
<!-- Button groups for filtering search results -->
<ButtonGroup
:buttons="folderSelect"
@button-clicked="addToTypes"
@remove-button-clicked="removeFromTypes"
@disableAll="folderSelectClicked()"
@enableAll="resetButtonGroups()"
/>
<ButtonGroup
:buttons="typeSelect"
@button-clicked="addToTypes"
@remove-button-clicked="removeFromTypes"
:isDisabled="isTypeSelectDisabled"
/>
<ButtonGroup :buttons="folderSelect" @button-clicked="addToTypes"
@remove-button-clicked="removeFromTypes" @disableAll="folderSelectClicked()"
@enableAll="resetButtonGroups()" />
<ButtonGroup :buttons="typeSelect" @button-clicked="addToTypes" @remove-button-clicked="removeFromTypes"
:isDisabled="isTypeSelectDisabled" />
<!-- Inputs for filtering by file size -->
<div class="sizeConstraints">
<div class="sizeInputWrapper">
<p>{{ $t("search.smallerThan") }}</p>
<input
class="sizeInput"
v-model="smallerThan"
type="number"
min="0"
:placeholder="$t('general.number')"
/><p>MB</p> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<input class="sizeInput" v-model="smallerThan" type="number" min="0"
:placeholder="$t('general.number')" />
<p>MB</p> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
<div class="sizeInputWrapper">
<p>{{ $t("search.largerThan") }}</p>
<input
class="sizeInput"
v-model="largerThan"
type="number"
:placeholder="$t('general.number')"
/><p>MB</p> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<input class="sizeInput" v-model="largerThan" type="number" :placeholder="$t('general.number')" />
<p>MB</p> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
</div>
<!-- Toggle for showing preview images -->
<div class="search-option-item">
<ToggleSwitch v-model="showPreviewImages" :name="$t('search.showPreviewImages')"
:description="$t('search.showPreviewImagesDescription')" />
</div>
</div>
</transition>
</div>
@@ -121,27 +82,22 @@
<!-- Message when no results are found -->
<div class="searchPrompt" v-show="isEmpty && !isRunning">
<p>{{ noneMessage }}</p>
<i
class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showHelpTooltip"
@mouseleave="hideTooltip"
>
<i class="no-select material-symbols-outlined tooltip-info-icon" @mouseenter="showHelpTooltip"
@mouseleave="hideTooltip">
help
</i>
</div>
<!-- List of search results -->
<ul v-show="results.length > 0">
<li
v-for="(s, k) in results"
:key="k"
class="search-entry clickable"
:class="{ active: activeStates[k] }"
:aria-label="baseName(s.path)"
>
<li v-for="(s, k) in results" :key="k" class="search-entry clickable"
:class="{ active: activeStates[k], 'large-icons': showPreviewImages }" :aria-label="baseName(s.path)">
<a :href="getRelative(s.path)" @contextmenu="addSelected(event, s)">
<Icon :mimetype="s.type" :filename="s.name" />
<Icon :mimetype="s.type" :filename="baseName(s.path)"
:hasPreview="showPreviewImages && (s.hasPreview || false)"
:thumbnailUrl="showPreviewImages ? getThumbnailUrl(s) : ''" />
<span class="text-container">
{{ basePath(s.path, s.type == "directory") }}{{ baseName(s.path) }} <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ basePath(s.path, s.type == "directory") }}{{ baseName(s.path) }}
<!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</span>
<div class="filesize">{{ humanSize(s.size) }}</div>
</a>
@@ -154,11 +110,12 @@
<script>
import ButtonGroup from "./ButtonGroup.vue";
import { search } from "@/api";
import { search, filesApi } from "@/api";
import { getters, mutations, state } from "@/store";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { url } from "@/utils/";
import Icon from "@/components/files/Icon.vue";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import { globalVars } from "@/utils/constants";
var boxes = {
@@ -175,6 +132,7 @@ export default {
components: {
ButtonGroup,
Icon,
ToggleSwitch,
},
name: "search",
data: function () {
@@ -184,6 +142,7 @@ export default {
noneMessage: this.$t("search.typeToSearch", { minSearchLength: globalVars.minSearchLength }),
searchTypes: "",
isTypeSelectDisabled: false,
showPreviewImages: false,
folderSelect: [
{ label: this.$t("search.onlyFolders"), value: "type:folder" },
{ label: this.$t("search.onlyFiles"), value: "type:file" },
@@ -478,6 +437,7 @@ export default {
resetSearchFilters() {
this.searchTypes = "";
this.hiddenOptions = true;
this.showPreviewImages = false;
},
removeFromTypes(string) {
if (string == null || string == "") {
@@ -537,6 +497,19 @@ export default {
clearContext() {
mutations.closeHovers();
},
getThumbnailUrl(s) {
if (!s.hasPreview) {
return "";
}
try {
const source = this.selectedSource || state.sources.current;
const path = s.path;
const modified = s.modified || "";
return filesApi.getPreviewURL(source, path, modified);
} catch (err) {
return "";
}
},
addSelected(event, s) {
const pathParts = url.removeTrailingSlash(s.path).split("/");
let path = this.getContext + url.removeTrailingSlash(s.path);
@@ -558,6 +531,7 @@ export default {
.sizeInputWrapper {
border: 1px solid #ccc;
}
.main-input {
width: 100%;
}
@@ -599,7 +573,7 @@ export default {
color: white;
}
#results > #result-list {
#results>#result-list {
max-height: 80vh;
width: 35em;
overflow: scroll;
@@ -713,6 +687,12 @@ export default {
background-color: var(--surfacePrimary);
}
/* Toggle switch styling in search options */
.search-option-item {
margin: 1em;
}
.text-container {
margin-left: 0.25em;
white-space: nowrap;
@@ -743,7 +723,7 @@ body.rtl #search #result {
direction: ltr;
}
#search #result > div > *:first-child {
#search #result>div>*:first-child {
margin-top: 0;
}
@@ -753,7 +733,7 @@ body.rtl #search #result {
}
/* Search Results */
body.rtl #search #result ul > * {
body.rtl #search #result ul>* {
direction: ltr;
text-align: left;
}
@@ -970,7 +950,7 @@ body.rtl #search .boxes h3 {
}
#result-list {
width:100vw !important;
width: 100vw !important;
max-width: 100vw !important;
left: 0;
top: 4em;
@@ -981,5 +961,4 @@ body.rtl #search .boxes h3 {
flex-direction: column;
}
}
</style>
+53 -114
View File
@@ -19,11 +19,11 @@
<!-- Previous button -->
<button
v-if="enabled && hasPrevious"
@click.stop="handlePrevClick"
@click.prevent="handlePrevClick"
@mousedown="startDrag($event, 'previous')"
@touchstart="handleTouchStart($event, 'previous')"
@touchmove="handleButtonTouchMove"
@touchend="handleTouchEnd"
@touchend.prevent="handleTouchEnd"
@mouseover="setHoverNav(true)"
@mouseleave="setHoverNav(false)"
class="nav-button nav-previous"
@@ -35,7 +35,7 @@
active: dragState.atFullExtent && dragState.type === 'previous',
'dark-mode': isDarkMode,
'media-mode': isMediaQueueMode,
}"
}"
:style="dragState.type === 'previous' ? { transform: `translateY(-50%) translate(${dragState.deltaX}px, 0)` } : {}"
:aria-label="$t('general.previous')"
:title="$t('general.previous')"
@@ -48,11 +48,11 @@
<!-- Next button -->
<button
v-if="enabled && hasNext"
@click.stop="handleNextClick"
@click.prevent="handleNextClick"
@mousedown="startDrag($event, 'next')"
@touchstart="handleTouchStart($event, 'next')"
@touchmove="handleButtonTouchMove"
@touchend="handleTouchEnd"
@touchend.prevent="handleTouchEnd"
@mouseover="setHoverNav(true)"
@mouseleave="setHoverNav(false)"
class="nav-button nav-next"
@@ -144,6 +144,10 @@ export default {
const view = getters.currentView();
return view;
},
isMediaFile() {
const previewType = getters.previewType();
return previewType === 'audio' || previewType === 'video';
},
isMediaQueueMode() {
const previewType = getters.previewType();
const isMediaView = previewType === 'audio' || previewType === 'video';
@@ -527,27 +531,15 @@ export default {
return;
}
// Check if any media element is currently playing
const mediaElements = document.querySelectorAll('audio, video');
let mediaActive = false;
mediaElements.forEach(media => {
if (!media.paused ||
document.activeElement === media) {
mediaActive = true;
}
});
// If media is playing don't handle arrow keys and let use fastfoward and rewind of the player
if (mediaActive) {
return;
}
// Don't handle arrow keys when playing media or when editing a file on the editor
const blockedViews = ['audio', 'video', 'editor'];
if (blockedViews.includes(this.currentView)) {
return;
}
// If we're in plyr, don't handle arrow keys to use fast-forward/rewind shortcuts, even if the media is paused.
if (this.isMediaFile) {
return;
}
// If we're in the editor, don't handle arrow keys to avoid change of file mistakenly.
const blockedViews = ['editor'];
if (blockedViews.includes(this.currentView)) {
return;
}
const { key } = event;
@@ -666,14 +658,6 @@ export default {
tapTimeout: null,
triggered: false
};
// Set a longer timeout to allow for drag intent detection
this.touchState.tapTimeout = setTimeout(() => {
// If we haven't moved significantly and not dragging, treat as tap
if (!this.touchState.hasMoved && !this.dragState.isDragging) {
this.handleButtonTap(buttonType);
}
}, 300); // Increased to 300ms to allow for drag detection
}
}
},
@@ -760,13 +744,20 @@ export default {
handleTouchEnd() {
// Handle touch end for buttons
if (this.touchState.isButtonTouch) {
const touchDuration = Date.now() - this.touchState.startTime;
// Only navigate on release if we didn't move our finger significantly (not a drag)
if (!this.touchState.hasMoved &&
!this.dragState.triggered &&
!this.touchState.triggered) {
// If it was a short touch without movement, and we haven't already navigated, treat as tap
if (!this.touchState.hasMoved && touchDuration < 300 && this.touchState.tapTimeout) {
clearTimeout(this.touchState.tapTimeout);
this.touchState.tapTimeout = null;
this.handleButtonTap(this.touchState.buttonType);
// Mark as triggered to prevent double navigation
this.touchState.triggered = true;
// Navigate based on button type
if (this.touchState.buttonType === 'previous' && this.hasPrevious) {
this.prev();
} else if (this.touchState.buttonType === 'next' && this.hasNext) {
this.next();
}
}
// Reset touch state
@@ -789,33 +780,6 @@ export default {
}
},
// Handle immediate button tap (mobile-friendly)
handleButtonTap(buttonType) {
// Prevent double navigation if already triggered
if (this.touchState.triggered) {
return;
}
// Clear any pending timeouts
if (this.touchState.tapTimeout) {
clearTimeout(this.touchState.tapTimeout);
this.touchState.tapTimeout = null;
}
// Mark as triggered to prevent double navigation
this.touchState.triggered = true;
// Navigate immediately on tap
if (buttonType === 'previous' && this.hasPrevious) {
this.prev();
} else if (buttonType === 'next' && this.hasNext) {
this.next();
}
// Reset touch state
this.resetTouchState();
},
resetTouchState() {
if (this.touchState.tapTimeout) {
clearTimeout(this.touchState.tapTimeout);
@@ -884,8 +848,13 @@ export default {
// Only show file list if user released at full extent
if (this.dragState.atFullExtent) {
this.showFileList(this.dragState.type);
this.dragState.triggered = true; // Mark that drag triggered an action
}
// We check if there was any movement (deltaX != 0) to distinguish from a simple click
if (this.dragState.deltaX !== 0) {
this.dragState.wasDrag = true;
}
this.resetDragState();
this.resetTouchState();
},
@@ -901,10 +870,17 @@ export default {
threshold: this.dragState.threshold,
atFullExtent: false,
triggered: false,
wasDrag: this.dragState.wasDrag,
};
},
handlePrevClick() {
// If a drag was not at the maximum, don't navigate and return the button to its initial position
if (this.dragState.wasDrag) {
this.dragState.wasDrag = false;
this.resetDragState();
return;
}
// Only navigate if this wasn't a drag
if (!this.dragState.triggered) {
this.prev();
@@ -913,6 +889,12 @@ export default {
},
handleNextClick() {
// If a drag was not at the maximum, don't navigate and return the button to its initial position
if (this.dragState.wasDrag) {
this.dragState.wasDrag = false;
this.resetDragState();
return;
}
// Only navigate if this wasn't a drag
if (!this.dragState.triggered) {
this.next();
@@ -923,29 +905,13 @@ export default {
showFileList(type) {
// Hide navigation buttons when showing file list
mutations.setNavigationShow(false);
// Determine what list to show based on drag type
if (type === 'previous') {
// Show parent directories for navigating up
this.showParentDirectories();
} else if (type === 'next') {
if (type === 'previous' || type === 'next') {
// Show current listing items for quick jumping
this.showCurrentListing();
}
},
showParentDirectories() {
// Show files in the current directory (same directory as the previewed file)
const currentItems = this.getCurrentListingItems();
mutations.showHover({
name: "file-list",
props: {
fileList: currentItems,
mode: "navigate-siblings",
title: this.$t("prompts.quickJump")
}
});
},
showCurrentListing() {
const currentItems = this.getCurrentListingItems();
mutations.showHover({
@@ -958,35 +924,6 @@ export default {
});
},
getParentDirectories() {
// Build array of parent directories from current path
const currentPath = state.req.path || "/";
const pathParts = currentPath.split("/").filter(part => part);
const parentDirs = [];
// Add root
parentDirs.push({
name: "/",
path: "/",
source: state.req.source,
isDirectory: true
});
// Add each level up to current
let buildPath = "";
for (let i = 0; i < pathParts.length; i++) {
buildPath += "/" + pathParts[i];
parentDirs.push({
name: pathParts[i],
path: buildPath,
source: state.req.source,
isDirectory: true
});
}
return parentDirs.reverse(); // Show deepest first
},
getCurrentListingItems() {
// Get items from the current navigation listing (files in same directory)
const listing = state.navigation.listing || [];
@@ -999,6 +936,7 @@ export default {
originalItem: item
}));
},
handleGlobalMouseMove(event) {
// Check if mouse is in the nav zone areas to show navigation buttons
if (!this.enabled) return;
@@ -1076,6 +1014,7 @@ export default {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
opacity: 1;
margin-top: 2em;
user-select: none;
}
.nav-button.dark-mode {
+29 -8
View File
@@ -19,7 +19,11 @@
<div v-if="!isDisplayMode" aria-label="filelist-path" class="searchContext button clickable">{{ $t('general.path', { suffix: ':' }) }}
{{ sourcePath.path }}</div>
<ul class="file-list">
<div v-if="loading" class="loading-spinner">
<i class="material-icons spin">sync</i>
</div>
<ul v-else class="file-list">
<li @click="itemClick" @touchstart="touchstart" @dblclick="next" role="button" tabindex="0"
:aria-label="item.name" :aria-selected="selected == item.path" :key="item.name" v-for="item in items"
:data-path="item.path" class="file-item">
@@ -92,6 +96,7 @@ export default {
selectedSource: null,
current: window.location.pathname,
currentSource: initialSource,
loading: false,
};
},
computed: {
@@ -140,10 +145,13 @@ export default {
mounted() {
if (this.isDisplayMode) {
// Display mode: use provided fileList
this.fillOptionsFromList();
this.withLoading(async () => {
await new Promise(resolve => setTimeout(resolve, 0)); // Make it async
this.fillOptionsFromList();
});
} else if (this.browseShare) {
// Browse a specific share
publicApi.fetchPub("/", this.browseShare).then(this.fillOptions);
this.withLoading(() => publicApi.fetchPub("/", this.browseShare).then(this.fillOptions));
} else {
// Normal browse mode: fetch files
const sourceToUse = this.currentSource;
@@ -155,13 +163,26 @@ export default {
};
// Fetch the initial data for the source
if (this.currentSource !== state.req.source) {
filesApi.fetchFiles(sourceToUse, pathToUse).then(this.fillOptions);
this.withLoading(() => filesApi.fetchFiles(sourceToUse, pathToUse).then(this.fillOptions));
} else {
this.fillOptions(initialReq);
}
}
},
methods: {
// Helper method to ensure loading spinner shows for minimum 200ms
async withLoading(operation) {
const startTime = Date.now();
this.loading = true;
try {
await operation();
} finally {
const elapsed = Date.now() - startTime;
const remaining = Math.max(0, 200 - elapsed);
await new Promise(resolve => setTimeout(resolve, remaining));
this.loading = false;
}
},
resetToSource(newSource) {
// Use current path if browsing the same source as current, otherwise start at root
const newPath = newSource === state.req.source ? state.req.path : "/";
@@ -172,7 +193,7 @@ export default {
this.selected = null;
this.selectedSource = null;
// Fetch files for the new source
filesApi.fetchFiles(newSource, newPath).then(this.fillOptions);
this.withLoading(() => filesApi.fetchFiles(newSource, newPath).then(this.fillOptions));
},
resetToShare(newHash) {
// Reset to the share root
@@ -182,7 +203,7 @@ export default {
this.selected = null;
this.selectedSource = null;
// Fetch files for the share
publicApi.fetchPub("/", newHash).then(this.fillOptions);
this.withLoading(() => publicApi.fetchPub("/", newHash).then(this.fillOptions));
},
fillOptions(req) {
// Sets the current path and resets
@@ -234,10 +255,10 @@ export default {
if (this.browseShare || getters.isShare()) {
// Browsing a share - use public API
const hashToUse = this.browseShare || state.shareInfo?.hash;
publicApi.fetchPub(path, hashToUse).then(this.fillOptions);
this.withLoading(() => publicApi.fetchPub(path, hashToUse).then(this.fillOptions));
} else {
this.source = sourceToUse;
filesApi.fetchFiles(sourceToUse, path).then(this.fillOptions);
this.withLoading(() => filesApi.fetchFiles(sourceToUse, path).then(this.fillOptions));
}
},
+33
View File
@@ -521,6 +521,39 @@ body.rtl #listingView {
transition: width 0.25s ease-out, height 0.25s ease-out;
}
/* Large icon styling for search results when preview images are enabled - matches icons view */
.search-entry.large-icons i.icon,
.search-entry.large-icons .image-preview,
.search-entry.large-icons .image-preview img.icon {
display: flex;
align-items: center;
justify-content: center;
transition: width 0.25s ease-out, height 0.25s ease-out;
padding: 0;
}
.search-entry.large-icons i.icon {
font-size: var(--icons-view-icon-size, 50px) !important;
line-height: 1 !important;
background: var(--iconBackground) !important;
}
.search-entry.large-icons .image-preview {
flex-shrink: 0;
}
.search-entry.large-icons .image-preview img.icon {
margin: 0;
object-fit: cover;
width: 50px;
height: 50px;
}
.search-entry.large-icons .image-preview {
width: unset !important;
height: unset !important;
}
#listingView.gallery .folder-items,
#listingView.gallery .file-items {
display: grid;
+3 -1
View File
@@ -230,7 +230,9 @@
"showOptions": "عرض الخيارات",
"searchContext": "سياق البحث: {context}",
"notEnoughCharacters": "أحرف غير كافية للبحث (الحد الأدنى {minSearchLength})",
"typeToSearch": "ابدأ الكتابة {minSearchLength} أو أكثر لبدء البحث."
"typeToSearch": "ابدأ الكتابة {minSearchLength} أو أكثر لبدء البحث.",
"showPreviewImages": "عرض صور المعاينة",
"showPreviewImagesDescription": "عرض معاينات الصور المصغرة لنتائج البحث عند توفرها، وإظهار أيقونات أكبر لجميع النتائج."
},
"settings": {
"administrator": "Administrator",
+3 -1
View File
@@ -301,7 +301,9 @@
"showOptions": "Zobrazit možnosti",
"searchContext": "Kontext vyhledávání: {context}",
"notEnoughCharacters": "Nedostatek znaků pro vyhledávání (min. {minSearchLength})",
"typeToSearch": "Pro zahájení vyhledávání začněte psát {minSearchLength} nebo více znaků."
"typeToSearch": "Pro zahájení vyhledávání začněte psát {minSearchLength} nebo více znaků.",
"showPreviewImages": "Zobrazit náhledové obrázky",
"showPreviewImagesDescription": "Zobrazení miniaturních náhledů výsledků vyhledávání, pokud jsou k dispozici, a zobrazení větších ikon u všech výsledků."
},
"profileSettings": {
"setDateFormat": "Nastavit formát data",
+3 -1
View File
@@ -230,7 +230,9 @@
"showOptions": "Optionen anzeigen",
"searchContext": "Suche im Kontext: {context}",
"notEnoughCharacters": "Nicht genügend Zeichen für die Suche (min {minSearchLength})",
"typeToSearch": "Beginnen Sie mit der Eingabe von {minSearchLength} oder mehr Zeichen, um die Suche zu starten."
"typeToSearch": "Beginnen Sie mit der Eingabe von {minSearchLength} oder mehr Zeichen, um die Suche zu starten.",
"showPreviewImages": "Vorschaubilder anzeigen",
"showPreviewImagesDescription": "Zeigen Sie eine Miniaturvorschau für Suchergebnisse an, wenn verfügbar, und zeigen Sie größere Symbole für alle Ergebnisse an."
},
"settings": {
"administrator": "Administrator",
+3 -1
View File
@@ -233,7 +233,9 @@
"showOptions": "Εμφάνιση επιλογών",
"searchContext": "Πλαίσιο αναζήτησης: {context}",
"notEnoughCharacters": "Δεν υπάρχουν αρκετοί χαρακτήρες για αναζήτηση (min {minSearchLength})",
"typeToSearch": "Αρχίστε να πληκτρολογείτε {minSearchLength} ή περισσότερους χαρακτήρες για να ξεκινήσετε την αναζήτηση."
"typeToSearch": "Αρχίστε να πληκτρολογείτε {minSearchLength} ή περισσότερους χαρακτήρες για να ξεκινήσετε την αναζήτηση.",
"showPreviewImages": "Εμφάνιση εικόνων προεπισκόπησης",
"showPreviewImagesDescription": "Εμφάνιση προεπισκοπήσεων μικρογραφιών για τα αποτελέσματα αναζήτησης, όταν είναι διαθέσιμα, και εμφάνιση μεγαλύτερων εικονιδίων για όλα τα αποτελέσματα."
},
"settings": {
"administrator": "Διαχειριστής",
+2
View File
@@ -412,6 +412,8 @@
"noResults": "No results found in indexed search.",
"smallerThan": "Smaller than:",
"largerThan": "Larger than:",
"showPreviewImages": "Show preview images",
"showPreviewImagesDescription": "Display thumbnail previews for search results when available, and show larger icons for all results.",
"helpText1": "Search occurs on each character you type (3 character minimum for search terms).",
"helpText2": "The index: Search utilizes the index which automatically gets updated on the configured interval (default: 5 minutes). Searching when the program has just started may result in incomplete results."
},
+3 -1
View File
@@ -230,7 +230,9 @@
"noResults": "No pudimos encontrar lo que buscas...",
"searchContext": "Buscar en: {context}",
"notEnoughCharacters": "Escribe al menos {minSearchLength} letras para empezar a buscar",
"typeToSearch": "Escribe {minSearchLength} letras o más para iniciar la búsqueda"
"typeToSearch": "Escribe {minSearchLength} letras o más para iniciar la búsqueda",
"showPreviewImages": "Mostrar imágenes de previsualización",
"showPreviewImagesDescription": "Mostrar vistas previas en miniatura de los resultados de búsqueda cuando estén disponibles, y mostrar iconos más grandes para todos los resultados."
},
"settings": {
"scopes": "Fuentes y carpetas que el usuario tendrá acceso",
+4 -2
View File
@@ -58,7 +58,7 @@
"itemSelected": "élément sélectionné",
"itemsSelected": "éléments sélectionnés",
"myFiles": "Mes dossiers{suffix}",
"newFile": "Nouveau dossier{suffix}",
"newFile": "Nouveau Fichier{suffix}",
"newFolder": "Nouveau dossier{suffix}",
"duration": "Durée de l'accord{suffix}",
"sortByDuration": "Trier par durée"
@@ -230,7 +230,9 @@
"showOptions": "Afficher les options",
"searchContext": "Contexte de la recherche : {context}",
"notEnoughCharacters": "Pas assez de caractères pour effectuer une recherche (min {minSearchLength})",
"typeToSearch": "Commencez à taper {minSearchLength} ou plus de caractères pour lancer la recherche."
"typeToSearch": "Commencez à taper {minSearchLength} ou plus de caractères pour lancer la recherche.",
"showPreviewImages": "Afficher les images de prévisualisation",
"showPreviewImagesDescription": "Afficher les vignettes des résultats de la recherche lorsqu'elles sont disponibles, et afficher des icônes plus grandes pour tous les résultats."
},
"settings": {
"scopes": "Définir les sources autorisées et la portée de l'utilisateur au sein de celles-ci",
+3 -1
View File
@@ -230,7 +230,9 @@
"showOptions": "הצג אפשרויות",
"searchContext": "הקשר החיפוש: {context}",
"notEnoughCharacters": "אין מספיק תווים לחיפוש (מינימום {minSearchLength})",
"typeToSearch": "התחל להקליד {minSearchLength} או יותר תווים כדי להתחיל בחיפוש."
"typeToSearch": "התחל להקליד {minSearchLength} או יותר תווים כדי להתחיל בחיפוש.",
"showPreviewImages": "הצג תמונות מקדימות",
"showPreviewImagesDescription": "הצג תצוגה מקדימה של תמונות ממוזערות עבור תוצאות החיפוש, כאשר הן זמינות, והצג סמלים גדולים יותר עבור כל התוצאות."
},
"settings": {
"administrator": "מנהל ראשי",
+3 -1
View File
@@ -230,7 +230,9 @@
"showOptions": "Beállítások megjelenítése",
"searchContext": "Keresési kontextus: {context}",
"notEnoughCharacters": "Nincs elég karakter a kereséshez (min {minSearchLength})",
"typeToSearch": "A keresés megkezdéséhez írja be a {minSearchLength} vagy több karaktert."
"typeToSearch": "A keresés megkezdéséhez írja be a {minSearchLength} vagy több karaktert.",
"showPreviewImages": "Előnézeti képek megjelenítése",
"showPreviewImagesDescription": "A keresési találatok miniatűr előnézeteinek megjelenítése, ha rendelkezésre állnak, és nagyobb ikonok megjelenítése az összes találathoz."
},
"settings": {
"administrator": "Adminisztrátor",
+3 -1
View File
@@ -230,7 +230,9 @@
"showOptions": "Mostra opzioni",
"searchContext": "Contesto di ricerca: {context}",
"notEnoughCharacters": "Non ci sono abbastanza caratteri per la ricerca (min {minSearchLength})",
"typeToSearch": "Iniziare a digitare {minSearchLength} o più caratteri per avviare la ricerca."
"typeToSearch": "Iniziare a digitare {minSearchLength} o più caratteri per avviare la ricerca.",
"showPreviewImages": "Mostra le immagini di anteprima",
"showPreviewImagesDescription": "Visualizzare le anteprime dei risultati della ricerca, se disponibili, e mostrare icone più grandi per tutti i risultati."
},
"settings": {
"administrator": "Amministratore",
+3 -1
View File
@@ -230,7 +230,9 @@
"showOptions": "オプションを表示",
"searchContext": "検索コンテキスト:{context}",
"notEnoughCharacters": "検索する文字数が不足しています(最小:{minSearchLength}文字)",
"typeToSearch": "検索を開始するには、{minSearchLength}文字以上を入力してください。"
"typeToSearch": "検索を開始するには、{minSearchLength}文字以上を入力してください。",
"showPreviewImages": "プレビュー画像を表示する",
"showPreviewImagesDescription": "検索結果のサムネイルプレビューを表示し、すべての検索結果に対して大きなアイコンを表示します。"
},
"settings": {
"administrator": "管理者",
+3 -1
View File
@@ -230,7 +230,9 @@
"showOptions": "옵션 표시",
"searchContext": "컨텍스트 검색: {context}",
"notEnoughCharacters": "검색할 문자가 부족합니다(최소 {minSearchLength})",
"typeToSearch": "검색을 시작하려면 {minSearchLength} 을 입력하세요."
"typeToSearch": "검색을 시작하려면 {minSearchLength} 을 입력하세요.",
"showPreviewImages": "미리 보기 이미지 표시",
"showPreviewImagesDescription": "가능한 경우 검색 결과의 썸네일 미리 보기를 표시하고 모든 결과에 대해 더 큰 아이콘을 표시합니다."
},
"settings": {
"administrator": "관리자",
+3 -1
View File
@@ -230,7 +230,9 @@
"showOptions": "Opties weergeven",
"searchContext": "Zoek context: {context}",
"notEnoughCharacters": "Niet genoeg tekens om te zoeken (min {minSearchLength})",
"typeToSearch": "Begin met het typen van {minSearchLength} of meer tekens om te beginnen met zoeken."
"typeToSearch": "Begin met het typen van {minSearchLength} of meer tekens om te beginnen met zoeken.",
"showPreviewImages": "Voorbeeldafbeeldingen tonen",
"showPreviewImagesDescription": "Toon miniatuurvoorbeelden voor zoekresultaten indien beschikbaar, en toon grotere pictogrammen voor alle resultaten."
},
"settings": {
"administrator": "Administrator",
+3 -1
View File
@@ -230,7 +230,9 @@
"showOptions": "Pokaż opcje",
"searchContext": "Kontekst wyszukiwania: {context}",
"notEnoughCharacters": "Za mało znaków do wyszukania (min. {minSearchLength})",
"typeToSearch": "Zacznij wpisywać {minSearchLength} lub więcej znaków, aby rozpocząć wyszukiwanie."
"typeToSearch": "Zacznij wpisywać {minSearchLength} lub więcej znaków, aby rozpocząć wyszukiwanie.",
"showPreviewImages": "Pokaż obrazy podglądu",
"showPreviewImagesDescription": "Wyświetlaj podglądy miniatur wyników wyszukiwania, jeśli są dostępne, i wyświetlaj większe ikony dla wszystkich wyników."
},
"settings": {
"administrator": "Administrator",
+3 -1
View File
@@ -230,7 +230,9 @@
"showOptions": "Mostrar opções",
"searchContext": "Contexto da pesquisa: {context}",
"notEnoughCharacters": "Não há caracteres suficientes para pesquisar (mín. {minSearchLength})",
"typeToSearch": "Comece a digitar {minSearchLength} ou mais caracteres para iniciar a busca."
"typeToSearch": "Comece a digitar {minSearchLength} ou mais caracteres para iniciar a busca.",
"showPreviewImages": "Mostrar imagens de visualização",
"showPreviewImagesDescription": "Exibir visualizações em miniatura dos resultados da pesquisa, quando disponíveis, e mostrar ícones maiores para todos os resultados."
},
"settings": {
"administrator": "Administrador",
+3 -1
View File
@@ -230,7 +230,9 @@
"showOptions": "Mostrar opções",
"searchContext": "Contexto de pesquisa: {context}",
"notEnoughCharacters": "Não há caracteres suficientes para pesquisar (min {minSearchLength})",
"typeToSearch": "Comece a escrever {minSearchLength} ou mais caracteres para iniciar a pesquisa."
"typeToSearch": "Comece a escrever {minSearchLength} ou mais caracteres para iniciar a pesquisa.",
"showPreviewImages": "Mostrar imagens de pré-visualização",
"showPreviewImagesDescription": "Apresentar pré-visualizações em miniatura dos resultados da pesquisa, quando disponíveis, e mostrar ícones maiores para todos os resultados."
},
"settings": {
"administrator": "Administrador",
+3 -1
View File
@@ -230,7 +230,9 @@
"showOptions": "Afișare opțiuni",
"searchContext": "Context de căutare: {context}",
"notEnoughCharacters": "Nu sunt suficiente caractere pentru căutare (min {minSearchLength})",
"typeToSearch": "Începeți să tastați {minSearchLength} sau mai multe caractere pentru a începe căutarea."
"typeToSearch": "Începeți să tastați {minSearchLength} sau mai multe caractere pentru a începe căutarea.",
"showPreviewImages": "Afișați imagini de previzualizare",
"showPreviewImagesDescription": "Afișați previzualizări în miniatură pentru rezultatele căutării, atunci când sunt disponibile, și afișați pictograme mai mari pentru toate rezultatele."
},
"settings": {
"administrator": "Administrator",
+3 -1
View File
@@ -230,7 +230,9 @@
"showOptions": "Показать параметры",
"searchContext": "Контекст поиска: {context}",
"notEnoughCharacters": "Недостаточно символов для поиска (мин. {minSearchLength})",
"typeToSearch": "Начните вводить {minSearchLength} или более символов, чтобы начать поиск."
"typeToSearch": "Начните вводить {minSearchLength} или более символов, чтобы начать поиск.",
"showPreviewImages": "Показать изображения для предварительного просмотра",
"showPreviewImagesDescription": "Показывайте миниатюры для результатов поиска, если они доступны, и отображайте более крупные значки для всех результатов."
},
"settings": {
"administrator": "Администратор",
+3 -1
View File
@@ -230,7 +230,9 @@
"showOptions": "Zobraziť možnosti",
"searchContext": "Kontext vyhľadávania: {context}",
"notEnoughCharacters": "Nedostatok znakov na vyhľadávanie (min. {minSearchLength})",
"typeToSearch": "Začnite písať {minSearchLength} alebo viac znakov, aby ste začali vyhľadávať."
"typeToSearch": "Začnite písať {minSearchLength} alebo viac znakov, aby ste začali vyhľadávať.",
"showPreviewImages": "Zobraziť náhľad obrázkov",
"showPreviewImagesDescription": "Zobrazenie miniatúrnych náhľadov výsledkov vyhľadávania, ak sú k dispozícii, a zobrazenie väčších ikon pre všetky výsledky."
},
"settings": {
"administrator": "Administrátor",
+3 -1
View File
@@ -230,7 +230,9 @@
"showOptions": "Visa alternativ",
"searchContext": "Sök kontext: {context}",
"notEnoughCharacters": "Inte tillräckligt med tecken för att söka (min {minSearchLength})",
"typeToSearch": "Börja skriva {minSearchLength} eller fler tecken för att påbörja sökningen."
"typeToSearch": "Börja skriva {minSearchLength} eller fler tecken för att påbörja sökningen.",
"showPreviewImages": "Visa förhandsgranskningsbilder",
"showPreviewImagesDescription": "Visa förhandsvisningar av miniatyrbilder för sökresultat när det är tillgängligt och visa större ikoner för alla resultat."
},
"settings": {
"administrator": "Administratör",
+3 -1
View File
@@ -230,7 +230,9 @@
"showOptions": "Seçenekleri Göster",
"searchContext": "Arama Bağlamı: {context}",
"notEnoughCharacters": "Arama yapmak için yeterli karakter yok (min {minSearchLength})",
"typeToSearch": "Aramaya başlamak için {minSearchLength} veya daha fazla karakter yazmaya başlayın."
"typeToSearch": "Aramaya başlamak için {minSearchLength} veya daha fazla karakter yazmaya başlayın.",
"showPreviewImages": "Önizleme resimlerini göster",
"showPreviewImagesDescription": "Kullanılabilir olduğunda arama sonuçları için küçük resim önizlemelerini görüntüleyin ve tüm sonuçlar için daha büyük simgeler gösterin."
},
"settings": {
"administrator": "Yönetici",
+3 -1
View File
@@ -230,7 +230,9 @@
"showOptions": "Показати параметри",
"searchContext": "Пошуковий контекст: {context}",
"notEnoughCharacters": "Недостатньо символів для пошуку (мінімум {minSearchLength})",
"typeToSearch": "Почніть вводити {minSearchLength} або більше символів, щоб почати пошук."
"typeToSearch": "Почніть вводити {minSearchLength} або більше символів, щоб почати пошук.",
"showPreviewImages": "Показати попередній перегляд зображень",
"showPreviewImagesDescription": "Відображати попередній перегляд мініатюр для результатів пошуку, якщо вони доступні, і показувати більші іконки для всіх результатів."
},
"settings": {
"administrator": "Адміністратор",
+3 -1
View File
@@ -230,7 +230,9 @@
"showOptions": "显示选项",
"searchContext": "搜索上下文:{context}",
"notEnoughCharacters": "搜索字符数不足(最少{minSearchLength})",
"typeToSearch": "开始键入{minSearchLength} 或更多字符开始搜索。"
"typeToSearch": "开始键入{minSearchLength} 或更多字符开始搜索。",
"showPreviewImages": "显示预览图像",
"showPreviewImagesDescription": "在可用时显示搜索结果的缩略图预览,并为所有结果显示更大的图标。"
},
"settings": {
"scopes": "定义允许的来源及其中的用户范围",
+3 -1
View File
@@ -230,7 +230,9 @@
"showOptions": "顯示選項",
"searchContext": "搜尋範圍:{context}",
"notEnoughCharacters": "搜尋字元不足 (最少{minSearchLength})",
"typeToSearch": "開始輸入{minSearchLength} 或更多字元以開始搜尋。"
"typeToSearch": "開始輸入{minSearchLength} 或更多字元以開始搜尋。",
"showPreviewImages": "顯示預覽影像",
"showPreviewImagesDescription": "在可用的情況下,顯示搜尋結果的縮圖預覽,並顯示所有結果的較大圖示。"
},
"settings": {
"administrator": "管理員",
+3 -2
View File
@@ -154,10 +154,11 @@ export function extractSourceFromPath(url) {
}
export function buildItemUrl(source, path) {
const encodedPath = encodePath(path);
if (getters.isShare()) {
return `/public/share/${state.shareInfo.hash}${path}`;
return `/public/share/${state.shareInfo.hash}${encodedPath}`;
}
return `/files/${source}${path}`;
return `/files/${source}${encodedPath}`;
}
export function encodedPath(path) {
@@ -1,6 +1,5 @@
import { test, expect, checkForNotification } from "../test-setup";
test("info from listing", async({ page, checkForErrors, context }) => {
await page.goto("/files/");
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
+9 -10
View File
@@ -1,4 +1,5 @@
import { Browser, firefox, expect, Page } from "@playwright/test";
import { openContextMenuHelper } from "./test-setup";
// Perform authentication and store auth state
async function globalSetup() {
@@ -57,14 +58,12 @@ async function globalSetup() {
localStorage.setItem('shareHashFile', hash);
}, shareHashFile);
// Create a share of folder "/share"
// Create a share of root folder "/"
await page.goto("http://127.0.0.1/files/playwright%20%2B%20files", { timeout: 500 });
await page.locator('a[aria-label="share"]').waitFor({ state: 'visible' });
await page.locator('a[aria-label="share"]').click({ button: "right" });
await page.locator('.selected-count-header').waitFor({ state: 'visible' });
await expect(page.locator('.selected-count-header')).toHaveText('1');
await openContextMenuHelper(page);
await page.locator('button[aria-label="Share"]').click();
await expect(page.locator('div[aria-label="share-path"]')).toHaveText('Path: /share/');
await expect(page.locator('div[aria-label="share-path"]')).toHaveText('Path: /');
// Toggle "Allow creating and uploading files and folders" setting
await page.locator('input[aria-label="allow creating and uploading files and folders toggle"]').waitFor({ state: 'attached' });
await page.locator('input[aria-label="allow creating and uploading files and folders toggle"] + .slider').click();
@@ -75,14 +74,14 @@ async function globalSetup() {
await page.locator('button[aria-label="Share-Confirm"]').click();
await expect(page.locator("div[aria-label='share-prompt'] .card-content table tbody tr:not(:has(th))")).toHaveCount(1);
const shareHashShare = await page.locator("div[aria-label='share-prompt'] .card-content table tbody tr:not(:has(th)) td").first().textContent();
if (!shareHashShare) {
throw new Error("Failed to retrieve shareHash");
const rootShareHash = await page.locator("div[aria-label='share-prompt'] .card-content table tbody tr:not(:has(th)) td").first().textContent();
if (!rootShareHash) {
throw new Error("Failed to retrieve rootShareHash");
}
// Store shareHash in localStorage
await page.evaluate((hash) => {
localStorage.setItem('shareHashShare', hash);
}, shareHashShare);
localStorage.setItem('rootShareHash', hash);
}, rootShareHash);
await context.storageState({ path: "./loginAuth.json" });
await browser.close();
@@ -138,4 +138,33 @@ test("rename file", async({ page, checkForErrors, context }) => {
await page.locator('#main-input').fill('renameme.txt');
await expect(page.locator('#result-list ul li.search-entry')).toHaveCount(0);
checkForErrors();
})
test("create a file with the same name as a directory", async({ page, checkForErrors, openContextMenu, context }) => {
await page.goto("/files/");
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
await openContextMenu();
await page.locator('button[aria-label="New file"]').click();
await page.locator('button[aria-label="Create"]').click();
await page.locator('input[aria-label="FileName Field"]').waitFor({ state: 'visible' });
await page.locator('input[aria-label="FileName Field"]').fill('mytest');
await page.locator('button[aria-label="Create"]').click();
await page.locator('a[aria-label="mytest"]').waitFor({ state: 'visible' });
await page.locator('a[aria-label="mytest"]').click({ button: "right" });
await page.locator('.selected-count-header').waitFor({ state: 'visible' });
await expect(page.locator('.selected-count-header')).toHaveText('1');
await page.locator('button[aria-label="Delete"]').click();
await expect(page.locator('.card-content')).toHaveText('Are you sure you want to delete this file/folder?/mytest');
await expect(page.locator('div[aria-label="delete-path"]')).toHaveText('/mytest');
await page.locator('button[aria-label="Confirm-Delete"]').click();
await checkForNotification(page, "Deleted successfully!");
await openContextMenu();
await page.locator('button[aria-label="New folder"]').click();
await page.locator('input[aria-label="New Folder Name"]').waitFor({ state: 'visible' });
await page.locator('input[aria-label="New Folder Name"]').fill('mytest');
await page.locator('button[aria-label="Create"]').click();
await page.locator('a[aria-label="mytest"]').waitFor({ state: 'visible' });
await page.locator('a[aria-label="mytest"]').dblclick();
await expect(page).toHaveTitle("Graham's Filebrowser - Files - mytest");
checkForErrors();
})
@@ -61,4 +61,4 @@ test("share download single file", async ({ page, checkForErrors, context }) =>
await page.locator('button[aria-label="Download"]').click();
await checkForNotification(page, "Downloading...");
checkForErrors(0,1); // redirect errors are expected
});
});
@@ -0,0 +1,41 @@
import { test, expect, checkForNotification } from '../test-setup'
test("access rules - deny folder does not show in folder listing", async ({ page, checkForErrors, context }) => {
await page.goto("/files/access");
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
// expect the excluded folder to not be visible
await expect(page.locator('div[aria-label="excluded"]')).toBeHidden();
checkForErrors();
});
test("access rules - deny folder has access denied message", async ({ page, checkForErrors, context }) => {
await page.goto("/files/access");
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
const msg = "403: access denied"
await checkForNotification(page, msg);
// expect the excluded folder to have an access denied message
checkForErrors(1,1); // expect 1 api error
});
test("navigate from search item", async({ page, checkForErrors, context }) => {
await page.goto("/files/");
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
await page.locator('#search').click()
await page.locator('#search-sources-dropdown').click()
await page.locator('#search-sources-dropdown option[value="access"]').click()
await page.locator('#main-input').fill('showme.txt');
await expect(page.locator('#result-list')).toHaveCount(0);
checkForErrors()
});
test("share access controls exist", async ({ page, checkForErrors, context }) => {
const rootShareHash = await page.evaluate(() => localStorage.getItem('rootShareHash'));
if (!rootShareHash) {
throw new Error("Share hash not found in localStorage");
}
await page.goto("/files/share/" + rootShareHash );
await expect(page).toHaveTitle("Graham's Filebrowser - Share - playwright-files");
// expect the excluded folder to not be visible
await expect(page.locator('div[aria-label="excluded"]')).toBeHidden();
checkForErrors();
});
@@ -4,7 +4,7 @@ test("check default sidebar links are added to sidebar", async ({ page, checkFor
await page.goto("/files/");
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
// sidebar should have two items
await expect(page.locator('.sidebar-links .inner-card').locator('a')).toHaveCount(2);
await expect(page.locator('.sidebar-links .inner-card').locator('a')).toHaveCount(3);
// check items exist
await page.locator('a[aria-label="playwright + files"]').waitFor({ state: 'visible' });
@@ -55,15 +55,15 @@ test("share private source", async ({ page, checkForErrors, openContextMenu }) =
});
test("share file creation", async ({ page, checkForErrors, openContextMenu }) => {
test("share file creation actions", async ({ page, checkForErrors, openContextMenu }) => {
await page.goto("/files/");
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
const shareHashShare = await page.evaluate(() => localStorage.getItem('shareHashShare'));
if (shareHashShare == "") {
const rootShareHash = await page.evaluate(() => localStorage.getItem('rootShareHash'));
if (rootShareHash == "") {
throw new Error("Share hash not found in localStorage");
}
await page.goto("public/share/" + shareHashShare);
await expect(page).toHaveTitle("Graham's Filebrowser - Share - share");
await page.goto("public/share/" + rootShareHash);
await expect(page).toHaveTitle("Graham's Filebrowser - Share - playwright-files");
await page.waitForTimeout(1000);
await openContextMenu();
await page.locator('button[aria-label="New file"]').click();
@@ -71,7 +71,7 @@ test("share file creation", async ({ page, checkForErrors, openContextMenu }) =>
await page.locator('input[aria-label="FileName Field"]').fill('dfsaf.txt');
await page.locator('button[aria-label="Create"]').click();
// Note: Share links don't show the "go to item" button, so no need to click notification
await expect(page).toHaveTitle("Graham's Filebrowser - Share - share");
await expect(page).toHaveTitle("Graham's Filebrowser - Share - playwright-files");
await page.locator('a[aria-label="dfsaf.txt"]').dblclick();
await expect(page).toHaveTitle("Graham's Filebrowser - Share - dfsaf.txt");
await page.locator(".ace_content").click();
+10 -2
View File
@@ -1,5 +1,14 @@
import { test as base, expect, Page } from "@playwright/test";
/**
* Standalone helper function to open the context menu (File-Actions button)
* Can be used in both test fixtures and global setup
*/
export async function openContextMenuHelper(page: Page): Promise<void> {
await page.locator('button[aria-label="File-Actions"]').waitFor({ state: 'visible' });
await page.locator('button[aria-label="File-Actions"]').click();
}
export const test = base.extend<{
checkForErrors: (expectedConsoleErrors?: number, expectedApiErrors?: number) => void;
openContextMenu: () => Promise<void>;
@@ -12,8 +21,7 @@ export const test = base.extend<{
},
openContextMenu: async ({ page }, use) => {
await use(async () => {
await page.locator('button[aria-label="File-Actions"]').waitFor({ state: 'visible' });
await page.locator('button[aria-label="File-Actions"]').click();
await openContextMenuHelper(page);
});
},
theme: async ({}, use, testInfo) => {
+1 -1
View File
@@ -108,9 +108,9 @@ test-frontend:
test-playwright: build-frontend
cd backend && GOOS=linux go build -o filebrowser .
docker build -t filebrowser-playwright-tests -f _docker/Dockerfile.playwright-settings .
docker build -t filebrowser-playwright-tests -f _docker/Dockerfile.playwright-noauth .
docker build -t filebrowser-playwright-tests -f _docker/Dockerfile.playwright-no-config .
docker build -t filebrowser-playwright-tests -f _docker/Dockerfile.playwright-settings .
docker build -t filebrowser-playwright-tests -f _docker/Dockerfile.playwright-general .
docker build -t filebrowser-playwright-tests -f _docker/Dockerfile.playwright-sharing .
docker build -t filebrowser-playwright-tests -f _docker/Dockerfile.playwright-proxy .