mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-04 16:50:08 -05:00
Compare commits
28
Commits
main
...
v1.4.2-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a997d4605 | ||
|
|
2633768d9f | ||
|
|
4bca80a156 | ||
|
|
fc929970b3 | ||
|
|
12c71d38a9 | ||
|
|
641e4c5397 | ||
|
|
587cd62da6 | ||
|
|
ea19302c25 | ||
|
|
d4dd0c70f4 | ||
|
|
1babfa03bc | ||
|
|
daa48c3c31 | ||
|
|
a9e4fc27b0 | ||
|
|
1715577d99 | ||
|
|
5eb4dc7f7c | ||
|
|
4248849bb6 | ||
|
|
c5eeb308fa | ||
|
|
12cd81340e | ||
|
|
2b532c368d | ||
|
|
28e9b81e43 | ||
|
|
6d4bffd407 | ||
|
|
5e49e1ecdd | ||
|
|
1aba10b87f | ||
|
|
15f484fba5 | ||
|
|
d4eb051294 | ||
|
|
5597b28f10 | ||
|
|
6bfc397419 | ||
|
|
112740bdd4 | ||
|
|
9117fb2b5f |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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**:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -2,7 +2,7 @@ server:
|
||||
port: 8080
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: '.*'
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
config:
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
server:
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: '.*'
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
config:
|
||||
|
||||
@@ -3,7 +3,7 @@ server:
|
||||
baseURL: "/"
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: '.*'
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ server:
|
||||
baseURL: "/subpath"
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: '.*'
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
config:
|
||||
|
||||
@@ -3,7 +3,7 @@ server:
|
||||
baseURL: "/files/"
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: '.*'
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "playwright"
|
||||
|
||||
@@ -3,7 +3,7 @@ server:
|
||||
baseURL: "/"
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: '.*'
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "playwright + files"
|
||||
|
||||
@@ -3,7 +3,7 @@ server:
|
||||
baseURL: "/"
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: '.*'
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "playwright + files"
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"), {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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, {});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"), {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": "قواعد الوصول",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Κανόνες πρόσβασης",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "כללי גישה",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "アクセスルール",
|
||||
|
||||
@@ -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": "액세스 규칙",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Правила доступа",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -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": "Правила доступу",
|
||||
|
||||
@@ -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": "访问规则",
|
||||
|
||||
@@ -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": "存取規則",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -6,6 +6,8 @@ export const state = reactive({
|
||||
tooltip: {
|
||||
show: false,
|
||||
content: "",
|
||||
component: null,
|
||||
componentProps: null,
|
||||
x: 0,
|
||||
y: 0,
|
||||
pointerEvents: false,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
Reference in new issue
Block a user