mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-04 16:50:08 -05:00
Compare commits
12
Commits
main
...
v1.1.4-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98ba8095db | ||
|
|
68fb573059 | ||
|
|
5747c40dca | ||
|
|
598bd366db | ||
|
|
e3be3300dc | ||
|
|
416aa75446 | ||
|
|
cfb3b9e869 | ||
|
|
166f1bf232 | ||
|
|
451f9ac69f | ||
|
|
b2fa1780ed | ||
|
|
9255bc174b | ||
|
|
a3a074d7b6 |
No files matched your search
@@ -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**:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -230,7 +230,9 @@
|
||||
"showOptions": "عرض الخيارات",
|
||||
"searchContext": "سياق البحث: {context}",
|
||||
"notEnoughCharacters": "أحرف غير كافية للبحث (الحد الأدنى {minSearchLength})",
|
||||
"typeToSearch": "ابدأ الكتابة {minSearchLength} أو أكثر لبدء البحث."
|
||||
"typeToSearch": "ابدأ الكتابة {minSearchLength} أو أكثر لبدء البحث.",
|
||||
"showPreviewImages": "عرض صور المعاينة",
|
||||
"showPreviewImagesDescription": "عرض معاينات الصور المصغرة لنتائج البحث عند توفرها، وإظهار أيقونات أكبر لجميع النتائج."
|
||||
},
|
||||
"settings": {
|
||||
"administrator": "Administrator",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -233,7 +233,9 @@
|
||||
"showOptions": "Εμφάνιση επιλογών",
|
||||
"searchContext": "Πλαίσιο αναζήτησης: {context}",
|
||||
"notEnoughCharacters": "Δεν υπάρχουν αρκετοί χαρακτήρες για αναζήτηση (min {minSearchLength})",
|
||||
"typeToSearch": "Αρχίστε να πληκτρολογείτε {minSearchLength} ή περισσότερους χαρακτήρες για να ξεκινήσετε την αναζήτηση."
|
||||
"typeToSearch": "Αρχίστε να πληκτρολογείτε {minSearchLength} ή περισσότερους χαρακτήρες για να ξεκινήσετε την αναζήτηση.",
|
||||
"showPreviewImages": "Εμφάνιση εικόνων προεπισκόπησης",
|
||||
"showPreviewImagesDescription": "Εμφάνιση προεπισκοπήσεων μικρογραφιών για τα αποτελέσματα αναζήτησης, όταν είναι διαθέσιμα, και εμφάνιση μεγαλύτερων εικονιδίων για όλα τα αποτελέσματα."
|
||||
},
|
||||
"settings": {
|
||||
"administrator": "Διαχειριστής",
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -230,7 +230,9 @@
|
||||
"showOptions": "הצג אפשרויות",
|
||||
"searchContext": "הקשר החיפוש: {context}",
|
||||
"notEnoughCharacters": "אין מספיק תווים לחיפוש (מינימום {minSearchLength})",
|
||||
"typeToSearch": "התחל להקליד {minSearchLength} או יותר תווים כדי להתחיל בחיפוש."
|
||||
"typeToSearch": "התחל להקליד {minSearchLength} או יותר תווים כדי להתחיל בחיפוש.",
|
||||
"showPreviewImages": "הצג תמונות מקדימות",
|
||||
"showPreviewImagesDescription": "הצג תצוגה מקדימה של תמונות ממוזערות עבור תוצאות החיפוש, כאשר הן זמינות, והצג סמלים גדולים יותר עבור כל התוצאות."
|
||||
},
|
||||
"settings": {
|
||||
"administrator": "מנהל ראשי",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -230,7 +230,9 @@
|
||||
"showOptions": "オプションを表示",
|
||||
"searchContext": "検索コンテキスト:{context}",
|
||||
"notEnoughCharacters": "検索する文字数が不足しています(最小:{minSearchLength}文字)",
|
||||
"typeToSearch": "検索を開始するには、{minSearchLength}文字以上を入力してください。"
|
||||
"typeToSearch": "検索を開始するには、{minSearchLength}文字以上を入力してください。",
|
||||
"showPreviewImages": "プレビュー画像を表示する",
|
||||
"showPreviewImagesDescription": "検索結果のサムネイルプレビューを表示し、すべての検索結果に対して大きなアイコンを表示します。"
|
||||
},
|
||||
"settings": {
|
||||
"administrator": "管理者",
|
||||
|
||||
@@ -230,7 +230,9 @@
|
||||
"showOptions": "옵션 표시",
|
||||
"searchContext": "컨텍스트 검색: {context}",
|
||||
"notEnoughCharacters": "검색할 문자가 부족합니다(최소 {minSearchLength})",
|
||||
"typeToSearch": "검색을 시작하려면 {minSearchLength} 을 입력하세요."
|
||||
"typeToSearch": "검색을 시작하려면 {minSearchLength} 을 입력하세요.",
|
||||
"showPreviewImages": "미리 보기 이미지 표시",
|
||||
"showPreviewImagesDescription": "가능한 경우 검색 결과의 썸네일 미리 보기를 표시하고 모든 결과에 대해 더 큰 아이콘을 표시합니다."
|
||||
},
|
||||
"settings": {
|
||||
"administrator": "관리자",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -230,7 +230,9 @@
|
||||
"showOptions": "Показать параметры",
|
||||
"searchContext": "Контекст поиска: {context}",
|
||||
"notEnoughCharacters": "Недостаточно символов для поиска (мин. {minSearchLength})",
|
||||
"typeToSearch": "Начните вводить {minSearchLength} или более символов, чтобы начать поиск."
|
||||
"typeToSearch": "Начните вводить {minSearchLength} или более символов, чтобы начать поиск.",
|
||||
"showPreviewImages": "Показать изображения для предварительного просмотра",
|
||||
"showPreviewImagesDescription": "Показывайте миниатюры для результатов поиска, если они доступны, и отображайте более крупные значки для всех результатов."
|
||||
},
|
||||
"settings": {
|
||||
"administrator": "Администратор",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -230,7 +230,9 @@
|
||||
"showOptions": "Показати параметри",
|
||||
"searchContext": "Пошуковий контекст: {context}",
|
||||
"notEnoughCharacters": "Недостатньо символів для пошуку (мінімум {minSearchLength})",
|
||||
"typeToSearch": "Почніть вводити {minSearchLength} або більше символів, щоб почати пошук."
|
||||
"typeToSearch": "Почніть вводити {minSearchLength} або більше символів, щоб почати пошук.",
|
||||
"showPreviewImages": "Показати попередній перегляд зображень",
|
||||
"showPreviewImagesDescription": "Відображати попередній перегляд мініатюр для результатів пошуку, якщо вони доступні, і показувати більші іконки для всіх результатів."
|
||||
},
|
||||
"settings": {
|
||||
"administrator": "Адміністратор",
|
||||
|
||||
@@ -230,7 +230,9 @@
|
||||
"showOptions": "显示选项",
|
||||
"searchContext": "搜索上下文:{context}",
|
||||
"notEnoughCharacters": "搜索字符数不足(最少{minSearchLength})",
|
||||
"typeToSearch": "开始键入{minSearchLength} 或更多字符开始搜索。"
|
||||
"typeToSearch": "开始键入{minSearchLength} 或更多字符开始搜索。",
|
||||
"showPreviewImages": "显示预览图像",
|
||||
"showPreviewImagesDescription": "在可用时显示搜索结果的缩略图预览,并为所有结果显示更大的图标。"
|
||||
},
|
||||
"settings": {
|
||||
"scopes": "定义允许的来源及其中的用户范围",
|
||||
|
||||
@@ -230,7 +230,9 @@
|
||||
"showOptions": "顯示選項",
|
||||
"searchContext": "搜尋範圍:{context}",
|
||||
"notEnoughCharacters": "搜尋字元不足 (最少{minSearchLength})",
|
||||
"typeToSearch": "開始輸入{minSearchLength} 或更多字元以開始搜尋。"
|
||||
"typeToSearch": "開始輸入{minSearchLength} 或更多字元以開始搜尋。",
|
||||
"showPreviewImages": "顯示預覽影像",
|
||||
"showPreviewImagesDescription": "在可用的情況下,顯示搜尋結果的縮圖預覽,並顯示所有結果的較大圖示。"
|
||||
},
|
||||
"settings": {
|
||||
"administrator": "管理員",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 .
|
||||
|
||||
Reference in new issue
Block a user