Compare commits

...
28 Commits
Author SHA1 Message Date
Graham Steffaniak 6a997d4605 updated with rate limiting (#2484) 2026-05-22 10:28:19 -05:00
Graham Steffaniakandcodacy-production[bot] 2633768d9f fix panic on shutdown or folder deletion (#2467)
Co-authored-by: codacy-production[bot] <61871480+codacy-production[bot]@users.noreply.github.com>
2026-05-20 14:20:26 -05:00
Graham Steffaniak 4bca80a156 Fix allow listing override (#2471) 2026-05-20 14:20:05 -05:00
Graham Steffaniak fc929970b3 Fix onlyoffice rewrite and remove CSP (#2466) 2026-05-19 18:26:41 -05:00
Graham Steffaniak 12c71d38a9 Read only source config (#2464) 2026-05-19 11:47:53 -05:00
Graham Steffaniak 641e4c5397 updated readme 2026-05-18 17:15:49 -05:00
Graham Steffaniak 587cd62da6 updated readme 2026-05-18 17:03:20 -05:00
Graham Steffaniak ea19302c25 Merge branch 'dev/v1.4.2' of github.com:gtsteffaniak/filebrowser into dev/v1.4.2 2026-05-18 16:54:14 -05:00
d4dd0c70f4 Update linting for codacy (#2460)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: codacy-production[bot] <61871480+codacy-production[bot]@users.noreply.github.com>
2026-05-18 16:53:35 -05:00
Graham Steffaniak 1babfa03bc Merge branch 'dev/v1.4.2' of github.com:gtsteffaniak/filebrowser into dev/v1.4.2 2026-05-18 16:45:13 -05:00
daa48c3c31 Add rate limiting for auth (#2458)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: codacy-production[bot] <61871480+codacy-production[bot]@users.noreply.github.com>
2026-05-18 16:00:40 -05:00
Graham Steffaniak a9e4fc27b0 Merge branch 'dev/v1.4.2' of github.com:gtsteffaniak/filebrowser into dev/v1.4.2 2026-05-18 14:52:00 -05:00
1715577d99 Merge main changes to v1.4.2 (#2457)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: codacy-production[bot] <61871480+codacy-production[bot]@users.noreply.github.com>
2026-05-18 14:51:24 -05:00
Graham Steffaniak 5eb4dc7f7c Merge branch 'main' into dev/v1.4.2 2026-05-18 14:43:49 -05:00
Graham Steffaniak 4248849bb6 Merge branch 'main' of github.com:gtsteffaniak/filebrowser 2026-05-18 14:43:36 -05:00
Graham Steffaniak c5eeb308fa Merge branch 'main' into dev/v1.4.2 2026-05-18 14:42:47 -05:00
Graham Steffaniak 12cd81340e Merge branch 'staging/v1.3.3' 2026-05-18 14:42:26 -05:00
2b532c368d fix logout and closing of profile settings in shares + other fixes (#2445)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-05-18 14:36:26 -05:00
Graham Steffaniak 28e9b81e43 Public patch transversal fix (#2444) 2026-05-18 14:35:14 -05:00
Graham Steffaniak 6d4bffd407 updated with static content handler change (#2439) 2026-05-18 14:33:49 -05:00
Kevin B. 5e49e1ecdd fix path slash issue on windows (#2451) 2026-05-18 09:45:25 -05:00
Graham Steffaniak 1aba10b87f updated share hash middleware (#2443) 2026-05-16 13:45:32 -05:00
Graham Steffaniak 15f484fba5 updated with new tests (#2429) 2026-05-15 10:10:38 -05:00
Graham Steffaniak d4eb051294 added codacy changes (#2430) 2026-05-15 10:10:25 -05:00
Graham Steffaniak 5597b28f10 update anonymous user access 2026-05-13 20:03:23 -05:00
Graham Steffaniak 6bfc397419 Fix inline svg attack (#2376) 2026-05-01 13:49:55 -05:00
Graham Steffaniak 112740bdd4 security bugfix (#2374) 2026-05-01 13:38:44 -05:00
Graham Steffaniak 9117fb2b5f fix context menu (#2373) 2026-05-01 10:05:04 -05:00
119 changed files with 2100 additions and 737 deletions

No files matched your search

+1
View File
@@ -23,6 +23,7 @@ frontend/tsconfig.tsbuildinfo
/frontend/generated
/frontend/tests/playwright-files/demo*
/frontend/loginAuth.json
/frontend/sharePrepStorage.json
/backend/vendor
/backend/*.cov
/backend/*test_config.yaml
+23
View File
@@ -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).
## v1.4.2
**Security**:
- [Moderate] Add Rate Limiting on Authentication Endpoint Enables Brute Force Attacks (GHSA-r4v7-6wcg-ghj5)
- [Critical] Path traversal in public share PATCH allows file ops outside shared directory -- thanks @Revanth011 and @fg0x0 (GHSA-qqqm-5547-774x)
**New Features**:
- read-only source configuration via `source.config.readOnly: true` (#2438)
**Notes**:
- auth rate limiting can be disabled via `auth.disableRateLimit`
- updated share hash middleware (#2443)
- updated source info popup to include private and readOnly properties
**BugFixes**:
- Logout from share page now redirects to the share instead of `/Login` again. (#2245)
- `This location cannot be reached` error when navigating with FileTree in shares. (#2245)
- Fix FileTree rename and move actions in previews. (#2245)
- Delete prompt not showing date and thumbnails in some previews. (#2245)
- fix path slash issue on windows (#2451) (#2433) (#2419)
- Always force url rewrite for onlyoffice internal URL. Fixes Error saving with OnlyOffice (#2450)
- Overriding a Deny with an Allow not working (#2405)
## v1.4.1
**Security**:
+2
View File
@@ -19,5 +19,7 @@ COPY [ "./backend/http/embed/", "./http/dist/"]
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser set -u testuser1,testuser1
RUN ./filebrowser set rule -s access -p / -r user -v admin -allow -c config.yaml
RUN ./filebrowser set rule -s access -p /excluded/showme.txt -r user -v admin -allow -c config.yaml
RUN ./filebrowser set rule -s access -p /denied -r user -v admin -c config.yaml
RUN ./filebrowser set rule -s access -p /excluded -r user -v admin -c config.yaml
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
@@ -3,7 +3,7 @@ server-main: &server-main
baseURL: "/"
logging:
- levels: "info|error|warning|debug"
apiFilter: '.*'
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright + files"
+1 -1
View File
@@ -2,7 +2,7 @@ server:
port: 8080
logging:
- levels: "info|error|warning|debug"
apiFilter: '.*'
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
config:
+1 -1
View File
@@ -3,7 +3,7 @@ server:
baseURL: "/files/"
logging:
- levels: "info|error|warning|debug"
apiFilter: '.*'
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
name: "exclude"
+1 -1
View File
@@ -1,7 +1,7 @@
server:
logging:
- levels: "info|error|warning|debug"
apiFilter: '.*'
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
config:
+1 -1
View File
@@ -3,7 +3,7 @@ server:
baseURL: "/"
logging:
- levels: "info|error|warning|debug"
apiFilter: '.*'
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
+1 -1
View File
@@ -3,7 +3,7 @@ server:
baseURL: "/subpath"
logging:
- levels: "info|error|warning|debug"
apiFilter: '.*'
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
config:
+1 -1
View File
@@ -3,7 +3,7 @@ server:
baseURL: "/files/"
logging:
- levels: "info|error|warning|debug"
apiFilter: '.*'
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright"
+1 -1
View File
@@ -3,7 +3,7 @@ server:
baseURL: "/"
logging:
- levels: "info|error|warning|debug"
apiFilter: '.*'
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright + files"
+1 -1
View File
@@ -3,7 +3,7 @@ server:
baseURL: "/"
logging:
- levels: "info|error|warning|debug"
apiFilter: '.*'
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright + files"
+39 -26
View File
@@ -145,7 +145,7 @@ func checkPermissionsImpl(opts utils.FileOptions, access *access.Storage, user *
// Layer 1: USER ACCESS CONTROL
// Quick check: Does THIS user have permission?
if !access.Permitted(idx.Path, indexPath, user.Username) {
return "", "", errors.ErrAccessDenied
return indexPath, "", errors.ErrAccessDenied
}
return indexPath, userScope, nil
}
@@ -157,30 +157,30 @@ type Items struct {
// This removes files whose names match any extension in hideFileExt.
func filterFilesByExt(files []iteminfo.ExtendedItemInfo, hideFileExt string) []iteminfo.ExtendedItemInfo {
if hideFileExt == "" {
return files
}
filtered := files[:0]
for _, f := range files {
if !utils.HideFileByExt(f.Name, hideFileExt) {
filtered = append(filtered, f)
}
}
return filtered
if hideFileExt == "" {
return files
}
filtered := files[:0]
for _, f := range files {
if !utils.HideFileByExt(f.Name, hideFileExt) {
filtered = append(filtered, f)
}
}
return filtered
}
func GetDirItems(opts utils.FileOptions, access *access.Storage, user *users.User) (Items, error) {
items := Items{}
indexPath, _, err := CheckPermissions(opts, access, user)
if err != nil {
return items, err
indexPath, _, topLevelErr := CheckPermissions(opts, access, user)
accessRulesErr := topLevelErr != nil && topLevelErr == errors.ErrAccessDenied && indexPath != ""
if topLevelErr != nil && !accessRulesErr {
return items, topLevelErr
}
// Get index
idx := indexing.GetIndex(opts.Source)
if idx == nil {
return items, fmt.Errorf("could not get index: %v ", opts.Source)
}
info, err := idx.GetFileInfo(indexing.FileInfoRequest{
IndexPath: indexPath,
FollowSymlinks: opts.FollowSymlinks,
@@ -198,6 +198,10 @@ func GetDirItems(opts utils.FileOptions, access *access.Storage, user *users.Use
if err := access.CheckChildItemAccess(info, idx, user.Username); err != nil {
return items, err
}
hasAccessibleItems := len(info.Files) > 0 || len(info.Folders) > 0
if accessRulesErr && !hasAccessibleItems {
return items, errors.ErrAccessDenied
}
if opts.Only == "files" || opts.Only == "" {
for _, file := range info.Files {
if opts.HideFileExt != "" && utils.HideFileByExt(file.Name, opts.HideFileExt) {
@@ -224,9 +228,10 @@ func FileInfoFaster(opts utils.FileOptions, access *access.Storage, user *users.
// fileInfoFasterImpl is the actual implementation of FileInfoFaster
func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *users.User, share *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
response := &iteminfo.ExtendedFileInfo{}
indexPath, userScope, err := CheckPermissions(opts, access, user)
if err != nil {
return response, err
indexPath, userScope, topLevelErr := CheckPermissions(opts, access, user)
accessRulesErr := topLevelErr != nil && topLevelErr == errors.ErrAccessDenied && indexPath != ""
if topLevelErr != nil && !accessRulesErr {
return response, topLevelErr
}
// Get index
idx := indexing.GetIndex(opts.Source)
@@ -246,13 +251,16 @@ func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *us
if err != nil {
return response, err // Path excluded by index rules OR doesn't exist
}
// otherwise response keeps unfiltered Folders/Files while CheckChildItemAccess only mutates info.
if info.Type == "directory" {
if err := access.CheckChildItemAccess(info, idx, user.Username); err != nil {
return response, err
}
}
hasAccessibleItems := len(info.Files) > 0 || len(info.Folders) > 0
if accessRulesErr && !hasAccessibleItems {
return response, errors.ErrAccessDenied
}
// Build response
response.FileInfo = *info
@@ -458,10 +466,12 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
if rawLyrics := m.Lyrics(); rawLyrics != "" {
item.Metadata.HasLyrics = true
} else {
// Check for .lrc file
ext := filepath.Ext(realPath)
base := strings.TrimSuffix(realPath, ext)
lrcPath := base + ".lrc"
// Check for sidecar .lrc file
dir := filepath.Dir(realPath)
base := filepath.Base(realPath)
ext := filepath.Ext(base)
nameWithoutExt := strings.TrimSuffix(base, ext)
lrcPath := filepath.Join(dir, nameWithoutExt+".lrc")
if _, err := os.Stat(lrcPath); err == nil {
item.Metadata.HasLyrics = true
}
@@ -526,9 +536,12 @@ func ExtractLyrics(realPath string) ([]iteminfo.Lyric, error) {
lyrics = parseLRC(raw)
}
if len(lyrics) == 0 {
ext := filepath.Ext(realPath)
base := strings.TrimSuffix(realPath, ext)
lrcPath := base + ".lrc"
// Check for sidecar .lrc file
dir := filepath.Dir(realPath)
base := filepath.Base(realPath)
ext := filepath.Ext(base)
nameWithoutExt := strings.TrimSuffix(base, ext)
lrcPath := filepath.Join(dir, nameWithoutExt+".lrc")
if data, err := os.ReadFile(lrcPath); err == nil {
lyrics = parseLRC(string(data))
}
+10 -20
View File
@@ -25,10 +25,9 @@ type webAuthnSession struct {
}
type WebAuthnService struct {
wa *webauthn.WebAuthn
sessions sync.Map
stopCh chan struct{}
originMu sync.Mutex
wa *webauthn.WebAuthn
sessions sync.Map
stopCh chan struct{}
}
var webAuthnInstance *WebAuthnService
@@ -66,24 +65,15 @@ func IsWebAuthnEnabled() bool {
return webAuthnInstance != nil
}
// EnsureOrigin dynamically adds the given origin to the allowed RP origins list.
// This supports self-hosted deployments where the access domain may vary.
func (s *WebAuthnService) EnsureOrigin(origin string) {
s.originMu.Lock()
defer s.originMu.Unlock()
for _, o := range s.wa.Config.RPOrigins {
if o == origin {
return
// ValidateOrigin checks if the given origin is in the configured whitelist.
// Returns true if the origin is allowed, false otherwise.
func (s *WebAuthnService) ValidateOrigin(origin string) bool {
for _, allowedOrigin := range s.wa.Config.RPOrigins {
if allowedOrigin == origin {
return true
}
}
s.wa.Config.RPOrigins = append(s.wa.Config.RPOrigins, origin)
}
// EnsureRPID dynamically updates the relying party ID to match the request host.
func (s *WebAuthnService) EnsureRPID(rpID string) {
s.originMu.Lock()
defer s.originMu.Unlock()
s.wa.Config.RPID = rpID
return false
}
func generateSessionID() string {
+4 -4
View File
@@ -118,10 +118,10 @@ type JwtAuthConfig struct {
// PasskeyAuthConfig configures WebAuthn / Passkey authentication as a second factor.
type PasskeyAuthConfig struct {
Enabled bool `json:"enabled"` // whether to enable passkey MFA
RPDisplayName string `json:"rpDisplayName"` // the Relying Party display name (defaults to frontend name)
RPID string `json:"rpId"` // the Relying Party ID; if empty, auto-derived from base URL host
RPOrigins []string `json:"rpOrigins"` // allowed origins; if empty, auto-derived from base URL
Enabled bool `json:"enabled"` // whether to enable passkey MFA
RPDisplayName string `json:"rpDisplayName"` // the Relying Party display name (defaults to frontend name)
RPID string `json:"rpId"` // the Relying Party ID; if empty, auto-derived from base URL host
RPOrigins []string `json:"rpOrigins"` // allowed origins; if empty, auto-derived from base URL
LoginButtonText string `json:"loginButtonText"` // custom text for the passkey login button
}
+10
View File
@@ -53,6 +53,7 @@ func Initialize(configFile string) {
}
setupFs()
setupServer()
setupHttp()
setupAuth(false)
setupSources(false)
InitializeUserResolvers() // Initialize user package resolvers after sources are set up
@@ -83,6 +84,15 @@ func setupEnv() {
}
}
func setupHttp() {
if len(Config.Http.TrustedHeadersArray) > 0 {
Config.Http.TrustedHeaders = make(map[string]bool)
for _, header := range Config.Http.TrustedHeadersArray {
Config.Http.TrustedHeaders[strings.ToLower(header)] = true
}
}
}
func setupFs() {
// Convert permission values (like 644, 755) to octal interpretation
filePermOctal, err := strconv.ParseUint(Config.Server.Filesystem.CreateFilePermission, 8, 32)
+9
View File
@@ -20,6 +20,14 @@ type Settings struct {
Frontend Frontend `json:"frontend"`
UserDefaults UserDefaults `json:"userDefaults"`
Integrations Integrations `json:"integrations"`
Http Http `json:"http"`
}
type Http struct {
TrustedHeadersArray []string `json:"trustedHeaders"` // list of headers to trust, useful when behind a reverse proxy.
DisableRateLimit bool `json:"disableRateLimit"` // turns off built-in auth route rate limiting and failed-login lockout (default false).
// internal map of trusted headers
TrustedHeaders map[string]bool `json:"-"`
}
type Environment struct {
@@ -203,6 +211,7 @@ type Source struct {
type SourceConfig struct {
DenyByDefault bool `json:"denyByDefault,omitempty"` // deny access unless an "allow" access rule was specifically created.
Private bool `json:"private"` // designate as source as private -- currently just means no sharing permitted.
ReadOnly bool `json:"readOnly,omitempty"` // read-only source, changes from the UI, webdav, and API will be disabled.
Disabled bool `json:"disabled,omitempty"` // disable the source, this is useful so you don't need to remove it from the config file
IndexingInterval uint32 `json:"indexingIntervalMinutes,omitempty"` // deprecated: create a rule with indexingIntervalMinutes to set the indexing interval for this source
Conditionals ConditionalFilter `json:"conditionals"` // deprecated: use source.rules instead
+28
View File
@@ -3,11 +3,29 @@ package utils
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"golang.org/x/crypto/bcrypt"
)
func randomBytes(n int) ([]byte, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return nil, err
}
return b, nil
}
// RandomHex returns a hex-encoded string from byteLen bytes of crypto/rand (length 2*byteLen).
func RandomHex(byteLen int) (string, error) {
b, err := randomBytes(byteLen)
if err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
var BcryptCost = bcrypt.DefaultCost
var InvalidPasswordHash = ""
@@ -44,3 +62,13 @@ func GenerateKey() string {
}
return string(b)
}
// CSPNonce returns a base64-encoded random value suitable for Content-Security-Policy nonces
// and matching HTML nonce="" attributes (cryptographically random, URL/header safe characters).
func CSPNonce() (string, error) {
b, err := randomBytes(16)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(b), nil
}
+3 -3
View File
@@ -43,9 +43,9 @@ type CommonShare struct {
AllowReplacements bool `json:"allowReplacements,omitempty"` // allow replacements of files
SidebarLinks []users.SidebarLink `json:"sidebarLinks"` // customizable sidebar links
HasPassword bool `json:"hasPassword,omitempty"`
ShowHidden bool `json:"showHidden,omitempty"` // show hidden files in share (true = show, false = hide)
HideFileExt string `json:"hideFileExt,omitempty"`// show hidden files based on extensions in shares
DisableLoginOption bool `json:"disableLoginOption"` // disable login option in share (true = hide, false = show)
ShowHidden bool `json:"showHidden,omitempty"` // show hidden files in share (true = show, false = hide)
HideFileExt string `json:"hideFileExt,omitempty"` // show hidden files based on extensions in shares
DisableLoginOption bool `json:"disableLoginOption"` // disable login option in share (true = hide, false = show)
SourceURL string `json:"sourceURL,omitempty"`
CanEditShare bool `json:"canEditShare,omitempty"`
}
+1 -7
View File
@@ -4,8 +4,6 @@ import (
"archive/tar"
"archive/zip"
"compress/gzip"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -46,11 +44,7 @@ var (
)
func randomArchiveToken() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
return utils.RandomHex(16)
}
func stopArchiveSpoolIdleTimer(token string) {
+4 -2
View File
@@ -221,7 +221,9 @@ func setupJwtUser(r *http.Request, data *requestContext, username string, claims
// @Param X-Password header string true "URL-encoded password"
// @Param X-Secret header string false "TOTP code (if 2FA is enabled)"
// @Success 200 {string} string "JWT token for authentication"
// @Failure 401 {object} map[string]string "Unauthorized - authentication failed"
// @Failure 403 {object} map[string]string "Forbidden - authentication failed"
// @Failure 429 {object} map[string]string "Too many requests - rate limited or temporarily locked out after failed attempts"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/auth/login [post]
func loginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
@@ -320,7 +322,7 @@ func logoutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/auth/signup [post]
func signupHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !settings.Config.Auth.Methods.PasswordAuth.Signup {
if !config.Auth.Methods.PasswordAuth.Signup {
return http.StatusMethodNotAllowed, fmt.Errorf("signup is disabled")
}
@@ -340,7 +342,7 @@ func signupHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
},
LoginMethod: users.LoginMethodPassword,
}
err := storage.CreateUser(user, settings.ConvertPermissionsToUsers(settings.Config.UserDefaults.Account.Permissions))
err := storage.CreateUser(user, settings.ConvertPermissionsToUsers(config.UserDefaults.Account.Permissions))
if err != nil {
logger.Debug(err.Error())
// Return the actual error message instead of a generic one
+269
View File
@@ -0,0 +1,269 @@
package http
import (
"fmt"
"math"
"net/http"
"strconv"
"time"
"github.com/gtsteffaniak/go-cache/cache"
"golang.org/x/time/rate"
)
// Built-in auth rate limits (per process). Toggle all off with http.disableRateLimit.
//
// Credential tier (login, OTP verify): dual token buckets (per IP + per username) plus
// failed-login lockout (per IP+username). Tune burst ≤ maxAttempts so rapid floods hit
// HTTP 429 from the bucket before lockout; lockout covers paced attacks that stay under RPM.
const (
authCredentialRPM = 10 // sustained ~1 attempt / 6s per IP and per username
authCredentialBurst = 8 // rapid burst; 9th immediate attempt gets 429
authModerateRPM = 30 // signup, logout, OTP generate
authModerateBurst = 10
authOIDCRPM = 60 // OIDC redirects (browser-driven, higher ceiling)
authOIDCBurst = 20
authAuthenticatedRPM = 180 // session/token management for logged-in users
authAuthenticatedBurst = 60
authFailedLoginMaxAttempts = 8 // lockout after N consecutive 401s (same IP + username)
authFailedLoginLockoutMins = 15
// Evict idle per-key token buckets so unique IPs/usernames cannot grow without bound.
authLimiterEntryTTL = 24 * time.Hour
)
// In-memory failed-attempt counters and lockout flags (per server process).
var (
authFailCounts = cache.NewCache[int](time.Minute, 10*time.Minute)
authLockouts = cache.NewCache[bool](time.Minute, 10*time.Minute)
)
// Per-route-class token buckets (keys: IP or username). Expired entries are dropped by go-cache.
var (
authRateLimitCredentialByIP = cache.NewCache[*rate.Limiter](authLimiterEntryTTL)
authRateLimitCredentialByUsername = cache.NewCache[*rate.Limiter](authLimiterEntryTTL)
authRateLimitModerateByIP = cache.NewCache[*rate.Limiter](authLimiterEntryTTL)
authRateLimitOIDCByIP = cache.NewCache[*rate.Limiter](authLimiterEntryTTL)
authRateLimitAuthenticatedByUser = cache.NewCache[*rate.Limiter](authLimiterEntryTTL)
)
const authRateKeySep = "\x1e"
// AuthRateLimitKind selects which /api/auth rate limit tier applies (see withRateLimit and withRateLimitChain).
type AuthRateLimitKind int
const (
// AuthRateLimitCredential: strict per-IP and per-username limits; no failed-login lockout.
AuthRateLimitCredential AuthRateLimitKind = iota
// AuthRateLimitCredentialLockout: same limits as Credential, plus lockout after repeated 401s.
AuthRateLimitCredentialLockout
AuthRateLimitModerate
AuthRateLimitOIDC
// AuthRateLimitAuthenticated: per logged-in username (compose: withUser(withRateLimitChain(AuthRateLimitAuthenticated, h))).
AuthRateLimitAuthenticated
)
// withRateLimitChain applies a rate limit tier then fn for nesting inside withUser / withOrWithoutUser / withoutUser.
func withRateLimitChain(kind AuthRateLimitKind, fn handleFunc) handleFunc {
switch kind {
case AuthRateLimitCredential:
return withAuthRateLimitCredential(false, fn)
case AuthRateLimitCredentialLockout:
return withAuthRateLimitCredential(true, fn)
case AuthRateLimitModerate:
return withAuthRateLimitModerate(fn)
case AuthRateLimitOIDC:
return withAuthRateLimitOIDC(fn)
case AuthRateLimitAuthenticated:
return withAuthRateLimitAuthenticated(fn)
default:
return fn
}
}
// withRateLimit registers a rate-limited route (same shape as withTimeout: option first, handler second).
func withRateLimit(kind AuthRateLimitKind, fn handleFunc) http.HandlerFunc {
return wrapHandler(withRateLimitChain(kind, fn))
}
func authRateLimitActive() bool {
if config.Http.DisableRateLimit {
return false
}
if config.Auth.Methods.NoAuth {
return false
}
return true
}
func tokenBucketLimiter(c *cache.KeyCache[*rate.Limiter], key string, requestsPerMinute, burst int) *rate.Limiter {
if requestsPerMinute < 1 {
requestsPerMinute = 1
}
if burst < 1 {
burst = 1
}
if lim, ok := c.Get(key); ok && lim != nil {
c.SetWithExp(key, lim, authLimiterEntryTTL)
return lim
}
lim := rate.NewLimiter(rate.Limit(float64(requestsPerMinute))/60.0, burst)
c.SetWithExp(key, lim, authLimiterEntryTTL)
return lim
}
func retryAfterSeconds(lim *rate.Limiter) int {
res := lim.Reserve()
delay := res.Delay()
res.Cancel()
secs := int(math.Ceil(delay.Seconds()))
if secs < 1 {
secs = 1
}
return secs
}
func allowCredential(r *http.Request, loginUsername string) (retryAfter int, ok bool) {
ip := getRemoteIP(r)
limIP := tokenBucketLimiter(authRateLimitCredentialByIP, ip, authCredentialRPM, authCredentialBurst)
if !limIP.Allow() {
return retryAfterSeconds(limIP), false
}
if loginUsername != "" {
limUser := tokenBucketLimiter(authRateLimitCredentialByUsername, loginUsername, authCredentialRPM, authCredentialBurst)
if !limUser.Allow() {
return retryAfterSeconds(limUser), false
}
}
return 0, true
}
func allowModerate(r *http.Request) (retryAfter int, ok bool) {
ip := getRemoteIP(r)
lim := tokenBucketLimiter(authRateLimitModerateByIP, ip, authModerateRPM, authModerateBurst)
if !lim.Allow() {
return retryAfterSeconds(lim), false
}
return 0, true
}
func allowOIDC(r *http.Request) (retryAfter int, ok bool) {
ip := getRemoteIP(r)
lim := tokenBucketLimiter(authRateLimitOIDCByIP, ip, authOIDCRPM, authOIDCBurst)
if !lim.Allow() {
return retryAfterSeconds(lim), false
}
return 0, true
}
func allowAuthenticated(username string) (retryAfter int, ok bool) {
lim := tokenBucketLimiter(authRateLimitAuthenticatedByUser, username, authAuthenticatedRPM, authAuthenticatedBurst)
if !lim.Allow() {
return retryAfterSeconds(lim), false
}
return 0, true
}
func authFailKey(ip, username string) string {
return "fail" + authRateKeySep + ip + authRateKeySep + username
}
func authLockKey(ip, username string) string {
return "lock" + authRateKeySep + ip + authRateKeySep + username
}
func authLockoutRetryAfterSecs() int {
return authFailedLoginLockoutMins * 60
}
func isAuthLockout(ip, username string) bool {
locked, ok := authLockouts.Get(authLockKey(ip, username))
return ok && locked
}
func clearAuthLockout(ip, username string) {
authLockouts.Delete(authLockKey(ip, username))
authFailCounts.Delete(authFailKey(ip, username))
}
func recordAuthFailure(ip, username string) {
if username == "" {
return
}
window := time.Duration(authFailedLoginLockoutMins) * time.Minute
fk := authFailKey(ip, username)
n := 0
if v, ok := authFailCounts.Get(fk); ok {
n = v
}
n++
authFailCounts.SetWithExp(fk, n, window)
if n >= authFailedLoginMaxAttempts {
authLockouts.SetWithExp(authLockKey(ip, username), true, window)
}
}
// withRateLimitInternal applies token-bucket allow checks: when inactive, calls fn; when denied, 429 + Retry-After.
func withRateLimitInternal(fn handleFunc, allow func(*http.Request, *requestContext) (retryAfter int, ok bool)) handleFunc {
return func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !authRateLimitActive() {
return fn(w, r, d)
}
if after, ok := allow(r, d); !ok {
w.Header().Set("Retry-After", strconv.Itoa(after))
return http.StatusTooManyRequests, fmt.Errorf("too many requests")
}
return fn(w, r, d)
}
}
// withAuthRateLimitCredential enforces the credential tier (and optional per-username limits).
// When trackFailures is true, 401 responses increment failed-attempt state for lockout; success clears it.
func withAuthRateLimitCredential(trackFailures bool, fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !authRateLimitActive() {
return fn(w, r, d)
}
username := r.URL.Query().Get("username")
if isAuthLockout(getRemoteIP(r), username) {
w.Header().Set("Retry-After", strconv.Itoa(authLockoutRetryAfterSecs()))
return http.StatusTooManyRequests, fmt.Errorf("too many failed authentication attempts")
}
if after, ok := allowCredential(r, username); !ok {
w.Header().Set("Retry-After", strconv.Itoa(after))
return http.StatusTooManyRequests, fmt.Errorf("too many requests")
}
status, err := fn(w, r, d)
if !trackFailures {
return status, err
}
ip := getRemoteIP(r)
if status == http.StatusUnauthorized && username != "" {
recordAuthFailure(ip, username)
}
if err == nil && (status == 0 || status == http.StatusOK) {
clearAuthLockout(ip, username)
}
return status, err
}
}
func withAuthRateLimitModerate(fn handleFunc) handleFunc {
return withRateLimitInternal(fn, func(r *http.Request, _ *requestContext) (int, bool) {
return allowModerate(r)
})
}
func withAuthRateLimitOIDC(fn handleFunc) handleFunc {
return withRateLimitInternal(fn, func(r *http.Request, _ *requestContext) (int, bool) {
return allowOIDC(r)
})
}
func withAuthRateLimitAuthenticated(fn handleFunc) handleFunc {
return withRateLimitInternal(fn, func(_ *http.Request, d *requestContext) (int, bool) {
if d.user == nil || d.user.Username == "" || d.user.Username == "anonymous" {
return 0, true
}
return allowAuthenticated(d.user.Username)
})
}
+21 -18
View File
@@ -80,6 +80,10 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
publicRoutes := http.NewServeMux()
publicApi := http.NewServeMux()
// health routes
api.HandleFunc("GET /health", healthHandler)
publicApi.HandleFunc("GET /health", healthHandler)
// ========================================
// User Routes - /api/users/ (with public routes)
// ========================================
@@ -92,23 +96,23 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
// ========================================
// Auth Routes - /api/auth/
// ========================================
api.HandleFunc("POST /auth/login", loginHelper(loginHandler))
api.HandleFunc("POST /auth/logout", withOrWithoutUser(logoutHandler))
api.HandleFunc("POST /auth/signup", withoutUser(signupHandler))
api.HandleFunc("POST /auth/otp/generate", withOrWithoutUser(generateOTPHandler))
api.HandleFunc("POST /auth/otp/verify", withOrWithoutUser(verifyOTPHandler))
api.HandleFunc("POST /auth/renew", withUser(renewHandler))
api.HandleFunc("POST /auth/token", withUser(createApiTokenHandler))
api.HandleFunc("DELETE /auth/token", withUser(deleteApiTokenHandler))
api.HandleFunc("GET /auth/token/list", withUser(listApiTokensHandler))
api.HandleFunc("GET /auth/token", withUser(getApiTokenHandler))
api.HandleFunc("GET /auth/oidc/callback", wrapHandler(oidcCallbackHandler))
api.HandleFunc("GET /auth/oidc/login", wrapHandler(oidcLoginHandler))
api.HandleFunc("POST /auth/webauthn/begin-login", withoutUser(beginPasskeyLoginHandler))
api.HandleFunc("POST /auth/webauthn/finish-login", withoutUser(finishPasskeyLoginHandler))
api.HandleFunc("POST /auth/webauthn/begin-register", withUser(beginPasskeyRegistrationHandler))
api.HandleFunc("POST /auth/webauthn/finish-register", withUser(finishPasskeyRegistrationHandler))
api.HandleFunc("DELETE /auth/webauthn/{id}", withUser(deletePasskeyCredentialHandler))
api.HandleFunc("POST /auth/login", withRateLimit(AuthRateLimitCredentialLockout, loginHelper(loginHandler)))
api.HandleFunc("POST /auth/logout", withOrWithoutUser(withRateLimitChain(AuthRateLimitModerate, logoutHandler)))
api.HandleFunc("POST /auth/signup", withoutUser(withRateLimitChain(AuthRateLimitModerate, signupHandler)))
api.HandleFunc("POST /auth/otp/generate", withOrWithoutUser(withRateLimitChain(AuthRateLimitModerate, generateOTPHandler)))
api.HandleFunc("POST /auth/otp/verify", withOrWithoutUser(withRateLimitChain(AuthRateLimitCredentialLockout, verifyOTPHandler)))
api.HandleFunc("POST /auth/renew", withUser(withRateLimitChain(AuthRateLimitAuthenticated, renewHandler)))
api.HandleFunc("POST /auth/token", withUser(withRateLimitChain(AuthRateLimitAuthenticated, createApiTokenHandler)))
api.HandleFunc("DELETE /auth/token", withUser(withRateLimitChain(AuthRateLimitAuthenticated, deleteApiTokenHandler)))
api.HandleFunc("GET /auth/token/list", withUser(withRateLimitChain(AuthRateLimitAuthenticated, listApiTokensHandler)))
api.HandleFunc("GET /auth/token", withUser(withRateLimitChain(AuthRateLimitAuthenticated, getApiTokenHandler)))
api.HandleFunc("GET /auth/oidc/callback", withRateLimit(AuthRateLimitOIDC, oidcCallbackHandler))
api.HandleFunc("GET /auth/oidc/login", withRateLimit(AuthRateLimitOIDC, oidcLoginHandler))
api.HandleFunc("POST /auth/webauthn/begin-login", withoutUser(withRateLimitChain(AuthRateLimitCredential, beginPasskeyLoginHandler)))
api.HandleFunc("POST /auth/webauthn/finish-login", withoutUser(withRateLimitChain(AuthRateLimitCredential, finishPasskeyLoginHandler)))
api.HandleFunc("POST /auth/webauthn/begin-register", withUser(withRateLimitChain(AuthRateLimitAuthenticated, beginPasskeyRegistrationHandler)))
api.HandleFunc("POST /auth/webauthn/finish-register", withUser(withRateLimitChain(AuthRateLimitAuthenticated, finishPasskeyRegistrationHandler)))
api.HandleFunc("DELETE /auth/webauthn/{id}", withUser(withRateLimitChain(AuthRateLimitAuthenticated, deletePasskeyCredentialHandler)))
// ========================================
// Resources Routes - /api/resources/ (with public routes)
@@ -199,7 +203,6 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
// ========================================
// Misc Routes
// ========================================
api.HandleFunc("GET /health", healthHandler)
api.HandleFunc("GET /events", withUser(sseHandler))
if settings.Env.IsDevMode {
api.HandleFunc("GET /inspectIndex", inspectIndex)
+2 -2
View File
@@ -26,7 +26,7 @@ func AuthenticateLDAPUser(username, password string) (*users.User, error) {
}
// Use the configured UserIdentifier if available, otherwise use the login username
ldapCfg := settings.Config.Auth.Methods.LdapAuth
ldapCfg := config.Auth.Methods.LdapAuth
mappedUsername := username
if ldapCfg.UserIdentifier != "" {
if val, ok := userAttributes[ldapCfg.UserIdentifier]; ok && val != "" {
@@ -42,7 +42,7 @@ func AuthenticateLDAPUser(username, password string) (*users.User, error) {
}
func authenticateLDAP(username, password string) ([]string, map[string]string, error) {
c := settings.Config.Auth.Methods.LdapAuth
c := config.Auth.Methods.LdapAuth
logger.Debugf("ldap: connecting to %s", c.Server)
var opts []ldap.DialOpt
+2 -3
View File
@@ -10,7 +10,6 @@ import (
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"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"
@@ -129,7 +128,7 @@ func metadataHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
Content: false,
Metadata: true,
AlbumArt: albumArt,
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles,
ExtractEmbeddedSubtitles: config.Integrations.Media.ExtractEmbeddedSubtitles,
ShowHidden: d.user.ShowHidden,
HideFileExt: d.user.HideFileExt,
SkipExtendedAttrs: false,
@@ -167,7 +166,7 @@ func publicMetadataHandler(w http.ResponseWriter, r *http.Request, d *requestCon
Content: false,
Metadata: true,
AlbumArt: albumArt,
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles && d.share.ExtractEmbeddedSubtitles,
ExtractEmbeddedSubtitles: config.Integrations.Media.ExtractEmbeddedSubtitles && d.share.ExtractEmbeddedSubtitles,
ShowHidden: d.share.ShowHidden,
HideFileExt: d.user.HideFileExt,
FollowSymlinks: false,
+15 -52
View File
@@ -8,6 +8,7 @@ import (
"net"
"net/http"
"net/url"
"path/filepath"
"runtime"
"slices"
"strings"
@@ -31,7 +32,6 @@ type requestContext struct {
fileInfo iteminfo.ExtendedFileInfo
token string
share *share.Link
shareValid bool
ctx context.Context
MaxBandwidth int
Data interface{}
@@ -58,6 +58,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
if err != nil && inputPath != "" {
return http.StatusBadRequest, err
}
path = filepath.ToSlash(path)
// Get the file link by hash
link, err := store.Share.GetByHash(hash)
@@ -132,9 +133,9 @@ func withHashFileHelper(fn handleFunc) handleFunc {
Content: getContent,
Metadata: getMetadata,
AlbumArt: strings.Contains(r.URL.Path, "/preview"),
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles && link.ExtractEmbeddedSubtitles,
ExtractEmbeddedSubtitles: config.Integrations.Media.ExtractEmbeddedSubtitles && link.ExtractEmbeddedSubtitles,
ShowHidden: link.ShowHidden,
HideFileExt: link.HideFileExt,
HideFileExt: link.HideFileExt,
FollowSymlinks: true,
}, store.Access, data.shareUser, store.Share)
if err != nil {
@@ -227,57 +228,19 @@ func extractUserFromExpiredToken(r *http.Request, data *requestContext) *users.U
// If authentication fails, the request continues without a user.
func withOrWithoutUserHelper(fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
var link *share.Link
var isShareRequest bool
var shareHash string
hash := r.URL.Query().Get("hash")
if hash != "" {
// Get the file link by hash
isShareRequest = true
shareHash = hash
link, _ = store.Share.GetByHash(hash)
} else {
prefix := config.Server.BaseURL + "public/share/"
reconstructed := config.Server.BaseURL + "public" + r.URL.Path
if strings.HasPrefix(reconstructed, prefix) {
remaining := strings.TrimPrefix(reconstructed, prefix)
if remaining != "" {
if idx := strings.IndexByte(remaining, '/'); idx >= 0 {
remaining = remaining[:idx]
}
if remaining != "" {
isShareRequest = true
shareHash = remaining
var err error
link, err = store.Share.GetByHash(remaining)
if err != nil {
logger.Debugf("error getting share by hash: %v", err)
}
}
}
}
}
// If this is a share request, always create a share context (even if invalid)
if isShareRequest {
if link != nil {
data.share = link
data.shareValid = true
} else {
// Create an empty share with just the hash for invalid shares
data.share = &share.Link{Hash: shareHash}
data.shareValid = false
_, err := store.Share.GetByHash(hash)
if err != nil {
return http.StatusNotFound, fmt.Errorf("share hash not found")
}
}
// Try to authenticate user first
status, err := withUserHelper(nil)(w, r, data)
if err == nil && status < 400 {
if data.share != nil && data.shareValid {
if data.user != nil {
data.user.CustomTheme = data.share.ShareTheme
}
if data.share != nil && data.user != nil {
data.user.CustomTheme = data.share.ShareTheme
}
return fn(w, r, data)
}
@@ -288,7 +251,7 @@ func withOrWithoutUserHelper(fn handleFunc) handleFunc {
userFromExpiredToken := extractUserFromExpiredToken(r, data)
if userFromExpiredToken != nil {
data.user = userFromExpiredToken
if data.share != nil && data.shareValid {
if data.share != nil {
data.user.CustomTheme = data.share.ShareTheme
}
setUserInResponseWriter(w, data.user)
@@ -300,7 +263,7 @@ func withOrWithoutUserHelper(fn handleFunc) handleFunc {
settings.ApplyUserDefaults(data.user)
// Clear any user data that might have been partially set
data.token = ""
if data.share != nil && data.shareValid {
if data.share != nil {
data.user.CustomTheme = data.share.ShareTheme
}
// Call the handler function without user context
@@ -664,8 +627,8 @@ func withoutUser(fn handleFunc) http.HandlerFunc {
return wrapHandler(withoutUserHelper(fn))
}
func loginHelper(fn handleFunc) http.HandlerFunc {
return wrapHandler(LoginHelper(false, fn))
func loginHelper(fn handleFunc) handleFunc {
return LoginHelper(false, fn)
}
func withSelfOrAdmin(fn handleFunc) http.HandlerFunc {
@@ -760,7 +723,7 @@ func setUserInResponseWriter(w http.ResponseWriter, user *users.User) {
func getRemoteIP(r *http.Request) string {
// 1. Check X-Forwarded-For
xff := r.Header.Get("X-Forwarded-For")
if xff != "" {
if config.Http.TrustedHeaders["x-forwarded-for"] && xff != "" {
// The first IP is the original client
ips := strings.Split(xff, ",")
return strings.TrimSpace(ips[0])
@@ -768,7 +731,7 @@ func getRemoteIP(r *http.Request) string {
// 2. Check X-Real-IP
xri := r.Header.Get("X-Real-IP")
if xri != "" {
if config.Http.TrustedHeaders["x-real-ip"] && xri != "" {
return xri
}
+2 -2
View File
@@ -159,7 +159,7 @@ func parseOIDCGroupsValue(groupsVal interface{}) []string {
// @Success 302 {string} string "Redirect to OIDC provider"
// @Router /api/auth/oidc/login [get]
func oidcLoginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
oidcCfg := settings.Config.Auth.Methods.OidcAuth
oidcCfg := config.Auth.Methods.OidcAuth
if !oidcCfg.Enabled {
return http.StatusForbidden, fmt.Errorf("oidc authentication is not enabled")
}
@@ -197,7 +197,7 @@ func oidcLoginHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
// @Router /api/auth/oidc/callback [get]
func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
ctx := r.Context()
oidcCfg := settings.Config.Auth.Methods.OidcAuth
oidcCfg := config.Auth.Methods.OidcAuth
if oidcCfg.Provider == nil || oidcCfg.Verifier == nil {
// Ensure Provider and Verifier are initialized on application startup
// This check is good, keep it.
+121 -16
View File
@@ -11,6 +11,7 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
@@ -28,8 +29,16 @@ const (
onlyOfficeStatusDocumentClosedWithNoChanges = 4
onlyOfficeStatusForceSaveWhileDocumentStillOpen = 6
onlyOfficeStatusForceSaveError = 7
onlyOfficeDownloadTimeout = 10 * time.Second
)
// onlyOfficeDownloadClient fetches saved documents from the OnlyOffice document server.
// A bounded timeout avoids hanging goroutines when the server is unreachable.
var onlyOfficeDownloadClient = &http.Client{
Timeout: onlyOfficeDownloadTimeout,
}
type OnlyOfficeCallback struct {
Actions []OnlyOfficeAction `json:"actions,omitempty"`
ChangesURL string `json:"changesurl,omitempty"`
@@ -81,7 +90,7 @@ type OnlyOfficeJWTPayload struct {
// @Router /api/office/config [get]
// @Security ApiKeyAuth
func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if settings.Config.Integrations.OnlyOffice.Url == "" {
if config.Integrations.OnlyOffice.Url == "" {
return http.StatusInternalServerError, errors.New("only-office integration must be configured in settings")
}
@@ -107,13 +116,13 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
var sourceInfo *settings.Source
var ok bool
if hash != "" {
sourceInfo, ok = settings.Config.Server.SourceMap[d.share.Source]
sourceInfo, ok = config.Server.SourceMap[d.share.Source]
if !ok {
logger.Error("OnlyOffice: source not found")
return http.StatusInternalServerError, fmt.Errorf("source not found")
}
} else {
sourceInfo, ok = settings.Config.Server.NameToSource[source]
sourceInfo, ok = config.Server.NameToSource[source]
if !ok {
logger.Error("OnlyOffice: source not found")
return http.StatusInternalServerError, fmt.Errorf("source not found")
@@ -210,7 +219,7 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
"title": d.fileInfo.Name,
"url": downloadURL,
"permissions": map[string]interface{}{
"edit": utils.Ternary(settings.Config.Integrations.OnlyOffice.ViewOnly, "view", canEditMode),
"edit": utils.Ternary(config.Integrations.OnlyOffice.ViewOnly, "view", canEditMode),
"download": true,
"print": true,
},
@@ -227,14 +236,14 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
"uiTheme": themeMode,
},
"lang": d.user.Locale,
"mode": utils.Ternary(settings.Config.Integrations.OnlyOffice.ViewOnly, "view", canEditMode),
"mode": utils.Ternary(config.Integrations.OnlyOffice.ViewOnly, "view", canEditMode),
},
}
// Sign configuration with JWT if secret is configured
if settings.Config.Integrations.OnlyOffice.Secret != "" {
if config.Integrations.OnlyOffice.Secret != "" {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(clientConfig))
signature, err := token.SignedString([]byte(settings.Config.Integrations.OnlyOffice.Secret))
signature, err := token.SignedString([]byte(config.Integrations.OnlyOffice.Secret))
if err != nil {
logger.Errorf("OnlyOffice: failed to sign JWT: %v", err)
return http.StatusInternalServerError, fmt.Errorf("failed to sign configuration")
@@ -249,10 +258,10 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
func buildOnlyOfficeDownloadURL(r *http.Request, source, path, hash, token string) string {
// Determine base URL (internal URL takes priority for OnlyOffice server communication)
var baseURL string
if settings.Config.Server.InternalUrl != "" {
if config.Server.InternalUrl != "" {
// InternalUrl is a full URL (e.g., http://localhost:8080), so use it directly
internalURL := strings.TrimSuffix(settings.Config.Server.InternalUrl, "/")
baseURLPath := strings.TrimPrefix(settings.Config.Server.BaseURL, "/")
internalURL := strings.TrimSuffix(config.Server.InternalUrl, "/")
baseURLPath := strings.TrimPrefix(config.Server.BaseURL, "/")
if baseURLPath != "" {
baseURL = internalURL + "/" + baseURLPath
} else {
@@ -276,7 +285,7 @@ func buildOnlyOfficeDownloadURL(r *http.Request, source, path, hash, token strin
host = r.Host
scheme = getScheme(r)
}
baseURL = fmt.Sprintf("%s://%s%s", scheme, host, settings.Config.Server.BaseURL)
baseURL = fmt.Sprintf("%s://%s%s", scheme, host, config.Server.BaseURL)
}
escapedPath := url.QueryEscape(path)
@@ -293,10 +302,10 @@ func buildOnlyOfficeDownloadURL(r *http.Request, source, path, hash, token strin
func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, token string) string {
// Determine base URL (internal URL takes priority for OnlyOffice server communication)
var baseURL string
if settings.Config.Server.InternalUrl != "" {
if config.Server.InternalUrl != "" {
// InternalUrl is a full URL (e.g., http://localhost:8080), so use it directly
internalURL := strings.TrimSuffix(settings.Config.Server.InternalUrl, "/")
baseURLPath := strings.TrimPrefix(settings.Config.Server.BaseURL, "/")
internalURL := strings.TrimSuffix(config.Server.InternalUrl, "/")
baseURLPath := strings.TrimPrefix(config.Server.BaseURL, "/")
if baseURLPath != "" {
baseURL = internalURL + "/" + baseURLPath
} else {
@@ -320,7 +329,7 @@ func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, token strin
host = r.Host
scheme = getScheme(r)
}
baseURL = fmt.Sprintf("%s://%s%s", scheme, host, settings.Config.Server.BaseURL)
baseURL = fmt.Sprintf("%s://%s%s", scheme, host, config.Server.BaseURL)
}
var callbackURL string
@@ -347,6 +356,97 @@ func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, token strin
return callbackURL
}
// resolveOnlyOfficeDownloadURL validates a callback document URL against
// integrations.office.url and optionally rewrites the origin to integrations.office.internalUrl.
// Returns an empty string when the URL is missing, malformed, or not hosted on the configured
// OnlyOffice server (SSRF protection).
func resolveOnlyOfficeDownloadURL(rawURL string) string {
if rawURL == "" {
return ""
}
publicBase := config.Integrations.OnlyOffice.Url
if publicBase == "" {
logger.Warningf("OnlyOffice callback: integrations.office.url is not configured")
return ""
}
parsedURL, err := url.Parse(rawURL)
if err != nil {
logger.Warningf("OnlyOffice callback: could not parse document URL (%q): %v", rawURL, err)
return ""
}
publicURL, err := url.Parse(publicBase)
if err != nil {
logger.Warningf("OnlyOffice callback: could not parse integrations.office.url (%q): %v", publicBase, err)
return ""
}
if !onlyOfficeURLHostsMatch(parsedURL, publicURL) {
logger.Warningf("OnlyOffice callback: rejecting document URL with untrusted host %q (expected %q)",
parsedURL.Host, publicURL.Host)
return ""
}
internalBase := config.Integrations.OnlyOffice.InternalUrl
if internalBase == "" {
return rawURL
}
internalURL, err := url.Parse(internalBase)
if err != nil {
logger.Warningf("OnlyOffice callback: could not parse integrations.office.internalUrl (%q): %v", internalBase, err)
return ""
}
if !isAllowedOnlyOfficeScheme(internalURL.Scheme) || internalURL.Host == "" {
logger.Warningf("OnlyOffice callback: invalid integrations.office.internalUrl (%q)", internalBase)
return ""
}
rewritten := *parsedURL
rewritten.Scheme = internalURL.Scheme
rewritten.Host = internalURL.Host
result := rewritten.String()
if result != rawURL {
logger.Debugf("OnlyOffice callback: rewrote URL from %s to %s", rawURL, result)
}
return result
}
func isAllowedOnlyOfficeScheme(scheme string) bool {
return scheme == "http" || scheme == "https"
}
func onlyOfficeEffectivePort(u *url.URL) string {
if port := u.Port(); port != "" {
return port
}
switch u.Scheme {
case "https":
return "443"
case "http":
return "80"
default:
return ""
}
}
// onlyOfficeURLHostsMatch reports whether callback and configured URLs refer to the same
// OnlyOffice host, comparing hostname and effective port (so office.local matches office.local:80).
func onlyOfficeURLHostsMatch(callback, configured *url.URL) bool {
if !isAllowedOnlyOfficeScheme(callback.Scheme) || !isAllowedOnlyOfficeScheme(configured.Scheme) {
return false
}
if callback.Hostname() == "" || configured.Hostname() == "" {
return false
}
if !strings.EqualFold(callback.Hostname(), configured.Hostname()) {
return false
}
return onlyOfficeEffectivePort(callback) == onlyOfficeEffectivePort(configured)
}
// processOnlyOfficeCallback handles the common callback processing logic for both GET and POST requests
func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *requestContext, data *OnlyOfficeCallback) (int, error) {
// Extract clean parameters from query string
@@ -492,7 +592,12 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
}
// Download the updated document from OnlyOffice server
doc, err := http.Get(data.URL)
downloadURL := resolveOnlyOfficeDownloadURL(data.URL)
if downloadURL == "" {
logger.Errorf("OnlyOffice callback: missing or untrusted document URL in callback payload")
return returnOnlyOfficeError(w, r, 500, "missing or untrusted document URL in callback payload")
}
doc, err := onlyOfficeDownloadClient.Get(downloadURL)
if err != nil {
logger.Errorf("OnlyOffice callback: failed to download updated document: %v", err)
return returnOnlyOfficeError(w, r, 500, "failed to download updated document")
+102
View File
@@ -0,0 +1,102 @@
package http
import (
"net/url"
"testing"
)
func TestResolveOnlyOfficeDownloadURL(t *testing.T) {
orig := config.Integrations.OnlyOffice
t.Cleanup(func() {
config.Integrations.OnlyOffice = orig
})
config.Integrations.OnlyOffice.Url = "http://192.168.88.100:8282"
config.Integrations.OnlyOffice.InternalUrl = "http://onlyoffice"
cachePath := "/cache/files/data/doc_1/output.ods/output.ods?md5=abc&expires=1"
publicURL := "http://192.168.88.100:8282" + cachePath
internalURL := "http://onlyoffice" + cachePath
tests := []struct {
name string
input string
want string
}{
{
name: "rewrites public cache URL to internal host",
input: publicURL,
want: internalURL,
},
{
name: "empty URL",
input: "",
want: "",
},
{
name: "untrusted host rejected",
input: "http://other-host:8282" + cachePath,
want: "",
},
{
name: "non-http scheme rejected",
input: "ftp://192.168.88.100:8282" + cachePath,
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveOnlyOfficeDownloadURL(tt.input)
if got != tt.want {
t.Errorf("resolveOnlyOfficeDownloadURL() = %q, want %q", got, tt.want)
}
})
}
t.Run("pass through public URL when internalUrl unset", func(t *testing.T) {
config.Integrations.OnlyOffice.InternalUrl = ""
got := resolveOnlyOfficeDownloadURL(publicURL)
if got != publicURL {
t.Errorf("resolveOnlyOfficeDownloadURL() = %q, want %q", got, publicURL)
}
})
t.Run("hostname match with default http port", func(t *testing.T) {
config.Integrations.OnlyOffice.Url = "http://office.local"
config.Integrations.OnlyOffice.InternalUrl = "http://onlyoffice"
input := "http://office.local:80" + cachePath
want := "http://onlyoffice" + cachePath
got := resolveOnlyOfficeDownloadURL(input)
if got != want {
t.Errorf("resolveOnlyOfficeDownloadURL() = %q, want %q", got, want)
}
})
t.Run("reject when public url not configured", func(t *testing.T) {
config.Integrations.OnlyOffice.Url = ""
config.Integrations.OnlyOffice.InternalUrl = "http://onlyoffice"
got := resolveOnlyOfficeDownloadURL(publicURL)
if got != "" {
t.Errorf("resolveOnlyOfficeDownloadURL() = %q, want empty", got)
}
})
}
func TestOnlyOfficeURLHostsMatch(t *testing.T) {
mustParse := func(raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("url.Parse(%q): %v", raw, err)
}
return u
}
if !onlyOfficeURLHostsMatch(mustParse("http://office.local:80/x"), mustParse("http://office.local/x")) {
t.Fatal("expected office.local:80 to match office.local with default port")
}
if onlyOfficeURLHostsMatch(mustParse("http://evil.example/x"), mustParse("http://office.local/x")) {
t.Fatal("expected different hostnames not to match")
}
}
+2 -3
View File
@@ -14,7 +14,6 @@ import (
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/preview"
@@ -121,7 +120,7 @@ func getDirectoryPreview(r *http.Request, d *requestContext, frameIndex int) (*i
path := utils.JoinPathAsUnix(d.fileInfo.Path, name)
user := d.user
if d.share != nil {
sourceInfo, ok := settings.Config.Server.SourceMap[d.share.Source]
sourceInfo, ok := config.Server.SourceMap[d.share.Source]
if !ok {
return nil, fmt.Errorf("source not found for share")
}
@@ -214,7 +213,7 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
if d.fileInfo.OnlyOfficeId != "" {
pathUrl := fmt.Sprintf("/api/resources/download?file=%s&source=%s", url.QueryEscape(d.fileInfo.Path), url.QueryEscape(d.fileInfo.Source))
pathUrl = pathUrl + "&auth=" + d.token
if settings.Config.Server.InternalUrl != "" {
if config.Server.InternalUrl != "" {
officeUrl = config.Server.InternalUrl + pathUrl
} else {
scheme := "http"
+12 -4
View File
@@ -8,7 +8,6 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/go-logger/logger"
@@ -80,7 +79,7 @@ func publicDownloadHandler(w http.ResponseWriter, r *http.Request, d *requestCon
}
// Get the actual source name from the share's source mapping
sourceInfo, ok := settings.Config.Server.SourceMap[d.share.Source]
sourceInfo, ok := config.Server.SourceMap[d.share.Source]
if !ok {
return http.StatusInternalServerError, fmt.Errorf("source not found for share")
}
@@ -368,10 +367,19 @@ func publicPatchHandler(w http.ResponseWriter, r *http.Request, d *requestContex
// Note: Share paths are absolute, so we don't strip user scope here
// resourcePatchHandler will skip adding scope for shares
for i := range req.Items {
var sanitizedPath string
sanitizedPath, err = utils.SanitizeUserPath(req.Items[i].FromPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid from path: %w", err)
}
req.Items[i].FromSource = sourceName
req.Items[i].FromPath = utils.JoinPathAsUnix(d.share.Path, req.Items[i].FromPath)
req.Items[i].FromPath = utils.JoinPathAsUnix(d.share.Path, sanitizedPath)
sanitizedPath, err = utils.SanitizeUserPath(req.Items[i].ToPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid to path: %w", err)
}
req.Items[i].ToSource = sourceName
req.Items[i].ToPath = utils.JoinPathAsUnix(d.share.Path, req.Items[i].ToPath)
req.Items[i].ToPath = utils.JoinPathAsUnix(d.share.Path, sanitizedPath)
}
d.Data = req
+86 -13
View File
@@ -18,7 +18,6 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
@@ -99,7 +98,7 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
Expand: true,
Content: getContent,
Metadata: getMetadata,
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles,
ExtractEmbeddedSubtitles: config.Integrations.Media.ExtractEmbeddedSubtitles,
ShowHidden: d.user.ShowHidden,
HideFileExt: d.user.HideFileExt,
SkipExtendedAttrs: skipExtendedAttrs,
@@ -146,18 +145,27 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
if !d.user.Permissions.Delete {
return http.StatusForbidden, fmt.Errorf("user is not allowed to delete")
}
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if path == "/" {
return http.StatusForbidden, fmt.Errorf("cannot delete your user's root directory")
}
idx := indexing.GetIndex(source)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
if idx.Config.ReadOnly {
return http.StatusForbidden, fmt.Errorf("source is read-only")
}
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
Path: path,
Source: source,
Expand: false,
ShowHidden: d.user.ShowHidden,
Path: path,
Source: source,
Expand: false,
ShowHidden: d.user.ShowHidden,
HideFileExt: d.user.HideFileExt,
}, store.Access, d.user, store.Share)
if err != nil {
@@ -286,8 +294,33 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
if d.share != nil {
sourceName := d.share.GetSourceName()
if sourceName == "" {
return http.StatusNotFound, fmt.Errorf("source not available")
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: sanitizedPath,
Message: "source name is empty",
})
continue
}
idx := indexing.GetIndex(sourceName)
if idx == nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: sanitizedPath,
Message: "source not found",
})
continue
}
if idx.Config.ReadOnly {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: sanitizedPath,
Message: "source is read-only",
})
continue
}
// get user scope path from share
userScope, err := d.shareUser.GetScopeForSourceName(sourceName)
if err != nil {
@@ -356,6 +389,15 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
continue
}
if idx.Config.ReadOnly {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: sanitizedPath,
Message: "source is read-only",
})
continue
}
// Get file info
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
@@ -493,6 +535,17 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
if d.share == nil && !d.user.Permissions.Create {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
idx := indexing.GetIndex(source)
if idx == nil {
logger.Debugf("source %s not found", source)
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
if idx.Config.ReadOnly {
return http.StatusForbidden, fmt.Errorf("source is read-only")
}
filePermUser := d.user
if d.share != nil {
filePermUser = d.shareUser
@@ -504,11 +557,6 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
Expand: false,
FollowSymlinks: true,
}
idx := indexing.GetIndex(source)
if idx == nil {
logger.Debugf("source %s not found", source)
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
userscope, err := filePermUser.GetScopeForSourceName(source)
if err != nil {
@@ -722,6 +770,9 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
if idx.Config.ReadOnly {
return http.StatusForbidden, fmt.Errorf("source is read-only")
}
if store.Access != nil && !store.Access.Permitted(idx.Path, fullIndexPath, d.user.Username) {
logger.Debugf("user %s denied access to path %s", d.user.Username, fullIndexPath)
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
@@ -855,6 +906,28 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
continue
}
if srcIdx.Config.ReadOnly && req.Action == "move" {
item.Message = "source source is read-only"
if d.share != nil {
response.Failed = append(response.Failed, MoveCopyItem{
Message: item.Message,
})
continue
}
}
if dstIdx.Config.ReadOnly {
item.Message = "destination source is read-only"
if d.share != nil {
response.Failed = append(response.Failed, MoveCopyItem{
Message: item.Message,
})
continue
}
response.Failed = append(response.Failed, item)
continue
}
// Build full index paths for access control
fullSrcIndexPath := utils.JoinPathAsUnix(userscopeSrc, item.FromPath)
fullDstIndexPath := utils.JoinPathAsUnix(userscopeDst, item.ToPath)
+7 -8
View File
@@ -8,7 +8,6 @@ import (
"strconv"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
@@ -43,12 +42,12 @@ type scopedSourcePath struct {
//
// Per-source search scope (preferred for multi-source):
//
// Repeated query parameter "scope" with the value "sourceName:relativePath",
// split on the first colon. The path is user-relative (same as the legacy single-scope
// path). Encode the whole value with the normal query string rules (e.g. %3A for ':' if needed).
// Examples:
// ?scope=mydisk:/&scope=backup:/Photos
// Duplicate source names: the last repeated scope for that source wins.
// Repeated query parameter "scope" with the value "sourceName:relativePath",
// split on the first colon. The path is user-relative (same as the legacy single-scope
// path). Encode the whole value with the normal query string rules (e.g. %3A for ':' if needed).
// Examples:
// ?scope=mydisk:/&scope=backup:/Photos
// Duplicate source names: the last repeated scope for that source wins.
//
// Legacy (still supported):
//
@@ -257,7 +256,7 @@ func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, erro
parsed := iteminfo.BuildSearchOptionsFromQuery(query, normalizedTerms, matchAllTerms)
minLen := settings.Config.Server.MinSearchLength
minLen := config.Server.MinSearchLength
if !largest {
if len(normalizedTerms) > 0 {
for _, t := range normalizedTerms {
+7 -1
View File
@@ -206,6 +206,12 @@ func sharePatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext
return http.StatusBadRequest, fmt.Errorf("hash and path are required")
}
sanitizedPath, err := utils.SanitizeUserPath(body.Path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %w", err)
}
body.Path = sanitizedPath
// only allow users to update their own shares
thisShare, err := store.Share.GetByHash(body.Hash)
if err != nil {
@@ -215,7 +221,7 @@ func sharePatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext
return http.StatusForbidden, fmt.Errorf("you are not allowed to update this share")
}
// Update the share path
err = store.Share.UpdateSharePath(body.Hash, body.Path)
err = store.Share.UpdateSharePath(body.Hash, sanitizedPath)
if err != nil {
return http.StatusInternalServerError, err
}
+7 -7
View File
@@ -95,8 +95,8 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
favicon := staticURL + "/favicon" + faviconExt
shareHash := ""
data := make(map[string]interface{})
disableNavButtons := settings.Config.Frontend.DisableNavButtons
if d.share != nil && d.shareValid {
disableNavButtons := config.Frontend.DisableNavButtons
if d.share != nil {
if d.share.Favicon != "" {
if strings.HasPrefix(d.share.Favicon, "http") {
favicon = d.share.Favicon
@@ -185,28 +185,28 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"pwaIcon512": pwaIcon512,
"manifestURL": staticURL + "/site.webmanifest",
}
// variables consumed by frontend as json
data["globalVars"] = map[string]interface{}{
"name": config.Frontend.Name,
"minSearchLength": config.Server.MinSearchLength,
"disableExternal": config.Frontend.DisableDefaultLinks,
"darkMode": settings.Config.UserDefaults.UI.DarkMode,
"darkMode": config.UserDefaults.UI.DarkMode,
"baseURL": config.Server.BaseURL,
"version": versionString,
"commitSHA": commitSHAString,
"signup": settings.Config.Auth.Methods.PasswordAuth.Signup,
"signup": config.Auth.Methods.PasswordAuth.Signup,
"noAuth": config.Auth.Methods.NoAuth,
"enableThumbs": !config.Server.DisablePreviews,
"externalLinks": externalLinks,
"externalUrl": strings.TrimSuffix(config.Server.ExternalUrl, "/"),
"onlyOfficeUrl": settings.Config.Integrations.OnlyOffice.Url,
"onlyOfficeUrl": config.Integrations.OnlyOffice.Url,
"oidcAvailable": config.Auth.Methods.OidcAuth.Enabled,
"jwtAvailable": config.Auth.Methods.JwtAuth.Enabled,
"proxyAvailable": config.Auth.Methods.ProxyAuth.Enabled,
"passwordAvailable": config.Auth.Methods.PasswordAuth.Enabled,
"ldapAvailable": config.Auth.Methods.LdapAuth.Enabled,
"mediaAvailable": settings.MediaEnabled(),
"exiftoolAvailable": settings.Config.Integrations.Media.ExiftoolPath != "",
"exiftoolAvailable": config.Integrations.Media.ExiftoolPath != "",
"muPdfAvailable": settings.Env.MuPdfAvailable,
"updateAvailable": utils.GetUpdateAvailableUrl(),
"disableNavButtons": disableNavButtons,
+1 -2
View File
@@ -14,7 +14,6 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
@@ -132,7 +131,7 @@ func GetFrontendSidebarLinks(links []users.SidebarLink, showToolsInSidebar bool)
if !ok {
continue
}
if full, ok := settings.Config.Server.SourceMap[source.Path]; ok {
if full, ok := config.Server.SourceMap[source.Path]; ok {
if full.Config.ResolvedRules.IndexingDisabled && link.Category != "source-minimal" {
link.Category = "source-alt"
}
+20 -12
View File
@@ -27,7 +27,9 @@ func beginPasskeyLoginHandler(w http.ResponseWriter, r *http.Request, d *request
return http.StatusForbidden, errors.ErrPasskeyNotEnabled
}
registerRequestOrigin(r)
if err := validateRequestOrigin(r); err != nil {
return http.StatusForbidden, err
}
username := r.URL.Query().Get("username")
password := r.Header.Get("X-Password")
@@ -70,7 +72,9 @@ func finishPasskeyLoginHandler(w http.ResponseWriter, r *http.Request, d *reques
return http.StatusForbidden, errors.ErrPasskeyNotEnabled
}
registerRequestOrigin(r)
if err := validateRequestOrigin(r); err != nil {
return http.StatusForbidden, err
}
sessionID := r.URL.Query().Get("session_id")
if sessionID == "" {
@@ -100,7 +104,9 @@ func beginPasskeyRegistrationHandler(w http.ResponseWriter, r *http.Request, d *
return http.StatusForbidden, errors.ErrPasskeyNotEnabled
}
registerRequestOrigin(r)
if err := validateRequestOrigin(r); err != nil {
return http.StatusForbidden, err
}
rpID := deriveRPID(r)
svc := auth.GetWebAuthn()
@@ -134,7 +140,9 @@ func finishPasskeyRegistrationHandler(w http.ResponseWriter, r *http.Request, d
return http.StatusForbidden, errors.ErrPasskeyNotEnabled
}
registerRequestOrigin(r)
if err := validateRequestOrigin(r); err != nil {
return http.StatusForbidden, err
}
sessionID := r.URL.Query().Get("session_id")
credentialName := r.URL.Query().Get("name")
@@ -207,8 +215,9 @@ func deriveRPID(r *http.Request) string {
return host
}
// registerRequestOrigin registers the request's origin and RP ID with the WebAuthn service.
func registerRequestOrigin(r *http.Request) {
// validateRequestOrigin validates that the request's origin is in the configured whitelist.
// Returns an error if the origin is not allowed.
func validateRequestOrigin(r *http.Request) error {
scheme := "http"
if r.TLS != nil {
scheme = "https"
@@ -220,13 +229,12 @@ func registerRequestOrigin(r *http.Request) {
host = r.Host
}
origin := fmt.Sprintf("%s://%s", scheme, host)
rpID := host
if i := strings.LastIndex(rpID, ":"); i != -1 {
rpID = rpID[:i]
}
svc := auth.GetWebAuthn()
if svc != nil {
svc.EnsureOrigin(origin)
svc.EnsureRPID(rpID)
if !svc.ValidateOrigin(origin) {
return fmt.Errorf("origin %s is not in the configured whitelist", origin)
}
}
return nil
}
+49 -49
View File
@@ -12,9 +12,9 @@ import (
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
commonerrors "github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
commonerrors "github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
@@ -41,7 +41,7 @@ func setupWebDAVTestEnv(t *testing.T) (string, string) {
filepath.Join(dockerPath, "data"),
filepath.Join(source2Path, "shared"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatal(err)
@@ -50,12 +50,12 @@ func setupWebDAVTestEnv(t *testing.T) (string, string) {
// Create test files
testFiles := map[string]string{
filepath.Join(source1Path, "public", "readme.txt"): "public content",
filepath.Join(source1Path, "private", "secret.txt"): "private content",
filepath.Join(source1Path, "viewable-only", "data.txt"): "viewable content",
filepath.Join(source1Path, "not-viewable", "hidden.txt"): "hidden content",
filepath.Join(dockerPath, "data", "config.yml"): "docker config",
filepath.Join(source2Path, "shared", "document.txt"): "shared content",
filepath.Join(source1Path, "public", "readme.txt"): "public content",
filepath.Join(source1Path, "private", "secret.txt"): "private content",
filepath.Join(source1Path, "viewable-only", "data.txt"): "viewable content",
filepath.Join(source1Path, "not-viewable", "hidden.txt"): "hidden content",
filepath.Join(dockerPath, "data", "config.yml"): "docker config",
filepath.Join(source2Path, "shared", "document.txt"): "shared content",
}
for path, content := range testFiles {
@@ -74,18 +74,18 @@ func setupWebDAVTestEnv(t *testing.T) (string, string) {
if err != nil {
t.Fatal(err)
}
// Clear any previous access rules
store.Access.AllRules = make(access.SourceRuleMap)
store.Access.Groups = make(access.GroupMap)
// Set cache directory for index database
settings.Config.Server.CacheDir = tempDir
// Setup config with sources - update both the local config var AND the global settings.Config
config.Server.CacheDir = tempDir
// Setup config with sources - update both the local config var AND the global config
config = &settings.Settings{
Server: settings.Server{
BaseURL: "/", // Use / for tests to avoid prefix mismatch
BaseURL: "/", // Use / for tests to avoid prefix mismatch
CacheDir: tempDir,
SourceMap: map[string]*settings.Source{
source1Path: {
@@ -109,8 +109,8 @@ func setupWebDAVTestEnv(t *testing.T) (string, string) {
},
},
}
// CRITICAL: Also update the global settings.Config so access.Permitted can find sources
// CRITICAL: Also update the global config so access.Permitted can find sources
settings.Config.Server.SourceMap = config.Server.SourceMap
settings.Config.Server.NameToSource = config.Server.NameToSource
@@ -136,11 +136,11 @@ func setupWebDAVTestEnv(t *testing.T) (string, string) {
// mockCheckPermissions mocks the CheckPermissions function to bypass index lookups
func mockCheckPermissions(t *testing.T, source1Path, source2Path string) {
t.Helper()
// Store original function
originalCheckPermissions := files.CheckPermissionsFunc
t.Cleanup(func() { files.CheckPermissionsFunc = originalCheckPermissions })
// Mock the function
files.CheckPermissionsFunc = func(opts utils.FileOptions, access *access.Storage, user *users.User) (string, string, error) {
// Get the source path
@@ -152,7 +152,7 @@ func mockCheckPermissions(t *testing.T, source1Path, source2Path string) {
} else {
return "", "", fmt.Errorf("unknown source: %s", opts.Source)
}
// Get user scope for this source - match by source PATH, not name
userScope := "/"
hasScope := false
@@ -163,26 +163,26 @@ func mockCheckPermissions(t *testing.T, source1Path, source2Path string) {
break
}
}
// If user has no scope for this source, deny access
if !hasScope && len(user.Scopes) > 0 {
return "", "", fmt.Errorf("user has no access to source: %s", opts.Source)
}
// Sanitize path
safePath, err := utils.SanitizeUserPath(opts.Path)
if err != nil {
return "", "", commonerrors.ErrAccessDenied
}
// Combine scope + sanitized path
indexPath := utils.JoinPathAsUnix(userScope, safePath)
// Check access control
if access != nil && !access.Permitted(sourcePath, indexPath, user.Username) {
return "", "", commonerrors.ErrAccessDenied
}
return indexPath, userScope, nil
}
}
@@ -203,7 +203,7 @@ func mockWebDAVIndexing(t *testing.T, source1Path, source2Path string) {
} else {
return nil, fmt.Errorf("unknown source: %s", opts.Source)
}
// Simulate access control
if access != nil && user != nil {
// Simple scope check based on user's configured scopes (which use source PATH)
@@ -214,7 +214,7 @@ func mockWebDAVIndexing(t *testing.T, source1Path, source2Path string) {
// Check if path is within user's scope
userScope := scope.Scope
fullPath := utils.JoinPathAsUnix(userScope, opts.Path)
// Check if user has permission using the access storage
if !access.Permitted(sourcePath, fullPath, user.Username) {
return nil, commonerrors.ErrAccessDenied
@@ -229,12 +229,12 @@ func mockWebDAVIndexing(t *testing.T, source1Path, source2Path string) {
// Simulate indexing rules
path := opts.Path
// Paths containing "not-viewable" are not indexed and not viewable
if strings.Contains(path, "not-viewable") {
return nil, commonerrors.ErrNotViewable
}
// Paths containing "viewable-only" are not indexed but viewable
if strings.Contains(path, "viewable-only") {
return nil, commonerrors.ErrNotIndexed
@@ -333,12 +333,12 @@ func TestWebDAV_PROPFIND_UserScopes(t *testing.T) {
}
testCases := []struct {
name string
user *users.User
source string
path string
expectedStatus int
shouldHaveContent bool
name string
user *users.User
source string
path string
expectedStatus int
shouldHaveContent bool
}{
{
name: "Admin can access root of source1",
@@ -353,7 +353,7 @@ func TestWebDAV_PROPFIND_UserScopes(t *testing.T) {
user: scopedUser,
source: "source1",
path: "/",
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
shouldHaveContent: false, // Empty because _docker is not viewable
},
{
@@ -390,19 +390,19 @@ func TestWebDAV_PROPFIND_UserScopes(t *testing.T) {
req := httptest.NewRequest("PROPFIND", "/dav/"+tc.source+tc.path, nil)
req.SetPathValue("source", tc.source)
req.SetPathValue("path", tc.path)
w := httptest.NewRecorder()
ctx := &requestContext{user: tc.user}
status, err := webDAVHandler(w, req, ctx)
// If handler returned an error status, use that instead of response code
if status != 0 && status != http.StatusOK {
if w.Code == 0 || w.Code == http.StatusOK {
w.Code = status
}
}
// Check the actual HTTP response status code from ResponseWriter
if w.Code != tc.expectedStatus {
t.Errorf("expected status %d, got %d (err: %v)", tc.expectedStatus, w.Code, err)
@@ -529,7 +529,7 @@ func TestWebDAV_WriteOperations(t *testing.T) {
name: "Scoped user can write in their scope",
user: scopedUser,
method: http.MethodPut,
path: "/scopedfile.txt", // Path relative to scope (/public)
path: "/scopedfile.txt", // Path relative to scope (/public)
expectedStatus: http.StatusCreated, // 201 for new file
},
{
@@ -551,19 +551,19 @@ func TestWebDAV_WriteOperations(t *testing.T) {
req := httptest.NewRequest(tc.method, "/dav/source1"+tc.path, body)
req.SetPathValue("source", "source1")
req.SetPathValue("path", tc.path)
w := httptest.NewRecorder()
ctx := &requestContext{user: tc.user}
status, err := webDAVHandler(w, req, ctx)
// If handler returned an error status, use that instead of response code
if status != 0 && status != http.StatusOK {
if w.Code == 0 || w.Code == http.StatusOK {
w.Code = status
}
}
// Check the actual HTTP response status code from ResponseWriter
if w.Code != tc.expectedStatus {
t.Errorf("expected status %d, got %d (err: %v)", tc.expectedStatus, w.Code, err)
@@ -611,7 +611,7 @@ func TestWebDAV_AccessControl(t *testing.T) {
// Initialize index
initTestIndex(t, "source1", source1Path)
// Create access rules by using the internal structure
// User1 can access /private, user2 cannot
// Set up a deny rule for user2 at /private
@@ -621,7 +621,7 @@ func TestWebDAV_AccessControl(t *testing.T) {
Users: access.StringSet{"user2": struct{}{}},
},
}
// Add the rule to the access storage
if store.Access.AllRules == nil {
store.Access.AllRules = make(access.SourceRuleMap)
@@ -668,19 +668,19 @@ func TestWebDAV_AccessControl(t *testing.T) {
req := httptest.NewRequest("PROPFIND", "/dav/source1"+tc.path, nil)
req.SetPathValue("source", "source1")
req.SetPathValue("path", tc.path)
w := httptest.NewRecorder()
ctx := &requestContext{user: tc.user}
status, err := webDAVHandler(w, req, ctx)
// If handler returned an error status, use that instead of response code
if status != 0 && status != http.StatusOK {
if w.Code == 0 || w.Code == http.StatusOK {
w.Code = status
}
}
// Check the actual HTTP response status code from ResponseWriter
if w.Code != tc.expectedStatus {
t.Errorf("expected status %d, got %d (err: %v)", tc.expectedStatus, w.Code, err)
@@ -775,19 +775,19 @@ func TestWebDAV_IndexingStates(t *testing.T) {
req := httptest.NewRequest(tc.method, "/dav/source1"+tc.path, body)
req.SetPathValue("source", "source1")
req.SetPathValue("path", tc.path)
w := httptest.NewRecorder()
ctx := &requestContext{user: user}
status, err := webDAVHandler(w, req, ctx)
// If handler returned an error status, use that instead of response code
if status != 0 && status != http.StatusOK {
if w.Code == 0 || w.Code == http.StatusOK {
w.Code = status
}
}
// Check the actual HTTP response status code from ResponseWriter
if w.Code != tc.expectedStatus {
t.Errorf("%s: expected status %d, got %d (err: %v)", tc.description, tc.expectedStatus, w.Code, err)
+7 -5
View File
@@ -29,7 +29,7 @@ var (
// This is a simplified helper for shallow calculations and API calls
// For scanning with hardlink detection, use handleFile instead
// If useLogicalSize is true, returns the logical file size instead
func getDiskUsage(fileInfo os.FileInfo, realPath string, useLogicalSize bool) int64 {
func getDiskUsage(fileInfo os.FileInfo, useLogicalSize bool) int64 {
// If useLogicalSize is true, return logical size (no block alignment)
if useLogicalSize {
if fileInfo.IsDir() {
@@ -95,6 +95,8 @@ type Stats struct {
type ReducedIndex struct {
Stats
IdxName string `json:"name"`
ReadOnly bool `json:"readOnly"`
Private bool `json:"private"`
Status IndexStatus `json:"status"`
Scanners []*ScannerInfo `json:"scanners,omitempty"`
}
@@ -538,7 +540,7 @@ func (idx *Index) GetFileInfo(req FileInfoRequest) (*iteminfo.FileInfo, error) {
// getFileInfoFromContext returns info for a single file
func (idx *Index) getFileInfoFromContext(ctx *PathContext, isIndexable bool) (*iteminfo.FileInfo, error) {
size := idx.getFileSizeForDisplay(ctx.FileInfo, ctx.RealPath)
size := idx.getFileSizeForDisplay(ctx.FileInfo)
fileInfo := &iteminfo.FileInfo{
Path: ctx.IndexPath,
ItemInfo: iteminfo.ItemInfo{
@@ -827,7 +829,7 @@ func (idx *Index) processFileItem(file os.FileInfo, indexPath string, opts Optio
var size uint64
var shouldCountSize bool
if !opts.Recursive && scanner == nil {
size = uint64(idx.getFileSizeForDisplay(file, fullCombined))
size = uint64(idx.getFileSizeForDisplay(file))
shouldCountSize = true
} else {
size, shouldCountSize = idx.handleFile(file, indexPath, fullCombined, opts.IsRoutineScan, scanner)
@@ -1157,14 +1159,14 @@ func (idx *Index) GetFolderSizeForDisplay(path string) int64 {
}
// getFileSizeForDisplay returns file size with appropriate calculation based on config
func (idx *Index) getFileSizeForDisplay(file os.FileInfo, realPath string) int64 {
func (idx *Index) getFileSizeForDisplay(file os.FileInfo) int64 {
if idx.Config.UseLogicalSize {
// Logical size mode: return actual bytes
return file.Size()
}
// Disk usage mode: use getDiskUsage helper
return getDiskUsage(file, realPath, false)
return getDiskUsage(file, false)
}
// SyncFolderSizesToDB syncs in-memory folder sizes to the database after a scan completes
+61 -12
View File
@@ -1,6 +1,7 @@
package indexing
import (
"errors"
"os"
"path/filepath"
"strings"
@@ -73,11 +74,12 @@ func (s *Scanner) executeScan() {
}()
if s.scanPath != "/" && !s.directoryExists() {
logger.Debugf("Scanner [%s] skipping: directory no longer exists", s.scanPath)
s.removeSelf()
s.handlePathGone("pre-scan check")
return
}
s.prepareScanState()
s.idx.mu.Lock()
s.idx.activeScannerPath = s.scanPath
s.idx.mu.Unlock()
@@ -173,13 +175,14 @@ func (s *Scanner) runFullScanRoot() {
s.purgeStaleEntries(true, false) // isRoot=true, isQuickScan=false
s.syncStatsWithDB()
if s.processedInodes != nil {
s.processedInodes = nil
s.foundHardLinks = nil
}
s.resetHardlinkTracking()
s.idx.mu.Lock()
s.idx.scanUpdatedPaths = make(map[string]bool)
if s.idx.scanUpdatedPaths == nil {
s.idx.scanUpdatedPaths = make(map[string]bool)
} else {
clear(s.idx.scanUpdatedPaths)
}
s.idx.mu.Unlock()
}
@@ -277,6 +280,10 @@ func (s *Scanner) runFullScanChild() {
_, _, err := s.idx.indexDirectory(s.scanPath, config, s)
if err != nil {
if isPathGone(err) {
s.handlePathGone("full scan")
return
}
logger.Errorf("Scanner [%s] error: %v", s.scanPath, err)
} else if config.Recursive && config.IsRoutineScan {
s.idx.mu.Lock()
@@ -294,10 +301,7 @@ func (s *Scanner) runFullScanChild() {
}
}
if s.processedInodes != nil {
s.processedInodes = nil
s.foundHardLinks = nil
}
s.resetHardlinkTracking()
s.idx.mu.Lock()
for path := range s.idx.scanUpdatedPaths {
@@ -498,6 +502,45 @@ func (s *Scanner) updateComplexity() {
}
}
// ensureScanUpdatedPaths initializes scan path tracking for the current scan session.
func (idx *Index) ensureScanUpdatedPaths() {
idx.mu.Lock()
defer idx.mu.Unlock()
if idx.scanUpdatedPaths == nil {
idx.scanUpdatedPaths = make(map[string]bool)
}
}
// prepareScanState initializes per-scan maps. Quick scans previously skipped this and could
// panic with "assignment to entry in nil map" when writing scanUpdatedPaths or hardlink tracking.
func (s *Scanner) prepareScanState() {
s.idx.ensureScanUpdatedPaths()
if s.processedInodes == nil {
s.processedInodes = make(map[uint64]struct{})
}
if s.foundHardLinks == nil {
s.foundHardLinks = make(map[string]uint64)
}
}
func (s *Scanner) resetHardlinkTracking() {
s.processedInodes = nil
s.foundHardLinks = nil
}
func isPathGone(err error) bool {
return err != nil && (os.IsNotExist(err) || errors.Is(err, os.ErrNotExist))
}
// handlePathGone removes a child scanner whose root directory was deleted.
func (s *Scanner) handlePathGone(context string) {
if s.scanPath == "/" {
return
}
logger.Infof("Scanner [%s] stopping (%s): directory no longer exists", s.scanPath, context)
s.removeSelf()
}
// directoryExists checks if the scanner's directory still exists
func (s *Scanner) directoryExists() bool {
realPath := strings.TrimRight(s.idx.Path, "/") + s.scanPath
@@ -553,7 +596,10 @@ func (s *Scanner) checkFolderModtime(folderPath string) (bool, error) {
info, err := os.Stat(realPath)
if err != nil {
if os.IsNotExist(err) {
// Folder was deleted - will be caught by purge
if folderPath == s.scanPath {
s.handlePathGone("quick scan")
}
// Subfolder deleted — stale entries are removed by purge
return false, nil
}
logger.Errorf("[QUICK_SCAN] Error stating %s: %v", folderPath, err)
@@ -593,6 +639,9 @@ func (s *Scanner) checkFolderModtime(folderPath string) (bool, error) {
// Track this directory as having been updated (for file purge logic)
s.idx.mu.Lock()
if s.idx.scanUpdatedPaths == nil {
s.idx.scanUpdatedPaths = make(map[string]bool)
}
s.idx.scanUpdatedPaths[folderPath] = true
s.idx.mu.Unlock()
+35
View File
@@ -0,0 +1,35 @@
package indexing
import (
"os"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
func TestPrepareScanStateInitializesMaps(t *testing.T) {
idx := &Index{
Source: settings.Source{Name: "test", Path: t.TempDir()},
}
s := &Scanner{scanPath: "/music/", idx: idx}
s.prepareScanState()
idx.mu.Lock()
idx.scanUpdatedPaths["/music/"] = true
idx.mu.Unlock()
if s.processedInodes == nil {
t.Fatal("processedInodes should be initialized")
}
s.processedInodes[1] = struct{}{}
}
func TestIsPathGone(t *testing.T) {
if isPathGone(nil) {
t.Fatal("nil error should not be path gone")
}
if !isPathGone(os.ErrNotExist) {
t.Fatal("os.ErrNotExist should be path gone")
}
}
@@ -236,6 +236,7 @@ func (idx *Index) runSerialScanPass() {
continue
}
if s.scanPath != "/" && !s.directoryExists() {
s.removeSelf()
continue
}
s.executeScan()
@@ -283,6 +284,10 @@ func (idx *Index) schedulerAdaptiveTick() {
}
})
logger.Debugf("[%s] scheduler: adaptive run path=%s nextRun=%s", idx.Name, s.scanPath, nextRunStr)
if s.scanPath != "/" && !s.directoryExists() {
s.removeSelf()
continue
}
s.executeScan()
}
}
+5
View File
@@ -71,6 +71,9 @@ func (idx *Index) UpdateMetadata(info *iteminfo.FileInfo, scanner *Scanner) bool
// Track that this directory was updated by the scan (for timestamp conflict detection)
normalizedPath := utils.AddTrailingSlashIfNotExists(info.Path)
idx.mu.Lock()
if idx.scanUpdatedPaths == nil {
idx.scanUpdatedPaths = make(map[string]bool)
}
idx.scanUpdatedPaths[normalizedPath] = true
idx.mu.Unlock()
@@ -380,6 +383,8 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
reducedIdx.LastScanned = lastIndexed
reducedIdx.LastIndexedUnix = lastIndexed.Unix()
reducedIdx.Status = idx.getStatusUnlocked()
reducedIdx.ReadOnly = idx.Config.ReadOnly
reducedIdx.Private = idx.Config.Private
idx.mu.RUnlock()
return reducedIdx, nil
}
+41
View File
@@ -512,6 +512,15 @@ const docTemplate = `{
"type": "string"
}
},
"401": {
"description": "Unauthorized - authentication failed",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"403": {
"description": "Forbidden - authentication failed",
"schema": {
@@ -521,6 +530,15 @@ const docTemplate = `{
}
}
},
"429": {
"description": "Too many requests - rate limited or temporarily locked out after failed attempts",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
@@ -5586,6 +5604,22 @@ const docTemplate = `{
}
}
},
"settings.Http": {
"type": "object",
"properties": {
"disableRateLimit": {
"description": "turns off built-in auth route rate limiting and failed-login lockout (default false).",
"type": "boolean"
},
"trustedHeaders": {
"description": "list of headers to trust, useful when behind a reverse proxy.",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"settings.IndexSqlConfig": {
"type": "object",
"properties": {
@@ -6149,6 +6183,9 @@ const docTemplate = `{
"frontend": {
"$ref": "#/definitions/settings.Frontend"
},
"http": {
"$ref": "#/definitions/settings.Http"
},
"integrations": {
"$ref": "#/definitions/settings.Integrations"
},
@@ -6218,6 +6255,10 @@ const docTemplate = `{
"description": "designate as source as private -- currently just means no sharing permitted.",
"type": "boolean"
},
"readOnly": {
"description": "read-only source, changes from the UI, webdav, and API will be disabled.",
"type": "boolean"
},
"rules": {
"description": "list of item rules to apply to specific paths",
"type": "array",
+41
View File
@@ -501,6 +501,15 @@
"type": "string"
}
},
"401": {
"description": "Unauthorized - authentication failed",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"403": {
"description": "Forbidden - authentication failed",
"schema": {
@@ -510,6 +519,15 @@
}
}
},
"429": {
"description": "Too many requests - rate limited or temporarily locked out after failed attempts",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
@@ -5575,6 +5593,22 @@
}
}
},
"settings.Http": {
"type": "object",
"properties": {
"disableRateLimit": {
"description": "turns off built-in auth route rate limiting and failed-login lockout (default false).",
"type": "boolean"
},
"trustedHeaders": {
"description": "list of headers to trust, useful when behind a reverse proxy.",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"settings.IndexSqlConfig": {
"type": "object",
"properties": {
@@ -6138,6 +6172,9 @@
"frontend": {
"$ref": "#/definitions/settings.Frontend"
},
"http": {
"$ref": "#/definitions/settings.Http"
},
"integrations": {
"$ref": "#/definitions/settings.Integrations"
},
@@ -6207,6 +6244,10 @@
"description": "designate as source as private -- currently just means no sharing permitted.",
"type": "boolean"
},
"readOnly": {
"description": "read-only source, changes from the UI, webdav, and API will be disabled.",
"type": "boolean"
},
"rules": {
"description": "list of item rules to apply to specific paths",
"type": "array",
+31
View File
@@ -758,6 +758,18 @@ definitions:
styling:
$ref: '#/definitions/settings.StylingConfig'
type: object
settings.Http:
properties:
disableRateLimit:
description: turns off built-in auth route rate limiting and failed-login
lockout (default false).
type: boolean
trustedHeaders:
description: list of headers to trust, useful when behind a reverse proxy.
items:
type: string
type: array
type: object
settings.IndexSqlConfig:
properties:
batchSize:
@@ -1195,6 +1207,8 @@ definitions:
$ref: '#/definitions/settings.Auth'
frontend:
$ref: '#/definitions/settings.Frontend'
http:
$ref: '#/definitions/settings.Http'
integrations:
$ref: '#/definitions/settings.Integrations'
server:
@@ -1246,6 +1260,10 @@ definitions:
description: designate as source as private -- currently just means no sharing
permitted.
type: boolean
readOnly:
description: read-only source, changes from the UI, webdav, and API will be
disabled.
type: boolean
rules:
description: list of item rules to apply to specific paths
items:
@@ -2686,12 +2704,25 @@ paths:
description: JWT token for authentication
schema:
type: string
"401":
description: Unauthorized - authentication failed
schema:
additionalProperties:
type: string
type: object
"403":
description: Forbidden - authentication failed
schema:
additionalProperties:
type: string
type: object
"429":
description: Too many requests - rate limited or temporarily locked out
after failed attempts
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
+4
View File
@@ -26,6 +26,7 @@ server:
config:
denyByDefault: false # deny access unless an "allow" access rule was specifically created.
private: false # designate as source as private -- currently just means no sharing permitted.
readOnly: false # read-only source, changes from the UI, webdav, and API will be disabled.
disabled: false # disable the source, this is useful so you don't need to remove it from the config file
rules: # list of item rules to apply to specific paths
- neverWatchPath: "" # index the folder in the first pass to get included in search, but never re-indexed.
@@ -257,3 +258,6 @@ integrations:
debug: false # output ffmpeg stdout for media integration -- careful can produces lots of output!
extractEmbeddedSubtitles: false # extract embedded subtitles from media files
exiftoolPath: "" # path to exiftool executable
http:
trustedHeaders: # list of headers to trust, useful when behind a reverse proxy.
disableRateLimit: false # turns off built-in auth route rate limiting and failed-login lockout (default false).
-20
View File
@@ -58,27 +58,7 @@ export default {
return getters.currentView();
}
},
mounted() {
this.reEvalAction();
},
watch: {
$route() {
this.reEvalAction();
},
req() {
this.reEvalAction();
},
currentView() {
this.reEvalAction();
},
},
methods: {
reEvalAction() {
const currentView = getters.currentView();
if (currentView == "settings") {
mutations.setActiveSettingsView(getters.currentHash());
}
},
actionMultiIcon() {
if (this.show) {
mutations.closeHovers();
+1 -1
View File
@@ -506,7 +506,7 @@ export default {
},
goToItem() {
const item = this.firstSelected;
url.goToItem(item.source, item.path, {}, true);
url.goToItem(item.source, item.path, {}, true, getters.isShare());
mutations.closeHovers();
},
hideTooltip() {
+27 -12
View File
@@ -3,14 +3,24 @@
ref="tooltipRef"
v-show="tooltip.show"
class="floating-tooltip floating-window"
:class="{ 'dark-mode': isDarkMode, 'pointer-enabled': tooltip.pointerEvents }"
:class="{
'dark-mode': isDarkMode,
'pointer-enabled': tooltip.pointerEvents,
'floating-tooltip--component': hasComponent,
}"
:style="tooltipStyle"
v-html="tooltip.content"
></div>
>
<component
v-if="hasComponent"
:is="tooltip.component"
v-bind="tooltip.componentProps || {}"
/>
<div v-else v-html="tooltip.content"></div>
</div>
</template>
<script>
import { state,getters } from "@/store";
import { state, getters, mutations } from "@/store";
export default {
name: "Tooltip",
@@ -25,6 +35,9 @@ export default {
tooltip() {
return state.tooltip;
},
hasComponent() {
return Boolean(this.tooltip.component);
},
isDarkMode() {
return getters.isDarkMode();
},
@@ -34,16 +47,14 @@ export default {
left: `${this.adjustedX}px`,
};
// Add custom width if specified
if (this.tooltip.width) {
style.maxWidth = this.tooltip.width;
style.width = this.tooltip.width;
}
// Add max height and scrolling for viewport overflow
if (this.tooltip.width || this.tooltip.pointerEvents) {
style.maxHeight = '80vh';
style.overflowY = 'auto';
style.maxHeight = "80vh";
style.overflowY = "auto";
}
return style;
@@ -52,9 +63,7 @@ export default {
watch: {
$route: {
handler() {
// hide tooltip when route changes
this.tooltip.show = false;
this.tooltip.content = "";
mutations.hideTooltip();
},
},
tooltip: {
@@ -121,6 +130,12 @@ export default {
overflow-wrap: break-word;
}
.floating-tooltip--component {
padding: 0;
max-width: min(28em, 90vw);
white-space: normal;
}
.floating-tooltip.pointer-enabled {
pointer-events: auto;
cursor: auto;
@@ -135,4 +150,4 @@ export default {
padding: 0.1em !important;
padding-left: 0.5em !important;
}
</style>
</style>
@@ -280,7 +280,7 @@ export default {
}
const buttonAction = () => {
url.goToItem(source, targetPath, {});
url.goToItem(source, targetPath, {}, false, getters.isShare());
};
notify.showSuccess(this.$t("prompts.moveSuccess"), {
+5 -8
View File
@@ -317,8 +317,8 @@ export default {
});
}
// If this folder is empty, finish here.
if (req.items === null) return;
// If this folder has no items or items is not an array, finish here.
if (!req.items || !Array.isArray(req.items)) return;
for (let item of req.items) {
if (!this.showFolders && item.type === "directory") continue;
if (!this.showFiles && item.type !== "directory") continue;
@@ -426,11 +426,6 @@ export default {
event.preventDefault();
event.stopPropagation();
// Double click: navigate (into folder or open file)
if (this.isDisplayMode) {
this.navigateToItem(item);
return;
}
const syntheticEvent = {
currentTarget: {
dataset: {
@@ -523,7 +518,9 @@ export default {
navigateToItem(item) {
mutations.closeTopPrompt();
mutations.setNavigationTransitioning(true);
url.goToItem(item.source || state.req.source, item.path, undefined);
const isShare = !!(this.browseShare) || getters.isShare();
const source = isShare ? (state.shareInfo?.hash) : (item.source || state.req.source);
url.goToItem(source, item.path, undefined, false, isShare);
},
},
};
+18 -13
View File
@@ -199,6 +199,7 @@ export default {
);
eventBus.on('itemsDeleted', this.refresh);
eventBus.on('itemsRenamed', this.refresh);
eventBus.on('itemsMoved', this.refresh);
window.addEventListener('dragend', this.clearAllDragStates);
},
beforeUnmount() {
@@ -210,10 +211,12 @@ export default {
}
eventBus.off('itemsDeleted', this.refresh);
eventBus.off('itemsRenamed', this.refresh);
eventBus.off('itemsMoved', this.refresh);
window.removeEventListener('dragend', this.clearAllDragStates);
},
methods: {
async loadRoot() {
if (this.expanding) return;
this.error = null;
this.rootLoaded = false;
if ((!this.currentSource && !this.shareHash) || !this.isRootInstance) {
@@ -303,7 +306,7 @@ export default {
},
async tryExpandNodes() {
if (!this.rootLoaded || !this.pendingExpandPath) return;
if (this.expanding || !this.rootLoaded || !this.pendingExpandPath) return;
await this.expandToPath(this.pendingExpandPath);
this.pendingExpandPath = null;
},
@@ -342,18 +345,25 @@ export default {
navigateTo(node) {
if (this.isShare) {
mutations.setNavigationTransitioning(true);
goToItem(null, node.path, {}, false, this.shareHash);
goToItem(this.shareHash, node.path, {}, false, true);
} else {
mutations.setNavigationTransitioning(true);
goToItem(this.currentSource, node.path, {});
}
},
normalizePath(p) {
if (!p || p === '/') return '/';
return p.replace(/\/+$/, '');
},
isCurrentItem(node) {
const normalizedNodePath = this.normalizePath(node.path);
const normalizedCurrentPath = this.normalizePath(this.currentPath);
if (this.isShare) {
return node.path === this.currentPath;
return normalizedNodePath === normalizedCurrentPath;
} else {
return node.source === this.currentSource && node.path === this.currentPath;
return node.source === this.currentSource && normalizedNodePath === normalizedCurrentPath;
}
},
@@ -398,7 +408,7 @@ export default {
},
async refresh() {
if (!this.isRootInstance || this.isRefreshing) return;
if (!this.isRootInstance || this.isRefreshing ) return;
this.isRefreshing = true;
this.expandTimeouts.clear();
// Save expanded paths before reload to restore them
@@ -537,13 +547,8 @@ export default {
if (items.length === 0) return;
// We'll use the same logic as Breadcrumbs
const normalizePath = (path) => {
if (!path || path === "/") return "/";
return path.replace(/\/$/, '');
};
const targetDir = normalizePath(node.path);
const sourceDir = normalizePath(state.req.path);
const targetDir = this.normalizePath(node.path);
const sourceDir = this.normalizePath(state.req.path);
if (targetDir === sourceDir) {
notify.showErrorToast(this.$t("files.sameFolder"));
return;
@@ -584,7 +589,7 @@ export default {
}
const buttonAction = () => {
if (this.isShare) {
goToItem(null, node.path, {}, false, this.shareHash);
goToItem(this.shareHash, node.path, {}, false, true);
} else {
goToItem(this.currentSource, node.path, {});
}
+41 -112
View File
@@ -1,67 +1,39 @@
<template>
<table class="index-info-table">
<thead>
<tr>
<th colspan="2">{{ info.name || 'Source' }}</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ $t("general.status") }}</td>
<td>{{ getStatusLabel(info.status) }}</td>
</tr>
<tr>
<td>{{ $t("index.assessment") }}</td>
<td>{{ getComplexityLabel(info.complexity || 0) }}</td>
</tr>
<tr>
<td>{{ $t("general.files") }}</td>
<td>{{ info.files || 0 }}</td>
</tr>
<tr>
<td>{{ $t("general.folders") }}</td>
<td>{{ info.folders || 0 }}</td>
</tr>
<tr>
<td>{{ $t("index.lastScanned") }}</td>
<td>{{ getHumanReadable(info.lastIndex) }}</td>
</tr>
<tr>
<td>{{ $t("index.quickScan") }}</td>
<td>{{ formatDuration(info.quickScanDurationSeconds) }}</td>
</tr>
<tr>
<td>{{ $t("index.fullScan") }}</td>
<td>{{ formatDuration(info.fullScanDurationSeconds) }}</td>
</tr>
</tbody>
</table>
<SettingsTable
class="index-info-table"
:header-title="info.name || 'Source'"
:columns="columns"
:items="rows"
item-key="id"
:aria-label="info.name || 'Source'"
/>
</template>
<script>
import { state } from "@/store";
import { fromNow } from "@/utils/moment";
import SettingsTable from "@/components/settings/Table.vue";
export default {
name: "IndexInfo",
components: {
SettingsTable,
},
props: {
info: {
type: Object,
required: true,
},
},
methods: {
getComplexityLabel(complexity) {
return getComplexityLabel(complexity, this.$t);
computed: {
columns() {
return [
{ key: "label", label: "", sortable: false, align: "left" },
{ key: "value", label: "", sortable: false, align: "left" },
];
},
getStatusLabel(status) {
return getStatusLabel(status, this.$t);
},
getHumanReadable(lastIndex) {
return getHumanReadableTime(lastIndex, state.user.locale);
},
formatDuration(seconds) {
return formatDuration(seconds);
rows() {
return buildIndexInfoRows(this.info, this.$t, state.user.locale);
},
},
};
@@ -99,77 +71,34 @@ export function formatDuration(seconds) {
return Number(seconds);
}
// Generate HTML tooltip content using the same logic as the component
export function buildIndexInfoTooltipHTML(info, $t, locale) {
return `
<table style="border-collapse: collapse; text-align: left;">
<thead>
<tr>
<th colspan="2" style="text-align: center; font-weight: bold; font-size: 1.1em; padding-bottom: 0.3em; border-bottom: 1px solid #888;">${info.name || 'Source'}</th>
</tr>
</thead>
<tbody>
<tr>
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${$t("general.status")}</td>
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${getStatusLabel(info.status, $t)}</td>
</tr>
<tr>
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${$t("index.assessment")}</td>
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${getComplexityLabel(info.complexity || 0, $t)}</td>
</tr>
<tr>
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${$t("general.files")}</td>
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${info.files || 0}</td>
</tr>
<tr>
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${$t("general.folders")}</td>
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${info.folders || 0}</td>
</tr>
<tr>
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${$t("index.lastScanned")}</td>
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${getHumanReadableTime(info.lastIndex, locale)}</td>
</tr>
<tr>
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${$t("index.quickScan")}</td>
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${formatDuration(info.quickScanDurationSeconds)}</td>
</tr>
<tr>
<td style="padding: 0.2em 0.5em;">${$t("index.fullScan")}</td>
<td style="padding: 0.2em 0.5em;">${formatDuration(info.fullScanDurationSeconds)}</td>
</tr>
</tbody>
</table>
`;
export function formatBooleanYesNo(value, $t) {
return value ? $t("general.yes") : $t("general.no");
}
/** @returns {{ id: string, label: string, value: string | number }[]} */
export function buildIndexInfoRows(info, $t, locale) {
return [
{ id: "status", label: $t("general.status"), value: getStatusLabel(info.status, $t) },
{ id: "readOnly", label: $t("general.readOnly"), value: formatBooleanYesNo(info.readOnly, $t) },
{ id: "private", label: $t("general.private"), value: formatBooleanYesNo(info.private, $t) },
{ id: "assessment", label: $t("index.assessment"), value: getComplexityLabel(info.complexity || 0, $t) },
{ id: "files", label: $t("general.files"), value: info.files || 0 },
{ id: "folders", label: $t("general.folders"), value: info.folders || 0 },
{ id: "lastScanned", label: $t("index.lastScanned"), value: getHumanReadableTime(info.lastIndex, locale) },
{ id: "quickScan", label: $t("index.quickScan"), value: formatDuration(info.quickScanDurationSeconds) },
{ id: "fullScan", label: $t("index.fullScan"), value: formatDuration(info.fullScanDurationSeconds) },
];
}
</script>
<style scoped>
.index-info-table {
border-collapse: collapse;
text-align: left;
width: 100%;
.index-info-table :deep(thead th),
.index-info-table :deep(tbody td) {
padding: 0.5em;
}
.index-info-table thead th {
text-align: center;
font-weight: bold;
font-size: 1.1em;
padding-bottom: 0.3em;
border-bottom: 1px solid #888;
}
.index-info-table tbody td {
padding: 0.2em 0.5em;
border-bottom: 1px solid #ccc;
}
.index-info-table tbody tr:last-child td {
border-bottom: none;
}
.index-info-table tbody td:first-child {
.index-info-table :deep(tbody td:first-child) {
font-weight: 500;
color: var(--textSecondary);
}
</style>
@@ -809,7 +809,7 @@ export default {
path: state.req.path,
};
}
url.goToItem(this.source, this.path, previousHistoryItem || {});
url.goToItem(this.source, this.path, previousHistoryItem || {}, false, getters.isShare());
},
},
};
@@ -506,6 +506,8 @@ export default {
state.previousHistoryItem.source,
state.previousHistoryItem.path,
state.previousHistoryItem,
false,
state.previousHistoryItem.isShare
);
return;
}
+1 -1
View File
@@ -200,7 +200,7 @@ export default {
const archivePath = dest;
const buttonAction = () => {
if (archivePath) {
goToItem(destSource || null, archivePath, {});
goToItem(destSource || null, archivePath, {}, false, getters.isShare());
}
};
notify.showSuccess(this.$t("prompts.archiveSuccess"), {
+4 -4
View File
@@ -264,13 +264,13 @@ export default {
mutations.setNavigationTransitioning(true);
// Try next, then previous, then parent directory
if (state.navigation.nextItem) {
url.goToItem(state.navigation.nextItem.source, state.navigation.nextItem.path, undefined);
url.goToItem(state.navigation.nextItem.source, state.navigation.nextItem.path, undefined, false, getters.isShare());
} else if (state.navigation.previousItem) {
url.goToItem(state.navigation.previousItem.source, state.navigation.previousItem.path, undefined);
url.goToItem(state.navigation.previousItem.source, state.navigation.previousItem.path, undefined, false, getters.isShare());
} else {
// Navigate to parent directory of deleted item
const parentPath = url.removeLastDir(deletedItem.path);
url.goToItem(deletedItem.source, parentPath, {});
url.goToItem(deletedItem.source, parentPath, {}, false, getters.isShare());
}
} else {
mutations.setReload(true);
@@ -281,7 +281,7 @@ export default {
const deletedItem = this.itemsToDelete[0];
if (deletedItem.type === 'directory' && deletedItem.path === state.req.path) {
const parentPath = url.removeLastDir(deletedItem.path);
url.goToItem(deletedItem.source, parentPath, {});
url.goToItem(deletedItem.source, parentPath, {}, false, getters.isShare());
this.deleting = false;
return; // return early to avoid extra reload (and 404)
}
+63 -32
View File
@@ -62,6 +62,7 @@ import { notify } from "@/notify";
import { goToItem } from "@/utils/url";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import PathPickerButton from "@/components/files/PathPickerButton.vue";
import { eventBus } from '@/store/eventBus';
export default {
name: "move-copy",
@@ -347,41 +348,71 @@ export default {
);
}
// Only close prompts and reload on success (or partial success)
mutations.setReload(true);
mutations.closeTopPrompt(); // close conflict prompt
mutations.closeTopPrompt(); // close moveCopy prompt after conflict is resolved and file copied/moved
mutations.setSearch(false); // close search if open
// Determine if we should navigate (we can move items with fileTree context menu in previews)
const shouldNavigate = getters.isPreviewView() && this.operation === 'move';
let currentItemMoved = false;
// Only show success notification if there were no failures (or partial success was already shown)
if (!hasFailures || hasSuccesses) {
// Store destination info for the button action
const destSource = this.destSource;
const destPath = this.destPath;
if (shouldNavigate) {
currentItemMoved = this.localItems.some(item =>
item.from === state.req?.path && item.fromSource === state.req?.source
);
}
// Show success notification with optional button to navigate to destination
// For shares, destSource might be null, but goToItem handles shares via state.shareInfo.hash
const buttonAction = () => {
if (destPath) {
// For shares, goToItem will use state.shareInfo.hash, so source can be null
// For regular files, destSource should be set
goToItem(destSource || null, destPath, {});
}
};
const buttonProps = {
icon: "folder",
buttons: destPath ? [
{
label: this.$t("buttons.goToItem"),
primary: true,
action: buttonAction
}
] : undefined
};
if (this.operation === "move") {
notify.showSuccess(this.$t("prompts.moveSuccess"), buttonProps);
if (shouldNavigate && currentItemMoved) {
eventBus.emit('itemsMoved');
mutations.closeTopPrompt(); // close conflict prompt
mutations.closeTopPrompt(); // close moveCopy prompt
if (!hasFailures || hasSuccesses) {
notify.showSuccessToast(this.$t("prompts.moveSuccess"));
}
// Navigate next, previous or parent dir (like when deleting)
const next = state.navigation.nextItem;
const prev = state.navigation.previousItem;
if (next) {
url.goToItem(next.source, next.path, undefined, false, getters.isShare());
} else if (prev) {
url.goToItem(prev.source, prev.path, undefined, false, getters.isShare());
} else {
notify.showSuccess(this.$t("prompts.copySuccess"), buttonProps);
url.goToItem(state.req.source, url.removeLastDir(state.req.path), {}, false, getters.isShare());
}
} else {
// Only close prompts and reload on success (or partial success)
mutations.setReload(true);
mutations.closeTopPrompt(); // close conflict prompt
mutations.closeTopPrompt(); // close moveCopy prompt after conflict is resolved and file copied/moved
mutations.setSearch(false); // close search if open
// Only show success notification if there were no failures (or partial success was already shown)
if (!hasFailures || hasSuccesses) {
// Store destination info for the button action
const destSource = this.destSource;
const destPath = this.destPath;
// Show success notification with optional button to navigate to destination
// For shares, destSource might be null, but goToItem handles shares via state.shareInfo.hash
const buttonAction = () => {
if (destPath) {
goToItem(destSource || state.shareInfo?.hash, destPath, {}, false, getters.isShare());
}
};
const buttonProps = {
icon: "folder",
buttons: destPath ? [
{
label: this.$t("buttons.goToItem"),
primary: true,
action: buttonAction
}
] : undefined
};
if (this.operation === "move") {
notify.showSuccess(this.$t("prompts.moveSuccess"), buttonProps);
} else {
notify.showSuccess(this.$t("prompts.copySuccess"), buttonProps);
}
}
}
} catch (error) {
+23 -26
View File
@@ -103,6 +103,21 @@ export default {
}
},
showNotification(source, newPath) {
const buttonAction = () => {
url.goToItem(source, newPath, {}, false, getters.isShare());
};
const buttonProps = {
icon: "folder",
buttons: [{
label: this.$t("buttons.goToItem"),
primary: true,
action: buttonAction
}]
};
notify.showSuccess(this.$t("prompts.newDirSuccess"), buttonProps);
},
async createDirectory(overwrite = false) {
this.creating = true;
try {
@@ -115,25 +130,16 @@ export default {
mutations.setReload(true);
mutations.closeTopPrompt();
this.creating = false;
// Show notification for shares
this.showNotification(state.shareInfo?.hash, newPath);
return;
}
await resourcesApi.post(source, newPath, "", overwrite, undefined, {}, true);
mutations.setReload(true);
mutations.closeTopPrompt();
// Show success notification with "go to item" button
const buttonAction = () => {
url.goToItem(source, newPath, {});
};
const buttonProps = {
icon: "folder",
buttons: [{
label: this.$t("buttons.goToItem"),
primary: true,
action: buttonAction
}]
};
notify.showSuccess(this.$t("prompts.newDirSuccess"), buttonProps);
// Show notification
this.showNotification(source, newPath);
this.creating = false;
} catch (error) {
if (error.message === "conflict") {
@@ -164,6 +170,8 @@ export default {
mutations.closeTopPrompt(); // close conflict prompt
mutations.closeTopPrompt(); // close new dir prompt
success = true;
// Show notification for shares after rename
this.showNotification(state.shareInfo?.hash, newPath);
return;
}
await resourcesApi.post(source, newPath, "", false, undefined, {}, true);
@@ -172,19 +180,8 @@ export default {
mutations.closeTopPrompt(); // close new dir prompt
success = true;
// Show success notification with "go to item" button
const buttonAction = () => {
url.goToItem(source, newPath, {});
};
const buttonProps = {
icon: "folder",
buttons: [{
label: this.$t("buttons.goToItem"),
primary: true,
action: buttonAction
}]
};
notify.showSuccess(this.$t("prompts.newDirSuccess"), buttonProps);
// Show notification
this.showNotification(source, newPath);
} catch (renameError) {
if (renameError.message === "conflict") {
// Continue to next iteration
+23 -26
View File
@@ -100,6 +100,21 @@ export default {
}
},
showNotification(source, newPath) {
const buttonAction = () => {
url.goToItem(source, newPath, {}, false, getters.isShare());
};
const buttonProps = {
icon: "insert_drive_file",
buttons: [{
label: this.$t("buttons.goToItem"),
primary: true,
action: buttonAction
}]
};
notify.showSuccess(this.$t("prompts.newFileSuccess"), buttonProps);
},
async createFile(overwrite = false) {
this.creating = true;
try {
@@ -112,25 +127,16 @@ export default {
mutations.setReload(true);
mutations.closeTopPrompt();
this.creating = false;
// Show notification for shares
this.showNotification(state.shareInfo?.hash, newPath);
return;
}
await resourcesApi.post(source, newPath, "", overwrite);
mutations.setReload(true);
mutations.closeTopPrompt();
// Show success notification with "go to item" button
const buttonAction = () => {
url.goToItem(source, newPath, {});
};
const buttonProps = {
icon: "insert_drive_file",
buttons: [{
label: this.$t("buttons.goToItem"),
primary: true,
action: buttonAction
}]
};
notify.showSuccess(this.$t("prompts.newFileSuccess"), buttonProps);
// Show notification
this.showNotification(source, newPath);
this.creating = false;
} catch (error) {
if (error.message === "conflict") {
@@ -161,6 +167,8 @@ export default {
mutations.closeTopPrompt(); // close conflict prompt
mutations.closeTopPrompt(); // close new file prompt
success = true;
// Show notification for shares after rename
this.showNotification(state.shareInfo?.hash, newPath);
return;
}
await resourcesApi.post(source, newPath, "", false);
@@ -169,19 +177,8 @@ export default {
mutations.closeTopPrompt(); // close new file prompt
success = true;
// Show success notification with "go to item" button
const buttonAction = () => {
url.goToItem(source, newPath, {});
};
const buttonProps = {
icon: "insert_drive_file",
buttons: [{
label: this.$t("buttons.goToItem"),
primary: true,
action: buttonAction
}]
};
notify.showSuccess(this.$t("prompts.newFileSuccess"), buttonProps);
// Show notification
this.showNotification(source, newPath);
} catch (renameError) {
if (renameError.message === "conflict") {
// Continue to next iteration
@@ -50,7 +50,7 @@
</template>
<script>
import { state, mutations } from "@/store";
import { state, mutations, getters } from "@/store";
import { url } from "@/utils";
export default {
name: "PlaybackQueue",
@@ -195,7 +195,7 @@ export default {
}
},
triggerNavigation(item) {
url.goToItem( item.source || state.req.source, item.path, undefined );
url.goToItem(item.source || state.req.source, item.path, undefined, false, getters.isShare());
},
scrollToCurrentItem() {
if (this.queueCount === 0) return;
+19 -1
View File
@@ -183,6 +183,9 @@ export default {
return { valid: true };
},
normalizePath(p) {
return (p || '').replace(/^\/+|\/+$/g, '');
},
async submit() {
// remove trailing slashes
if (this.name.endsWith("/") || this.name.endsWith("\\")) {
@@ -216,7 +219,22 @@ export default {
mutations.closeTopPrompt();
if (this.isPreviewView) {
url.goToItem(this.item.source, newPath, undefined); // When undefined will not create browser history
// Navigate only if we rename the file that we're currently previewing (eg: from fileTree)
const currentReqPath = this.normalizePath(state.req?.path);
const oldItemPath = this.normalizePath(this.item.path);
// For shares compare path and for regulars compare both path and source
const isCurrentItem = getters.isShare()
? currentReqPath === oldItemPath
: (currentReqPath === oldItemPath && this.item.source === state.req?.source);
if (isCurrentItem) {
// Navigate to the renamed file
const source = getters.isShare() ? state.shareInfo.hash : this.item.source;
url.goToItem(source, newPath, undefined, false, getters.isShare()); // When undefined will not create browser history
} else {
mutations.setReload(true);
}
} else {
mutations.setReload(true);
}
+60 -6
View File
@@ -32,6 +32,7 @@
</a>
</div>
<p> {{ $t('share.notice') }} </p>
<p v-if="sourceReadOnly" class="read-only-notice">{{ $t('share.readOnlySourceNotice') }}</p>
<div v-if="listing">
<settings-table
@@ -121,7 +122,7 @@
</p>
<select class="input" v-model="shareType">
<option value="normal">{{ $t("share.normalShare") }}</option>
<option value="upload">{{ $t("share.uploadShare") }}</option>
<option value="upload" :disabled="sourceReadOnly">{{ $t("share.uploadShare") }}</option>
</select>
<button @click="openSidebarLinksCustomization" class="button button--flat customize-sidebar-links-button">
<i class="material-symbols">link</i>
@@ -130,13 +131,13 @@
<div class="settings-items" style="margin-top: 0.5em;">
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="allowModify"
:name="$t('share.allowModify')" :description="$t('share.allowModifyDescription')"
aria-label="allow editing files toggle" />
aria-label="allow editing files toggle" :disabled="sourceReadOnly" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="allowCreate"
:name="$t('share.allowCreate')" :description="$t('share.allowCreateDescription')"
aria-label="allow creating and uploading files and folders toggle" />
aria-label="allow creating and uploading files and folders toggle" :disabled="sourceReadOnly" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="allowDelete"
:name="$t('share.allowDelete')" :description="$t('share.allowDeleteDescription')"
aria-label="allow deleting files toggle" />
aria-label="allow deleting files toggle" :disabled="sourceReadOnly" />
</div>
</div>
<SettingsItem :title="showMoreExpanded ? $t('buttons.showLess') : $t('buttons.showMore')" :collapsable="true"
@@ -173,7 +174,8 @@
</select>
</div>
<ToggleSwitch v-if="createAllowed" class="item" v-model="allowReplacements"
:name="$t('share.allowReplacements')" :description="$t('share.allowReplacementsDescription')" />
:name="$t('share.allowReplacements')" :description="$t('share.allowReplacementsDescription')"
:disabled="sourceReadOnly" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="disableDownload"
:name="$t('share.disableDownload')" :description="$t('share.disableDownloadDescription')"
aria-label="disable downloading files toggle" />
@@ -192,7 +194,8 @@
:placeholder="$t('share.allowedUsernamesPlaceholder')" />
</div>
<ToggleSwitch v-if="shareType === 'normal' && onlyOfficeAvailable" class="item" v-model="enableOnlyOffice"
:name="$t('share.enableOnlyOffice')" :description="$t('share.enableOnlyOfficeDescription')" />
:name="$t('share.enableOnlyOffice')" :description="$t('share.enableOnlyOfficeDescription')"
:disabled="sourceReadOnly" />
<p>
{{ $t("share.enforceDarkLightMode") }}
<i class="material-symbols-outlined tooltip-info-icon"
@@ -451,6 +454,10 @@ export default {
// When editing, use the link's source; otherwise use the item's source
return this.isEditMode ? this.link.source : this.item.source;
},
sourceReadOnly() {
const info = state.sources.info?.[this.displaySource];
return info?.readOnly === true;
},
onlyOfficeAvailable() {
return globalVars.onlyOfficeUrl !== "";
},
@@ -525,12 +532,24 @@ export default {
}
},
shareType(newType) {
if (this.sourceReadOnly && newType === 'upload') {
this.shareType = 'normal';
return;
}
if (newType === 'upload') {
this.description = this.$t("share.descriptionUploadDefault");
} else {
this.description = this.$t("share.descriptionDefault");
}
},
sourceReadOnly: {
immediate: true,
handler(readOnly) {
if (readOnly) {
this.applyReadOnlyConstraints();
}
},
},
isEditMode: {
immediate: true,
handler(isEditMode) {
@@ -578,6 +597,9 @@ export default {
this.disableLoginOption = this.link.disableLoginOption || false;
this.sidebarLinks = Array.isArray(this.link.sidebarLinks) ? [...this.link.sidebarLinks] : [];
//this.viewMode = this.link.viewMode || "normal";
if (this.sourceReadOnly) {
this.applyReadOnlyConstraints();
}
}
},
},
@@ -620,6 +642,16 @@ export default {
eventBus.off('pathPickerCancelled', this.onBannerFaviconPathPickerCancelled);
},
methods: {
applyReadOnlyConstraints() {
if (this.shareType === 'upload') {
this.shareType = 'normal';
}
this.allowModify = false;
this.allowDelete = false;
this.allowCreate = false;
this.allowReplacements = false;
this.enableOnlyOffice = false;
},
async copyToClipboard(text) {
await copyToClipboard(text);
},
@@ -708,6 +740,15 @@ export default {
payload.hash = this.editingLink.hash;
}
if (this.sourceReadOnly) {
payload.shareType = 'normal';
payload.allowModify = false;
payload.allowDelete = false;
payload.allowCreate = false;
payload.allowReplacements = false;
payload.enableOnlyOffice = false;
}
const res = await shareApi.create(payload);
if (!this.isEditMode && !this.editingLink) {
@@ -787,6 +828,9 @@ export default {
this.sidebarLinks = Array.isArray(link.sidebarLinks) ? [...link.sidebarLinks] : [];
// Store the link being edited
this.editingLink = link;
if (this.sourceReadOnly) {
this.applyReadOnlyConstraints();
}
},
/**
* @param {Event} event
@@ -1075,4 +1119,14 @@ export default {
color: var(--primaryColor);
font-size: 1.25em;
}
.read-only-notice {
font-size: 0.9em;
color: var(--textSecondary, #666);
margin-top: 0.25em;
}
select.input option:disabled {
color: #999;
}
</style>
@@ -158,7 +158,7 @@ export default {
const destPath = this.destPath;
const destSource = toSource;
const buttonAction = () => destPath && goToItem(destSource, destPath, {});
const buttonAction = () => destPath && goToItem(destSource || state.shareInfo?.hash, destPath, {}, false, getters.isShare());
notify.showSuccess(this.$t("prompts.unarchiveSuccess"), {
icon: "folder",
buttons: destPath ? [{ label: this.$t("buttons.goToItem"), primary: true, action: buttonAction }] : undefined,
+21 -2
View File
@@ -6,7 +6,16 @@
:aria-busy="loading ? 'true' : undefined"
>
<thead>
<tr>
<tr v-if="headerTitle">
<th
:colspan="emptyColumnSpan"
scope="col"
class="settings-table__align-center settings-table__th--nosort settings-table__th--unified"
>
{{ headerTitle }}
</th>
</tr>
<tr v-else>
<th
v-for="column in columns"
:key="column.key"
@@ -123,6 +132,11 @@ export default {
type: String,
default: undefined,
},
/** When set, renders one centered header cell spanning all columns instead of per-column headers. */
headerTitle: {
type: String,
default: "",
},
/** i18n key for empty-state caption (icon + label), default `files.lonely`. */
lonelyMessageKey: {
type: String,
@@ -328,7 +342,7 @@ export default {
.settings-table thead th,
.settings-table tbody td {
padding: 0.5em 0;
padding: 0.5em;
vertical-align: middle;
border-bottom: 1px solid var(--divider);
}
@@ -384,6 +398,11 @@ body.rtl .settings-table tr > *:last-child {
background: color-mix(in srgb, var(--primaryColor) 12%, var(--surfacePrimary));
}
.settings-table thead th.settings-table__th--unified,
body.rtl .settings-table thead th.settings-table__th--unified {
text-align: center;
}
.settings-table thead th.settings-table__th::after {
content: "↕";
position: absolute;
+17 -2
View File
@@ -12,7 +12,7 @@
</div>
<div v-else-if="user.username === 'anonymous' && shouldShowLogin" @click="navigateToLogin"
class="inner-card user-card__profile">
<button class="person-button action button">
<button class="person-button action button" aria-label="Login">
<i class="material-symbols-outlined">login</i> {{ $t("general.login") }}
</button>
</div>
@@ -76,6 +76,7 @@ import * as auth from "@/utils/auth";
import { globalVars } from "@/utils/constants";
import { state, getters, mutations } from "@/store";
import SidebarLinks from "./Links.vue";
import { url } from "@/utils";
export default {
name: "SidebarGeneral",
@@ -171,6 +172,7 @@ export default {
name: state.req.name,
source: state.req.source,
path: state.req.path,
isShare: getters.isShare()
});
this.$router.push({ path: path, hash: hash });
mutations.closeTopPrompt();
@@ -184,7 +186,20 @@ export default {
},
// Logout the user
logout: auth.logout,
async logout() {
const isShare = getters.isShare();
let sharePath;
if (isShare && state.shareInfo?.hash) {
const shareBase = `/public/share/${state.shareInfo.hash}`;
const subPath = state.req?.path && state.req.path !== '/'
? url.removeLeadingSlash(state.req.path)
: '';
sharePath = subPath ? `${shareBase}/${url.encodedPath(subPath)}` : shareBase;
}
await auth.logout();
window.location.href = sharePath || '/login';
},
beforeEnter(el) {
el.style.maxHeight = '0';
el.style.opacity = '0';
+95 -18
View File
@@ -4,7 +4,9 @@
<!-- Links section header -->
<div class="sidebar-links-header"
:class="{ 'with-top-spacing': isShare && !disableShareCard }">
<i :class="{ 'disabled': !isLoggedIn }" @click="goHome()" class="material-symbols action">home</i>
<i :class="{ 'disabled': !isLoggedIn }"
aria-label="Navigate Home"
@click="goHome()" class="material-symbols action">home</i>
<!-- Mode button (is the title) -->
<button @click="cycleMode" class="mode-toggle" @mouseenter="showTooltip($event, $t('sidebar.switchMode'))" @mouseleave="hideTooltip">
{{ mode === 'links' ? $t('general.links') : $t('general.navigation') }}
@@ -13,6 +15,7 @@
:class="{ 'disabled': !canEdit }"
@click="showEditShareHover" class="material-symbols action">edit</i>
<i v-else @mouseenter="showTooltip($event, $t('sidebar.customizeLinks'))" @mouseleave="hideTooltip"
aria-label="Edit Links"
@click="openSidebarLinksPrompt" class="material-symbols action">edit</i>
</div>
<!-- Scrollable Content Area -->
@@ -63,7 +66,9 @@
</i>
<span>{{ link.name }}</span>
<i v-if="hasUsageInfo(link)" class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showSourceTooltip($event, sourceInfo[link.sourceName] || {})" @mouseleave="hideTooltip">
@click="onSourceInfoClick($event, sourceInfo[link.sourceName] || {})"
@mouseenter="onSourceInfoMouseEnter($event, sourceInfo[link.sourceName] || {})"
@mouseleave="onSourceInfoMouseLeave">
info
</i>
</div>
@@ -134,9 +139,9 @@
<span>{{ activeSourceLink.name }}</span>
<i v-if="hasUsageInfo(activeSourceLink)"
class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showSourceTooltip($event, activeSourceInfo)"
@mouseleave="hideTooltip"
@click.stop>
@click="onSourceInfoClick($event, activeSourceInfo)"
@mouseenter="onSourceInfoMouseEnter($event, activeSourceInfo)"
@mouseleave="onSourceInfoMouseLeave">
info
</i>
<!-- Dropdown arrow -->
@@ -188,7 +193,7 @@ import { state, getters, mutations } from "@/store";
import ProgressBar from "@/components/ProgressBar.vue";
import { goToItem } from "@/utils/url";
import { getIconClass } from "@/utils/material-symbols";
import { buildIndexInfoTooltipHTML } from "@/components/files/IndexInfo.vue";
import IndexInfo from "@/components/files/IndexInfo.vue";
import { globalVars } from "@/utils/constants";
import { resourcesApi } from "@/api";
import ShareInfo from "@/components/files/ShareInfo.vue";
@@ -204,9 +209,13 @@ export default {
data() {
return {
showSourceDropdown: false,
sourceTooltipDismissHandler: null,
};
},
computed: {
useTapForSourceTooltip() {
return getters.isMobile();
},
editShareText() {
return this.$t('settings.shareManagement');
},
@@ -214,7 +223,7 @@ export default {
return state.shareInfo?.canEditShare || false;
},
isLoggedIn() {
return state.user?.username !== 'anonymous';
return getters.isLoggedIn();
},
currentSource() {
return state.req?.source || null;
@@ -318,6 +327,7 @@ export default {
},
beforeUnmount() {
document.removeEventListener('click', this.closeDropdown);
this.unregisterSourceTooltipDismiss();
},
methods: {
isSourceCategory(category) {
@@ -447,7 +457,7 @@ export default {
// For source links, use sourceName and target (relative path)
if (!link.sourceName) return;
const path = link.target || "/";
goToItem(link.sourceName, path, {});
goToItem(link.sourceName, path, {}, false, false);
return;
}
@@ -549,20 +559,87 @@ export default {
}
},
hideTooltip() {
this.unregisterSourceTooltipDismiss();
mutations.hideTooltip();
},
isSourceTooltipVisible(info) {
if (!state.tooltip.show || state.tooltip.component !== IndexInfo) {
return false;
}
const shown = state.tooltip.componentProps?.info;
if (!shown || !info) {
return false;
}
return shown.name === info.name;
},
onSourceInfoClick(event, info) {
if (!this.useTapForSourceTooltip) {
return;
}
this.toggleSourceTooltip(event, info);
},
onSourceInfoMouseEnter(event, info) {
if (this.useTapForSourceTooltip) {
return;
}
this.showSourceTooltip(event, info);
},
onSourceInfoMouseLeave() {
if (this.useTapForSourceTooltip) {
return;
}
this.hideTooltip();
},
toggleSourceTooltip(event, info) {
if (this.isSourceTooltipVisible(info)) {
this.hideTooltip();
return;
}
this.showSourceTooltip(event, info);
},
showSourceTooltip(event, info) {
if (info) {
const tooltipContent = this.buildSourceTooltipContent(info);
mutations.showTooltip({
content: tooltipContent,
x: event.clientX,
y: event.clientY,
});
if (!info?.name) {
return;
}
mutations.showTooltip({
component: IndexInfo,
componentProps: { info },
x: event.clientX,
y: event.clientY,
pointerEvents: this.useTapForSourceTooltip,
});
if (this.useTapForSourceTooltip) {
this.registerSourceTooltipDismiss();
}
},
buildSourceTooltipContent(info) {
return buildIndexInfoTooltipHTML(info, this.$t, state.user.locale);
registerSourceTooltipDismiss() {
this.unregisterSourceTooltipDismiss();
this.sourceTooltipDismissHandler = (e) => {
const target = e.target;
if (target instanceof Element && (
target.closest(".tooltip-info-icon") ||
target.closest(".floating-tooltip")
)) {
return;
}
this.hideTooltip();
};
// Defer so the same tap does not immediately dismiss the tooltip.
requestAnimationFrame(() => {
if (!this.sourceTooltipDismissHandler) {
return;
}
document.addEventListener("click", this.sourceTooltipDismissHandler, true);
document.addEventListener("touchstart", this.sourceTooltipDismissHandler, true);
});
},
unregisterSourceTooltipDismiss() {
if (!this.sourceTooltipDismissHandler) {
return;
}
document.removeEventListener("click", this.sourceTooltipDismissHandler, true);
document.removeEventListener("touchstart", this.sourceTooltipDismissHandler, true);
this.sourceTooltipDismissHandler = null;
},
async showEditShareHover() {
if (!this.canEdit) {
@@ -606,7 +683,7 @@ export default {
this.navigateToSource(sourceName);
},
navigateToSource(sourceName) {
goToItem(sourceName, '/', {});
goToItem(sourceName, '/', {}, false, false);
},
closeDropdown(event) {
if (!this.showSourceDropdown) return;
+9 -4
View File
@@ -126,8 +126,8 @@
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nlBE": "Dutch (Belgium)",
"nl": "Dutch",
"nl": "Nederlands",
"nlBE": "Nederlands (België)",
"pl": "Polski",
"pt": "Português",
"ptBR": "Português (Brasil)",
@@ -493,7 +493,8 @@
"selectBannerImage": "تحديد صورة الشعار",
"selectFaviconImage": "تحديد الصورة المفضلة",
"selectImage": "حدد الصورة",
"deleteFailed": "فشل في حذف المشاركة"
"deleteFailed": "فشل في حذف المشاركة",
"readOnlySourceNotice": "هذا المصدر للقراءة فقط. يتم تعطيل تحميل المشاركات والأذونات التي تعدل الملفات."
},
"api": {
"createdAt": "تم الإنشاء في:",
@@ -677,7 +678,11 @@
"divider": "مقسم{suffix}",
"audio": "الصوت{suffix}",
"and": "و",
"or": "أو"
"or": "أو",
"readOnly": "للقراءة فقط{suffix}",
"private": "خاص{suffix}",
"yes": "نعم{suffix}",
"no": "لا يوجد{suffix}"
},
"access": {
"rules": "قواعد الوصول",
+9 -4
View File
@@ -129,8 +129,8 @@
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nlBE": "Dutch (Belgium)",
"nl": "Dutch",
"nl": "Nederlands",
"nlBE": "Nederlands (België)",
"pl": "Polski",
"pt": "Português",
"ptBR": "Português (Brasil)",
@@ -229,7 +229,8 @@
"selectBannerImage": "Výběr obrázku banneru",
"selectFaviconImage": "Výběr obrázku favicon",
"selectImage": "Vybrat obrázek",
"deleteFailed": "Nepodařilo se odstranit sdílenou složku"
"deleteFailed": "Nepodařilo se odstranit sdílenou složku",
"readOnlySourceNotice": "Tento zdroj je určen pouze pro čtení. Sdílení nahrávání a oprávnění, která upravují soubory, jsou zakázána."
},
"prompts": {
"copyMessage": "Vyberte místo, kam chcete soubory kopírovat:",
@@ -778,7 +779,11 @@
"divider": "Rozdělovač{suffix}",
"audio": "Audio{suffix}",
"and": "a",
"or": "nebo"
"or": "nebo",
"readOnly": "Pouze pro čtení{suffix}",
"private": "Soukromé{suffix}",
"yes": "Ano{suffix}",
"no": "Ne{suffix}"
},
"access": {
"rules": "Pravidla přístupu",
+9 -4
View File
@@ -126,8 +126,8 @@
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nlBE": "Dutch (Belgium)",
"nl": "Dutch",
"nl": "Nederlands",
"nlBE": "Nederlands (België)",
"pl": "Polski",
"pt": "Português",
"ptBR": "Português (Brasil)",
@@ -493,7 +493,8 @@
"selectBannerImage": "Bannerbild auswählen",
"selectFaviconImage": "Favicon-Bild auswählen",
"selectImage": "Bild auswählen",
"deleteFailed": "Freigabe konnte nicht gelöscht werden"
"deleteFailed": "Freigabe konnte nicht gelöscht werden",
"readOnlySourceNotice": "Diese Quelle ist schreibgeschützt. Upload-Freigaben und Berechtigungen, die Dateien ändern, sind deaktiviert."
},
"api": {
"createdAt": "Erstellt am:",
@@ -677,7 +678,11 @@
"divider": "Teiler{suffix}",
"audio": "Audio{suffix}",
"and": "und",
"or": "oder"
"or": "oder",
"readOnly": "Schreibgeschützt{suffix}",
"private": "Privat{suffix}",
"yes": "Ja{suffix}",
"no": "Nein{suffix}"
},
"access": {
"rules": "Zugangsregeln",
+9 -4
View File
@@ -129,8 +129,8 @@
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nlBE": "Dutch (Belgium)",
"nl": "Dutch",
"nl": "Nederlands",
"nlBE": "Nederlands (België)",
"pl": "Polski",
"pt": "Português",
"ptBR": "Português (Brasil)",
@@ -493,7 +493,8 @@
"selectBannerImage": "Επιλέξτε εικόνα banner",
"selectFaviconImage": "Επιλέξτε εικόνα favicon",
"selectImage": "Επιλέξτε εικόνα",
"deleteFailed": "Απέτυχε η διαγραφή κοινής χρήσης"
"deleteFailed": "Απέτυχε η διαγραφή κοινής χρήσης",
"readOnlySourceNotice": "Αυτή η πηγή είναι μόνο για ανάγνωση. Οι μετοχές φόρτωσης και τα δικαιώματα που τροποποιούν αρχεία είναι απενεργοποιημένα."
},
"api": {
"createdAt": "Δημιουργήθηκε στο:",
@@ -677,7 +678,11 @@
"divider": "Διαχωριστικό{suffix}",
"audio": "Ήχος{suffix}",
"and": "και",
"or": "ή"
"or": "ή",
"readOnly": "Μόνο για ανάγνωση{suffix}",
"private": "Ιδιωτικό{suffix}",
"yes": "Ναι{suffix}",
"no": "Όχι{suffix}"
},
"access": {
"rules": "Κανόνες πρόσβασης",
+6 -1
View File
@@ -131,7 +131,11 @@
"watching": "Watching{suffix}",
"lines": "Lines{suffix}",
"lastUpdate": "Last Update{suffix}",
"latency": "Latency{suffix}"
"latency": "Latency{suffix}",
"readOnly": "Read-only{suffix}",
"private": "Private{suffix}",
"yes": "Yes{suffix}",
"no": "No{suffix}"
},
"buttons": {
"copySuccess": "Copied to clipboard!",
@@ -292,6 +296,7 @@
},
"share": {
"notice": "Note: when a share is a created, it becomes more exposed to the public and has different rules for access. If your share is not securely configured, it may be accessed by anyone.",
"readOnlySourceNotice": "This source is read-only. Upload shares and permissions that modify files are disabled.",
"disableAnonymous": "Disable anonymous access",
"disableThumbnails": "Disable Thumbnails",
"enableAllowedUsernames": "Only share to certain users",
+9 -4
View File
@@ -126,8 +126,8 @@
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nlBE": "Dutch (Belgium)",
"nl": "Dutch",
"nl": "Nederlands",
"nlBE": "Nederlands (België)",
"pl": "Polski",
"pt": "Português",
"ptBR": "Português (Brasil)",
@@ -493,7 +493,8 @@
"selectBannerImage": "Seleccionar imagen de banner",
"selectFaviconImage": "Seleccionar imagen de favicon",
"selectImage": "Seleccionar imagen",
"deleteFailed": "Error al borrar recurso compartido"
"deleteFailed": "Error al borrar recurso compartido",
"readOnlySourceNotice": "Esta fuente es de sólo lectura. Los recursos compartidos de carga y los permisos que modifican archivos están desactivados."
},
"api": {
"createdAt": "Creado en:",
@@ -677,7 +678,11 @@
"divider": "Divisor{suffix}",
"audio": "Audio{suffix}",
"and": "y",
"or": "o"
"or": "o",
"readOnly": "Sólo lectura{suffix}",
"private": "Privado{suffix}",
"yes": "Sí{suffix}",
"no": "No{suffix}"
},
"access": {
"rules": "Reglas de acceso",
+9 -4
View File
@@ -126,8 +126,8 @@
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nlBE": "Dutch (Belgium)",
"nl": "Dutch",
"nl": "Nederlands",
"nlBE": "Nederlands (België)",
"pl": "Polski",
"pt": "Português",
"ptBR": "Português (Brasil)",
@@ -493,7 +493,8 @@
"selectBannerImage": "Sélectionner l'image de la bannière",
"selectFaviconImage": "Sélectionner l'image du favicon",
"selectImage": "Sélectionner l'image",
"deleteFailed": "Échec de la suppression du partage"
"deleteFailed": "Échec de la suppression du partage",
"readOnlySourceNotice": "Cette source est en lecture seule. Les partages de téléchargement et les autorisations qui modifient les fichiers sont désactivés."
},
"api": {
"createdAt": "Créée le :",
@@ -677,7 +678,11 @@
"divider": "Diviseur{suffix}",
"audio": "Audio{suffix}",
"and": "et",
"or": "ou"
"or": "ou",
"readOnly": "Lecture seule{suffix}",
"private": "Privé{suffix}",
"yes": "Oui{suffix}",
"no": "Non{suffix}"
},
"access": {
"rules": "Règles d'accès",
+9 -4
View File
@@ -126,8 +126,8 @@
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nlBE": "Dutch (Belgium)",
"nl": "Dutch",
"nl": "Nederlands",
"nlBE": "Nederlands (België)",
"pl": "Polski",
"pt": "Português",
"ptBR": "Português (Brasil)",
@@ -493,7 +493,8 @@
"selectBannerImage": "בחר תמונת באנר",
"selectFaviconImage": "בחר תמונת סמל מועדף",
"selectImage": "בחר תמונה",
"deleteFailed": "לא ניתן למחוק את השיתוף"
"deleteFailed": "לא ניתן למחוק את השיתוף",
"readOnlySourceNotice": "מקור זה מוגדר לקריאה בלבד. העלאת קבצים והרשאות המשנות קבצים אינן זמינות."
},
"api": {
"createdAt": "נוצר ב:",
@@ -677,7 +678,11 @@
"divider": "מפריד{suffix}",
"audio": "אודיו{suffix}",
"and": "ו-",
"or": "או"
"or": "או",
"readOnly": "לקריאה בלבד{suffix}",
"private": "פרטי{suffix}",
"yes": "כן{suffix}",
"no": "לא{suffix}"
},
"access": {
"rules": "כללי גישה",
+9 -4
View File
@@ -126,8 +126,8 @@
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nlBE": "Dutch (Belgium)",
"nl": "Dutch",
"nl": "Nederlands",
"nlBE": "Nederlands (België)",
"pl": "Polski",
"pt": "Português",
"ptBR": "Português (Brasil)",
@@ -493,7 +493,8 @@
"selectBannerImage": "Banner kép kiválasztása",
"selectFaviconImage": "Favicon kép kiválasztása",
"selectImage": "Kép kiválasztása",
"deleteFailed": "Nem sikerült törölni a megosztást"
"deleteFailed": "Nem sikerült törölni a megosztást",
"readOnlySourceNotice": "Ez a forrás csak olvasható. A feltöltési megosztások és a fájlokat módosító engedélyek letiltva vannak."
},
"api": {
"createdAt": "Létrehozva:",
@@ -677,7 +678,11 @@
"divider": "Választó{suffix}",
"audio": "Audio{suffix}",
"and": "és",
"or": "vagy"
"or": "vagy",
"readOnly": "Csak olvasható{suffix}",
"private": "Privát{suffix}",
"yes": "Igen{suffix}",
"no": "Nem{suffix}"
},
"access": {
"rules": "Hozzáférési szabályok",
+9 -4
View File
@@ -131,7 +131,11 @@
"latency": "Latenza{suffix}",
"audio": "Audio{suffix}",
"and": "e",
"or": "o"
"or": "o",
"readOnly": "Solo lettura{suffix}",
"private": "Privato{suffix}",
"yes": "Sì{suffix}",
"no": "No{suffix}"
},
"buttons": {
"copySuccess": "Copiato negli appunti!",
@@ -263,8 +267,8 @@
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nlBE": "Dutch (Belgium)",
"nl": "Dutch",
"nl": "Nederlands",
"nlBE": "Nederlands (België)",
"pl": "Polski",
"pt": "Português",
"ptBR": "Português (Brasil)",
@@ -363,7 +367,8 @@
"shareInfo": "QR Code e Info Condivisione",
"shareInfoDescription": "Mostra le informazioni della condivisione (titolo, descrizione, banner, QR) in un popup.",
"downloadDescription": "Permette di scaricare l'intero contenuto della condivisione come file compresso.",
"deleteFailed": "Eliminazione condivisione fallita"
"deleteFailed": "Eliminazione condivisione fallita",
"readOnlySourceNotice": "Questa sorgente è di sola lettura. Le azioni di caricamento e le autorizzazioni che modificano i file sono disabilitate."
},
"prompts": {
"operationInProgress": "Operazione in corso, attendere...",
+9 -4
View File
@@ -126,8 +126,8 @@
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nlBE": "Dutch (Belgium)",
"nl": "Dutch",
"nl": "Nederlands",
"nlBE": "Nederlands (België)",
"pl": "Polski",
"pt": "Português",
"ptBR": "Português (Brasil)",
@@ -493,7 +493,8 @@
"selectBannerImage": "バナー画像を選択",
"selectFaviconImage": "ファビコン画像を選択",
"selectImage": "画像を選択",
"deleteFailed": "共有の削除に失敗しました"
"deleteFailed": "共有の削除に失敗しました",
"readOnlySourceNotice": "このソースは読み取り専用です。アップロード共有およびファイルを変更するパーミッションは無効です。"
},
"api": {
"createdAt": "で作成された:",
@@ -677,7 +678,11 @@
"divider": "ディバイダー{suffix}",
"audio": "オーディオ{suffix}",
"and": "そして",
"or": "または"
"or": "または",
"readOnly": "読み取り専用{suffix}",
"private": "プライベート{suffix}",
"yes": "はい{suffix}",
"no": "いいえ{suffix}"
},
"access": {
"rules": "アクセスルール",
+9 -4
View File
@@ -126,8 +126,8 @@
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nlBE": "Dutch (Belgium)",
"nl": "Dutch",
"nl": "Nederlands",
"nlBE": "Nederlands (België)",
"pl": "Polski",
"pt": "Português",
"ptBR": "Português (Brasil)",
@@ -493,7 +493,8 @@
"selectBannerImage": "배너 이미지 선택",
"selectFaviconImage": "파비콘 이미지 선택",
"selectImage": "이미지 선택",
"deleteFailed": "공유를 삭제하지 못했습니다."
"deleteFailed": "공유를 삭제하지 못했습니다.",
"readOnlySourceNotice": "이 소스는 읽기 전용입니다. 파일을 수정하는 업로드 공유 및 권한은 비활성화됩니다."
},
"api": {
"createdAt": "만든 곳:",
@@ -677,7 +678,11 @@
"divider": "디바이더{suffix}",
"audio": "오디오{suffix}",
"and": "그리고",
"or": "또는"
"or": "또는",
"readOnly": "읽기 전용{suffix}",
"private": "비공개{suffix}",
"yes": "예{suffix}",
"no": "아니요{suffix}"
},
"access": {
"rules": "액세스 규칙",
+9 -4
View File
@@ -126,8 +126,8 @@
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nlBE": "Dutch (Belgium)",
"nl": "Dutch",
"nl": "Nederlands",
"nlBE": "Nederlands (België)",
"pl": "Polski",
"pt": "Português",
"ptBR": "Português (Brasil)",
@@ -493,7 +493,8 @@
"selectBannerImage": "Selecteer bannerafbeelding",
"selectFaviconImage": "Selecteer favicon afbeelding",
"selectImage": "Afbeelding selecteren",
"deleteFailed": "Share niet verwijderd"
"deleteFailed": "Share niet verwijderd",
"readOnlySourceNotice": "Deze bron is alleen-lezen. Upload shares en rechten om bestanden te wijzigen zijn uitgeschakeld."
},
"api": {
"createdAt": "Gemaakt op:",
@@ -778,7 +779,11 @@
"divider": "Verdeler{suffix}",
"audio": "Audio{suffix}",
"and": "en",
"or": "of"
"or": "of",
"readOnly": "Alleen-lezen{suffix}",
"private": "Privé{suffix}",
"yes": "Ja{suffix}",
"no": "Geen{suffix}"
},
"access": {
"rules": "Toegangsregels",
+8 -3
View File
@@ -131,7 +131,11 @@
"watching": "Bekijken{suffix}",
"lines": "Regels{suffix}",
"lastUpdate": "Laatste update{suffix}",
"latency": "Vertraging{suffix}"
"latency": "Vertraging{suffix}",
"readOnly": "Alleen-lezen{suffix}",
"private": "Privé{suffix}",
"yes": "Ja{suffix}",
"no": "Geen{suffix}"
},
"buttons": {
"copySuccess": "Gekopieerd naar klembord!",
@@ -263,8 +267,8 @@
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nlBE": "Nederlands (België)",
"nl": "Nederlands",
"nlBE": "Nederlands (België)",
"pl": "Polski",
"pt": "Português",
"ptBR": "Português (Brasil)",
@@ -363,7 +367,8 @@
"shareInfo": "QR-code en info delen",
"shareInfoDescription": "Deze koppeling toont de informatie van de gedeelde map, inclusief titel, beschrijving, banner en QR-code in een popup.",
"downloadDescription": "Met deze koppeling kunt u de volledige inhoud van de gedeelde map downloaden als een gecomprimeerd bestand.",
"deleteFailed": "Share niet verwijderd"
"deleteFailed": "Share niet verwijderd",
"readOnlySourceNotice": "Deze bron is alleen-lezen. Upload shares en rechten om bestanden te wijzigen zijn uitgeschakeld."
},
"prompts": {
"operationInProgress": "Werk in uitvoering, even geduld...",
+9 -4
View File
@@ -126,8 +126,8 @@
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nlBE": "Dutch (Belgium)",
"nl": "Dutch",
"nl": "Nederlands",
"nlBE": "Nederlands (België)",
"pl": "Polski",
"pt": "Português",
"ptBR": "Português (Brasil)",
@@ -493,7 +493,8 @@
"selectBannerImage": "Wybierz obraz banera",
"selectFaviconImage": "Wybierz obraz favicon",
"selectImage": "Wybierz obraz",
"deleteFailed": "Nie udało się usunąć udziału"
"deleteFailed": "Nie udało się usunąć udziału",
"readOnlySourceNotice": "To źródło jest tylko do odczytu. Udziały przesyłania i uprawnienia modyfikujące pliki są wyłączone."
},
"api": {
"createdAt": "Utworzono w:",
@@ -677,7 +678,11 @@
"divider": "Dzielnik{suffix}",
"audio": "Audio{suffix}",
"and": "oraz",
"or": "lub"
"or": "lub",
"readOnly": "Tylko do odczytu{suffix}",
"private": "Prywatny{suffix}",
"yes": "Tak{suffix}",
"no": "Nie{suffix}"
},
"access": {
"rules": "Zasady dostępu",
+9 -4
View File
@@ -126,8 +126,8 @@
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nlBE": "Dutch (Belgium)",
"nl": "Dutch",
"nl": "Nederlands",
"nlBE": "Nederlands (België)",
"pl": "Polski",
"pt": "Português",
"ptBR": "Português (Brasil)",
@@ -493,7 +493,8 @@
"selectBannerImage": "Selecione a imagem do banner",
"selectFaviconImage": "Selecione a imagem do favicon",
"selectImage": "Selecionar imagem",
"deleteFailed": "Falha ao excluir o compartilhamento"
"deleteFailed": "Falha ao excluir o compartilhamento",
"readOnlySourceNotice": "Essa fonte é somente leitura. Os compartilhamentos de upload e as permissões que modificam arquivos estão desativados."
},
"api": {
"createdAt": "Criado em:",
@@ -677,7 +678,11 @@
"divider": "Divisor{suffix}",
"audio": "Áudio{suffix}",
"and": "e",
"or": "ou"
"or": "ou",
"readOnly": "Somente leitura{suffix}",
"private": "Privado{suffix}",
"yes": "Sim{suffix}",
"no": "Não{suffix}"
},
"access": {
"rules": "Regras de acesso",
+9 -4
View File
@@ -126,8 +126,8 @@
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nlBE": "Dutch (Belgium)",
"nl": "Dutch",
"nl": "Nederlands",
"nlBE": "Nederlands (België)",
"pl": "Polski",
"pt": "Português",
"ptBR": "Português (Brasil)",
@@ -493,7 +493,8 @@
"selectBannerImage": "Selecionar imagem de banner",
"selectFaviconImage": "Selecionar a imagem do favicon",
"selectImage": "Selecionar imagem",
"deleteFailed": "Falha ao eliminar a partilha"
"deleteFailed": "Falha ao eliminar a partilha",
"readOnlySourceNotice": "Esta fonte é apenas de leitura. As partilhas de carregamento e as permissões que modificam ficheiros estão desactivadas."
},
"api": {
"createdAt": "Criado em:",
@@ -778,7 +779,11 @@
"divider": "Divisor{suffix}",
"audio": "Áudio{suffix}",
"and": "e",
"or": "ou"
"or": "ou",
"readOnly": "Só de leitura{suffix}",
"private": "Privado{suffix}",
"yes": "Sim{suffix}",
"no": "Não{suffix}"
},
"access": {
"rules": "Regras de acesso",
+9 -4
View File
@@ -126,8 +126,8 @@
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nlBE": "Dutch (Belgium)",
"nl": "Dutch",
"nl": "Nederlands",
"nlBE": "Nederlands (België)",
"pl": "Polski",
"pt": "Português",
"ptBR": "Português (Brasil)",
@@ -493,7 +493,8 @@
"selectBannerImage": "Selectați imaginea bannerului",
"selectFaviconImage": "Selectați imaginea favicon",
"selectImage": "Selectați imaginea",
"deleteFailed": "Eșec la ștergerea partajului"
"deleteFailed": "Eșec la ștergerea partajului",
"readOnlySourceNotice": "Această sursă este numai pentru citire. Acțiunile de încărcare și permisiunile care modifică fișiere sunt dezactivate."
},
"api": {
"createdAt": "Creat la:",
@@ -677,7 +678,11 @@
"divider": "Divizor{suffix}",
"audio": "Audio{suffix}",
"and": "și",
"or": "sau"
"or": "sau",
"readOnly": "Numai citire{suffix}",
"private": "Privat{suffix}",
"yes": "Da{suffix}",
"no": "Nu{suffix}"
},
"access": {
"rules": "Reguli de acces",
+9 -4
View File
@@ -126,8 +126,8 @@
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nlBE": "Dutch (Belgium)",
"nl": "Dutch",
"nl": "Nederlands",
"nlBE": "Nederlands (België)",
"pl": "Polski",
"pt": "Português",
"ptBR": "Português (Brasil)",
@@ -493,7 +493,8 @@
"selectBannerImage": "Выберите изображение баннера",
"selectFaviconImage": "Выберите изображение фавикона",
"selectImage": "Выберите изображение",
"deleteFailed": "Не удалось удалить общий ресурс"
"deleteFailed": "Не удалось удалить общий ресурс",
"readOnlySourceNotice": "Этот источник доступен только для чтения. Общие ресурсы загрузки и разрешения, изменяющие файлы, отключены."
},
"api": {
"createdAt": "Создан:",
@@ -677,7 +678,11 @@
"divider": "Разделитель{suffix}",
"audio": "Аудио{suffix}",
"and": "и",
"or": "или"
"or": "или",
"readOnly": "Только для чтения{suffix}",
"private": "Частный{suffix}",
"yes": "Да{suffix}",
"no": "Нет{suffix}"
},
"access": {
"rules": "Правила доступа",
+9 -4
View File
@@ -126,8 +126,8 @@
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nlBE": "Dutch (Belgium)",
"nl": "Dutch",
"nl": "Nederlands",
"nlBE": "Nederlands (België)",
"pl": "Polski",
"pt": "Português",
"ptBR": "Português (Brasil)",
@@ -493,7 +493,8 @@
"selectBannerImage": "Vyberte obrázok bannera",
"selectFaviconImage": "Vyberte obrázok favicon",
"selectImage": "Vybrať obrázok",
"deleteFailed": "Nepodarilo sa odstrániť podiel"
"deleteFailed": "Nepodarilo sa odstrániť podiel",
"readOnlySourceNotice": "Tento zdroj je len na čítanie. Zdieľania nahrávania a oprávnenia, ktoré upravujú súbory, sú zakázané."
},
"api": {
"createdAt": "Vytvorené v:",
@@ -677,7 +678,11 @@
"divider": "Rozdeľovač{suffix}",
"audio": "Audio{suffix}",
"and": "a",
"or": "alebo"
"or": "alebo",
"readOnly": "Len na čítanie{suffix}",
"private": "Súkromná stránka{suffix}",
"yes": "Áno{suffix}",
"no": "Nie{suffix}"
},
"access": {
"rules": "Pravidlá prístupu",
+9 -4
View File
@@ -126,8 +126,8 @@
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nlBE": "Dutch (Belgium)",
"nl": "Dutch",
"nl": "Nederlands",
"nlBE": "Nederlands (België)",
"pl": "Polski",
"pt": "Português",
"ptBR": "Português (Brasil)",
@@ -493,7 +493,8 @@
"selectBannerImage": "Välj bannerbild",
"selectFaviconImage": "Välj favicon-bild",
"selectImage": "Välj bild",
"deleteFailed": "Misslyckades med att ta bort delning"
"deleteFailed": "Misslyckades med att ta bort delning",
"readOnlySourceNotice": "Denna källa är skrivskyddad. Uppladdningsandelar och behörigheter som ändrar filer är inaktiverade."
},
"api": {
"createdAt": "Skapad på:",
@@ -778,7 +779,11 @@
"divider": "Uppdelare{suffix}",
"audio": "Ljud{suffix}",
"and": "och",
"or": "eller"
"or": "eller",
"readOnly": "Skrivskyddad{suffix}",
"private": "Privat{suffix}",
"yes": "Ja{suffix}",
"no": "Nej{suffix}"
},
"access": {
"rules": "Regler för åtkomst",
+9 -4
View File
@@ -126,8 +126,8 @@
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nlBE": "Dutch (Belgium)",
"nl": "Dutch",
"nl": "Nederlands",
"nlBE": "Nederlands (België)",
"pl": "Polski",
"pt": "Português",
"ptBR": "Português (Brasil)",
@@ -493,7 +493,8 @@
"selectBannerImage": "Afiş resmini seçin",
"selectFaviconImage": "Favicon resmini seçin",
"selectImage": "Resim seçiniz",
"deleteFailed": "Paylaşım silinemedi"
"deleteFailed": "Paylaşım silinemedi",
"readOnlySourceNotice": "Bu kaynak salt okunurdur. Yükleme paylaşımları ve dosyaları değiştiren izinler devre dışı bırakılmıştır."
},
"api": {
"createdAt": "Oluşturuldu:",
@@ -677,7 +678,11 @@
"divider": "Bölücü{suffix}",
"audio": "Ses{suffix}",
"and": "ve",
"or": "veya"
"or": "veya",
"readOnly": "Salt okunur{suffix}",
"private": "Özel{suffix}",
"yes": "Evet{suffix}",
"no": "Hayır{suffix}"
},
"access": {
"rules": "Erişim kuralları",
+9 -4
View File
@@ -126,8 +126,8 @@
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nlBE": "Dutch (Belgium)",
"nl": "Dutch",
"nl": "Nederlands",
"nlBE": "Nederlands (België)",
"pl": "Polski",
"pt": "Português",
"ptBR": "Português (Brasil)",
@@ -493,7 +493,8 @@
"selectBannerImage": "Виберіть зображення банера",
"selectFaviconImage": "Виберіть зображення іконки",
"selectImage": "Виберіть зображення",
"deleteFailed": "Не вдалося видалити спільний ресурс"
"deleteFailed": "Не вдалося видалити спільний ресурс",
"readOnlySourceNotice": "Це джерело доступне лише для читання. Спільні ресурси для завантаження та дозволи, які змінюють файли, вимкнено."
},
"api": {
"createdAt": "Створено в Ат:",
@@ -778,7 +779,11 @@
"divider": "Перегородка{suffix}",
"audio": "Аудіо{suffix}",
"and": "і",
"or": "або"
"or": "або",
"readOnly": "Тільки для читання{suffix}",
"private": "Рядовий{suffix}",
"yes": "Так.{suffix}",
"no": "Ні.{suffix}"
},
"access": {
"rules": "Правила доступу",
+9 -4
View File
@@ -126,8 +126,8 @@
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nlBE": "Dutch (Belgium)",
"nl": "Dutch",
"nl": "Nederlands",
"nlBE": "Nederlands (België)",
"pl": "Polski",
"pt": "Português",
"ptBR": "Português (Brasil)",
@@ -493,7 +493,8 @@
"selectBannerImage": "选择横幅图片",
"selectFaviconImage": "选择图标图像",
"selectImage": "选择图像",
"deleteFailed": "删除共享失败"
"deleteFailed": "删除共享失败",
"readOnlySourceNotice": "此源为只读。上传共享和修改文件的权限被禁用。"
},
"api": {
"createdAt": "创建于",
@@ -778,7 +779,11 @@
"divider": "分割器{suffix}",
"audio": "音频{suffix}",
"and": "和",
"or": "或"
"or": "或",
"readOnly": "只读{suffix}",
"private": "私人{suffix}",
"yes": "是{suffix}",
"no": "没有{suffix}"
},
"access": {
"rules": "访问规则",
+9 -4
View File
@@ -126,8 +126,8 @@
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nlBE": "Dutch (Belgium)",
"nl": "Dutch",
"nl": "Nederlands",
"nlBE": "Nederlands (België)",
"pl": "Polski",
"pt": "Português",
"ptBR": "Português (Brasil)",
@@ -493,7 +493,8 @@
"selectBannerImage": "選擇橫幅圖片",
"selectFaviconImage": "選擇 Favicon 圖片",
"selectImage": "選擇圖片",
"deleteFailed": "刪除分享失敗"
"deleteFailed": "刪除分享失敗",
"readOnlySourceNotice": "此來源為唯讀。上傳共用和修改檔案的權限已停用。"
},
"api": {
"createdAt": "建立於:",
@@ -778,7 +779,11 @@
"divider": "分隔線{suffix}",
"audio": "音訊{suffix}",
"and": "和",
"or": "或"
"or": "或",
"readOnly": "唯讀{suffix}",
"private": "私人{suffix}",
"yes": "是{suffix}",
"no": "沒有{suffix}"
},
"access": {
"rules": "存取規則",
+1 -1
View File
@@ -130,7 +130,7 @@ async function setupSSE () {
}
// Original notification logic - only show error after multiple failures
if (state.realtimeDownCount == 2 && !isManuallyClosed) {
if (state.realtimeDownCount === 2 && !isManuallyClosed) {
notify.showErrorToast(i18n.global.t('events.connectionLost'))
}
scheduleReconnect()
+4 -4
View File
@@ -47,8 +47,8 @@ const actions = {
upload: (context, item) => {
let uploadsCount = Object.keys(context.state.uploads).length;
let isQueueEmpty = context.state.queue.length == 0;
let isUploadsEmpty = uploadsCount == 0;
let isQueueEmpty = context.state.queue.length === 0;
let isUploadsEmpty = uploadsCount === 0;
if (isQueueEmpty && isUploadsEmpty) {
window.addEventListener("beforeunload", beforeUnload);
@@ -67,8 +67,8 @@ const actions = {
let uploadsCount = Object.keys(context.state.uploads).length;
let isBellowLimit = uploadsCount < UPLOADS_LIMIT;
let isQueueEmpty = context.state.queue.length == 0;
let isUploadsEmpty = uploadsCount == 0;
let isQueueEmpty = context.state.queue.length === 0;
let isUploadsEmpty = uploadsCount === 0;
let isFinished = isQueueEmpty && isUploadsEmpty;
let canProcess = isBellowLimit && !isQueueEmpty;
+17 -2
View File
@@ -1,3 +1,4 @@
import { markRaw } from "vue";
import * as i18n from "@/i18n";
import { state } from "./state.js";
import { getters } from "./getters.js";
@@ -127,7 +128,7 @@ export const mutations = {
for (const k of Object.keys(value)) {
const source = value[k];
if (state.sources.info[k]) {
if (source.total == 0) {
if (source.total === 0) {
state.sources.hasSourceInfo = false
} else {
state.sources.hasSourceInfo = true
@@ -145,6 +146,8 @@ export const mutations = {
state.sources.info[k].fullScanDurationSeconds = source.fullScanDurationSeconds || 0;
state.sources.info[k].complexity = source.complexity || 0;
state.sources.info[k].scanners = source.scanners || [];
state.sources.info[k].readOnly = source.readOnly || false;
state.sources.info[k].private = source.private || false;
}
}
}
@@ -196,6 +199,8 @@ export const mutations = {
fullScanDurationSeconds: merge ? prev.fullScanDurationSeconds : 0,
complexity: merge ? prev.complexity : 0,
scanners: merge && prev.scanners ? [...prev.scanners] : [],
readOnly: merge ? prev.readOnly : false,
private: merge ? prev.private : false,
};
}
// Sidebar usage bar uses hasSourceInfo + per-source used/total; must survive object replace
@@ -744,9 +749,14 @@ export const mutations = {
emitStateChanged();
},
showTooltip(value) {
state.tooltip.content = value.content;
const useComponent = Boolean(value.component);
state.tooltip.content = useComponent ? "" : (value.content ?? "");
state.tooltip.component = useComponent ? markRaw(value.component) : null;
state.tooltip.componentProps = useComponent ? (value.componentProps ?? {}) : null;
state.tooltip.x = value.x;
state.tooltip.y = value.y;
state.tooltip.width = value.width ?? null;
state.tooltip.pointerEvents = value.pointerEvents ?? false;
state.tooltip.show = true;
emitStateChanged();
},
@@ -758,6 +768,11 @@ export const mutations = {
return;
}
state.tooltip.show = false;
state.tooltip.content = "";
state.tooltip.component = null;
state.tooltip.componentProps = null;
state.tooltip.width = null;
state.tooltip.pointerEvents = false;
emitStateChanged();
},
setMaxConcurrentUpload: (value) => {
+2
View File
@@ -6,6 +6,8 @@ export const state = reactive({
tooltip: {
show: false,
content: "",
component: null,
componentProps: null,
x: 0,
y: 0,
pointerEvents: false,
+3
View File
@@ -18,6 +18,7 @@ export interface ReqObject {
source?: string;
content?: string;
modified?: string;
hasPreview?: string;
subtitles?: any[];
// Directory listing properties
@@ -108,6 +109,8 @@ export interface StoreState {
tooltip: {
show: boolean;
content: string;
component: import("vue").Component | null;
componentProps: Record<string, unknown> | null;
x: number;
y: number;
pointerEvents: boolean;
+4 -4
View File
@@ -8,10 +8,10 @@ describe('testSort', () => {
{input: 1, expected:"1.0 bytes"},
{input: 1150, expected:"1.1 KB"},
{input: 5105650, expected:"4.9 MB"},
{input: 156518899684, expected:"145.8 GB"},
{input: 1020993183744, expected:"950.9 GB"},
{input: 4891498498488, expected:"4.4 TB"},
{input: 11991498498488488, expected:"10.7 PB"},
{input: Number('156518899684'), expected:"145.8 GB"},
{input: Number('1020993183744'), expected:"950.9 GB"},
{input: Number('4891498498488'), expected:"4.4 TB"},
{input: Number('11991498498488488'), expected:"10.7 PB"},
]
for (let i in tests) {
expect(getHumanReadableFilesize(tests[i].input)).toEqual(tests[i].expected);
+1 -1
View File
@@ -5,7 +5,7 @@ export function fromNow(date, locale) {
const now = new Date();
const diffInSeconds = Math.floor((now - date) / 1000);
const intervals = [
{ label: 'year', seconds: 31536000 },
{ label: 'year', seconds: 60 * 60 * 24 * 365 },
{ label: 'month', seconds: 2592000 },
{ label: 'week', seconds: 604800 },
{ label: 'day', seconds: 86400 },
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more