Compare commits
14
Commits
main
...
v0.8.7-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c99c82b9d9 | ||
|
|
a2246c85f1 | ||
|
|
7f037746cd | ||
|
|
973a0782f0 | ||
|
|
23460a0008 | ||
|
|
d823c82d47 | ||
|
|
771237ecf7 | ||
|
|
a119d20285 | ||
|
|
465cccf030 | ||
|
|
d4e2fe0bac | ||
|
|
1f0868a8f7 | ||
|
|
7a57e11a49 | ||
|
|
e0e464cca5 | ||
|
|
bbc087a4a5 |
No files matched your search
@@ -2,6 +2,29 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
|
||||
|
||||
## v0.8.7-beta
|
||||
|
||||
**New Features**:
|
||||
- json logging format support
|
||||
|
||||
**Notes**:
|
||||
- increased overall file size limit to retrieve metadata from 50MB to 300MB #1234
|
||||
- document previews don't show up as folder previews.
|
||||
|
||||
**BugFixes**:
|
||||
- Fix directories empty issue when disableIndexing is true #1249
|
||||
- OIDC to Authentik behind CloudFlare with Bot Fight turned on triggers a JS challenge #1165
|
||||
- OIDC Auth - loop with expired token #995
|
||||
- We lost the ability for download all? #1250
|
||||
- Directories are all empty when disableIndexing is true #1248
|
||||
- Access Management: issues on the shared folder when there's a sub folder #1208
|
||||
- user preferences for individual preview file types not getting respected (disable/enable office,image,video)
|
||||
- High server load when browsing folders with .mkv files #1259
|
||||
- 2FA can not be disabled with non-admin user #1244
|
||||
- 2FA not working on login #1243
|
||||
- Thumbnails of .m4a, .flac, and .opus are not displaying #1234
|
||||
- creating files/folders with same name overwrite each other #1242
|
||||
|
||||
## v0.8.6-beta
|
||||
|
||||
**Notes**:
|
||||
|
||||
@@ -11,6 +11,7 @@ server:
|
||||
- path: "."
|
||||
name: "docker"
|
||||
config:
|
||||
disableIndexing: true
|
||||
defaultEnabled: true
|
||||
frontend:
|
||||
name: "Graham's Filebrowser"
|
||||
|
||||
@@ -30,6 +30,9 @@ func FileInfoFaster(opts iteminfo.FileOptions) (*iteminfo.ExtendedFileInfo, erro
|
||||
if index == nil {
|
||||
return response, fmt.Errorf("could not get index: %v ", opts.Source)
|
||||
}
|
||||
if !strings.HasPrefix(opts.Path, "/") {
|
||||
opts.Path = "/" + opts.Path
|
||||
}
|
||||
realPath, isDir, err := index.GetRealPath(opts.Path)
|
||||
if err != nil {
|
||||
return response, fmt.Errorf("could not get real path for requested path: %v", opts.Path)
|
||||
@@ -150,7 +153,7 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index) {
|
||||
}
|
||||
|
||||
func generateOfficeId(realPath string) string {
|
||||
key, ok := utils.OnlyOfficeCache.Get(realPath).(string)
|
||||
key, ok := utils.OnlyOfficeCache.Get(realPath)
|
||||
if !ok {
|
||||
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||
documentKey := utils.HashSHA256(realPath + timestamp)
|
||||
@@ -174,10 +177,10 @@ func extractAudioMetadata(item *iteminfo.ExtendedFileInfo) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip files larger than 50MB to prevent memory issues
|
||||
maxSize := int64(50)
|
||||
// Skip files larger than 300MB to prevent memory issues
|
||||
maxSize := int64(300)
|
||||
if fileInfo.Size() > maxSize*1024*1024 {
|
||||
return fmt.Errorf("file exceeds metadata check limit: %d MB", maxSize)
|
||||
return fmt.Errorf("file with size %d MB exceeds metadata check limit: %d MB", fileInfo.Size()/1024/1024, maxSize)
|
||||
}
|
||||
|
||||
m, err := tag.ReadFrom(file)
|
||||
|
||||
@@ -20,8 +20,6 @@ func init() {
|
||||
type Auther interface {
|
||||
// Auth is called to authenticate a request.
|
||||
Auth(r *http.Request, userStore *users.Storage) (*users.User, error)
|
||||
// LoginPage indicates if this auther needs a login page.
|
||||
LoginPage() bool
|
||||
}
|
||||
|
||||
func IsRevokedApiKey(key string) bool {
|
||||
|
||||
@@ -65,11 +65,6 @@ func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.U
|
||||
|
||||
}
|
||||
|
||||
// LoginPage tells that json auth doesn't require a login page.
|
||||
func (a JSONAuth) LoginPage() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
const reCaptchaAPI = "/recaptcha/api/siteverify"
|
||||
|
||||
// ReCaptcha identifies a recaptcha connection.
|
||||
|
||||
@@ -16,8 +16,3 @@ type NoAuth struct{}
|
||||
func (a NoAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
|
||||
return usr.Get(uint(1))
|
||||
}
|
||||
|
||||
// LoginPage tells that no auth doesn't require a login page.
|
||||
func (a NoAuth) LoginPage() bool {
|
||||
return false
|
||||
}
|
||||
@@ -26,8 +26,3 @@ func (a ProxyAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error
|
||||
|
||||
return user, err
|
||||
}
|
||||
|
||||
// LoginPage tells that proxy auth doesn't require a login page.
|
||||
func (a ProxyAuth) LoginPage() bool {
|
||||
return false
|
||||
}
|
||||
@@ -48,7 +48,7 @@ var (
|
||||
// (2*2 + 1) * 30s = 150s (2.5 minutes). This is the closest we can get.
|
||||
TOTPSkew uint = uint(TokenValidTime.Seconds()) / (2 * uint(TOTPPeriod))
|
||||
encryptionKey []byte
|
||||
TotpCache = cache.NewCache(5 * time.Minute)
|
||||
TotpCache = cache.NewCache[string](5 * time.Minute)
|
||||
)
|
||||
|
||||
func GenerateOtpForUser(user *users.User, userStore *users.Storage) (string, error) {
|
||||
@@ -145,7 +145,7 @@ func decryptSecret(b64Ciphertext, b64Nonce string) (string, error) {
|
||||
|
||||
func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) error {
|
||||
// get data from cache
|
||||
cachedSecret, found := TotpCache.Get(user.Username).(string)
|
||||
cachedSecret, found := TotpCache.Get(user.Username)
|
||||
if !found && user.TOTPSecret == "" {
|
||||
return fmt.Errorf("OTP token not found in cache, please generate a new one")
|
||||
}
|
||||
@@ -191,7 +191,7 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
|
||||
user.TOTPNonce = totpNonce // The nonce if encrypted, or empty if plaintext
|
||||
user.OtpEnabled = true // Enable OTP for the user
|
||||
// save user
|
||||
if err := userStore.Update(user, true, "TOTPSecret", "TOTPNonce"); err != nil {
|
||||
if err := userStore.Update(user, true, "TOTPSecret", "TOTPNonce", "OtpEnabled"); err != nil {
|
||||
logger.Error("error updating user with OTP token:", err)
|
||||
return fmt.Errorf("error updating user with OTP token: %w", err)
|
||||
}
|
||||
|
||||
+1
-1
@@ -92,10 +92,10 @@ func StartFilebrowser() {
|
||||
}
|
||||
logger.Infof("Initializing FileBrowser Quantum (%v)", version.Version)
|
||||
logger.Infof("Using Config file : %v", configPath)
|
||||
logger.Infof("Using Embedded FS : %v", settings.Config.Server.EmbeddedFs)
|
||||
logger.Infof("Auth Methods : %v", settings.Config.Auth.AuthMethods)
|
||||
logger.Info(database)
|
||||
logger.Infof("Sources : %v", sourceList)
|
||||
logger.Debugf("Using Embedded FS : %v", settings.Config.Server.EmbeddedFs)
|
||||
|
||||
serverConfig := settings.Config.Server
|
||||
swagInfo := docs.SwaggerInfo
|
||||
|
||||
@@ -8,9 +8,27 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/version"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// userAgentTransport wraps an http.RoundTripper to add a User-Agent header
|
||||
type userAgentTransport struct {
|
||||
base http.RoundTripper
|
||||
userAgent string
|
||||
}
|
||||
|
||||
// RoundTrip implements the http.RoundTripper interface
|
||||
func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
// Clone the request to avoid modifying the original
|
||||
reqCopy := req.Clone(req.Context())
|
||||
// Set User-Agent header if not already set
|
||||
if reqCopy.Header.Get("User-Agent") == "" {
|
||||
reqCopy.Header.Set("User-Agent", t.userAgent)
|
||||
}
|
||||
return t.base.RoundTrip(reqCopy)
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
TokenExpirationHours int `json:"tokenExpirationHours"` // time in hours each web UI session token is valid for. Default is 2 hours.
|
||||
Methods LoginMethods `json:"methods"`
|
||||
@@ -84,22 +102,26 @@ func validateOidcAuth() error {
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
// If disableVerifyTLS is true, create a custom HTTP client
|
||||
// and set it in the context for the OIDC provider.
|
||||
// Create a custom HTTP client with proper User-Agent to avoid being blocked by bot protection
|
||||
transport := &http.Transport{}
|
||||
|
||||
// If disableVerifyTLS is true, disable TLS verification
|
||||
if oidcCfg.DisableVerifyTLS {
|
||||
// Create a custom transport with InsecureSkipVerify set to true
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
customClient := &http.Client{
|
||||
Transport: transport,
|
||||
}
|
||||
// Use oidc.ClientContext to pass the custom client to the OIDC library
|
||||
ctx = oidc.ClientContext(ctx, customClient)
|
||||
// Log that TLS verification is disabled (important for security awareness)
|
||||
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
logger.Warning("OIDC TLS verification is disabled.")
|
||||
}
|
||||
|
||||
// Create custom client with User-Agent header to bypass bot protection (like Cloudflare Bot Fight Mode)
|
||||
customClient := &http.Client{
|
||||
Transport: &userAgentTransport{
|
||||
base: transport,
|
||||
userAgent: fmt.Sprintf("FileBrowser Quantum - %s (OIDC Client)", version.Version),
|
||||
},
|
||||
}
|
||||
|
||||
// Use oidc.ClientContext to pass the custom client to the OIDC library
|
||||
ctx = oidc.ClientContext(ctx, customClient)
|
||||
|
||||
provider, err := oidc.NewProvider(ctx, oidcCfg.IssuerUrl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("url '%v' failed to create OIDC provider: %w", oidcCfg.IssuerUrl, err)
|
||||
|
||||
@@ -243,14 +243,15 @@ func setupLogging() {
|
||||
}
|
||||
|
||||
logConfig := logger.JsonConfig{
|
||||
Levels: levels,
|
||||
ApiLevels: logConfig.ApiLevels,
|
||||
Output: logConfig.Output,
|
||||
Utc: logConfig.Utc,
|
||||
NoColors: logConfig.NoColors,
|
||||
Json: logConfig.Json,
|
||||
Levels: levels,
|
||||
ApiLevels: logConfig.ApiLevels,
|
||||
Output: logConfig.Output,
|
||||
Utc: logConfig.Utc,
|
||||
NoColors: logConfig.NoColors,
|
||||
Json: logConfig.Json,
|
||||
Structured: false,
|
||||
}
|
||||
err := logger.SetupLogger(logConfig)
|
||||
err := logger.EnableCompatibilityMode(logConfig)
|
||||
if err != nil {
|
||||
log.Println("[ERROR] Failed to set up logger:", err)
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ type LogConfig struct {
|
||||
ApiLevels string `json:"apiLevels" yaml:"apiLevels"` // separated list of log levels to enable for the API. (eg. "info|warning|error")
|
||||
Output string `json:"output" yaml:"output"` // output location. (eg. "stdout" or "path/to/file.log")
|
||||
NoColors bool `json:"noColors" yaml:"noColors"` // disable colors in the output
|
||||
Json bool `json:"json" yaml:"json"` // output in json format, currently not supported
|
||||
Json bool `json:"json" yaml:"json"` // output in json format
|
||||
Utc bool `json:"utc" yaml:"utc"` // use UTC time in the output instead of local time
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,6 @@ func readCustomCSS(path string) string {
|
||||
logger.Warningf("Custom CSS file %s is too large (%d bytes), ignoring", path, len(content))
|
||||
return ""
|
||||
}
|
||||
logger.Infof("Loaded custom CSS from: %s (%d bytes)", path, len(content))
|
||||
logger.Debugf("Loaded custom CSS from: %s (%d bytes)", path, len(content))
|
||||
return string(content)
|
||||
}
|
||||
@@ -7,10 +7,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
DiskUsageCache = cache.NewCache(30*time.Second, 24*time.Hour)
|
||||
RealPathCache = cache.NewCache(48*time.Hour, 72*time.Hour)
|
||||
SearchResultsCache = cache.NewCache(15*time.Second, 1*time.Hour)
|
||||
OnlyOfficeCache = cache.NewCache(48*time.Hour, 1*time.Hour)
|
||||
JwtCache = cache.NewCache(1*time.Hour, 72*time.Hour)
|
||||
MediaCache = cache.NewCache(24 * time.Hour) // subtitles get cached for 24 hours
|
||||
DiskUsageCache = cache.NewCache[bool](30*time.Second, 24*time.Hour)
|
||||
RealPathCache = cache.NewCache[string](48*time.Hour, 72*time.Hour)
|
||||
SearchResultsCache = cache.NewCache[string](15*time.Second, 1*time.Hour)
|
||||
OnlyOfficeCache = cache.NewCache[string](48*time.Hour, 1*time.Hour)
|
||||
JwtCache = cache.NewCache[string](1*time.Hour, 72*time.Hour)
|
||||
)
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"maps"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -18,7 +19,12 @@ import (
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
var accessCache = cache.NewCache(1 * time.Minute)
|
||||
var (
|
||||
accessCache = cache.NewCache[string](1 * time.Minute) // for accessChangedKey
|
||||
versionCache = cache.NewCache[int](1 * time.Minute) // for version keys
|
||||
permissionCache = cache.NewCache[bool](1 * time.Minute) // for permission keys
|
||||
rulesCache = cache.NewCache[map[string]FrontendAccessRule](1 * time.Minute) // for rules
|
||||
)
|
||||
|
||||
const accessRulesBucket = "access_rules"
|
||||
const accessRulesKey = "rules"
|
||||
@@ -138,8 +144,11 @@ func NewStorage(db *storm.DB, usersStore *users.Storage) *Storage {
|
||||
|
||||
// ClearCache clears the access cache (useful for testing)
|
||||
func ClearCache() {
|
||||
// Recreate the cache to clear it
|
||||
accessCache = cache.NewCache(1 * time.Minute)
|
||||
// Recreate the caches to clear them
|
||||
accessCache = cache.NewCache[string](1 * time.Minute)
|
||||
versionCache = cache.NewCache[int](1 * time.Minute)
|
||||
permissionCache = cache.NewCache[bool](1 * time.Minute)
|
||||
rulesCache = cache.NewCache[map[string]FrontendAccessRule](1 * time.Minute)
|
||||
}
|
||||
|
||||
// getOrCreateRuleNL ensures a rule exists for the given source and index path.
|
||||
@@ -176,7 +185,7 @@ func (s *Storage) DenyUser(sourcePath, indexPath, username string) error {
|
||||
}
|
||||
rule.Deny.Users[username] = struct{}{}
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
accessCache.Set(accessChangedKey+sourcePath, false)
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
return s.SaveToDB()
|
||||
}
|
||||
|
||||
@@ -196,7 +205,7 @@ func (s *Storage) AllowUser(sourcePath, indexPath, username string) error {
|
||||
}
|
||||
rule.Allow.Users[username] = struct{}{}
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
accessCache.Set(accessChangedKey+sourcePath, false)
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
return s.SaveToDB()
|
||||
}
|
||||
|
||||
@@ -214,7 +223,7 @@ func (s *Storage) DenyGroup(sourcePath, indexPath, groupname string) error {
|
||||
}
|
||||
rule.Deny.Groups[groupname] = struct{}{}
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
accessCache.Set(accessChangedKey+sourcePath, false)
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
return s.SaveToDB()
|
||||
}
|
||||
|
||||
@@ -232,7 +241,7 @@ func (s *Storage) AllowGroup(sourcePath, indexPath, groupname string) error {
|
||||
}
|
||||
rule.Allow.Groups[groupname] = struct{}{}
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
accessCache.Set(accessChangedKey+sourcePath, false)
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
return s.SaveToDB()
|
||||
}
|
||||
|
||||
@@ -246,29 +255,36 @@ func (s *Storage) DenyAll(sourcePath, indexPath string) error {
|
||||
}
|
||||
rule.DenyAll = true
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
accessCache.Set(accessChangedKey+sourcePath, false)
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
return s.SaveToDB()
|
||||
}
|
||||
|
||||
// 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
|
||||
if !strings.HasPrefix(indexPath, "/") {
|
||||
logger.Debugf("Access denied: path %q does not start with '/' (user: %s, source: %s)", indexPath, username, sourcePath)
|
||||
return false
|
||||
}
|
||||
|
||||
// Get current version for the sourcePath
|
||||
versionKey := "version:" + sourcePath
|
||||
version := 0
|
||||
if v, ok := accessCache.Get(versionKey).(int); ok {
|
||||
if v, ok := versionCache.Get(versionKey); ok {
|
||||
version = v
|
||||
}
|
||||
|
||||
// Check cache with versioned key
|
||||
permKey := fmt.Sprintf("perm:%s:%d:%s:%s", sourcePath, version, indexPath, username)
|
||||
if p, ok := accessCache.Get(permKey).(bool); ok {
|
||||
if p, ok := permissionCache.Get(permKey); ok {
|
||||
return p
|
||||
}
|
||||
|
||||
// Not in cache, compute, then cache it.
|
||||
result := s.computePermitted(sourcePath, indexPath, username)
|
||||
accessCache.Set(permKey, result)
|
||||
permissionCache.Set(permKey, result)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -486,7 +502,7 @@ func (s *Storage) GetFrontendRules(sourcePath, indexPath string) (FrontendAccess
|
||||
|
||||
// GetAllRules returns all access rules as a map.
|
||||
func (s *Storage) GetAllRules(sourcePath string) (map[string]FrontendAccessRule, error) {
|
||||
value, ok := accessCache.Get(accessChangedKey + sourcePath).(map[string]FrontendAccessRule)
|
||||
value, ok := rulesCache.Get(accessChangedKey + sourcePath)
|
||||
if ok {
|
||||
return value, nil
|
||||
}
|
||||
@@ -523,7 +539,7 @@ func (s *Storage) GetAllRules(sourcePath string) (map[string]FrontendAccessRule,
|
||||
}
|
||||
}
|
||||
// cache responses
|
||||
accessCache.Set(accessChangedKey+sourcePath, frontendRules)
|
||||
rulesCache.Set(accessChangedKey+sourcePath, frontendRules)
|
||||
return frontendRules, nil
|
||||
}
|
||||
|
||||
@@ -650,7 +666,7 @@ func (s *Storage) RemoveAllowUser(sourcePath, indexPath, username string) (bool,
|
||||
}
|
||||
}
|
||||
if removed {
|
||||
accessCache.Set(accessChangedKey+sourcePath, false)
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
return exists, s.SaveToDB()
|
||||
}
|
||||
return false, nil
|
||||
@@ -681,7 +697,7 @@ func (s *Storage) RemoveAllowGroup(sourcePath, indexPath, groupname string) (boo
|
||||
}
|
||||
}
|
||||
if removed {
|
||||
accessCache.Set(accessChangedKey+sourcePath, false)
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
return exists, s.SaveToDB()
|
||||
}
|
||||
return exists, nil
|
||||
@@ -712,7 +728,7 @@ func (s *Storage) RemoveDenyUser(sourcePath, indexPath, username string) (bool,
|
||||
}
|
||||
}
|
||||
if removed {
|
||||
accessCache.Set(accessChangedKey+sourcePath, false)
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
return exists, s.SaveToDB()
|
||||
}
|
||||
return false, nil
|
||||
@@ -743,7 +759,7 @@ func (s *Storage) RemoveDenyGroup(sourcePath, indexPath, groupname string) (bool
|
||||
}
|
||||
}
|
||||
if removed {
|
||||
accessCache.Set(accessChangedKey+sourcePath, false)
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
return exists, s.SaveToDB()
|
||||
}
|
||||
return exists, nil
|
||||
@@ -771,7 +787,7 @@ func (s *Storage) RemoveDenyAll(sourcePath, indexPath string) (bool, error) {
|
||||
}
|
||||
}
|
||||
if removed {
|
||||
accessCache.Set(accessChangedKey+sourcePath, false)
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
return true, s.SaveToDB()
|
||||
}
|
||||
return false, nil
|
||||
@@ -805,7 +821,7 @@ func (s *Storage) RemoveAllRulesForUser(username string) error {
|
||||
}
|
||||
for sp := range changedSourcePaths {
|
||||
s.incrementSourceVersion(sp)
|
||||
accessCache.Set(accessChangedKey+sp, false)
|
||||
accessCache.Set(accessChangedKey+sp, "false")
|
||||
}
|
||||
if changed {
|
||||
return s.SaveToDB()
|
||||
@@ -841,7 +857,7 @@ func (s *Storage) RemoveAllRulesForGroup(groupname string) error {
|
||||
}
|
||||
for sp := range changedSourcePaths {
|
||||
s.incrementSourceVersion(sp)
|
||||
accessCache.Set(accessChangedKey+sp, false)
|
||||
accessCache.Set(accessChangedKey+sp, "false")
|
||||
}
|
||||
if changed {
|
||||
return s.SaveToDB()
|
||||
@@ -1042,12 +1058,11 @@ func (s *Storage) GetAllRulesByGroups(sourcePath string) map[string]map[string]F
|
||||
}
|
||||
|
||||
// incrementSourceVersion increments the version of a sourcePath to invalidate caches.
|
||||
// The caller MUST hold the mutex lock.
|
||||
func (s *Storage) incrementSourceVersion(sourcePath string) {
|
||||
key := "version:" + sourcePath
|
||||
version := 0
|
||||
if v, ok := accessCache.Get(key).(int); ok {
|
||||
if v, ok := versionCache.Get(key); ok {
|
||||
version = v
|
||||
}
|
||||
accessCache.Set(key, version+1)
|
||||
versionCache.Set(key, version+1)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package access_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/asdine/storm/v3"
|
||||
@@ -479,3 +480,207 @@ func TestPermitted_DenyByDefault_AdminRootAccess(t *testing.T) {
|
||||
t.Error("bob should NOT be permitted for /test/sub")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUserReportedBug reproduces the exact scenario described by the user
|
||||
func TestUserReportedBug(t *testing.T) {
|
||||
access.ClearCache()
|
||||
s, userStore := createTestStorage(t)
|
||||
|
||||
// Create the exact users from user's report
|
||||
createTestUser(t, userStore, "testu1")
|
||||
createTestUser(t, userStore, "testu2")
|
||||
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Setup test configuration for TEST_FOLDER source
|
||||
originalSourceMap := settings.Config.Server.SourceMap
|
||||
defer func() {
|
||||
settings.Config.Server.SourceMap = originalSourceMap
|
||||
access.ClearCache()
|
||||
}()
|
||||
|
||||
settings.Config.Server.SourceMap = map[string]settings.Source{
|
||||
"TEST_FOLDER": {
|
||||
Path: "TEST_FOLDER",
|
||||
Name: "TEST_FOLDER",
|
||||
Config: settings.SourceConfig{
|
||||
DenyByDefault: false, // Allow by default
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// SCENARIO 1: Set up initial access rules exactly as described
|
||||
// testu1 is denied access to USER2_folder
|
||||
if err := s.DenyUser("TEST_FOLDER", "/USER2_folder", "testu1"); err != nil {
|
||||
t.Fatalf("DenyUser failed for testu1->USER2_folder: %v", err)
|
||||
}
|
||||
|
||||
// testu2 is denied access to USER1_folder
|
||||
if err := s.DenyUser("TEST_FOLDER", "/USER1_folder", "testu2"); err != nil {
|
||||
t.Fatalf("DenyUser failed for testu2->USER1_folder: %v", err)
|
||||
}
|
||||
|
||||
// VERIFY SCENARIO 1: Initial permissions work correctly
|
||||
if s.Permitted("TEST_FOLDER", "/USER2_folder", "testu1") {
|
||||
t.Error("SCENARIO 1 FAILED: testu1 should NOT be permitted to access USER2_folder")
|
||||
}
|
||||
if s.Permitted("TEST_FOLDER", "/USER1_folder", "testu2") {
|
||||
t.Error("SCENARIO 1 FAILED: testu2 should NOT be permitted to access USER1_folder")
|
||||
}
|
||||
|
||||
// Verify allowed access still works
|
||||
if !s.Permitted("TEST_FOLDER", "/USER1_folder", "testu1") {
|
||||
t.Error("SCENARIO 1 FAILED: testu1 SHOULD be permitted to access USER1_folder")
|
||||
}
|
||||
if !s.Permitted("TEST_FOLDER", "/USER2_folder", "testu2") {
|
||||
t.Error("SCENARIO 1 FAILED: testu2 SHOULD be permitted to access USER2_folder")
|
||||
}
|
||||
|
||||
t.Log("SCENARIO 1 PASSED: Initial access rules work correctly")
|
||||
|
||||
// SCENARIO 2: The bug should NOT happen with proper access control
|
||||
// testu2 should still be denied access regardless of filesystem changes
|
||||
if s.Permitted("TEST_FOLDER", "/USER1_folder", "testu2") {
|
||||
t.Fatal("BUG REPRODUCED: testu2 can now access USER1_folder!")
|
||||
}
|
||||
|
||||
t.Log("SCENARIO 2 PASSED: Access control rules work correctly")
|
||||
}
|
||||
|
||||
// TestSubfolderAccessLogicBug tests the theory that the bug is caused by subfolder access logic
|
||||
func TestSubfolderAccessLogicBug(t *testing.T) {
|
||||
access.ClearCache()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "testu1")
|
||||
createTestUser(t, userStore, "testu2")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
originalSourceMap := settings.Config.Server.SourceMap
|
||||
defer func() {
|
||||
settings.Config.Server.SourceMap = originalSourceMap
|
||||
access.ClearCache()
|
||||
}()
|
||||
|
||||
settings.Config.Server.SourceMap = map[string]settings.Source{
|
||||
"TEST_FOLDER": {
|
||||
Path: "TEST_FOLDER",
|
||||
Name: "TEST_FOLDER",
|
||||
Config: settings.SourceConfig{
|
||||
DenyByDefault: false, // This is key!
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Set up the exact scenario: testu2 denied access to /USER1_folder
|
||||
if err := s.DenyUser("TEST_FOLDER", "/USER1_folder", "testu2"); err != nil {
|
||||
t.Fatalf("DenyUser failed: %v", err)
|
||||
}
|
||||
|
||||
// TEST THEORY: What happens when we check testu2's access to a SUBFOLDER that has no explicit rule?
|
||||
|
||||
// Case 1: testu2 access to parent folder (should be denied)
|
||||
if s.Permitted("TEST_FOLDER", "/USER1_folder", "testu2") {
|
||||
t.Error("testu2 should be denied access to /USER1_folder")
|
||||
}
|
||||
|
||||
// Case 2: testu2 access to subfolder that doesn't have explicit deny rule
|
||||
// With DenyByDefault=false, this should be ALLOWED!
|
||||
if !s.Permitted("TEST_FOLDER", "/USER1_folder/test_folder", "testu2") {
|
||||
t.Log("testu2 is denied access to /USER1_folder/test_folder (subfolder)")
|
||||
} else {
|
||||
t.Log("testu2 HAS access to /USER1_folder/test_folder (subfolder) - this might be the bug!")
|
||||
}
|
||||
|
||||
// Case 3: Let's test with a deeply nested subfolder
|
||||
if s.Permitted("TEST_FOLDER", "/USER1_folder/sub1/sub2/deep", "testu2") {
|
||||
t.Log("testu2 HAS access to deep subfolder - confirming the pattern")
|
||||
}
|
||||
|
||||
// THE CRITICAL TEST: What about parent directory inheritance?
|
||||
// According to the access control docs, rules should apply recursively to subdirectories
|
||||
// If testu2 is denied access to /USER1_folder, they should also be denied access to ALL subdirectories
|
||||
|
||||
// This might be where the bug is - the recursive checking might not be working correctly
|
||||
}
|
||||
|
||||
// TestFileInfoBrowsingBug tests if the file browsing logic causes the access bug
|
||||
func TestFileInfoBrowsingBug(t *testing.T) {
|
||||
access.ClearCache()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "testu1")
|
||||
createTestUser(t, userStore, "testu2")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
originalSourceMap := settings.Config.Server.SourceMap
|
||||
defer func() {
|
||||
settings.Config.Server.SourceMap = originalSourceMap
|
||||
access.ClearCache()
|
||||
}()
|
||||
|
||||
settings.Config.Server.SourceMap = map[string]settings.Source{
|
||||
"TEST_FOLDER": {
|
||||
Path: "TEST_FOLDER",
|
||||
Name: "TEST_FOLDER",
|
||||
Config: settings.SourceConfig{
|
||||
DenyByDefault: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Set up: testu2 denied access to /USER1_folder
|
||||
if err := s.DenyUser("TEST_FOLDER", "/USER1_folder", "testu2"); err != nil {
|
||||
t.Fatalf("DenyUser failed: %v", err)
|
||||
}
|
||||
|
||||
// CRITICAL TEST: What if testu2 has access to a subfolder?
|
||||
// This tests the exact line: indexPath := info.Path + subFolder.Name
|
||||
|
||||
// Scenario A: Subfolder should inherit deny rule from parent (correct behavior)
|
||||
parentDenied := !s.Permitted("TEST_FOLDER", "/USER1_folder", "testu2")
|
||||
subfolderDenied := !s.Permitted("TEST_FOLDER", "/USER1_folder/test_folder", "testu2")
|
||||
|
||||
t.Logf("Parent denied: %v, Subfolder denied: %v", parentDenied, subfolderDenied)
|
||||
|
||||
if parentDenied && !subfolderDenied {
|
||||
t.Error("BUG FOUND: Parent denied but subfolder allowed - this would trigger hasPermittedPaths = true")
|
||||
}
|
||||
|
||||
// Scenario B: What if there's a path construction bug?
|
||||
// Maybe the path constructed in FileInfoFaster doesn't match the path used in access rules?
|
||||
|
||||
// Let's test different path formats that might be constructed
|
||||
testPaths := []string{
|
||||
"/USER1_folder/test_folder", // Normal path
|
||||
"/USER1_folder/test_folder/", // With trailing slash
|
||||
"USER1_folder/test_folder", // Without leading slash
|
||||
"/USER1_folder/test_folder", // info.Path + subFolder.Name simulation
|
||||
}
|
||||
|
||||
for _, testPath := range testPaths {
|
||||
permitted := s.Permitted("TEST_FOLDER", testPath, "testu2")
|
||||
t.Logf("Path %q permitted for testu2: %v", testPath, permitted)
|
||||
|
||||
// Paths without leading slash should ALWAYS be denied (security fix)
|
||||
if !strings.HasPrefix(testPath, "/") {
|
||||
if permitted {
|
||||
t.Errorf("SECURITY BUG: Path without leading slash %q should be denied but was permitted", testPath)
|
||||
} else {
|
||||
t.Logf("SECURITY FIX WORKING: Path without leading slash %q correctly denied", testPath)
|
||||
}
|
||||
} else {
|
||||
// Paths with leading slash should follow normal access rules (deny in this case)
|
||||
if permitted {
|
||||
t.Errorf("ACCESS CONTROL BUG: testu2 has access to %q despite being denied parent folder", testPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,10 +93,7 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
|
||||
}
|
||||
|
||||
if !actorIsAdmin {
|
||||
err := checkRestrictedFields(existingUser, fields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields = filterRestrictedFields(fields)
|
||||
}
|
||||
|
||||
if len(fields) == 0 {
|
||||
@@ -133,11 +130,11 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
|
||||
val := fieldValue.Interface()
|
||||
if field == "OtpEnabled" {
|
||||
otpEnabled, _ := val.(bool)
|
||||
if otpEnabled {
|
||||
continue
|
||||
if !otpEnabled {
|
||||
field = "TOTPSecret" // if otp is disabled, we also want to clear the TOTPSecret
|
||||
val = "" // clear the TOTPSecret
|
||||
}
|
||||
field = "TOTPSecret" // if otp is disabled, we also want to clear the TOTPSecret
|
||||
val = "" // clear the TOTPSecret
|
||||
// If otpEnabled is true, continue with normal field update
|
||||
}
|
||||
// Update the database
|
||||
if err := st.db.UpdateField(existingUser, field, val); err != nil {
|
||||
@@ -204,17 +201,19 @@ func (st usersBackend) DeleteByUsername(username string) error {
|
||||
return st.db.DeleteStruct(user)
|
||||
}
|
||||
|
||||
// Define a function to check for restricted fields
|
||||
func checkRestrictedFields(existingUser *users.User, fields []string) error {
|
||||
// Define a function to filter out restricted fields for non-admin users
|
||||
func filterRestrictedFields(fields []string) []string {
|
||||
// Get a list of allowed fields from NonAdminEditable
|
||||
allowed := getNonAdminEditableFieldNames()
|
||||
var filteredFields []string
|
||||
|
||||
for _, field := range fields {
|
||||
if !slices.Contains(allowed, field) {
|
||||
return fmt.Errorf("non-admins cannot modify field: %s", field)
|
||||
if slices.Contains(allowed, field) {
|
||||
filteredFields = append(filteredFields, field)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return filteredFields
|
||||
}
|
||||
|
||||
// Helper to return list of field names from NonAdminEditable struct
|
||||
@@ -242,12 +241,27 @@ func parseFields(user *users.User, fields []string, actorIsAdmin bool) ([]string
|
||||
field := t.Field(i)
|
||||
// which=all can't update password
|
||||
switch strings.ToLower(field.Name) {
|
||||
case "id", "username", "password", "apikeys", "totpenabled", "totpsecret", "totpnonce":
|
||||
case "id", "username", "password", "apikeys", "totpsecret", "totpnonce":
|
||||
// Skip these fields
|
||||
continue
|
||||
}
|
||||
|
||||
fields = append(fields, field.Name)
|
||||
// Handle embedded structs (like NonAdminEditable)
|
||||
if field.Anonymous {
|
||||
// Get the embedded struct type
|
||||
embeddedType := field.Type
|
||||
if embeddedType.Kind() == reflect.Ptr {
|
||||
embeddedType = embeddedType.Elem()
|
||||
}
|
||||
|
||||
// Add all fields from the embedded struct
|
||||
for j := 0; j < embeddedType.NumField(); j++ {
|
||||
embeddedField := embeddedType.Field(j)
|
||||
fields = append(fields, embeddedField.Name)
|
||||
}
|
||||
} else {
|
||||
fields = append(fields, field.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
newfields := []string{}
|
||||
@@ -259,18 +273,24 @@ func parseFields(user *users.User, fields []string, actorIsAdmin bool) ([]string
|
||||
}
|
||||
}
|
||||
if capitalField == "Password" {
|
||||
if user.LoginMethod != users.LoginMethodPassword {
|
||||
return nil, fmt.Errorf("password cannot be changed when login method is not password")
|
||||
// Only process password if it's actually being updated (not empty)
|
||||
if user.Password != "" {
|
||||
if user.LoginMethod != users.LoginMethodPassword {
|
||||
return nil, fmt.Errorf("password cannot be changed when login method is not password")
|
||||
}
|
||||
err := checkPassword(user.Password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("password does not meet complexity requirements")
|
||||
}
|
||||
value, err := users.HashPwd(user.Password)
|
||||
if err != nil {
|
||||
logger.Error(err.Error())
|
||||
}
|
||||
user.Password = value
|
||||
} else {
|
||||
// Skip password field if it's empty
|
||||
continue
|
||||
}
|
||||
err := checkPassword(user.Password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("password does not meet complexity requirements")
|
||||
}
|
||||
value, err := users.HashPwd(user.Password)
|
||||
if err != nil {
|
||||
logger.Error(err.Error())
|
||||
}
|
||||
user.Password = value
|
||||
}
|
||||
newfields = append(newfields, capitalField)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
package bolt
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/asdine/storm/v3"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
func createTestUsersBackend(t *testing.T) users.StorageBackend {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
db, err := storm.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open storm db: %v", err)
|
||||
}
|
||||
return NewUsersBackend(db)
|
||||
}
|
||||
|
||||
func createTestUser(t *testing.T, backend users.StorageBackend, username string, isAdmin bool) *users.User {
|
||||
user := &users.User{
|
||||
Username: username,
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
Password: "testpass123",
|
||||
DarkMode: false,
|
||||
OtpEnabled: true,
|
||||
Locale: "en",
|
||||
ViewMode: "normal",
|
||||
SingleClick: false,
|
||||
},
|
||||
Permissions: users.Permissions{
|
||||
Admin: isAdmin,
|
||||
},
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
}
|
||||
|
||||
err := backend.Save(user, true, false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user %s: %v", username, err)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func TestUpdate_AdminModifyOtherUser(t *testing.T) {
|
||||
backend := createTestUsersBackend(t)
|
||||
|
||||
// Create admin user
|
||||
_ = createTestUser(t, backend, "admin", true)
|
||||
|
||||
// Create regular user
|
||||
regularUser := createTestUser(t, backend, "regular", false)
|
||||
|
||||
// Admin modifies regular user
|
||||
updateData := &users.User{
|
||||
ID: regularUser.ID,
|
||||
Username: regularUser.Username,
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
DarkMode: true,
|
||||
OtpEnabled: false,
|
||||
Locale: "fr",
|
||||
},
|
||||
Permissions: users.Permissions{
|
||||
Admin: false, // Admin can modify permissions
|
||||
},
|
||||
}
|
||||
|
||||
// Test with which=all
|
||||
err := backend.Update(updateData, true, "all")
|
||||
if err != nil {
|
||||
t.Errorf("Admin should be able to modify other user with which=all: %v", err)
|
||||
}
|
||||
|
||||
// Verify changes were applied
|
||||
updatedUser, err := backend.GetBy(regularUser.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get updated user: %v", err)
|
||||
}
|
||||
|
||||
if !updatedUser.DarkMode {
|
||||
t.Error("DarkMode should have been updated to true")
|
||||
}
|
||||
if updatedUser.OtpEnabled {
|
||||
t.Error("OtpEnabled should have been updated to false")
|
||||
}
|
||||
if updatedUser.Locale != "fr" {
|
||||
t.Errorf("Locale should have been updated to 'fr', got '%s'", updatedUser.Locale)
|
||||
}
|
||||
|
||||
// Test with specific fields
|
||||
updateData2 := &users.User{
|
||||
ID: regularUser.ID,
|
||||
Username: regularUser.Username,
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
ViewMode: "list",
|
||||
},
|
||||
}
|
||||
|
||||
err = backend.Update(updateData2, true, "ViewMode")
|
||||
if err != nil {
|
||||
t.Errorf("Admin should be able to modify specific field: %v", err)
|
||||
}
|
||||
|
||||
updatedUser2, err := backend.GetBy(regularUser.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get updated user: %v", err)
|
||||
}
|
||||
|
||||
if updatedUser2.ViewMode != "list" {
|
||||
t.Errorf("ViewMode should have been updated to 'list', got '%s'", updatedUser2.ViewMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_AdminModifySelf(t *testing.T) {
|
||||
backend := createTestUsersBackend(t)
|
||||
|
||||
// Create admin user
|
||||
admin := createTestUser(t, backend, "admin", true)
|
||||
|
||||
// Admin modifies themselves
|
||||
updateData := &users.User{
|
||||
ID: admin.ID,
|
||||
Username: admin.Username,
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
DarkMode: true,
|
||||
OtpEnabled: false,
|
||||
Locale: "de",
|
||||
},
|
||||
}
|
||||
|
||||
// Test with which=all
|
||||
err := backend.Update(updateData, true, "all")
|
||||
if err != nil {
|
||||
t.Errorf("Admin should be able to modify themselves with which=all: %v", err)
|
||||
}
|
||||
|
||||
// Verify changes were applied
|
||||
updatedUser, err := backend.GetBy(admin.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get updated user: %v", err)
|
||||
}
|
||||
|
||||
if !updatedUser.DarkMode {
|
||||
t.Error("DarkMode should have been updated to true")
|
||||
}
|
||||
if updatedUser.OtpEnabled {
|
||||
t.Error("OtpEnabled should have been updated to false")
|
||||
}
|
||||
if updatedUser.Locale != "de" {
|
||||
t.Errorf("Locale should have been updated to 'de', got '%s'", updatedUser.Locale)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_NonAdminModifySelf(t *testing.T) {
|
||||
backend := createTestUsersBackend(t)
|
||||
|
||||
// Create regular user
|
||||
regularUser := createTestUser(t, backend, "regular", false)
|
||||
|
||||
// Non-admin modifies themselves
|
||||
updateData := &users.User{
|
||||
ID: regularUser.ID,
|
||||
Username: regularUser.Username,
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
DarkMode: true,
|
||||
OtpEnabled: false, // This should be allowed now
|
||||
Locale: "es",
|
||||
ViewMode: "grid",
|
||||
},
|
||||
}
|
||||
|
||||
// Test with which=all
|
||||
err := backend.Update(updateData, false, "all")
|
||||
if err != nil {
|
||||
t.Errorf("Non-admin should be able to modify themselves with which=all: %v", err)
|
||||
}
|
||||
|
||||
// Verify changes were applied
|
||||
updatedUser, err := backend.GetBy(regularUser.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get updated user: %v", err)
|
||||
}
|
||||
|
||||
if !updatedUser.DarkMode {
|
||||
t.Error("DarkMode should have been updated to true")
|
||||
}
|
||||
if updatedUser.OtpEnabled {
|
||||
t.Error("OtpEnabled should have been updated to false")
|
||||
}
|
||||
if updatedUser.Locale != "es" {
|
||||
t.Errorf("Locale should have been updated to 'es', got '%s'", updatedUser.Locale)
|
||||
}
|
||||
if updatedUser.ViewMode != "grid" {
|
||||
t.Errorf("ViewMode should have been updated to 'grid', got '%s'", updatedUser.ViewMode)
|
||||
}
|
||||
|
||||
// Test with specific fields
|
||||
updateData2 := &users.User{
|
||||
ID: regularUser.ID,
|
||||
Username: regularUser.Username,
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
SingleClick: true,
|
||||
},
|
||||
}
|
||||
|
||||
err = backend.Update(updateData2, false, "SingleClick")
|
||||
if err != nil {
|
||||
t.Errorf("Non-admin should be able to modify specific allowed field: %v", err)
|
||||
}
|
||||
|
||||
updatedUser2, err := backend.GetBy(regularUser.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get updated user: %v", err)
|
||||
}
|
||||
|
||||
if !updatedUser2.SingleClick {
|
||||
t.Error("SingleClick should have been updated to true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_NonAdminModifySelfRestrictedFields(t *testing.T) {
|
||||
backend := createTestUsersBackend(t)
|
||||
|
||||
// Create regular user
|
||||
regularUser := createTestUser(t, backend, "regular", false)
|
||||
|
||||
// Non-admin tries to modify restricted fields (should be filtered out, not error)
|
||||
updateData := &users.User{
|
||||
ID: regularUser.ID,
|
||||
Username: regularUser.Username,
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
DarkMode: true, // This should be allowed
|
||||
},
|
||||
Permissions: users.Permissions{
|
||||
Admin: true, // This should be filtered out
|
||||
},
|
||||
Scopes: []users.SourceScope{ // This should be filtered out
|
||||
{Name: "test", Scope: "/test"},
|
||||
},
|
||||
}
|
||||
|
||||
// Test with which=all - should not error, but should filter out restricted fields
|
||||
err := backend.Update(updateData, false, "all")
|
||||
if err != nil {
|
||||
t.Errorf("Non-admin should not error when trying to modify restricted fields (should be filtered): %v", err)
|
||||
}
|
||||
|
||||
// Verify only allowed fields were updated
|
||||
updatedUser, err := backend.GetBy(regularUser.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get updated user: %v", err)
|
||||
}
|
||||
|
||||
// Allowed field should be updated
|
||||
if !updatedUser.DarkMode {
|
||||
t.Error("DarkMode should have been updated to true")
|
||||
}
|
||||
|
||||
// Restricted fields should NOT be updated
|
||||
if updatedUser.Permissions.Admin {
|
||||
t.Error("Permissions.Admin should not have been updated (restricted field)")
|
||||
}
|
||||
if len(updatedUser.Scopes) != 0 {
|
||||
t.Error("Scopes should not have been updated (restricted field)")
|
||||
}
|
||||
|
||||
// Test with specific restricted fields - should be filtered out
|
||||
updateData2 := &users.User{
|
||||
ID: regularUser.ID,
|
||||
Username: regularUser.Username,
|
||||
Permissions: users.Permissions{
|
||||
Admin: true,
|
||||
},
|
||||
}
|
||||
|
||||
err = backend.Update(updateData2, false, "Permissions")
|
||||
if err == nil {
|
||||
t.Error("Non-admin should get error when trying to modify only restricted fields (all fields filtered out)")
|
||||
}
|
||||
if err.Error() != "no fields to update" {
|
||||
t.Errorf("Expected 'no fields to update' error, got: %v", err)
|
||||
}
|
||||
|
||||
// Verify restricted field was not updated
|
||||
updatedUser2, err := backend.GetBy(regularUser.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get updated user: %v", err)
|
||||
}
|
||||
|
||||
if updatedUser2.Permissions.Admin {
|
||||
t.Error("Permissions.Admin should not have been updated (restricted field)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_NonAdminModifyOtherUser(t *testing.T) {
|
||||
backend := createTestUsersBackend(t)
|
||||
|
||||
// Create two regular users
|
||||
_ = createTestUser(t, backend, "user1", false)
|
||||
user2 := createTestUser(t, backend, "user2", false)
|
||||
|
||||
// Non-admin tries to modify another user (this should be handled at a higher level)
|
||||
// But if it gets to the backend, it should work the same as modifying self
|
||||
updateData := &users.User{
|
||||
ID: user2.ID,
|
||||
Username: user2.Username,
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
DarkMode: true,
|
||||
},
|
||||
}
|
||||
|
||||
// This should work (filtering restricted fields) but the higher-level auth should prevent this
|
||||
err := backend.Update(updateData, false, "DarkMode")
|
||||
if err != nil {
|
||||
t.Errorf("Backend should handle non-admin modifying other user (filtering restricted fields): %v", err)
|
||||
}
|
||||
|
||||
// Verify the change was applied
|
||||
updatedUser, err := backend.GetBy(user2.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get updated user: %v", err)
|
||||
}
|
||||
|
||||
if !updatedUser.DarkMode {
|
||||
t.Error("DarkMode should have been updated to true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_NonAdminModifyOtherUserOtp(t *testing.T) {
|
||||
backend := createTestUsersBackend(t)
|
||||
|
||||
// Create two regular users
|
||||
_ = createTestUser(t, backend, "user1", false)
|
||||
user2 := createTestUser(t, backend, "user2", false)
|
||||
|
||||
// Non-admin tries to modify another user's OTP
|
||||
updateData := &users.User{
|
||||
ID: user2.ID,
|
||||
Username: user2.Username,
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
OtpEnabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
// This should work (OtpEnabled is now in NonAdminEditable)
|
||||
err := backend.Update(updateData, false, "OtpEnabled")
|
||||
if err != nil {
|
||||
t.Errorf("Non-admin should be able to modify OTP field: %v", err)
|
||||
}
|
||||
|
||||
// Verify the change was applied
|
||||
updatedUser, err := backend.GetBy(user2.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get updated user: %v", err)
|
||||
}
|
||||
|
||||
if updatedUser.OtpEnabled {
|
||||
t.Error("OtpEnabled should have been updated to false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_WhichAllVsSpecific(t *testing.T) {
|
||||
backend := createTestUsersBackend(t)
|
||||
|
||||
// Create regular user
|
||||
regularUser := createTestUser(t, backend, "regular", false)
|
||||
|
||||
// Test which=all
|
||||
updateDataAll := &users.User{
|
||||
ID: regularUser.ID,
|
||||
Username: regularUser.Username,
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
DarkMode: true,
|
||||
OtpEnabled: false,
|
||||
Locale: "fr",
|
||||
ViewMode: "list",
|
||||
},
|
||||
}
|
||||
|
||||
err := backend.Update(updateDataAll, false, "all")
|
||||
if err != nil {
|
||||
t.Errorf("which=all should work: %v", err)
|
||||
}
|
||||
|
||||
// Verify changes
|
||||
updatedUser, err := backend.GetBy(regularUser.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get updated user: %v", err)
|
||||
}
|
||||
|
||||
if !updatedUser.DarkMode || updatedUser.OtpEnabled || updatedUser.Locale != "fr" || updatedUser.ViewMode != "list" {
|
||||
t.Error("which=all should update all allowed fields")
|
||||
}
|
||||
|
||||
// Reset user
|
||||
regularUser = createTestUser(t, backend, "regular2", false)
|
||||
|
||||
// Test specific fields
|
||||
updateDataSpecific := &users.User{
|
||||
ID: regularUser.ID,
|
||||
Username: regularUser.Username,
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
DarkMode: true,
|
||||
Locale: "de",
|
||||
},
|
||||
}
|
||||
|
||||
err = backend.Update(updateDataSpecific, false, "DarkMode", "Locale")
|
||||
if err != nil {
|
||||
t.Errorf("specific fields should work: %v", err)
|
||||
}
|
||||
|
||||
// Verify only specified fields were updated
|
||||
updatedUser2, err := backend.GetBy(regularUser.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get updated user: %v", err)
|
||||
}
|
||||
|
||||
if !updatedUser2.DarkMode {
|
||||
t.Error("DarkMode should have been updated")
|
||||
}
|
||||
if updatedUser2.Locale != "de" {
|
||||
t.Error("Locale should have been updated")
|
||||
}
|
||||
if updatedUser2.ViewMode != "normal" { // Should remain default
|
||||
t.Error("ViewMode should not have been updated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_EmptyFieldsAfterFiltering(t *testing.T) {
|
||||
backend := createTestUsersBackend(t)
|
||||
|
||||
// Create regular user
|
||||
regularUser := createTestUser(t, backend, "regular", false)
|
||||
|
||||
// Non-admin tries to update only restricted fields
|
||||
updateData := &users.User{
|
||||
ID: regularUser.ID,
|
||||
Username: regularUser.Username,
|
||||
Permissions: users.Permissions{
|
||||
Admin: true, // This will be filtered out
|
||||
},
|
||||
Scopes: []users.SourceScope{ // This will be filtered out
|
||||
{Name: "test", Scope: "/test"},
|
||||
},
|
||||
}
|
||||
|
||||
// This should result in "no fields to update" error
|
||||
err := backend.Update(updateData, false, "Permissions", "Scopes")
|
||||
if err == nil {
|
||||
t.Error("Should get error when all fields are filtered out")
|
||||
}
|
||||
|
||||
if err.Error() != "no fields to update" {
|
||||
t.Errorf("Expected 'no fields to update' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterRestrictedFields(t *testing.T) {
|
||||
// Test the filterRestrictedFields function directly
|
||||
testCases := []struct {
|
||||
name string
|
||||
fields []string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "all allowed fields",
|
||||
fields: []string{"DarkMode", "OtpEnabled", "Locale", "ViewMode"},
|
||||
expected: []string{"DarkMode", "OtpEnabled", "Locale", "ViewMode"},
|
||||
},
|
||||
{
|
||||
name: "mixed allowed and restricted",
|
||||
fields: []string{"DarkMode", "Permissions", "Scopes", "OtpEnabled"},
|
||||
expected: []string{"DarkMode", "OtpEnabled"},
|
||||
},
|
||||
{
|
||||
name: "all restricted fields",
|
||||
fields: []string{"Permissions", "Scopes", "Username", "ID"},
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "empty fields",
|
||||
fields: []string{},
|
||||
expected: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := filterRestrictedFields(tc.fields)
|
||||
|
||||
if len(result) != len(tc.expected) {
|
||||
t.Errorf("Expected %d fields, got %d", len(tc.expected), len(result))
|
||||
}
|
||||
|
||||
for i, field := range result {
|
||||
if field != tc.expected[i] {
|
||||
t.Errorf("Expected field %s at position %d, got %s", tc.expected[i], i, field)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNonAdminEditableContainsOtpEnabled(t *testing.T) {
|
||||
// Test that OtpEnabled is included in NonAdminEditable struct
|
||||
structType := reflect.TypeOf(NonAdminEditable{})
|
||||
|
||||
// Check if OtpEnabled field exists
|
||||
otpEnabledField, exists := structType.FieldByName("OtpEnabled")
|
||||
if !exists {
|
||||
t.Fatal("OtpEnabled field should exist in NonAdminEditable struct")
|
||||
}
|
||||
|
||||
// Check the JSON tag
|
||||
expectedTag := "otpEnabled"
|
||||
if otpEnabledField.Tag.Get("json") != expectedTag {
|
||||
t.Errorf("Expected JSON tag '%s', got '%s'", expectedTag, otpEnabledField.Tag.Get("json"))
|
||||
}
|
||||
|
||||
// Check the field type
|
||||
if otpEnabledField.Type.Kind() != reflect.Bool {
|
||||
t.Errorf("Expected OtpEnabled to be bool, got %s", otpEnabledField.Type.Kind())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNonAdminEditableFieldNames(t *testing.T) {
|
||||
// This is a helper function that should be tested
|
||||
// We'll test it indirectly by checking if OtpEnabled is in the list
|
||||
names := getNonAdminEditableFieldNames()
|
||||
|
||||
// Check that OtpEnabled is in the list
|
||||
found := false
|
||||
for _, name := range names {
|
||||
if name == "OtpEnabled" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Error("OtpEnabled should be in the list of non-admin editable field names")
|
||||
}
|
||||
|
||||
// Check that some other expected fields are present
|
||||
expectedFields := []string{"DarkMode", "Locale", "ViewMode", "SingleClick"}
|
||||
for _, expected := range expectedFields {
|
||||
found := false
|
||||
for _, name := range names {
|
||||
if name == expected {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Expected field %s should be in non-admin editable fields", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get field names (copied from bolt/users.go for testing)
|
||||
func getNonAdminEditableFieldNames() []string {
|
||||
var names []string
|
||||
t := reflect.TypeOf(NonAdminEditable{})
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
names = append(names, t.Field(i).Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -106,6 +106,7 @@ type NonAdminEditable struct {
|
||||
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
|
||||
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
|
||||
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
|
||||
OtpEnabled bool `json:"otpEnabled"` // allow non-admin users to disable their own OTP
|
||||
}
|
||||
|
||||
type FileLoading struct {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
@@ -25,6 +25,11 @@ type SubtitleTrack struct {
|
||||
IsFile bool `json:"isFile"` // true for external files, false for embedded streams
|
||||
}
|
||||
|
||||
var (
|
||||
MediaCache = cache.NewCache[[]SubtitleTrack](24 * time.Hour) // subtitles get cached for 24 hours
|
||||
SubtitleContentCache = cache.NewCache[string](24 * time.Hour) // subtitle content gets cached for 24 hours
|
||||
)
|
||||
|
||||
// FFProbeOutput represents the JSON output from ffprobe
|
||||
type FFProbeOutput struct {
|
||||
Streams []struct {
|
||||
@@ -41,7 +46,7 @@ func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) [
|
||||
key := "all_subtitles:" + videoPath + ":" + modtime.Format(time.RFC3339)
|
||||
|
||||
// Check cache first
|
||||
if cached, ok := utils.MediaCache.Get(key).([]SubtitleTrack); ok {
|
||||
if cached, ok := MediaCache.Get(key); ok {
|
||||
return cached
|
||||
}
|
||||
|
||||
@@ -56,7 +61,7 @@ func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) [
|
||||
allSubtitles = append(allSubtitles, externalSubs...)
|
||||
|
||||
// Cache the complete list
|
||||
utils.MediaCache.Set(key, allSubtitles)
|
||||
MediaCache.Set(key, allSubtitles)
|
||||
|
||||
return allSubtitles
|
||||
}
|
||||
@@ -252,7 +257,7 @@ func LoadAllSubtitleContent(videoPath string, subtitles []SubtitleTrack, modtime
|
||||
|
||||
// Check if content is already cached
|
||||
contentKey := fmt.Sprintf("subtitle_content:%s:%d:%s", videoPath, idx, modtime.Format(time.RFC3339))
|
||||
if cached, ok := utils.MediaCache.Get(contentKey).(string); ok {
|
||||
if cached, ok := SubtitleContentCache.Get(contentKey); ok {
|
||||
subtitle.Content = cached
|
||||
continue
|
||||
}
|
||||
@@ -283,7 +288,7 @@ func LoadAllSubtitleContent(videoPath string, subtitles []SubtitleTrack, modtime
|
||||
|
||||
subtitle.Content = content
|
||||
// Cache the content for future requests
|
||||
utils.MediaCache.Set(contentKey, content)
|
||||
SubtitleContentCache.Set(contentKey, content)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+89
-14
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
@@ -46,18 +47,22 @@ func (s *VideoService) release() {
|
||||
// GenerateVideoPreview generates a single preview image from a video using ffmpeg.
|
||||
// videoPath: path to the input video file.
|
||||
// outputPath: path where the generated preview image will be saved (e.g., "/tmp/preview.jpg").
|
||||
// seekTime: how many seconds into the video to seek before capturing the frame.
|
||||
// percentageSeek: percentage of video duration to seek to (0-100).
|
||||
func (s *VideoService) GenerateVideoPreview(videoPath, outputPath string, percentageSeek int) ([]byte, error) {
|
||||
if err := s.acquire(context.Background()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer s.release()
|
||||
|
||||
// Validate percentage parameter
|
||||
if percentageSeek < 0 || percentageSeek > 100 {
|
||||
percentageSeek = 10 // Default to 10% if invalid
|
||||
}
|
||||
|
||||
// Step 1: Get video duration from the container format
|
||||
probeCmd := exec.Command(
|
||||
s.ffprobePath,
|
||||
"-v", "error",
|
||||
// Use format=duration for better compatibility
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
videoPath,
|
||||
@@ -81,7 +86,6 @@ func (s *VideoService) GenerateVideoPreview(videoPath, outputPath string, percen
|
||||
|
||||
durationFloat, err := strconv.ParseFloat(durationStr, 64)
|
||||
if err != nil {
|
||||
// The original error you saw would be caught here if "N/A" was still the output
|
||||
return nil, fmt.Errorf("invalid duration: %v", err)
|
||||
}
|
||||
|
||||
@@ -89,22 +93,18 @@ func (s *VideoService) GenerateVideoPreview(videoPath, outputPath string, percen
|
||||
return nil, fmt.Errorf("video duration must be positive")
|
||||
}
|
||||
|
||||
// Step 2: Get the duration of the video in whole seconds
|
||||
duration := int(durationFloat)
|
||||
// Step 2: Calculate seek time with higher precision
|
||||
seekTime := durationFloat * float64(percentageSeek) / 100.0
|
||||
seekTimeStr := strconv.FormatFloat(seekTime, 'f', 3, 64) // 3 decimal places precision
|
||||
|
||||
// Step 3: Calculate seek time based on percentageSeek (percentage value)
|
||||
seekSeconds := duration * percentageSeek / 100
|
||||
|
||||
// Step 4: Convert seekSeconds to string for ffmpeg command
|
||||
seekTime := strconv.Itoa(seekSeconds)
|
||||
|
||||
// Step 5: Extract frame at seek time
|
||||
// Step 3: Extract frame at seek time with optimized parameters
|
||||
cmd := exec.Command(
|
||||
s.ffmpegPath,
|
||||
"-ss", seekTime,
|
||||
"-ss", seekTimeStr, // Use precise seek time
|
||||
"-i", videoPath,
|
||||
"-frames:v", "1",
|
||||
"-q:v", "10",
|
||||
"-q:v", "10", // Good quality/size balance
|
||||
"-f", "image2", // Explicitly specify image format
|
||||
"-y", // overwrite output
|
||||
outputPath,
|
||||
)
|
||||
@@ -120,6 +120,81 @@ func (s *VideoService) GenerateVideoPreview(videoPath, outputPath string, percen
|
||||
return os.ReadFile(outputPath)
|
||||
}
|
||||
|
||||
// GenerateVideoPreviewStreaming generates a video preview and streams it directly to a writer
|
||||
// This is more memory efficient for large previews as it doesn't load the entire file into memory
|
||||
func (s *VideoService) GenerateVideoPreviewStreaming(videoPath string, percentageSeek int, writer io.Writer) error {
|
||||
if err := s.acquire(context.Background()); err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.release()
|
||||
|
||||
// Validate percentage parameter
|
||||
if percentageSeek < 0 || percentageSeek > 100 {
|
||||
percentageSeek = 10 // Default to 10% if invalid
|
||||
}
|
||||
|
||||
// Step 1: Get video duration from the container format
|
||||
probeCmd := exec.Command(
|
||||
s.ffprobePath,
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
videoPath,
|
||||
)
|
||||
|
||||
var probeOut bytes.Buffer
|
||||
probeCmd.Stdout = &probeOut
|
||||
if s.debug {
|
||||
probeCmd.Stderr = os.Stderr
|
||||
}
|
||||
if err := probeCmd.Run(); err != nil {
|
||||
logger.Errorf("ffprobe command failed on file '%v' : %v", videoPath, err)
|
||||
return fmt.Errorf("ffprobe failed: %w", err)
|
||||
}
|
||||
|
||||
durationStr := strings.TrimSpace(probeOut.String())
|
||||
if durationStr == "" || durationStr == "N/A" {
|
||||
logger.Errorf("could not determine video duration for file '%v' using duration info '%v'", videoPath, durationStr)
|
||||
return fmt.Errorf("could not determine video duration")
|
||||
}
|
||||
|
||||
durationFloat, err := strconv.ParseFloat(durationStr, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid duration: %v", err)
|
||||
}
|
||||
|
||||
if durationFloat <= 0 {
|
||||
return fmt.Errorf("video duration must be positive")
|
||||
}
|
||||
|
||||
// Step 2: Calculate seek time with higher precision
|
||||
seekTime := durationFloat * float64(percentageSeek) / 100.0
|
||||
seekTimeStr := strconv.FormatFloat(seekTime, 'f', 3, 64) // 3 decimal places precision
|
||||
|
||||
// Step 3: Extract frame and stream directly to writer
|
||||
cmd := exec.Command(
|
||||
s.ffmpegPath,
|
||||
"-ss", seekTimeStr, // Use precise seek time
|
||||
"-i", videoPath,
|
||||
"-frames:v", "1",
|
||||
"-q:v", "10", // Good quality/size balance
|
||||
"-f", "image2", // Explicitly specify image format
|
||||
"-vcodec", "mjpeg", // Use MJPEG codec for better compression
|
||||
"-", // Output to stdout
|
||||
)
|
||||
|
||||
cmd.Stdout = writer
|
||||
if s.debug {
|
||||
cmd.Stderr = os.Stderr
|
||||
}
|
||||
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ffmpeg command failed on file '%v' : %w", videoPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetVideoDuration extracts the duration of a video file using ffprobe
|
||||
func GetVideoDuration(ffprobePath string, videoPath string) (float64, error) {
|
||||
cmd := exec.Command(ffprobePath,
|
||||
|
||||
+4
-4
@@ -12,8 +12,8 @@ require (
|
||||
github.com/goccy/go-yaml v1.18.0
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/gtsteffaniak/go-cache v0.0.0-20250521142451-edc77dfcb063
|
||||
github.com/gtsteffaniak/go-logger v0.1.2
|
||||
github.com/gtsteffaniak/go-cache v1.0.0
|
||||
github.com/gtsteffaniak/go-logger v0.2.2
|
||||
github.com/kovidgoyal/imaging v1.6.4
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/spf13/afero v1.15.0
|
||||
@@ -131,7 +131,7 @@ require (
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/gofrs/flock v0.12.1 // indirect
|
||||
github.com/gohugoio/hugo v0.149.1 // indirect
|
||||
github.com/golang/geo v0.0.0-20250912065020-b504328d3ef3 // indirect
|
||||
github.com/golang/geo v0.0.0-20250917161122-64cb148137c6 // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
|
||||
github.com/golangci/go-printf-func-name v0.1.0 // indirect
|
||||
@@ -176,7 +176,7 @@ require (
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/macabu/inamedparam v0.2.0 // indirect
|
||||
github.com/magiconair/properties v1.8.6 // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/manuelarte/embeddedstructfieldcheck v0.3.0 // indirect
|
||||
github.com/manuelarte/funcorder v0.5.0 // indirect
|
||||
github.com/maratori/testableexamples v1.0.0 // indirect
|
||||
|
||||
+10
-8
@@ -384,8 +384,8 @@ github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w
|
||||
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
||||
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
||||
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
||||
github.com/golang/geo v0.0.0-20250912065020-b504328d3ef3 h1:vLpszxHK60Falbg9wHLAFI5LPqXlxnbvQRGxCJJHvLY=
|
||||
github.com/golang/geo v0.0.0-20250912065020-b504328d3ef3/go.mod h1:AN0OjM34c3PbjAsX+QNma1nYtJtRxl+s9MZNV7S+efw=
|
||||
github.com/golang/geo v0.0.0-20250917161122-64cb148137c6 h1:8VSqEdhJCzXKe/SYulyQ7jgHNxP+BoCPmqzWF5STIyw=
|
||||
github.com/golang/geo v0.0.0-20250917161122-64cb148137c6/go.mod h1:AN0OjM34c3PbjAsX+QNma1nYtJtRxl+s9MZNV7S+efw=
|
||||
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=
|
||||
@@ -485,10 +485,12 @@ github.com/gostaticanalysis/nilerr v0.1.1/go.mod h1:wZYb6YI5YAxxq0i1+VJbY0s2YONW
|
||||
github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M=
|
||||
github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+3Zh0sUGqw8=
|
||||
github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs=
|
||||
github.com/gtsteffaniak/go-cache v0.0.0-20250521142451-edc77dfcb063 h1:CoLbeqTmM7348kulYBXYN82Bowx7Iu9PHe0shqQ2QR8=
|
||||
github.com/gtsteffaniak/go-cache v0.0.0-20250521142451-edc77dfcb063/go.mod h1:rHUZvf4huhMX/bnNK4/oQ3sNAE2fYC2jFpAWZiBPWac=
|
||||
github.com/gtsteffaniak/go-logger v0.1.2 h1:0g6UxxNwU+Q+7IVRw+Yn7AuYsIK0ukiTgbzjTLm+Uy4=
|
||||
github.com/gtsteffaniak/go-logger v0.1.2/go.mod h1:U3ZkdAclcxXNMvoQLq+zDkqcTmdqhX3hTf+6AuTo6v0=
|
||||
github.com/gtsteffaniak/go-cache v1.0.0 h1:CuZw6eI/be6Oi3oR94Jb5LhcZShMCs6+1wvS7WGN8oQ=
|
||||
github.com/gtsteffaniak/go-cache v1.0.0/go.mod h1:rHUZvf4huhMX/bnNK4/oQ3sNAE2fYC2jFpAWZiBPWac=
|
||||
github.com/gtsteffaniak/go-logger v0.2.1 h1:NTim7RgpZ0+JAuep+2n1a7oHJbnyaksnWJStdM7qMeU=
|
||||
github.com/gtsteffaniak/go-logger v0.2.1/go.mod h1:U3ZkdAclcxXNMvoQLq+zDkqcTmdqhX3hTf+6AuTo6v0=
|
||||
github.com/gtsteffaniak/go-logger v0.2.2 h1:5bEX4ndHK0CsHZjw76T+j0phKoaEGiDwC8erseF99yU=
|
||||
github.com/gtsteffaniak/go-logger v0.2.2/go.mod h1:U3ZkdAclcxXNMvoQLq+zDkqcTmdqhX3hTf+6AuTo6v0=
|
||||
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=
|
||||
@@ -581,8 +583,8 @@ github.com/macabu/inamedparam v0.2.0 h1:VyPYpOc10nkhI2qeNUdh3Zket4fcZjEWe35poddB
|
||||
github.com/macabu/inamedparam v0.2.0/go.mod h1:+Pee9/YfGe5LJ62pYXqB89lJ+0k5bsR8Wgz/C0Zlq3U=
|
||||
github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo=
|
||||
github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
|
||||
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
|
||||
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1rSnAZns+1msaCXetrMFE=
|
||||
github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc=
|
||||
github.com/manuelarte/embeddedstructfieldcheck v0.3.0 h1:VhGqK8gANDvFYDxQkjPbv7/gDJtsGU9k6qj/hC2hgso=
|
||||
|
||||
@@ -130,12 +130,12 @@ func loginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in
|
||||
func logoutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
defer auth.RevokeAPIKey(d.token)
|
||||
logoutUrl := fmt.Sprintf("%vlogin", config.Server.BaseURL) // Default fallback
|
||||
if d.user.LoginMethod == users.LoginMethodProxy {
|
||||
if d.user != nil && d.user.LoginMethod == users.LoginMethodProxy {
|
||||
proxyRedirectUrl := config.Auth.Methods.ProxyAuth.LogoutRedirectUrl
|
||||
if proxyRedirectUrl != "" {
|
||||
logoutUrl = proxyRedirectUrl
|
||||
}
|
||||
} else if d.user.LoginMethod == users.LoginMethodOidc {
|
||||
} else if d.user != nil && d.user.LoginMethod == users.LoginMethodOidc {
|
||||
oidcRedirectUrl := config.Auth.Methods.OidcAuth.LogoutRedirectUrl
|
||||
if oidcRedirectUrl != "" {
|
||||
logoutUrl = oidcRedirectUrl
|
||||
|
||||
@@ -88,7 +88,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
|
||||
// Auth routes
|
||||
api.HandleFunc("POST /auth/login", userWithoutOTP(loginHandler))
|
||||
api.HandleFunc("POST /auth/logout", withUser(logoutHandler))
|
||||
api.HandleFunc("POST /auth/logout", withOrWithoutUser(logoutHandler))
|
||||
api.HandleFunc("POST /auth/signup", withoutUser(signupHandler))
|
||||
api.HandleFunc("POST /auth/otp/generate", userWithoutOTP(generateOTPHandler))
|
||||
api.HandleFunc("POST /auth/otp/verify", userWithoutOTP(verifyOTPHandler))
|
||||
|
||||
@@ -133,6 +133,62 @@ func withAdminHelper(fn handleFunc) handleFunc {
|
||||
})
|
||||
}
|
||||
|
||||
// extractUserFromExpiredToken attempts to extract user information from an expired token
|
||||
// This is used by withOrWithoutUserHelper to get user context even when tokens are expired
|
||||
func extractUserFromExpiredToken(r *http.Request, data *requestContext) *users.User {
|
||||
if config.Auth.Methods.NoAuth {
|
||||
user, err := store.Users.Get(uint(1))
|
||||
if err != nil {
|
||||
logger.Errorf("no auth: %v", err)
|
||||
return nil
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
|
||||
if config.Auth.Methods.ProxyAuth.Enabled && proxyUser != "" {
|
||||
user, err := setupProxyUser(r, data, proxyUser)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
keyFunc := func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(config.Auth.Key), nil
|
||||
}
|
||||
|
||||
tokenString, err := extractToken(r)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
data.token = tokenString
|
||||
var tk users.AuthToken
|
||||
token, err := jwt.ParseWithClaims(tokenString, &tk, keyFunc)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Token is valid (but might be expired or revoked)
|
||||
// Try to get the user regardless of expiration status
|
||||
user, err := store.Users.Get(tk.BelongsTo)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get user with ID %v: %v", tk.BelongsTo, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if user.Username == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
// withOrWithoutUserHelper is a middleware that tries to authenticate a user.
|
||||
// If authentication is successful, the user is added to the request context.
|
||||
// If authentication fails, the request continues without a user.
|
||||
@@ -192,11 +248,23 @@ func withOrWithoutUserHelper(fn handleFunc) handleFunc {
|
||||
}
|
||||
return fn(w, r, data)
|
||||
}
|
||||
// Only fall back to anonymous if authentication actually failed
|
||||
|
||||
// Authentication failed, but try to extract user info from expired tokens
|
||||
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||||
// Try to extract user info from potentially expired token
|
||||
userFromExpiredToken := extractUserFromExpiredToken(r, data)
|
||||
if userFromExpiredToken != nil {
|
||||
data.user = userFromExpiredToken
|
||||
if data.share != nil && data.shareValid {
|
||||
data.user.CustomTheme = data.share.ShareTheme
|
||||
}
|
||||
setUserInResponseWriter(w, data.user)
|
||||
return fn(w, r, data)
|
||||
}
|
||||
|
||||
// No valid token or user found, fall back to anonymous
|
||||
data.user = &users.User{Username: "anonymous"}
|
||||
settings.ApplyUserDefaults(data.user)
|
||||
// If user authentication failed, call the handler without user context
|
||||
// Clear any user data that might have been partially set
|
||||
data.token = ""
|
||||
if data.share != nil && data.shareValid {
|
||||
|
||||
@@ -409,7 +409,7 @@ func parseOnlyOfficeCallbackFromJSON(r *http.Request) (*OnlyOfficeCallback, erro
|
||||
func getOnlyOfficeId(realpath string) (string, error) {
|
||||
// error is intentionally ignored in order treat errors
|
||||
// the same as a cache-miss
|
||||
cachedDocumentKey, ok := utils.OnlyOfficeCache.Get(realpath).(string)
|
||||
cachedDocumentKey, ok := utils.OnlyOfficeCache.Get(realpath)
|
||||
if ok {
|
||||
return cachedDocumentKey, nil
|
||||
}
|
||||
|
||||
+14
-1
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/preview"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
type FileCache interface {
|
||||
@@ -101,6 +102,7 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
if !d.fileInfo.HasPreview {
|
||||
return http.StatusBadRequest, fmt.Errorf("this item does not have a preview")
|
||||
}
|
||||
var childMD5 string
|
||||
if d.fileInfo.Type == "directory" {
|
||||
// get extended file info of first previewable item in directory
|
||||
for _, item := range d.fileInfo.Files {
|
||||
@@ -125,6 +127,17 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
// Calculate child's MD5 for cache sharing
|
||||
childMD5, err = utils.GetChecksum(fileInfo.RealPath, "md5")
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to get child file checksum: %w", err)
|
||||
}
|
||||
// Validate child MD5 is not empty
|
||||
if childMD5 == "" {
|
||||
errorMsg := fmt.Sprintf("Child MD5 is empty for file: %s (path: %s)", fileInfo.Name, fileInfo.RealPath)
|
||||
logger.Errorf("Preview generation failed: %s", errorMsg)
|
||||
return http.StatusInternalServerError, fmt.Errorf("preview generation failed: %s", errorMsg)
|
||||
}
|
||||
d.fileInfo = *fileInfo
|
||||
break
|
||||
}
|
||||
@@ -166,7 +179,7 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
officeUrl = scheme + "://" + r.Host + pathUrl
|
||||
}
|
||||
}
|
||||
previewImg, err := preview.GetPreviewForFile(d.fileInfo, previewSize, officeUrl, seekPercentage)
|
||||
previewImg, err := preview.GetPreviewForFileWithChildMD5(d.fileInfo, previewSize, officeUrl, seekPercentage, childMD5)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
@@ -240,6 +240,20 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
}
|
||||
realPath, _, _ := idx.GetRealPath(userscope, path)
|
||||
isDir := strings.HasSuffix(path, "/")
|
||||
|
||||
// Check for file/folder conflicts before creation
|
||||
if stat, statErr := os.Stat(realPath); statErr == nil {
|
||||
// Path exists, check for type conflicts
|
||||
existingIsDir := stat.IsDir()
|
||||
requestingDir := isDir
|
||||
|
||||
// If type mismatch (file vs folder or folder vs file) and not overriding
|
||||
if existingIsDir != requestingDir && r.URL.Query().Get("override") != "true" {
|
||||
logger.Debugf("Type conflict detected: existing is dir=%v, requesting dir=%v at path=%v", existingIsDir, requestingDir, realPath)
|
||||
return http.StatusConflict, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Directories creation on POST.
|
||||
if isDir {
|
||||
err = files.WriteDirectory(fileOpts)
|
||||
@@ -269,6 +283,18 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
}
|
||||
// On the first chunk, check for conflicts or handle override
|
||||
if offset == 0 {
|
||||
// Check for file/folder conflicts for chunked uploads
|
||||
if stat, statErr := os.Stat(realPath); statErr == nil {
|
||||
existingIsDir := stat.IsDir()
|
||||
requestingDir := false // Files are never directories
|
||||
|
||||
// If type mismatch (existing dir vs requesting file) and not overriding
|
||||
if existingIsDir != requestingDir && r.URL.Query().Get("override") != "true" {
|
||||
logger.Debugf("Type conflict detected in chunked: existing is dir=%v, requesting dir=%v at path=%v", existingIsDir, requestingDir, realPath)
|
||||
return http.StatusConflict, nil
|
||||
}
|
||||
}
|
||||
|
||||
var fileInfo *iteminfo.ExtendedFileInfo
|
||||
fileInfo, err = files.FileInfoFaster(fileOpts)
|
||||
if err == nil { // File exists
|
||||
@@ -334,6 +360,19 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
|
||||
logger.Debugf("non-chunked upload logic for %s", realPath)
|
||||
// Non-chunked upload logic (original code)
|
||||
|
||||
// Check for file/folder conflicts for non-chunked uploads
|
||||
if stat, statErr := os.Stat(realPath); statErr == nil {
|
||||
existingIsDir := stat.IsDir()
|
||||
requestingDir := false // Files are never directories
|
||||
|
||||
// If type mismatch (existing dir vs requesting file) and not overriding
|
||||
if existingIsDir != requestingDir && r.URL.Query().Get("override") != "true" {
|
||||
logger.Debugf("Type conflict detected in non-chunked: existing is dir=%v, requesting dir=%v at path=%v", existingIsDir, requestingDir, realPath)
|
||||
return http.StatusConflict, nil
|
||||
}
|
||||
}
|
||||
|
||||
fileInfo, err := files.FileInfoFaster(fileOpts)
|
||||
if err == nil {
|
||||
if r.URL.Query().Get("override") != "true" {
|
||||
|
||||
@@ -42,10 +42,6 @@ func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data inter
|
||||
func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestContext, file, contentType string) (int, error) {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
|
||||
auther, err := store.Auth.Get("password")
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
userSelectedTheme := ""
|
||||
if d.user != nil {
|
||||
theme, ok := config.Frontend.Styling.CustomThemeOptions[d.user.CustomTheme]
|
||||
@@ -167,13 +163,13 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
"commitSHA": version.CommitSHA,
|
||||
"signup": settings.Config.Auth.Methods.PasswordAuth.Signup,
|
||||
"noAuth": config.Auth.Methods.NoAuth,
|
||||
"loginPage": auther.LoginPage(),
|
||||
"enableThumbs": !config.Server.DisablePreviews,
|
||||
"externalLinks": config.Frontend.ExternalLinks,
|
||||
"externalUrl": strings.TrimSuffix(config.Server.ExternalUrl, "/"),
|
||||
"onlyOfficeUrl": settings.Config.Integrations.OnlyOffice.Url,
|
||||
"sourceCount": len(config.Server.SourceMap),
|
||||
"oidcAvailable": config.Auth.Methods.OidcAuth.Enabled,
|
||||
"proxyAvailable": config.Auth.Methods.ProxyAuth.Enabled,
|
||||
"passwordAvailable": config.Auth.Methods.PasswordAuth.Enabled,
|
||||
"mediaAvailable": config.Integrations.Media.FfmpegPath != "",
|
||||
"muPdfAvailable": config.Server.MuPdfAvailable,
|
||||
|
||||
@@ -18,12 +18,16 @@ import (
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
var RealPathCache = cache.NewCache(48*time.Hour, 72*time.Hour)
|
||||
var (
|
||||
RealPathCache = cache.NewCache[string](48*time.Hour, 72*time.Hour)
|
||||
IsDirCache = cache.NewCache[bool](48*time.Hour, 72*time.Hour)
|
||||
)
|
||||
|
||||
// actionConfig holds all configuration options for indexing operations
|
||||
type actionConfig struct {
|
||||
Quick bool // whether to perform a quick scan (skip unchanged directories)
|
||||
Recursive bool // whether to recursively index subdirectories
|
||||
ForceCheck bool // whether to check indexing skip rules.
|
||||
}
|
||||
|
||||
// NewactionConfig creates a new actionConfig with common presets
|
||||
@@ -231,6 +235,7 @@ func (idx *Index) GetFsDirInfo(adjustedPath string) (*iteminfo.FileInfo, error)
|
||||
response, err = idx.GetDirInfo(dir, dirInfo, realPath, adjustedPath, combinedPath, &actionConfig{
|
||||
Quick: false,
|
||||
Recursive: false,
|
||||
ForceCheck: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -286,7 +291,7 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
|
||||
continue
|
||||
}
|
||||
}
|
||||
if idx.shouldSkip(isDir, hidden, fullCombined, baseName) {
|
||||
if !config.ForceCheck && idx.shouldSkip(isDir, hidden, fullCombined, baseName) {
|
||||
continue
|
||||
}
|
||||
itemInfo := &iteminfo.ItemInfo{
|
||||
@@ -373,6 +378,11 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
|
||||
|
||||
itemInfo.Size = int64(size)
|
||||
|
||||
// all checks after this won't update folder preview
|
||||
if itemInfo.HasPreview {
|
||||
hasPreview = true
|
||||
}
|
||||
|
||||
// Set HasPreview before appending to fileInfos
|
||||
// these don't create preview for parent folders
|
||||
if settings.Config.Integrations.OnlyOffice.Secret != "" && iteminfo.IsOnlyOffice(file.Name()) {
|
||||
@@ -389,9 +399,7 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
|
||||
if config.Recursive {
|
||||
idx.NumFiles++
|
||||
}
|
||||
if itemInfo.HasPreview {
|
||||
hasPreview = true
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,8 +467,8 @@ func (idx *Index) recursiveUpdateDirSizes(childInfo *iteminfo.FileInfo, previous
|
||||
func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
|
||||
combined := append([]string{idx.Path}, relativePath...)
|
||||
joinedPath := filepath.Join(combined...)
|
||||
isDir, _ := RealPathCache.Get(joinedPath + ":isdir").(bool)
|
||||
cached, ok := RealPathCache.Get(joinedPath).(string)
|
||||
isDir, _ := IsDirCache.Get(joinedPath + ":isdir")
|
||||
cached, ok := RealPathCache.Get(joinedPath)
|
||||
if ok && cached != "" {
|
||||
return cached, isDir, nil
|
||||
}
|
||||
@@ -473,7 +481,7 @@ func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
|
||||
realPath, isDir, err := iteminfo.ResolveSymlinks(absolutePath)
|
||||
if err == nil {
|
||||
RealPathCache.Set(joinedPath, realPath)
|
||||
RealPathCache.Set(joinedPath+":isdir", isDir)
|
||||
IsDirCache.Set(joinedPath+":isdir", isDir)
|
||||
}
|
||||
return realPath, isDir, err
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package iteminfo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
@@ -201,6 +200,7 @@ var audioMetadataTypes = []string{
|
||||
".mp3",
|
||||
".flac",
|
||||
".ogg",
|
||||
".opus",
|
||||
".m4a",
|
||||
".mp4",
|
||||
".wav",
|
||||
@@ -345,7 +345,7 @@ func hasAlbumArtLowLevel(filePath string, extension string) (bool, error) {
|
||||
switch extension {
|
||||
case ".mp3":
|
||||
return hasAlbumArtMP3(file)
|
||||
case ".flac", ".ogg":
|
||||
case ".flac", ".ogg", ".opus":
|
||||
return hasAlbumArtVorbis(file)
|
||||
case ".m4a", ".mp4":
|
||||
return hasAlbumArtM4A(file)
|
||||
@@ -355,54 +355,93 @@ func hasAlbumArtLowLevel(filePath string, extension string) (bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// hasAlbumArtMP3 checks for APIC frames in ID3v2 tags
|
||||
// hasAlbumArtMP3 checks for APIC/PIC frames in ID3v2 tags with optimized parsing
|
||||
func hasAlbumArtMP3(file *os.File) (bool, error) {
|
||||
// 1. Read the 10-byte ID3v2 header.
|
||||
// Read 10-byte ID3v2 header
|
||||
header := make([]byte, 10)
|
||||
if _, err := io.ReadFull(file, header); err != nil {
|
||||
return false, nil // No ID3v2 tag found
|
||||
}
|
||||
|
||||
// 2. Check for the "ID3" identifier.
|
||||
// Check for "ID3" identifier
|
||||
if string(header[0:3]) != "ID3" {
|
||||
return false, nil // No ID3v2 tag found
|
||||
}
|
||||
|
||||
// 3. Decode the tag size. It's a "synchsafe" integer.
|
||||
// Determine ID3v2 version for proper frame ID detection
|
||||
version := header[3]
|
||||
var pictureFrameID string
|
||||
var frameHeaderSize int
|
||||
|
||||
switch version {
|
||||
case 2:
|
||||
pictureFrameID = "PIC" // ID3v2.2 uses 3-byte frame IDs
|
||||
frameHeaderSize = 6
|
||||
case 3, 4:
|
||||
pictureFrameID = "APIC" // ID3v2.3/2.4 use 4-byte frame IDs
|
||||
frameHeaderSize = 10
|
||||
default:
|
||||
return false, nil // Unsupported version
|
||||
}
|
||||
|
||||
// Decode synchsafe tag size
|
||||
tagSize := (int(header[6]) << 21) | (int(header[7]) << 14) | (int(header[8]) << 7) | int(header[9])
|
||||
|
||||
// 4. Read the entire tag content into a buffer.
|
||||
tagData := make([]byte, tagSize)
|
||||
// Limit reading to reasonable size for performance
|
||||
maxReadSize := 32768 // 32KB should be enough to find picture frames
|
||||
readSize := tagSize
|
||||
if readSize > maxReadSize {
|
||||
readSize = maxReadSize
|
||||
}
|
||||
|
||||
// Read tag data
|
||||
tagData := make([]byte, readSize)
|
||||
if _, err := io.ReadFull(file, tagData); err != nil {
|
||||
return false, fmt.Errorf("failed to read tag data: %w", err)
|
||||
return false, nil // Failed to read, assume no artwork
|
||||
}
|
||||
|
||||
position := 0
|
||||
// 5. Loop through the frames within the tag data.
|
||||
for position < tagSize {
|
||||
if position+10 > tagSize {
|
||||
frameIDLen := len(pictureFrameID)
|
||||
|
||||
// Loop through frames looking for picture frame
|
||||
for position+frameHeaderSize <= readSize {
|
||||
// Check if we've hit padding (null bytes)
|
||||
if tagData[position] == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
frameHeader := tagData[position : position+10]
|
||||
frameID := string(frameHeader[0:4])
|
||||
// Extract frame ID
|
||||
if position+frameIDLen > readSize {
|
||||
break
|
||||
}
|
||||
frameID := string(tagData[position : position+frameIDLen])
|
||||
|
||||
// We found it! The APIC frame contains the album art.
|
||||
if frameID == "APIC" {
|
||||
// Found picture frame!
|
||||
if frameID == pictureFrameID {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// If it's not APIC, find the frame's size to skip over it.
|
||||
var frameSize uint32
|
||||
if err := binary.Read(bytes.NewReader(frameHeader[4:8]), binary.BigEndian, &frameSize); err != nil {
|
||||
return false, fmt.Errorf("corrupt frame header: %w", err)
|
||||
// Skip to next frame
|
||||
var frameSize int
|
||||
if version == 2 {
|
||||
// ID3v2.2: 3-byte size
|
||||
if position+6 > readSize {
|
||||
break
|
||||
}
|
||||
frameSize = int(tagData[position+3])<<16 | int(tagData[position+4])<<8 | int(tagData[position+5])
|
||||
position += 6 + frameSize
|
||||
} else {
|
||||
// ID3v2.3/2.4: 4-byte size
|
||||
if position+10 > readSize {
|
||||
break
|
||||
}
|
||||
frameSize = int(tagData[position+4])<<24 | int(tagData[position+5])<<16 |
|
||||
int(tagData[position+6])<<8 | int(tagData[position+7])
|
||||
position += 10 + frameSize
|
||||
}
|
||||
|
||||
// Move position to the next frame header.
|
||||
position += 10 + int(frameSize)
|
||||
|
||||
// Break if the next frame would start with padding (null bytes).
|
||||
if position >= tagSize || tagData[position] == 0 {
|
||||
// Safety check to prevent infinite loops
|
||||
if frameSize <= 0 || position >= tagSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -410,22 +449,95 @@ func hasAlbumArtMP3(file *os.File) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// hasAlbumArtVorbis checks for METADATA_BLOCK_PICTURE in Vorbis comments (FLAC, OGG)
|
||||
// hasAlbumArtVorbis checks for album art in FLAC, OGG, and OPUS files using surgical format-specific parsing
|
||||
func hasAlbumArtVorbis(file *os.File) (bool, error) {
|
||||
// For FLAC files, look for METADATA_BLOCK_PICTURE in Vorbis comments
|
||||
// This is a simplified check - in practice, you'd need to parse the FLAC structure
|
||||
// For now, we'll do a basic search for common picture-related strings
|
||||
buffer := make([]byte, 8192) // Read first 8KB
|
||||
// Read initial buffer to determine file type
|
||||
buffer := make([]byte, 16384) // 16KB should be enough for most metadata sections
|
||||
n, err := file.Read(buffer)
|
||||
if err != nil && err != io.EOF {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Look for METADATA_BLOCK_PICTURE or COVERART in the buffer
|
||||
// Check if it's a FLAC file
|
||||
if n >= 4 && string(buffer[0:4]) == "fLaC" {
|
||||
return hasAlbumArtFLAC(buffer, n)
|
||||
}
|
||||
|
||||
// Check if it's an OGG file (including OPUS)
|
||||
if n >= 4 && string(buffer[0:4]) == "OggS" {
|
||||
return hasAlbumArtOGG(buffer, n)
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// hasAlbumArtFLAC checks for PICTURE metadata block (type 6) in FLAC files
|
||||
func hasAlbumArtFLAC(buffer []byte, n int) (bool, error) {
|
||||
pos := 4 // Skip "fLaC" header
|
||||
|
||||
for pos < n-4 {
|
||||
// Read metadata block header (4 bytes total)
|
||||
blockHeader := buffer[pos]
|
||||
blockType := blockHeader & 0x7F // bits 0-6
|
||||
isLast := blockHeader&0x80 != 0 // bit 7
|
||||
|
||||
// Found picture block!
|
||||
if blockType == 6 { // PICTURE block type
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Get block size (next 3 bytes, big-endian)
|
||||
if pos+4 > n {
|
||||
break
|
||||
}
|
||||
blockSize := int(buffer[pos+1])<<16 | int(buffer[pos+2])<<8 | int(buffer[pos+3])
|
||||
|
||||
// Move to next block
|
||||
pos += 4 + blockSize
|
||||
|
||||
// Stop if this was the last block or we've exceeded buffer
|
||||
if isLast || pos >= n {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// hasAlbumArtOGG checks for METADATA_BLOCK_PICTURE in OGG Vorbis comments (including OPUS)
|
||||
func hasAlbumArtOGG(buffer []byte, n int) (bool, error) {
|
||||
// Look for Vorbis comment patterns
|
||||
vorbisPrefix := []byte("\x03vorbis")
|
||||
opusPrefix := []byte("OpusTags")
|
||||
|
||||
// Search for either Vorbis or Opus tags
|
||||
for i := 0; i < n-8; i++ {
|
||||
if i+len(vorbisPrefix) < n && bytes.Equal(buffer[i:i+len(vorbisPrefix)], vorbisPrefix) {
|
||||
// Found Vorbis comment block, look for METADATA_BLOCK_PICTURE
|
||||
return searchForPictureInVorbisComment(buffer[i+len(vorbisPrefix):], n-i-len(vorbisPrefix))
|
||||
}
|
||||
if i+len(opusPrefix) < n && bytes.Equal(buffer[i:i+len(opusPrefix)], opusPrefix) {
|
||||
// Found Opus tags block, look for METADATA_BLOCK_PICTURE
|
||||
return searchForPictureInVorbisComment(buffer[i+len(opusPrefix):], n-i-len(opusPrefix))
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// searchForPictureInVorbisComment looks for METADATA_BLOCK_PICTURE field in Vorbis comments
|
||||
func searchForPictureInVorbisComment(buffer []byte, n int) (bool, error) {
|
||||
// Convert to string and look for the METADATA_BLOCK_PICTURE field
|
||||
// This field contains base64-encoded picture data
|
||||
content := string(buffer[:n])
|
||||
if strings.Contains(content, "METADATA_BLOCK_PICTURE") ||
|
||||
strings.Contains(content, "COVERART") ||
|
||||
strings.Contains(content, "COVER_ART") {
|
||||
|
||||
// Look for the exact field name used in Vorbis comments
|
||||
if strings.Contains(content, "METADATA_BLOCK_PICTURE=") {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Some files might use alternative field names
|
||||
if strings.Contains(content, "COVERART=") || strings.Contains(content, "COVER_ART=") {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -435,16 +547,67 @@ func hasAlbumArtVorbis(file *os.File) (bool, error) {
|
||||
// hasAlbumArtM4A checks for embedded artwork in M4A/MP4 files
|
||||
func hasAlbumArtM4A(file *os.File) (bool, error) {
|
||||
// For M4A files, look for 'covr' atom which contains artwork
|
||||
// This is a simplified check - in practice, you'd need to parse the MP4 structure
|
||||
buffer := make([]byte, 8192) // Read first 8KB
|
||||
// MP4/M4A files use a nested atom structure
|
||||
buffer := make([]byte, 65536) // Read first 64KB to increase chances of finding atoms
|
||||
n, err := file.Read(buffer)
|
||||
if err != nil && err != io.EOF {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Look for 'covr' atom in the buffer
|
||||
content := string(buffer[:n])
|
||||
if strings.Contains(content, "covr") {
|
||||
// Look for the binary 'covr' atom signature
|
||||
// MP4 atoms have: [4-byte size][4-byte type][data...]
|
||||
if bytes.Contains(buffer[:n], []byte("covr")) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Also look for other common MP4 metadata atoms that might contain artwork
|
||||
// 'data' atoms often contain the actual image data
|
||||
pos := 0
|
||||
for pos < n-8 {
|
||||
// Skip if not enough bytes left for an atom header
|
||||
if pos+8 > n {
|
||||
break
|
||||
}
|
||||
|
||||
// Read atom size (big-endian 32-bit)
|
||||
atomSize := int(buffer[pos])<<24 | int(buffer[pos+1])<<16 | int(buffer[pos+2])<<8 | int(buffer[pos+3])
|
||||
|
||||
// Read atom type (4 bytes)
|
||||
atomType := string(buffer[pos+4 : pos+8])
|
||||
|
||||
// Check for relevant atoms
|
||||
if atomType == "covr" || atomType == "data" {
|
||||
// If we found a data atom, check if it contains image signatures
|
||||
if atomType == "data" && pos+16 < n {
|
||||
dataStart := pos + 16 // Skip atom header + data atom header
|
||||
if dataStart < n {
|
||||
// Check for common image format signatures
|
||||
if bytes.HasPrefix(buffer[dataStart:], []byte("\xFF\xD8\xFF")) || // JPEG
|
||||
bytes.HasPrefix(buffer[dataStart:], []byte("\x89PNG")) || // PNG
|
||||
bytes.HasPrefix(buffer[dataStart:], []byte("GIF8")) { // GIF
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if atomType == "covr" {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Move to next atom
|
||||
if atomSize <= 8 || atomSize > n-pos {
|
||||
// Invalid atom size, try to find next potential atom
|
||||
pos++
|
||||
} else {
|
||||
pos += atomSize
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: look for common image format signatures anywhere in the buffer
|
||||
// This catches cases where the atom structure parsing fails
|
||||
if bytes.Contains(buffer[:n], []byte("\xFF\xD8\xFF")) || // JPEG signature
|
||||
bytes.Contains(buffer[:n], []byte("\x89PNG\x0D\x0A\x1A\x0A")) || // Full PNG signature
|
||||
bytes.Contains(buffer[:n], []byte("GIF8")) { // GIF signature
|
||||
return true, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ func GetIndexInfo(sourceName string) (ReducedIndex, error) {
|
||||
}
|
||||
sourcePath := idx.Path
|
||||
cacheKey := "usageCache-" + sourceName
|
||||
_, ok = utils.DiskUsageCache.Get(cacheKey).(bool)
|
||||
_, ok = utils.DiskUsageCache.Get(cacheKey)
|
||||
if !ok {
|
||||
totalBytes, err := getPartitionSize(sourcePath)
|
||||
if err != nil {
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
)
|
||||
|
||||
var SearchResultsCache = cache.NewCache(15 * time.Second)
|
||||
var SearchResultsCache = cache.NewCache[[]string](15 * time.Second)
|
||||
|
||||
var (
|
||||
sessionInProgress sync.Map
|
||||
@@ -37,7 +37,7 @@ func (idx *Index) Search(search string, scope string, sourceSession string) []Se
|
||||
results := make(map[string]SearchResult, 0)
|
||||
count := 0
|
||||
var directories []string
|
||||
cachedDirs, ok := SearchResultsCache.Get(idx.Path + scope).([]string)
|
||||
cachedDirs, ok := SearchResultsCache.Get(idx.Path + scope)
|
||||
if ok {
|
||||
directories = cachedDirs
|
||||
} else {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// convertHEICToJPEGWithFFmpeg converts a HEIC file to JPEG format using FFmpeg
|
||||
// This function handles all FFmpeg-related logic and parameters
|
||||
func (s *Service) convertHEICToJPEGWithFFmpeg(filePath string, previewSize string) ([]byte, error) {
|
||||
// Create FFmpeg image service
|
||||
imageService := ffmpeg.NewImageService(s.ffmpegPath, s.ffprobePath, s.debug, filepath.Join(settings.Config.Server.CacheDir, "heic", filepath.Base(filePath)))
|
||||
// Use the shared image service
|
||||
if s.imageService == nil {
|
||||
return nil, fmt.Errorf("image service not available")
|
||||
}
|
||||
|
||||
// Determine target dimensions and quality based on preview size
|
||||
var width, height int
|
||||
var quality string
|
||||
@@ -28,7 +28,7 @@ func (s *Service) convertHEICToJPEGWithFFmpeg(filePath string, previewSize strin
|
||||
quality = "5" // Medium quality
|
||||
}
|
||||
// Use tile-based conversion for correct full-resolution image reconstruction
|
||||
result, err := imageService.ConvertHEICToJPEG(filePath, width, height, quality)
|
||||
result, err := s.imageService.ConvertHEICToJPEG(filePath, width, height, quality)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -444,3 +444,24 @@ func TestService_FormatFromExtension(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheKeyConsistency(t *testing.T) {
|
||||
// Test that folder previews and direct file previews use the same cache key
|
||||
// when the folder preview is based on a child file
|
||||
|
||||
// Test direct file preview
|
||||
cacheKey1 := CacheKey("testmd5hash123", "small", 0)
|
||||
|
||||
// Test folder preview with same child MD5
|
||||
cacheKey2 := CacheKey("testmd5hash123", "small", 0)
|
||||
|
||||
// Both should produce the same cache key
|
||||
require.Equal(t, cacheKey1, cacheKey2, "Folder preview and direct file preview should use the same cache key")
|
||||
|
||||
// Test with different seek percentages
|
||||
cacheKey3 := CacheKey("testmd5hash123", "small", 25)
|
||||
cacheKey4 := CacheKey("testmd5hash123", "small", 25)
|
||||
|
||||
require.Equal(t, cacheKey3, cacheKey4, "Cache keys should be consistent for same parameters")
|
||||
require.NotEqual(t, cacheKey1, cacheKey3, "Different seek percentages should produce different cache keys")
|
||||
}
|
||||
+76
-22
@@ -19,6 +19,7 @@ import (
|
||||
"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/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
@@ -30,12 +31,14 @@ var (
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
sem chan struct{}
|
||||
ffmpegPath string
|
||||
ffprobePath string
|
||||
fileCache diskcache.Interface
|
||||
debug bool
|
||||
docGenMutex sync.Mutex // Mutex to serialize access to doc generation
|
||||
sem chan struct{}
|
||||
ffmpegPath string
|
||||
ffprobePath string
|
||||
fileCache diskcache.Interface
|
||||
debug bool
|
||||
docGenMutex sync.Mutex // Mutex to serialize access to doc generation
|
||||
videoService *ffmpeg.VideoService
|
||||
imageService *ffmpeg.ImageService
|
||||
}
|
||||
|
||||
func NewPreviewGenerator(concurrencyLimit int, ffmpegPath string, cacheDir string) *Service {
|
||||
@@ -81,12 +84,24 @@ func NewPreviewGenerator(concurrencyLimit int, ffmpegPath string, cacheDir strin
|
||||
logger.Debugf("Media Enabled : %v", ffmpegMainPath != "" && ffprobePath != "")
|
||||
settings.Config.Server.MuPdfAvailable = docEnabled()
|
||||
logger.Debugf("MuPDF Enabled : %v", settings.Config.Server.MuPdfAvailable)
|
||||
|
||||
// Create shared ffmpeg services
|
||||
var videoService *ffmpeg.VideoService
|
||||
var imageService *ffmpeg.ImageService
|
||||
|
||||
if ffmpegMainPath != "" && ffprobePath != "" {
|
||||
videoService = ffmpeg.NewVideoService(ffmpegMainPath, ffprobePath, concurrencyLimit, settings.Config.Server.DebugMedia)
|
||||
imageService = ffmpeg.NewImageService(ffmpegMainPath, ffprobePath, settings.Config.Server.DebugMedia, filepath.Join(settings.Config.Server.CacheDir, "heic"))
|
||||
}
|
||||
|
||||
return &Service{
|
||||
sem: make(chan struct{}, concurrencyLimit),
|
||||
ffmpegPath: ffmpegMainPath,
|
||||
ffprobePath: ffprobePath,
|
||||
fileCache: fileCache,
|
||||
debug: settings.Config.Server.DebugMedia,
|
||||
sem: make(chan struct{}, concurrencyLimit),
|
||||
ffmpegPath: ffmpegMainPath,
|
||||
ffprobePath: ffprobePath,
|
||||
fileCache: fileCache,
|
||||
debug: settings.Config.Server.DebugMedia,
|
||||
videoService: videoService,
|
||||
imageService: imageService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,11 +111,18 @@ func StartPreviewGenerator(concurrencyLimit int, ffmpegPath, cacheDir string) er
|
||||
}
|
||||
|
||||
func GetPreviewForFile(file iteminfo.ExtendedFileInfo, previewSize, url string, seekPercentage int) ([]byte, error) {
|
||||
return GetPreviewForFileWithChildMD5(file, previewSize, url, seekPercentage, "")
|
||||
}
|
||||
|
||||
func GetPreviewForFileWithChildMD5(file iteminfo.ExtendedFileInfo, previewSize, url string, seekPercentage int, childMD5 string) ([]byte, error) {
|
||||
if !file.HasPreview {
|
||||
return nil, ErrUnsupportedMedia
|
||||
}
|
||||
var thisMd5 string
|
||||
if file.AudioMeta != nil && file.AudioMeta.AlbumArt != "" {
|
||||
if childMD5 != "" {
|
||||
// Use the child's MD5 for folder previews to ensure cache sharing
|
||||
thisMd5 = childMD5
|
||||
} else if file.AudioMeta != nil && file.AudioMeta.AlbumArt != "" {
|
||||
// md5 is based on album art
|
||||
// md5 file.AlbumArt
|
||||
hasher := md5.New()
|
||||
@@ -120,16 +142,31 @@ func GetPreviewForFile(file iteminfo.ExtendedFileInfo, previewSize, url string,
|
||||
}
|
||||
file.Checksums["md5"] = thisMd5
|
||||
}
|
||||
|
||||
// Validate that MD5 is not empty to prevent cache corruption
|
||||
if thisMd5 == "" {
|
||||
errorMsg := fmt.Sprintf("MD5 is empty for file: %s (path: %s)", file.Name, file.RealPath)
|
||||
logger.Errorf("Preview generation failed: %s", errorMsg)
|
||||
return nil, fmt.Errorf("preview generation failed: %s", errorMsg)
|
||||
}
|
||||
|
||||
cacheKey := CacheKey(thisMd5, previewSize, seekPercentage)
|
||||
if data, found, err := service.fileCache.Load(context.Background(), cacheKey); err != nil {
|
||||
return nil, fmt.Errorf("failed to load from cache: %w", err)
|
||||
} else if found {
|
||||
return data, nil
|
||||
}
|
||||
return GeneratePreview(file, previewSize, url, seekPercentage)
|
||||
return GeneratePreviewWithMD5(file, previewSize, url, seekPercentage, thisMd5)
|
||||
}
|
||||
|
||||
func GeneratePreview(file iteminfo.ExtendedFileInfo, previewSize, officeUrl string, seekPercentage int) ([]byte, error) {
|
||||
func GeneratePreviewWithMD5(file iteminfo.ExtendedFileInfo, previewSize, officeUrl string, seekPercentage int, fileMD5 string) ([]byte, error) {
|
||||
// Validate that MD5 is not empty to prevent cache corruption
|
||||
if fileMD5 == "" {
|
||||
errorMsg := fmt.Sprintf("MD5 is empty for file: %s (path: %s)", file.Name, file.RealPath)
|
||||
logger.Errorf("Preview generation failed: %s", errorMsg)
|
||||
return nil, fmt.Errorf("preview generation failed: %s", errorMsg)
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(file.Name))
|
||||
var (
|
||||
err error
|
||||
@@ -138,7 +175,7 @@ func GeneratePreview(file iteminfo.ExtendedFileInfo, previewSize, officeUrl stri
|
||||
|
||||
// Generate thumbnail image from video
|
||||
hasher := md5.New()
|
||||
_, _ = hasher.Write([]byte(CacheKey(file.Checksums["md5"], previewSize, seekPercentage)))
|
||||
_, _ = hasher.Write([]byte(CacheKey(fileMD5, previewSize, seekPercentage)))
|
||||
hash := hex.EncodeToString(hasher.Sum(nil))
|
||||
// Generate an image from office document
|
||||
if iteminfo.HasDocConvertableExtension(file.Name, file.Type) {
|
||||
@@ -159,7 +196,7 @@ func GeneratePreview(file iteminfo.ExtendedFileInfo, previewSize, officeUrl stri
|
||||
return nil, fmt.Errorf("failed to process HEIC image file: %w", err)
|
||||
}
|
||||
// For HEIC files, we've already done the resize/conversion, so cache and return directly
|
||||
cacheKey := CacheKey(file.Checksums["md5"], previewSize, seekPercentage)
|
||||
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
|
||||
if err = service.fileCache.Store(context.Background(), cacheKey, imageBytes); err != nil {
|
||||
logger.Errorf("failed to cache HEIC image: %v", err)
|
||||
}
|
||||
@@ -170,12 +207,13 @@ func GeneratePreview(file iteminfo.ExtendedFileInfo, previewSize, officeUrl stri
|
||||
return nil, fmt.Errorf("failed to read image file: %w", err)
|
||||
}
|
||||
} else if strings.HasPrefix(file.Type, "video") {
|
||||
if seekPercentage == 0 {
|
||||
seekPercentage = 10
|
||||
videoSeekPercentage := seekPercentage
|
||||
if videoSeekPercentage == 0 {
|
||||
videoSeekPercentage = 10
|
||||
}
|
||||
outPathPattern := filepath.Join(settings.Config.Server.CacheDir, "thumbnails", "videos", hash) + ".jpg"
|
||||
defer os.Remove(outPathPattern) // cleanup
|
||||
imageBytes, err = service.GenerateVideoPreview(file.RealPath, outPathPattern, seekPercentage)
|
||||
imageBytes, err = service.GenerateVideoPreview(file.RealPath, outPathPattern, videoSeekPercentage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create image for video file: %w", err)
|
||||
}
|
||||
@@ -203,13 +241,13 @@ func GeneratePreview(file iteminfo.ExtendedFileInfo, previewSize, officeUrl stri
|
||||
return nil, fmt.Errorf("failed to resize preview image: %w", err)
|
||||
}
|
||||
// Cache and return
|
||||
cacheKey := CacheKey(file.Checksums["md5"], previewSize, seekPercentage)
|
||||
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
|
||||
if err := service.fileCache.Store(context.Background(), cacheKey, resizedBytes); err != nil {
|
||||
logger.Errorf("failed to cache resized image: %v", err)
|
||||
}
|
||||
return resizedBytes, nil
|
||||
} else {
|
||||
cacheKey := CacheKey(file.Checksums["md5"], previewSize, seekPercentage)
|
||||
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
|
||||
if err := service.fileCache.Store(context.Background(), cacheKey, imageBytes); err != nil {
|
||||
logger.Errorf("failed to cache original image: %v", err)
|
||||
}
|
||||
@@ -218,6 +256,21 @@ func GeneratePreview(file iteminfo.ExtendedFileInfo, previewSize, officeUrl stri
|
||||
|
||||
}
|
||||
|
||||
func GeneratePreview(file iteminfo.ExtendedFileInfo, previewSize, officeUrl string, seekPercentage int) ([]byte, error) {
|
||||
// For backward compatibility, calculate MD5 if not provided
|
||||
var fileMD5 string
|
||||
if file.Checksums != nil && file.Checksums["md5"] != "" {
|
||||
fileMD5 = file.Checksums["md5"]
|
||||
} else {
|
||||
var err error
|
||||
fileMD5, err = utils.GetChecksum(file.RealPath, "md5")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get checksum: %w", err)
|
||||
}
|
||||
}
|
||||
return GeneratePreviewWithMD5(file, previewSize, officeUrl, seekPercentage, fileMD5)
|
||||
}
|
||||
|
||||
func (s *Service) CreatePreview(data []byte, previewSize string) ([]byte, error) {
|
||||
var (
|
||||
width int
|
||||
@@ -247,7 +300,8 @@ func (s *Service) CreatePreview(data []byte, previewSize string) ([]byte, error)
|
||||
}
|
||||
|
||||
func CacheKey(md5, previewSize string, percentage int) string {
|
||||
return fmt.Sprintf("%x%x%x", md5, previewSize, percentage)
|
||||
key := fmt.Sprintf("%x%x%x", md5, previewSize, percentage)
|
||||
return key
|
||||
}
|
||||
|
||||
func DelThumbs(ctx context.Context, file iteminfo.ExtendedFileInfo) {
|
||||
|
||||
@@ -2,8 +2,7 @@ package preview
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// GenerateVideoPreview generates a single preview image from a video using ffmpeg.
|
||||
@@ -16,7 +15,9 @@ func (s *Service) GenerateVideoPreview(videoPath, outputPath string, percentageS
|
||||
}
|
||||
defer s.release()
|
||||
|
||||
// Create a temporary video service for this operation
|
||||
videoSvc := ffmpeg.NewVideoService(s.ffmpegPath, s.ffprobePath, 1, s.debug)
|
||||
return videoSvc.GenerateVideoPreview(videoPath, outputPath, percentageSeek)
|
||||
// Use the shared video service
|
||||
if s.videoService == nil {
|
||||
return nil, fmt.Errorf("video service not available")
|
||||
}
|
||||
return s.videoService.GenerateVideoPreview(videoPath, outputPath, percentageSeek)
|
||||
}
|
||||
@@ -2827,7 +2827,7 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
},
|
||||
"json": {
|
||||
"description": "output in json format, currently not supported",
|
||||
"description": "output in json format",
|
||||
"type": "boolean"
|
||||
},
|
||||
"levels": {
|
||||
|
||||
@@ -2816,7 +2816,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"json": {
|
||||
"description": "output in json format, currently not supported",
|
||||
"description": "output in json format",
|
||||
"type": "boolean"
|
||||
},
|
||||
"levels": {
|
||||
|
||||
@@ -311,7 +311,7 @@ definitions:
|
||||
description: separated list of log levels to enable for the API. (eg. "info|warning|error")
|
||||
type: string
|
||||
json:
|
||||
description: output in json format, currently not supported
|
||||
description: output in json format
|
||||
type: boolean
|
||||
levels:
|
||||
description: separated list of log levels to enable. (eg. "info|warning|error|debug")
|
||||
|
||||
@@ -15,7 +15,7 @@ server:
|
||||
apiLevels: "" # separated list of log levels to enable for the API. (eg. "info|warning|error")
|
||||
output: "stdout" # output location. (eg. "stdout" or "path/to/file.log")
|
||||
noColors: false # disable colors in the output
|
||||
json: false # output in json format, currently not supported
|
||||
json: false # output in json format
|
||||
utc: false # use UTC time in the output instead of local time
|
||||
debugMedia: false # output ffmpeg stdout for media integration -- careful can produces lots of output!
|
||||
database: "database.db" # path to the database file
|
||||
|
||||
@@ -158,7 +158,6 @@ export async function create(user) {
|
||||
const res = await fetchURL(apiPath, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
what: 'user',
|
||||
which: [],
|
||||
data: user
|
||||
})
|
||||
@@ -187,7 +186,6 @@ export async function update(user, which = ['all']) {
|
||||
await fetchURL(apiPath, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
what: 'user',
|
||||
which: which,
|
||||
data: user
|
||||
})
|
||||
|
||||
@@ -19,7 +19,6 @@ vi.mock('@/utils/constants', () => {
|
||||
userSelectableThemes: {},
|
||||
enableThumbs: true,
|
||||
noAuth: false,
|
||||
loginPage: true,
|
||||
signup: false,
|
||||
version: "test",
|
||||
commitSHA: "test",
|
||||
|
||||
@@ -83,6 +83,20 @@ export default {
|
||||
if (!this.hasPreview) {
|
||||
return false;
|
||||
}
|
||||
if (this.getIconForType().simpleType === "video" && !state.user.preview?.video) {
|
||||
return false;
|
||||
}
|
||||
if (this.getIconForType().simpleType === "image" && !state.user.preview?.image) {
|
||||
return false;
|
||||
}
|
||||
// office files
|
||||
if (this.getIconForType().simpleType === "document" && !state.user.preview?.office) {
|
||||
return false;
|
||||
}
|
||||
// text files
|
||||
if (this.getIconForType().simpleType === "text" && !state.user.preview?.text) {
|
||||
return false;
|
||||
}
|
||||
if (!state.user.preview.folder && this.mimetype == "directory") {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -65,7 +65,10 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
accessible() {
|
||||
return shareInfo.isPasswordProtected && state.share.passwordValid;
|
||||
if (!shareInfo.isPasswordProtected) {
|
||||
return true
|
||||
}
|
||||
return state.share.passwordValid
|
||||
},
|
||||
getShareBanner() {
|
||||
if (this.shareInfo.banner.startsWith("http")) {
|
||||
|
||||
@@ -311,6 +311,22 @@ 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;
|
||||
}
|
||||
|
||||
const { key } = event;
|
||||
|
||||
switch (key) {
|
||||
|
||||
@@ -90,6 +90,7 @@ import { notify } from "@/notify";
|
||||
import { accessApi } from "@/api";
|
||||
import { mutations } from "@/store";
|
||||
import FileList from "./FileList.vue";
|
||||
import { eventBus } from "@/store/eventBus";
|
||||
|
||||
export default {
|
||||
name: "access",
|
||||
@@ -211,7 +212,8 @@ export default {
|
||||
await accessApi.del(this.currentSource, this.currentPath, body);
|
||||
notify.showSuccess(this.$t("access.deleted"));
|
||||
await this.fetchRule();
|
||||
this.$emit('updated');
|
||||
// Emit event to refresh access rules list
|
||||
eventBus.emit('accessRulesChanged');
|
||||
} catch (e) {
|
||||
notify.showError(e);
|
||||
}
|
||||
@@ -235,7 +237,8 @@ export default {
|
||||
notify.showSuccess(this.$t("access.added"));
|
||||
this.addName = "";
|
||||
await this.fetchRule();
|
||||
this.$emit('updated');
|
||||
// Emit event to refresh access rules list
|
||||
eventBus.emit('accessRulesChanged');
|
||||
} catch (e) {
|
||||
notify.showError(e);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
import { mutations } from "@/store";
|
||||
import { notify } from "@/notify";
|
||||
import { usersApi } from "@/api";
|
||||
import { eventBus } from "@/store/eventBus";
|
||||
|
||||
export default {
|
||||
name: "ActionApi",
|
||||
@@ -80,8 +81,9 @@ export default {
|
||||
// Dummy delete function, to be filled in later
|
||||
try {
|
||||
usersApi.deleteApiKey({ name: this.name });
|
||||
// Emit event to refresh API keys list
|
||||
eventBus.emit('apiKeysChanged');
|
||||
notify.showSuccess("API key deleted!");
|
||||
window.location.reload();
|
||||
mutations.closeHovers();
|
||||
} catch (error) {
|
||||
notify.showError(error);
|
||||
|
||||
@@ -44,6 +44,7 @@ import { mutations } from "@/store";
|
||||
import { notify } from "@/notify";
|
||||
import { usersApi } from "@/api";
|
||||
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
|
||||
import { eventBus } from "@/store/eventBus";
|
||||
|
||||
export default {
|
||||
name: "CreateAPI",
|
||||
@@ -87,8 +88,9 @@ export default {
|
||||
};
|
||||
|
||||
await usersApi.createApiKey(params);
|
||||
// Emit event to refresh API keys list
|
||||
eventBus.emit('apiKeysChanged');
|
||||
notify.showSuccess($t("api.createKeySuccess"));
|
||||
window.location.reload();
|
||||
mutations.closeHovers();
|
||||
} catch (error) {
|
||||
notify.showError($t("api.createKeyFailed"));
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { usersApi } from "@/api";
|
||||
import { notify } from "@/notify";
|
||||
import { mutations, getters } from "@/store";
|
||||
import { eventBus } from "@/store/eventBus";
|
||||
|
||||
export default {
|
||||
name: "delete",
|
||||
@@ -32,8 +33,10 @@ export default {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await usersApi.remove(this.user.id);
|
||||
// Emit event to refresh user list
|
||||
eventBus.emit('usersChanged');
|
||||
notify.showSuccess(this.$t("settings.userDeleted"));
|
||||
window.location.reload();
|
||||
mutations.closeHovers();
|
||||
} catch (e) {
|
||||
e.message === "403"
|
||||
? notify.showError(this.$t("errors.forbidden"), false)
|
||||
|
||||
@@ -59,12 +59,64 @@ export default {
|
||||
try {
|
||||
event.preventDefault();
|
||||
if (this.name === "") return;
|
||||
await filesApi.post(state.req.source, state.req.path + "/" + this.name + "/");
|
||||
goToItem(state.req.source, state.req.path + "/" + this.name);
|
||||
await this.createDirectory(false);
|
||||
} catch (error) {
|
||||
notify.showError(error);
|
||||
}
|
||||
},
|
||||
|
||||
async createDirectory(overwrite = false) {
|
||||
try {
|
||||
await filesApi.post(state.req.source, state.req.path + "/" + this.name + "/", "", overwrite);
|
||||
goToItem(state.req.source, state.req.path + "/" + this.name);
|
||||
mutations.closeHovers();
|
||||
} catch (error) {
|
||||
if (error.message === "conflict") {
|
||||
// Show replace-rename prompt for file/folder conflicts
|
||||
mutations.showHover({
|
||||
name: "replace-rename",
|
||||
confirm: async (event, option) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
if (option === "overwrite") {
|
||||
await this.createDirectory(true); // Retry with overwrite
|
||||
} else if (option === "rename") {
|
||||
// Add a suffix to make the name unique (max 100 attempts)
|
||||
const originalName = this.name;
|
||||
const maxAttempts = 100;
|
||||
let success = false;
|
||||
|
||||
for (let counter = 1; counter <= maxAttempts && !success; counter++) {
|
||||
try {
|
||||
const newName = counter === 1 ? `${originalName} (1)` : `${originalName} (${counter})`;
|
||||
await filesApi.post(state.req.source, state.req.path + "/" + newName + "/", "", false);
|
||||
goToItem(state.req.source, state.req.path + "/" + newName);
|
||||
mutations.closeHovers();
|
||||
success = true;
|
||||
} catch (renameError) {
|
||||
if (renameError.message === "conflict") {
|
||||
// Continue to next iteration
|
||||
continue;
|
||||
} else {
|
||||
throw renameError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
throw new Error("Could not find a unique name after " + maxAttempts + " attempts");
|
||||
}
|
||||
}
|
||||
} catch (retryError) {
|
||||
notify.showError(retryError);
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -50,12 +50,64 @@ export default {
|
||||
try {
|
||||
event.preventDefault();
|
||||
if (this.name === "") return;
|
||||
await filesApi.post(state.req.source,state.req.path + "/" + this.name, "", true );
|
||||
goToItem(state.req.source, state.req.path + "/" + this.name);
|
||||
await this.createFile(false);
|
||||
} catch (error) {
|
||||
notify.showError(error);
|
||||
}
|
||||
},
|
||||
|
||||
async createFile(overwrite = false) {
|
||||
try {
|
||||
await filesApi.post(state.req.source, state.req.path + "/" + this.name, "", overwrite);
|
||||
goToItem(state.req.source, state.req.path + "/" + this.name);
|
||||
mutations.closeHovers();
|
||||
} catch (error) {
|
||||
if (error.message === "conflict") {
|
||||
// Show replace-rename prompt for file/folder conflicts
|
||||
mutations.showHover({
|
||||
name: "replace-rename",
|
||||
confirm: async (event, option) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
if (option === "overwrite") {
|
||||
await this.createFile(true); // Retry with overwrite
|
||||
} else if (option === "rename") {
|
||||
// Add a suffix to make the name unique (max 100 attempts)
|
||||
const originalName = this.name;
|
||||
const maxAttempts = 100;
|
||||
let success = false;
|
||||
|
||||
for (let counter = 1; counter <= maxAttempts && !success; counter++) {
|
||||
try {
|
||||
const newName = counter === 1 ? `${originalName} (1)` : `${originalName} (${counter})`;
|
||||
await filesApi.post(state.req.source, state.req.path + "/" + newName, "", false);
|
||||
goToItem(state.req.source, state.req.path + "/" + newName);
|
||||
mutations.closeHovers();
|
||||
success = true;
|
||||
} catch (renameError) {
|
||||
if (renameError.message === "conflict") {
|
||||
// Continue to next iteration
|
||||
continue;
|
||||
} else {
|
||||
throw renameError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
throw new Error("Could not find a unique name after " + maxAttempts + " attempts");
|
||||
}
|
||||
}
|
||||
} catch (retryError) {
|
||||
notify.showError(retryError);
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -80,7 +80,7 @@
|
||||
<ToggleSwitch class="item" v-model="allowEdit" :name="'Allow modifications'" />
|
||||
<ToggleSwitch class="item" v-model="allowUpload" :name="'Allow uploading'" />
|
||||
-->
|
||||
<ToggleSwitch class="item" v-model="disableFileViewer" :name="'Disable File Viewer'" />
|
||||
<ToggleSwitch class="item" v-model="disableFileViewer" :name="$t('share.disableFileViewer')" />
|
||||
<ToggleSwitch
|
||||
class="item"
|
||||
v-model="quickDownload"
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
|
||||
<input
|
||||
class="input flat-left scope-input"
|
||||
placeholder="scope eg. '/subfolder', leave blank for default path"
|
||||
:placeholder="$t('settings.newUserHintSubFolder')"
|
||||
@input="updateParent({ source: source, input: $event })"
|
||||
:value="source.scope"
|
||||
:class="{ 'flat-right': selectedSources.length > 1 }"
|
||||
@@ -183,9 +183,9 @@
|
||||
<div v-if="stateUser.permissions.admin">
|
||||
<label for="loginMethod">{{ $t("settings.loginMethodDescription") }}</label>
|
||||
<select v-model="user.loginMethod" class="input" id="loginMethod">
|
||||
<option value="password">Password</option> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
<option value="oidc">OIDC</option> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
<option value="proxy">Proxy</option> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
<option v-if="globalVars.passwordAvailable" value="password">{{ $t("settings.loginMethods.password") }}</option> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
<option v-if="globalVars.oidcAvailable" value="oidc">{{ $t("settings.loginMethods.oidc") }}</option> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
<option v-if="globalVars.proxyAvailable" value="proxy">{{ $t("settings.loginMethods.proxy") }}</option> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
</select>
|
||||
</div>
|
||||
<permissions v-if="stateUser.permissions.admin" :permissions="user.permissions" />
|
||||
@@ -216,6 +216,7 @@ import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
|
||||
import Errors from "@/views/Errors.vue";
|
||||
import { notify } from "@/notify";
|
||||
import { globalVars } from "@/utils/constants";
|
||||
import { eventBus } from "@/store/eventBus";
|
||||
|
||||
export default {
|
||||
name: "user-edit",
|
||||
@@ -241,6 +242,7 @@ export default {
|
||||
password: "",
|
||||
permissions: { admin: false },
|
||||
otpEnabled: false,
|
||||
loginMethod: null, // Will be set based on available methods
|
||||
},
|
||||
showDelete: false,
|
||||
createUserDir: false,
|
||||
@@ -289,6 +291,12 @@ export default {
|
||||
displayHomeDirectoryCheckbox() {
|
||||
return this.isNew && this.createUserDir;
|
||||
},
|
||||
firstAvailableLoginMethod() {
|
||||
if (this.globalVars.passwordAvailable) return "password";
|
||||
if (this.globalVars.oidcAvailable) return "oidc";
|
||||
if (this.globalVars.proxyAvailable) return "proxy";
|
||||
return "password"; // fallback
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
createUserDir(newVal) {
|
||||
@@ -299,6 +307,15 @@ export default {
|
||||
this.user.otpEnabled = state.user.otpEnabled;
|
||||
this.emitUserUpdate();
|
||||
},
|
||||
globalVars: {
|
||||
handler() {
|
||||
// Set loginMethod when globalVars becomes available
|
||||
if (this.isNew) {
|
||||
this.setDefaultLoginMethod();
|
||||
}
|
||||
},
|
||||
immediate: true
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
closeHovers() {
|
||||
@@ -311,6 +328,15 @@ export default {
|
||||
let defaults = await settingsApi.get("userDefaults");
|
||||
this.user = defaults;
|
||||
this.user.password = "";
|
||||
// Ensure loginMethod is valid, set to first available method if not set or invalid
|
||||
const validMethods = [];
|
||||
if (this.globalVars.passwordAvailable) validMethods.push("password");
|
||||
if (this.globalVars.oidcAvailable) validMethods.push("oidc");
|
||||
if (this.globalVars.proxyAvailable) validMethods.push("proxy");
|
||||
|
||||
if (!this.user.loginMethod || !validMethods.includes(this.user.loginMethod)) {
|
||||
this.user.loginMethod = this.firstAvailableLoginMethod;
|
||||
}
|
||||
} else {
|
||||
const id = this.userId;
|
||||
if (id === undefined) {
|
||||
@@ -318,6 +344,15 @@ export default {
|
||||
}
|
||||
this.user = { ...(await usersApi.get(id)) };
|
||||
this.user.password = "";
|
||||
// Ensure loginMethod is valid, set to first available method if not set or invalid
|
||||
const validMethods = [];
|
||||
if (this.globalVars.passwordAvailable) validMethods.push("password");
|
||||
if (this.globalVars.oidcAvailable) validMethods.push("oidc");
|
||||
if (this.globalVars.proxyAvailable) validMethods.push("proxy");
|
||||
|
||||
if (!this.user.loginMethod || !validMethods.includes(this.user.loginMethod)) {
|
||||
this.user.loginMethod = this.firstAvailableLoginMethod;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
notify.showError(e);
|
||||
@@ -335,6 +370,8 @@ export default {
|
||||
}
|
||||
|
||||
this.user.password = this.user.password || "";
|
||||
// Set default login method
|
||||
this.setDefaultLoginMethod();
|
||||
this.selectedSources = this.user.scopes || [];
|
||||
this.availableSources = this.sourceList.filter(
|
||||
(s) => !this.selectedSources.some((sel) => sel.name === s.name)
|
||||
@@ -355,18 +392,25 @@ export default {
|
||||
event.preventDefault();
|
||||
try {
|
||||
let fields = ["all"];
|
||||
if (!state.user.permissions.admin) {
|
||||
notify.showError(this.$t("settings.userNotAdmin"));
|
||||
return;
|
||||
}
|
||||
if (this.isNew) {
|
||||
if (!state.user.permissions.admin) {
|
||||
notify.showError(this.$t("settings.userNotAdmin"));
|
||||
return;
|
||||
}
|
||||
await usersApi.create({ ...this.user, scopes: this.selectedSources });
|
||||
this.$router.push({ path: "/settings", hash: "#users-main" });
|
||||
// Emit event to refresh user list
|
||||
eventBus.emit('usersChanged');
|
||||
// Close the modal
|
||||
mutations.closeHovers();
|
||||
} else {
|
||||
await usersApi.update({ ...this.user, scopes: this.selectedSources }, fields);
|
||||
// Only emit usersChanged for admin user management, not profile updates
|
||||
if (state.user.permissions.admin && this.user.id !== state.user.id) {
|
||||
eventBus.emit('usersChanged');
|
||||
}
|
||||
notify.showSuccess(this.$t("settings.userUpdated"));
|
||||
mutations.closeHovers();
|
||||
}
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
notify.showError(e);
|
||||
}
|
||||
@@ -387,6 +431,10 @@ export default {
|
||||
}
|
||||
try {
|
||||
await usersApi.update(this.user, ["password"]);
|
||||
// Only emit usersChanged for admin user management, not profile updates
|
||||
if (state.user.permissions.admin && this.user.id !== state.user.id) {
|
||||
eventBus.emit('usersChanged');
|
||||
}
|
||||
notify.showSuccess(this.$t("settings.userUpdated"));
|
||||
} catch (e) {
|
||||
notify.showError(e);
|
||||
@@ -395,10 +443,18 @@ export default {
|
||||
emitUserUpdate() {
|
||||
// Update the user object with current scopes
|
||||
this.user = { ...this.user, scopes: this.selectedSources };
|
||||
// Ensure loginMethod is preserved
|
||||
if (!this.user.loginMethod) {
|
||||
this.user.loginMethod = this.firstAvailableLoginMethod;
|
||||
}
|
||||
},
|
||||
emitUpdate() {
|
||||
// Update the user object
|
||||
this.user = { ...this.user };
|
||||
// Ensure loginMethod is preserved
|
||||
if (!this.user.loginMethod) {
|
||||
this.user.loginMethod = this.firstAvailableLoginMethod;
|
||||
}
|
||||
},
|
||||
setUpdatePassword() {
|
||||
// This method is kept for compatibility but not used in the new structure
|
||||
@@ -437,6 +493,19 @@ export default {
|
||||
this.user[field] = value;
|
||||
this.emitUserUpdate();
|
||||
},
|
||||
setDefaultLoginMethod() {
|
||||
// Set loginMethod to first available method if not already set or if current value is invalid
|
||||
const validMethods = [];
|
||||
if (this.globalVars.passwordAvailable) validMethods.push("password");
|
||||
if (this.globalVars.oidcAvailable) validMethods.push("oidc");
|
||||
if (this.globalVars.proxyAvailable) validMethods.push("proxy");
|
||||
|
||||
const isValidMethod = validMethods.includes(this.user.loginMethod);
|
||||
|
||||
if (!this.user.loginMethod || this.user.loginMethod === null || !isValidMethod) {
|
||||
this.user.loginMethod = this.firstAvailableLoginMethod;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -28,14 +28,14 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="inner-card" v-if="user.username !== 'anonymous'" @click="logout">
|
||||
<div class="inner-card" v-if="canLogout" @click="logout">
|
||||
<button
|
||||
aria-label="logout-button"
|
||||
class="logout-button action"
|
||||
@mouseenter="showTooltip($event, $t('index.logout'))"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
<i v-if="canLogout" class="material-icons">exit_to_app</i>
|
||||
<i class="material-icons">exit_to_app</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,7 +174,7 @@ export default {
|
||||
version: () => globalVars.version,
|
||||
commitSHA: () => globalVars.commitSHA,
|
||||
disableExternal: () => globalVars.disableExternal,
|
||||
canLogout: () => !globalVars.noAuth && globalVars.loginPage,
|
||||
canLogout: () => !globalVars.noAuth && state.user.username !== 'anonymous',
|
||||
route: () => state.route,
|
||||
sourceInfo: () => state.sources.info,
|
||||
activeSource: () => state.sources.current,
|
||||
|
||||
@@ -325,7 +325,14 @@
|
||||
"configViewerShowComments": "إظهار التعليقات",
|
||||
"configViewerLoadConfig": "تكوين التحميل",
|
||||
"brandingHelp": "يمكنك تخصيص شكل ومظهر مثيل متصفح الملفات الخاص بك عن طريق تغيير اسمه، واستبدال الشعار، وإضافة أنماط مخصصة، وحتى تعطيل الروابط الخارجية إلى GitHub.\nلمزيد من المعلومات حول العلامة التجارية المخصصة، يرجى الاطلاع على {0}.",
|
||||
"commandRunnerHelp": "هنا يمكنك تعيين الأوامر التي يتم تنفيذها في الأحداث المسماة. يجب عليك كتابة أمر واحد لكل سطر. سيكون متغيرا البيئة {0} و {1} متاحين، حيث {0} بالنسبة إلى {1}. لمزيد من المعلومات حول هذه الميزة ومتغيرات البيئة المتاحة، يرجى قراءة {2}."
|
||||
"commandRunnerHelp": "هنا يمكنك تعيين الأوامر التي يتم تنفيذها في الأحداث المسماة. يجب عليك كتابة أمر واحد لكل سطر. سيكون متغيرا البيئة {0} و {1} متاحين، حيث {0} بالنسبة إلى {1}. لمزيد من المعلومات حول هذه الميزة ومتغيرات البيئة المتاحة، يرجى قراءة {2}.",
|
||||
"loginMethods": {
|
||||
"password": "كلمة المرور",
|
||||
"oidc": "OIDC",
|
||||
"proxy": "الوكيل"
|
||||
},
|
||||
"newUserHintSubFolder": "على سبيل المثال '/مجلد فرعي' - فارغ للافتراضي",
|
||||
"userScopes": "النطاقات"
|
||||
},
|
||||
"sidebar": {
|
||||
"help": "مساعدة",
|
||||
@@ -399,7 +406,8 @@
|
||||
"enableOnlyOfficeEditingDescription": "السماح بتحرير الملفات المكتبية باستخدام OnlyOffice في هذه الحصة.",
|
||||
"titleDefault": "الملفات المشتركة - {title}",
|
||||
"defaultViewMode": "وضع العرض الافتراضي",
|
||||
"defaultViewModeDescription": "تعيين وضع العرض الافتراضي للمستخدمين المجهولين الذين يشاهدون هذه المشاركة. لا يزال بإمكان المستخدمين المسجلين الدخول تغيير تفضيلات العرض الخاصة بهم."
|
||||
"defaultViewModeDescription": "تعيين وضع العرض الافتراضي للمستخدمين المجهولين الذين يشاهدون هذه المشاركة. لا يزال بإمكان المستخدمين المسجلين الدخول تغيير تفضيلات العرض الخاصة بهم.",
|
||||
"disableFileViewer": "تعطيل عارض الملفات"
|
||||
},
|
||||
"api": {
|
||||
"title": "مفاتيح واجهة برمجة التطبيقات (API):",
|
||||
@@ -552,7 +560,8 @@
|
||||
"fileViewerOptions": "خيارات عارض الملفات",
|
||||
"themeAndLanguage": "الموضوع واللغة",
|
||||
"previewFolder": "إظهار الصور المصغرة للمجلدات إذا كانت متوفرة",
|
||||
"previewFolderDescription": "إظهار معاينات الصور المصغرة للمجلدات التي تحتوي على العنصر الفرعي الأول مع صورة مصغرة كغلاف."
|
||||
"previewFolderDescription": "إظهار معاينات الصور المصغرة للمجلدات التي تحتوي على العنصر الفرعي الأول مع صورة مصغرة كغلاف.",
|
||||
"disableFileExtensions": "أدخل امتدادات الملفات لتعطيلها"
|
||||
},
|
||||
"colors": {
|
||||
"red": "أحمر",
|
||||
|
||||
@@ -182,7 +182,8 @@
|
||||
"enableOnlyOfficeEditingDescription": "Povolit úpravy kancelářských souborů pomocí OnlyOffice v této sdílené složce.",
|
||||
"titleDefault": "Sdílené soubory - {title}",
|
||||
"defaultViewMode": "Výchozí režim zobrazení",
|
||||
"defaultViewModeDescription": "Nastavení výchozího režimu zobrazení pro anonymní uživatele prohlížející tuto sdílenou složku. Přihlášení uživatelé mohou stále měnit předvolby zobrazení."
|
||||
"defaultViewModeDescription": "Nastavení výchozího režimu zobrazení pro anonymní uživatele prohlížející tuto sdílenou složku. Přihlášení uživatelé mohou stále měnit předvolby zobrazení.",
|
||||
"disableFileViewer": "Zakázat prohlížeč souborů"
|
||||
},
|
||||
"prompts": {
|
||||
"copy": "Kopírovat",
|
||||
@@ -328,7 +329,8 @@
|
||||
"fileViewerOptions": "Možnosti prohlížeče souborů",
|
||||
"themeAndLanguage": "Téma a jazyk",
|
||||
"previewFolder": "Zobrazení miniatur složek, pokud jsou k dispozici",
|
||||
"previewFolderDescription": "Zobrazení náhledů miniatur pro složky s první podřízenou položkou s miniaturou jako obálkou."
|
||||
"previewFolderDescription": "Zobrazení náhledů miniatur pro složky s první podřízenou položkou s miniaturou jako obálkou.",
|
||||
"disableFileExtensions": "Zadejte přípony souborů, které chcete zakázat"
|
||||
},
|
||||
"settings": {
|
||||
"enterPassword": "Zadejte heslo",
|
||||
@@ -432,7 +434,14 @@
|
||||
"configViewerShowComments": "Zobrazit komentáře",
|
||||
"configViewerLoadConfig": "Konfigurace načítání",
|
||||
"brandingHelp": "Vzhled instance Průzkumníka souborů si můžete přizpůsobit změnou názvu, nahrazením loga, přidáním vlastních stylů a dokonce i zakázáním externích odkazů na GitHub.\nDalší informace o vlastním brandingu naleznete na stránce {0}.",
|
||||
"commandRunnerHelp": "Zde můžete nastavit příkazy, které se provedou v pojmenovaných událostech. Na každý řádek musíte napsat jeden. K dispozici budou proměnné prostředí {0} a {1}, přičemž {0} bude relativní vůči {1}. Další informace o této funkci a dostupných proměnných prostředí naleznete na {2}."
|
||||
"commandRunnerHelp": "Zde můžete nastavit příkazy, které se provedou v pojmenovaných událostech. Na každý řádek musíte napsat jeden. K dispozici budou proměnné prostředí {0} a {1}, přičemž {0} bude relativní vůči {1}. Další informace o této funkci a dostupných proměnných prostředí naleznete na {2}.",
|
||||
"loginMethods": {
|
||||
"password": "Heslo",
|
||||
"oidc": "OIDC",
|
||||
"proxy": "Proxy"
|
||||
},
|
||||
"newUserHintSubFolder": "např. '/subfolder' - prázdný pro výchozí nastavení",
|
||||
"userScopes": "Rozsahy"
|
||||
},
|
||||
"sidebar": {
|
||||
"help": "Nápověda",
|
||||
|
||||
@@ -325,7 +325,14 @@
|
||||
"configViewerShowComments": "Kommentare anzeigen",
|
||||
"configViewerLoadConfig": "Lastkonfiguration",
|
||||
"brandingHelp": "Sie können das Aussehen Ihrer Dateibrowser-Instanz anpassen, indem Sie den Namen ändern, das Logo ersetzen, eigene Stile hinzufügen und sogar externe Links zu GitHub deaktivieren.\nWeitere Informationen zum benutzerdefinierten Branding finden Sie unter {0}.",
|
||||
"commandRunnerHelp": "Hier können Sie Befehle eingeben, die bei den genannten Ereignissen ausgeführt werden. Sie müssen einen pro Zeile schreiben. Die Umgebungsvariablen {0} und {1} werden verfügbar sein, wobei {0} relativ zu {1} ist. Weitere Informationen über diese Funktion und die verfügbaren Umgebungsvariablen finden Sie unter {2}."
|
||||
"commandRunnerHelp": "Hier können Sie Befehle eingeben, die bei den genannten Ereignissen ausgeführt werden. Sie müssen einen pro Zeile schreiben. Die Umgebungsvariablen {0} und {1} werden verfügbar sein, wobei {0} relativ zu {1} ist. Weitere Informationen über diese Funktion und die verfügbaren Umgebungsvariablen finden Sie unter {2}.",
|
||||
"loginMethods": {
|
||||
"password": "Passwort",
|
||||
"oidc": "OIDC",
|
||||
"proxy": "Vollmacht"
|
||||
},
|
||||
"newUserHintSubFolder": "z.B. '/subfolder' - leer für Standard",
|
||||
"userScopes": "Geltungsbereiche"
|
||||
},
|
||||
"sidebar": {
|
||||
"help": "Hilfe",
|
||||
@@ -399,7 +406,8 @@
|
||||
"enableOnlyOfficeEditingDescription": "Erlauben Sie die Bearbeitung von Office-Dateien mit OnlyOffice in dieser Freigabe.",
|
||||
"titleDefault": "Gemeinsame Dateien - {title}",
|
||||
"defaultViewMode": "Standard-Ansichtsmodus",
|
||||
"defaultViewModeDescription": "Legen Sie den Standardanzeigemodus für anonyme Benutzer fest, die diese Freigabe anzeigen. Angemeldete Benutzer können ihre Ansichtseinstellungen immer noch ändern."
|
||||
"defaultViewModeDescription": "Legen Sie den Standardanzeigemodus für anonyme Benutzer fest, die diese Freigabe anzeigen. Angemeldete Benutzer können ihre Ansichtseinstellungen immer noch ändern.",
|
||||
"disableFileViewer": "Dateibetrachter deaktivieren"
|
||||
},
|
||||
"api": {
|
||||
"title": "API-Schlüssel:",
|
||||
@@ -560,7 +568,8 @@
|
||||
"fileViewerOptions": "Optionen des Dateibetrachters",
|
||||
"themeAndLanguage": "Thema und Sprache",
|
||||
"previewFolder": "Miniaturansichten für Ordner anzeigen, falls vorhanden",
|
||||
"previewFolderDescription": "Zeigen Sie Miniaturvorschauen für Ordner an, deren erstes untergeordnetes Element ein Miniaturbild als Cover hat."
|
||||
"previewFolderDescription": "Zeigen Sie Miniaturvorschauen für Ordner an, deren erstes untergeordnetes Element ein Miniaturbild als Cover hat.",
|
||||
"disableFileExtensions": "Zu deaktivierende Dateierweiterungen eingeben"
|
||||
},
|
||||
"editor": {
|
||||
"uninitialized": "Der Editor konnte nicht initialisiert werden. Bitte neu laden.",
|
||||
|
||||
@@ -328,7 +328,14 @@
|
||||
"configViewerShowComments": "Εμφάνιση σχολίων",
|
||||
"configViewerLoadConfig": "Φόρτωση Config",
|
||||
"brandingHelp": "Μπορείτε να προσαρμόσετε την εμφάνιση και την αίσθηση του File Browser αλλάζοντας το όνομά του, αντικαθιστώντας το λογότυπο, προσθέτοντας προσαρμοσμένα στυλ και ακόμη και απενεργοποιώντας τους εξωτερικούς συνδέσμους προς το GitHub.\nΓια περισσότερες πληροφορίες σχετικά με την προσαρμοσμένη επωνυμία, ανατρέξτε στη διεύθυνση {0}.",
|
||||
"commandRunnerHelp": "Εδώ μπορείτε να ορίσετε τις εντολές που εκτελούνται στα συμβάντα που ονομάζονται. Πρέπει να γράψετε μία ανά γραμμή. Οι περιβαλλοντικές μεταβλητές {0} και {1} θα είναι διαθέσιμες, ενώ η {0} θα είναι σχετική με την {1}. Για περισσότερες πληροφορίες σχετικά με αυτή τη λειτουργία και τις διαθέσιμες μεταβλητές περιβάλλοντος, διαβάστε το {2}."
|
||||
"commandRunnerHelp": "Εδώ μπορείτε να ορίσετε τις εντολές που εκτελούνται στα συμβάντα που ονομάζονται. Πρέπει να γράψετε μία ανά γραμμή. Οι περιβαλλοντικές μεταβλητές {0} και {1} θα είναι διαθέσιμες, ενώ η {0} θα είναι σχετική με την {1}. Για περισσότερες πληροφορίες σχετικά με αυτή τη λειτουργία και τις διαθέσιμες μεταβλητές περιβάλλοντος, διαβάστε το {2}.",
|
||||
"loginMethods": {
|
||||
"password": "Κωδικός πρόσβασης",
|
||||
"oidc": "OIDC",
|
||||
"proxy": "Πληρεξούσιος"
|
||||
},
|
||||
"newUserHintSubFolder": "π.χ. '/subfolder' - κενό για προεπιλογή",
|
||||
"userScopes": "Πεδία"
|
||||
},
|
||||
"sidebar": {
|
||||
"help": "Βοήθεια",
|
||||
@@ -399,7 +406,8 @@
|
||||
"enableOnlyOfficeEditingDescription": "Επιτρέψτε την επεξεργασία αρχείων γραφείου χρησιμοποιώντας το OnlyOffice σε αυτό το κοινόχρηστο χώρο.",
|
||||
"titleDefault": "Κοινόχρηστα αρχεία - {title}",
|
||||
"defaultViewMode": "Προεπιλεγμένη λειτουργία προβολής",
|
||||
"defaultViewModeDescription": "Ορίστε την προεπιλεγμένη λειτουργία προβολής για τους ανώνυμους χρήστες που προβάλλουν αυτή την κοινή χρήση. Οι συνδεδεμένοι χρήστες μπορούν ακόμα να αλλάξουν τις προτιμήσεις προβολής τους."
|
||||
"defaultViewModeDescription": "Ορίστε την προεπιλεγμένη λειτουργία προβολής για τους ανώνυμους χρήστες που προβάλλουν αυτή την κοινή χρήση. Οι συνδεδεμένοι χρήστες μπορούν ακόμα να αλλάξουν τις προτιμήσεις προβολής τους.",
|
||||
"disableFileViewer": "Απενεργοποίηση της προβολής αρχείων"
|
||||
},
|
||||
"api": {
|
||||
"title": "Κλειδιά API:",
|
||||
@@ -560,7 +568,8 @@
|
||||
"fileViewerOptions": "Επιλογές προβολής αρχείων",
|
||||
"themeAndLanguage": "Θέμα & Γλώσσα",
|
||||
"previewFolder": "Εμφάνιση μικρογραφιών για φακέλους, εάν είναι διαθέσιμες",
|
||||
"previewFolderDescription": "Εμφάνιση προεπισκοπήσεων μικρογραφιών για φακέλους με το πρώτο στοιχείο-παιδί με μικρογραφία ως εξώφυλλο."
|
||||
"previewFolderDescription": "Εμφάνιση προεπισκοπήσεων μικρογραφιών για φακέλους με το πρώτο στοιχείο-παιδί με μικρογραφία ως εξώφυλλο.",
|
||||
"disableFileExtensions": "Εισάγετε επεκτάσεις αρχείων για απενεργοποίηση"
|
||||
},
|
||||
"editor": {
|
||||
"uninitialized": "Απέτυχε η αρχικοποίηση του συντάκτη. Παρακαλούμε επαναφορτώστε.",
|
||||
|
||||
@@ -171,6 +171,7 @@
|
||||
"disableShareCard": "Disable share card",
|
||||
"disableShareCardDescription": "Disable the share card on the shared page in the sidebar or at the top of the page on mobile.",
|
||||
"disableSidebar": "Disable sidebar",
|
||||
"disableFileViewer": "Disable File Viewer",
|
||||
"disableSidebarDescription": "Disable the sidebar on the shared page.",
|
||||
"enforceDarkLightMode": "Enforce theme mode",
|
||||
"enforceDarkLightModeDescription": "Force a specific theme mode (dark or light) for this share, overriding user preferences.",
|
||||
@@ -308,6 +309,7 @@
|
||||
"autoplayMediaDescription": "Autoplay media files when viewing. This will only work if the file is a media file.",
|
||||
"sidebarOptions": "Sidebar options",
|
||||
"disableQuickToggles": "Disable quick toggles",
|
||||
"disableFileExtensions": "Enter file extensions to disable",
|
||||
"disableQuickTogglesDescription": "Disable the quick toggles that show up on the sidebar.",
|
||||
"disableSearchOptions": "Disable search options",
|
||||
"disableSearchOptionsDescription": "Disable the search options that show up on the sidebar.",
|
||||
@@ -340,6 +342,11 @@
|
||||
"enterPasswordAgain": "Enter the password again",
|
||||
"passwordsDoNotMatch": "Passwords do not match",
|
||||
"loginMethod": "Login method",
|
||||
"loginMethods": {
|
||||
"password": "Password",
|
||||
"oidc": "OIDC",
|
||||
"proxy": "Proxy"
|
||||
},
|
||||
"loginMethodDescription": "allowed login method for the user.",
|
||||
"modifyOtherUser": "Modify user:",
|
||||
"modifyCurrentUser": "Modify current user",
|
||||
@@ -393,6 +400,7 @@
|
||||
"newPassword": "Your new password",
|
||||
"newPasswordConfirm": "Confirm your new password",
|
||||
"newUser": "New User",
|
||||
"newUserHintSubFolder": "eg '/subfolder' - blank for default",
|
||||
"password": "Password",
|
||||
"passwordUpdated": "Password updated!",
|
||||
"path": "Path",
|
||||
@@ -432,6 +440,7 @@
|
||||
"userUpdated": "User updated!",
|
||||
"username": "Username",
|
||||
"users": "Users",
|
||||
"userScopes": "Scopes",
|
||||
"adminOptions": "Admin user options"
|
||||
},
|
||||
"sidebar": {
|
||||
|
||||
+65
-56
@@ -51,10 +51,10 @@
|
||||
"showLess": "Mostrar menos",
|
||||
"openParentFolder": "Abrir carpeta principal",
|
||||
"selectedCount": "Número de elementos seleccionados para realizar una acción",
|
||||
"listView": "Ver lista",
|
||||
"compactView": "Vista compacta",
|
||||
"normalView": "Vista normal",
|
||||
"galleryView": "Vista de la galería"
|
||||
"listView": "Lista",
|
||||
"compactView": "Compacto",
|
||||
"normalView": "Normal",
|
||||
"galleryView": "Galería"
|
||||
},
|
||||
"download": {
|
||||
"downloadFile": "Descargar archivo",
|
||||
@@ -169,7 +169,7 @@
|
||||
"destinationSource": "Destino:",
|
||||
"downloadsLimit": "Límite de descargas",
|
||||
"maxBandwidth": "Velocidad máxima de descarga (kbps)",
|
||||
"shareTheme": "Tema",
|
||||
"shareTheme": "Apariencia",
|
||||
"shareThemeColor": "Color del tema (valor CSS)",
|
||||
"shareTitle": "Título la página",
|
||||
"shareDescription": "Descripción de la página",
|
||||
@@ -195,37 +195,37 @@
|
||||
"moveSuccess": "¡Archivos movidos con éxito!",
|
||||
"copySuccess": "¡Archivos copiados correctamente!",
|
||||
"lastModified": "Última modificación{suffix}",
|
||||
"size": "Talla{suffix}"
|
||||
"size": "Tamaño {suffix}"
|
||||
},
|
||||
"search": {
|
||||
"images": "Imágenes",
|
||||
"music": "Música",
|
||||
"pdf": "PDF",
|
||||
"search": "Buscar...",
|
||||
"search": " Buscar...",
|
||||
"types": "Tipos",
|
||||
"video": "Vídeo",
|
||||
"path": "Ruta:",
|
||||
"smallerThan": "Menor que:",
|
||||
"largerThan": "Mayor que:",
|
||||
"helpText1": "La búsqueda se realiza por cada carácter (mínimo de 3 caracteres)",
|
||||
"helpText1": "La busqueda se realiza por cada carácter (mínimo de 3 caracteres)",
|
||||
"helpText2": "El índice se actualiza automáticamente (cada 5 min por defecto). Si es interrumpido pueden ocurrir errores",
|
||||
"onlyFolders": "Sólo carpetas",
|
||||
"onlyFiles": "Sólo archivos",
|
||||
"onlyFolders": "Solo carpetas",
|
||||
"onlyFiles": "Solo archivos",
|
||||
"showOptions": "Mostrar opciones",
|
||||
"photos": "Fotos",
|
||||
"audio": "Audio",
|
||||
"videos": "Vídeos",
|
||||
"documents": "Documentos",
|
||||
"archives": "Archivos",
|
||||
"number": "Número",
|
||||
"noResults": "No se han encontrado resultados en la búsqueda indexada.",
|
||||
"searchContext": "Contexto de búsqueda: {context}",
|
||||
"number": "número",
|
||||
"noResults": "No se han encontrado resultados en la busqueda indexada.",
|
||||
"searchContext": "Lugares a buscar: {context}",
|
||||
"notEnoughCharacters": "No hay suficientes caracteres para buscar (min {minSearchLength})",
|
||||
"typeToSearch": "Escriba {minSearchLength} o más caracteres para iniciar la búsqueda."
|
||||
"typeToSearch": "Escribe {minSearchLength} o más carácteres para iniciar la busqueda."
|
||||
},
|
||||
"settings": {
|
||||
"scopes": "Establece los directorios y los permisos que el usuario puede acceder",
|
||||
"admin": "Admin",
|
||||
"scopes": "Fuentes y directorios que el usuario podrá acceder",
|
||||
"admin": "Administrador",
|
||||
"administrator": "Administrador",
|
||||
"allowCommands": "Permitir comandos",
|
||||
"allowEdit": "Editar, borrar, cambiar nombres de archivos o carpetas",
|
||||
@@ -256,10 +256,11 @@
|
||||
"insertRegex": "Ingresar regla RegEx",
|
||||
"instanceName": "Nombre de la instancia",
|
||||
"language": "Idioma",
|
||||
"lockPassword": "Bloquear que el usuario cambie la contraseña",
|
||||
"lockPassword": "Impedir que el usuario cambie su contraseña",
|
||||
"newPassword": "Nueva contraseña",
|
||||
"newPasswordConfirm": "Confirmar nueva contraseña",
|
||||
"newUser": "Nuevo usuario",
|
||||
"newUserHintSubFolder": "ej: '/subfolder' - vacio para predeterminado",
|
||||
"password": "Contraseña",
|
||||
"passwordUpdated": "¡Contraseña actualizada!",
|
||||
"path": "Ruta",
|
||||
@@ -267,11 +268,11 @@
|
||||
"modify": "Editar archivos",
|
||||
"share": "Compartir archivos",
|
||||
"admin": "Administrador",
|
||||
"api": "Gestión de APIs",
|
||||
"api": "Claves API",
|
||||
"realtime": "Actualizaciones en tiempo real"
|
||||
},
|
||||
"permissions-name": "Permisos",
|
||||
"permissionsHelp": "Puedes elegir los permisos individualmente. Si seleccionas \"Administrador\", todas las demás opciones serán activadas. Los usuarios con ese permiso podrán modificar a otros usuarios.\n",
|
||||
"permissionsHelp": "Puedes elegir permisos individualmente. Si seleccionas \"Administrador\", todas las demás opciones serán activadas, y los usuarios con ese permiso podrán modificar a otros usuarios.\n",
|
||||
"profileSettings": "Ajustes del perfil",
|
||||
"ruleExample1": "Previene el acceso a una extensión de archivo (ej: .git) en todas las carpetas.\n",
|
||||
"ruleExample2": "Bloquea el acceso al archivo llamado \"Caddyfile\" en la carpeta raíz (/)",
|
||||
@@ -299,6 +300,7 @@
|
||||
"users": "Usuarios",
|
||||
"modifyOtherUser": "Modificar usuario:",
|
||||
"modifyCurrentUser": "Modificar usuario actual",
|
||||
"userScopes": "Alcances",
|
||||
"themeColor": "Color del tema",
|
||||
"listingOptions": "Opciones de listado",
|
||||
"emptyGlobal": "Actualmente no hay configuracion global, edita el archivo YAML para establecer las configuraciones del sistema",
|
||||
@@ -308,22 +310,27 @@
|
||||
"tusUploadsChunkSize": "Tamaño máximo de subida de un archivo. Puede escribir un número entero que indique una entrada en bytes o una cadena como 10 MB, 1 GB, etc",
|
||||
"tusUploadsRetryCount": "Número de reintentos, si falla la subida de un archivo",
|
||||
"api": "Claves API",
|
||||
"loginMethodDescription": "Método de inicio de sesión permitido",
|
||||
"loginMethodDescription": "Método de inicio de sesión",
|
||||
"userNotAdmin": "El usuario no es administrador",
|
||||
"enterPassword": "Escriba su contraseña",
|
||||
"enterPasswordAgain": "Vuelva a escribir su contraseña",
|
||||
"enterPassword": "Ingrese una contraseña",
|
||||
"enterPasswordAgain": "Vuelva a ingresar la contraseña",
|
||||
"passwordsDoNotMatch": "Las contraseñas no coinciden",
|
||||
"loginMethod": "Método de inicio de sesión",
|
||||
"loginMethods": {
|
||||
"password": "Contraseña",
|
||||
"oidc": "OIDC",
|
||||
"proxy": "Proxy"
|
||||
},
|
||||
"shareSettings": "Recursos compartidos",
|
||||
"adminOptions": "Opciones de administrador",
|
||||
"shareDuration": "Expira en",
|
||||
"searchOptions": "Opciones de búsqueda",
|
||||
"shareDuration": "Tiempo de expiración:",
|
||||
"searchOptions": "Opciones de busqueda",
|
||||
"downloads": "Descargas",
|
||||
"systemAdmin": "Sistema y administración",
|
||||
"configViewer": "Visor de configuración",
|
||||
"configViewerShowFull": "Mostrar configuración completa",
|
||||
"configViewerShowComments": "Mostrar comentarios",
|
||||
"configViewerLoadConfig": "Configuración de carga",
|
||||
"configViewerLoadConfig": "Cargar configuración",
|
||||
"brandingHelp": "Puede personalizar el aspecto de su instancia del Navegador de archivos cambiando su nombre, sustituyendo el logotipo, añadiendo estilos personalizados e incluso desactivando los enlaces externos a GitHub.\nPara obtener más información sobre la personalización de la marca, consulta la página {0}.",
|
||||
"commandRunnerHelp": "Aquí puede establecer los comandos que se ejecutan en los eventos nombrados. Debe escribir uno por línea. Las variables de entorno {0} y {1} estarán disponibles, siendo {0} relativa a {1}. Para más información sobre esta característica y las variables de entorno disponibles, por favor lea el {2}."
|
||||
},
|
||||
@@ -357,21 +364,21 @@
|
||||
"abortUpload": "¿Cancelar subida?"
|
||||
},
|
||||
"share": {
|
||||
"notice": "Nota: cuando se crea un recurso compartido, queda más expuesto al público y tiene diferentes normas de acceso. Si su recurso compartido no está configurado de forma segura, cualquiera puede acceder a él.",
|
||||
"notice": "Nota: Cuando se crea un recurso compartido, este queda más expuesto al público y tendrá diferentes normas de acceso. Si tu recurso compartido no está configurado de forma segura, cualquiera podrá acceder a él.",
|
||||
"disableAnonymousDescription": "Cuando está activado, sólo los usuarios con su permiso pueden acceder al recurso compartido. El usuario debe tener acceso al origen recurso compartido.",
|
||||
"disableThumbnailsDescription": "Si está activada, las miniaturas de previsualización no se mostrarán en la acción.",
|
||||
"disableThumbnailsDescription": "Las miniaturas de archivos, fotos y videos no estarán activadas.",
|
||||
"enableAllowedUsernamesDescription": "Cuando se activa, sólo los usuarios que especifique pueden acceder al recurso compartido. El usuario debe tener acceso al origen del recurso compartido.",
|
||||
"downloadsLimitDescription": "Número máximo de veces que se puede descargar un archivo/carpeta del recurso compartido. Dejar vacío para ilimitado.",
|
||||
"maxBandwidthDescription": "El ancho de banda máximo de descarga en kilobytes por segundo. Dejar vacío para ilimitado.",
|
||||
"shareThemeDescription": "El tema que se utilizará para el enlace compartido.",
|
||||
"disableAnonymous": "Desactivar el acceso anónimo",
|
||||
"disableThumbnails": "Desactivar miniaturas",
|
||||
"enableAllowedUsernames": "Compartir sólo con determinados usuarios",
|
||||
"allowedUsernamesPlaceholder": "Escriba los nombres de usuario (separado por comas)",
|
||||
"enableAllowedUsernames": "Compartir sólo con usuarios predefinidos",
|
||||
"allowedUsernamesPlaceholder": "Nombres de usuario (separado por comas)",
|
||||
"keepAfterExpiration": "No eliminar el recurso compartido una vez expirado",
|
||||
"keepAfterExpirationDescription": "Los recursos compartidos no se borraran cuando expiren. Es útil si después quieres ampliar la duración del recurso, o editarlo.",
|
||||
"saveDisabled": "No se pueden guardar los cambios en los archivos compartidos.",
|
||||
"shareDurationDescription": "Duración del recurso compartido. Dejar en blanco para permanente.",
|
||||
"shareDurationDescription": "Tiempo que durará el recurso compartido, puedes dejarlo en blanco para indefinido. (o sea, sin expiración)",
|
||||
"passwordDescription": "Contraseña opcional necesaria para acceder al recurso compartido, incluidos los usuarios autenticados.",
|
||||
"allowedUsernamesListDescription": "Nombres de usuario separados por comas que pueden acceder al recurso compartido (si está activado).",
|
||||
"shareThemeColorDescription": "Valor de color CSS aplicado al tema de la acción (por ejemplo, rojo, #0ea5e9, rgb(14,165,233)).",
|
||||
@@ -381,49 +388,50 @@
|
||||
"shareBannerDescription": "Enlace (URL), o ruta de la imagen del banner que se mostrará en el enlace -- por ejemplo, 'https://ejemplo.com/banner.png' o '/ruta/a/banner.png'.",
|
||||
"shareFaviconDescription": "Enlace (URL) del icono (favicon) que se mostrará en el enlace -- por ejemplo 'https://domain.com/favicon.png' o '/ruta/a/favicon.png'",
|
||||
"hideNavButtons": "Ocultar botones de navegación",
|
||||
"hideNavButtonsDescription": "Oculte los botones de navegación de la barra de navegación en la acción para crear un aspecto minimalista.",
|
||||
"hideNavButtonsDescription": "Oculta los botones de navegación en la parte superior, es útil si quieres crear un aspecto minimalista.",
|
||||
"viewModeDescription": "Diseño por defecto de la página compartida.",
|
||||
"descriptionDefault": "Se le ha enviado una acción para que la vea o descargue.",
|
||||
"disableShareCard": "Desactivar tarjeta compartida",
|
||||
"disableShareCard": "Desactiva la opción de compartir en la página",
|
||||
"disableShareCardDescription": "Desactivar la tarjeta de compartir en la página compartida en el panel lateral o en la parte superior de la página en móvil.",
|
||||
"disableSidebar": "Desactivar el panel lateral",
|
||||
"disableSidebarDescription": "Desactivar el panel lateral en la página compartida.",
|
||||
"enforceDarkLightMode": "Aplicar el modo temático",
|
||||
"disableFileViewer": "Desactivar visor de archivos",
|
||||
"enforceDarkLightMode": "Tema",
|
||||
"enforceDarkLightModeDescription": "Forzar un modo de tema específico (oscuro o claro) para esta acción, anulando las preferencias del usuario.",
|
||||
"default": "No imponer un modo",
|
||||
"default": "Automatico",
|
||||
"dark": "Oscuro",
|
||||
"light": "Luz",
|
||||
"light": "Claro",
|
||||
"enableOnlyOffice": "Activar el visor de OnlyOffice",
|
||||
"enableOnlyOfficeDescription": "Permitir la visualización de archivos de oficina utilizando OnlyOffice en este recurso compartido.",
|
||||
"enableOnlyOfficeEditing": "Habilitar la edición de OnlyOffice",
|
||||
"enableOnlyOfficeEditing": "Habilitar editor de OnlyOffice",
|
||||
"enableOnlyOfficeEditingDescription": "Permitir la edición de archivos de oficina utilizando OnlyOffice en este recurso compartido.",
|
||||
"titleDefault": "Archivos compartidos - {title}",
|
||||
"defaultViewMode": "Modo de vista por defecto",
|
||||
"defaultViewMode": "Modo de visualización",
|
||||
"defaultViewModeDescription": "Establece el modo de visualización predeterminado para los usuarios anónimos que vean esta acción. Los usuarios registrados pueden cambiar su preferencia de visualización."
|
||||
},
|
||||
"api": {
|
||||
"title": "Claves API",
|
||||
"keyName": "Nombre para la API",
|
||||
"title": "Gestión de claves API",
|
||||
"keyName": "Nombre de la API: ",
|
||||
"createdAt": "Creado en:",
|
||||
"expiresAt": "Expira en:",
|
||||
"expiresAt": "Expira el:",
|
||||
"permissions": "Permisos:",
|
||||
"createTitle": "Crear clave API",
|
||||
"keyNamePlaceholder": "Escriba un nombre único",
|
||||
"tokenDuration": "Duración:",
|
||||
"keyNamePlaceholder": "Ingrese un nombre único",
|
||||
"tokenDuration": "Tiempo de expiración:",
|
||||
"durationNumberPlaceholder": "Número:",
|
||||
"days": "Días",
|
||||
"months": "Meses",
|
||||
"swaggerLinkText": "Documentación de Swagger (API)",
|
||||
"swaggerLinkText": "documentación de Swagger.",
|
||||
"name": "Nombre",
|
||||
"created": "Creado",
|
||||
"expires": "Expira en:",
|
||||
"expires": "Expira el:",
|
||||
"actions": "Acciones",
|
||||
"enabled": "Habilitado",
|
||||
"disabled": "Deshabilitado",
|
||||
"permissionNote": "Elija al menos un permiso para la API. Tu usuario también debe tener ese permiso.",
|
||||
"createKeyFailed": "Error al crear la clave API.",
|
||||
"permissionNote": "Elije al menos un permiso - Tu usuario también debe tener ese permiso.",
|
||||
"createKeyFailed": "Hubo un error al crear la API",
|
||||
"createKeySuccess": "¡Clave API creada con exito!",
|
||||
"description": "Las claves API solo podrán tener los permisos que el usuario tenga. Para más info:"
|
||||
"description": "Las claves API solo podrán tener los permisos que tu usuario tenga. Para más info mira la"
|
||||
},
|
||||
"index": {
|
||||
"status": "Estado",
|
||||
@@ -475,7 +483,7 @@
|
||||
"allow": "Permitir",
|
||||
"allowDeny": "Permitir/Denegar",
|
||||
"userGroup": "Usuario/Grupo",
|
||||
"enterName": "Escriba el nombre del usuario o del grupo",
|
||||
"enterName": "Escribe el nombre del usuario, o del grupo",
|
||||
"deleted": "Regla de acceso eliminada",
|
||||
"added": "Regla de acceso añadida",
|
||||
"name": "Nombre",
|
||||
@@ -492,7 +500,7 @@
|
||||
"maxConcurrentUploadHelp": "El número máximo de archivos a subir a la vez. Los valores más altos suelen dar mejores resultados, pero esto depende de muchos factores",
|
||||
"uploadChunkSizeMb": "Velocidad de subida (en MB)",
|
||||
"uploadChunkSizeMbHelp": "Típicamente 5 a 50 MB, más pequeño es mejor para conexiones inestables. Si se pone a 0, se desactiva la subida de archivo en partes",
|
||||
"clearAll": "El borrado completado debería eliminar el error, la pausa y el conflicto",
|
||||
"clearAll": " Hacer que 'Limpiar completados' borre todo (incluyendo error, pausa y en conflicto)",
|
||||
"clearAllDescription": "El botón de borrado de cargas completadas borrará todas las cargas en estado finalizado, incluidos los estados de error, pausa y conflicto."
|
||||
},
|
||||
"colors": {
|
||||
@@ -510,7 +518,7 @@
|
||||
"editorViewerOptions": "Edición y visualización",
|
||||
"editorQuickSave": "Botón de guardado rápido en el editor",
|
||||
"editorQuickSaveDescription": "El editor de texto mostrará siempre el icono de guardar, ocultando cualquier otra acción",
|
||||
"previewOffice": "Mostrar miniaturas de los archivos de Office",
|
||||
"previewOffice": "Mostrar miniaturas para los archivos de Office",
|
||||
"previewOfficeDescription": "Mostrar miniaturas de los tipos de archivos de Office en el tipo de vista: Lista",
|
||||
"disableThumbnailPreviews": "Desactivar miniaturas para las siguientes extensiones:",
|
||||
"disableThumbnailPreviewsDescription": "Una lista (separada por espacios) de extensiones de archivo para desactivar la vista previa en las miniaturas (por ejemplo, '.txt .html').",
|
||||
@@ -535,7 +543,7 @@
|
||||
"previewVideosDescription": "Mostrar vistas previas en las miniaturas de los vídeos, mostrará un icono genérico si está desactivado",
|
||||
"previewMotionVideos": "Previsualización de vídeos",
|
||||
"previewMotionVideosDescription": "Recorre los fotogramas del vídeo. \n La previsulización de videos es en la vista previa emergente (tienes que tener activada esa opción también).",
|
||||
"highQualityPreview": "Miniaturas de alta calidad",
|
||||
"highQualityPreview": "Miniaturas en alta resolución",
|
||||
"highQualityPreviewDescription": "Sólo afecta a la vista de galería. Puede tardar más en cargarse si la conexión es lenta",
|
||||
"editOffice": "Ver y editar archivos de ofimática con OnlyOffice",
|
||||
"editOfficeDescription": "Cuando está activado, el visor/editor de archivos estará desactivado para todas las extensiones de archivo relacionadas con archivos office, en vez, las abrirá en OnlyOffice.",
|
||||
@@ -548,8 +556,9 @@
|
||||
"disableHideSidebarDescription": "Si está activado, el panel lateral no se ocultará automáticamente al previsualizar o editar archivos.",
|
||||
"disableQuickToggles": "Desactivar ajustes rápidos",
|
||||
"disableQuickTogglesDescription": "Desactiva los ajustes rápidos que aparecen en el panel lateral. (cambiar tema, visibilidad del panel y modo de un solo click)",
|
||||
"disableSearchOptions": "Desactivar las opciones de búsqueda",
|
||||
"disableSearchOptionsDescription": "Desactivar las opciones de búsqueda que aparecen en el panel.",
|
||||
"disableSearchOptions": "Desactivar las opciones de busqueda",
|
||||
"disableFileExtensions": "Escribe aquí las extensiones para deshabilitar",
|
||||
"disableSearchOptionsDescription": "Desactivar las opciones de busqueda que aparecen en el panel.",
|
||||
"hideSidebarFileActions": "Ocultar el botón de acciones de archivo en el panel",
|
||||
"deleteWithoutConfirming": "Eliminar archivos sin mensaje de confirmación",
|
||||
"deleteWithoutConfirmingDescription": "Eliminar archivos sin mensaje de confirmación. Esto sólo funcionará si el archivo es un archivo multimedia.",
|
||||
@@ -558,9 +567,9 @@
|
||||
"debugOfficeEditor": "Activar el modo de depuración de OnlyOffice",
|
||||
"debugOfficeEditorDescription": "Mostrar una ventana emergente de depuración con información adicional en el editor OnlyOffice.",
|
||||
"fileViewerOptions": "Opciones del visor de archivos",
|
||||
"themeAndLanguage": "Tema y lengua",
|
||||
"previewFolder": "Mostrar miniaturas de las carpetas si están disponibles",
|
||||
"previewFolderDescription": "Mostrar vistas previas en miniatura para carpetas con el primer elemento hijo con miniatura como portada."
|
||||
"themeAndLanguage": "Tema e idioma",
|
||||
"previewFolder": "Vista previa para carpetas (si se puede)",
|
||||
"previewFolderDescription": "Mostrar vistas previas en miniatura para las carpetas - Se usará el primer elemento como portada. (puede ser una imagen, o audio con imagén)"
|
||||
},
|
||||
"editor": {
|
||||
"uninitialized": "Error al iniciar el editor, inténtelo de nuevo",
|
||||
|
||||
@@ -325,7 +325,14 @@
|
||||
"configViewerShowComments": "Afficher les commentaires",
|
||||
"configViewerLoadConfig": "Configuration de la charge",
|
||||
"brandingHelp": "Vous pouvez personnaliser l'apparence de votre instance du navigateur de fichiers en changeant son nom, en remplaçant le logo, en ajoutant des styles personnalisés et même en désactivant les liens externes vers GitHub.\nPour plus d'informations sur la personnalisation de l'image de marque, consultez le site {0}.",
|
||||
"commandRunnerHelp": "Vous pouvez définir ici les commandes qui seront exécutées dans les événements nommés. Vous devez en écrire une par ligne. Les variables d'environnement {0} et {1} seront disponibles, {0} étant relatif à {1}. Pour plus d'informations sur cette fonctionnalité et les variables d'environnement disponibles, veuillez consulter le site {2}."
|
||||
"commandRunnerHelp": "Vous pouvez définir ici les commandes qui seront exécutées dans les événements nommés. Vous devez en écrire une par ligne. Les variables d'environnement {0} et {1} seront disponibles, {0} étant relatif à {1}. Pour plus d'informations sur cette fonctionnalité et les variables d'environnement disponibles, veuillez consulter le site {2}.",
|
||||
"loginMethods": {
|
||||
"password": "Mot de passe",
|
||||
"oidc": "OIDC",
|
||||
"proxy": "Proxy"
|
||||
},
|
||||
"newUserHintSubFolder": "par exemple \"/sous-dossier\" - vide par défaut",
|
||||
"userScopes": "Scopes"
|
||||
},
|
||||
"sidebar": {
|
||||
"help": "Aide",
|
||||
@@ -399,7 +406,8 @@
|
||||
"enableOnlyOfficeEditingDescription": "Autoriser l'édition de fichiers bureautiques en utilisant OnlyOffice dans ce partage.",
|
||||
"titleDefault": "Fichiers partagés - {title}",
|
||||
"defaultViewMode": "Mode d'affichage par défaut",
|
||||
"defaultViewModeDescription": "Définit le mode d'affichage par défaut pour les utilisateurs anonymes qui consultent ce partage. Les utilisateurs connectés peuvent toujours modifier leurs préférences d'affichage."
|
||||
"defaultViewModeDescription": "Définit le mode d'affichage par défaut pour les utilisateurs anonymes qui consultent ce partage. Les utilisateurs connectés peuvent toujours modifier leurs préférences d'affichage.",
|
||||
"disableFileViewer": "Désactiver le visualiseur de fichiers"
|
||||
},
|
||||
"api": {
|
||||
"title": "Clés API :",
|
||||
@@ -560,7 +568,8 @@
|
||||
"fileViewerOptions": "Options de visualisation des fichiers",
|
||||
"themeAndLanguage": "Thème et langue",
|
||||
"previewFolder": "Afficher les vignettes des dossiers si elles sont disponibles",
|
||||
"previewFolderDescription": "Afficher des aperçus de vignettes pour les dossiers dont le premier élément enfant a une vignette comme couverture."
|
||||
"previewFolderDescription": "Afficher des aperçus de vignettes pour les dossiers dont le premier élément enfant a une vignette comme couverture.",
|
||||
"disableFileExtensions": "Saisir les extensions de fichiers à désactiver"
|
||||
},
|
||||
"editor": {
|
||||
"uninitialized": "Échec de l'initialisation de l'éditeur. Veuillez recharger.",
|
||||
|
||||
@@ -325,7 +325,14 @@
|
||||
"configViewerShowComments": "הצג תגובות",
|
||||
"configViewerLoadConfig": "טען תצורה",
|
||||
"brandingHelp": "ניתן להתאים אישית את המראה והתחושה של דפדפן הקבצים על ידי שינוי שמו, החלפת הלוגו, הוספת סגנונות מותאמים אישית ואפילו השבתת קישורים חיצוניים ל-GitHub.\nלמידע נוסף על מיתוג מותאם אישית, בקרו ב- {0}.",
|
||||
"commandRunnerHelp": "כאן תוכל להגדיר פקודות שיבוצעו באירועים המוזכרים. עליך לכתוב פקודה אחת בכל שורה. המשתנים הסביבתיים {0} ו- {1} יהיו זמינים, והם יהיו {0} ביחס ל- {1}. למידע נוסף על תכונה זו ועל המשתנים הסביבתיים הזמינים, אנא קרא את {2}."
|
||||
"commandRunnerHelp": "כאן תוכל להגדיר פקודות שיבוצעו באירועים המוזכרים. עליך לכתוב פקודה אחת בכל שורה. המשתנים הסביבתיים {0} ו- {1} יהיו זמינים, והם יהיו {0} ביחס ל- {1}. למידע נוסף על תכונה זו ועל המשתנים הסביבתיים הזמינים, אנא קרא את {2}.",
|
||||
"loginMethods": {
|
||||
"password": "סיסמה",
|
||||
"oidc": "OIDC",
|
||||
"proxy": "פרוקסי"
|
||||
},
|
||||
"newUserHintSubFolder": "לדוגמה, '/subfolder' - ריק עבור ברירת המחדל",
|
||||
"userScopes": "סקופים"
|
||||
},
|
||||
"sidebar": {
|
||||
"help": "עזרה",
|
||||
@@ -399,7 +406,8 @@
|
||||
"enableOnlyOfficeEditingDescription": "אפשר עריכת קבצי Office באמצעות OnlyOffice בשיתוף זה.",
|
||||
"titleDefault": "קבצים משותפים - {title}",
|
||||
"defaultViewMode": "מצב תצוגה ברירת מחדל",
|
||||
"defaultViewModeDescription": "הגדר את מצב התצוגה המוגדר כברירת מחדל עבור משתמשים אנונימיים הצופים בשיתוף זה. משתמשים מחוברים עדיין יכולים לשנות את העדפות התצוגה שלהם."
|
||||
"defaultViewModeDescription": "הגדר את מצב התצוגה המוגדר כברירת מחדל עבור משתמשים אנונימיים הצופים בשיתוף זה. משתמשים מחוברים עדיין יכולים לשנות את העדפות התצוגה שלהם.",
|
||||
"disableFileViewer": "השבת תצוגת קבצים"
|
||||
},
|
||||
"api": {
|
||||
"title": "מפתחות API",
|
||||
@@ -560,7 +568,8 @@
|
||||
"fileViewerOptions": "אפשרויות תצוגת קבצים",
|
||||
"themeAndLanguage": "נושא ושפה",
|
||||
"previewFolder": "הצג תמונות ממוזערות של תיקיות, אם זמינות",
|
||||
"previewFolderDescription": "הצג תצוגה מקדימה של תמונות ממוזערות עבור תיקיות עם פריט משנה ראשון עם תמונה ממוזערת ככריכה."
|
||||
"previewFolderDescription": "הצג תצוגה מקדימה של תמונות ממוזערות עבור תיקיות עם פריט משנה ראשון עם תמונה ממוזערת ככריכה.",
|
||||
"disableFileExtensions": "הזן סיומות קבצים שיש להשבית"
|
||||
},
|
||||
"editor": {
|
||||
"uninitialized": "לא ניתן לאתחל את העורך. אנא טען מחדש.",
|
||||
|
||||
@@ -325,7 +325,14 @@
|
||||
"configViewerShowComments": "Hozzászólások megjelenítése",
|
||||
"configViewerLoadConfig": "Konfiguráció betöltése",
|
||||
"brandingHelp": "Testreszabhatja a Fájlböngésző példányának megjelenését és érzetét a név megváltoztatásával, a logó cseréjével, egyéni stílusok hozzáadásával és akár a GitHubra mutató külső hivatkozások letiltásával.\nAz egyéni márkaépítéssel kapcsolatos további információkat a {0} oldalon talál.",
|
||||
"commandRunnerHelp": "Itt állíthatja be azokat a parancsokat, amelyek a megnevezett események során végrehajtásra kerülnek. Soronként egyet kell írni. A {0} és a {1} környezeti változók lesznek elérhetőek, a {0} a {1}-hoz képest . A funkcióról és a rendelkezésre álló környezeti változókról bővebb információt a {2} oldalon talál."
|
||||
"commandRunnerHelp": "Itt állíthatja be azokat a parancsokat, amelyek a megnevezett események során végrehajtásra kerülnek. Soronként egyet kell írni. A {0} és a {1} környezeti változók lesznek elérhetőek, a {0} a {1}-hoz képest . A funkcióról és a rendelkezésre álló környezeti változókról bővebb információt a {2} oldalon talál.",
|
||||
"loginMethods": {
|
||||
"password": "Jelszó",
|
||||
"oidc": "OIDC",
|
||||
"proxy": "Proxy"
|
||||
},
|
||||
"newUserHintSubFolder": "pl. '/subfolder' - üres az alapértelmezéshez",
|
||||
"userScopes": "Területek"
|
||||
},
|
||||
"sidebar": {
|
||||
"help": "Súgó",
|
||||
@@ -399,7 +406,8 @@
|
||||
"enableOnlyOfficeEditingDescription": "Engedélyezze az irodai fájlok szerkesztését az OnlyOffice használatával ezen a megosztáson.",
|
||||
"titleDefault": "Megosztott fájlok - {title}",
|
||||
"defaultViewMode": "Alapértelmezett nézetmód",
|
||||
"defaultViewModeDescription": "A megosztást megtekintő névtelen felhasználók alapértelmezett megtekintési módjának beállítása. A bejelentkezett felhasználók továbbra is módosíthatják a nézeti beállításaikat."
|
||||
"defaultViewModeDescription": "A megosztást megtekintő névtelen felhasználók alapértelmezett megtekintési módjának beállítása. A bejelentkezett felhasználók továbbra is módosíthatják a nézeti beállításaikat.",
|
||||
"disableFileViewer": "Fájlnéző letiltása"
|
||||
},
|
||||
"api": {
|
||||
"title": "API-kulcsok:",
|
||||
@@ -560,7 +568,8 @@
|
||||
"fileViewerOptions": "Fájlnéző beállításai",
|
||||
"themeAndLanguage": "Téma és nyelv",
|
||||
"previewFolder": "A mappák miniatűrjeinek megjelenítése, ha rendelkezésre állnak",
|
||||
"previewFolderDescription": "Miniatűr előnézetek megjelenítése olyan mappákhoz, amelyekben az első gyermekelem miniatűrrel borítóképként szerepel."
|
||||
"previewFolderDescription": "Miniatűr előnézetek megjelenítése olyan mappákhoz, amelyekben az első gyermekelem miniatűrrel borítóképként szerepel.",
|
||||
"disableFileExtensions": "Adja meg a letiltandó fájlkiterjesztéseket"
|
||||
},
|
||||
"editor": {
|
||||
"uninitialized": "Nem sikerült inicializálni a szerkesztőt. Kérjük, töltse újra.",
|
||||
|
||||
@@ -325,7 +325,14 @@
|
||||
"configViewerShowComments": "Mostra i commenti",
|
||||
"configViewerLoadConfig": "Configurazione del carico",
|
||||
"brandingHelp": "È possibile personalizzare l'aspetto dell'istanza del File Browser cambiandone il nome, sostituendo il logo, aggiungendo stili personalizzati e persino disabilitando i collegamenti esterni a GitHub.\nPer ulteriori informazioni sul branding personalizzato, consultare il sito {0}.",
|
||||
"commandRunnerHelp": "Qui è possibile impostare i comandi che vengono eseguiti negli eventi denominati. È necessario scriverne uno per riga. Le variabili d'ambiente {0} e {1} saranno disponibili, essendo {0} relativa a {1}. Per ulteriori informazioni su questa funzione e sulle variabili d'ambiente disponibili, leggere {2}."
|
||||
"commandRunnerHelp": "Qui è possibile impostare i comandi che vengono eseguiti negli eventi denominati. È necessario scriverne uno per riga. Le variabili d'ambiente {0} e {1} saranno disponibili, essendo {0} relativa a {1}. Per ulteriori informazioni su questa funzione e sulle variabili d'ambiente disponibili, leggere {2}.",
|
||||
"loginMethods": {
|
||||
"password": "Password",
|
||||
"oidc": "OIDC",
|
||||
"proxy": "Proxy"
|
||||
},
|
||||
"newUserHintSubFolder": "es. '/sottocartella' - vuoto per l'impostazione predefinita",
|
||||
"userScopes": "Cannocchiali"
|
||||
},
|
||||
"sidebar": {
|
||||
"help": "Aiuto",
|
||||
@@ -399,7 +406,8 @@
|
||||
"enableOnlyOfficeEditingDescription": "Consentire la modifica dei file di Office utilizzando OnlyOffice in questa condivisione.",
|
||||
"titleDefault": "File condivisi - {title}",
|
||||
"defaultViewMode": "Modalità di visualizzazione predefinita",
|
||||
"defaultViewModeDescription": "Imposta la modalità di visualizzazione predefinita per gli utenti anonimi che visualizzano questa condivisione. Gli utenti registrati possono comunque modificare le loro preferenze di visualizzazione."
|
||||
"defaultViewModeDescription": "Imposta la modalità di visualizzazione predefinita per gli utenti anonimi che visualizzano questa condivisione. Gli utenti registrati possono comunque modificare le loro preferenze di visualizzazione.",
|
||||
"disableFileViewer": "Disattivare il visualizzatore di file"
|
||||
},
|
||||
"api": {
|
||||
"title": "Chiavi API:",
|
||||
@@ -560,7 +568,8 @@
|
||||
"fileViewerOptions": "Opzioni del visualizzatore di file",
|
||||
"themeAndLanguage": "Tema e linguaggio",
|
||||
"previewFolder": "Mostra le miniature delle cartelle, se disponibili.",
|
||||
"previewFolderDescription": "Mostra le anteprime per le cartelle con il primo elemento figlio con miniatura come copertina."
|
||||
"previewFolderDescription": "Mostra le anteprime per le cartelle con il primo elemento figlio con miniatura come copertina.",
|
||||
"disableFileExtensions": "Immettere le estensioni dei file da disabilitare"
|
||||
},
|
||||
"editor": {
|
||||
"uninitialized": "Impossibile inizializzare l'editor. Ricaricare.",
|
||||
|
||||
@@ -325,7 +325,14 @@
|
||||
"configViewerShowComments": "コメントを表示",
|
||||
"configViewerLoadConfig": "設定を読み込み",
|
||||
"brandingHelp": "File Browserインスタンスの見た目と操作感は、名前の変更、ロゴの置き換え、カスタムスタイルの追加、GitHubへの外部リンクの無効化などでカスタマイズできます。\nカスタムブランディングの詳細については、{0}をご覧ください。",
|
||||
"commandRunnerHelp": "ここでは、指定したイベントで実行されるコマンドを設定できます。1行に1つ記述する必要があります。環境変数{0}と{1}が使用可能になり、{0}は{1}の相対変数になります。この機能と使用可能な環境変数の詳細については、{2}をお読みください。"
|
||||
"commandRunnerHelp": "ここでは、指定したイベントで実行されるコマンドを設定できます。1行に1つ記述する必要があります。環境変数{0}と{1}が使用可能になり、{0}は{1}の相対変数になります。この機能と使用可能な環境変数の詳細については、{2}をお読みください。",
|
||||
"loginMethods": {
|
||||
"password": "パスワード",
|
||||
"oidc": "OIDC",
|
||||
"proxy": "プロキシ"
|
||||
},
|
||||
"newUserHintSubFolder": "例 '/subfolder' - デフォルトは空白",
|
||||
"userScopes": "スコープ"
|
||||
},
|
||||
"sidebar": {
|
||||
"help": "ヘルプ",
|
||||
@@ -399,7 +406,8 @@
|
||||
"defaultViewMode": "デフォルトの表示モード",
|
||||
"defaultViewModeDescription": "匿名ユーザーがこの共有を閲覧する際のデフォルトの表示モードを設定します。ログインしているユーザーは、表示モードを変更することができます。",
|
||||
"enableOnlyOfficeEditingDescription": "この共有でOnlyOfficeを使ったオフィスファイルの編集を許可する",
|
||||
"titleDefault": "共有ファイル{title}"
|
||||
"titleDefault": "共有ファイル{title}",
|
||||
"disableFileViewer": "ファイルビューアを無効にする"
|
||||
},
|
||||
"api": {
|
||||
"title": "APIキー:",
|
||||
@@ -560,7 +568,8 @@
|
||||
"fileViewerOptions": "ファイルビューアオプション",
|
||||
"themeAndLanguage": "テーマと言語",
|
||||
"previewFolder": "利用可能であれば、フォルダのサムネイルを表示する",
|
||||
"previewFolderDescription": "サムネイルを持つ最初の子アイテムをカバーとするフォルダのサムネイルプレビューを表示します。"
|
||||
"previewFolderDescription": "サムネイルを持つ最初の子アイテムをカバーとするフォルダのサムネイルプレビューを表示します。",
|
||||
"disableFileExtensions": "無効にするファイル拡張子を入力"
|
||||
},
|
||||
"editor": {
|
||||
"uninitialized": "エディタの初期化に失敗しました。再読み込みしてください。",
|
||||
|
||||
@@ -325,7 +325,14 @@
|
||||
"configViewerShowComments": "댓글 표시",
|
||||
"configViewerLoadConfig": "로드 구성",
|
||||
"brandingHelp": "파일 브라우저 인스턴스의 이름을 변경하고, 로고를 바꾸고, 사용자 지정 스타일을 추가하고, GitHub에 대한 외부 링크를 비활성화하여 모양과 느낌을 사용자 지정할 수 있습니다.\n사용자 지정 브랜딩에 대한 자세한 내용은 {0} 을 참조하세요.",
|
||||
"commandRunnerHelp": "여기에서 명명된 이벤트에서 실행되는 명령을 설정할 수 있습니다. 한 줄에 하나씩 작성해야 합니다. 환경 변수 {0} 및 {1} 을 사용할 수 있으며, {0} 은 {1} 에 상대적입니다. 이 기능 및 사용 가능한 환경 변수에 대한 자세한 내용은 {2} 을 참조하세요."
|
||||
"commandRunnerHelp": "여기에서 명명된 이벤트에서 실행되는 명령을 설정할 수 있습니다. 한 줄에 하나씩 작성해야 합니다. 환경 변수 {0} 및 {1} 을 사용할 수 있으며, {0} 은 {1} 에 상대적입니다. 이 기능 및 사용 가능한 환경 변수에 대한 자세한 내용은 {2} 을 참조하세요.",
|
||||
"loginMethods": {
|
||||
"password": "비밀번호",
|
||||
"oidc": "OIDC",
|
||||
"proxy": "프록시"
|
||||
},
|
||||
"newUserHintSubFolder": "예: '/subfolder' - 기본값은 비어 있습니다.",
|
||||
"userScopes": "범위"
|
||||
},
|
||||
"sidebar": {
|
||||
"help": "도움말",
|
||||
@@ -399,7 +406,8 @@
|
||||
"enableOnlyOfficeEditingDescription": "이 공유에서 OnlyOffice를 사용하여 Office 파일을 편집할 수 있도록 허용합니다.",
|
||||
"titleDefault": "공유 파일 - {title}",
|
||||
"defaultViewMode": "기본 보기 모드",
|
||||
"defaultViewModeDescription": "이 공유를 보는 익명 사용자의 기본 보기 모드를 설정합니다. 로그인한 사용자는 여전히 보기 기본 설정을 변경할 수 있습니다."
|
||||
"defaultViewModeDescription": "이 공유를 보는 익명 사용자의 기본 보기 모드를 설정합니다. 로그인한 사용자는 여전히 보기 기본 설정을 변경할 수 있습니다.",
|
||||
"disableFileViewer": "파일 뷰어 비활성화"
|
||||
},
|
||||
"api": {
|
||||
"title": "API 키:",
|
||||
@@ -560,7 +568,8 @@
|
||||
"fileViewerOptions": "파일 뷰어 옵션",
|
||||
"themeAndLanguage": "테마 및 언어",
|
||||
"previewFolder": "가능한 경우 폴더의 썸네일 표시",
|
||||
"previewFolderDescription": "썸네일을 표지로 사용하는 첫 번째 하위 항목이 있는 폴더의 썸네일 미리 보기를 표시합니다."
|
||||
"previewFolderDescription": "썸네일을 표지로 사용하는 첫 번째 하위 항목이 있는 폴더의 썸네일 미리 보기를 표시합니다.",
|
||||
"disableFileExtensions": "비활성화할 파일 확장명을 입력합니다."
|
||||
},
|
||||
"editor": {
|
||||
"uninitialized": "편집기를 초기화하지 못했습니다. 다시 로드하세요.",
|
||||
|
||||
@@ -325,7 +325,14 @@
|
||||
"configViewerShowComments": "Opmerkingen tonen",
|
||||
"configViewerLoadConfig": "Configuratie laden",
|
||||
"brandingHelp": "Je kunt aanpassen hoe je File Browser instance eruit ziet en aanvoelt door de naam te veranderen, het logo te vervangen, aangepaste stijlen toe te voegen en zelfs externe links naar GitHub uit te schakelen.\nVoor meer informatie over het aanpassen van je branding, kun je kijken op {0}.",
|
||||
"commandRunnerHelp": "Hier kun je commando's instellen die worden uitgevoerd in de genoemde gebeurtenissen. Je moet er één per regel schrijven. De omgevingsvariabelen {0} en {1} zullen beschikbaar zijn, waarbij {0} relatief is ten opzichte van {1}. Lees voor meer informatie over deze functie en de beschikbare omgevingsvariabelen de {2}."
|
||||
"commandRunnerHelp": "Hier kun je commando's instellen die worden uitgevoerd in de genoemde gebeurtenissen. Je moet er één per regel schrijven. De omgevingsvariabelen {0} en {1} zullen beschikbaar zijn, waarbij {0} relatief is ten opzichte van {1}. Lees voor meer informatie over deze functie en de beschikbare omgevingsvariabelen de {2}.",
|
||||
"loginMethods": {
|
||||
"password": "Wachtwoord",
|
||||
"oidc": "OIDC",
|
||||
"proxy": "Proxy"
|
||||
},
|
||||
"newUserHintSubFolder": "bijv. '/submap' - leeg voor standaard",
|
||||
"userScopes": "Richtkijkers"
|
||||
},
|
||||
"sidebar": {
|
||||
"help": "Help",
|
||||
@@ -399,7 +406,8 @@
|
||||
"enableOnlyOfficeEditingDescription": "Laat het bewerken van Office-bestanden met OnlyOffice toe in deze share.",
|
||||
"titleDefault": "Gedeelde bestanden - {title}",
|
||||
"defaultViewMode": "Standaard weergavemodus",
|
||||
"defaultViewModeDescription": "Stel de standaard weergavemodus in voor anonieme gebruikers die deze share bekijken. Aangemelde gebruikers kunnen nog steeds hun weergavevoorkeur wijzigen."
|
||||
"defaultViewModeDescription": "Stel de standaard weergavemodus in voor anonieme gebruikers die deze share bekijken. Aangemelde gebruikers kunnen nog steeds hun weergavevoorkeur wijzigen.",
|
||||
"disableFileViewer": "Bestandsviewer uitschakelen"
|
||||
},
|
||||
"api": {
|
||||
"title": "API-sleutels",
|
||||
@@ -524,7 +532,8 @@
|
||||
"fileViewerOptions": "Opties voor bestandsviewer",
|
||||
"themeAndLanguage": "Thema & Taal",
|
||||
"previewFolder": "Toon miniaturen voor mappen indien beschikbaar",
|
||||
"previewFolderDescription": "Toon miniatuurafbeeldingen voor mappen met het eerste kinditem met miniatuurafbeelding als omslag."
|
||||
"previewFolderDescription": "Toon miniatuurafbeeldingen voor mappen met het eerste kinditem met miniatuurafbeelding als omslag.",
|
||||
"disableFileExtensions": "Voer bestandsextensies in om uit te schakelen"
|
||||
},
|
||||
"general": {
|
||||
"name": "Naam",
|
||||
|
||||
@@ -325,7 +325,14 @@
|
||||
"configViewerShowComments": "Pokaż komentarze",
|
||||
"configViewerLoadConfig": "Konfiguracja ładowania",
|
||||
"brandingHelp": "Możesz dostosować wygląd i sposób działania swojej instancji przeglądarki plików, zmieniając jej nazwę, zastępując logo, dodając niestandardowe style, a nawet wyłączając zewnętrzne łącza do GitHub.\nWięcej informacji na temat niestandardowego brandingu można znaleźć na stronie {0}.",
|
||||
"commandRunnerHelp": "Tutaj można ustawić polecenia, które będą wykonywane w nazwanych zdarzeniach. Należy wpisać jedną komendę w wierszu. Dostępne będą zmienne środowiskowe {0} i {1}, przy czym {0} będzie relatywna do {1}. Więcej informacji na temat tej funkcji i dostępnych zmiennych środowiskowych można znaleźć na stronie {2}."
|
||||
"commandRunnerHelp": "Tutaj można ustawić polecenia, które będą wykonywane w nazwanych zdarzeniach. Należy wpisać jedną komendę w wierszu. Dostępne będą zmienne środowiskowe {0} i {1}, przy czym {0} będzie relatywna do {1}. Więcej informacji na temat tej funkcji i dostępnych zmiennych środowiskowych można znaleźć na stronie {2}.",
|
||||
"loginMethods": {
|
||||
"password": "Hasło",
|
||||
"oidc": "OIDC",
|
||||
"proxy": "Pełnomocnik"
|
||||
},
|
||||
"newUserHintSubFolder": "np. '/subfolder' - domyślnie puste",
|
||||
"userScopes": "Lunety"
|
||||
},
|
||||
"sidebar": {
|
||||
"help": "Pomoc",
|
||||
@@ -399,7 +406,8 @@
|
||||
"enableOnlyOfficeEditingDescription": "Zezwalaj na edycję plików biurowych za pomocą OnlyOffice w tym udziale.",
|
||||
"titleDefault": "Udostępnione pliki - {title}",
|
||||
"defaultViewMode": "Domyślny tryb widoku",
|
||||
"defaultViewModeDescription": "Ustawia domyślny tryb widoku dla anonimowych użytkowników przeglądających ten folder udostępniony. Zalogowani użytkownicy mogą nadal zmieniać swoje preferencje widoku."
|
||||
"defaultViewModeDescription": "Ustawia domyślny tryb widoku dla anonimowych użytkowników przeglądających ten folder udostępniony. Zalogowani użytkownicy mogą nadal zmieniać swoje preferencje widoku.",
|
||||
"disableFileViewer": "Wyłącz przeglądarkę plików"
|
||||
},
|
||||
"api": {
|
||||
"title": "Klucze API:",
|
||||
@@ -560,7 +568,8 @@
|
||||
"fileViewerOptions": "Opcje przeglądarki plików",
|
||||
"themeAndLanguage": "Temat i język",
|
||||
"previewFolder": "Wyświetlanie miniatur folderów, jeśli są dostępne.",
|
||||
"previewFolderDescription": "Wyświetlanie podglądu miniatur dla folderów z pierwszym elementem podrzędnym z miniaturą jako okładką."
|
||||
"previewFolderDescription": "Wyświetlanie podglądu miniatur dla folderów z pierwszym elementem podrzędnym z miniaturą jako okładką.",
|
||||
"disableFileExtensions": "Wprowadź rozszerzenia plików do wyłączenia"
|
||||
},
|
||||
"editor": {
|
||||
"uninitialized": "Nie udało się zainicjować edytora. Przeładuj ponownie.",
|
||||
|
||||
@@ -325,7 +325,14 @@
|
||||
"configViewerShowComments": "Mostrar comentários",
|
||||
"configViewerLoadConfig": "Configuração de carga",
|
||||
"brandingHelp": "Você pode personalizar a aparência da instância do Navegador de arquivos alterando seu nome, substituindo o logotipo, adicionando estilos personalizados e até mesmo desativando links externos para o GitHub.\nPara obter mais informações sobre branding personalizado, consulte {0}.",
|
||||
"commandRunnerHelp": "Aqui você pode definir comandos que são executados nos eventos nomeados. Você deve escrever um por linha. As variáveis de ambiente {0} e {1} estarão disponíveis, sendo {0} relativo a {1}. Para obter mais informações sobre esse recurso e as variáveis de ambiente disponíveis, leia {2}."
|
||||
"commandRunnerHelp": "Aqui você pode definir comandos que são executados nos eventos nomeados. Você deve escrever um por linha. As variáveis de ambiente {0} e {1} estarão disponíveis, sendo {0} relativo a {1}. Para obter mais informações sobre esse recurso e as variáveis de ambiente disponíveis, leia {2}.",
|
||||
"loginMethods": {
|
||||
"password": "Senha",
|
||||
"oidc": "OIDC",
|
||||
"proxy": "Proxy"
|
||||
},
|
||||
"newUserHintSubFolder": "Por exemplo, '/subfolder' - em branco para o padrão",
|
||||
"userScopes": "Escopos"
|
||||
},
|
||||
"sidebar": {
|
||||
"help": "Ajuda",
|
||||
@@ -399,7 +406,8 @@
|
||||
"enableOnlyOfficeEditingDescription": "Permitir a edição de arquivos do Office usando o OnlyOffice nesse compartilhamento.",
|
||||
"titleDefault": "Arquivos compartilhados - {title}",
|
||||
"defaultViewMode": "Modo de exibição padrão",
|
||||
"defaultViewModeDescription": "Define o modo de exibição padrão para usuários anônimos que visualizam esse compartilhamento. Os usuários conectados ainda podem alterar sua preferência de visualização."
|
||||
"defaultViewModeDescription": "Define o modo de exibição padrão para usuários anônimos que visualizam esse compartilhamento. Os usuários conectados ainda podem alterar sua preferência de visualização.",
|
||||
"disableFileViewer": "Desativar o visualizador de arquivos"
|
||||
},
|
||||
"api": {
|
||||
"title": "Chaves de API:",
|
||||
@@ -560,7 +568,8 @@
|
||||
"fileViewerOptions": "Opções do visualizador de arquivos",
|
||||
"themeAndLanguage": "Tema e linguagem",
|
||||
"previewFolder": "Mostrar miniaturas das pastas, se disponíveis",
|
||||
"previewFolderDescription": "Mostrar visualizações em miniatura para pastas com o primeiro item filho com miniatura como capa."
|
||||
"previewFolderDescription": "Mostrar visualizações em miniatura para pastas com o primeiro item filho com miniatura como capa.",
|
||||
"disableFileExtensions": "Digite as extensões de arquivo a serem desativadas"
|
||||
},
|
||||
"editor": {
|
||||
"uninitialized": "Falha ao inicializar o editor. Favor recarregar.",
|
||||
|
||||
@@ -325,7 +325,14 @@
|
||||
"configViewerShowComments": "Mostrar comentários",
|
||||
"configViewerLoadConfig": "Configuração de carga",
|
||||
"brandingHelp": "É possível personalizar a aparência da instância do Navegador de arquivos alterando seu nome, substituindo o logotipo, adicionando estilos personalizados e até mesmo desativando links externos para o GitHub.\nPara obter mais informações sobre a marca personalizada, consulte {0}.",
|
||||
"commandRunnerHelp": "Aqui pode definir comandos que são executados nos eventos nomeados. Deve escrever um por linha. As variáveis de ambiente {0} e {1} estarão disponíveis, sendo {0} relativo a {1}. Para obter mais informações sobre este recurso e as variáveis de ambiente disponíveis, leia {2}."
|
||||
"commandRunnerHelp": "Aqui pode definir comandos que são executados nos eventos nomeados. Deve escrever um por linha. As variáveis de ambiente {0} e {1} estarão disponíveis, sendo {0} relativo a {1}. Para obter mais informações sobre este recurso e as variáveis de ambiente disponíveis, leia {2}.",
|
||||
"loginMethods": {
|
||||
"password": "Palavra-passe",
|
||||
"oidc": "OIDC",
|
||||
"proxy": "Proxy"
|
||||
},
|
||||
"newUserHintSubFolder": "por exemplo, '/subpasta' - em branco por defeito",
|
||||
"userScopes": "Escopos"
|
||||
},
|
||||
"sidebar": {
|
||||
"help": "Ajuda",
|
||||
@@ -399,7 +406,8 @@
|
||||
"enableOnlyOfficeEditingDescription": "Permitir a edição de ficheiros do Office utilizando o OnlyOffice nesta partilha.",
|
||||
"titleDefault": "Ficheiros partilhados - {title}",
|
||||
"defaultViewMode": "Modo de visualização predefinido",
|
||||
"defaultViewModeDescription": "Define o modo de visualização predefinido para os utilizadores anónimos que visualizam esta partilha. Os utilizadores com sessão iniciada ainda podem alterar a sua preferência de visualização."
|
||||
"defaultViewModeDescription": "Define o modo de visualização predefinido para os utilizadores anónimos que visualizam esta partilha. Os utilizadores com sessão iniciada ainda podem alterar a sua preferência de visualização.",
|
||||
"disableFileViewer": "Desativar o visualizador de ficheiros"
|
||||
},
|
||||
"api": {
|
||||
"title": "Chaves da API",
|
||||
@@ -524,7 +532,8 @@
|
||||
"fileViewerOptions": "Opções do visualizador de ficheiros",
|
||||
"themeAndLanguage": "Tema e linguagem",
|
||||
"previewFolder": "Mostrar miniaturas das pastas, se disponíveis",
|
||||
"previewFolderDescription": "Mostrar pré-visualizações em miniatura para pastas com o primeiro item filho com miniatura como capa."
|
||||
"previewFolderDescription": "Mostrar pré-visualizações em miniatura para pastas com o primeiro item filho com miniatura como capa.",
|
||||
"disableFileExtensions": "Introduzir as extensões de ficheiros a desativar"
|
||||
},
|
||||
"general": {
|
||||
"name": "Nome",
|
||||
|
||||
@@ -325,7 +325,14 @@
|
||||
"configViewerShowComments": "Afișați comentariile",
|
||||
"configViewerLoadConfig": "Configurare încărcare",
|
||||
"brandingHelp": "Puteți personaliza aspectul instanței File Browser prin schimbarea numelui, înlocuirea logo-ului, adăugarea de stiluri personalizate și chiar dezactivarea linkurilor externe către GitHub.\nPentru mai multe informații despre brandingul personalizat, consultați {0}.",
|
||||
"commandRunnerHelp": "Aici puteți seta comenzi care sunt executate în cadrul evenimentelor numite. Trebuie să scrieți câte una pe linie. Variabilele de mediu {0} și {1} vor fi disponibile, {0} fiind relativ la {1}. Pentru mai multe informații despre această caracteristică și variabilele de mediu disponibile, vă rugăm să citiți {2}."
|
||||
"commandRunnerHelp": "Aici puteți seta comenzi care sunt executate în cadrul evenimentelor numite. Trebuie să scrieți câte una pe linie. Variabilele de mediu {0} și {1} vor fi disponibile, {0} fiind relativ la {1}. Pentru mai multe informații despre această caracteristică și variabilele de mediu disponibile, vă rugăm să citiți {2}.",
|
||||
"loginMethods": {
|
||||
"password": "Parolă",
|
||||
"oidc": "OIDC",
|
||||
"proxy": "Proxy"
|
||||
},
|
||||
"newUserHintSubFolder": "de exemplu \"/subfolder\" - gol pentru implicit",
|
||||
"userScopes": "Scopuri"
|
||||
},
|
||||
"sidebar": {
|
||||
"help": "Ajutor",
|
||||
@@ -399,7 +406,8 @@
|
||||
"enableOnlyOfficeEditingDescription": "Permiteți editarea fișierelor de birou utilizând OnlyOffice în această partajare.",
|
||||
"titleDefault": "Fișiere partajate - {title}",
|
||||
"defaultViewMode": "Mod de vizualizare implicit",
|
||||
"defaultViewModeDescription": "Setează modul de vizualizare implicit pentru utilizatorii anonimi care vizualizează această partajare. Utilizatorii conectați își pot schimba în continuare preferințele de vizualizare."
|
||||
"defaultViewModeDescription": "Setează modul de vizualizare implicit pentru utilizatorii anonimi care vizualizează această partajare. Utilizatorii conectați își pot schimba în continuare preferințele de vizualizare.",
|
||||
"disableFileViewer": "Dezactivați vizualizatorul de fișiere"
|
||||
},
|
||||
"api": {
|
||||
"title": "Chei API:",
|
||||
@@ -560,7 +568,8 @@
|
||||
"fileViewerOptions": "Opțiuni vizualizator de fișiere",
|
||||
"themeAndLanguage": "Temă și limbaj",
|
||||
"previewFolder": "Afișați miniaturi pentru foldere, dacă sunt disponibile",
|
||||
"previewFolderDescription": "Afișează previzualizări ale miniaturilor pentru folderele cu primul element copil cu miniatură ca copertă."
|
||||
"previewFolderDescription": "Afișează previzualizări ale miniaturilor pentru folderele cu primul element copil cu miniatură ca copertă.",
|
||||
"disableFileExtensions": "Introduceți extensiile de fișier pe care doriți să le dezactivați"
|
||||
},
|
||||
"editor": {
|
||||
"uninitialized": "Nu s-a reușit inițializarea editorului. Vă rugăm să reîncărcați.",
|
||||
|
||||
@@ -325,7 +325,14 @@
|
||||
"configViewerShowComments": "Показать комментарии",
|
||||
"configViewerLoadConfig": "Конфигурация нагрузки",
|
||||
"brandingHelp": "Вы можете настроить внешний вид экземпляра File Browser, изменив его название, заменив логотип, добавив пользовательские стили и даже отключив внешние ссылки на GitHub.\nБолее подробную информацию о пользовательском брендинге можно найти на сайте {0}.",
|
||||
"commandRunnerHelp": "Здесь вы можете задать команды, которые будут выполняться в названных событиях. Вы должны написать по одной команде в строке. Будут доступны переменные окружения {0} и {1}, причем {0} будет относиться к {1}. Для получения дополнительной информации об этой функции и доступных переменных окружения, пожалуйста, прочтите {2}."
|
||||
"commandRunnerHelp": "Здесь вы можете задать команды, которые будут выполняться в названных событиях. Вы должны написать по одной команде в строке. Будут доступны переменные окружения {0} и {1}, причем {0} будет относиться к {1}. Для получения дополнительной информации об этой функции и доступных переменных окружения, пожалуйста, прочтите {2}.",
|
||||
"loginMethods": {
|
||||
"password": "Пароль",
|
||||
"oidc": "OIDC",
|
||||
"proxy": "Прокси-сервер"
|
||||
},
|
||||
"newUserHintSubFolder": "например, '/subfolder' - пустое место по умолчанию",
|
||||
"userScopes": "Прицелы"
|
||||
},
|
||||
"sidebar": {
|
||||
"help": "Помощь",
|
||||
@@ -399,7 +406,8 @@
|
||||
"enableOnlyOfficeEditingDescription": "Разрешите редактирование офисных файлов с помощью OnlyOffice в этом ресурсе.",
|
||||
"titleDefault": "Общие файлы - {title}",
|
||||
"defaultViewMode": "Режим просмотра по умолчанию",
|
||||
"defaultViewModeDescription": "Установите режим просмотра по умолчанию для анонимных пользователей, просматривающих этот ресурс. Пользователи, вошедшие в систему, могут изменить свой режим просмотра."
|
||||
"defaultViewModeDescription": "Установите режим просмотра по умолчанию для анонимных пользователей, просматривающих этот ресурс. Пользователи, вошедшие в систему, могут изменить свой режим просмотра.",
|
||||
"disableFileViewer": "Отключить средство просмотра файлов"
|
||||
},
|
||||
"api": {
|
||||
"title": "Ключи API:",
|
||||
@@ -560,7 +568,8 @@
|
||||
"fileViewerOptions": "Параметры просмотра файлов",
|
||||
"themeAndLanguage": "Тема и язык",
|
||||
"previewFolder": "Показывайте миниатюры для папок, если они доступны",
|
||||
"previewFolderDescription": "Показывать предварительный просмотр миниатюр для папок, в которых первый дочерний элемент с миниатюрой является обложкой."
|
||||
"previewFolderDescription": "Показывать предварительный просмотр миниатюр для папок, в которых первый дочерний элемент с миниатюрой является обложкой.",
|
||||
"disableFileExtensions": "Введите расширения файлов, которые необходимо отключить"
|
||||
},
|
||||
"editor": {
|
||||
"uninitialized": "Не удалось инициализировать редактор. Пожалуйста, перезагрузитесь.",
|
||||
|
||||
@@ -325,7 +325,14 @@
|
||||
"configViewerShowComments": "Zobraziť komentáre",
|
||||
"configViewerLoadConfig": "Konfigurácia načítania",
|
||||
"brandingHelp": "Vzhľad a pôsobenie inštancie Prieskumníka súborov môžete prispôsobiť zmenou názvu, výmenou loga, pridaním vlastných štýlov a dokonca aj zakázaním externých prepojení na GitHub.\nViac informácií o používaní vlastnej značky nájdete na stránke {0}.",
|
||||
"commandRunnerHelp": "Tu môžete nastaviť príkazy, ktoré sa vykonajú v pomenovaných udalostiach. Na každý riadok musíte napísať jeden. K dispozícii budú premenné prostredia {0} a {1}, pričom {0} bude relatívne k {1}. Viac informácií o tejto funkcii a dostupných premenných prostredia nájdete na stránke {2}."
|
||||
"commandRunnerHelp": "Tu môžete nastaviť príkazy, ktoré sa vykonajú v pomenovaných udalostiach. Na každý riadok musíte napísať jeden. K dispozícii budú premenné prostredia {0} a {1}, pričom {0} bude relatívne k {1}. Viac informácií o tejto funkcii a dostupných premenných prostredia nájdete na stránke {2}.",
|
||||
"loginMethods": {
|
||||
"password": "Heslo",
|
||||
"oidc": "OIDC",
|
||||
"proxy": "Proxy"
|
||||
},
|
||||
"newUserHintSubFolder": "napr. '/subfolder' - prázdne pre predvolené nastavenie",
|
||||
"userScopes": "Rozsahy"
|
||||
},
|
||||
"sidebar": {
|
||||
"help": "Pomoc",
|
||||
@@ -399,7 +406,8 @@
|
||||
"enableOnlyOfficeEditingDescription": "Povoľte úpravu kancelárskych súborov pomocou aplikácie OnlyOffice v tejto zdieľanej zložke.",
|
||||
"titleDefault": "Zdieľané súbory - {title}",
|
||||
"defaultViewMode": "Predvolený režim zobrazenia",
|
||||
"defaultViewModeDescription": "Nastavenie predvoleného režimu zobrazenia pre anonymných používateľov pri prezeraní tejto zdieľanej zložky. Prihlásení používatelia môžu stále zmeniť svoje preferencie zobrazenia."
|
||||
"defaultViewModeDescription": "Nastavenie predvoleného režimu zobrazenia pre anonymných používateľov pri prezeraní tejto zdieľanej zložky. Prihlásení používatelia môžu stále zmeniť svoje preferencie zobrazenia.",
|
||||
"disableFileViewer": "Zakázanie prehliadača súborov"
|
||||
},
|
||||
"api": {
|
||||
"title": "Kľúče API:",
|
||||
@@ -560,7 +568,8 @@
|
||||
"fileViewerOptions": "Možnosti prehliadača súborov",
|
||||
"themeAndLanguage": "Téma a jazyk",
|
||||
"previewFolder": "Zobrazenie miniatúr priečinkov, ak sú k dispozícii",
|
||||
"previewFolderDescription": "Zobrazenie náhľadov miniatúr pre priečinky s prvou podriadenou položkou s miniatúrou ako obálkou."
|
||||
"previewFolderDescription": "Zobrazenie náhľadov miniatúr pre priečinky s prvou podriadenou položkou s miniatúrou ako obálkou.",
|
||||
"disableFileExtensions": "Zadajte prípony súborov, ktoré chcete zakázať"
|
||||
},
|
||||
"editor": {
|
||||
"uninitialized": "Nepodarilo sa inicializovať editor. Prosím, načítajte ho znova.",
|
||||
|
||||
@@ -325,7 +325,14 @@
|
||||
"configViewerShowComments": "Visa kommentarer",
|
||||
"configViewerLoadConfig": "Ladda konfig",
|
||||
"brandingHelp": "Du kan anpassa hur din File Browser-instans ser ut och känns genom att ändra dess namn, byta ut logotypen, lägga till anpassade stilar och till och med inaktivera externa länkar till GitHub.\nFör mer information om anpassad branding, se {0}.",
|
||||
"commandRunnerHelp": "Här kan du ställa in kommandon som utförs i de namngivna händelserna. Du måste skriva ett per rad. Miljövariablerna {0} och {1} kommer att vara tillgängliga, {0} i förhållande till {1}. För mer information om denna funktion och de tillgängliga miljövariablerna, läs {2}."
|
||||
"commandRunnerHelp": "Här kan du ställa in kommandon som utförs i de namngivna händelserna. Du måste skriva ett per rad. Miljövariablerna {0} och {1} kommer att vara tillgängliga, {0} i förhållande till {1}. För mer information om denna funktion och de tillgängliga miljövariablerna, läs {2}.",
|
||||
"loginMethods": {
|
||||
"password": "Lösenord",
|
||||
"oidc": "OIDC",
|
||||
"proxy": "Proxy"
|
||||
},
|
||||
"newUserHintSubFolder": "t.ex. \"/undermapp\" - tom för standard",
|
||||
"userScopes": "Omfattningar"
|
||||
},
|
||||
"sidebar": {
|
||||
"help": "Hjälp",
|
||||
@@ -399,7 +406,8 @@
|
||||
"enableOnlyOfficeEditingDescription": "Tillåt redigering av kontorsfiler med hjälp av OnlyOffice i denna andel.",
|
||||
"titleDefault": "Delade filer {title}",
|
||||
"defaultViewMode": "Standardläge för visning",
|
||||
"defaultViewModeDescription": "Ange standardvisningsläge för anonyma användare som visar den här andelen. Inloggade användare kan fortfarande ändra sina visningspreferenser."
|
||||
"defaultViewModeDescription": "Ange standardvisningsläge för anonyma användare som visar den här andelen. Inloggade användare kan fortfarande ändra sina visningspreferenser.",
|
||||
"disableFileViewer": "Inaktivera filvisare"
|
||||
},
|
||||
"api": {
|
||||
"title": "API-nycklar",
|
||||
@@ -524,7 +532,8 @@
|
||||
"fileViewerOptions": "Alternativ för filvisare",
|
||||
"themeAndLanguage": "Tema & språk",
|
||||
"previewFolder": "Visa miniatyrbilder för mappar om de är tillgängliga",
|
||||
"previewFolderDescription": "Visa förhandsvisningar av miniatyrbilder för mappar med första underordnade objekt med miniatyrbild som omslag."
|
||||
"previewFolderDescription": "Visa förhandsvisningar av miniatyrbilder för mappar med första underordnade objekt med miniatyrbild som omslag.",
|
||||
"disableFileExtensions": "Ange filändelser som ska inaktiveras"
|
||||
},
|
||||
"general": {
|
||||
"name": "Namn",
|
||||
|
||||
@@ -325,7 +325,14 @@
|
||||
"configViewerShowComments": "Yorumları göster",
|
||||
"configViewerLoadConfig": "Yük Konfigürasyonu",
|
||||
"brandingHelp": "Adını değiştirerek, logoyu değiştirerek, özel stiller ekleyerek ve hatta GitHub'a harici bağlantıları devre dışı bırakarak Dosya Tarayıcı örneğinizin nasıl göründüğünü ve hissettirdiğini özelleştirebilirsiniz.\nÖzel markalama hakkında daha fazla bilgi için lütfen {0} adresine göz atın.",
|
||||
"commandRunnerHelp": "Burada, adlandırılmış olaylarda yürütülecek komutları ayarlayabilirsiniz. Her satıra bir tane yazmalısınız. {0} ve {1} ortam değişkenleri kullanılabilir olacaktır, {0} ise {1} ile ilişkilidir. Bu özellik ve mevcut ortam değişkenleri hakkında daha fazla bilgi için lütfen {2} adresini okuyun."
|
||||
"commandRunnerHelp": "Burada, adlandırılmış olaylarda yürütülecek komutları ayarlayabilirsiniz. Her satıra bir tane yazmalısınız. {0} ve {1} ortam değişkenleri kullanılabilir olacaktır, {0} ise {1} ile ilişkilidir. Bu özellik ve mevcut ortam değişkenleri hakkında daha fazla bilgi için lütfen {2} adresini okuyun.",
|
||||
"loginMethods": {
|
||||
"password": "Şifre",
|
||||
"oidc": "OIDC",
|
||||
"proxy": "Proxy"
|
||||
},
|
||||
"newUserHintSubFolder": "örneğin '/subfolder' - varsayılan için boş",
|
||||
"userScopes": "Dürbünler"
|
||||
},
|
||||
"sidebar": {
|
||||
"help": "Yardım",
|
||||
@@ -399,7 +406,8 @@
|
||||
"enableOnlyOfficeEditingDescription": "Bu paylaşımda OnlyOffice kullanarak ofis dosyalarının düzenlenmesine izin verin.",
|
||||
"titleDefault": "Paylaşılan dosyalar - {title}",
|
||||
"defaultViewMode": "Varsayılan görünüm modu",
|
||||
"defaultViewModeDescription": "Bu paylaşımı görüntüleyen anonim kullanıcılar için varsayılan görünüm modunu ayarlayın. Oturum açmış kullanıcılar yine de görünüm tercihlerini değiştirebilirler."
|
||||
"defaultViewModeDescription": "Bu paylaşımı görüntüleyen anonim kullanıcılar için varsayılan görünüm modunu ayarlayın. Oturum açmış kullanıcılar yine de görünüm tercihlerini değiştirebilirler.",
|
||||
"disableFileViewer": "Dosya Görüntüleyiciyi Devre Dışı Bırak"
|
||||
},
|
||||
"api": {
|
||||
"title": "API Anahtarları:",
|
||||
@@ -560,7 +568,8 @@
|
||||
"fileViewerOptions": "Dosya Görüntüleyici Seçenekleri",
|
||||
"themeAndLanguage": "Tema ve Dil",
|
||||
"previewFolder": "Varsa klasörler için küçük resimleri göster",
|
||||
"previewFolderDescription": "Kapak olarak küçük resmi olan ilk alt öğeye sahip klasörler için küçük resim önizlemelerini gösterin."
|
||||
"previewFolderDescription": "Kapak olarak küçük resmi olan ilk alt öğeye sahip klasörler için küçük resim önizlemelerini gösterin.",
|
||||
"disableFileExtensions": "Devre dışı bırakılacak dosya uzantılarını girin"
|
||||
},
|
||||
"editor": {
|
||||
"uninitialized": "Düzenleyici başlatılamadı. Lütfen yeniden yükleyin.",
|
||||
|
||||
@@ -325,7 +325,14 @@
|
||||
"configViewerShowComments": "Показати коментарі",
|
||||
"configViewerLoadConfig": "Завантажити конфігурацію",
|
||||
"brandingHelp": "Ви можете налаштувати зовнішній вигляд вашого екземпляра File Browser, змінивши його назву, замінивши логотип, додавши власні стилі і навіть вимкнувши зовнішні посилання на GitHub.\nЩоб дізнатися більше про кастомне брендування, будь ласка, відвідайте сторінку {0}.",
|
||||
"commandRunnerHelp": "Тут ви можете задати команди, які будуть виконуватися у названих подіях. Ви повинні писати по одній у рядку. Змінні оточення {0} і {1} будуть доступні, будучи {0} відносно {1}. Для отримання додаткової інформації про цю можливість і доступні змінні оточення, будь ласка, прочитайте {2}."
|
||||
"commandRunnerHelp": "Тут ви можете задати команди, які будуть виконуватися у названих подіях. Ви повинні писати по одній у рядку. Змінні оточення {0} і {1} будуть доступні, будучи {0} відносно {1}. Для отримання додаткової інформації про цю можливість і доступні змінні оточення, будь ласка, прочитайте {2}.",
|
||||
"loginMethods": {
|
||||
"password": "Пароль",
|
||||
"oidc": "OIDC",
|
||||
"proxy": "Довірена особа"
|
||||
},
|
||||
"newUserHintSubFolder": "наприклад, '/subfolder' - порожній за замовчуванням",
|
||||
"userScopes": "Сфери застосування"
|
||||
},
|
||||
"sidebar": {
|
||||
"help": "Допомога",
|
||||
@@ -399,7 +406,8 @@
|
||||
"enableOnlyOfficeEditingDescription": "Дозволити редагування офісних файлів за допомогою OnlyOffice у цьому ресурсі.",
|
||||
"titleDefault": "Спільні файли - {title}",
|
||||
"defaultViewMode": "Режим перегляду за замовчуванням",
|
||||
"defaultViewModeDescription": "Встановити режим перегляду за замовчуванням для анонімних користувачів, які переглядають цей ресурс. Користувачі, що увійшли в систему, можуть змінити режим перегляду."
|
||||
"defaultViewModeDescription": "Встановити режим перегляду за замовчуванням для анонімних користувачів, які переглядають цей ресурс. Користувачі, що увійшли в систему, можуть змінити режим перегляду.",
|
||||
"disableFileViewer": "Вимкнути переглядач файлів"
|
||||
},
|
||||
"api": {
|
||||
"title": "Ключі API",
|
||||
@@ -524,7 +532,8 @@
|
||||
"fileViewerOptions": "Параметри перегляду файлів",
|
||||
"themeAndLanguage": "Тема та мова",
|
||||
"previewFolder": "Показувати мініатюри для папок, якщо вони доступні",
|
||||
"previewFolderDescription": "Показувати попередній перегляд мініатюр для папок з першим дочірнім елементом, що має мініатюру як обкладинку."
|
||||
"previewFolderDescription": "Показувати попередній перегляд мініатюр для папок з першим дочірнім елементом, що має мініатюру як обкладинку.",
|
||||
"disableFileExtensions": "Введіть розширення файлів для вимкнення"
|
||||
},
|
||||
"general": {
|
||||
"name": "Ім'я",
|
||||
|
||||
@@ -325,7 +325,14 @@
|
||||
"configViewerShowComments": "显示评论",
|
||||
"configViewerLoadConfig": "加载配置",
|
||||
"brandingHelp": "您可以更改文件浏览器实例的名称、更换徽标、添加自定义样式,甚至禁用指向 GitHub 的外部链接,从而自定义文件浏览器实例的外观和感觉。\n有关自定义品牌的更多信息,请查阅{0} 。",
|
||||
"commandRunnerHelp": "您可以在此设置在命名事件中执行的命令。每行必须写一条。环境变量{0} 和{1} 将可用,{0} 与{1} 相对。有关此功能和可用环境变量的更多信息,请阅读{2} 。"
|
||||
"commandRunnerHelp": "您可以在此设置在命名事件中执行的命令。每行必须写一条。环境变量{0} 和{1} 将可用,{0} 与{1} 相对。有关此功能和可用环境变量的更多信息,请阅读{2} 。",
|
||||
"loginMethods": {
|
||||
"password": "密码",
|
||||
"oidc": "OIDC",
|
||||
"proxy": "代理"
|
||||
},
|
||||
"newUserHintSubFolder": "例如\"/子文件夹\"--默认为空",
|
||||
"userScopes": "瞄准镜"
|
||||
},
|
||||
"sidebar": {
|
||||
"help": "帮助",
|
||||
@@ -399,7 +406,8 @@
|
||||
"enableOnlyOfficeEditingDescription": "允许在此共享中使用 OnlyOffice 编辑办公文件。",
|
||||
"titleDefault": "共享文件{title}",
|
||||
"defaultViewMode": "默认视图模式",
|
||||
"defaultViewModeDescription": "设置匿名用户查看此共享的默认视图模式。已登录的用户仍可更改其查看首选项。"
|
||||
"defaultViewModeDescription": "设置匿名用户查看此共享的默认视图模式。已登录的用户仍可更改其查看首选项。",
|
||||
"disableFileViewer": "禁用文件查看器"
|
||||
},
|
||||
"api": {
|
||||
"title": "应用程序接口密钥",
|
||||
@@ -524,7 +532,8 @@
|
||||
"fileViewerOptions": "文件查看器选项",
|
||||
"themeAndLanguage": "主题与语言",
|
||||
"previewFolder": "如果有的话,显示文件夹的缩略图",
|
||||
"previewFolderDescription": "显示文件夹的缩略图预览,第一个子项的缩略图为封面。"
|
||||
"previewFolderDescription": "显示文件夹的缩略图预览,第一个子项的缩略图为封面。",
|
||||
"disableFileExtensions": "输入要禁用的文件扩展名"
|
||||
},
|
||||
"general": {
|
||||
"name": "名称",
|
||||
|
||||
@@ -325,7 +325,14 @@
|
||||
"configViewerShowComments": "顯示評論",
|
||||
"configViewerLoadConfig": "載入組態",
|
||||
"brandingHelp": "您可以透過更改名稱、更換標誌、新增自訂樣式,甚至停用 GitHub 的外部連結,自訂檔案瀏覽器實例的外觀和感覺。\n有關自訂品牌的詳細資訊,請參閱{0} 。",
|
||||
"commandRunnerHelp": "您可以在此設定在命名事件中執行的指令。您必須每行寫一個。環境變數{0} 和{1} 將可用,是相對於{1} 的{0} 。如需更多關於此功能和可用環境變數的資訊,請閱讀{2} 。"
|
||||
"commandRunnerHelp": "您可以在此設定在命名事件中執行的指令。您必須每行寫一個。環境變數{0} 和{1} 將可用,是相對於{1} 的{0} 。如需更多關於此功能和可用環境變數的資訊,請閱讀{2} 。",
|
||||
"loginMethods": {
|
||||
"password": "密碼",
|
||||
"oidc": "OIDC",
|
||||
"proxy": "代理"
|
||||
},
|
||||
"newUserHintSubFolder": "例如 '/subfolder' - 預設為空白",
|
||||
"userScopes": "範圍"
|
||||
},
|
||||
"sidebar": {
|
||||
"help": "幫助",
|
||||
@@ -399,7 +406,8 @@
|
||||
"enableOnlyOfficeEditingDescription": "允許在此共用中使用 OnlyOffice 編輯辦公室檔案。",
|
||||
"titleDefault": "共用檔案 -{title}",
|
||||
"defaultViewMode": "預設檢視模式",
|
||||
"defaultViewModeDescription": "設定匿名使用者檢視此分享的預設檢視模式。登入的使用者仍可變更檢視偏好。"
|
||||
"defaultViewModeDescription": "設定匿名使用者檢視此分享的預設檢視模式。登入的使用者仍可變更檢視偏好。",
|
||||
"disableFileViewer": "停用檔案檢視器"
|
||||
},
|
||||
"api": {
|
||||
"title": "API 金鑰",
|
||||
@@ -496,7 +504,8 @@
|
||||
"fileViewerOptions": "檔案檢視器選項",
|
||||
"themeAndLanguage": "主題與語言",
|
||||
"previewFolder": "顯示資料夾的縮圖(若有",
|
||||
"previewFolderDescription": "顯示資料夾的縮圖預覽,第一個子項目以縮圖為封面。"
|
||||
"previewFolderDescription": "顯示資料夾的縮圖預覽,第一個子項目以縮圖為封面。",
|
||||
"disableFileExtensions": "輸入要停用的檔案副檔名"
|
||||
},
|
||||
"otp": {
|
||||
"name": "雙因素驗證",
|
||||
|
||||
@@ -92,7 +92,7 @@ export async function logout() {
|
||||
//}
|
||||
|
||||
export async function initAuth() {
|
||||
if (globalVars.loginPage && !getters.isShare()) {
|
||||
if (!getters.isShare()) {
|
||||
console.log("validating login");
|
||||
await validateLogin();
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ vi.mock('@/utils/constants', () => {
|
||||
userSelectableThemes: {},
|
||||
enableThumbs: true,
|
||||
noAuth: false,
|
||||
loginPage: true,
|
||||
signup: false,
|
||||
version: "test",
|
||||
commitSHA: "test",
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
<div
|
||||
v-if="numDirs > 0"
|
||||
class="folder-items"
|
||||
aria-label="Folder Items"
|
||||
:class="{ lastGroup: numFiles === 0 }"
|
||||
>
|
||||
<item
|
||||
@@ -122,7 +123,7 @@
|
||||
<h2>{{ $t("general.files") }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="numFiles > 0" class="file-items" :class="{ lastGroup: numFiles > 0 }">
|
||||
<div v-if="numFiles > 0" class="file-items" :class="{ lastGroup: numFiles > 0 }" aria-label="File Items">
|
||||
<item
|
||||
v-for="item in files"
|
||||
:key="base64(item.name)"
|
||||
|
||||
@@ -156,6 +156,7 @@ export default {
|
||||
tooltips: { controls: true, seek: true },
|
||||
loop: { active: true },
|
||||
blankVideo: "",
|
||||
muted: false, // Disable muting automatically
|
||||
autoplay: false, // The users will manage this from their profile settings
|
||||
clickToPlay: true,
|
||||
resetOnEnd: true,
|
||||
@@ -175,6 +176,7 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.updateMedia();
|
||||
this.hookEvents();
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.toastTimeout) {
|
||||
@@ -359,6 +361,31 @@ export default {
|
||||
this.albumArtUrl = null;
|
||||
}
|
||||
},
|
||||
hookEvents() {
|
||||
if (!this.useDefaultMediaPlayer && this.$refs.videoPlayer && this.$refs.videoPlayer.player) {
|
||||
const player = this.$refs.videoPlayer.player;
|
||||
|
||||
// Attach handlers only if the screen.orientation API is available.
|
||||
if (screen.orientation) {
|
||||
player.on('enterfullscreen', this.onFullscreenEnter);
|
||||
player.on('exitfullscreen', this.onFullscreenExit);
|
||||
}
|
||||
}
|
||||
},
|
||||
async onFullscreenEnter() {
|
||||
// Allow free rotation when video enters full screen mode. This works even if the device's orientation is currently locked.
|
||||
try {
|
||||
await screen.orientation.lock('any');
|
||||
} catch (error) {
|
||||
// The NotSupportedError is thrown for non-mobile browsers and there seems to be no way to pre-check if it is supported.
|
||||
// -> Swallow NotSupportedError but let other errors be thrown.
|
||||
if (error.name !== 'NotSupportedError')
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
onFullscreenExit() {
|
||||
screen.orientation.unlock();
|
||||
},
|
||||
},
|
||||
expose: ['toggleLoop'],
|
||||
};
|
||||
@@ -416,6 +443,10 @@ button:hover,
|
||||
background-color: rgb(216 216 216);
|
||||
}
|
||||
|
||||
.plyr__controls {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.audio-controls-container.dark-mode .plyr {
|
||||
background-color: rgb(37 49 55 / 33%);
|
||||
color: white;
|
||||
@@ -435,6 +466,7 @@ button:hover,
|
||||
background-color: transparent;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.audio-controls-container.dark-mode .plyr .plyr__controls {
|
||||
color: white;
|
||||
}
|
||||
@@ -473,17 +505,29 @@ button:hover,
|
||||
background: var(--plyr-video-control-background-hover, var(--primaryColor));
|
||||
border: 0;
|
||||
display: none;
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
transition: 0.3s;
|
||||
z-index: 2;
|
||||
height: 4em;
|
||||
transform: none;
|
||||
padding: unset;
|
||||
left: unset;
|
||||
right: unset;
|
||||
bottom: unset;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
right: auto;
|
||||
transform: translate(-50%, -50%) !important;
|
||||
bottom: auto;
|
||||
width: 4em !important;
|
||||
margin: 0 !important;
|
||||
border-radius: 5em !important;
|
||||
transition: transform 0.2s ease !important;
|
||||
}
|
||||
|
||||
.plyr--fullscreen-active .plyr__control--overlaid {
|
||||
top: 50% !important;
|
||||
left: 50% !important;
|
||||
transform: translate(-50%, -50%) !important;
|
||||
}
|
||||
|
||||
.plyr__control--overlaid:hover {
|
||||
transform: translate(-50%, -50%) scale(1.05) !important;
|
||||
}
|
||||
|
||||
/************
|
||||
@@ -513,7 +557,6 @@ button:hover,
|
||||
*** AUDIO ***
|
||||
************/
|
||||
|
||||
|
||||
/* Hide some unnesary buttons on the audio player */
|
||||
.plyr--audio .plyr__control--overlaid,
|
||||
.plyr--audio .plyr__control[data-plyr="captions"],
|
||||
@@ -551,7 +594,7 @@ button:hover,
|
||||
|
||||
/* Play button a bit more big */
|
||||
.plyr--audio .plyr__control--play {
|
||||
transform: scale(1.2);
|
||||
transform: scale(1.25);
|
||||
}
|
||||
|
||||
/* Hide volume buttons for made more space */
|
||||
@@ -595,7 +638,7 @@ button:hover,
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
margin: auto;
|
||||
margin: 0 auto;
|
||||
gap: 1em;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -607,7 +650,6 @@ button:hover,
|
||||
overflow: hidden;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
|
||||
transition: max-height 0.3s ease, max-width 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.album-art {
|
||||
@@ -623,9 +665,7 @@ button:hover,
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(115deg,
|
||||
var(--primaryColor),
|
||||
rgba(2, 0, 36, 0.9));
|
||||
background: linear-gradient(115deg, var(--primaryColor), rgba(2, 0, 36, 0.9));
|
||||
filter: brightness(0.85);
|
||||
}
|
||||
|
||||
@@ -640,18 +680,6 @@ button:hover,
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.audio-metadata {
|
||||
text-align: center;
|
||||
color: var(--text-color);
|
||||
margin-top: 15px;
|
||||
padding: 15px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
max-width: min(350px, 80vw);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.audio-title {
|
||||
font-size: clamp(1.2rem, 4vw, 1.5rem);
|
||||
font-weight: bold;
|
||||
@@ -660,7 +688,11 @@ button:hover,
|
||||
}
|
||||
|
||||
.metadata-info {
|
||||
color: whitesmoke
|
||||
text-align: center;
|
||||
color: whitesmoke;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 15px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.audio-artist,
|
||||
@@ -675,6 +707,7 @@ button:hover,
|
||||
.audio-controls-container {
|
||||
width: 100%;
|
||||
border-radius: 1em;
|
||||
margin: -2px;
|
||||
}
|
||||
|
||||
/* For small tablets and phones with big screen */
|
||||
@@ -684,18 +717,15 @@ button:hover,
|
||||
padding-top: 1em;
|
||||
}
|
||||
|
||||
.metadata-info {
|
||||
padding: 12px 15px;
|
||||
}
|
||||
|
||||
.album-art-container {
|
||||
width: min(280px, 70vw);
|
||||
height: min(280px, 70vw);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.audio-metadata {
|
||||
max-width: min(280px, 70vw);
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* For ultra-wide screens. This need test, I'm not sure if will work correctly */
|
||||
@@ -704,9 +734,54 @@ button:hover,
|
||||
width: min(400px, 25vw);
|
||||
height: min(400px, 25vw);
|
||||
}
|
||||
}
|
||||
|
||||
.audio-metadata {
|
||||
max-width: min(400px, 25vw);
|
||||
/* For small screens in landscape orientation (Like a phone) */
|
||||
@media (max-height: 500px) and (orientation: landscape) {
|
||||
|
||||
.audio-player-container {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.audio-player-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1.5em;
|
||||
width: auto;
|
||||
max-width: 90vw;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.metadata-info {
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
padding: 15px;
|
||||
flex: 0 1 auto;
|
||||
align-self: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.album-art-container {
|
||||
width: min(150px, 30vh);
|
||||
height: min(150px, 30vh);
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.audio-title {
|
||||
font-size: clamp(1rem, 3vw, 1.3rem);
|
||||
}
|
||||
|
||||
.audio-artist,
|
||||
.audio-album,
|
||||
.audio-year {
|
||||
font-size: clamp(0.85rem, 2vw, 1rem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -729,6 +804,7 @@ button:hover,
|
||||
gap: 10px;
|
||||
z-index: 10000;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
import { accessApi } from "@/api";
|
||||
import { state, mutations } from "@/store";
|
||||
import Errors from "@/views/Errors.vue";
|
||||
import { eventBus } from "@/store/eventBus";
|
||||
|
||||
export default {
|
||||
name: "accessSettings",
|
||||
@@ -71,6 +72,12 @@ export default {
|
||||
async mounted() {
|
||||
this.selectedSource = state.sources.current;
|
||||
await this.fetchRules();
|
||||
// Listen for access rule changes
|
||||
eventBus.on('accessRulesChanged', this.fetchRules);
|
||||
},
|
||||
beforeUnmount() {
|
||||
// Clean up event listener
|
||||
eventBus.removeEventListener('accessRulesChanged', this.fetchRules);
|
||||
},
|
||||
computed: {
|
||||
/*loading() {
|
||||
|
||||
@@ -65,6 +65,7 @@ import { usersApi } from "@/api";
|
||||
import { state, mutations, getters } from "@/store";
|
||||
import Clipboard from "clipboard";
|
||||
import Errors from "@/views/Errors.vue";
|
||||
import { eventBus } from "@/store/eventBus";
|
||||
|
||||
export default {
|
||||
name: "api",
|
||||
@@ -82,23 +83,19 @@ export default {
|
||||
};
|
||||
},
|
||||
async created() {
|
||||
mutations.setLoading("api", true);
|
||||
try {
|
||||
// Fetch the API keys from the specified endpoint
|
||||
this.links = await usersApi.getApiKeys(); // Updated to the correct API endpoint
|
||||
} catch (e) {
|
||||
this.error = e;
|
||||
} finally {
|
||||
mutations.setLoading("api", false);
|
||||
}
|
||||
await this.reloadApiKeys();
|
||||
},
|
||||
mounted() {
|
||||
// Listen for API key changes
|
||||
eventBus.on('apiKeysChanged', this.reloadApiKeys);
|
||||
this.clip = new Clipboard(".copy-clipboard");
|
||||
this.clip.on("success", () => {
|
||||
notify.showSuccess("Copied API Key!");
|
||||
});
|
||||
},
|
||||
beforeUnmount() {
|
||||
// Clean up event listener
|
||||
eventBus.removeEventListener('apiKeysChanged', this.reloadApiKeys);
|
||||
this.clip.destroy();
|
||||
},
|
||||
computed: {
|
||||
@@ -113,6 +110,18 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async reloadApiKeys() {
|
||||
mutations.setLoading("api", true);
|
||||
try {
|
||||
// Fetch the API keys from the specified endpoint
|
||||
this.links = await usersApi.getApiKeys();
|
||||
this.error = null; // Clear any previous errors
|
||||
} catch (e) {
|
||||
this.error = e;
|
||||
} finally {
|
||||
mutations.setLoading("api", false);
|
||||
}
|
||||
},
|
||||
showResult(value) {
|
||||
return value ? "✓" : "✗";
|
||||
},
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
<div class="form-flex-group">
|
||||
<input class="input form-form flat-right"
|
||||
:class="{ 'form-invalid': !validateExtensions(formDisablePreviews) }" type="text"
|
||||
placeholder="enter file extensions" id="disablePreviews" v-model="formDisablePreviews" />
|
||||
:placeholder="$t('profileSettings.disableFileExtensions')" id="disablePreviews" v-model="formDisablePreviews" />
|
||||
<button type="button" class="button form-button flat-left" @click="submitDisablePreviewsChange">
|
||||
{{ $t("buttons.save") }}
|
||||
</button>
|
||||
@@ -103,7 +103,7 @@
|
||||
<div class="form-flex-group">
|
||||
<input class="input form-form flat-right"
|
||||
:class="{ 'form-invalid': !validateExtensions(formDisabledViewing) }" type="text"
|
||||
placeholder="enter file extensions" id="disableViewing" v-model="formDisabledViewing" />
|
||||
:placeholder="$t('profileSettings.disableFileExtensions')" id="disableViewing" v-model="formDisabledViewing" />
|
||||
<button type="button" class="button form-button flat-left" @click="submitDisabledViewingChange">
|
||||
{{ $t("buttons.save") }}
|
||||
</button>
|
||||
@@ -121,7 +121,7 @@
|
||||
<div class="form-flex-group">
|
||||
<input class="input form-form flat-right"
|
||||
:class="{ 'form-invalid': !validateExtensions(formDisableOfficePreview) }" type="text"
|
||||
placeholder="enter file extensions" id="disableOfficePreview" v-model="formDisableOfficePreview" />
|
||||
:placeholder="$t('profileSettings.disableFileExtensions')" id="disableOfficePreview" v-model="formDisableOfficePreview" />
|
||||
<button type="button" class="button form-button flat-left" @click="submitDisableOfficePreviewChange">
|
||||
{{ $t("buttons.save") }}
|
||||
</button>
|
||||
@@ -352,4 +352,4 @@ export default {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
</style>
|
||||
</style>
|
||||
@@ -14,7 +14,7 @@
|
||||
<th>{{ $t("settings.username") }}</th>
|
||||
<th>{{ $t("settings.loginMethod") }}</th>
|
||||
<th>{{ $t("settings.admin") }}</th>
|
||||
<th>Scopes</th> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
<th>{{ $t("settings.userScopes") }}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -43,6 +43,7 @@
|
||||
import { state, mutations, getters } from "@/store";
|
||||
import { usersApi } from "@/api";
|
||||
import Errors from "@/views/Errors.vue";
|
||||
import { eventBus } from "@/store/eventBus";
|
||||
|
||||
export default {
|
||||
name: "users",
|
||||
@@ -56,10 +57,15 @@ export default {
|
||||
};
|
||||
},
|
||||
async created() {
|
||||
mutations.setLoading("users", true);
|
||||
// Set loading state to true
|
||||
this.users = await usersApi.getAllUsers();
|
||||
mutations.setLoading("users", false);
|
||||
await this.reloadUsers();
|
||||
},
|
||||
mounted() {
|
||||
// Listen for user changes
|
||||
eventBus.on('usersChanged', this.reloadUsers);
|
||||
},
|
||||
beforeUnmount() {
|
||||
// Clean up event listener
|
||||
eventBus.removeEventListener('usersChanged', this.reloadUsers);
|
||||
},
|
||||
computed: {
|
||||
settings() {
|
||||
@@ -74,6 +80,17 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async reloadUsers() {
|
||||
mutations.setLoading("users", true);
|
||||
try {
|
||||
this.users = await usersApi.getAllUsers();
|
||||
this.error = null; // Clear any previous errors
|
||||
} catch (e) {
|
||||
this.error = e;
|
||||
} finally {
|
||||
mutations.setLoading("users", false);
|
||||
}
|
||||
},
|
||||
formatScopes(scopes) {
|
||||
if (!Array.isArray(scopes)) {
|
||||
return scopes;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { test, expect } from "../test-setup";
|
||||
|
||||
test("indexing disabled still shows files", async ({ page, checkForErrors, context }) => {
|
||||
await page.goto("/files/docker");
|
||||
await expect(page).toHaveTitle("Graham's Filebrowser - Files - backend");
|
||||
// expect some items
|
||||
await expect(page.locator('div[aria-label="File Items"]')).toBeVisible();
|
||||
await expect(page.locator('div[aria-label="Folder Items"]')).toBeVisible();
|
||||
checkForErrors();
|
||||
});
|
||||
@@ -171,7 +171,31 @@ test.describe("User Settings Persistence", () => {
|
||||
await checkTogglePersistence(page, "Enable real-time connections and updates");
|
||||
});
|
||||
|
||||
test('should persist "allowed login method" setting', async ({ page }) => {
|
||||
// test('should persist "allowed login method" setting', async ({ page }) => {
|
||||
// const userRow = page.locator("tr.item", { hasText: username });
|
||||
// await userRow.getByLabel('Edit User').click();
|
||||
// const modal = page.locator('.card.floating');
|
||||
// await expect(modal).toBeVisible();
|
||||
//
|
||||
// const loginMethodSelector = modal.locator("#loginMethod");
|
||||
// await expect(loginMethodSelector).toHaveValue("password");
|
||||
//
|
||||
// await loginMethodSelector.selectOption({ label: "Proxy" });
|
||||
// await modal.locator('button[aria-label="Save"]').click();
|
||||
// await expect(modal).not.toBeVisible();
|
||||
//
|
||||
// await userRow.getByLabel('Edit User').click();
|
||||
// await expect(modal).toBeVisible();
|
||||
//
|
||||
// await expect(modal.locator("#loginMethod")).toHaveValue("proxy");
|
||||
//
|
||||
// // Revert change
|
||||
// const loginMethodSelector2 = modal.locator("#loginMethod");
|
||||
// await loginMethodSelector2.selectOption({ label: "Password" });
|
||||
// await modal.locator('button[aria-label="Save"]').click();
|
||||
// await expect(modal).not.toBeVisible();
|
||||
// });
|
||||
test('should persist "allowed login method" setting', async ({ page, checkForErrors }) => {
|
||||
const userRow = page.locator("tr.item", { hasText: username });
|
||||
await userRow.getByLabel('Edit User').click();
|
||||
const modal = page.locator('.card.floating');
|
||||
@@ -179,20 +203,6 @@ test.describe("User Settings Persistence", () => {
|
||||
|
||||
const loginMethodSelector = modal.locator("#loginMethod");
|
||||
await expect(loginMethodSelector).toHaveValue("password");
|
||||
|
||||
await loginMethodSelector.selectOption({ label: "Proxy" });
|
||||
await modal.locator('button[aria-label="Save"]').click();
|
||||
await expect(modal).not.toBeVisible();
|
||||
|
||||
await userRow.getByLabel('Edit User').click();
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
await expect(modal.locator("#loginMethod")).toHaveValue("proxy");
|
||||
|
||||
// Revert change
|
||||
const loginMethodSelector2 = modal.locator("#loginMethod");
|
||||
await loginMethodSelector2.selectOption({ label: "Password" });
|
||||
await modal.locator('button[aria-label="Save"]').click();
|
||||
await expect(modal).not.toBeVisible();
|
||||
checkForErrors();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user